2 Commits

Author SHA1 Message Date
fb6fb67d45 Release v0.1.4 2026-07-02 14:59:52 +02:00
ab2343aa88 Release v0.1.3 2026-06-26 01:39:19 +02:00
37 changed files with 3258 additions and 1627 deletions

View File

@@ -61,6 +61,10 @@ Frontend package:
Platform RBAC and governance rules are documented in `govoplan-core/docs/`. Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
## Operations
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
## Release packaging ## Release packaging
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. Files and mail WebUI packages remain optional product-composition dependencies supplied by the core host build, not required campaign package dependencies. The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. Files and mail WebUI packages remain optional product-composition dependencies supplied by the core host build, not required campaign package dependencies.

View File

@@ -0,0 +1,77 @@
# Campaign Delivery Runbook
This runbook covers controlled campaign delivery after a campaign version has
been validated, built, reviewed, and locked.
## Operating Modes
- Local direct send: `CELERY_ENABLED=false`. Queueing stores jobs in the DB, and
small development runs can be processed with "Send queued now".
- Worker send: `CELERY_ENABLED=true` with Redis/Celery workers running. Queueing
publishes delivery tasks, and the Review & Send page polls summary counters.
- Mock send: use only for development review. It does not prove real SMTP/IMAP
credentials or server policy.
## Before First Live Use
- Use dedicated non-production SMTP/IMAP credentials.
- Use a dedicated mailbox/folder for append-to-Sent tests.
- Confirm policy allows the SMTP host, envelope sender, recipients, and optional
IMAP append target.
- Run one campaign each for no attachment, one attachment, and password-protected
ZIP before using production recipients.
- Keep the report page open during tests; it is the operational source of truth
for attempts, outcomes, and reconciliation.
## Queue And Send
1. Validate the version with file checks enabled.
2. Build the version and inspect all blocking review items.
3. Queue only after the selected version is the intended immutable execution
version.
4. In local mode, use "Send queued now" for small test runs.
5. In worker mode, verify queue counters move from queued/claimed/sending to a
terminal SMTP state.
## Outcome Handling
- `smtp_accepted`: Do not retry. If IMAP append is enabled and pending, run or
enqueue the append action.
- `failed_temporary`: Retry explicitly after checking the error and retry count.
- `failed_permanent`: Retry only if the operator has corrected the root cause and
intentionally includes permanent failures.
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
provider control panels, then reconcile as accepted or not sent.
- `claimed` or `sending` that does not progress: treat as a worker interruption.
Re-run worker handling or reconcile if SMTP may already have accepted the
message.
## Reconciliation
- Choose "Accepted" only with external evidence that SMTP accepted the message.
The job becomes protected from retry and may proceed to IMAP append.
- Choose "Not sent" only when SMTP did not accept the message. The job becomes a
temporary failure and can be selected by explicit retry.
- Add a note that identifies the evidence used, for example SMTP log line,
provider message ID, or operator ticket.
## Fault Injection Checklist
Use mock infrastructure first, then repeat against the non-production real test
bed where possible:
- SMTP temporary failure.
- SMTP permanent failure.
- Recipient refusal after partial SMTP acceptance.
- Connection drop or worker interruption during SMTP.
- IMAP append failure after SMTP acceptance.
- Worker restart with queued, claimed, and sending jobs.
## Reporting Checks
- Partial delivery must show accepted, failed, and unknown counts separately.
- Accepted and unknown jobs must not appear in retry selections.
- Reconciled accepted jobs must remain protected from resend.
- Reconciled not-sent jobs must appear only as explicit retry candidates.
- The final report should include SMTP attempts, IMAP append attempts, and any
reconciliation notes before the campaign is considered operationally closed.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.1", "version": "0.1.4",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,8 +18,11 @@
"README.md", "README.md",
"LICENSE" "LICENSE"
], ],
"dependencies": {
"read-excel-file": "^9.2.0"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-campaign" name = "govoplan-campaign"
version = "0.1.2" version = "0.1.4"
description = "GovOPlaN campaigns module with backend and WebUI integration." description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.2", "govoplan-core>=0.1.4",
"jsonschema>=4,<5", "jsonschema>=4,<5",
"pydantic>=2,<3", "pydantic>=2,<3",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",

View File

@@ -489,6 +489,35 @@ class EntryConfig(StrictModel):
last_sent: str | None = None last_sent: str | None = None
class ImportColumnProvenance(StrictModel):
column_index: int = Field(ge=0)
header: str
kind: str
field_name: str | None = None
new_field_name: str | None = None
class ImportProvenance(StrictModel):
id: str
imported_at: str
mode: Literal["append", "replace"]
source_type: Literal["csv", "xlsx", "text"]
filename: str | None = None
sheet_name: str | None = None
encoding: str | None = None
delimiter: str | None = None
header_rows: int = Field(default=0, ge=0)
quoted: bool | None = None
value_separators: str | None = None
rows_total: int = Field(default=0, ge=0)
valid_rows: int = Field(default=0, ge=0)
invalid_rows: int = Field(default=0, ge=0)
imported_rows: int = Field(default=0, ge=0)
field_names_created: list[str] = Field(default_factory=list)
attachment_patterns: int = Field(default=0, ge=0)
mapping: list[ImportColumnProvenance] = Field(default_factory=list)
class SourceConfig(StrictModel): class SourceConfig(StrictModel):
type: SourceType type: SourceType
path: str path: str
@@ -502,6 +531,7 @@ class EntriesConfig(StrictModel):
source: SourceConfig | None = None source: SourceConfig | None = None
mapping: dict[str, str] | None = None mapping: dict[str, str] | None = None
defaults: EntryConfig | None = None defaults: EntryConfig | None = None
imports: list[ImportProvenance] = Field(default_factory=list)
@model_validator(mode="after") @model_validator(mode="after")
def inline_or_external(self) -> "EntriesConfig": def inline_or_external(self) -> "EntriesConfig":

View File

@@ -144,6 +144,33 @@ class CampaignShare(Base, TimestampMixin):
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class RecipientImportMappingProfile(Base, TimestampMixin):
__tablename__ = "campaign_recipient_import_mapping_profiles"
__table_args__ = (
Index("ix_recipient_import_profiles_owner", "tenant_id", "owner_user_id"),
Index("ix_recipient_import_profiles_ordered_fp", "tenant_id", "owner_user_id", "ordered_header_fingerprint"),
Index("ix_recipient_import_profiles_unordered_fp", "tenant_id", "owner_user_id", "unordered_header_fingerprint"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
column_count: Mapped[int] = mapped_column(Integer, nullable=False)
headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
normalized_headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
ordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
unordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
delimiter: Mapped[str] = mapped_column(String(8), nullable=False)
header_rows: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
quoted: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
value_separators: Mapped[str] = mapped_column(String(50), default=",;|", nullable=False)
mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
tenant: Mapped[Tenant] = relationship()
owner: Mapped[User] = relationship()
class CampaignVersion(Base, TimestampMixin): class CampaignVersion(Base, TimestampMixin):
__tablename__ = "campaign_versions" __tablename__ = "campaign_versions"
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),) __table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)

View File

@@ -109,7 +109,7 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="campaigns", id="campaigns",
name="Campaigns", name="Campaigns",
version="1.0.0", version="0.1.4",
dependencies=("access",), dependencies=("access",),
optional_dependencies=("files", "mail"), optional_dependencies=("files", "mail"),
permissions=PERMISSIONS, permissions=PERMISSIONS,
@@ -165,4 +165,3 @@ manifest = ModuleManifest(
def get_manifest() -> ModuleManifest: def get_manifest() -> ModuleManifest:
return manifest return manifest

View File

@@ -0,0 +1,65 @@
"""recipient import mapping profiles
Revision ID: 2c3d4e5f7081
Revises: 1b2c3d4e5f70
Create Date: 2026-06-26 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "2c3d4e5f7081"
down_revision = "1b2c3d4e5f70"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "campaign_recipient_import_mapping_profiles" in inspector.get_table_names():
return
op.create_table(
"campaign_recipient_import_mapping_profiles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("owner_user_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("column_count", sa.Integer(), nullable=False),
sa.Column("headers", sa.JSON(), nullable=False),
sa.Column("normalized_headers", sa.JSON(), nullable=False),
sa.Column("ordered_header_fingerprint", sa.String(length=64), nullable=False),
sa.Column("unordered_header_fingerprint", sa.String(length=64), nullable=False),
sa.Column("delimiter", sa.String(length=8), nullable=False),
sa.Column("header_rows", sa.Integer(), nullable=False),
sa.Column("quoted", sa.Boolean(), nullable=False),
sa.Column("value_separators", sa.String(length=50), nullable=False),
sa.Column("mappings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_owner_user_id_users"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_tenants"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_campaign_recipient_import_mapping_profiles")),
)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), "campaign_recipient_import_mapping_profiles", ["tenant_id"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_owner_user_id"), "campaign_recipient_import_mapping_profiles", ["owner_user_id"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint"), "campaign_recipient_import_mapping_profiles", ["ordered_header_fingerprint"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint"), "campaign_recipient_import_mapping_profiles", ["unordered_header_fingerprint"], unique=False)
op.create_index("ix_recipient_import_profiles_owner", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id"], unique=False)
op.create_index("ix_recipient_import_profiles_ordered_fp", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id", "ordered_header_fingerprint"], unique=False)
op.create_index("ix_recipient_import_profiles_unordered_fp", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id", "unordered_header_fingerprint"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "campaign_recipient_import_mapping_profiles" not in inspector.get_table_names():
return
op.drop_index("ix_recipient_import_profiles_unordered_fp", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index("ix_recipient_import_profiles_ordered_fp", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index("ix_recipient_import_profiles_owner", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_owner_user_id"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_table("campaign_recipient_import_mapping_profiles")

View File

@@ -19,6 +19,9 @@ from govoplan_campaign.backend.schemas import (
CampaignShareTargetsResponse, CampaignShareTargetsResponse,
CampaignShareUpsertRequest, CampaignShareUpsertRequest,
CampaignOwnerUpdateRequest, CampaignOwnerUpdateRequest,
RecipientImportMappingProfileListResponse,
RecipientImportMappingProfilePayload,
RecipientImportMappingProfileResponse,
CampaignJobsResponse, CampaignJobsResponse,
CampaignJobDetailResponse, CampaignJobDetailResponse,
CampaignRetryJobsRequest, CampaignRetryJobsRequest,
@@ -40,7 +43,7 @@ from govoplan_campaign.backend.schemas import (
) )
from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal from govoplan_core.audit.logging import audit_from_principal
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, Group, ImapAppendAttempt, SendAttempt, User, UserGroupMembership from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, Group, ImapAppendAttempt, RecipientImportMappingProfile, SendAttempt, User, UserGroupMembership
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
@@ -181,6 +184,27 @@ def _require_permission(principal: ApiPrincipal, scope: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _get_recipient_import_profile_for_principal(session: Session, profile_id: str, principal: ApiPrincipal) -> RecipientImportMappingProfile:
profile = session.get(RecipientImportMappingProfile, profile_id)
if not profile or profile.tenant_id != principal.tenant_id or profile.owner_user_id != principal.user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipient import mapping profile not found")
return profile
def _apply_recipient_import_profile_payload(profile: RecipientImportMappingProfile, payload: RecipientImportMappingProfilePayload) -> None:
profile.name = payload.name.strip()
profile.column_count = payload.column_count
profile.headers = list(payload.headers)
profile.normalized_headers = list(payload.normalized_headers)
profile.ordered_header_fingerprint = payload.ordered_header_fingerprint
profile.unordered_header_fingerprint = payload.unordered_header_fingerprint
profile.delimiter = payload.delimiter
profile.header_rows = payload.header_rows
profile.quoted = payload.quoted
profile.value_separators = payload.value_separators
profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings]
def _recipient_sections_changed(current: dict[str, object] | None, proposed: dict[str, object] | None) -> bool: def _recipient_sections_changed(current: dict[str, object] | None, proposed: dict[str, object] | None) -> bool:
if proposed is None: if proposed is None:
return False return False
@@ -346,6 +370,109 @@ def list_campaigns(
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns]) return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns])
@router.get("/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse)
def list_recipient_import_mapping_profiles(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profiles = (
session.query(RecipientImportMappingProfile)
.filter(
RecipientImportMappingProfile.tenant_id == principal.tenant_id,
RecipientImportMappingProfile.owner_user_id == principal.user.id,
)
.order_by(RecipientImportMappingProfile.updated_at.desc(), RecipientImportMappingProfile.name.asc())
.all()
)
return RecipientImportMappingProfileListResponse(
profiles=[RecipientImportMappingProfileResponse.model_validate(profile) for profile in profiles]
)
@router.post(
"/recipient-import/mapping-profiles",
response_model=RecipientImportMappingProfileResponse,
status_code=status.HTTP_201_CREATED,
)
def create_recipient_import_mapping_profile(
payload: RecipientImportMappingProfilePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = RecipientImportMappingProfile(
tenant_id=principal.tenant_id,
owner_user_id=principal.user.id,
name=payload.name.strip(),
column_count=payload.column_count,
headers=list(payload.headers),
normalized_headers=list(payload.normalized_headers),
ordered_header_fingerprint=payload.ordered_header_fingerprint,
unordered_header_fingerprint=payload.unordered_header_fingerprint,
delimiter=payload.delimiter,
header_rows=payload.header_rows,
quoted=payload.quoted,
value_separators=payload.value_separators,
mappings=[mapping.model_dump(mode="json") for mapping in payload.mappings],
)
session.add(profile)
session.flush()
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_created",
object_type="recipient_import_mapping_profile",
object_id=profile.id,
details={"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint},
commit=True,
)
session.refresh(profile)
return RecipientImportMappingProfileResponse.model_validate(profile)
@router.put("/recipient-import/mapping-profiles/{profile_id}", response_model=RecipientImportMappingProfileResponse)
def update_recipient_import_mapping_profile(
profile_id: str,
payload: RecipientImportMappingProfilePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = _get_recipient_import_profile_for_principal(session, profile_id, principal)
_apply_recipient_import_profile_payload(profile, payload)
session.add(profile)
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_updated",
object_type="recipient_import_mapping_profile",
object_id=profile.id,
details={"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint},
commit=True,
)
session.refresh(profile)
return RecipientImportMappingProfileResponse.model_validate(profile)
@router.delete("/recipient-import/mapping-profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_recipient_import_mapping_profile(
profile_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = _get_recipient_import_profile_for_principal(session, profile_id, principal)
details = {"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint}
session.delete(profile)
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_deleted",
object_type="recipient_import_mapping_profile",
object_id=profile_id,
details=details,
commit=True,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{campaign_id}", response_model=CampaignResponse) @router.get("/{campaign_id}", response_model=CampaignResponse)
def get_campaign( def get_campaign(
campaign_id: str, campaign_id: str,
@@ -2008,4 +2135,3 @@ def preview_campaign_attachments(
rules=rules, rules=rules,
unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [], unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [],
) )

View File

@@ -482,6 +482,13 @@
}, },
"defaults": { "defaults": {
"$ref": "#/$defs/entry" "$ref": "#/$defs/entry"
},
"imports": {
"type": "array",
"items": {
"$ref": "#/$defs/import_provenance"
},
"default": []
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -505,6 +512,13 @@
}, },
"defaults": { "defaults": {
"$ref": "#/$defs/entry" "$ref": "#/$defs/entry"
},
"imports": {
"type": "array",
"items": {
"$ref": "#/$defs/import_provenance"
},
"default": []
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -1027,6 +1041,151 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"import_provenance": {
"type": "object",
"required": [
"id",
"imported_at",
"mode",
"source_type",
"header_rows",
"rows_total",
"valid_rows",
"invalid_rows",
"imported_rows"
],
"properties": {
"id": {
"type": "string"
},
"imported_at": {
"type": "string",
"format": "date-time"
},
"mode": {
"type": "string",
"enum": [
"append",
"replace"
]
},
"source_type": {
"type": "string",
"enum": [
"csv",
"xlsx",
"text"
]
},
"filename": {
"type": [
"string",
"null"
]
},
"sheet_name": {
"type": [
"string",
"null"
]
},
"encoding": {
"type": [
"string",
"null"
]
},
"delimiter": {
"type": [
"string",
"null"
]
},
"header_rows": {
"type": "integer",
"minimum": 0,
"default": 0
},
"quoted": {
"type": [
"boolean",
"null"
]
},
"value_separators": {
"type": [
"string",
"null"
]
},
"rows_total": {
"type": "integer",
"minimum": 0
},
"valid_rows": {
"type": "integer",
"minimum": 0
},
"invalid_rows": {
"type": "integer",
"minimum": 0
},
"imported_rows": {
"type": "integer",
"minimum": 0
},
"field_names_created": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"attachment_patterns": {
"type": "integer",
"minimum": 0,
"default": 0
},
"mapping": {
"type": "array",
"items": {
"type": "object",
"required": [
"column_index",
"header",
"kind"
],
"properties": {
"column_index": {
"type": "integer",
"minimum": 0
},
"header": {
"type": "string"
},
"kind": {
"type": "string"
},
"field_name": {
"type": [
"string",
"null"
]
},
"new_field_name": {
"type": [
"string",
"null"
]
}
},
"additionalProperties": false
},
"default": []
}
},
"additionalProperties": false
},
"attachment_base_path": { "attachment_base_path": {
"type": "object", "type": "object",
"required": [ "required": [

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.mail.config import ImapConfig, SmtpConfig from govoplan_core.mail.config import ImapConfig, SmtpConfig
@@ -186,6 +186,70 @@ class CampaignOwnerUpdateRequest(BaseModel):
owner_group_id: str | None = None owner_group_id: str | None = None
RecipientImportColumnKind = Literal[
"ignore",
"id",
"active",
"name",
"from",
"to",
"cc",
"bcc",
"reply_to",
"field",
"new_field",
"attachment_pattern",
]
class RecipientImportColumnMappingPayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
column_index: int = Field(ge=0, alias="columnIndex")
kind: RecipientImportColumnKind
field_name: str | None = Field(default=None, max_length=255, alias="fieldName")
new_field_name: str | None = Field(default=None, max_length=255, alias="newFieldName")
class RecipientImportMappingProfilePayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
name: str = Field(min_length=1, max_length=255)
column_count: int = Field(ge=0, le=500, alias="columnCount")
headers: list[str] = Field(default_factory=list, max_length=500)
normalized_headers: list[str] = Field(default_factory=list, max_length=500, alias="normalizedHeaders")
ordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="orderedHeaderFingerprint")
unordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="unorderedHeaderFingerprint")
delimiter: Literal[",", ";", "\t"]
header_rows: int = Field(ge=0, le=10, alias="headerRows")
quoted: bool = True
value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators")
mappings: list[RecipientImportColumnMappingPayload] = Field(default_factory=list, max_length=500)
@model_validator(mode="after")
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
if len(self.headers) != self.column_count:
raise ValueError("headers length must match columnCount")
if len(self.normalized_headers) != self.column_count:
raise ValueError("normalizedHeaders length must match columnCount")
for mapping in self.mappings:
if mapping.column_index >= self.column_count:
raise ValueError("mapping columnIndex exceeds columnCount")
return self
class RecipientImportMappingProfileResponse(RecipientImportMappingProfilePayload):
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
id: str
created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt")
class RecipientImportMappingProfileListResponse(BaseModel):
profiles: list[RecipientImportMappingProfileResponse] = Field(default_factory=list)
class CampaignJobsResponse(BaseModel): class CampaignJobsResponse(BaseModel):
jobs: list[dict[str, Any]] jobs: list[dict[str, Any]]
page: int = 1 page: int = 1

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.1", "version": "0.1.4",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,8 +13,11 @@
}, },
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css" "./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
}, },
"dependencies": {
"read-excel-file": "^9.2.0"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -22,7 +25,8 @@
}, },
"scripts": { "scripts": {
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js", "test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js" "test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"

View File

@@ -92,6 +92,38 @@ export type CampaignWorkspaceQuery = {
includeVersions?: boolean; includeVersions?: boolean;
}; };
export type RecipientImportColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
export type RecipientImportColumnMappingProfileItem = {
columnIndex: number;
kind: RecipientImportColumnKind;
fieldName?: string | null;
newFieldName?: string | null;
};
export type RecipientImportMappingProfile = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
columnCount: number;
headers: string[];
normalizedHeaders: string[];
orderedHeaderFingerprint: string;
unorderedHeaderFingerprint: string;
delimiter: "," | ";" | "\t";
headerRows: number;
quoted: boolean;
valueSeparators: string;
mappings: RecipientImportColumnMappingProfileItem[];
};
export type RecipientImportMappingProfilePayload = Omit<RecipientImportMappingProfile, "id" | "createdAt" | "updatedAt">;
export type RecipientImportMappingProfileListResponse = {
profiles: RecipientImportMappingProfile[];
};
export type CampaignVersionUpdatePayload = { export type CampaignVersionUpdatePayload = {
campaign_json?: Record<string, unknown> | null; campaign_json?: Record<string, unknown> | null;
current_flow?: string | null; current_flow?: string | null;
@@ -298,6 +330,36 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
return response.campaigns ?? response.items ?? response.results ?? []; return response.campaigns ?? response.items ?? response.results ?? [];
} }
export async function listRecipientImportMappingProfiles(settings: ApiSettings): Promise<RecipientImportMappingProfile[]> {
const response = await apiFetch<RecipientImportMappingProfileListResponse>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles");
return response.profiles ?? [];
}
export async function createRecipientImportMappingProfile(
settings: ApiSettings,
payload: RecipientImportMappingProfilePayload
): Promise<RecipientImportMappingProfile> {
return apiFetch<RecipientImportMappingProfile>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function updateRecipientImportMappingProfile(
settings: ApiSettings,
profileId: string,
payload: RecipientImportMappingProfilePayload
): Promise<RecipientImportMappingProfile> {
return apiFetch<RecipientImportMappingProfile>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export async function deleteRecipientImportMappingProfile(settings: ApiSettings, profileId: string): Promise<void> {
await apiFetch<void>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
}
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> { export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`); return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
} }

View File

@@ -1,91 +0,0 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type FileSpace = {
id: string;
label: string;
owner_type: "user" | "group";
owner_id: string;
description?: string | null;
};
export type FileShare = {
id: string;
target_type: string;
target_id: string;
permission: string;
created_at: string;
revoked_at?: string | null;
};
export type ManagedFile = {
id: string;
tenant_id: string;
owner_type: "user" | "group";
owner_id: string;
display_path: string;
filename: string;
description?: string | null;
size_bytes: number;
content_type?: string | null;
checksum_sha256: string;
version_id: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
audit_relevant: boolean;
metadata?: Record<string, unknown> | null;
shares?: FileShare[];
};
export type FileListResponse = { files: ManagedFile[] };
export type FileSpacesResponse = { spaces: FileSpace[] };
export type FileFolder = {
id: string;
tenant_id: string;
owner_type: "user" | "group";
owner_id: string;
path: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
};
export type FileFoldersResponse = { folders: FileFolder[] };
export type PatternResolveResponse = {
patterns: { pattern: string; matches: ManagedFile[] }[];
unmatched: ManagedFile[];
};
export function listFileSpaces(settings: ApiSettings): Promise<FileSpacesResponse> {
return apiFetch<FileSpacesResponse>(settings, "/api/v1/files/spaces");
}
export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise<FileFoldersResponse> {
const search = new URLSearchParams();
search.set("owner_type", params.owner_type);
search.set("owner_id", params.owner_id);
return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`);
}
export function listFiles(settings: ApiSettings, params: { owner_type?: string; owner_id?: string; campaign_id?: string; path_prefix?: string } = {}): Promise<FileListResponse> {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value) search.set(key, value);
}
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
}
export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
return apiFetch<FileShare>(settings, `/api/v1/files/${fileId}/shares`, {
method: "POST",
body: JSON.stringify({ target_type: "campaign", target_id: campaignId, permission: "read" })
});
}
export function resolveFilePatterns(
settings: ApiSettings,
payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
): Promise<PatternResolveResponse> {
return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) });
}

View File

@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react"; import { Pencil } from "lucide-react";
import { usePlatformModuleInstalled } from "@govoplan/core-webui"; import { usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { listFileSpaces, type FileSpace } from "../../api/files";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
@@ -18,21 +17,24 @@ import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { updateNested } from "./utils/draftEditor"; import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay"; import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
import ManagedFileChooser from "./components/ManagedFileChooser";
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog"; import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments"; import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui"; import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions"; import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders"; import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
type PathChooserState = { index: number }; type PathChooserState = { index: number };
type IndividualDisableState = { index: number; usageCount: number }; type IndividualDisableState = { index: number; usageCount: number };
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const filesModuleInstalled = usePlatformModuleInstalled("files"); const filesModuleInstalled = usePlatformModuleInstalled("files");
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
const managedFilesAvailable = Boolean(ManagedFileChooser);
const listManagedFileSpaces = filesModuleInstalled ? filesFileExplorer?.listFileSpaces : undefined;
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null); const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]); const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null); const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null); const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
const version = data.currentVersion; const version = data.currentVersion;
@@ -61,18 +63,26 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message; const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]); const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]); const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
const attachmentPreviewEntry = useMemo(
() => asArray(asRecord(displayDraft.entries).inline).map(asRecord).find((entry) => entry.active !== false) ?? {},
[displayDraft.entries]
);
const attachmentPreviewContext = useMemo(
() => buildTemplatePreviewContext(displayDraft, attachmentPreviewEntry),
[attachmentPreviewEntry, displayDraft]
);
useEffect(() => { useEffect(() => {
if (!filesModuleInstalled) { if (!listManagedFileSpaces) {
setFileSpaces([]); setFileSpaces([]);
return; return;
} }
let cancelled = false; let cancelled = false;
void listFileSpaces(settings) void listManagedFileSpaces(settings)
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); }) .then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
.catch(() => { if (!cancelled) setFileSpaces([]); }); .catch(() => { if (!cancelled) setFileSpaces([]); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [filesModuleInstalled, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); }, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchBasePaths(paths: AttachmentBasePath[]) { function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return; if (locked) return;
@@ -243,7 +253,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<DataGrid <DataGrid
id={`campaign-${campaignId}-attachment-sources`} id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths} rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })} columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id} getRowKey={(basePath) => basePath.id}
emptyText="No attachment sources configured." emptyText="No attachment sources configured."
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />} emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
@@ -302,7 +312,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
settings={settings} settings={settings}
campaignId={campaignId} campaignId={campaignId}
zipConfig={zipConfig} zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled} filesModuleInstalled={managedFilesAvailable}
previewContext={attachmentPreviewContext}
onChange={(rules) => patch(["attachments", "global"], rules)} onChange={(rules) => patch(["attachments", "global"], rules)}
/> />
</Card> </Card>
@@ -312,9 +323,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div> <div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div> <div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div> <div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
<div><dt>Upload support</dt><dd>{filesModuleInstalled ? "Connected through Files" : "Manual paths"}</dd></div> <div><dt>Upload support</dt><dd>{managedFilesAvailable ? "Connected through Files" : "Manual paths"}</dd></div>
</dl> </dl>
<p className="muted small-note">{filesModuleInstalled ? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build." : "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}</p> <p className="muted small-note">{managedFilesAvailable
? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build."
: filesModuleInstalled
? "Managed file browsing is unavailable. Use manual paths and patterns until the Files module exposes the file explorer capability."
: "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}</p>
</Card> </Card>
</> </>
@@ -348,11 +363,11 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)} canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
/> />
{pathChooser && filesModuleInstalled && ( {pathChooser && ManagedFileChooser && (
<ManagedFileChooser <ManagedFileChooser
open open
settings={settings} settings={settings}
campaignId={campaignId} storageScope={campaignId}
mode="folder" mode="folder"
source={basePaths[pathChooser.index]?.source} source={basePaths[pathChooser.index]?.source}
basePath={basePaths[pathChooser.index]?.path} basePath={basePaths[pathChooser.index]?.path}
@@ -553,8 +568,8 @@ function uniqueStrings(values: string[]): string[] {
type AttachmentSourceColumnContext = { type AttachmentSourceColumnContext = {
locked: boolean; locked: boolean;
basePaths: AttachmentBasePath[]; basePaths: AttachmentBasePath[];
fileSpaces: FileSpace[]; fileSpaces: FilesFileSpace[];
filesModuleInstalled: boolean; managedFilesAvailable: boolean;
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void; patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
setIndividualEligibility: (index: number, checked: boolean) => void; setIndividualEligibility: (index: number, checked: boolean) => void;
addBasePath: (afterIndex?: number) => void; addBasePath: (afterIndex?: number) => void;
@@ -563,7 +578,7 @@ type AttachmentSourceColumnContext = {
setPathChooser: (state: PathChooserState | null) => void; setPathChooser: (state: PathChooserState | null) => void;
}; };
function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] { function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
return [ return [
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name }, { id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
{ {
@@ -577,23 +592,23 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleIns
<div className="field-with-action split-field-action"> <div className="field-with-action split-field-action">
<input <input
className="chooser-display-input" className="chooser-display-input"
value={filesModuleInstalled ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path} value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
disabled={locked} disabled={locked}
readOnly={filesModuleInstalled} readOnly={managedFilesAvailable}
tabIndex={filesModuleInstalled ? -1 : undefined} tabIndex={managedFilesAvailable ? -1 : undefined}
placeholder="attachments" placeholder="attachments"
onChange={(event) => { onChange={(event) => {
if (!filesModuleInstalled) patchBasePath(index, { path: event.target.value, source: "" }); if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
}} }}
onClick={() => filesModuleInstalled && !locked && setPathChooser({ index })} onClick={() => managedFilesAvailable && !locked && setPathChooser({ index })}
onKeyDown={(event) => { onKeyDown={(event) => {
if (filesModuleInstalled && !locked && (event.key === "Enter" || event.key === " ")) { if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) {
event.preventDefault(); event.preventDefault();
setPathChooser({ index }); setPathChooser({ index });
} }
}} }}
/> />
{filesModuleInstalled && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>} {managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
</div> </div>
), ),
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces) value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
@@ -622,7 +637,7 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleIns
} }
]; ];
} }
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string { function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FilesFileSpace[]): string {
const parsedSource = parseManagedAttachmentSource(basePath.source); const parsedSource = parseManagedAttachmentSource(basePath.source);
const matchingSpace = parsedSource const matchingSpace = parsedSource
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId) ? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)

View File

@@ -26,7 +26,7 @@ import {
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool, getNumber, getText } from "./utils/draftEditor"; import { cloneJson, getBool, getNumber, getText } from "./utils/draftEditor";
import { campaignMailSettingsPolicyState } from "./policyUi"; import { campaignMailSettingsPolicyState } from "./policyUi";
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[]; const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
@@ -70,7 +70,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
unsavedTitle: isPolicyView ? "Unsaved mail policy changes" : "Unsaved mail settings", unsavedTitle: isPolicyView ? "Unsaved mail policy changes" : "Unsaved mail settings",
unsavedMessage: isPolicyView unsavedMessage: isPolicyView
? "Mail policy changes have unsaved draft changes. Save them before leaving, or discard them and continue." ? "Mail policy changes have unsaved draft changes. Save them before leaving, or discard them and continue."
: "Mail settings have unsaved changes. Save them before leaving, or discard them and continue." : "Mail settings have unsaved changes. Save them before leaving, or discard them and continue.",
transformDraftBeforeSave: normalizeMailSettingsBeforeSave
}); });
const server = asRecord(displayDraft.server); const server = asRecord(displayDraft.server);
const smtp = asRecord(server.smtp); const smtp = asRecord(server.smtp);
@@ -165,6 +166,37 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
} }
} }
function normalizeMailSettingsBeforeSave(value: Record<string, unknown>): Record<string, unknown> {
if (mailModuleInstalled === false) return value;
const next = cloneJson(value);
const nextServer = { ...asRecord(next.server) };
const profileId = getText(nextServer, "mail_profile_id");
if (profileId.length === 0) return next;
const nextCredentials = { ...asRecord(nextServer.credentials) };
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "smtp", effectiveMailPolicy?.smtp_credentials?.inherit === false ? false : true);
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "imap", effectiveMailPolicy?.imap_credentials?.inherit === false ? false : true);
nextServer.credentials = nextCredentials;
next.server = nextServer;
return next;
}
function normalizeProfileCredentialProtocol(
serverValue: Record<string, unknown>,
credentialsValue: Record<string, unknown>,
protocol: "smtp" | "imap",
inherit: boolean
) {
serverValue["inherit_" + protocol + "_credentials"] = inherit;
if (inherit === false) return;
const transport = { ...asRecord(serverValue[protocol]) };
delete transport.username;
delete transport.password;
serverValue[protocol] = transport;
credentialsValue[protocol] = {};
}
function selectMailProfile(profileId: string) { function selectMailProfile(profileId: string) {
if (!mailModuleInstalled || locked) return; if (!mailModuleInstalled || locked) return;
if (!profileId && !campaignProfilesAllowed) { if (!profileId && !campaignProfilesAllowed) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { usePlatformModuleInstalled } from "@govoplan/core-webui"; import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
@@ -14,7 +14,10 @@ import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import FieldValueInput from "./components/FieldValueInput"; import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay"; import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import { getDraftFields } from "./utils/fieldDefinitions"; import { getDraftFields } from "./utils/fieldDefinitions";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments"; import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { importedRowsNeedAttachmentSource, materializeRecipientImport, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
import { RecipientImportDialog } from "./RecipientDataPage";
import { addressesFromValue } from "@govoplan/core-webui"; import { addressesFromValue } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
@@ -23,10 +26,13 @@ import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const filesModuleInstalled = usePlatformModuleInstalled("files"); const filesModuleInstalled = usePlatformModuleInstalled("files");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false);
const [recipientDataPage, setRecipientDataPage] = useState(1);
const [recipientDataPageSize, setRecipientDataPageSize] = useState(10);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({ const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
version, version,
@@ -44,6 +50,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
const attachmentSection = asRecord(displayDraft.attachments); const attachmentSection = asRecord(displayDraft.attachments);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]); const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]); const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection, true), [attachmentSection]);
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]); const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]);
@@ -70,6 +78,38 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
updateEntry(index, (entry) => ({ ...entry, attachments })); updateEntry(index, (entry) => ({ ...entry, attachments }));
} }
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
if (locked || !draft) return;
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: AttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
const nextDraft = needsAttachmentSource
? {
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
}
: importedDraft;
setDraft(nextDraft);
markDirty();
setImportOpen(false);
}
return ( return (
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
@@ -90,7 +130,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<> <>
<Card title="Recipient field values and attachments"> <Card title="Recipient field values and attachments" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>Import</Button>}>
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.</p>} {inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.</p>}
{inlineEntries.length === 0 && Boolean(source.type) && ( {inlineEntries.length === 0 && Boolean(source.type) && (
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</DismissibleAlert> <DismissibleAlert tone="info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</DismissibleAlert>
@@ -99,17 +139,39 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
<div className="admin-table-surface recipient-data-table-surface"> <div className="admin-table-surface recipient-data-table-surface">
<DataGrid <DataGrid
id={`campaign-${campaignId}-recipient-data`} id={`campaign-${campaignId}-recipient-data`}
rows={inlineEntries.slice(0, 100)} rows={inlineEntries}
columns={recipientDataColumns({ settings, campaignId, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })} columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
getRowKey={(entry, index) => String(entry.id || index)} getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient data found." emptyText="No recipient data found."
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table" className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
pagination={{
page: recipientDataPage,
pageSize: recipientDataPageSize,
pageSizeOptions: [10, 25, 50, 100, 250],
onPageChange: setRecipientDataPage,
onPageSizeChange: (pageSize) => {
setRecipientDataPageSize(pageSize);
setRecipientDataPage(1);
}
}}
/> />
</div> </div>
)} )}
</Card> </Card>
</> </>
</LoadingFrame> </LoadingFrame>
{importOpen && (
<RecipientImportDialog
settings={settings}
campaignId={campaignId}
existingEntries={inlineEntries}
existingFields={fieldDefinitions}
defaultAttachmentBasePath={defaultImportBasePath}
onCancel={() => setImportOpen(false)}
onImport={applyRecipientImport}
/>
)}
</div> </div>
); );
} }
@@ -117,6 +179,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
type RecipientDataColumnContext = { type RecipientDataColumnContext = {
settings: ApiSettings; settings: ApiSettings;
campaignId: string; campaignId: string;
draft: Record<string, unknown>;
locked: boolean; locked: boolean;
filesModuleInstalled: boolean; filesModuleInstalled: boolean;
fieldDefinitions: ReturnType<typeof getDraftFields>; fieldDefinitions: ReturnType<typeof getDraftFields>;
@@ -126,7 +189,7 @@ type RecipientDataColumnContext = {
updateEntryField: (index: number, field: string, value: unknown) => void; updateEntryField: (index: number, field: string, value: unknown) => void;
}; };
function recipientDataColumns({ settings, campaignId, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] { function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [ return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 }, { id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
{ {
@@ -163,6 +226,7 @@ function recipientDataColumns({ settings, campaignId, locked, filesModuleInstall
basePaths={individualAttachmentBasePaths} basePaths={individualAttachmentBasePaths}
zipConfig={zipConfig} zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled} filesModuleInstalled={filesModuleInstalled}
previewContext={buildTemplatePreviewContext(draft, entry)}
onChange={(rules) => updateEntryAttachments(index, rules)} onChange={(rules) => updateEntryAttachments(index, rules)}
/> />
); );

View File

@@ -30,6 +30,7 @@ import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui"; import { Button, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
@@ -1351,14 +1352,15 @@ function DeliveryJobDetailOverlay({ detail, onClose }: { detail: Record<string,
const smtpAttempts = asArray(attempts.smtp).map(asRecord); const smtpAttempts = asArray(attempts.smtp).map(asRecord);
const imapAttempts = asArray(attempts.imap).map(asRecord); const imapAttempts = asArray(attempts.imap).map(asRecord);
const issues = asArray(job.issues).map(asRecord); const issues = asArray(job.issues).map(asRecord);
return ( return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="delivery-job-detail-title"> <Dialog
<div className="modal-panel template-preview-modal message-preview-modal"> open
<header className="modal-header"> title="Delivery job details"
<h2 id="delivery-job-detail-title">Delivery job details</h2> className="template-preview-modal"
<button className="modal-close" onClick={onClose}>×</button> onClose={onClose}
</header> footer={<Button variant="primary" onClick={onClose}>Close</Button>}
<div className="modal-body"> >
<dl className="detail-list"> <dl className="detail-list">
<div><dt>Recipient</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div> <div><dt>Recipient</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
<div><dt>Subject</dt><dd>{String(job.subject ?? "-")}</dd></div> <div><dt>Subject</dt><dd>{String(job.subject ?? "-")}</dd></div>
@@ -1369,10 +1371,7 @@ function DeliveryJobDetailOverlay({ detail, onClose }: { detail: Record<string,
{issues.length > 0 && <AttemptList title="Message issues" rows={issues} />} {issues.length > 0 && <AttemptList title="Message issues" rows={issues} />}
<AttemptList title="SMTP attempts" rows={smtpAttempts} emptyText="No SMTP attempts were recorded." /> <AttemptList title="SMTP attempts" rows={smtpAttempts} emptyText="No SMTP attempts were recorded." />
<AttemptList title="IMAP attempts" rows={imapAttempts} emptyText="No IMAP append attempts were recorded. If status is pending, append has not run yet." /> <AttemptList title="IMAP attempts" rows={imapAttempts} emptyText="No IMAP append attempts were recorded. If status is pending, append has not run yet." />
</div> </Dialog>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
); );
} }

View File

@@ -17,7 +17,7 @@ import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils
import { cloneJson, getBool, getText } from "./utils/draftEditor"; import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions"; import { humanizeFieldName } from "./utils/fieldDefinitions";
import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft"; import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders"; import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, replacePlaceholderInText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
type TemplateBodyMode = "text" | "html" | "both"; type TemplateBodyMode = "text" | "html" | "both";
type BodyEditorMode = "text" | "html"; type BodyEditorMode = "text" | "html";
@@ -207,6 +207,22 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
setUndefinedDialog(null); setUndefinedDialog(null);
} }
function replacePlaceholder(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) {
if (locked) return;
const replacement = `{{${namespace}:${name}}}`;
setDraft((current) => {
const next = cloneJson(current ?? {});
const nextTemplate = { ...asRecord(next.template) };
nextTemplate.subject = replacePlaceholderInText(getText(nextTemplate, "subject"), field.raw, replacement);
nextTemplate.text = replacePlaceholderInText(getText(nextTemplate, "text"), field.raw, replacement);
nextTemplate.html = replacePlaceholderInText(getText(nextTemplate, "html"), field.raw, replacement);
next.template = nextTemplate;
return next;
});
markDirty();
setUndefinedDialog(null);
}
return ( return (
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
@@ -349,7 +365,10 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
removeLabel="Remove from template" removeLabel="Remove from template"
onCancel={() => setUndefinedDialog(null)} onCancel={() => setUndefinedDialog(null)}
onRemove={removePlaceholder} onRemove={removePlaceholder}
onReplace={replacePlaceholder}
onAddField={addUndefinedField} onAddField={addUndefinedField}
localFields={localFieldOptions}
globalFields={globalFieldOptions}
/> />
</div> </div>
); );

View File

@@ -2,13 +2,15 @@ import { useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types"; import type { ApiSettings } from "../../../types";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid"; import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
import { ToggleSwitch } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
import { getBool, getText } from "../utils/draftEditor"; import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments"; import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui"; import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
import { asRecord } from "../utils/campaignView"; import { asRecord } from "../utils/campaignView";
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments"; export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
@@ -23,6 +25,7 @@ type AttachmentRulesOverlayProps = {
basePaths?: AttachmentBasePath[]; basePaths?: AttachmentBasePath[];
zipConfig?: AttachmentZipCollection; zipConfig?: AttachmentZipCollection;
filesModuleInstalled?: boolean; filesModuleInstalled?: boolean;
previewContext?: Record<string, string>;
onChange: (rules: AttachmentRule[]) => void; onChange: (rules: AttachmentRule[]) => void;
}; };
@@ -37,6 +40,7 @@ type AttachmentRulesTableProps = {
activeChooserRuleIndex?: number | null; activeChooserRuleIndex?: number | null;
zipConfig?: AttachmentZipCollection; zipConfig?: AttachmentZipCollection;
filesModuleInstalled?: boolean; filesModuleInstalled?: boolean;
previewContext?: Record<string, string>;
onOpenFileChooser?: (ruleIndex: number) => void; onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void; onChange: (rules: AttachmentRule[]) => void;
}; };
@@ -57,6 +61,7 @@ export default function AttachmentRulesOverlay({
basePaths = [], basePaths = [],
zipConfig = { enabled: false, archives: [] }, zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false, filesModuleInstalled = false,
previewContext,
onChange onChange
}: AttachmentRulesOverlayProps) { }: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -80,13 +85,19 @@ export default function AttachmentRulesOverlay({
} }
const dialog = open ? createPortal( const dialog = open ? createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title"> <Dialog
<div className="modal-panel attachment-rules-modal"> open
<header className="modal-header"> title={title}
<h2 id="attachment-rules-title">{title}</h2> className="attachment-rules-modal"
<button className="modal-close" aria-label="Cancel attachment changes" onClick={cancelOverlay}>×</button> bodyClassName="attachment-rules-body"
</header> onClose={cancelOverlay}
<div className="modal-body attachment-rules-body"> footer={(
<>
<Button onClick={cancelOverlay}>Cancel</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
</>
)}
>
<AttachmentRulesTable <AttachmentRulesTable
rules={draftRules} rules={draftRules}
settings={settings} settings={settings}
@@ -96,16 +107,11 @@ export default function AttachmentRulesOverlay({
basePaths={basePaths} basePaths={basePaths}
zipConfig={zipConfig} zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled} filesModuleInstalled={filesModuleInstalled}
previewContext={previewContext}
activeChooserRuleIndex={null} activeChooserRuleIndex={null}
onChange={setDraftRules} onChange={setDraftRules}
/> />
</div> </Dialog>,
<footer className="modal-footer">
<Button onClick={cancelOverlay}>Cancel</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
</footer>
</div>
</div>,
document.body document.body
) : null; ) : null;
@@ -150,10 +156,14 @@ export function AttachmentRulesDataGrid({
activeChooserRuleIndex = null, activeChooserRuleIndex = null,
zipConfig = { enabled: false, archives: [] }, zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false, filesModuleInstalled = false,
previewContext,
onOpenFileChooser, onOpenFileChooser,
onChange onChange
}: AttachmentRulesTableProps) { }: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null); const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
const managedFilesAvailable = Boolean(ManagedFileChooser);
function patchRule(index: number, patch: Partial<AttachmentRule>) { function patchRule(index: number, patch: Partial<AttachmentRule>) {
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule)); onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
@@ -177,7 +187,7 @@ export function AttachmentRulesDataGrid({
} }
function openFileChooser(ruleIndex: number) { function openFileChooser(ruleIndex: number) {
if (!filesModuleInstalled) return; if (!managedFilesAvailable) return;
if (onOpenFileChooser) { if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex); onOpenFileChooser(ruleIndex);
return; return;
@@ -194,7 +204,7 @@ export function AttachmentRulesDataGrid({
setFileChooser({ ruleIndex, basePath }); setFileChooser({ ruleIndex, basePath });
} }
function selectAttachment(selection: ManagedAttachmentSelection) { function selectAttachment(selection: FilesManagedAttachmentSelection) {
if (!fileChooser) return; if (!fileChooser) return;
const currentRule = rules[fileChooser.ruleIndex] ?? {}; const currentRule = rules[fileChooser.ruleIndex] ?? {};
patchRule(fileChooser.ruleIndex, { patchRule(fileChooser.ruleIndex, {
@@ -213,23 +223,26 @@ export function AttachmentRulesDataGrid({
<DataGrid <DataGrid
id={id} id={id}
rows={rules} rows={rules}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })} columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
getRowKey={(rule, index) => String(rule.id ?? index)} getRowKey={(rule, index) => String(rule.id ?? index)}
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText} emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />} emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
className="attachment-rules-table-wrap attachment-rules-table" className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined} rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/> />
{fileChooser && filesModuleInstalled && ( {fileChooser && ManagedFileChooser && (
<ManagedFileChooser <ManagedFileChooser
open open
settings={settings} settings={settings}
campaignId={campaignId} storageScope={campaignId}
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
mode="attachment" mode="attachment"
source={fileChooser.basePath?.source} source={fileChooser.basePath?.source}
basePath={fileChooser.basePath?.path ?? "."} basePath={fileChooser.basePath?.path ?? "."}
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")} initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`} rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
previewContext={previewContext}
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
onClose={() => setFileChooser(null)} onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment} onSelectAttachment={selectAttachment}
/> />

View File

@@ -1,638 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import {
listFileSpaces,
listFiles,
listFolders,
resolveFilePatterns,
shareFileWithCampaign,
type FileFolder,
type FileSpace,
type ManagedFile
} from "../../../api/files";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { FolderTree } from "../../files/components/FileManagerComponents";
import { useFileTreeState } from "../../files/hooks/useFileTreeState";
import type { FolderNode, SortColumn, SortDirection } from "../../files/types";
import {
buildExplorerEntries,
buildFolderTree,
formatBytes,
formatDate,
normalizeFilePath,
normalizeFolder,
parentFolderPath,
sortExplorerEntries
} from "../../files/utils/fileManagerUtils";
import {
encodeManagedAttachmentSource,
parseManagedAttachmentSource,
type ManagedAttachmentSource
} from "../utils/attachments";
type ManagedFileChooserMode = "folder" | "attachment";
type AttachmentChoiceMode = "file" | "pattern";
type RememberedChooserState = {
spaceId?: string;
folder?: string;
pattern?: string;
};
export type ManagedFolderSelection = {
space: FileSpace;
folderPath: string;
source: string;
};
export type ManagedAttachmentSelection = ManagedFolderSelection & {
fileFilter: string;
matchCount: number;
fileIds: string[];
selectionType: AttachmentChoiceMode;
};
type ManagedFileChooserProps = {
open: boolean;
settings: ApiSettings;
campaignId: string;
mode: ManagedFileChooserMode;
source?: string;
basePath?: string;
initialPattern?: string;
rememberKey?: string;
onClose: () => void;
onSelectFolder?: (selection: ManagedFolderSelection) => void;
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
};
export default function ManagedFileChooser({
open,
settings,
campaignId,
mode,
source,
basePath = ".",
initialPattern = "",
rememberKey = mode,
onClose,
onSelectFolder,
onSelectAttachment
}: ManagedFileChooserProps) {
const filesExplorerUi = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const FolderTreeComponent = filesExplorerUi?.FolderTree ?? FolderTree;
const parsedSource = useMemo(() => parseManagedAttachmentSource(source), [source]);
const normalizedBasePath = normalizeManagedBasePath(basePath);
const storageKey = useMemo(
() => `multimailer.managedFileChooser.${campaignId}.${rememberKey}`,
[campaignId, rememberKey]
);
const remembered = useMemo(() => readRememberedState(storageKey), [storageKey, open]);
const [spaces, setSpaces] = useState<FileSpace[]>([]);
const [selectedSpaceId, setSelectedSpaceId] = useState("");
const [files, setFiles] = useState<ManagedFile[]>([]);
const [folders, setFolders] = useState<FileFolder[]>([]);
const [currentFolder, setCurrentFolder] = useState("");
const [pattern, setPattern] = useState("");
const [patternMatches, setPatternMatches] = useState<ManagedFile[] | null>(null);
const [pendingExactFile, setPendingExactFile] = useState<ManagedFile | null>(null);
const [loading, setLoading] = useState(false);
const [resolving, setResolving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null;
const sourceLocked = mode === "attachment" && Boolean(parsedSource);
const sourceMatchesSpace = selectedSpace
? !parsedSource || (selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId)
: false;
const effectiveRoot = mode === "attachment" ? normalizedBasePath : "";
const entries = useMemo(
() => buildExplorerEntries(files, folders, currentFolder, false),
[currentFolder, files, folders]
);
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const sortedEntries = useMemo(
() => sortExplorerEntries(entries, sortColumn, sortDirection),
[entries, sortColumn, sortDirection]
);
const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot);
const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]);
const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]);
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
activeSpaceId: selectedSpaceId,
currentFolder,
onOpenFolder: (_spaceId, path) => openFolder(path)
});
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
setPattern(remembered.pattern ?? initialPattern.trim());
setPatternMatches(null);
setPendingExactFile(null);
void listFileSpaces(settings)
.then((response) => {
if (cancelled) return;
setSpaces(response.spaces);
const sourceSpace = response.spaces.find((item) => sourceMatches(item, parsedSource));
const rememberedSpace = response.spaces.find((item) => item.id === remembered.spaceId);
const firstSpace = sourceSpace ?? rememberedSpace ?? response.spaces[0] ?? null;
setSelectedSpaceId(firstSpace?.id ?? "");
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [initialPattern, open, parsedSource?.ownerId, parsedSource?.ownerType, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
if (!open || !selectedSpace) return;
let cancelled = false;
setLoading(true);
setError("");
setPatternMatches(null);
const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : "";
const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot;
setCurrentFolder(initialFolder || effectiveRoot);
void Promise.all([
listFiles(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id }),
listFolders(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id })
])
.then(([fileResponse, folderResponse]) => {
if (cancelled) return;
setFiles(fileResponse.files);
setFolders(folderResponse.folders);
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [effectiveRoot, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
if (!open || !selectedSpaceId) return;
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern });
}, [currentFolder, open, pattern, selectedSpaceId, storageKey]);
function toggleSort(column: SortColumn) {
if (sortColumn === column) {
setSortDirection((current) => current === "asc" ? "desc" : "asc");
return;
}
setSortColumn(column);
setSortDirection(column === "name" ? "asc" : "desc");
}
function openFolder(path: string) {
const normalized = normalizeFolder(path);
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
setCurrentFolder(normalized);
setPatternMatches(null);
}
async function previewPattern(): Promise<ManagedFile[]> {
if (!selectedSpace) return [];
const trimmed = pattern.trim();
if (!trimmed) {
setPatternMatches([]);
return [];
}
setResolving(true);
setError("");
try {
const response = await resolveFilePatterns(settings, {
patterns: [trimmed],
owner_type: selectedSpace.owner_type,
owner_id: selectedSpace.owner_id,
path_prefix: effectiveRoot || undefined,
include_unmatched: false
});
const matches = response.patterns[0]?.matches ?? [];
setPatternMatches(matches);
return matches;
} catch (reason) {
setError(errorMessage(reason));
return [];
} finally {
setResolving(false);
}
}
function requestExactFile(file: ManagedFile) {
const exactPattern = relativePath(file.display_path, effectiveRoot);
const currentPattern = pattern.trim();
if (currentPattern && currentPattern !== exactPattern) {
setPendingExactFile(file);
return;
}
applyExactFile(file);
}
function applyExactFile(file: ManagedFile) {
setPattern(relativePath(file.display_path, effectiveRoot));
setPatternMatches([file]);
setPendingExactFile(null);
}
async function shareMatches(matches: ManagedFile[]) {
const toShare = matches.filter((file) => !file.shares?.some((share) => (
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
)));
await Promise.all(toShare.map((file) => shareFileWithCampaign(settings, file.id, campaignId)));
}
async function confirmSelection() {
if (!selectedSpace || !sourceMatchesSpace) return;
setSubmitting(true);
setError("");
try {
if (mode === "folder") {
onSelectFolder?.({
space: selectedSpace,
folderPath: currentFolder,
source: encodeManagedAttachmentSource(selectedSpace)
});
return;
}
const matches = patternMatches ?? await previewPattern();
await shareMatches(matches);
const trimmedPattern = pattern.trim();
onSelectAttachment?.({
space: selectedSpace,
folderPath: effectiveRoot,
source: encodeManagedAttachmentSource(selectedSpace),
fileFilter: trimmedPattern,
matchCount: matches.length,
fileIds: matches.map((file) => file.id),
selectionType: isExactPattern(trimmedPattern) ? "file" : "pattern"
});
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmitting(false);
}
}
if (!open || typeof document === "undefined") return null;
const dialog = (
<>
<Dialog
open
title={mode === "folder" ? "Choose managed attachment source" : "Choose managed file or pattern"}
onClose={onClose}
closeDisabled={submitting}
backdropClassName="managed-file-chooser-backdrop"
className="managed-file-chooser-dialog"
bodyClassName="managed-file-chooser-body"
footerClassName="managed-file-chooser-footer"
footer={(
<>
<Button onClick={onClose} disabled={submitting}>Cancel</Button>
<Button
variant="primary"
onClick={() => void confirmSelection()}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !pattern.trim()))}
>
{submitting ? "Linking…" : mode === "folder" ? "Use this folder" : "Use pattern"}
</Button>
</>
)}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{mode === "attachment" && !parsedSource && (
<DismissibleAlert tone="warning" dismissible={false}>
This attachment base path is not connected to a managed file space. Choose its folder under <strong>Attachments Attachment sources</strong> first.
</DismissibleAlert>
)}
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && (
<DismissibleAlert tone="danger" dismissible={false}>The configured managed file space is no longer available to this user.</DismissibleAlert>
)}
<div className="managed-file-chooser-layout" aria-busy={loading}>
<aside className="managed-file-chooser-spaces file-tree-panel" aria-label="File spaces and folders">
<div className="file-tree-heading">Spaces</div>
<div className="file-tree-list">
{spaces.map((space) => {
const selected = space.id === selectedSpaceId;
const lockedOut = sourceLocked && !sourceMatches(space, parsedSource);
return (
<div className="file-tree-space" key={space.id}>
<button
type="button"
className={`file-tree-node file-tree-root ${selected && currentFolder === effectiveRoot ? "is-active" : ""}`}
onClick={() => {
if (!selected) setSelectedSpaceId(space.id);
else openFolder(effectiveRoot);
}}
disabled={loading || lockedOut}
>
{space.owner_type === "group" ? <FolderOpen size={15} aria-hidden="true" /> : <Home size={15} aria-hidden="true" />}
<span>{space.label}</span>
</button>
{selected && (
<FolderTreeComponent
nodes={treeNodes}
activeSpaceId={selectedSpaceId}
spaceId={space.id}
currentFolder={currentFolder}
expandedKeys={expandedTreeNodes}
onOpen={(_spaceId, path) => openFolder(path)}
onToggle={toggleTreeFolder}
dragDropEnabled={false}
contextMenuEnabled={false}
disabled={loading}
/>
)}
</div>
);
})}
{!loading && spaces.length === 0 && <p className="muted small-note">No accessible file spaces were found.</p>}
</div>
</aside>
<section className={`managed-file-chooser-browser ${mode === "folder" ? "folder-mode" : "attachment-mode"}`}>
<div className="managed-file-chooser-toolbar">
<div className="managed-file-breadcrumb" aria-label="Current folder">
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "Files"}
</button>
{breadcrumbs.map((crumb) => (
<span key={crumb.path}>
<span aria-hidden="true">/</span>
<button type="button" onClick={() => openFolder(crumb.path)} disabled={loading}>{crumb.name}</button>
</span>
))}
</div>
</div>
{mode === "attachment" && (
<div className="managed-pattern-editor">
<label>
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
<div className="field-with-action split-field-action">
<input
value={pattern}
onChange={(event) => { setPattern(event.target.value); setPatternMatches(null); }}
onKeyDown={(event) => { if (event.key === "Enter") void previewPattern(); }}
placeholder="folder/file.pdf or **/*.pdf"
/>
<Button onClick={() => void previewPattern()} disabled={resolving || !pattern.trim()}>
<Search size={15} aria-hidden="true" /> {resolving ? "Checking…" : "Preview"}
</Button>
</div>
</label>
{patternMatches !== null && (
<div className="managed-pattern-summary">
<span>{patternMatches.length} current file{patternMatches.length === 1 ? "" : "s"} match.</span>
<Button variant="ghost" onClick={() => setPatternMatches(null)}>Back to folder</Button>
</div>
)}
</div>
)}
{patternMatches !== null && mode === "attachment" ? (
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
) : (
<div className="managed-file-entry-table">
<div className="managed-file-entry-head" role="row">
<span aria-hidden="true" />
<ChooserSortButton column="name" label="Name" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
</div>
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
<button
type="button"
className="managed-file-entry is-folder is-parent"
onClick={() => openFolder(parentWithinRoot(currentFolder, effectiveRoot))}
disabled={loading || currentFolder === effectiveRoot}
>
<FolderOpen size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
<span className="managed-file-entry-size"></span>
<span className="managed-file-entry-modified"></span>
</button>
{sortedEntries.map((entry) => {
if (entry.kind === "folder") {
return (
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => openFolder(entry.path)}>
<Folder size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
</button>
);
}
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
const selected = mode === "attachment" && pattern.trim() === exactPattern;
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
return (
<button
type="button"
key={entry.file.id}
className={`managed-file-entry is-file ${selected ? "is-selected" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
onClick={() => mode === "attachment" ? requestExactFile(entry.file) : undefined}
disabled={mode === "folder"}
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
>
<File size={18} aria-hidden="true" />
<span className="managed-file-entry-name">
<strong>{entry.file.filename}</strong>
<small>{campaignShared ? <><Link2 size={12} aria-hidden="true" /> linked to campaign</> : "File"}</small>
</span>
<span className="managed-file-entry-size">{formatBytes(entry.file.size_bytes)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.file.updated_at)}</span>
</button>
);
})}
{!loading && sortedEntries.length === 0 && (
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
)}
</div>
</div>
)}
{mode === "folder" && (
<p className="muted small-note managed-file-chooser-note">Choose the folder that should act as this campaign attachment source.</p>
)}
{mode === "attachment" && (
<p className="muted small-note managed-file-chooser-note">Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.</p>
)}
</section>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(pendingExactFile)}
title="Replace the current pattern?"
message={pendingExactFile ? `Replace “${pattern.trim()}” with the exact file “${relativePath(pendingExactFile.display_path, effectiveRoot)}”?` : "Replace the current pattern?"}
confirmLabel="Replace pattern"
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
onCancel={() => setPendingExactFile(null)}
/>
</>
);
return createPortal(dialog, document.body);
}
function ChooserSortButton({
column,
label,
activeColumn,
direction,
onSort,
}: {
column: SortColumn;
label: string;
activeColumn: SortColumn;
direction: SortDirection;
onSort: (column: SortColumn) => void;
}) {
const active = activeColumn === column;
const Icon = active ? (direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
return (
<button
type="button"
className={active ? "is-sorted" : ""}
aria-label={`${label}: ${active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "not sorted"}. Activate to sort.`}
onClick={() => onSort(column)}
>
<span>{label}</span><Icon size={14} aria-hidden="true" />
</button>
);
}
function PatternResultList({ files, campaignId, onChoose }: { files: ManagedFile[]; campaignId: string; onChoose: (file: ManagedFile) => void }) {
return (
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results">
<div className="file-list-table-head" role="row">
<span>Name</span><span>Size</span><span>Modified</span>
</div>
<div className="file-list-table">
{files.length === 0 && <div className="file-list-empty">No current files match this pattern.</div>}
{files.map((file) => (
<button type="button" className="file-list-row file-row managed-pattern-result-row" role="row" key={file.id} onClick={() => onChoose(file)}>
<span className="file-list-name-cell">
<span className="file-list-name">
<File className="file-row-icon" size={20} aria-hidden="true" />
<span><strong>{file.display_path}</strong>{isSharedWithCampaign(file, campaignId) && <small>Linked to campaign</small>}</span>
</span>
</span>
<span>{formatBytes(file.size_bytes)}</span>
<span className="file-row-tail"><span>{formatDate(file.updated_at)}</span></span>
</button>
))}
</div>
</div>
);
}
function sourceMatches(space: FileSpace, source: ManagedAttachmentSource | null): boolean {
return Boolean(source && space.owner_type === source.ownerType && space.owner_id === source.ownerId);
}
function normalizeManagedBasePath(value: string): string {
const normalized = normalizeFolder(value);
return normalized === "." ? "" : normalized;
}
function relativePath(path: string, root: string): string {
const normalizedPath = normalizeFilePath(path);
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return normalizedPath;
const prefix = `${normalizedRoot}/`;
return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
}
function chooserBreadcrumbs(folder: string, root: string): { name: string; path: string }[] {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
const relative = normalizedRoot && normalizedFolder.startsWith(`${normalizedRoot}/`)
? normalizedFolder.slice(normalizedRoot.length + 1)
: normalizedRoot === normalizedFolder ? "" : normalizedFolder;
const parts = relative.split("/").filter(Boolean);
return parts.map((name, index) => ({
name,
path: normalizeFolder([normalizedRoot, ...parts.slice(0, index + 1)].filter(Boolean).join("/"))
}));
}
function parentWithinRoot(folder: string, root: string): string {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
if (!normalizedFolder || normalizedFolder === normalizedRoot) return normalizedRoot;
const parent = parentFolderPath(normalizedFolder);
return isWithinRoot(parent, normalizedRoot) ? parent : normalizedRoot;
}
function isWithinRoot(path: string, root: string): boolean {
const normalizedPath = normalizeFolder(path);
const normalizedRoot = normalizeFolder(root);
return !normalizedRoot || normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
}
function nodesWithinRoot(nodes: FolderNode[], root: string): FolderNode[] {
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return nodes;
const node = findNode(nodes, normalizedRoot);
return node?.children ?? [];
}
function findNode(nodes: FolderNode[], path: string): FolderNode | null {
for (const node of nodes) {
if (node.path === path) return node;
const child = findNode(node.children, path);
if (child) return child;
}
return null;
}
function isExactPattern(value: string): boolean {
return Boolean(value) && !/[?*\[\]{}]/.test(value);
}
function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
}
function readRememberedState(key: string): RememberedChooserState {
if (typeof window === "undefined") return {};
try {
const parsed = JSON.parse(window.localStorage.getItem(key) || "{}") as RememberedChooserState;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function writeRememberedState(key: string, state: RememberedChooserState) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(state));
} catch {
// Storage is optional; chooser behavior remains functional without it.
}
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react"; import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { Button, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui"; import { Button, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
@@ -59,6 +59,34 @@ export default function CampaignMessagePreviewOverlay({
const shownSubject = subject?.trim() || "No subject"; const shownSubject = subject?.trim() || "No subject";
const fields = metaItems.map((item) => ({ label: item.label, value: item.value })); const fields = metaItems.map((item) => ({ label: item.label, value: item.value }));
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (!navigation) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
if (navigation.index > 0) navigation.onPrevious();
} else if (event.key === "ArrowRight") {
event.preventDefault();
if (navigation.index < navigation.total - 1) navigation.onNext();
} else if (event.key === "Home") {
event.preventDefault();
if (navigation.index > 0) navigation.onFirst();
} else if (event.key === "End") {
event.preventDefault();
if (navigation.index < navigation.total - 1) navigation.onLast();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [navigation, onClose]);
return ( return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title"> <div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
<div className="modal-panel template-preview-modal message-preview-modal"> <div className="modal-panel template-preview-modal message-preview-modal">
@@ -109,6 +137,11 @@ export default function CampaignMessagePreviewOverlay({
); );
} }
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || ["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName);
}
export type MessagePreviewAttachment = CampaignMessagePreviewAttachment; export type MessagePreviewAttachment = CampaignMessagePreviewAttachment;
export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem; export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem;

View File

@@ -7,6 +7,7 @@ import {
buildUndefinedPlaceholders, buildUndefinedPlaceholders,
extractTemplatePlaceholders, extractTemplatePlaceholders,
removePlaceholderFromText, removePlaceholderFromText,
replacePlaceholderInText,
type TemplateNamespace, type TemplateNamespace,
type UndefinedPlaceholder type UndefinedPlaceholder
} from "../utils/templatePlaceholders"; } from "../utils/templatePlaceholders";
@@ -100,6 +101,11 @@ export default function TemplateExpressionEditorDialog({
setUndefinedDialog(null); setUndefinedDialog(null);
} }
function replaceUndefined(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) {
setDraftValue((current) => replacePlaceholderInText(current, field.raw, `{{${namespace}:${name}}}`));
setUndefinedDialog(null);
}
if (!open || typeof document === "undefined") return null; if (!open || typeof document === "undefined") return null;
return createPortal( return createPortal(
@@ -168,7 +174,10 @@ export default function TemplateExpressionEditorDialog({
removeLabel="Remove from filename" removeLabel="Remove from filename"
onCancel={() => setUndefinedDialog(null)} onCancel={() => setUndefinedDialog(null)}
onRemove={removeUndefined} onRemove={removeUndefined}
onReplace={replaceUndefined}
onAddField={addUndefined} onAddField={addUndefined}
localFields={localFields}
globalFields={globalFields}
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")} addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
/> />
</>, </>,

View File

@@ -1,3 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
@@ -74,19 +75,49 @@ export function UndefinedPlaceholderDecisionDialog({
field, field,
contextLabel = "template", contextLabel = "template",
removeLabel = "Remove placeholder", removeLabel = "Remove placeholder",
localFields = [],
globalFields = [],
onCancel, onCancel,
onRemove, onRemove,
onReplace,
onAddField, onAddField,
addDisabled = false addDisabled = false
}: { }: {
field: UndefinedPlaceholder | null; field: UndefinedPlaceholder | null;
contextLabel?: string; contextLabel?: string;
removeLabel?: string; removeLabel?: string;
localFields?: TemplateFieldOption[];
globalFields?: TemplateFieldOption[];
onCancel: () => void; onCancel: () => void;
onRemove: (field: UndefinedPlaceholder) => void; onRemove: (field: UndefinedPlaceholder) => void;
onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;
onAddField: (field: UndefinedPlaceholder) => void; onAddField: (field: UndefinedPlaceholder) => void;
addDisabled?: boolean; addDisabled?: boolean;
}) { }) {
const replacementOptions = useMemo(
() => [
...localFields.map((item) => ({ namespace: "local" as const, ...item })),
...globalFields.map((item) => ({ namespace: "global" as const, ...item }))
],
[globalFields, localFields]
);
const [replacement, setReplacement] = useState("");
const firstReplacement = replacementOptions[0];
const effectiveReplacement = replacement || (firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
useEffect(() => {
setReplacement(firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
}, [field?.raw, firstReplacement?.namespace, firstReplacement?.name]);
function replaceWithSelected() {
if (!field || !onReplace || !effectiveReplacement) return;
const separator = effectiveReplacement.indexOf(":");
if (separator < 0) return;
const namespace = effectiveReplacement.slice(0, separator) as TemplateNamespace;
const name = effectiveReplacement.slice(separator + 1);
onReplace(field, namespace, name);
}
return ( return (
<Dialog <Dialog
open={Boolean(field)} open={Boolean(field)}
@@ -98,6 +129,7 @@ export function UndefinedPlaceholderDecisionDialog({
<> <>
<Button onClick={onCancel}>Continue editing</Button> <Button onClick={onCancel}>Continue editing</Button>
<Button onClick={() => onRemove(field)}>{removeLabel}</Button> <Button onClick={() => onRemove(field)}>{removeLabel}</Button>
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>Replace</Button>}
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button> <Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
</> </>
) : undefined} ) : undefined}
@@ -111,6 +143,18 @@ export function UndefinedPlaceholderDecisionDialog({
{field.reason === "missing-field" && ( {field.reason === "missing-field" && (
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p> <p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p>
)} )}
{onReplace && replacementOptions.length > 0 && (
<label className="template-replacement-field">
<span>Replace by existing field</span>
<select value={effectiveReplacement} onChange={(event) => setReplacement(event.target.value)}>
{replacementOptions.map((option) => (
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
{option.namespace}:{option.label || option.name}
</option>
))}
</select>
</label>
)}
</> </>
)} )}
</Dialog> </Dialog>

View File

@@ -22,6 +22,7 @@ type UseCampaignDraftEditorOptions = {
unsavedMessage: string; unsavedMessage: string;
loadedLabel?: (version: CampaignVersionDetail) => string; loadedLabel?: (version: CampaignVersionDetail) => string;
transformLoadedDraft?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => Record<string, unknown>; transformLoadedDraft?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => Record<string, unknown>;
transformDraftBeforeSave?: (draft: Record<string, unknown>) => Record<string, unknown>;
extraPayload?: () => Partial<CampaignVersionUpdatePayload>; extraPayload?: () => Partial<CampaignVersionUpdatePayload>;
onLoaded?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => void; onLoaded?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => void;
onSaved?: (version: CampaignVersionDetail) => void; onSaved?: (version: CampaignVersionDetail) => void;
@@ -50,19 +51,22 @@ export function useCampaignDraftEditor({
unsavedMessage, unsavedMessage,
loadedLabel = defaultLoadedLabel, loadedLabel = defaultLoadedLabel,
transformLoadedDraft, transformLoadedDraft,
transformDraftBeforeSave,
extraPayload, extraPayload,
onLoaded, onLoaded,
onSaved onSaved
}: UseCampaignDraftEditorOptions) { }: UseCampaignDraftEditorOptions) {
const loadedLabelRef = useRef(loadedLabel); const loadedLabelRef = useRef(loadedLabel);
const transformLoadedDraftRef = useRef(transformLoadedDraft); const transformLoadedDraftRef = useRef(transformLoadedDraft);
const transformDraftBeforeSaveRef = useRef(transformDraftBeforeSave);
const onLoadedRef = useRef(onLoaded); const onLoadedRef = useRef(onLoaded);
useEffect(() => { useEffect(() => {
loadedLabelRef.current = loadedLabel; loadedLabelRef.current = loadedLabel;
transformLoadedDraftRef.current = transformLoadedDraft; transformLoadedDraftRef.current = transformLoadedDraft;
transformDraftBeforeSaveRef.current = transformDraftBeforeSave;
onLoadedRef.current = onLoaded; onLoadedRef.current = onLoaded;
}, [loadedLabel, transformLoadedDraft, onLoaded]); }, [loadedLabel, transformLoadedDraft, transformDraftBeforeSave, onLoaded]);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null); const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
@@ -97,8 +101,9 @@ export function useCampaignDraftEditor({
setError(""); setError("");
setLocalError(""); setLocalError("");
try { try {
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, { const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft, campaign_json: draftToSave,
current_flow: currentFlow, current_flow: currentFlow,
current_step: resolveStep(currentStep), current_step: resolveStep(currentStep),
workflow_state: workflowState, workflow_state: workflowState,

View File

@@ -1,4 +1,3 @@
import type { FileSpace } from "../../../api/files";
import { asArray, asRecord, isRecord } from "./campaignView"; import { asArray, asRecord, isRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor"; import { getBool, getText } from "./draftEditor";
@@ -98,9 +97,14 @@ export type ManagedAttachmentSource = {
ownerId: string; ownerId: string;
}; };
type ManagedAttachmentSourceSpace = {
owner_type: "user" | "group";
owner_id: string;
};
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:"; const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
export function encodeManagedAttachmentSource(space: Pick<FileSpace, "owner_type" | "owner_id">): string { export function encodeManagedAttachmentSource(space: ManagedAttachmentSourceSpace): string {
return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`; return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
} }

View File

@@ -0,0 +1,797 @@
type ImportRecord = Record<string, unknown>;
type XlsxWorkbookSheet = {
sheet: string;
data: unknown[][];
};
type XlsxWorkbookReader = (input: ArrayBuffer) => Promise<XlsxWorkbookSheet[]>;
export type ImportFieldDefinition = {
name: string;
label?: string;
type?: string;
required?: boolean;
can_override?: boolean;
};
export type ImportAttachmentBasePath = {
id?: string;
path?: string;
};
export type RecipientImportMode = "append" | "replace";
export type CsvDelimiter = "auto" | "," | ";" | "\t";
export type CsvParseOptions = {
delimiter: CsvDelimiter;
headerRows: number;
quoted: boolean;
};
export type RecipientImportSourceType = "csv" | "xlsx" | "text";
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
export type RecipientColumnMapping = {
columnIndex: number;
kind: RecipientColumnKind;
fieldName?: string | null;
newFieldName?: string | null;
};
export type RecipientImportTable = {
delimiter: "," | ";" | "\t";
headers: string[];
headerRows: string[][];
rows: string[][];
dataRows: string[][];
firstDataRowNumber: number;
};
export type RecipientImportSpreadsheetSheet = {
name: string;
rows: string[][];
};
export type ImportedAddress = {
email: string;
name?: string;
};
export type ImportedRecipientRow = {
rowNumber: number;
id: string;
name: string;
email: string;
active: boolean;
addresses: {
from: ImportedAddress[];
to: ImportedAddress[];
cc: ImportedAddress[];
bcc: ImportedAddress[];
reply_to: ImportedAddress[];
};
fields: Record<string, string>;
patterns: string[];
issues: string[];
};
export type RecipientImportPreview = {
table: RecipientImportTable;
rows: ImportedRecipientRow[];
fieldNamesToCreate: string[];
validCount: number;
invalidCount: number;
patternCount: number;
};
export type RecipientImportColumnProvenance = {
column_index: number;
header: string;
kind: RecipientColumnKind;
field_name?: string | null;
new_field_name?: string | null;
};
export type RecipientImportProvenance = {
id: string;
imported_at: string;
mode: RecipientImportMode;
source_type: RecipientImportSourceType;
filename?: string | null;
sheet_name?: string | null;
encoding?: string | null;
delimiter?: string | null;
header_rows: number;
quoted?: boolean | null;
value_separators?: string | null;
rows_total: number;
valid_rows: number;
invalid_rows: number;
imported_rows: number;
field_names_created: string[];
attachment_patterns: number;
mapping: RecipientImportColumnProvenance[];
};
export type RecipientImportPreviewOptions = {
existingFields: ImportFieldDefinition[];
existingEntries?: Record<string, unknown>[];
valueSeparators?: string;
};
export type RecipientMappingProfile = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
columnCount: number;
headers: string[];
normalizedHeaders: string[];
orderedHeaderFingerprint: string;
unorderedHeaderFingerprint: string;
delimiter: "," | ";" | "\t";
headerRows: number;
quoted: boolean;
valueSeparators: string;
mappings: RecipientColumnMapping[];
};
export type RecipientMappingProfileMatchMode = "ordered" | "unordered" | "similar";
export type RecipientMappingProfileMatch = {
profile: RecipientMappingProfile;
mode: RecipientMappingProfileMatchMode;
score: number;
matchedHeaders: number;
missingHeaders: string[];
extraHeaders: string[];
};
export type RecipientMappingProfileInput = {
id?: string;
name: string;
table: RecipientImportTable;
mappings: RecipientColumnMapping[];
parseOptions: Pick<CsvParseOptions, "headerRows" | "quoted">;
valueSeparators: string;
createdAt?: string;
updatedAt?: string;
};
export type MaterializeImportOptions = {
mode: RecipientImportMode;
attachmentBasePath?: ImportAttachmentBasePath | null;
provenance?: RecipientImportProvenance | null;
};
const EMAIL_COLUMNS = new Set(["email", "e_mail", "mail", "to", "to_email", "recipient", "recipient_email"]);
const NAME_COLUMNS = new Set(["name", "full_name", "recipient_name", "to_name"]);
const ID_COLUMNS = new Set(["id", "entry_id", "recipient_id"]);
const ACTIVE_COLUMNS = new Set(["active", "enabled", "send", "include"]);
export function buildImportTable(text: string, options: CsvParseOptions): RecipientImportTable {
const delimiter = options.delimiter === "auto" ? detectDelimiter(text, options.quoted) : options.delimiter;
const rows = parseDelimitedText(text, delimiter, options.quoted);
return buildImportTableFromRows(rows, { headerRows: options.headerRows, delimiter });
}
export function buildImportTableFromRows(
sourceRows: string[][],
options: { headerRows: number; delimiter?: "," | ";" | "\t" }
): RecipientImportTable {
const rows = sourceRows.map((row) => row.map((cell) => String(cell ?? "")));
const normalizedHeaderRows = Math.max(0, Math.min(Math.floor(options.headerRows || 0), rows.length));
const headerRows = rows.slice(0, normalizedHeaderRows);
const dataRows = rows.slice(normalizedHeaderRows);
const columnCount = Math.max(0, ...rows.map((row) => row.length));
const headers = Array.from({ length: columnCount }, (_value, columnIndex) => headerForColumn(headerRows, columnIndex));
return {
delimiter: options.delimiter ?? ",",
headers,
headerRows,
rows,
dataRows,
firstDataRowNumber: normalizedHeaderRows + 1
};
}
export async function xlsxSheetsFromArrayBuffer(buffer: ArrayBuffer): Promise<RecipientImportSpreadsheetSheet[]> {
const readXlsxFile = await loadXlsxWorkbookReader();
const workbook = await readXlsxFile(buffer);
return workbook.map((sheet) => ({
name: sheet.sheet,
rows: sheet.data.map((row) => row.map((cell) => cellToImportText(cell)))
}));
}
async function loadXlsxWorkbookReader(): Promise<XlsxWorkbookReader> {
if (typeof window !== "undefined" && typeof DOMParser !== "undefined") {
const module = await import("read-excel-file/browser");
return module.default as XlsxWorkbookReader;
}
const module = await import("read-excel-file/universal");
return module.default as XlsxWorkbookReader;
}
export function defaultColumnMappings(headers: string[], existingFields: ImportFieldDefinition[]): RecipientColumnMapping[] {
const existingFieldNames = new Set(existingFields.map((field) => field.name));
return headers.map((header, columnIndex) => {
const key = normalizeColumnKey(header);
if (ID_COLUMNS.has(key)) return { columnIndex, kind: "id" };
if (ACTIVE_COLUMNS.has(key)) return { columnIndex, kind: "active" };
if (NAME_COLUMNS.has(key)) return { columnIndex, kind: "name" };
if (key === "from" || key === "from_email") return { columnIndex, kind: "from" };
if (key === "cc" || key === "cc_email") return { columnIndex, kind: "cc" };
if (key === "bcc" || key === "bcc_email") return { columnIndex, kind: "bcc" };
if (key === "reply_to" || key === "reply_to_email") return { columnIndex, kind: "reply_to" };
if (EMAIL_COLUMNS.has(key)) return { columnIndex, kind: "to" };
if (isPatternColumn(key)) return { columnIndex, kind: "attachment_pattern" };
const explicitFieldName = explicitFieldNameFromHeader(header);
if (explicitFieldName) return existingFieldNames.has(explicitFieldName) ? { columnIndex, kind: "field", fieldName: explicitFieldName } : { columnIndex, kind: "new_field", newFieldName: explicitFieldName };
const sanitized = sanitizeFieldName(header);
if (existingFieldNames.has(sanitized)) return { columnIndex, kind: "field", fieldName: sanitized };
return sanitized ? { columnIndex, kind: "new_field", newFieldName: sanitized } : { columnIndex, kind: "ignore" };
});
}
export function createRecipientMappingProfile(input: RecipientMappingProfileInput): RecipientMappingProfile {
const fingerprints = recipientImportHeaderFingerprints(input.table.headers);
const updatedAt = input.updatedAt ?? new Date().toISOString();
const createdAt = input.createdAt ?? updatedAt;
const id = input.id ?? `recipient-import-${fingerprints.orderedHeaderFingerprint}-${Date.now().toString(36)}`;
return {
id,
name: input.name.trim() || "Recipient import mapping",
createdAt,
updatedAt,
columnCount: input.table.headers.length,
headers: input.table.headers.slice(),
normalizedHeaders: fingerprints.normalizedHeaders,
orderedHeaderFingerprint: fingerprints.orderedHeaderFingerprint,
unorderedHeaderFingerprint: fingerprints.unorderedHeaderFingerprint,
delimiter: input.table.delimiter,
headerRows: Math.max(0, Math.floor(input.parseOptions.headerRows || 0)),
quoted: input.parseOptions.quoted,
valueSeparators: input.valueSeparators,
mappings: normalizeMappingList(input.mappings, input.table.headers.length)
};
}
export function recipientImportHeaderFingerprints(headers: string[]): Pick<RecipientMappingProfile, "normalizedHeaders" | "orderedHeaderFingerprint" | "unorderedHeaderFingerprint"> {
const normalizedHeaders = headers.map(normalizeImportHeader);
return {
normalizedHeaders,
orderedHeaderFingerprint: fingerprintHeaderList(normalizedHeaders),
unorderedHeaderFingerprint: fingerprintHeaderList(normalizedHeaders.slice().sort())
};
}
export function matchRecipientMappingProfiles(table: RecipientImportTable, profiles: RecipientMappingProfile[]): RecipientMappingProfileMatch[] {
const fingerprints = recipientImportHeaderFingerprints(table.headers);
return profiles
.map((profile): RecipientMappingProfileMatch | null => {
const diff = headerSetDiff(fingerprints.normalizedHeaders, profile.normalizedHeaders);
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint;
const exactUnordered = fingerprints.unorderedHeaderFingerprint === profile.unorderedHeaderFingerprint;
const mode: RecipientMappingProfileMatchMode = exactOrdered ? "ordered" : exactUnordered ? "unordered" : "similar";
const baseScore = exactOrdered ? 1 : exactUnordered ? 0.96 : diff.matchedHeaders / Math.max(fingerprints.normalizedHeaders.length, profile.normalizedHeaders.length, 1);
const mappedMissingCount = missingMappedHeaders(profile, fingerprints.normalizedHeaders).length;
const score = mode === "similar" ? Math.max(0, baseScore - Math.min(0.25, mappedMissingCount * 0.05)) : baseScore;
if (mode === "similar" && score < 0.45) return null;
return {
profile,
mode,
score,
matchedHeaders: diff.matchedHeaders,
missingHeaders: diff.missingHeaders,
extraHeaders: diff.extraHeaders
};
})
.filter((match): match is RecipientMappingProfileMatch => match !== null)
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return right.profile.updatedAt.localeCompare(left.profile.updatedAt);
});
}
export function applyRecipientMappingProfile(
table: RecipientImportTable,
profile: RecipientMappingProfile,
existingFields: ImportFieldDefinition[]
): RecipientColumnMapping[] {
const defaults = defaultColumnMappings(table.headers, existingFields);
const fingerprints = recipientImportHeaderFingerprints(table.headers);
const profileMappings = normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length);
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint && table.headers.length === profileMappings.length;
if (exactOrdered) {
return table.headers.map((header, columnIndex) => normalizeProfileMapping(profileMappings[columnIndex], columnIndex, header, existingFields, defaults[columnIndex]));
}
const profileIndexesByHeader = new Map<string, number[]>();
profile.normalizedHeaders.forEach((header, index) => {
const indexes = profileIndexesByHeader.get(header) ?? [];
indexes.push(index);
profileIndexesByHeader.set(header, indexes);
});
return table.headers.map((header, columnIndex) => {
const normalizedHeader = normalizeImportHeader(header);
const profileIndex = profileIndexesByHeader.get(normalizedHeader)?.shift();
if (profileIndex === undefined) return defaults[columnIndex] ?? { columnIndex, kind: "ignore" };
return normalizeProfileMapping(profileMappings[profileIndex], columnIndex, header, existingFields, defaults[columnIndex]);
});
}
export function buildRecipientImportPreview(
table: RecipientImportTable,
mappings: RecipientColumnMapping[],
options: RecipientImportPreviewOptions
): RecipientImportPreview {
const existingFields = options.existingFields;
const existingFieldNames = new Set(existingFields.map((field) => field.name));
const existingIds = new Set((options.existingEntries ?? []).map((entry) => String(entry.id || "")).filter(Boolean));
const usedIds = new Set(existingIds);
const fieldNamesToCreate = new Set<string>();
const mappingsByColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping]));
const valueSeparators = options.valueSeparators || ",;|";
const rows = table.dataRows
.map((cells, index): ImportedRecipientRow | null => {
if (cells.every((cell) => !cell.trim())) return null;
const rowNumber = table.firstDataRowNumber + index;
const addresses: ImportedRecipientRow["addresses"] = { from: [], to: [], cc: [], bcc: [], reply_to: [] };
const fields: Record<string, string> = {};
const patterns: string[] = [];
const issues: string[] = [];
let preferredId = "";
let name = "";
let active = true;
cells.forEach((rawValue, columnIndex) => {
const value = String(rawValue ?? "").trim();
if (!value) return;
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
switch (mapping.kind) {
case "id":
preferredId = value;
break;
case "active":
active = parseOptionalBoolean(value, true);
break;
case "name":
name = value;
break;
case "from":
case "to":
case "cc":
case "bcc":
case "reply_to":
addresses[mapping.kind].push(...parseAddressCell(value, valueSeparators));
break;
case "field": {
const fieldName = mapping.fieldName?.trim();
if (fieldName) fields[fieldName] = value;
break;
}
case "new_field": {
const fieldName = sanitizeFieldName(mapping.newFieldName || table.headers[columnIndex] || `column_${columnIndex + 1}`);
if (fieldName) {
fields[fieldName] = value;
if (!existingFieldNames.has(fieldName)) fieldNamesToCreate.add(fieldName);
}
break;
}
case "attachment_pattern":
patterns.push(...splitCell(value, valueSeparators));
break;
case "ignore":
default:
break;
}
});
if (name && addresses.to.length === 1 && !addresses.to[0].name) addresses.to[0].name = name;
const primaryAddress = addresses.to[0] ?? addresses.cc[0] ?? addresses.bcc[0] ?? addresses.reply_to[0] ?? addresses.from[0] ?? null;
const email = primaryAddress?.email ?? "";
const id = uniqueId(preferredId || idFromEmail(email) || `recipient-${rowNumber}`, usedIds);
usedIds.add(id);
for (const [key, list] of Object.entries(addresses)) {
for (const address of list) {
if (!address.email.includes("@")) issues.push(`${address.email || key} must contain @`);
}
}
if (addresses.to.length === 0) issues.push("Missing To address");
return {
rowNumber,
id,
name: name || primaryAddress?.name || "",
email,
active,
addresses,
fields,
patterns,
issues: [...new Set(issues)]
};
})
.filter((row): row is ImportedRecipientRow => row !== null);
return {
table,
rows,
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
validCount: rows.filter((row) => row.issues.length === 0).length,
invalidCount: rows.filter((row) => row.issues.length > 0).length,
patternCount: rows.reduce((count, row) => count + row.patterns.length, 0)
};
}
export function materializeRecipientImport(
draft: Record<string, unknown>,
preview: RecipientImportPreview,
options: MaterializeImportOptions
): Record<string, unknown> {
const currentEntries = asRecord(draft.entries);
const currentInlineEntries = asArray(currentEntries.inline).map(asRecord);
const previousImports = asArray(currentEntries.imports).map(asRecord);
const importedEntries = preview.rows
.filter((row) => row.issues.length === 0)
.map((row) => importedRowToEntry(row, options.attachmentBasePath));
const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries;
const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries };
if (options.provenance) nextEntries.imports = [...previousImports, options.provenance];
delete nextEntries.source;
delete nextEntries.mapping;
return {
...draft,
fields: mergeFieldDefinitions(draft.fields, preview.fieldNamesToCreate),
entries: nextEntries
};
}
export function createRecipientImportProvenance(input: {
preview: RecipientImportPreview;
mappings: RecipientColumnMapping[];
mode: RecipientImportMode;
sourceType: RecipientImportSourceType;
filename?: string | null;
sheetName?: string | null;
encoding?: string | null;
delimiter?: string | null;
headerRows: number;
quoted?: boolean | null;
valueSeparators?: string | null;
}): RecipientImportProvenance {
const now = new Date().toISOString();
const mappingsByColumn = new Map(input.mappings.map((mapping) => [mapping.columnIndex, mapping]));
return {
id: `recipient-import-${stableHash(`${now}\u001f${input.filename ?? ""}\u001f${input.sheetName ?? ""}\u001f${input.preview.rows.length}`)}`,
imported_at: now,
mode: input.mode,
source_type: input.sourceType,
filename: input.filename?.trim() || null,
sheet_name: input.sheetName?.trim() || null,
encoding: input.encoding?.trim() || null,
delimiter: input.delimiter?.trim() || null,
header_rows: Math.max(0, Math.floor(input.headerRows || 0)),
quoted: input.quoted ?? null,
value_separators: input.valueSeparators ?? null,
rows_total: input.preview.rows.length,
valid_rows: input.preview.validCount,
invalid_rows: input.preview.invalidCount,
imported_rows: input.preview.validCount,
field_names_created: input.preview.fieldNamesToCreate.slice(),
attachment_patterns: input.preview.patternCount,
mapping: input.preview.table.headers.map((header, columnIndex) => {
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
return {
column_index: columnIndex,
header,
kind: mapping.kind,
...(mapping.fieldName ? { field_name: mapping.fieldName } : {}),
...(mapping.newFieldName ? { new_field_name: mapping.newFieldName } : {})
};
})
};
}
export function importedRowsNeedAttachmentSource(preview: RecipientImportPreview): boolean {
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
}
function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: ImportAttachmentBasePath | null): Record<string, unknown> {
return {
id: row.id,
active: row.active,
name: row.name,
email: row.email,
from: row.addresses.from.slice(0, 1),
to: row.addresses.to,
cc: row.addresses.cc,
bcc: row.addresses.bcc,
reply_to: row.addresses.reply_to,
merge_to: false,
merge_cc: true,
merge_bcc: true,
merge_reply_to: true,
fields: row.fields,
attachments: row.patterns.map((pattern, index) => ({
id: `attachment-import-${row.rowNumber}-${index + 1}`,
label: row.patterns.length === 1 ? "Imported attachment pattern" : `Imported attachment pattern ${index + 1}`,
type: "pattern",
base_path_id: attachmentBasePath?.id || undefined,
base_dir: attachmentBasePath?.path || ".",
file_filter: pattern,
include_subdirs: false,
required: true,
zip: { archive_id: "inherit" }
})),
combine_attachments: true
};
}
function mergeFieldDefinitions(fieldsValue: unknown, fieldNamesToCreate: string[]): Record<string, unknown>[] {
const existing = asArray(fieldsValue).map(asRecord);
const existingNames = new Set(existing.map((field) => getText(field, "name") || getText(field, "id")).filter(Boolean));
const additions = fieldNamesToCreate
.filter((name) => !existingNames.has(name))
.map((name) => ({
name,
label: humanizeFieldName(name),
type: "string",
required: false,
can_override: true
}));
return [...existing, ...additions];
}
export function parseDelimitedText(text: string, delimiter: "," | ";" | "\t", quoted = true): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let inQuotes = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
const next = text[index + 1];
if (quoted && inQuotes) {
if (char === '"' && next === '"') {
cell += '"';
index += 1;
} else if (char === '"') {
inQuotes = false;
} else {
cell += char;
}
continue;
}
if (quoted && char === '"') {
inQuotes = true;
} else if (char === delimiter) {
row.push(cell);
cell = "";
} else if (char === "\n") {
row.push(cell);
rows.push(row);
row = [];
cell = "";
} else if (char !== "\r") {
cell += char;
}
}
row.push(cell);
if (row.length > 1 || row[0] !== "" || text.endsWith(delimiter)) rows.push(row);
return rows;
}
function detectDelimiter(text: string, quoted: boolean): "," | ";" | "\t" {
const firstLine = text.split(/\r?\n/, 1)[0] ?? "";
const candidates: Array<"," | ";" | "\t"> = [",", ";", "\t"];
return candidates
.map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 }))
.sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ",";
}
function headerForColumn(headerRows: string[][], columnIndex: number): string {
const parts = headerRows.map((row) => String(row[columnIndex] ?? "").trim()).filter(Boolean);
return parts.length ? parts.join(" / ") : `Column ${columnIndex + 1}`;
}
function cellToImportText(value: unknown): string {
if (value === null || value === undefined) return "";
if (value instanceof Date) return value.toISOString();
return String(value);
}
function explicitFieldNameFromHeader(header: string): string {
const explicit = /^(?:field|fields)[.:](.+)$/i.exec(header.trim());
return explicit ? sanitizeFieldName(explicit[1]) : "";
}
function normalizeColumnKey(value: string): string {
return value.trim().toLowerCase().replace(/[\s.-]+/g, "_").replace(/^_+|_+$/g, "");
}
function normalizeImportHeader(value: string): string {
return normalizeColumnKey(value).replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
}
function fingerprintHeaderList(headers: string[]): string {
return stableHash(headers.join("\u001f"));
}
function stableHash(value: string): string {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
function headerSetDiff(currentHeaders: string[], profileHeaders: string[]): { matchedHeaders: number; missingHeaders: string[]; extraHeaders: string[] } {
const currentCounts = countHeaders(currentHeaders);
const profileCounts = countHeaders(profileHeaders);
let matchedHeaders = 0;
const missingHeaders: string[] = [];
const extraHeaders: string[] = [];
for (const [header, profileCount] of profileCounts) {
const currentCount = currentCounts.get(header) ?? 0;
matchedHeaders += Math.min(currentCount, profileCount);
for (let index = currentCount; index < profileCount; index += 1) missingHeaders.push(header);
}
for (const [header, currentCount] of currentCounts) {
const profileCount = profileCounts.get(header) ?? 0;
for (let index = profileCount; index < currentCount; index += 1) extraHeaders.push(header);
}
return { matchedHeaders, missingHeaders, extraHeaders };
}
function countHeaders(headers: string[]): Map<string, number> {
const counts = new Map<string, number>();
headers.forEach((header) => counts.set(header, (counts.get(header) ?? 0) + 1));
return counts;
}
function missingMappedHeaders(profile: RecipientMappingProfile, currentHeaders: string[]): string[] {
const currentCounts = countHeaders(currentHeaders);
return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length)
.filter((mapping) => mapping.kind !== "ignore")
.map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "")
.filter((header) => header && !currentCounts.has(header));
}
function normalizeMappingList(mappings: RecipientColumnMapping[], columnCount: number): RecipientColumnMapping[] {
const byColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping]));
return Array.from({ length: Math.max(0, columnCount) }, (_value, columnIndex) => normalizeMappingShape(byColumn.get(columnIndex), columnIndex));
}
function normalizeMappingShape(mapping: RecipientColumnMapping | undefined, columnIndex: number): RecipientColumnMapping {
if (!mapping) return { columnIndex, kind: "ignore" };
const kind = mapping.kind;
if (kind === "field") return { columnIndex, kind, fieldName: mapping.fieldName ?? "" };
if (kind === "new_field") return { columnIndex, kind, newFieldName: mapping.newFieldName ?? "" };
return { columnIndex, kind };
}
function normalizeProfileMapping(
mapping: RecipientColumnMapping | undefined,
columnIndex: number,
header: string,
existingFields: ImportFieldDefinition[],
fallback: RecipientColumnMapping | undefined
): RecipientColumnMapping {
const fallbackMapping = fallback ? { ...fallback, columnIndex } : { columnIndex, kind: "ignore" as const };
if (!mapping) return fallbackMapping;
const existingFieldNames = new Set(existingFields.map((field) => field.name));
if (mapping.kind === "field") {
const fieldName = sanitizeFieldName(mapping.fieldName ?? "");
if (!fieldName) return fallbackMapping;
return existingFieldNames.has(fieldName) ? { columnIndex, kind: "field", fieldName } : { columnIndex, kind: "new_field", newFieldName: fieldName };
}
if (mapping.kind === "new_field") {
const fieldName = sanitizeFieldName(mapping.newFieldName || header);
if (!fieldName) return fallbackMapping;
return existingFieldNames.has(fieldName) ? { columnIndex, kind: "field", fieldName } : { columnIndex, kind: "new_field", newFieldName: fieldName };
}
return { columnIndex, kind: mapping.kind };
}
function isPatternColumn(key: string): boolean {
return key === "pattern"
|| key === "patterns"
|| key === "file"
|| key === "files"
|| key === "file_pattern"
|| key === "file_patterns"
|| key === "attachment"
|| key === "attachments"
|| key === "attachment_pattern"
|| key === "attachment_patterns"
|| /^pattern_\d+$/.test(key)
|| /^file_\d+$/.test(key)
|| /^attachment_\d+$/.test(key);
}
function parseAddressCell(value: string, separators: string): ImportedAddress[] {
return splitCell(value, separators)
.map((item) => parseAddress(item))
.filter((address) => Boolean(address.email));
}
function parseAddress(value: string): ImportedAddress {
const trimmed = value.trim();
const match = /^(.*?)<([^<>]+)>$/.exec(trimmed);
if (match) {
const name = match[1].trim().replace(/^"|"$/g, "");
return { email: match[2].trim(), ...(name ? { name } : {}) };
}
return { email: trimmed };
}
function splitCell(value: string, separators: string): string[] {
const escaped = separators.split("").map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
if (!escaped) return [value.trim()].filter(Boolean);
return value.split(new RegExp(`[${escaped}]+`)).map((item) => item.trim()).filter(Boolean);
}
function parseOptionalBoolean(value: string, fallback: boolean): boolean {
const normalized = value.trim().toLowerCase();
if (!normalized) return fallback;
if (["1", "true", "yes", "y", "ja", "j", "x", "active", "aktiv"].includes(normalized)) return true;
if (["0", "false", "no", "n", "nein", "inactive", "inaktiv"].includes(normalized)) return false;
return fallback;
}
function idFromEmail(email: string): string {
const localPart = email.split("@", 1)[0] || "";
return sanitizeFieldName(localPart).toLowerCase() || "";
}
function uniqueId(preferred: string, usedIds: Set<string>): string {
const base = sanitizeFieldName(preferred).toLowerCase() || "recipient";
if (!usedIds.has(base)) return base;
let index = 2;
let candidate = `${base}-${index}`;
while (usedIds.has(candidate)) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
}
function sanitizeFieldName(value: string): string {
return value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
}
function isRecord(value: unknown): value is ImportRecord {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function asRecord(value: unknown): ImportRecord {
return isRecord(value) ? value : {};
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function getText(record: ImportRecord, key: string, fallback = ""): string {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
function humanizeFieldName(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}

View File

@@ -0,0 +1,145 @@
import type { ApiSettings, FilesFileExplorerUiCapability, FilesManagedFile } from "@govoplan/core-webui";
import { parseManagedAttachmentSource, type AttachmentBasePath } from "./attachments";
import type { ImportedRecipientRow, RecipientImportPreview } from "./bulkImport";
export type RenderedImportPattern = {
key: string;
rowNumber: number;
sourcePattern: string;
renderedPattern: string;
};
export type ImportFileLinkCapability = Pick<Required<FilesFileExplorerUiCapability>, "listFiles" | "resolveFilePatterns" | "shareFilesWithTarget">;
export type ImportFileLinkResolution = {
basePath: AttachmentBasePath;
patterns: RenderedImportPattern[];
matchedPatterns: { pattern: RenderedImportPattern; matches: FilesManagedFile[] }[];
unmatchedPatterns: RenderedImportPattern[];
files: FilesManagedFile[];
linkedFiles: FilesManagedFile[];
linkableFiles: FilesManagedFile[];
};
export async function resolveImportedAttachmentLinks(
filesCapability: ImportFileLinkCapability,
settings: ApiSettings,
campaignId: string,
basePath: AttachmentBasePath,
preview: RecipientImportPreview
): Promise<ImportFileLinkResolution> {
const parsedSource = parseManagedAttachmentSource(basePath.source);
if (!parsedSource) {
throw new Error("The selected attachment source is not connected to a managed file space.");
}
const patterns = renderedImportPatterns(preview);
if (patterns.length === 0) {
return { basePath, patterns, matchedPatterns: [], unmatchedPatterns: [], files: [], linkedFiles: [], linkableFiles: [] };
}
const root = normalizedPathPrefix(basePath.path);
const [resolved, visibleFiles] = await Promise.all([
filesCapability.resolveFilePatterns(settings, {
patterns: patterns.map((item) => item.renderedPattern),
owner_type: parsedSource.ownerType,
owner_id: parsedSource.ownerId,
path_prefix: root || undefined,
include_unmatched: false
}),
filesCapability.listFiles(settings, {
owner_type: parsedSource.ownerType,
owner_id: parsedSource.ownerId,
path_prefix: root || undefined
})
]);
const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file]));
const fileById = new Map<string, FilesManagedFile>();
const matchedPatterns = patterns.map((pattern, index) => {
const matches = (resolved.patterns[index]?.matches ?? [])
.map((file) => visibleById.get(file.id) ?? file);
matches.forEach((file) => fileById.set(file.id, file));
return { pattern, matches };
});
const files = [...fileById.values()].sort((left, right) => left.display_path.localeCompare(right.display_path));
const linkedFiles = files.filter((file) => isSharedWithCampaign(file, campaignId));
const linkableFiles = files.filter((file) => !isSharedWithCampaign(file, campaignId));
return {
basePath,
patterns,
matchedPatterns,
unmatchedPatterns: matchedPatterns.filter((item) => item.matches.length === 0).map((item) => item.pattern),
files,
linkedFiles,
linkableFiles
};
}
export async function bulkLinkFilesToCampaign(filesCapability: ImportFileLinkCapability, settings: ApiSettings, campaignId: string, files: FilesManagedFile[]): Promise<number> {
const uniqueIds = [...new Set(files.map((file) => file.id).filter(Boolean))];
if (uniqueIds.length === 0) return 0;
const response = await filesCapability.shareFilesWithTarget(settings, uniqueIds, { type: "campaign", id: campaignId, label: "campaign" });
return response.shared_count;
}
export function renderedImportPatterns(preview: RecipientImportPreview): RenderedImportPattern[] {
const byKey = new Map<string, RenderedImportPattern>();
preview.rows
.filter((row) => row.issues.length === 0)
.forEach((row) => {
row.patterns.forEach((sourcePattern) => {
const renderedPattern = renderPatternForImportedRow(sourcePattern, row).trim();
if (!renderedPattern) return;
const key = `${row.rowNumber}:${sourcePattern}:${renderedPattern}`;
if (!byKey.has(key)) byKey.set(key, { key, rowNumber: row.rowNumber, sourcePattern, renderedPattern });
});
});
return [...byKey.values()];
}
export function isSharedWithCampaign(file: FilesManagedFile, campaignId: string): boolean {
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
}
function renderPatternForImportedRow(pattern: string, row: ImportedRecipientRow): string {
const values = valuesForImportedRow(row);
const replace = (rawKey: string, original: string) => {
const key = normalizeTemplateKey(rawKey);
const value = values.get(key);
return value === undefined ? original : value;
};
const dollarRendered = pattern.replace(/\\?\$\{([^}]+)\}/g, (match, key) => match.startsWith("\\") ? match.slice(1) : replace(key, match));
return dollarRendered.replace(/\\?\{\{\s*([^}]+?)\s*\}\}/g, (match, key) => match.startsWith("\\") ? match.slice(1) : replace(key, match));
}
function valuesForImportedRow(row: ImportedRecipientRow): Map<string, string> {
const values = new Map<string, string>();
const setValue = (key: string, value: string) => {
const trimmedKey = key.trim();
if (!trimmedKey) return;
values.set(trimmedKey, value);
values.set(`local:${trimmedKey}`, value);
values.set(`local::${trimmedKey}`, value);
};
setValue("id", row.id);
setValue("name", row.name);
setValue("email", row.email);
Object.entries(row.fields).forEach(([key, value]) => setValue(key, String(value ?? "")));
return values;
}
function normalizeTemplateKey(value: string): string {
const key = value.trim();
if (key.startsWith("local::") || key.startsWith("global::")) return key;
if (key.startsWith("local:")) return `local::${key.slice("local:".length)}`;
if (key.startsWith("global:")) return `global::${key.slice("global:".length)}`;
return key;
}
function normalizedPathPrefix(path: string): string {
const trimmed = path.trim();
if (!trimmed || trimmed === "." || trimmed === "/") return "";
return trimmed.replace(/^[\/]+|[\/]+$/g, "");
}

View File

@@ -215,6 +215,12 @@ export function removePlaceholderFromText(text: string, raw: string): string {
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), ""); return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
} }
export function replacePlaceholderInText(text: string, raw: string, replacement: string): string {
if (!text) return text;
const escaped = escapeRegExp(raw.trim());
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), replacement);
}
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> { function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
const policy = new Map<string, boolean>(); const policy = new Map<string, boolean>();
for (const field of asArray(draft?.fields).map(asRecord)) { for (const field of asArray(draft?.fields).map(asRecord)) {

View File

@@ -1,79 +0,0 @@
import type { DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent } from "react";
import { ExplorerTree, type ExplorerTreeNodeContext } from "@govoplan/core-webui";
import type { FolderNode } from "../types";
import { normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
type FileActionTarget = { spaceId: string; folderPath: string };
export function FolderTree({
nodes,
activeSpaceId,
spaceId,
currentFolder,
dropTargetKey = "",
expandedKeys,
onOpen,
onToggle,
onContextMenu,
onDragOverTarget,
onDropOnTarget,
onClearDropState,
onRequestDragExpand,
onDragStartFolder,
onDragEndFolder,
dragDropEnabled = true,
contextMenuEnabled = true,
disabled,
depth = 1
}: {
nodes: FolderNode[];
activeSpaceId: string;
spaceId: string;
currentFolder: string;
dropTargetKey?: string;
expandedKeys: Set<string>;
onOpen: (spaceId: string, path: string) => void;
onToggle: (spaceId: string, path: string) => void;
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
onClearDropState?: () => void;
onRequestDragExpand?: (spaceId: string, path: string) => void;
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
onDragEndFolder?: () => void;
dragDropEnabled?: boolean;
contextMenuEnabled?: boolean;
disabled?: boolean;
depth?: number;
}) {
return (
<ExplorerTree
nodes={nodes}
getNodeId={(node) => treeNodeKey(spaceId, node.path)}
getNodeLabel={(node) => node.name}
getNodeChildren={(node) => node.children}
activeId={activeSpaceId === spaceId ? treeNodeKey(spaceId, currentFolder) : ""}
expandedIds={expandedKeys}
onOpen={(node) => onOpen(spaceId, node.path)}
onToggle={(node) => onToggle(spaceId, node.path)}
disabled={disabled}
depth={depth}
getNodeWrapClassName={(node) => {
const target = { spaceId, folderPath: node.path };
return dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}` ? "is-drop-target" : undefined;
}}
getNodeWrapStyle={(_node, context: ExplorerTreeNodeContext) => ({ paddingLeft: `${Math.min(context.depth * 14, 56)}px` })}
getNodeDraggable={() => dragDropEnabled && !disabled}
onContextMenu={contextMenuEnabled && onContextMenu ? (event, node) => onContextMenu(event, spaceId, node.path) : undefined}
onDragStart={dragDropEnabled && onDragStartFolder ? (event, node) => onDragStartFolder(spaceId, node.path, event) : undefined}
onDragEnd={dragDropEnabled && onDragEndFolder ? () => onDragEndFolder() : undefined}
onDragOver={dragDropEnabled && onDragOverTarget ? (event, node, context) => {
const target = { spaceId, folderPath: node.path };
onDragOverTarget(event, target);
if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path);
} : undefined}
onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined}
onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined}
/>
);
}

View File

@@ -1,87 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { folderAncestorPaths, isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
export function useFileTreeState({
activeSpaceId,
currentFolder,
onOpenFolder
}: {
activeSpaceId: string;
currentFolder: string;
onOpenFolder: (spaceId: string, path: string) => void;
}) {
const [expandedTreeNodes, setExpandedTreeNodes] = useState<Set<string>>(() => new Set());
const dragExpandTimerRef = useRef<number | null>(null);
const dragExpandKeyRef = useRef<string | null>(null);
const suppressTreeAutoExpandKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!activeSpaceId) return;
const ancestors = folderAncestorPaths(currentFolder, { includeSelf: true });
if (ancestors.length === 0) return;
const suppressedKey = suppressTreeAutoExpandKeyRef.current;
suppressTreeAutoExpandKeyRef.current = null;
setExpandedTreeNodes((current) => {
const next = new Set(current);
ancestors.forEach((path) => {
const key = treeNodeKey(activeSpaceId, path);
if (key !== suppressedKey) next.add(key);
});
return next;
});
}, [activeSpaceId, currentFolder]);
function cancelTreeDragExpand() {
if (dragExpandTimerRef.current !== null) {
window.clearTimeout(dragExpandTimerRef.current);
dragExpandTimerRef.current = null;
}
dragExpandKeyRef.current = null;
}
function expandTreeNode(spaceId: string, folderPath: string) {
const key = treeNodeKey(spaceId, folderPath);
setExpandedTreeNodes((current) => {
if (current.has(key)) return current;
const next = new Set(current);
next.add(key);
return next;
});
}
function scheduleTreeDragExpand(spaceId: string, folderPath: string) {
const key = treeNodeKey(spaceId, folderPath);
if (expandedTreeNodes.has(key) || dragExpandKeyRef.current === key) return;
cancelTreeDragExpand();
dragExpandKeyRef.current = key;
dragExpandTimerRef.current = window.setTimeout(() => {
expandTreeNode(spaceId, folderPath);
dragExpandTimerRef.current = null;
dragExpandKeyRef.current = null;
}, 750);
}
function toggleTreeFolder(spaceId: string, folderPath: string) {
const normalizedPath = normalizeFolder(folderPath);
const key = treeNodeKey(spaceId, normalizedPath);
const isExpanded = expandedTreeNodes.has(key);
if (isExpanded && spaceId === activeSpaceId && isPathUnderOrSame(currentFolder, normalizedPath)) {
suppressTreeAutoExpandKeyRef.current = key;
onOpenFolder(spaceId, normalizedPath);
}
setExpandedTreeNodes((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
return {
expandedTreeNodes,
setExpandedTreeNodes,
cancelTreeDragExpand,
scheduleTreeDragExpand,
toggleTreeFolder
};
}

View File

@@ -1,32 +0,0 @@
import type { ManagedFile } from "../../api/files";
export type SortColumn = "name" | "size" | "modified";
export type SortDirection = "asc" | "desc";
export type FolderNode = {
name: string;
path: string;
children: FolderNode[];
fileCount: number;
persisted: boolean;
};
export type FolderEntry = {
kind: "folder";
id: string;
name: string;
path: string;
fileCount: number;
folderCount: number;
totalSize: number;
updatedAt: string;
persisted: boolean;
};
export type FileEntry = {
kind: "file";
id: string;
file: ManagedFile;
};
export type ExplorerEntry = FolderEntry | FileEntry;

View File

@@ -1,214 +0,0 @@
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
import type { FileFolder, ManagedFile } from "../../../api/files";
import type { ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types";
export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] {
const byPath = new Map<string, FolderNode>();
const ensureNode = (path: string, persisted: boolean): FolderNode | null => {
const normalized = normalizeFolder(path);
if (!normalized) return null;
const existing = byPath.get(normalized);
if (existing) {
existing.persisted = existing.persisted || persisted;
return existing;
}
const parts = normalized.split("/");
const node: FolderNode = {
name: parts[parts.length - 1],
path: normalized,
children: [],
fileCount: 0,
persisted
};
byPath.set(normalized, node);
const parentPath = parts.slice(0, -1).join("/");
const parent = ensureNode(parentPath, false);
if (parent) parent.children.push(node);
return node;
};
for (const folder of folders) ensureNode(folder.path, true);
for (const file of files) {
const parts = normalizeFilePath(file.display_path).split("/").filter(Boolean);
for (let index = 0; index < parts.length - 1; index += 1) {
const node = ensureNode(parts.slice(0, index + 1).join("/"), false);
if (node) node.fileCount += 1;
}
}
const roots = Array.from(byPath.values()).filter((node) => !node.path.includes("/"));
sortFolderNodes(roots);
return roots;
}
export function sortFolderNodes(nodes: FolderNode[]) {
nodes.sort((a, b) => a.name.localeCompare(b.name));
for (const node of nodes) sortFolderNodes(node.children);
}
export function treeNodeKey(spaceId: string, folderPath: string): string {
return `${spaceId}:${normalizeFolder(folderPath)}`;
}
export function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] {
const parts = normalizeFolder(folderPath).split("/").filter(Boolean);
const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0);
const result: string[] = [];
for (let index = 1; index <= limit; index += 1) {
result.push(parts.slice(0, index).join("/"));
}
return result;
}
export function isPathUnderOrSame(path: string, root: string): boolean {
const normalizedPath = normalizeFolder(path);
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return true;
return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
}
export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[], currentFolder: string, searchActive: boolean): ExplorerEntry[] {
if (searchActive) return files.map((file) => ({ kind: "file", id: file.id, file }));
const folder = normalizeFolder(currentFolder);
const prefix = folder ? `${folder}/` : "";
const folderMap = new Map<string, FolderEntry>();
const directFiles: FileEntry[] = [];
for (const persisted of folders) {
const path = normalizeFolder(persisted.path);
if (!path.startsWith(prefix) || path === folder) continue;
const relative = prefix ? path.slice(prefix.length) : path;
if (!relative || relative.includes("/")) continue;
folderMap.set(path, {
kind: "folder",
id: `folder:${path}`,
name: relative,
path,
fileCount: 0,
folderCount: 0,
totalSize: 0,
updatedAt: persisted.updated_at,
persisted: true
});
}
for (const file of files) {
const path = normalizeFilePath(file.display_path || file.filename);
if (prefix && !path.startsWith(prefix)) continue;
const relativePath = prefix ? path.slice(prefix.length) : path;
if (!relativePath) continue;
const slashIndex = relativePath.indexOf("/");
if (slashIndex === -1) {
directFiles.push({ kind: "file", id: file.id, file });
continue;
}
const folderName = relativePath.slice(0, slashIndex);
const folderPath = prefix ? `${folder}/${folderName}` : folderName;
const existing = folderMap.get(folderPath);
if (existing) {
existing.totalSize += file.size_bytes;
if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at;
} else {
folderMap.set(folderPath, {
kind: "folder",
id: `folder:${folderPath}`,
name: folderName,
path: folderPath,
fileCount: 0,
folderCount: 0,
totalSize: file.size_bytes,
updatedAt: file.updated_at,
persisted: false
});
}
}
for (const entry of folderMap.values()) {
const counts = directFolderCounts(files, folders, entry.path);
entry.fileCount = counts.files;
entry.folderCount = counts.folders;
}
return [...Array.from(folderMap.values()), ...directFiles];
}
export function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn, direction: SortDirection): ExplorerEntry[] {
const factor = direction === "asc" ? 1 : -1;
return [...entries].sort((a, b) => {
if (column === "name") {
if (a.kind !== b.kind) return a.kind === "folder" ? (direction === "asc" ? -1 : 1) : (direction === "asc" ? 1 : -1);
return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
}
if (column === "size") {
const delta = entrySize(a) - entrySize(b);
if (delta !== 0) return factor * delta;
return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
}
const timeA = entryModifiedTime(a);
const timeB = entryModifiedTime(b);
if (timeA !== timeB) return factor * (timeA - timeB);
return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
});
}
export function entryName(entry: ExplorerEntry): string {
return entry.kind === "folder" ? entry.name : entry.file.filename;
}
export function entrySize(entry: ExplorerEntry): number {
return entry.kind === "folder" ? entry.totalSize : entry.file.size_bytes;
}
export function entryModifiedTime(entry: ExplorerEntry): number {
const raw = entry.kind === "folder" ? entry.updatedAt : entry.file.updated_at;
const parsed = new Date(raw).getTime();
return Number.isNaN(parsed) ? 0 : parsed;
}
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } {
const normalized = normalizeFolder(folderPath);
const prefix = normalized ? `${normalized}/` : "";
const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length;
const childFolders = new Set<string>();
for (const folder of folders) {
const path = normalizeFolder(folder.path);
if (!path.startsWith(prefix) || path === normalized) continue;
const relative = prefix ? path.slice(prefix.length) : path;
if (!relative) continue;
childFolders.add(relative.split("/")[0]);
}
for (const file of files) {
const path = normalizeFilePath(file.display_path);
if (!path.startsWith(prefix)) continue;
const relative = prefix ? path.slice(prefix.length) : path;
if (!relative || !relative.includes("/")) continue;
childFolders.add(relative.split("/")[0]);
}
return { files: directFiles, folders: childFolders.size };
}
export function parentFolderPath(path: string): string {
const parts = normalizeFilePath(path).split("/").filter(Boolean);
return normalizeFolder(parts.slice(0, -1).join("/"));
}
export function normalizeFolder(value: string): string {
return value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/").trim();
}
export function normalizeFilePath(value: string): string {
return value.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/").trim();
}
export function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
const units = ["KB", "MB", "GB", "TB"];
let size = value / 1024;
for (const unit of units) {
if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`;
size /= 1024;
}
return `${size.toFixed(1)} PB`;
}
export function formatDate(value: string): string {
return formatPlatformDateTime(value);
}

View File

@@ -1290,235 +1290,6 @@
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
/* Managed file picker shared by attachment sources and rule editors. */
.managed-file-chooser-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(1080px, calc(100vw - 40px));
height: min(820px, calc(100vh - 40px));
max-height: min(820px, calc(100vh - 40px));
overflow: hidden;
}
.managed-file-chooser-body {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
overflow: hidden;
padding: 0;
}
.managed-file-chooser-layout {
display: grid;
grid-template-columns: minmax(190px, 240px) minmax(0, 1fr);
flex: 1 1 auto;
min-height: 0;
max-height: none;
border: 0;
border-radius: 0;
overflow: hidden;
background: var(--panel);
}
.managed-file-chooser-spaces {
min-width: 0;
padding: 16px;
border-right: 1px solid var(--line);
background: var(--panel-soft);
overflow: auto;
}
.managed-file-chooser-spaces h3 {
margin: 0 0 12px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--muted);
}
.managed-file-space-list {
display: grid;
gap: 7px;
}
.managed-file-space-button,
.managed-file-entry {
width: 100%;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: var(--ink);
text-align: left;
cursor: pointer;
}
.managed-file-space-button {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 9px;
align-items: start;
padding: 9px;
}
.managed-file-space-button span,
.managed-file-entry > span:not(.managed-file-shared-badge) {
display: grid;
gap: 2px;
min-width: 0;
}
.managed-file-space-button small,
.managed-file-entry small {
color: var(--muted);
font-size: 11px;
}
.managed-file-space-button:hover:not(:disabled),
.managed-file-space-button.is-selected {
border-color: var(--line-dark);
background: var(--panel);
}
.managed-file-space-button.is-selected {
border-color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent);
}
.managed-file-space-button:disabled {
cursor: not-allowed;
opacity: .45;
}
.managed-file-chooser-browser {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
}
.managed-file-chooser-toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.managed-file-breadcrumb {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
min-width: 0;
}
.managed-file-breadcrumb > button,
.managed-file-breadcrumb span button {
display: inline-flex;
gap: 5px;
align-items: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--ink);
padding: 5px 6px;
cursor: pointer;
}
.managed-file-breadcrumb button:hover:not(:disabled) {
background: var(--panel-soft);
}
.managed-file-choice-tabs {
display: inline-flex;
border: 1px solid var(--line-dark);
border-radius: 8px;
overflow: hidden;
flex: 0 0 auto;
}
.managed-file-choice-tabs button {
border: 0;
border-right: 1px solid var(--line-dark);
background: var(--panel);
color: var(--muted);
padding: 7px 10px;
cursor: pointer;
}
.managed-file-choice-tabs button:last-child { border-right: 0; }
.managed-file-choice-tabs button.is-active {
background: var(--accent-soft);
color: var(--accent-strong);
font-weight: 700;
}
.managed-pattern-editor {
display: grid;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
}
.managed-pattern-editor label {
display: grid;
gap: 6px;
font-size: 12px;
font-weight: 700;
}
.managed-file-entry-list {
display: grid;
align-content: start;
gap: 4px;
padding: 10px;
min-height: 0;
overflow: auto;
}
.managed-file-entry {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
min-height: 52px;
padding: 8px 10px;
}
.managed-file-entry:hover:not(:disabled) {
border-color: var(--line);
background: var(--panel-soft);
}
.managed-file-entry.is-selected {
border-color: var(--accent);
background: var(--accent-soft);
}
.managed-file-entry:disabled {
color: var(--ink);
opacity: 1;
cursor: default;
}
.managed-file-entry.is-folder:not(:disabled) { cursor: pointer; }
.managed-file-shared-badge {
display: inline-flex;
gap: 4px;
align-items: center;
border: 1px solid var(--success-line, var(--line));
border-radius: 999px;
padding: 3px 7px;
color: var(--success, var(--muted));
font-size: 11px;
white-space: nowrap;
}
.managed-file-empty {
padding: 28px 16px;
text-align: center;
}
.managed-file-chooser-note {
margin: 0;
padding: 10px 14px;
border-top: 1px solid var(--line);
background: var(--panel-soft);
}
@media (max-width: 760px) {
.managed-file-chooser-dialog {
width: calc(100vw - 20px);
height: calc(100vh - 20px);
}
.managed-file-chooser-layout {
grid-template-columns: 1fr;
max-height: calc(100vh - 220px);
}
.managed-file-chooser-spaces {
border-right: 0;
border-bottom: 1px solid var(--line);
max-height: 160px;
}
.managed-file-chooser-toolbar {
align-items: flex-start;
flex-direction: column;
}
}
.attachment-source-path-cell { .attachment-source-path-cell {
display: grid; display: grid;
gap: 5px; gap: 5px;
@@ -1549,72 +1320,6 @@
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
box-shadow: none; box-shadow: none;
} }
.managed-file-chooser-dialog > .dialog-header {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.managed-file-chooser-body > .alert {
margin: 14px 14px 0;
}
.managed-file-chooser-footer {
flex: 0 0 auto;
padding: 12px 18px;
border-top: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06);
}
.managed-file-chooser-spaces.file-tree-panel {
display: flex;
padding: 0;
overflow: hidden;
}
.managed-file-chooser-spaces .file-tree-list {
padding-bottom: 12px;
}
.managed-pattern-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.managed-pattern-results {
min-width: 0;
min-height: 0;
overflow: auto;
}
.managed-pattern-results .file-list-table {
min-width: 680px;
}
.managed-pattern-result-row {
width: 100%;
border-top: 0;
border-right: 0;
border-left: 0;
background: transparent;
color: var(--ink);
text-align: left;
font: inherit;
cursor: pointer;
}
.managed-pattern-result-row .file-list-name > span {
display: grid;
min-width: 0;
}
.managed-pattern-result-row .file-list-name strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.managed-pattern-result-row .file-list-name small {
color: var(--muted);
font-size: 11px;
}
/* Review & Send workflow page. */ /* Review & Send workflow page. */
.review-send-page { .review-send-page {
--review-flow-purple: #9b86c7; --review-flow-purple: #9b86c7;
@@ -2139,6 +1844,14 @@
background: var(--panel); background: var(--panel);
} }
.template-replacement-field {
display: grid;
gap: 6px;
margin-top: 12px;
color: var(--text-strong);
font-weight: 700;
}
.template-expression-description { .template-expression-description {
margin-top: 0; margin-top: 0;
} }
@@ -2236,93 +1949,9 @@
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
} }
/* Managed chooser list uses a stable, sortable four-column layout. */
.managed-file-entry-table {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.managed-file-entry-head,
.managed-file-entry {
grid-template-columns: 22px minmax(0, 1fr) 110px 160px;
}
.managed-file-entry-head {
display: grid;
gap: 9px;
align-items: center;
min-height: 38px;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
color: var(--muted);
font-size: 12px;
font-weight: 750;
}
.managed-file-entry-head button {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 5px;
width: max-content;
max-width: 100%;
border: 0;
background: transparent;
color: inherit;
padding: 5px 0;
font: inherit;
cursor: pointer;
}
.managed-file-entry-head button.is-sorted {
color: var(--text-strong);
}
.managed-file-entry-name {
display: grid;
gap: 2px;
min-width: 0;
}
.managed-file-entry-name strong,
.managed-file-entry-name small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.managed-file-entry-name small {
display: inline-flex;
align-items: center;
gap: 4px;
}
.managed-file-entry-size,
.managed-file-entry-modified {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.managed-file-entry.is-context-only {
opacity: .56;
}
.managed-file-entry.is-parent:disabled {
opacity: .38;
}
@media (max-width: 850px) {
.managed-file-entry-head,
.managed-file-entry {
grid-template-columns: 22px minmax(0, 1fr) 100px;
}
.managed-file-entry-head > :last-child,
.managed-file-entry-modified {
display: none;
}
}
.review-send-page .btn { text-decoration: none; } .review-send-page .btn { text-decoration: none; }
.review-send-controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 16px 0; } .review-send-controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 16px 0; }
.managed-file-chooser-browser.folder-mode {
grid-template-rows: auto minmax(0, 1fr) auto;
}
/* Delivery report additions ------------------------------------------------ */ /* Delivery report additions ------------------------------------------------ */
.dialog-panel-wide { .dialog-panel-wide {
@@ -2537,6 +2166,234 @@
min-width: 0; min-width: 0;
box-shadow: var(--shadow-card, 0 2px 10px rgba(0,0,0,.06)); box-shadow: var(--shadow-card, 0 2px 10px rgba(0,0,0,.06));
} }
.recipient-import-modal {
width: min(1100px, calc(100vw - 32px));
}
.recipient-import-body {
display: grid;
gap: 14px;
}
.recipient-import-steps {
padding: 13px 10px 9px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(247, 246, 244, .96);
box-shadow: 0 8px 24px rgba(48, 49, 53, .10);
overflow-x: auto;
}
.recipient-import-step-track {
display: flex;
align-items: flex-start;
width: max-content;
min-width: max-content;
margin: 0 auto;
}
.recipient-import-step-group {
display: flex;
align-items: flex-start;
}
.recipient-import-step-item {
display: grid;
justify-items: center;
gap: 6px;
min-width: 104px;
padding: 3px 6px;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
text-align: center;
cursor: pointer;
}
.recipient-import-step-item:disabled {
cursor: default;
opacity: .56;
}
.recipient-import-step-item:not(:disabled):hover .recipient-import-step-icon,
.recipient-import-step-item:focus-visible .recipient-import-step-icon {
background: color-mix(in srgb, var(--accent, #5d776c) 14%, #fff);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #5d776c) 12%, transparent);
}
.recipient-import-step-item:focus-visible {
outline: none;
}
.recipient-import-step-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent, #5d776c) 70%, var(--line));
border-radius: 50%;
color: color-mix(in srgb, var(--accent, #5d776c) 82%, var(--text));
background: color-mix(in srgb, var(--accent, #5d776c) 8%, #fff);
font-weight: 700;
transition: background .16s ease, box-shadow .16s ease, color .16s ease;
}
.recipient-import-step-item.is-current .recipient-import-step-icon,
.recipient-import-step-item.is-complete .recipient-import-step-icon {
color: #fff;
background: color-mix(in srgb, var(--accent, #5d776c) 86%, #333);
}
.recipient-import-step-copy {
display: grid;
justify-items: center;
min-width: 0;
}
.recipient-import-step-copy strong {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-strong);
font-size: 12px;
white-space: nowrap;
}
.recipient-import-step-copy small {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 14px;
max-width: 120px;
color: var(--muted);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-import-step-line {
width: 42px;
height: 2px;
margin: 21px 2px 0;
flex: 0 0 auto;
background: color-mix(in srgb, var(--accent, #5d776c) 55%, var(--line));
opacity: .82;
}
.recipient-import-step-panel {
display: grid;
gap: 14px;
min-height: 360px;
}
.recipient-import-upload-grid,
.recipient-import-map-controls {
align-items: start;
}
.recipient-import-textarea {
min-height: 190px;
max-height: 300px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.45;
}
.recipient-import-toggles {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 18px;
min-height: 38px;
}
.recipient-import-summary {
grid-template-columns: repeat(4, minmax(120px, 1fr));
}
.recipient-import-preview-surface {
overflow: auto;
}
.recipient-import-preview-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
}
.recipient-import-preview-table th,
.recipient-import-preview-table td {
border-bottom: 1px solid var(--line);
padding: 9px 10px;
text-align: left;
vertical-align: top;
}
.recipient-import-preview-table th {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.recipient-import-preview-table td {
color: var(--text);
font-size: 13px;
word-break: break-word;
}
.recipient-import-preview-table tr.is-invalid td {
color: var(--danger-text);
}
.recipient-import-raw-table td {
white-space: pre;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.recipient-import-raw-table tr.is-header-row td {
background: color-mix(in srgb, var(--accent, #5d776c) 8%, transparent);
font-weight: 700;
}
.recipient-import-mapping-table select,
.recipient-import-mapping-table input {
width: 100%;
min-width: 160px;
}
.recipient-import-mapping-table td:nth-child(2) {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-import-file-step {
display: grid;
gap: 14px;
}
.recipient-import-file-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
.recipient-import-file-actions > div:first-child {
display: grid;
gap: 4px;
min-width: 0;
}
.recipient-import-unmatched-patterns {
display: grid;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--warning-bg, #fff3cd) 62%, transparent);
}
.recipient-import-unmatched-patterns ul {
display: grid;
gap: 5px;
margin: 0;
padding-left: 18px;
}
.recipient-import-unmatched-patterns code {
overflow-wrap: anywhere;
}
.recipient-import-file-link-table td:nth-child(2) {
overflow-wrap: anywhere;
}
@media (max-width: 760px) {
.recipient-import-file-actions {
display: grid;
}
}
@media (max-width: 760px) {
.recipient-import-summary {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
.recipient-import-step-panel {
min-height: 300px;
}
}
.card-body > .admin-table-surface:only-child { .card-body > .admin-table-surface:only-child {
margin: -22px -24px; margin: -22px -24px;
width: calc(100% + 48px); width: calc(100% + 48px);

View File

@@ -0,0 +1,161 @@
import {
buildImportTableFromRows,
applyRecipientMappingProfile,
buildImportTable,
buildRecipientImportPreview,
createRecipientImportProvenance,
createRecipientMappingProfile,
defaultColumnMappings,
matchRecipientMappingProfiles,
materializeRecipientImport,
parseDelimitedText,
xlsxSheetsFromArrayBuffer,
type RecipientColumnMapping
} from "../src/features/campaigns/utils/bulkImport";
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function mappingFor(mappings: RecipientColumnMapping[], columnIndex: number): RecipientColumnMapping {
return mappings.find((mapping) => mapping.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" };
}
function base64ToArrayBuffer(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
const parsed = parseDelimitedText('email;name;field.note\n"alice@example.org";"Alice; A";"hello"', ";");
assert(parsed[1][1] === "Alice; A", "quoted delimiters are preserved");
const csv = [
"email;name;customer_id;field.contract_no;attachment_pattern;active",
"alice@example.org;Alice;C-1;CN-1;${customer_id}.pdf|archive/*.pdf;yes",
"bad-address;Broken;C-2;CN-2;broken.pdf;yes",
";Missing;C-3;CN-3;;yes"
].join("\n");
const existingFields = [{ name: "customer_id", label: "Customer ID", type: "string", required: false, can_override: true }];
const table = buildImportTable(csv, { delimiter: "auto", headerRows: 1, quoted: true });
const mappings = defaultColumnMappings(table.headers, existingFields);
const preview = buildRecipientImportPreview(table, mappings, {
existingFields,
existingEntries: [{ id: "alice" }],
valueSeparators: ",;|"
});
assert(table.delimiter === ";", "semicolon delimiter is detected");
assert(table.headerRows.length === 1 && table.dataRows.length === 3, "header and data rows are separated");
assert(mappingFor(mappings, 0).kind === "to", "email columns map to To");
assert(mappingFor(mappings, 2).kind === "field" && mappingFor(mappings, 2).fieldName === "customer_id", "existing fields are detected");
assert(mappingFor(mappings, 3).kind === "new_field" && mappingFor(mappings, 3).newFieldName === "contract_no", "explicit new field columns are detected");
const profile = createRecipientMappingProfile({
id: "profile-1",
name: "ERP export",
table,
mappings,
parseOptions: { headerRows: 1, quoted: true },
valueSeparators: ",;|",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z"
});
const exactProfileMatches = matchRecipientMappingProfiles(table, [profile]);
assert(exactProfileMatches[0]?.mode === "ordered" && exactProfileMatches[0].score === 1, "ordered header fingerprints match exactly");
const reorderedTable = buildImportTable(
[
"name;email;active;attachment_pattern;field.contract_no;customer_id",
"Alice;alice@example.org;yes;${customer_id}.pdf;CN-1;C-1"
].join("\n"),
{ delimiter: "auto", headerRows: 1, quoted: true }
);
const reorderedMatches = matchRecipientMappingProfiles(reorderedTable, [profile]);
assert(reorderedMatches[0]?.mode === "unordered", "unordered header fingerprints match reordered exports");
const reorderedMappings = applyRecipientMappingProfile(reorderedTable, profile, existingFields);
assert(mappingFor(reorderedMappings, 0).kind === "name", "reordered profiles apply mappings by header");
assert(mappingFor(reorderedMappings, 1).kind === "to", "email mapping follows the reordered header");
assert(mappingFor(reorderedMappings, 4).kind === "new_field" && mappingFor(reorderedMappings, 4).newFieldName === "contract_no", "new field mappings follow reordered headers");
const similarTable = buildImportTable(
[
"email;name;customer_id;attachment_pattern",
"alice@example.org;Alice;C-1;${customer_id}.pdf"
].join("\n"),
{ delimiter: "auto", headerRows: 1, quoted: true }
);
const similarMatches = matchRecipientMappingProfiles(similarTable, [profile]);
assert(similarMatches[0]?.mode === "similar", "similar profiles are suggested when headers changed");
assert(preview.validCount === 1, "one valid row is detected");
assert(preview.invalidCount === 2, "invalid rows are detected");
assert(preview.patternCount === 3, "all pattern cells are counted before invalid rows are skipped");
assert(preview.fieldNamesToCreate.length === 1 && preview.fieldNamesToCreate[0] === "contract_no", "mapped new fields are created");
assert(preview.rows[0].id === "alice-2", "import ids avoid existing entries");
assert(preview.rows[0].patterns.length === 2, "pattern cells split on configured separators");
const multiTable = buildImportTable('to;name\n"Alice <alice@example.org>|bob@example.org";Team', { delimiter: "auto", headerRows: 1, quoted: true });
const multiPreview = buildRecipientImportPreview(multiTable, defaultColumnMappings(multiTable.headers, []), {
existingFields: [],
valueSeparators: "|"
});
assert(multiPreview.validCount === 1, "multi-address cells can produce a valid recipient");
assert(multiPreview.rows[0].addresses.to.length === 2, "To columns can contain multiple addresses");
assert(multiPreview.rows[0].addresses.to[0].name === "Alice", "display names are parsed from address cells");
const draft = {
fields: [{ name: "customer_id", label: "Customer ID", type: "string", required: false, can_override: true }],
entries: { inline: [{ id: "alice", to: [{ email: "old@example.org" }] }], source: { type: "csv" }, mapping: { id: "id" } }
};
const imported = materializeRecipientImport(draft, preview, { mode: "append", attachmentBasePath: { id: "bp-1", path: "invoices" } });
const entries = asRecord(imported.entries);
const inline = entries.inline as Record<string, unknown>[];
const fields = imported.fields as Record<string, unknown>[];
const importedEntry = inline[1];
const attachments = importedEntry.attachments as Record<string, unknown>[];
assert(inline.length === 2, "append keeps existing entries");
assert(entries.source === undefined && entries.mapping === undefined, "external source metadata is removed for inline imports");
assert(fields.some((field) => field.name === "contract_no"), "new field definition is added");
assert(importedEntry.email === "alice@example.org", "entry email is materialized");
assert(attachments.length === 2, "valid row patterns become attachment rules");
assert(attachments[0].base_path_id === "bp-1" && attachments[0].base_dir === "invoices", "attachment rules use the selected source");
assert(attachments[0].file_filter === "${customer_id}.pdf", "field placeholders remain in imported patterns");
void runXlsxImportAssertions();
async function runXlsxImportAssertions(): Promise<void> {
const workbookBuffer = base64ToArrayBuffer("UEsDBBQAAAAIACQe2lzFLx19AQEAAC4CAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbK2RzU7DMBCE7zyF5SuKnXJACCXpgZ8jcCgPsDibxIr/5HVL+vY4acsBlZ56Wtk7M9/IrtaTNWyHkbR3NV+JkjN0yrfa9TX/3LwWD5xRAteC8Q5rvkfi6+am2uwDEstmRzUfUgqPUpIa0AIJH9DlTeejhZSPsZcB1Ag9yruyvJfKu4QuFWnO4E31jB1sTWIvU74+FIloiLOng3Bm1RxCMFpBynu5c+0fSnEkiOxcNDToQLdZwOVZwrz5H3D0veeXibpF9gExvYHNKjkZ+e3j+OX9KC6HnGnpu04rbL3a2mwRFCJCSwNiskYsU1jQ7tT7An8Rk1zG6spFfvNPPeTy3c0PUEsDBBQAAAAIACQe2lwGWceCsgAAACgBAAALAAAAX3JlbHMvLnJlbHOFz00KwjAQBeC9pwizt6kuRKRpNyJ0K/UAMZ3+0CQTkqjt7c3SiuBymJnv8YpqNpo90YeRrIBdlgNDq6gdbS/g1ly2R2AhSttKTRYFLBigKjfFFbWM6ScMowssITYIGGJ0J86DGtDIkJFDmzYdeSNjGn3PnVST7JHv8/zA/acB5cpkdSvA1+0OWLO4FPzfpq4bFZ5JPQza+CPi6yLJ0vcYBcyav8hPd6IpSyjwsuCrguUbUEsDBBQAAAAIACQe2lyPkDDbwAAAACABAAAPAAAAeGwvd29ya2Jvb2sueG1sjY+7bsMwDEX3foXAvZHToSgM21mKAlmL9gNUiY6FWKRAqq+/L5vHnokv3Mt7ht1PWd0XimamEbabDhxS5JTpMML728v9EzhtgVJYmXCEX1TYTXfDN8vxg/noTE86wtJa7b3XuGAJuuGKZJeZpYRmoxy8VsGQdEFsZfUPXffoS8gEZ4debvHgec4Rnzl+FqR2NhFcQ7P0uuSqMA2nD3qpjkKx1K8Yc80mUaP53++TwYKTPlsj+7QFPw3+KvVXuukPUEsDBBQAAAAIACQe2lyabzx8tQAAACkBAAAaAAAAeGwvX3JlbHMvd29ya2Jvb2sueG1sLnJlbHOFz80KwjAMB/C7T1Fyd9k8iMi6XUTYVeYDlC77YFtbmvqxt7d4EAeCp5CE/P4kL5/zJO7kebBGQpakIMho2wymk3Ctz9sDCA7KNGqyhiQsxFAWm/xCkwrxhvvBsYiIYQl9CO6IyLqnWXFiHZm4aa2fVYit79ApPaqOcJeme/TfBhQrU1SNBF81GYh6cTH4v23bdtB0svo2kwk/IvBh/cg9UYio8h0FCZ8R47tkSVQBixxXHxYvUEsDBBQAAAAIACQe2lz+BM8T8AAAAAgCAAAYAAAAeGwvd29ya3NoZWV0cy9zaGVldDEueG1sdZFRTgMhEIbfPQXh3WW7D8YYlqo1vYB6gAk77RJh2MDE1tvLVrPRZnmDn/mGj0Fvz8GLT0zZRerlpmmlQLJxcHTs5fvb/vZeisxAA/hI2MsvzHJrbvQppo88IrIoDSj3cmSeHpTKdsQAuYkTUjk5xBSAyzYdVZ4SwnCBgldd296pAI6k0ZfsBRiMTvEkUhEpqZ0XTxspuJeOvCN85VRyl41mU25xXis2Ws2Bsr/Acw0gCLhSv6vVHxz6oRlwgsQBif+zqogutt1i21WaWUgeHvEMYfI4T2PNvAbvZnhNvQbsHQHZq9f+GKs/s1bLJ5pvUEsBAhQAFAAAAAgAJB7aXMUvHX0BAQAALgIAABMAAAAAAAAAAAAAAAAAAAAAAFtDb250ZW50X1R5cGVzXS54bWxQSwECFAAUAAAACAAkHtpcBlnHgrIAAAAoAQAACwAAAAAAAAAAAAAAAAAyAQAAX3JlbHMvLnJlbHNQSwECFAAUAAAACAAkHtpcj5Aw28AAAAAgAQAADwAAAAAAAAAAAAAAAAANAgAAeGwvd29ya2Jvb2sueG1sUEsBAhQAFAAAAAgAJB7aXJpvPHy1AAAAKQEAABoAAAAAAAAAAAAAAAAA+gIAAHhsL19yZWxzL3dvcmtib29rLnhtbC5yZWxzUEsBAhQAFAAAAAgAJB7aXP4EzxPwAAAACAIAABgAAAAAAAAAAAAAAAAA5wMAAHhsL3dvcmtzaGVldHMvc2hlZXQxLnhtbFBLBQYAAAAABQAFAEUBAAANBQAAAAA=");
const workbookSheets = await xlsxSheetsFromArrayBuffer(workbookBuffer);
assert(workbookSheets[0]?.name === "Recipients", "xlsx sheets are listed");
const xlsxTable = buildImportTableFromRows(workbookSheets[0].rows, { headerRows: 1 });
const xlsxMappings = defaultColumnMappings(xlsxTable.headers, []);
const xlsxPreview = buildRecipientImportPreview(xlsxTable, xlsxMappings, { existingFields: [], valueSeparators: ",;|" });
assert(xlsxPreview.validCount === 1, "xlsx rows become import preview rows");
assert(mappingFor(xlsxMappings, 2).kind === "new_field" && mappingFor(xlsxMappings, 2).newFieldName === "department", "xlsx headers use the same mapping guesser");
const provenance = createRecipientImportProvenance({
preview: xlsxPreview,
mappings: xlsxMappings,
mode: "replace",
sourceType: "xlsx",
filename: "recipients.xlsx",
sheetName: "Recipients",
headerRows: 1,
valueSeparators: ",;|"
});
const importedWithProvenance = materializeRecipientImport(draft, xlsxPreview, { mode: "replace", provenance });
const provenanceEntries = asRecord(importedWithProvenance.entries);
const imports = provenanceEntries.imports as Record<string, unknown>[];
assert(imports.length === 1, "recipient import provenance is stored with entries");
assert(imports[0].source_type === "xlsx" && imports[0].sheet_name === "Recipients", "xlsx provenance records source details");
assert((imports[0].mapping as Record<string, unknown>[]).length === xlsxTable.headers.length, "provenance stores column mapping");
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"ES2020",
"DOM"
],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": ".import-test-build",
"rootDir": "."
},
"include": [
"tests/import-utils.test.ts",
"src/features/campaigns/utils/bulkImport.ts"
]
}