Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb6fb67d45 | |||
| ab2343aa88 |
@@ -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.
|
||||||
|
|||||||
77
docs/CAMPAIGN_DELIVERY_RUNBOOK.md
Normal file
77
docs/CAMPAIGN_DELIVERY_RUNBOOK.md
Normal 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.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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"),)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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 [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) });
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,28 +1352,26 @@ 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>
|
||||||
<div><dt>SMTP status</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
|
<div><dt>SMTP status</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
|
||||||
<div><dt>IMAP status</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
|
<div><dt>IMAP status</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
|
||||||
{job.last_error ? <div><dt>Last error</dt><dd>{String(job.last_error)}</dd></div> : null}
|
{job.last_error ? <div><dt>Last error</dt><dd>{String(job.last_error)}</dd></div> : null}
|
||||||
</dl>
|
</dl>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,32 +85,33 @@ 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={(
|
||||||
<AttachmentRulesTable
|
<>
|
||||||
rules={draftRules}
|
|
||||||
settings={settings}
|
|
||||||
campaignId={campaignId}
|
|
||||||
disabled={disabled}
|
|
||||||
emptyText={emptyText}
|
|
||||||
basePaths={basePaths}
|
|
||||||
zipConfig={zipConfig}
|
|
||||||
filesModuleInstalled={filesModuleInstalled}
|
|
||||||
activeChooserRuleIndex={null}
|
|
||||||
onChange={setDraftRules}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<footer className="modal-footer">
|
|
||||||
<Button onClick={cancelOverlay}>Cancel</Button>
|
<Button onClick={cancelOverlay}>Cancel</Button>
|
||||||
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
|
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
|
||||||
</footer>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>,
|
>
|
||||||
|
<AttachmentRulesTable
|
||||||
|
rules={draftRules}
|
||||||
|
settings={settings}
|
||||||
|
campaignId={campaignId}
|
||||||
|
disabled={disabled}
|
||||||
|
emptyText={emptyText}
|
||||||
|
basePaths={basePaths}
|
||||||
|
zipConfig={zipConfig}
|
||||||
|
filesModuleInstalled={filesModuleInstalled}
|
||||||
|
previewContext={previewContext}
|
||||||
|
activeChooserRuleIndex={null}
|
||||||
|
onChange={setDraftRules}
|
||||||
|
/>
|
||||||
|
</Dialog>,
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ?? "")}
|
||||||
/>
|
/>
|
||||||
</>,
|
</>,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
797
webui/src/features/campaigns/utils/bulkImport.ts
Normal file
797
webui/src/features/campaigns/utils/bulkImport.ts
Normal 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());
|
||||||
|
}
|
||||||
145
webui/src/features/campaigns/utils/fileLinking.ts
Normal file
145
webui/src/features/campaigns/utils/fileLinking.ts
Normal 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, "");
|
||||||
|
}
|
||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
|||||||
161
webui/tests/import-utils.test.ts
Normal file
161
webui/tests/import-utils.test.ts
Normal 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");
|
||||||
|
}
|
||||||
23
webui/tsconfig.import-tests.json
Normal file
23
webui/tsconfig.import-tests.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user