3 Commits

Author SHA1 Message Date
57f6066bf7 Release v0.1.6 2026-07-07 15:55:37 +02:00
533c4c8fe3 Release v0.1.5 2026-07-07 15:49:06 +02:00
fb6fb67d45 Release v0.1.4 2026-07-02 14:59:52 +02:00
39 changed files with 2132 additions and 1833 deletions

View File

@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/campaign
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/campaign
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/campaign
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/campaign
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/campaign
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

View File

@@ -28,6 +28,19 @@ Files and mail are optional module integrations declared in the campaign manifes
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.
Campaign also provides narrow kernel capabilities so other modules and core
services can cooperate without importing campaign internals:
- `campaigns.access` for campaign share/existence checks
- `campaigns.mailPolicyContext` for campaign-scoped mail policy and owner
context
- `campaigns.policyContext` for retention/policy provenance
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
- `campaigns.retention` for campaign-owned retention cleanup
Keep these capability payloads narrow: stable ids, policy payloads, and task
results only.
## Development
Install through the core environment:
@@ -61,6 +74,10 @@ Frontend package:
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
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. Files and mail WebUI packages remain optional product-composition dependencies supplied by the core host build, not required campaign package dependencies.

View File

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

View File

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

View File

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

View File

@@ -489,6 +489,35 @@ class EntryConfig(StrictModel):
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):
type: SourceType
path: str
@@ -502,6 +531,7 @@ class EntriesConfig(StrictModel):
source: SourceConfig | None = None
mapping: dict[str, str] | None = None
defaults: EntryConfig | None = None
imports: list[ImportProvenance] = Field(default_factory=list)
@model_validator(mode="after")
def inline_or_external(self) -> "EntriesConfig":

View File

@@ -0,0 +1,177 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from sqlalchemy import and_, or_
from govoplan_core.core.campaigns import (
CampaignAccessProvider,
CampaignDeliveryTaskProvider,
CampaignMailPolicyContext,
CampaignMailPolicyContextProvider,
CampaignPolicyContext,
CampaignPolicyContextProvider,
CampaignRetentionProvider,
)
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider):
def get_campaign_mail_policy_context(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
) -> CampaignMailPolicyContext | None:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
return None
return CampaignMailPolicyContext(
id=campaign.id,
tenant_id=campaign.tenant_id,
owner_user_id=campaign.owner_user_id,
owner_group_id=campaign.owner_group_id,
mail_profile_policy=dict(campaign.mail_profile_policy or {}),
)
def set_campaign_mail_profile_policy(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
policy: Mapping[str, object],
) -> Mapping[str, object]:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
raise ValueError("Campaign policy not found")
payload = dict(policy)
campaign.mail_profile_policy = payload
session.add(campaign) # type: ignore[attr-defined]
return payload
def mail_policy_context_capability(context: object) -> CampaignMailPolicyContextService:
return CampaignMailPolicyContextService()
class CampaignAccessService(CampaignAccessProvider):
def campaign_exists(self, session: object, *, tenant_id: str, campaign_id: str) -> bool:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
return bool(campaign and campaign.tenant_id == tenant_id)
def can_read_campaign(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
user_id: str,
group_ids: Iterable[str] = (),
tenant_admin: bool = False,
) -> bool:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
return False
if tenant_admin or campaign.owner_user_id == user_id:
return True
group_id_set = {str(group_id) for group_id in group_ids}
if campaign.owner_group_id and campaign.owner_group_id in group_id_set:
return True
share_clauses = [
and_(
CampaignShare.target_type == "user",
CampaignShare.target_id == user_id,
)
]
if group_id_set:
share_clauses.append(
and_(
CampaignShare.target_type == "group",
CampaignShare.target_id.in_(group_id_set),
)
)
share = (
session.query(CampaignShare) # type: ignore[attr-defined]
.filter(
CampaignShare.tenant_id == tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
or_(*share_clauses),
)
.first()
)
return share is not None
def access_capability(context: object) -> CampaignAccessService:
return CampaignAccessService()
class CampaignPolicyContextService(CampaignPolicyContextProvider):
def get_campaign_policy_context(
self,
session: object,
*,
tenant_id: str | None = None,
campaign_id: str,
) -> CampaignPolicyContext | None:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
if campaign is None or (tenant_id is not None and campaign.tenant_id != tenant_id):
return None
return CampaignPolicyContext(
id=campaign.id,
tenant_id=campaign.tenant_id,
owner_user_id=campaign.owner_user_id,
owner_group_id=campaign.owner_group_id,
settings=dict(campaign.settings or {}),
)
def set_campaign_settings(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
settings: Mapping[str, object],
) -> Mapping[str, object]:
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
if campaign is None or campaign.tenant_id != tenant_id:
raise ValueError("Campaign privacy policy not found")
payload = dict(settings)
campaign.settings = payload
session.add(campaign) # type: ignore[attr-defined]
return payload
def policy_context_capability(context: object) -> CampaignPolicyContextService:
return CampaignPolicyContextService()
class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider):
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
from govoplan_campaign.backend.sending.jobs import send_campaign_job
return send_campaign_job(session, job_id=job_id, enqueue_imap_task=enqueue_imap_task).as_dict() # type: ignore[arg-type]
def append_sent_for_job(self, session: object, *, job_id: str) -> Mapping[str, object]:
from govoplan_campaign.backend.sending.jobs import append_sent_for_job
return append_sent_for_job(session, job_id=job_id).as_dict() # type: ignore[arg-type]
def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService:
return CampaignDeliveryTaskService()
class CampaignRetentionService(CampaignRetentionProvider):
def apply_retention(self, session: object, *, dry_run, now, policy_for_campaign_id):
from govoplan_campaign.backend.retention import apply_campaign_retention
return apply_campaign_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id) # type: ignore[arg-type]
def retention_capability(context: object) -> CampaignRetentionService:
return CampaignRetentionService()

View File

@@ -10,11 +10,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
try:
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership
except (ImportError, ModuleNotFoundError):
pass
def new_uuid() -> str:
return str(uuid.uuid4())
@@ -125,7 +120,7 @@ class Campaign(Base, TimestampMixin):
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
tenant: Mapped[Tenant] = relationship()
tenant: Mapped["Tenant"] = relationship("Tenant")
versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
@@ -144,6 +139,33 @@ class CampaignShare(Base, TimestampMixin):
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("Tenant")
owner: Mapped["User"] = relationship("User")
class CampaignVersion(Base, TimestampMixin):
__tablename__ = "campaign_versions"
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)
@@ -319,7 +341,6 @@ __all__ = [
"CampaignVersion",
"CampaignVersionFlow",
"CampaignVersionWorkflowState",
"Group",
"ImapAppendAttempt",
"IssueSeverity",
"JobBuildStatus",
@@ -328,7 +349,4 @@ __all__ = [
"JobSendStatus",
"JobValidationStatus",
"SendAttempt",
"Tenant",
"User",
"UserGroupMembership",
]

View File

@@ -2,35 +2,28 @@ from __future__ import annotations
from email import policy
from email.message import EmailMessage
from importlib import import_module
from types import ModuleType
from typing import Any
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
from govoplan_core.core.optional import reraise_unless_missing_package
from govoplan_campaign.backend.campaign.loader import load_campaign_json
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
from govoplan_campaign.backend.integrations import files_integration
from govoplan_campaign.backend.integrations import files_integration, mail_integration
class MockCampaignSendError(RuntimeError):
pass
def _mock_mailbox() -> ModuleType | None:
try:
return import_module("govoplan_mail.backend.dev.mock_mailbox")
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_mail")
return None
def _mock_mailbox() -> Any | None:
return mail_integration().mock_mailbox()
def _require_mock_mailbox() -> ModuleType:
def _require_mock_mailbox() -> Any:
mailbox = _mock_mailbox()
if mailbox is None:
raise MockCampaignSendError("Mail module is not available")

View File

@@ -34,7 +34,9 @@ class ImapConfigurationError(RuntimeError):
class ImapAppendError(RuntimeError):
pass
def __init__(self, message: str, *, temporary: bool | None = None) -> None:
super().__init__(message)
self.temporary = temporary
class MailProfileError(OptionalModuleUnavailable):
@@ -208,7 +210,7 @@ class MailCampaignIntegration:
try:
return delegate.append_message_to_sent(*args, **kwargs)
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
raise ImapAppendError(str(exc)) from exc
raise ImapAppendError(str(exc), temporary=getattr(exc, "temporary", None)) from exc
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
raise ImapConfigurationError(str(exc)) from exc
@@ -221,6 +223,11 @@ class MailCampaignIntegration:
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
raise SmtpConfigurationError(str(exc)) from exc
def mock_mailbox(self) -> Any | None:
if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"):
return None
return self._delegate.mock_mailbox()
def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY))

View File

@@ -2,6 +2,14 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_ACCESS,
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_RETENTION,
)
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.db.base import Base
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
@@ -97,24 +105,36 @@ ROLE_TEMPLATES = (
)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_campaign.backend.db.models import Campaign
return {"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count()}
def _campaigns_router(context: ModuleContext):
from govoplan_campaign.backend.runtime import configure_runtime
configure_runtime(registry=context.registry, settings=context.settings)
from fastapi import APIRouter
from govoplan_campaign.backend.router import router
from govoplan_campaign.backend.schema_router import router as schema_router
return router
aggregate = APIRouter()
aggregate.include_router(router)
aggregate.include_router(schema_router)
return aggregate
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.2",
version="0.1.6",
dependencies=("access",),
optional_dependencies=("files", "mail"),
permissions=PERMISSIONS,
route_factory=_campaigns_router,
role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,),
nav_items=(
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20),
NavItem(
@@ -159,7 +179,59 @@ manifest = ModuleManifest(
module_id="campaigns",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
campaign_models.Campaign,
campaign_models.CampaignShare,
campaign_models.RecipientImportMappingProfile,
campaign_models.CampaignVersion,
campaign_models.CampaignJob,
campaign_models.CampaignIssue,
campaign_models.AttachmentBlob,
campaign_models.AttachmentInstance,
campaign_models.SendAttempt,
campaign_models.ImapAppendAttempt,
label="Campaigns",
),
retirement_notes="Destructive retirement drops campaign-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
campaign_models.Campaign,
campaign_models.CampaignShare,
campaign_models.RecipientImportMappingProfile,
campaign_models.CampaignVersion,
campaign_models.CampaignJob,
campaign_models.CampaignIssue,
campaign_models.AttachmentBlob,
campaign_models.AttachmentInstance,
campaign_models.SendAttempt,
campaign_models.ImapAppendAttempt,
label="Campaigns",
),
),
capability_factories={
CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__(
"govoplan_campaign.backend.capabilities",
fromlist=["access_capability"],
).access_capability(context),
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS: lambda context: __import__(
"govoplan_campaign.backend.capabilities",
fromlist=["delivery_tasks_capability"],
).delivery_tasks_capability(context),
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT: lambda context: __import__(
"govoplan_campaign.backend.capabilities",
fromlist=["mail_policy_context_capability"],
).mail_policy_context_capability(context),
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT: lambda context: __import__(
"govoplan_campaign.backend.capabilities",
fromlist=["policy_context_capability"],
).policy_context_capability(context),
CAPABILITY_CAMPAIGNS_RETENTION: lambda context: __import__(
"govoplan_campaign.backend.capabilities",
fromlist=["retention_capability"],
).retention_capability(context),
},
)

View File

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

View File

@@ -0,0 +1,191 @@
from __future__ import annotations
import copy
import hashlib
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
FINAL_VERSION_STATES = {
"completed",
"partially_completed",
"outcome_unknown",
"failed",
"cancelled",
"archived",
}
FINAL_EML_SEND_STATUSES = {
"smtp_accepted",
"sent",
"failed_permanent",
"cancelled",
"outcome_unknown",
}
def _cutoff(days: int | None, *, now: datetime) -> datetime | None:
if days is None:
return None
return now - timedelta(days=days)
def _is_before_cutoff(value: datetime | None, cutoff: datetime | None) -> bool:
if value is None or cutoff is None:
return False
candidate = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
return candidate < cutoff
def _json_sha256(value: Any) -> str:
payload = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _summary_was_redacted(summary: Any) -> bool:
return isinstance(summary, dict) and bool(summary.get("_retention", {}).get("report_detail_redacted"))
def _redacted_report_summary(summary: Any, *, now: datetime) -> tuple[dict[str, Any] | None, bool]:
if not isinstance(summary, dict):
return summary, False
if _summary_was_redacted(summary):
return summary, False
detail_keys = {"messages", "issues", "recent_failures", "jobs"}
if not any(key in summary for key in detail_keys):
return summary, False
next_summary = copy.deepcopy(summary)
for key in detail_keys:
next_summary.pop(key, None)
retention = dict(next_summary.get("_retention") or {})
retention.update({"report_detail_redacted": True, "redacted_at": now.isoformat()})
next_summary["_retention"] = retention
return next_summary, True
def _apply_raw_json_retention(
session: Session,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
) -> dict[str, int]:
result = {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0}
versions = session.query(CampaignVersion).order_by(CampaignVersion.updated_at.asc()).all()
for version in versions:
policy = policy_for_campaign_id(version.campaign_id)
cutoff = _cutoff(0 if not policy.store_raw_campaign_json else policy.raw_campaign_json_retention_days, now=now)
if not _is_before_cutoff(version.updated_at, cutoff):
continue
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
if raw_json.get("_retention", {}).get("raw_json_redacted"):
result["already_redacted"] += 1
continue
if version.workflow_state not in FINAL_VERSION_STATES:
result["skipped_not_final"] += 1
continue
result["eligible"] += 1
if dry_run:
continue
snapshot = version.execution_snapshot if isinstance(version.execution_snapshot, dict) else {}
version.raw_json = {
"version": version.schema_version or "1.0",
"_retention": {
"raw_json_redacted": True,
"redacted_at": now.isoformat(),
"original_sha256": snapshot.get("campaign_json_sha256") or _json_sha256(raw_json),
},
}
session.add(version)
result["redacted"] += 1
return result
def _apply_eml_retention(
session: Session,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
) -> dict[str, int]:
result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0}
jobs = (
session.query(CampaignJob)
.filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None)))
.order_by(CampaignJob.updated_at.asc())
.all()
)
for job in jobs:
policy = policy_for_campaign_id(job.campaign_id)
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
if not _is_before_cutoff(job.updated_at, cutoff):
continue
if job.queue_status in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value} or job.send_status not in FINAL_EML_SEND_STATUSES:
result["skipped_not_final"] += 1
continue
if job.imap_status == JobImapStatus.PENDING.value:
result["skipped_not_final"] += 1
continue
result["eligible"] += 1
if dry_run:
continue
if job.eml_local_path:
path = Path(job.eml_local_path)
if path.exists():
path.unlink()
result["files_deleted"] += 1
else:
result["files_missing"] += 1
job.eml_local_path = None
job.eml_storage_key = None
session.add(job)
result["metadata_cleared"] += 1
return result
def _apply_report_detail_retention(
session: Session,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
) -> dict[str, int]:
result = {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0}
versions = session.query(CampaignVersion).order_by(CampaignVersion.updated_at.asc()).all()
for version in versions:
policy = policy_for_campaign_id(version.campaign_id)
cutoff = _cutoff(policy.stored_report_detail_retention_days, now=now)
if not _is_before_cutoff(version.updated_at, cutoff):
continue
next_validation, validation_changed = _redacted_report_summary(version.validation_summary, now=now)
next_build, build_changed = _redacted_report_summary(version.build_summary, now=now)
if not validation_changed and not build_changed:
if _summary_was_redacted(version.validation_summary) or _summary_was_redacted(version.build_summary):
result["already_redacted"] += 1
continue
result["eligible_versions"] += 1
if dry_run:
continue
version.validation_summary = next_validation
version.build_summary = next_build
session.add(version)
result["summaries_redacted"] += int(validation_changed) + int(build_changed)
return result
def apply_campaign_retention(
session: Session,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
) -> dict[str, dict[str, int]]:
return {
"raw_campaign_json": _apply_raw_json_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
"generated_eml": _apply_eml_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
"stored_report_detail": _apply_report_detail_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
}

View File

@@ -19,6 +19,9 @@ from govoplan_campaign.backend.schemas import (
CampaignShareTargetsResponse,
CampaignShareUpsertRequest,
CampaignOwnerUpdateRequest,
RecipientImportMappingProfileListResponse,
RecipientImportMappingProfilePayload,
RecipientImportMappingProfileResponse,
CampaignJobsResponse,
CampaignJobDetailResponse,
CampaignRetryJobsRequest,
@@ -38,9 +41,11 @@ from govoplan_campaign.backend.schemas import (
ReportEmailRequest,
ReportEmailResponse,
)
from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_scope
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_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.core.runtime import get_registry
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, ImapAppendAttempt, RecipientImportMappingProfile, SendAttempt
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.emailing import CampaignReportEmailError, send_campaign_report_email
@@ -76,6 +81,16 @@ from govoplan_campaign.backend.persistence.versions import (
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _access_directory() -> AccessDirectory:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access directory capability is not configured")
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
if not isinstance(capability, AccessDirectory):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access directory capability is invalid")
return capability
def _get_campaign_for_tenant(session: Session, campaign_id: str, tenant_id: str) -> Campaign:
campaign = session.get(Campaign, campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
@@ -96,12 +111,8 @@ def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) -
def _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]:
return {
row[0]
for row in session.query(UserGroupMembership.group_id)
.filter(UserGroupMembership.tenant_id == principal.tenant_id, UserGroupMembership.user_id == principal.user.id)
.all()
}
del session
return {group.id for group in _access_directory().groups_for_user(principal.user.id, tenant_id=principal.tenant_id)}
@@ -181,6 +192,27 @@ def _require_permission(principal: ApiPrincipal, scope: str) -> None:
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:
if proposed is None:
return False
@@ -346,6 +378,109 @@ def list_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)
def get_campaign(
campaign_id: str,
@@ -1403,21 +1538,12 @@ def list_campaign_share_targets(
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
users = (
session.query(User)
.filter(User.tenant_id == principal.tenant_id, User.is_active.is_(True))
.order_by(User.display_name.asc(), User.email.asc())
.all()
)
groups = (
session.query(Group)
.filter(Group.tenant_id == principal.tenant_id, Group.is_active.is_(True))
.order_by(Group.name.asc())
.all()
)
directory = _access_directory()
users = [user for user in directory.users_for_tenant(principal.tenant_id) if user.status == "active"]
groups = [group for group in directory.groups_for_tenant(principal.tenant_id) if group.status == "active"]
return CampaignShareTargetsResponse(
users=[CampaignShareTargetItem(id=item.id, name=item.display_name or item.email, secondary=item.email) for item in users],
groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=item.slug) for item in groups],
groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=None) for item in groups],
)
@@ -1447,12 +1573,17 @@ def update_campaign_owner(
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
if payload.owner_user_id and payload.owner_group_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Choose either a user owner or a group owner, not both")
directory = _access_directory()
if payload.owner_user_id:
owner = session.query(User).filter(User.id == payload.owner_user_id, User.tenant_id == principal.tenant_id, User.is_active.is_(True)).one_or_none()
owner = directory.get_user(payload.owner_user_id)
if owner is not None and (owner.tenant_id != principal.tenant_id or owner.status != "active"):
owner = None
if owner is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found")
if payload.owner_group_id:
group = session.query(Group).filter(Group.id == payload.owner_group_id, Group.tenant_id == principal.tenant_id, Group.is_active.is_(True)).one_or_none()
group = directory.get_group(payload.owner_group_id)
if group is not None and (group.tenant_id != principal.tenant_id or group.status != "active"):
group = None
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found")
campaign.owner_user_id = payload.owner_user_id
@@ -1470,10 +1601,15 @@ def upsert_campaign_share(
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
directory = _access_directory()
if payload.target_type == "user":
target = session.query(User).filter(User.id == payload.target_id, User.tenant_id == principal.tenant_id, User.is_active.is_(True)).one_or_none()
target = directory.get_user(payload.target_id)
if target is not None and (target.tenant_id != principal.tenant_id or target.status != "active"):
target = None
else:
target = session.query(Group).filter(Group.id == payload.target_id, Group.tenant_id == principal.tenant_id, Group.is_active.is_(True)).one_or_none()
target = directory.get_group(payload.target_id)
if target is not None and (target.tenant_id != principal.tenant_id or target.status != "active"):
target = None
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found")
share = (
@@ -2008,4 +2144,3 @@ def preview_campaign_attachments(
rules=rules,
unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [],
)

View File

@@ -482,6 +482,13 @@
},
"defaults": {
"$ref": "#/$defs/entry"
},
"imports": {
"type": "array",
"items": {
"$ref": "#/$defs/import_provenance"
},
"default": []
}
},
"additionalProperties": false
@@ -505,6 +512,13 @@
},
"defaults": {
"$ref": "#/$defs/entry"
},
"imports": {
"type": "array",
"items": {
"$ref": "#/$defs/import_provenance"
},
"default": []
}
},
"additionalProperties": false
@@ -1027,6 +1041,151 @@
},
"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": {
"type": "object",
"required": [

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_any_scope
from govoplan_campaign.backend.campaign.loader import load_campaign_schema, load_campaign_schema_ui
router = APIRouter(prefix="/schemas", tags=["schemas"])
@router.get("/campaign")
def get_campaign_schema(
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
) -> dict[str, Any]:
"""Return the authoritative campaign JSON Schema used by the backend."""
del principal
return load_campaign_schema()
@router.get("/campaign/ui")
def get_campaign_schema_ui(
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
) -> dict[str, Any]:
"""Return UI metadata paired with the campaign JSON Schema."""
del principal
return load_campaign_schema_ui()

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
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
@@ -186,6 +186,70 @@ class CampaignOwnerUpdateRequest(BaseModel):
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):
jobs: list[dict[str, Any]]
page: int = 1

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.2",
"version": "0.1.6",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,8 +13,11 @@
},
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
},
"dependencies": {
"read-excel-file": "^9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -92,6 +92,38 @@ export type CampaignWorkspaceQuery = {
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 = {
campaign_json?: Record<string, unknown> | null;
current_flow?: string | null;
@@ -298,6 +330,36 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
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> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
}

View File

@@ -1,100 +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 type FileBulkShareResponse = { shares: FileShare[]; shared_count: number };
export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise<FileBulkShareResponse> {
return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", {
method: "POST",
body: JSON.stringify({ file_ids: fileIds, target_type: "campaign", target_id: campaignId, permission: "read" })
});
}
export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
const response = await shareFilesWithCampaign(settings, [fileId], campaignId);
const share = response.shares[0];
if (!share) throw new Error("File share was not created.");
return share;
}
export function resolveFilePatterns(
settings: ApiSettings,
payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
): Promise<PatternResolveResponse> {
return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) });
}

View File

@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { 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 { listFileSpaces, type FileSpace } from "../../api/files";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
@@ -18,7 +17,6 @@ import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
import ManagedFileChooser from "./components/ManagedFileChooser";
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 { insertAfter, moveArrayItem } from "@govoplan/core-webui";
@@ -30,9 +28,13 @@ type IndividualDisableState = { index: number; usageCount: number };
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
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 [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
const version = data.currentVersion;
@@ -71,16 +73,16 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
);
useEffect(() => {
if (!filesModuleInstalled) {
if (!listManagedFileSpaces) {
setFileSpaces([]);
return;
}
let cancelled = false;
void listFileSpaces(settings)
void listManagedFileSpaces(settings)
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
.catch(() => { if (!cancelled) setFileSpaces([]); });
return () => { cancelled = true; };
}, [filesModuleInstalled, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return;
@@ -251,7 +253,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
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}
emptyText="No attachment sources configured."
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
@@ -310,7 +312,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
settings={settings}
campaignId={campaignId}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
filesModuleInstalled={managedFilesAvailable}
previewContext={attachmentPreviewContext}
onChange={(rules) => patch(["attachments", "global"], rules)}
/>
@@ -321,9 +323,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<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>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>
<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>
</>
@@ -357,11 +363,11 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
/>
{pathChooser && filesModuleInstalled && (
{pathChooser && ManagedFileChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={campaignId}
storageScope={campaignId}
mode="folder"
source={basePaths[pathChooser.index]?.source}
basePath={basePaths[pathChooser.index]?.path}
@@ -562,8 +568,8 @@ function uniqueStrings(values: string[]): string[] {
type AttachmentSourceColumnContext = {
locked: boolean;
basePaths: AttachmentBasePath[];
fileSpaces: FileSpace[];
filesModuleInstalled: boolean;
fileSpaces: FilesFileSpace[];
managedFilesAvailable: boolean;
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
setIndividualEligibility: (index: number, checked: boolean) => void;
addBasePath: (afterIndex?: number) => void;
@@ -572,7 +578,7 @@ type AttachmentSourceColumnContext = {
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 [
{ 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 },
{
@@ -586,23 +592,23 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleIns
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={filesModuleInstalled ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
disabled={locked}
readOnly={filesModuleInstalled}
tabIndex={filesModuleInstalled ? -1 : undefined}
readOnly={managedFilesAvailable}
tabIndex={managedFilesAvailable ? -1 : undefined}
placeholder="attachments"
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) => {
if (filesModuleInstalled && !locked && (event.key === "Enter" || event.key === " ")) {
if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setPathChooser({ index });
}
}}
/>
{filesModuleInstalled && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
</div>
),
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
@@ -631,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 matchingSpace = parsedSource
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)

View File

@@ -1,11 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../types";
import {
createRecipientImportMappingProfile,
listRecipientImportMappingProfiles,
updateRecipientImportMappingProfile,
type RecipientImportMappingProfilePayload
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FileDropZone } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui";
@@ -20,20 +27,33 @@ import { getBool } from "./utils/draftEditor";
import { getDraftFields } from "./utils/fieldDefinitions";
import { normalizeAttachmentBasePaths, type AttachmentBasePath } from "./utils/attachments";
import {
applyRecipientMappingProfile,
buildImportTable,
buildImportTableFromRows,
buildRecipientImportPreview,
createRecipientImportProvenance,
createRecipientMappingProfile as buildRecipientMappingProfile,
defaultColumnMappings,
importedRowsNeedAttachmentSource,
matchRecipientMappingProfiles,
materializeRecipientImport,
recipientImportHeaderFingerprints,
xlsxSheetsFromArrayBuffer,
type CsvDelimiter,
type ImportedAddress,
type RecipientColumnKind,
type RecipientColumnMapping,
type RecipientImportMode,
type RecipientImportPreview
type RecipientImportPreview,
type RecipientImportProvenance,
type RecipientImportSourceType,
type RecipientImportSpreadsheetSheet,
type RecipientMappingProfile,
type RecipientMappingProfileMatch
} from "./utils/bulkImport";
import {
bulkLinkFilesToCampaign,
type ImportFileLinkCapability,
renderedImportPatterns,
resolveImportedAttachmentLinks,
type ImportFileLinkResolution
@@ -178,7 +198,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
}
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
if (locked || !draft) return;
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
@@ -194,7 +214,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath, provenance });
const nextDraft = needsAttachmentSource
? {
...importedDraft,
@@ -359,12 +379,12 @@ type RecipientImportDialogProps = {
existingFields: ReturnType<typeof getDraftFields>;
defaultAttachmentBasePath: AttachmentBasePath | null;
onCancel: () => void;
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode) => void;
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) => void;
};
type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files";
const recipientImportSteps: Array<{ id: RecipientImportStepId; label: string }> = [
const allRecipientImportSteps: Array<{ id: RecipientImportStepId; label: string }> = [
{ id: "upload", label: "Upload" },
{ id: "parse", label: "Parse" },
{ id: "map", label: "Map" },
@@ -387,28 +407,74 @@ const recipientColumnKindOptions: Array<{ value: RecipientColumnKind; label: str
{ value: "attachment_pattern", label: "Attachment pattern" }
];
const AUTOMATIC_MAPPING_PROFILE_MIN_SCORE = 0.55;
const RECIPIENT_IMPORT_ENCODINGS = [
{ value: "utf-8", label: "UTF-8" },
{ value: "windows-1252", label: "Windows-1252" },
{ value: "iso-8859-1", label: "ISO-8859-1" },
{ value: "utf-16le", label: "UTF-16 LE" },
{ value: "utf-16be", label: "UTF-16 BE" }
];
export function RecipientImportDialog({ settings, campaignId, existingEntries, existingFields, defaultAttachmentBasePath, onCancel, onImport }: RecipientImportDialogProps) {
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const fileLinkCapability = useMemo<ImportFileLinkCapability | null>(() => {
if (!filesFileExplorer?.listFiles || !filesFileExplorer.resolveFilePatterns || !filesFileExplorer.shareFilesWithTarget) return null;
return {
listFiles: filesFileExplorer.listFiles,
resolveFilePatterns: filesFileExplorer.resolveFilePatterns,
shareFilesWithTarget: filesFileExplorer.shareFilesWithTarget
};
}, [filesFileExplorer]);
const recipientImportSteps = useMemo(
() => fileLinkCapability ? allRecipientImportSteps : allRecipientImportSteps.filter((step) => step.id !== "files"),
[fileLinkCapability]
);
const [activeStep, setActiveStep] = useState<RecipientImportStepId>("upload");
const [csvText, setCsvText] = useState("");
const [sourceType, setSourceType] = useState<RecipientImportSourceType>("text");
const [filename, setFilename] = useState("");
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
const [encoding, setEncoding] = useState("utf-8");
const [workbookSheets, setWorkbookSheets] = useState<RecipientImportSpreadsheetSheet[]>([]);
const [selectedSheetName, setSelectedSheetName] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [delimiter, setDelimiter] = useState<CsvDelimiter>("auto");
const [headerRows, setHeaderRows] = useState(1);
const [quoted, setQuoted] = useState(true);
const [valueSeparators, setValueSeparators] = useState(",;|");
const [mappings, setMappings] = useState<RecipientColumnMapping[]>([]);
const [mappingProfiles, setMappingProfiles] = useState<RecipientMappingProfile[]>([]);
const [mappingManuallyChanged, setMappingManuallyChanged] = useState(false);
const [mappingProfileError, setMappingProfileError] = useState("");
const [mappingProfileNotice, setMappingProfileNotice] = useState("");
const [fileError, setFileError] = useState("");
const [fileLinkResolution, setFileLinkResolution] = useState<ImportFileLinkResolution | null>(null);
const [fileLinkResolving, setFileLinkResolving] = useState(false);
const [fileLinking, setFileLinking] = useState(false);
const [fileLinkError, setFileLinkError] = useState("");
const [fileLinkNotice, setFileLinkNotice] = useState("");
const hasContent = csvText.trim().length > 0;
const selectedSheet = useMemo(
() => workbookSheets.find((sheet) => sheet.name === selectedSheetName) ?? workbookSheets[0] ?? null,
[selectedSheetName, workbookSheets]
);
const hasContent = sourceType === "xlsx"
? Boolean(selectedSheet && selectedSheet.rows.some((row) => row.some((cell) => cell.trim())))
: csvText.trim().length > 0;
const table = useMemo(
() => hasContent ? buildImportTable(csvText, { delimiter, headerRows, quoted }) : null,
[csvText, delimiter, hasContent, headerRows, quoted]
() => {
if (!hasContent) return null;
if (sourceType === "xlsx") return selectedSheet ? buildImportTableFromRows(selectedSheet.rows, { headerRows }) : null;
return buildImportTable(csvText, { delimiter, headerRows, quoted });
},
[csvText, delimiter, hasContent, headerRows, quoted, selectedSheet, sourceType]
);
const tableHeaderKey = table ? `${table.delimiter}:${table.firstDataRowNumber}:${table.headers.join("\u001f")}` : "";
const mappingProfileMatches = useMemo(
() => table ? matchRecipientMappingProfiles(table, mappingProfiles) : [],
[mappingProfiles, table]
);
const parseReady = Boolean(table && table.dataRows.some((row) => row.some((cell) => cell.trim())));
const preview = useMemo(
() => table ? buildRecipientImportPreview(table, mappings, { existingFields, existingEntries, valueSeparators }) : null,
@@ -417,33 +483,93 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
const activeStepIndex = recipientImportSteps.findIndex((step) => step.id === activeStep);
const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null;
const previousStep = recipientImportSteps[activeStepIndex - 1]?.id ?? null;
const isLastStep = activeStepIndex === recipientImportSteps.length - 1;
const mappedColumnCount = mappings.filter((mapping) => mapping.kind !== "ignore").length;
const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0;
const renderedPatterns = useMemo(() => preview ? renderedImportPatterns(preview) : [], [preview]);
useEffect(() => {
let cancelled = false;
setMappingProfileError("");
void listRecipientImportMappingProfiles(settings)
.then((profiles) => {
if (!cancelled) setMappingProfiles(profiles);
})
.catch((err) => {
if (!cancelled) setMappingProfileError(err instanceof Error ? err.message : String(err));
});
return () => { cancelled = true; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!fileBuffer || sourceType !== "csv") return;
try {
setCsvText(decodeImportText(fileBuffer, encoding));
setFileError("");
} catch (err) {
setCsvText("");
setFileError(err instanceof Error ? err.message : String(err));
}
}, [encoding, fileBuffer, sourceType]);
useEffect(() => {
setMappingManuallyChanged(false);
}, [tableHeaderKey]);
useEffect(() => {
if (!table) {
setMappings([]);
setMappingProfileNotice("");
return;
}
if (mappingManuallyChanged) return;
const automaticMatch = mappingProfileMatches.find(isAutomaticMappingProfileMatch);
if (automaticMatch) {
setMappings(applyRecipientMappingProfile(table, automaticMatch.profile, existingFields));
setValueSeparators(automaticMatch.profile.valueSeparators || ",;|");
setMappingProfileNotice(mappingProfileMatchNotice(automaticMatch));
return;
}
setMappings(defaultColumnMappings(table.headers, existingFields));
}, [existingFields, tableHeaderKey]);
setMappingProfileNotice("");
}, [existingFields, mappingManuallyChanged, mappingProfileMatches, table, tableHeaderKey]);
useEffect(() => {
if (!hasContent) setActiveStep("upload");
}, [hasContent]);
useEffect(() => {
if (activeStep === "files") void refreshFileLinks();
if (activeStep === "files" && !fileLinkCapability) setActiveStep("preview");
}, [activeStep, fileLinkCapability]);
useEffect(() => {
if (activeStep === "files" && fileLinkCapability) void refreshFileLinks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, renderedPatterns.length]);
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, fileLinkCapability, renderedPatterns.length]);
async function readFile(file: File | undefined) {
if (!file) return;
setFilename(file.name);
setFileError("");
setWorkbookSheets([]);
setSelectedSheetName("");
try {
setCsvText(await file.text());
const buffer = await file.arrayBuffer();
if (isXlsxFile(file)) {
const sheets = await xlsxSheetsFromArrayBuffer(buffer);
if (sheets.length === 0) {
throw new Error("Workbook contains no readable sheets.");
}
setSourceType("xlsx");
setFileBuffer(null);
setCsvText("");
setWorkbookSheets(sheets);
setSelectedSheetName(sheets[0]?.name ?? "");
} else {
setSourceType("csv");
setFileBuffer(buffer);
setCsvText(decodeImportText(buffer, encoding));
}
setActiveStep("parse");
} catch (err) {
setFileError(err instanceof Error ? err.message : String(err));
@@ -454,6 +580,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
if (stepId === "upload") return true;
if (stepId === "parse") return hasContent;
if (stepId === "map") return parseReady;
if (stepId === "files" && !fileLinkCapability) return false;
return parseReady && mappings.length > 0;
}
@@ -475,6 +602,11 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
async function refreshFileLinks() {
setFileLinkNotice("");
if (!fileLinkCapability) {
setFileLinkResolution(null);
setFileLinkError("");
return;
}
if (!preview || preview.patternCount === 0) {
setFileLinkResolution(null);
setFileLinkError("");
@@ -488,7 +620,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
setFileLinkResolving(true);
setFileLinkError("");
try {
setFileLinkResolution(await resolveImportedAttachmentLinks(settings, campaignId, defaultAttachmentBasePath, preview));
setFileLinkResolution(await resolveImportedAttachmentLinks(fileLinkCapability, settings, campaignId, defaultAttachmentBasePath, preview));
} catch (err) {
setFileLinkResolution(null);
setFileLinkError(err instanceof Error ? err.message : String(err));
@@ -498,12 +630,12 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
}
async function linkImportedFiles() {
if (!fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
if (!fileLinkCapability || !fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
setFileLinking(true);
setFileLinkError("");
setFileLinkNotice("");
try {
const linkedCount = await bulkLinkFilesToCampaign(settings, campaignId, fileLinkResolution.linkableFiles);
const linkedCount = await bulkLinkFilesToCampaign(fileLinkCapability, settings, campaignId, fileLinkResolution.linkableFiles);
await refreshFileLinks();
setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`);
} catch (err) {
@@ -513,9 +645,60 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
}
}
function rememberCurrentMappingProfile() {
if (!table || mappings.length === 0) return;
const tableSnapshot = table;
const mappingsSnapshot = mappings;
const fallbackProfiles = mappingProfiles;
const filenameSnapshot = filename;
const parseOptions = { headerRows, quoted };
const valueSeparatorsSnapshot = valueSeparators;
void (async () => {
try {
const latestProfiles = await listRecipientImportMappingProfiles(settings).catch(() => fallbackProfiles);
const reusableProfile = findReusableMappingProfile(tableSnapshot, latestProfiles);
const draftProfile = buildRecipientMappingProfile({
id: reusableProfile?.id,
name: reusableProfile?.name ?? defaultMappingProfileName(filenameSnapshot, tableSnapshot),
table: tableSnapshot,
mappings: mappingsSnapshot,
parseOptions,
valueSeparators: valueSeparatorsSnapshot,
createdAt: reusableProfile?.createdAt,
updatedAt: new Date().toISOString()
});
const payload = mappingProfilePayload(draftProfile);
await (reusableProfile
? updateRecipientImportMappingProfile(settings, reusableProfile.id, payload)
: createRecipientImportMappingProfile(settings, payload));
} catch {
// Import acceptance must not be blocked by passive profile learning.
}
})();
}
function confirmRecipientImport(nextPreview: RecipientImportPreview) {
rememberCurrentMappingProfile();
const provenance = createRecipientImportProvenance({
preview: nextPreview,
mappings,
mode,
sourceType,
filename,
sheetName: sourceType === "xlsx" ? selectedSheet?.name ?? null : null,
encoding: sourceType === "xlsx" ? null : encoding,
delimiter: sourceType === "xlsx" ? null : formatDelimiter(nextPreview.table.delimiter),
headerRows,
quoted: sourceType === "xlsx" ? null : quoted,
valueSeparators
});
onImport(nextPreview, mode, provenance);
}
function replaceColumnMapping(nextMapping: RecipientColumnMapping) {
const columnCount = table?.headers.length ?? 0;
setMappingManuallyChanged(true);
setMappings((current) => {
const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping]));
byColumn.set(nextMapping.columnIndex, nextMapping);
@@ -537,11 +720,11 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
<div className="campaign-header-grid recipient-import-upload-grid">
<FormField label="Recipient file">
<FileDropZone
accept=".csv,.tsv,text/csv,text/tab-separated-values,text/plain"
accept=".csv,.tsv,.txt,.xlsx,text/csv,text/tab-separated-values,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
multiple={false}
label="Drop recipient file here"
actionLabel="or click to select CSV"
note={filename || "CSV, TSV or text"}
actionLabel="or click to select file"
note={filename || "CSV, TSV, text or XLSX"}
onFiles={(files) => readFile(files[0])}
/>
</FormField>
@@ -553,21 +736,38 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
</FormField>
</div>
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
<FormField label={filename ? `Raw content: ${filename}` : "Raw content"}>
<textarea
className="json-editor recipient-import-textarea"
value={csvText}
onChange={(event) => {
setCsvText(event.target.value);
if (!event.target.value.trim()) setFilename("");
}}
spellCheck={false}
/>
</FormField>
{sourceType === "xlsx" ? (
<DismissibleAlert tone="info" compact dismissible={false}>
Workbook loaded: {workbookSheets.length} sheet{workbookSheets.length === 1 ? "" : "s"}.
</DismissibleAlert>
) : (
<FormField label={filename ? `Raw content: ${filename}` : "Raw content"}>
<textarea
className="json-editor recipient-import-textarea"
value={csvText}
onChange={(event) => {
setFileBuffer(null);
setSourceType(filename ? "csv" : "text");
setCsvText(event.target.value);
if (!event.target.value.trim()) setFilename("");
}}
spellCheck={false}
/>
</FormField>
)}
</>
) : activeStep === "parse" ? (
<>
<div className="campaign-header-grid">
{sourceType === "xlsx" && (
<FormField label="Sheet">
<select value={selectedSheet?.name ?? ""} onChange={(event) => setSelectedSheetName(event.target.value)}>
{workbookSheets.map((sheet) => (
<option key={sheet.name} value={sheet.name}>{sheet.name}</option>
))}
</select>
</FormField>
)}
<FormField label="Header rows">
<input
type="number"
@@ -577,22 +777,33 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
/>
</FormField>
<FormField label="Separator">
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
<option value="auto">Auto</option>
<option value=",">Comma</option>
<option value=";">Semicolon</option>
<option value={"\t"}>Tab</option>
</select>
</FormField>
<div className="campaign-header-toggle recipient-import-toggles">
<ToggleSwitch label="Quoted contents" checked={quoted} onChange={setQuoted} />
</div>
{sourceType !== "xlsx" && (
<>
<FormField label="Encoding">
<select value={encoding} onChange={(event) => setEncoding(event.target.value)}>
{RECIPIENT_IMPORT_ENCODINGS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</FormField>
<FormField label="Separator">
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
<option value="auto">Auto</option>
<option value=",">Comma</option>
<option value=";">Semicolon</option>
<option value={"\t"}>Tab</option>
</select>
</FormField>
<div className="campaign-header-toggle recipient-import-toggles">
<ToggleSwitch label="Quoted contents" checked={quoted} onChange={setQuoted} />
</div>
</>
)}
</div>
{table && (
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Separator</dt><dd>{formatDelimiter(table.delimiter)}</dd></div>
<div><dt>{sourceType === "xlsx" ? "Sheet" : "Separator"}</dt><dd>{sourceType === "xlsx" ? selectedSheet?.name ?? "Workbook" : formatDelimiter(table.delimiter)}</dd></div>
<div><dt>Header rows</dt><dd>{table.headerRows.length}</dd></div>
<div><dt>Data rows</dt><dd>{table.dataRows.length}</dd></div>
<div><dt>Columns</dt><dd>{table.headers.length}</dd></div>
@@ -603,9 +814,17 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
</>
) : activeStep === "map" ? (
<>
{mappingProfileError && <DismissibleAlert tone="warning" compact resetKey={mappingProfileError}>{mappingProfileError}</DismissibleAlert>}
{mappingProfileNotice && <DismissibleAlert tone="info" compact resetKey={mappingProfileNotice}>{mappingProfileNotice}</DismissibleAlert>}
<div className="campaign-header-grid recipient-import-map-controls">
<FormField label="Multi-value separators">
<input value={valueSeparators} onChange={(event) => setValueSeparators(event.target.value)} />
<input
value={valueSeparators}
onChange={(event) => {
setMappingManuallyChanged(true);
setValueSeparators(event.target.value);
}}
/>
</FormField>
<FormField label="Mode">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
@@ -729,10 +948,10 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
<>
<Button onClick={onCancel} disabled={fileLinking || fileLinkResolving}>Cancel</Button>
{previousStep && <Button onClick={() => setActiveStep(previousStep)} disabled={fileLinking || fileLinkResolving}>Back</Button>}
{activeStep !== "files" ? (
{!isLastStep ? (
<Button variant="primary" disabled={!nextStep || !canOpenStep(nextStep)} onClick={goNext}>Next</Button>
) : (
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && onImport(preview, mode)}>Import valid rows</Button>
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && confirmRecipientImport(preview)}>Import valid rows</Button>
)}
</>
)}
@@ -895,6 +1114,65 @@ function formatDelimiter(delimiter: "," | ";" | "\t"): string {
return "Comma";
}
function isXlsxFile(file: File): boolean {
const name = file.name.toLowerCase();
return name.endsWith(".xlsx") || file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
function decodeImportText(buffer: ArrayBuffer, encoding: string): string {
try {
return new TextDecoder(encoding).decode(buffer).replace(/^\uFEFF/, "");
} catch (err) {
throw new Error(`Could not decode file as ${encoding}: ${err instanceof Error ? err.message : String(err)}`);
}
}
function mappingProfilePayload(profile: RecipientMappingProfile): RecipientImportMappingProfilePayload {
return {
name: profile.name,
columnCount: profile.columnCount,
headers: profile.headers,
normalizedHeaders: profile.normalizedHeaders,
orderedHeaderFingerprint: profile.orderedHeaderFingerprint,
unorderedHeaderFingerprint: profile.unorderedHeaderFingerprint,
delimiter: profile.delimiter,
headerRows: profile.headerRows,
quoted: profile.quoted,
valueSeparators: profile.valueSeparators,
mappings: profile.mappings
};
}
function defaultMappingProfileName(filename: string, table: { headers: string[] }): string {
const trimmedFilename = filename.trim().replace(/\.[^.]+$/, "");
if (trimmedFilename) return trimmedFilename;
const headerLabel = table.headers.slice(0, 3).map((header) => header.trim()).filter(Boolean).join(", ");
return headerLabel ? `Mapping: ${headerLabel}` : "Recipient import mapping";
}
function formatMappingProfileMatch(match: RecipientMappingProfileMatch): string {
if (match.mode === "ordered") return "exact";
if (match.mode === "unordered") return "same headers";
return `${Math.round(match.score * 100)}%`;
}
function isAutomaticMappingProfileMatch(match: RecipientMappingProfileMatch): boolean {
return match.mode === "ordered" || match.mode === "unordered" || match.score >= AUTOMATIC_MAPPING_PROFILE_MIN_SCORE;
}
function findReusableMappingProfile(table: { headers: string[] }, profiles: RecipientMappingProfile[]): RecipientMappingProfile | null {
const fingerprints = recipientImportHeaderFingerprints(table.headers);
return profiles.find((profile) => profile.orderedHeaderFingerprint === fingerprints.orderedHeaderFingerprint)
?? profiles.find((profile) => profile.unorderedHeaderFingerprint === fingerprints.unorderedHeaderFingerprint)
?? null;
}
function mappingProfileMatchNotice(match: RecipientMappingProfileMatch): string {
if (match.mode === "ordered") return `Using mapping from previous import: ${match.profile.name}.`;
if (match.mode === "unordered") return `Using mapping from previous import: ${match.profile.name}; matched by header names.`;
return `Using mapping derived from previous import: ${match.profile.name} (${formatMappingProfileMatch(match)} match).`;
}
function sampleColumnValue(rows: string[][], columnIndex: number): string {
return rows.map((row) => String(row[columnIndex] ?? "").trim()).find(Boolean) ?? "";
}

View File

@@ -3,13 +3,14 @@ import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types";
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 { ToggleSwitch } from "@govoplan/core-webui";
import { getBool, getText } from "../utils/draftEditor";
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 { asRecord } from "../utils/campaignView";
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
@@ -160,6 +161,9 @@ export function AttachmentRulesDataGrid({
onChange
}: AttachmentRulesTableProps) {
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>) {
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
@@ -183,7 +187,7 @@ export function AttachmentRulesDataGrid({
}
function openFileChooser(ruleIndex: number) {
if (!filesModuleInstalled) return;
if (!managedFilesAvailable) return;
if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex);
return;
@@ -200,7 +204,7 @@ export function AttachmentRulesDataGrid({
setFileChooser({ ruleIndex, basePath });
}
function selectAttachment(selection: ManagedAttachmentSelection) {
function selectAttachment(selection: FilesManagedAttachmentSelection) {
if (!fileChooser) return;
const currentRule = rules[fileChooser.ruleIndex] ?? {};
patchRule(fileChooser.ruleIndex, {
@@ -219,24 +223,26 @@ export function AttachmentRulesDataGrid({
<DataGrid
id={id}
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)}
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" />}
className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/>
{fileChooser && filesModuleInstalled && (
{fileChooser && ManagedFileChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={campaignId}
storageScope={campaignId}
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
mode="attachment"
source={fileChooser.basePath?.source}
basePath={fileChooser.basePath?.path ?? "."}
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
previewContext={previewContext}
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment}
/>

View File

@@ -1,788 +0,0 @@
import { memo, useCallback, useEffect, useMemo, useRef, 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,
shareFilesWithCampaign,
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";
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
type ManagedFileChooserMode = "folder" | "attachment";
type AttachmentChoiceMode = "file" | "pattern";
type RememberedChooserState = {
spaceId?: string;
folder?: string;
pattern?: string;
};
type ChooserExplorerEntry = ReturnType<typeof buildExplorerEntries>[number];
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;
previewContext?: Record<string, string>;
onClose: () => void;
onSelectFolder?: (selection: ManagedFolderSelection) => void;
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
};
export default function ManagedFileChooser({
open,
settings,
campaignId,
mode,
source,
basePath = ".",
initialPattern = "",
rememberKey = mode,
previewContext,
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 patternRef = useRef("");
const rememberDraftTimerRef = useRef<number | null>(null);
const [patternHasText, setPatternHasText] = useState(false);
const [exactSelectionPattern, setExactSelectionPattern] = 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 openFolder = useCallback((path: string) => {
const normalized = normalizeFolder(path);
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
setCurrentFolder(normalized);
setPatternMatches(null);
}, [effectiveRoot, mode]);
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
activeSpaceId: selectedSpaceId,
currentFolder,
onOpenFolder: (_spaceId, path) => openFolder(path)
});
const toggleSort = useCallback((column: SortColumn) => {
if (sortColumn === column) {
setSortDirection((current) => current === "asc" ? "desc" : "asc");
return;
}
setSortColumn(column);
setSortDirection(column === "name" ? "asc" : "desc");
}, [sortColumn]);
const applyExactFile = useCallback((file: ManagedFile) => {
const exactPattern = relativePath(file.display_path, effectiveRoot);
patternRef.current = exactPattern;
setPattern(exactPattern);
setPatternHasText(true);
setExactSelectionPattern(exactPattern);
setPatternMatches([file]);
setPendingExactFile(null);
}, [effectiveRoot]);
const scheduleRememberedState = useCallback((patternValue = patternRef.current) => {
if (!open || !selectedSpaceId) return;
if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current);
rememberDraftTimerRef.current = window.setTimeout(() => {
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern: patternValue });
rememberDraftTimerRef.current = null;
}, 350);
}, [currentFolder, open, selectedSpaceId, storageKey]);
useEffect(() => () => {
if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current);
}, []);
const requestExactFile = useCallback((file: ManagedFile) => {
const exactPattern = relativePath(file.display_path, effectiveRoot);
const currentPattern = patternRef.current.trim();
if (currentPattern && currentPattern !== exactPattern) {
setPendingExactFile(file);
return;
}
applyExactFile(file);
}, [applyExactFile, effectiveRoot]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
const initialPatternValue = remembered.pattern ?? initialPattern.trim();
patternRef.current = initialPatternValue;
setPattern(initialPatternValue);
setPatternHasText(Boolean(initialPatternValue.trim()));
setExactSelectionPattern("");
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(() => {
scheduleRememberedState();
}, [currentFolder, pattern, scheduleRememberedState]);
function updatePatternInput(value: string) {
patternRef.current = value;
setPatternHasText((current) => {
const next = Boolean(value.trim());
return current === next ? current : next;
});
setExactSelectionPattern("");
if (patternMatches !== null) setPatternMatches(null);
scheduleRememberedState(value);
}
async function previewPattern(): Promise<ManagedFile[]> {
if (!selectedSpace) return [];
const trimmed = patternRef.current.trim();
if (!trimmed) {
setPatternMatches([]);
setPattern("");
setPatternHasText(false);
return [];
}
const previewPatternValue = renderChooserPattern(trimmed, previewContext);
if (!previewPatternValue) {
setPatternMatches([]);
setPattern(trimmed);
return [];
}
setPattern(trimmed);
setPatternHasText(true);
setResolving(true);
setError("");
try {
const response = await resolveFilePatterns(settings, {
patterns: [previewPatternValue],
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);
}
}
async function shareMatches(matches: ManagedFile[]) {
const fileIds = matches
.filter((file) => !file.shares?.some((share) => (
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
)))
.map((file) => file.id);
const uniqueIds = [...new Set(fileIds)];
if (uniqueIds.length > 0) await shareFilesWithCampaign(settings, uniqueIds, 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 = patternRef.current.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 || !patternHasText))}
>
{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" && (
<ManagedPatternEditor
value={pattern}
effectiveRoot={effectiveRoot}
resolving={resolving}
matchCount={patternMatches?.length ?? null}
previewContext={previewContext}
onDraftChange={updatePatternInput}
onPreview={() => void previewPattern()}
onBackToFolder={() => setPatternMatches(null)}
/>
)}
{patternMatches !== null && mode === "attachment" ? (
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
) : (
<ChooserFileBrowser
mode={mode}
loading={loading}
currentFolder={currentFolder}
effectiveRoot={effectiveRoot}
sortedEntries={sortedEntries}
sortColumn={sortColumn}
sortDirection={sortDirection}
campaignId={campaignId}
selectedExactPattern={exactSelectionPattern}
onOpenFolder={openFolder}
onToggleSort={toggleSort}
onRequestExactFile={requestExactFile}
/>
)}
{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 "${patternRef.current.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 ManagedPatternEditor({
value,
effectiveRoot,
resolving,
matchCount,
previewContext,
onDraftChange,
onPreview,
onBackToFolder
}: {
value: string;
effectiveRoot: string;
resolving: boolean;
matchCount: number | null;
previewContext?: Record<string, string>;
onDraftChange: (value: string) => void;
onPreview: () => void;
onBackToFolder: () => void;
}) {
const [draft, setDraft] = useState(value);
const renderedPattern = useMemo(() => renderChooserPattern(draft, previewContext), [draft, previewContext]);
const patternIsRendered = Boolean(draft.trim() && renderedPattern && renderedPattern !== draft.trim());
useEffect(() => {
setDraft(value);
}, [value]);
function updateDraft(next: string) {
setDraft(next);
onDraftChange(next);
}
return (
<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={draft}
onChange={(event) => updateDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") onPreview(); }}
placeholder="folder/file.pdf or **/*.pdf"
/>
<Button onClick={onPreview} disabled={resolving || !draft.trim()}>
<Search size={15} aria-hidden="true" /> {resolving ? "Checking..." : "Preview"}
</Button>
</div>
</label>
{matchCount !== null && (
<div className="managed-pattern-summary">
<span>{matchCount} current file{matchCount === 1 ? "" : "s"} match.</span>
<Button variant="ghost" onClick={onBackToFolder}>Back to folder</Button>
</div>
)}
{patternIsRendered && <small className="managed-pattern-rendered">Preview resolves to <code>{renderedPattern}</code>.</small>}
</div>
);
}
const ChooserFileBrowser = memo(function ChooserFileBrowser({
mode,
loading,
currentFolder,
effectiveRoot,
sortedEntries,
sortColumn,
sortDirection,
campaignId,
selectedExactPattern,
onOpenFolder,
onToggleSort,
onRequestExactFile
}: {
mode: ManagedFileChooserMode;
loading: boolean;
currentFolder: string;
effectiveRoot: string;
sortedEntries: ChooserExplorerEntry[];
sortColumn: SortColumn;
sortDirection: SortDirection;
campaignId: string;
selectedExactPattern: string;
onOpenFolder: (path: string) => void;
onToggleSort: (column: SortColumn) => void;
onRequestExactFile: (file: ManagedFile) => void;
}) {
return (
<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={onToggleSort} />
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
</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={() => onOpenFolder(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={() => onOpenFolder(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" && selectedExactPattern === 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" ? onRequestExactFile(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>
);
});
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 renderChooserPattern(pattern: string, previewContext?: Record<string, string>): string {
const trimmed = pattern.trim();
if (!trimmed || !previewContext) return trimmed;
return renderTemplatePreviewText(trimmed, previewContext, false).trim();
}
function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
}
function readRememberedState(key: string): RememberedChooserState {
if (typeof window === "undefined") return {};
try {
const parsed = JSON.parse(window.localStorage.getItem(key) || "{}") as RememberedChooserState;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function writeRememberedState(key: string, state: RememberedChooserState) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(state));
} catch {
// Storage is optional; chooser behavior remains functional without it.
}
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}

View File

@@ -1,4 +1,3 @@
import type { FileSpace } from "../../../api/files";
import { asArray, asRecord, isRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
@@ -98,9 +97,14 @@ export type ManagedAttachmentSource = {
ownerId: string;
};
type ManagedAttachmentSourceSpace = {
owner_type: "user" | "group";
owner_id: string;
};
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}`;
}

View File

@@ -1,4 +1,9 @@
type ImportRecord = Record<string, unknown>;
type XlsxWorkbookSheet = {
sheet: string;
data: unknown[][];
};
type XlsxWorkbookReader = (input: ArrayBuffer) => Promise<XlsxWorkbookSheet[]>;
export type ImportFieldDefinition = {
name: string;
@@ -23,13 +28,15 @@ export type CsvParseOptions = {
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;
newFieldName?: string;
fieldName?: string | null;
newFieldName?: string | null;
};
export type RecipientImportTable = {
@@ -41,6 +48,11 @@ export type RecipientImportTable = {
firstDataRowNumber: number;
};
export type RecipientImportSpreadsheetSheet = {
name: string;
rows: string[][];
};
export type ImportedAddress = {
email: string;
name?: string;
@@ -73,15 +85,84 @@ export type RecipientImportPreview = {
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"]);
@@ -92,13 +173,21 @@ 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,
delimiter: options.delimiter ?? ",",
headers,
headerRows,
rows,
@@ -107,6 +196,24 @@ export function buildImportTable(text: string, options: CsvParseOptions): Recipi
};
}
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) => {
@@ -128,6 +235,95 @@ export function defaultColumnMappings(headers: string[], existingFields: ImportF
});
}
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[],
@@ -240,11 +436,13 @@ export function materializeRecipientImport(
): 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;
@@ -255,6 +453,52 @@ export function materializeRecipientImport(
};
}
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);
}
@@ -360,6 +604,12 @@ function headerForColumn(headerRows: string[][], columnIndex: number): string {
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]) : "";
@@ -369,6 +619,93 @@ 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"

View File

@@ -1,5 +1,4 @@
import type { ApiSettings } from "../../../types";
import { listFiles, resolveFilePatterns, shareFilesWithCampaign, type ManagedFile } from "../../../api/files";
import type { ApiSettings, FilesFileExplorerUiCapability, FilesManagedFile } from "@govoplan/core-webui";
import { parseManagedAttachmentSource, type AttachmentBasePath } from "./attachments";
import type { ImportedRecipientRow, RecipientImportPreview } from "./bulkImport";
@@ -10,17 +9,20 @@ export type RenderedImportPattern = {
renderedPattern: string;
};
export type ImportFileLinkCapability = Pick<Required<FilesFileExplorerUiCapability>, "listFiles" | "resolveFilePatterns" | "shareFilesWithTarget">;
export type ImportFileLinkResolution = {
basePath: AttachmentBasePath;
patterns: RenderedImportPattern[];
matchedPatterns: { pattern: RenderedImportPattern; matches: ManagedFile[] }[];
matchedPatterns: { pattern: RenderedImportPattern; matches: FilesManagedFile[] }[];
unmatchedPatterns: RenderedImportPattern[];
files: ManagedFile[];
linkedFiles: ManagedFile[];
linkableFiles: ManagedFile[];
files: FilesManagedFile[];
linkedFiles: FilesManagedFile[];
linkableFiles: FilesManagedFile[];
};
export async function resolveImportedAttachmentLinks(
filesCapability: ImportFileLinkCapability,
settings: ApiSettings,
campaignId: string,
basePath: AttachmentBasePath,
@@ -38,14 +40,14 @@ export async function resolveImportedAttachmentLinks(
const root = normalizedPathPrefix(basePath.path);
const [resolved, visibleFiles] = await Promise.all([
resolveFilePatterns(settings, {
filesCapability.resolveFilePatterns(settings, {
patterns: patterns.map((item) => item.renderedPattern),
owner_type: parsedSource.ownerType,
owner_id: parsedSource.ownerId,
path_prefix: root || undefined,
include_unmatched: false
}),
listFiles(settings, {
filesCapability.listFiles(settings, {
owner_type: parsedSource.ownerType,
owner_id: parsedSource.ownerId,
path_prefix: root || undefined
@@ -53,7 +55,7 @@ export async function resolveImportedAttachmentLinks(
]);
const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file]));
const fileById = new Map<string, ManagedFile>();
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);
@@ -75,10 +77,10 @@ export async function resolveImportedAttachmentLinks(
};
}
export async function bulkLinkFilesToCampaign(settings: ApiSettings, campaignId: string, files: ManagedFile[]): Promise<number> {
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 shareFilesWithCampaign(settings, uniqueIds, campaignId);
const response = await filesCapability.shareFilesWithTarget(settings, uniqueIds, { type: "campaign", id: campaignId, label: "campaign" });
return response.shared_count;
}
@@ -97,7 +99,7 @@ export function renderedImportPatterns(preview: RecipientImportPreview): Rendere
return [...byKey.values()];
}
export function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
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));
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1290,235 +1290,6 @@
font-weight: 700;
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 {
display: grid;
gap: 5px;
@@ -1549,77 +1320,6 @@
border-bottom-left-radius: 0;
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;
}
.managed-pattern-rendered {
display: block;
color: var(--muted);
}
/* Review & Send workflow page. */
.review-send-page {
--review-flow-purple: #9b86c7;
@@ -2249,93 +1949,9 @@
max-width: 100%;
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-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 ------------------------------------------------ */
.dialog-panel-wide {

View File

@@ -1,9 +1,15 @@
import {
buildImportTableFromRows,
applyRecipientMappingProfile,
buildImportTable,
buildRecipientImportPreview,
createRecipientImportProvenance,
createRecipientMappingProfile,
defaultColumnMappings,
matchRecipientMappingProfiles,
materializeRecipientImport,
parseDelimitedText,
xlsxSheetsFromArrayBuffer,
type RecipientColumnMapping
} from "../src/features/campaigns/utils/bulkImport";
@@ -19,6 +25,15 @@ function mappingFor(mappings: RecipientColumnMapping[], columnIndex: number): Re
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");
@@ -42,6 +57,44 @@ assert(table.headerRows.length === 1 && table.dataRows.length === 3, "header and
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");
@@ -76,3 +129,33 @@ 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");
}