14 Commits
v0.1.2 ... main

104 changed files with 15991 additions and 6502 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:

16
.gitignore vendored
View File

@@ -331,3 +331,19 @@ cython_debug/
# GovOPlaN WebUI test output
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# GovOPlaN shared ignore rules from govoplan-core
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
*.db
# GovOPlaN local runtime state
runtime/
webui/.module-test-build/
webui/.component-test-build/

View File

@@ -26,8 +26,8 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
For combined checks, run:
```bash
cd /mnt/DATA/git/govoplan-core
./scripts/check-focused.sh
cd /mnt/DATA/git/govoplan
tools/checks/check-focused.sh
```
## Working Rules

View File

@@ -1,5 +1,9 @@
# govoplan-campaign
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Campaign is the campaign authoring, validation, review, sending-control, and reporting module. It bundles backend campaign APIs with the campaign WebUI package.
## Ownership
@@ -11,9 +15,12 @@ This repository owns:
- campaign/version/job/issue/send-attempt/append-attempt models and migrations
- campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths
- WebUI package `@govoplan/campaign-webui`
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, `/address-book`, and `/templates`
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, and `/templates`
Core owns auth, tenants, RBAC evaluation, database/session primitives, CSRF/API helpers, shell layout, and route rendering. Files and mail own their respective storage and transport capabilities.
Core owns the auth facade, RBAC/capability contracts, database/session
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
optional platform module for tenant administration and tenant resolver behavior.
Files and mail own their respective storage and transport capabilities.
## Dependencies
@@ -28,6 +35,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 +81,15 @@ 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.
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
- [Recipient and address boundary](docs/RECIPIENT_ADDRESS_BOUNDARY.md) defines the split between campaign-local recipients and future reusable address management.
- [Example campaigns and release checklist](docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md) defines the maintained example scenarios and release gates.
- [Campaign examples](examples/README.md) is the credential-free scenario catalogue that release fixtures must follow.
- [SMTP/IMAP test bed](dev/mail-testbed/README.md) provides the GreenMail Docker Compose setup and transport smoke for dedicated non-production delivery tests.
## 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,11 @@
GOVOPLAN_MAIL_TEST_LOCAL_PART=campaign-test
GOVOPLAN_MAIL_TEST_DOMAIN=govoplan.test
GOVOPLAN_MAIL_TEST_USER=campaign-test@govoplan.test
GOVOPLAN_MAIL_TEST_PASSWORD=campaign-test-password
GOVOPLAN_MAIL_TEST_RECIPIENT=campaign-test@govoplan.test
GOVOPLAN_MAIL_TEST_FROM=campaign-test@govoplan.test
GOVOPLAN_MAIL_TEST_SMTP_PORT=3025
GOVOPLAN_MAIL_TEST_IMAP_PORT=3143
GOVOPLAN_MAIL_TEST_SENT_FOLDER=Sent
GOVOPLAN_MAIL_TEST_ZIP_PASSWORD=zip-test-password
GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS=45

View File

@@ -0,0 +1,69 @@
# Campaign SMTP/IMAP Test Bed
This test bed provides dedicated non-production SMTP/IMAP infrastructure for
campaign delivery checks. It uses GreenMail and must never be configured with
production recipients or credentials.
## Start
```bash
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
cp .env.example .env
docker compose --env-file .env up -d
```
Default endpoints:
- SMTP: `127.0.0.1:3025`, plain transport
- IMAP: `127.0.0.1:3143`, plain transport
- GreenMail API/UI endpoint: `127.0.0.1:38080`
## Smoke Test
Run the transport smoke from the core virtual environment after installing the
campaign and mail modules:
```bash
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
set -a
. ./.env
set +a
/mnt/DATA/git/govoplan-core/.venv/bin/python run_transport_smoke.py
```
The smoke sends and appends three messages:
- no attachment
- one normal attachment
- one password-protected AES ZIP attachment
It verifies SMTP authentication, IMAP authentication, folder listing, delivery
to `INBOX`, and append-to-Sent behavior.
If the smoke is started immediately after `docker compose up -d`, GreenMail may
bind the SMTP/IMAP ports before the services are fully ready. The smoke retries
login and folder setup for `GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS`.
## Use With A Campaign
Use the same settings in a campaign mail profile:
- SMTP host `127.0.0.1`, port `3025`, security `plain`
- IMAP host `127.0.0.1`, port `3143`, security `plain`
- username and password from `.env`
- append folder from `GOVOPLAN_MAIL_TEST_SENT_FOLDER`
When changing the mailbox, keep `GOVOPLAN_MAIL_TEST_USER` equal to
`${GOVOPLAN_MAIL_TEST_LOCAL_PART}@${GOVOPLAN_MAIL_TEST_DOMAIN}` because the
compose file creates the GreenMail account from local part, password, and
domain while login uses the full email address.
Then run the controlled sending checklist from
`docs/CAMPAIGN_DELIVERY_RUNBOOK.md` for no attachment, one attachment, and
password-protected ZIP campaign variants.
## Stop
```bash
docker compose --env-file .env down -v
```

View File

@@ -0,0 +1,17 @@
services:
greenmail:
image: greenmail/standalone:2.1.9
container_name: govoplan-campaign-mail-testbed
restart: unless-stopped
environment:
GREENMAIL_OPTS: >-
-Dgreenmail.setup.test.all
-Dgreenmail.hostname=0.0.0.0
-Dgreenmail.auth.disabled=false
-Dgreenmail.users=${GOVOPLAN_MAIL_TEST_LOCAL_PART:-campaign-test}:${GOVOPLAN_MAIL_TEST_PASSWORD:-campaign-test-password}@${GOVOPLAN_MAIL_TEST_DOMAIN:-govoplan.test}
-Dgreenmail.users.login=email
-Dgreenmail.verbose
ports:
- "${GOVOPLAN_MAIL_TEST_SMTP_PORT:-3025}:3025"
- "${GOVOPLAN_MAIL_TEST_IMAP_PORT:-3143}:3143"
- "127.0.0.1:38080:8080"

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
from __future__ import annotations
import imaplib
import mimetypes
import os
import tempfile
import time
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
from pathlib import Path
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
from govoplan_mail.backend.sending.imap import append_message_to_sent, list_imap_folders, list_imap_messages, test_imap_login
from govoplan_mail.backend.sending.smtp import send_email_message, test_smtp_login
from govoplan_campaign.backend.services.zip_service import create_zip_archive
def _env(name: str, default: str) -> str:
return os.environ.get(name, default).strip() or default
SMTP_HOST = _env("GOVOPLAN_MAIL_TEST_SMTP_HOST", "127.0.0.1")
IMAP_HOST = _env("GOVOPLAN_MAIL_TEST_IMAP_HOST", "127.0.0.1")
SMTP_PORT = int(_env("GOVOPLAN_MAIL_TEST_SMTP_PORT", "3025"))
IMAP_PORT = int(_env("GOVOPLAN_MAIL_TEST_IMAP_PORT", "3143"))
USERNAME = _env("GOVOPLAN_MAIL_TEST_USER", "campaign-test@govoplan.test")
PASSWORD = _env("GOVOPLAN_MAIL_TEST_PASSWORD", "campaign-test-password")
SENDER = _env("GOVOPLAN_MAIL_TEST_FROM", USERNAME)
RECIPIENT = _env("GOVOPLAN_MAIL_TEST_RECIPIENT", USERNAME)
SENT_FOLDER = _env("GOVOPLAN_MAIL_TEST_SENT_FOLDER", "Sent")
ZIP_PASSWORD = _env("GOVOPLAN_MAIL_TEST_ZIP_PASSWORD", "zip-test-password")
SERVICE_READY_TIMEOUT_SECONDS = int(_env("GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS", "45"))
def _smtp_config() -> SmtpConfig:
return SmtpConfig(
host=SMTP_HOST,
port=SMTP_PORT,
security=TransportSecurity.PLAIN,
username=USERNAME,
password=PASSWORD,
timeout_seconds=10,
)
def _imap_config() -> ImapConfig:
return ImapConfig(
host=IMAP_HOST,
port=IMAP_PORT,
security=TransportSecurity.PLAIN,
username=USERNAME,
password=PASSWORD,
sent_folder=SENT_FOLDER,
timeout_seconds=10,
)
def _message(subject: str, body: str, attachments: list[Path] | None = None) -> EmailMessage:
msg = EmailMessage()
msg["From"] = SENDER
msg["To"] = RECIPIENT
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=False)
msg["Message-ID"] = make_msgid(domain="govoplan.test")
msg.set_content(body)
for attachment in attachments or []:
content_type = mimetypes.guess_type(attachment.name)[0] or "application/octet-stream"
maintype, subtype = content_type.split("/", 1)
msg.add_attachment(attachment.read_bytes(), maintype=maintype, subtype=subtype, filename=attachment.name)
return msg
def _ensure_folder(config: ImapConfig, folder: str) -> None:
client = imaplib.IMAP4(host=config.host or "", port=config.port or 143, timeout=config.timeout_seconds)
try:
if config.username and config.password:
client.login(config.username, config.password)
typ, data = client.create(folder)
if typ not in {"OK", "NO"}:
raise RuntimeError(f"Could not create IMAP folder {folder!r}: {data!r}")
finally:
try:
client.logout()
except Exception:
pass
def _wait_for_delivery(config: ImapConfig, *, before_count: int, expected_increment: int) -> None:
deadline = time.monotonic() + 15
last_count = before_count
while time.monotonic() < deadline:
result = list_imap_messages(imap_config=config, folder="INBOX", limit=10)
last_count = result.total_count
if last_count >= before_count + expected_increment:
return
time.sleep(0.5)
raise RuntimeError(
f"Expected at least {expected_increment} new INBOX message(s), "
f"but count moved from {before_count} to {last_count}."
)
def _wait_for_service(label: str, check):
deadline = time.monotonic() + SERVICE_READY_TIMEOUT_SECONDS
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
return check()
except Exception as exc: # GreenMail can bind before SMTP/IMAP is ready.
last_error = exc
time.sleep(1)
raise RuntimeError(f"{label} did not become ready within {SERVICE_READY_TIMEOUT_SECONDS}s: {last_error}") from last_error
def main() -> int:
smtp = _smtp_config()
imap = _imap_config()
smtp_login = _wait_for_service("SMTP", lambda: test_smtp_login(smtp_config=smtp))
imap_login = _wait_for_service("IMAP", lambda: test_imap_login(imap_config=imap))
print(f"SMTP login OK: {smtp_login.host}:{smtp_login.port} authenticated={smtp_login.authenticated}")
print(f"IMAP login OK: {imap_login.host}:{imap_login.port} authenticated={imap_login.authenticated}")
_wait_for_service("IMAP folder creation", lambda: _ensure_folder(imap, SENT_FOLDER))
folders = _wait_for_service("IMAP folder listing", lambda: list_imap_folders(imap_config=imap))
print("IMAP folders: " + ", ".join(folder.name for folder in folders.folders))
before_inbox = list_imap_messages(imap_config=imap, folder="INBOX", limit=10).total_count
with tempfile.TemporaryDirectory(prefix="govoplan-campaign-mail-test-") as tmp:
root = Path(tmp)
attachment = root / "single-attachment.txt"
attachment.write_text("GovOPlaN single attachment smoke test\n", encoding="utf-8")
zip_source = root / "zip-source.txt"
zip_source.write_text("GovOPlaN password-protected ZIP smoke test\n", encoding="utf-8")
zip_path = create_zip_archive(root / "password-protected.zip", [zip_source], ZIP_PASSWORD)
messages = [
_message("[GovOPlaN test] no attachment", "No attachment SMTP/IMAP smoke."),
_message("[GovOPlaN test] one attachment", "One attachment SMTP/IMAP smoke.", [attachment]),
_message("[GovOPlaN test] password ZIP", "Password-protected ZIP SMTP/IMAP smoke.", [zip_path]),
]
for message in messages:
result = send_email_message(
message,
smtp_config=smtp,
envelope_from=SENDER,
envelope_recipients=[RECIPIENT],
)
print(f"SMTP accepted {result.accepted_count} recipient(s): {message['Subject']}")
append = append_message_to_sent(bytes(message), imap_config=imap, folder=SENT_FOLDER)
print(f"IMAP appended {append.bytes_appended} byte(s) to {append.folder}: {message['Subject']}")
_wait_for_delivery(imap, before_count=before_inbox, expected_increment=3)
sent_count = list_imap_messages(imap_config=imap, folder=SENT_FOLDER, limit=10).total_count
if sent_count < 3:
raise RuntimeError(f"Expected at least 3 messages in {SENT_FOLDER!r}, found {sent_count}.")
print("Transport smoke OK: no attachment, one attachment, and password-protected ZIP messages verified.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,98 @@
# 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.
- Start the repository test bed in `dev/mail-testbed/` when a local
production-like SMTP/IMAP server is sufficient.
- 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.
## Deliverability Preflight
Before the first live send for a sender domain or mail-server profile:
- Confirm the selected SMTP identity matches the visible From/envelope sender
policy.
- Confirm SPF, DKIM, and DMARC are handled by the sending infrastructure or
documented as out of scope for the selected test environment.
- Confirm rate limits are explicitly set for the expected provider and recipient
volume.
- Confirm bounce/reply/notification addresses are monitored by an operational
mailbox or intentionally disabled.
- Confirm large attachments and password-protected ZIPs are acceptable for the
recipient systems.
- Confirm owner transfer or policy changes force profile reselection and
revalidation before live delivery.
## 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 CSV export should include message id, resolved envelope headers,
attachment evidence, EML reference/checksum, latest SMTP response/error, and
latest IMAP folder/error before the campaign is considered operationally
closed.

View File

@@ -0,0 +1,76 @@
# Example Campaigns And Release Checklist
This document defines the campaign examples and release gates that should be
kept working before a GovOPlaN Campaign release is tagged.
## Example Campaign Set
Maintain fixtures or guided examples for these scenarios. The canonical
scenario catalogue lives in `examples/README.md`; committed fixture files should
be added under `examples/` only when they validate against the current campaign
schema and are safe to run in non-production environments.
- simple announcement with one active recipient and no attachments
- multi-recipient message with To, CC, BCC, Reply-To, bounce, and disposition
notification fields
- campaign with global attachments and recipient-specific attachment rules
- campaign with password-protected ZIP attachments
- campaign using a reusable mail profile from the mail module
- campaign using inline SMTP/IMAP settings where policy allows campaign-local
settings
- campaign with validation warnings that may be sent only after explicit review
- campaign with blocked recipients or attachment errors that must not be sent
- mock delivery campaign that captures SMTP and IMAP append messages in the mail
development mailbox
- real non-production delivery campaign against the GreenMail test bed
## Fixture Rules
- Examples must not contain production recipient data or production credentials.
- Attachment examples should use deterministic small files and checksums.
- Secret values must be represented through saved-credential placeholders or
secret references.
- Examples that require optional modules must declare the required modules and
capabilities in their README or fixture metadata.
- Examples must stay valid when files or mail modules are physically absent,
with optional behavior disabled instead of import failures.
## Release Gates
Before tagging a campaign release:
- Review `examples/README.md` and update the scenario catalogue when a release
adds or removes delivery behavior.
- Run core module permutation tests with campaign installed both with and
without files/mail.
- Validate and build each maintained example campaign.
- Run the mock delivery example when the mail development mailbox capability is
enabled.
- Run the GreenMail SMTP/IMAP smoke for a non-production real delivery path.
- Confirm reusable mail profile selection is revalidated after campaign owner
transfer.
- Confirm inline SMTP/IMAP settings are hidden or blocked when policy disables
campaign-local mail settings.
- Confirm delivery reports include SMTP outcome, IMAP append outcome, latest
error, generated EML reference, and attachment evidence.
- Confirm retries cannot resend messages already accepted by SMTP unless an
explicit reconciliation path allows it.
## Ownership Transfer Check
Reusable user/group mail profiles are owner-context-sensitive. When campaign
ownership changes, the editable current version must require profile reselection
and validation before live delivery. A locked delivery-final version should not
be silently rewritten.
## Delivery Checklist
Use the Review & Send preflight panel and the delivery runbook together:
1. Validate and resolve all blocking policy/data issues.
2. Build exact messages.
3. Review warnings, generated recipients, body content, and attachment evidence.
4. Run mock delivery if available for the release channel.
5. Test SMTP and IMAP settings against non-production infrastructure.
6. Send only after queue, rate limit, and append-to-Sent behavior are understood.
7. Reconcile failed, unknown, or pending jobs from the report/audit surfaces.

View File

@@ -0,0 +1,79 @@
# Recipient And Address Management Boundary
Campaigns own campaign-local recipient entries because sending and reporting
need a frozen recipient snapshot. Long-lived address management is a separate
domain owned by `govoplan-addresses`.
## `govoplan-campaign` Owns
- campaign-local recipient entries
- campaign-local recipient import mapping and validation
- message addressing for a concrete campaign version
- send/build/report evidence for the exact recipients used
- campaign-local exclusions, warnings, and review status
- recipient-specific attachment and template evidence
Campaign data is immutable once a version is built for sending. Later address
book changes must not rewrite historical campaign evidence.
## `govoplan-addresses` Owns
- Adrema-style address management
- reusable person, organization, household, and postal-address records
- reusable email address lists and segments
- postal-letter recipient views
- consent, legal-basis, and communication-preference metadata
- deduplication and merge workflows
- import/export of reusable address directories
- address quality checks and change history
The addresses module provides the UI/module boundary for this domain and should
grow stable DTOs and capabilities that campaigns, mail, forms, reporting,
portal, and postbox modules can consume without direct imports.
## Current Integration Contract
The campaign module asks the platform registry for address capabilities and
keeps working when they are absent. It must not import `govoplan-addresses`
ORM models, services, or WebUI components.
When `addresses.lookup` is available, campaign recipient address fields use it
for autocomplete. The campaign page may warm a small suggestion cache with an
empty query and then query by typed text as the user edits sender, reply-to,
global recipient, or per-recipient address fields.
When `addresses.recipient_source` is available, campaign offers a separate
address-book/list import flow. This is for importing reusable recipient sources
into the campaign recipient table; normal one-off address entry still happens
inside the address fields while typing.
The capability should return snapshots, not live ORM objects:
- selected source id and display label
- normalized recipient rows
- provenance fields for source, segment, legal basis, and import time
- update markers so campaigns can show whether a draft is based on stale source
data
Campaign stores the resolved snapshot in the campaign version. It may keep a
reference to the address source for traceability, but the built campaign remains
auditable even if the address source changes later.
If the source revision changes after import, campaign shows a stale-source
warning and lets the user reopen the import dialog with that source preselected.
The user still chooses append or replace; campaign should not silently rewrite
recipient rows.
## Non-Goals For Campaign
Campaign should not become the global address book. It should not own:
- deduplication across campaigns
- consent lifecycle
- master-data merge policy
- address-directory permissions beyond campaign use
- postal address normalization
- reusable segmentation rules
Those belong in `govoplan-addresses` or a dedicated records/identity module when
the domain needs stronger governance.

View File

@@ -0,0 +1,87 @@
# Recipient Import Guide
Recipient import lets campaign authors turn spreadsheet-like source data into
campaign-local recipient entries. It is intentionally campaign-local:
reusable address books and Adrema-style address management belong in the
`govoplan-addresses` module.
## Supported Inputs
The current importer is designed for tabular data with a header row. CSV and
spreadsheet-derived tables should be normalized before import so the campaign UI
sees:
- column headers
- row values
- source filename and sheet name when available
- a stable ordered and unordered header fingerprint
Avoid importing production secrets or credentials as recipient fields. Recipient
custom fields may be used in templates and reports, so they should be treated as
campaign data.
## Mapping Fields
Common headers are detected automatically:
- `email`, `e_mail`, `mail`, `to`, `to_email`, `recipient`,
`recipient_email`
- `name`, `full_name`, `recipient_name`, `to_name`
- `id`, `entry_id`, `recipient_id`
Authors can map columns to address fields:
- `from`
- `to`
- `cc`
- `bcc`
- `reply_to`
Rows without a valid `to` address should remain visible with validation issues
instead of disappearing silently. Authors should be able to fix the source file,
adjust the mapping, or exclude the row before building the campaign.
## Mapping Profiles
Mapping profiles save a known column layout so repeated imports can reuse the
same mapping. The importer stores ordered and unordered header fingerprints so a
profile can distinguish exact column order from equivalent column sets.
Administrators should curate shared profiles only for stable recurring sources.
Campaign-local profiles are acceptable for one-off work and experiments.
## User Workflow
1. Open the campaign recipient/data import screen.
2. Upload or paste a tabular source.
3. Review detected headers and preview rows.
4. Pick or adjust a mapping profile.
5. Confirm validation issues, exclusions, and generated recipient ids.
6. Import into the campaign draft.
7. Build messages and review recipient-specific evidence before sending.
## Admin Workflow
Administrators should:
- define naming conventions for recurring mapping profiles
- verify that imported fields have a lawful processing basis for the campaign
- keep reusable address-directory ownership out of campaigns because
`govoplan-addresses` owns that domain
- use campaign reports and audit evidence to trace which source produced which
recipient entries
- remove obsolete shared profiles when an upstream source layout changes
## Evidence Expectations
The campaign should preserve enough evidence to explain a send:
- source filename and sheet name where available
- header fingerprints
- mapping profile id/name when used
- row number or source id
- validation status and exclusion reason
- final recipient addresses used for the built message
The evidence should be available in reports without requiring the original
source file to be reprocessed.

45
examples/README.md Normal file
View File

@@ -0,0 +1,45 @@
# GovOPlaN Campaign Examples
These examples are the maintained scenario catalogue for campaign release
checks. They are intentionally small and credential-free. Add concrete fixture
files next to this README only when they can be validated by the current
campaign schema and do not require production data.
## Scenarios
| Scenario | Required Modules | Release Check |
| --- | --- | --- |
| `simple-announcement` | core, access, campaigns | Validate and build one active recipient without attachments. |
| `addressing-matrix` | core, access, campaigns | Exercise To, CC, BCC, Reply-To, bounce, and disposition-notification fields. |
| `global-attachment` | core, access, campaigns; optional files | Build one deterministic attachment and verify evidence. |
| `recipient-attachment-rules` | core, access, campaigns; optional files | Match recipient-specific attachment rules and verify per-recipient evidence. |
| `zip-protected` | core, access, campaigns | Build password-protected AES ZIP output and verify password-source metadata. |
| `mail-profile-send` | core, access, campaigns, mail | Select a reusable mail profile and send through the GreenMail test bed. |
| `inline-mail-settings` | core, access, campaigns, mail | Use campaign-local SMTP/IMAP settings only when policy allows it. |
| `warnings-review` | core, access, campaigns | Require explicit review before queueing jobs with warnings. |
| `blocked-send` | core, access, campaigns | Confirm blocked recipients or missing attachments cannot be queued. |
| `mock-delivery` | core, access, campaigns, mail with dev capability | Capture messages in the development mailbox. |
| `greenmail-delivery` | core, access, campaigns, mail | Send no-attachment, normal attachment, and ZIP attachment variants through `dev/mail-testbed`. |
## Fixture Rules
- Do not commit real recipients, mail credentials, or production attachment
names.
- Keep attachments deterministic and small.
- Store secrets as placeholders, saved-credential references, or local `.env`
values consumed by the test bed.
- Declare optional module requirements in fixture metadata.
- Fixtures must not import files or mail modules directly; optional behavior is
discovered through core module metadata and capabilities.
## Validation Flow
Before a release tag:
1. Run module permutation startup checks from core.
2. Validate every committed example fixture against the current campaign schema.
3. Build exact messages for each fixture.
4. Run the mock-delivery example when the dev mailbox capability is enabled.
5. Run `dev/mail-testbed/run_transport_smoke.py`.
6. Execute the delivery checklist in
`docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md`.

View File

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

View File

@@ -4,18 +4,18 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-campaign"
version = "0.1.2"
version = "0.1.8"
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.2",
"govoplan-core>=0.1.8",
"jsonschema>=4,<5",
"pydantic>=2,<3",
"SQLAlchemy>=2,<3",
"pyzipper>=0.3,<1",
"pyzipper>=0.4,<1",
]
[tool.setuptools.packages.find]

View File

@@ -6,7 +6,14 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from govoplan_core.mail.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials, TransportSecurity
from govoplan_core.mail.config import (
ImapConfig,
ImapServerConfig,
SmtpConfig,
SmtpServerConfig,
TransportCredentials,
normalize_split_transport_credentials,
)
class StrictModel(BaseModel):
@@ -124,28 +131,7 @@ class ServerConfig(StrictModel):
@model_validator(mode="before")
@classmethod
def normalize_legacy_credentials(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
data = dict(value)
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
for protocol in ("smtp", "imap"):
transport = data.get(protocol)
if not isinstance(transport, dict):
continue
next_transport = dict(transport)
next_credentials = dict(credentials.get(protocol) or {})
for field in ("username", "password"):
if field in next_transport and field not in next_credentials:
next_credentials[field] = next_transport[field]
next_transport.pop(field, None)
next_transport.pop("enabled", None)
data[protocol] = next_transport
if next_credentials:
credentials[protocol] = next_credentials
if credentials:
data["credentials"] = credentials
return data
return normalize_split_transport_credentials(value)
def runtime_smtp_config(self) -> SmtpConfig | None:
if self.smtp is None:
@@ -489,6 +475,39 @@ 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", "addresses"]
source_id: str | None = None
source_label: str | None = None
source_revision: str | None = None
source_provenance: dict[str, Any] = Field(default_factory=dict)
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 +521,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

@@ -1,6 +1,7 @@
from __future__ import annotations
import csv
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from typing import Iterable
@@ -8,7 +9,7 @@ from typing import Iterable
from pydantic import BaseModel, ConfigDict, Field
from .field_values import ignored_entry_field_overrides
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
class Severity(StrEnum):
@@ -51,6 +52,13 @@ class SemanticReport(BaseModel):
return self.error_count == 0
@dataclass(frozen=True, slots=True)
class _EntriesValidation:
mode: str
count: int | None
issues: list[SemanticIssue]
def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue:
return SemanticIssue(severity=severity, code=code, message=message, path=path)
@@ -200,57 +208,95 @@ def _attachment_path_issues(config: CampaignConfig) -> list[SemanticIssue]:
def _zip_configuration_issues(config: CampaignConfig) -> list[SemanticIssue]:
collection = config.attachments.zip
issues: list[SemanticIssue] = []
if not collection.enabled:
return issues
return []
if not collection.archives:
return [_issue(Severity.ERROR, "zip_archive_missing", "Attachment zipping is enabled, but no ZIP archive is configured", "/attachments/zip/archives")]
issues, archive_ids, standard_count = _zip_archive_catalog_issues(config)
if standard_count != 1:
issues.append(_issue(Severity.ERROR, "zip_standard_archive_invalid", "Exactly one ZIP archive must be selected as the campaign standard", "/attachments/zip/archives"))
issues.extend(_zip_rule_selection_issues(config, archive_ids))
return issues
def _zip_archive_catalog_issues(config: CampaignConfig) -> tuple[list[SemanticIssue], set[str], int]:
issues: list[SemanticIssue] = []
archive_ids: set[str] = set()
archive_names: set[str] = set()
standard_count = 0
field_definitions = {field.name: field for field in config.fields}
for index, archive in enumerate(collection.archives):
for index, archive in enumerate(config.attachments.zip.archives):
path = f"/attachments/zip/archives/{index}"
issues.extend(_zip_archive_identity_issues(archive, path, archive_ids, archive_names))
if archive.standard:
standard_count += 1
issues.extend(_zip_password_issues(config, archive, path, field_definitions))
return issues, archive_ids, standard_count
def _zip_archive_identity_issues(
archive: ZipArchiveConfig,
path: str,
archive_ids: set[str],
archive_names: set[str],
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
if not archive.id.strip():
issues.append(_issue(Severity.ERROR, "zip_archive_id_missing", "ZIP archive has no identifier", f"{path}/id"))
elif archive.id in archive_ids:
issues.append(_issue(Severity.ERROR, "zip_archive_id_duplicate", f"ZIP archive id {archive.id!r} is used more than once", f"{path}/id"))
archive_ids.add(archive.id)
normalized_archive_name = _normalized_zip_archive_name(archive.name)
if not normalized_archive_name:
issues.append(_issue(Severity.ERROR, "zip_archive_name_missing", "ZIP archive has no filename", f"{path}/name"))
elif normalized_archive_name in archive_names:
issues.append(_issue(Severity.ERROR, "zip_archive_name_duplicate", f"ZIP archive filename {archive.name!r} is used more than once; archive filenames must be unique", f"{path}/name"))
archive_names.add(normalized_archive_name)
if archive.standard:
standard_count += 1
return issues
def _zip_password_issues(
config: CampaignConfig,
archive: ZipArchiveConfig,
path: str,
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
if not archive.password_enabled:
continue
return []
if archive.password_mode == ZipPasswordMode.DIRECT:
if not (archive.password or ""):
issues.append(_issue(Severity.ERROR, "zip_password_missing", "A legacy fixed ZIP password is enabled, but no password is configured", f"{path}/password"))
continue
return [_issue(Severity.ERROR, "zip_password_missing", "A legacy fixed ZIP password is enabled, but no password is configured", f"{path}/password")]
return []
if archive.password_mode == ZipPasswordMode.TEMPLATE:
if not (archive.password_template or ""):
issues.append(_issue(Severity.ERROR, "zip_password_template_missing", "A legacy ZIP password template is enabled, but no template is configured", f"{path}/password_template"))
continue
return [_issue(Severity.ERROR, "zip_password_template_missing", "A legacy ZIP password template is enabled, but no template is configured", f"{path}/password_template")]
return []
return _zip_password_field_issues(config, archive, path, field_definitions)
def _zip_password_field_issues(
config: CampaignConfig,
archive: ZipArchiveConfig,
path: str,
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
field_name = (archive.password_field or "").strip()
field = field_definitions.get(field_name)
if not field_name:
issues.append(_issue(Severity.ERROR, "zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no field is selected", f"{path}/password_field"))
elif field is None:
issues.append(_issue(Severity.ERROR, "zip_password_field_unknown", f"ZIP password field {field_name!r} is not declared in campaign fields", f"{path}/password_field"))
elif field.type != FieldType.PASSWORD:
issues.append(_issue(Severity.WARNING, "zip_password_field_not_password_type", f"ZIP password field {field_name!r} is not configured with field type 'password'", f"{path}/password_field"))
elif archive.password_scope == ZipPasswordScope.GLOBAL and config.global_values.get(field_name) in (None, ""):
issues.append(_issue(Severity.ERROR, "zip_global_password_value_missing", f"Global ZIP password field {field_name!r} has no campaign-wide value", f"/global_values/{field_name}"))
return [_issue(Severity.ERROR, "zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no field is selected", f"{path}/password_field")]
if field is None:
return [_issue(Severity.ERROR, "zip_password_field_unknown", f"ZIP password field {field_name!r} is not declared in campaign fields", f"{path}/password_field")]
if getattr(field, "type", None) != FieldType.PASSWORD:
return [_issue(Severity.WARNING, "zip_password_field_not_password_type", f"ZIP password field {field_name!r} is not configured with field type 'password'", f"{path}/password_field")]
if archive.password_scope == ZipPasswordScope.GLOBAL and config.global_values.get(field_name) in (None, ""):
return [_issue(Severity.ERROR, "zip_global_password_value_missing", f"Global ZIP password field {field_name!r} has no campaign-wide value", f"/global_values/{field_name}")]
return []
if standard_count != 1:
issues.append(_issue(Severity.ERROR, "zip_standard_archive_invalid", "Exactly one ZIP archive must be selected as the campaign standard", "/attachments/zip/archives"))
def _zip_rule_selection_issues(config: CampaignConfig, archive_ids: set[str]) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
for path, rule, _is_individual in _iter_attachment_rules(config):
selection = (rule.zip.archive_id or ZipRuleMode.INHERIT.value).strip()
if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value, ZipRuleMode.EXCLUDE.value} and selection not in archive_ids:
@@ -276,6 +322,236 @@ def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_pr
]
def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> list[SemanticIssue]:
return [
_issue(
Severity.WARNING,
"unknown_global_value",
f"global_values contains {key!r}, but it is not declared in fields",
f"/global_values/{key}",
)
for key in config.global_values
if declared_names and key not in declared_names
]
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
runtime_imap = config.server.runtime_imap_config()
if config.delivery.imap_append_sent.enabled:
issues.extend(_imap_delivery_issues(runtime_imap))
runtime_smtp = config.server.runtime_smtp_config()
if config.campaign.mode == "send" and not runtime_smtp:
issues.append(
_issue(
Severity.ERROR,
"missing_smtp_config",
"campaign mode is 'send', but no server.smtp configuration is present",
"/server/smtp",
)
)
if runtime_smtp:
missing = [name for name in ["host", "port"] if getattr(runtime_smtp, name) in (None, "")]
if missing:
issues.append(
_issue(
Severity.WARNING,
"incomplete_smtp_config",
"SMTP settings are present, but these settings are missing: " + ", ".join(missing),
"/server/smtp",
)
)
return issues
def _imap_delivery_issues(runtime_imap: object | None) -> list[SemanticIssue]:
if runtime_imap is None:
return [
_issue(
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
"/delivery/imap_append_sent/enabled",
)
]
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_imap, name) in (None, "")]
if not missing:
return []
return [
_issue(
Severity.ERROR,
"incomplete_imap_config",
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
"/server/imap",
)
]
def _entries_validation(
config: CampaignConfig,
*,
campaign_path: Path,
check_files: bool,
field_names: set[str],
field_definitions: dict[str, object],
) -> _EntriesValidation:
if config.entries.is_inline:
inline_entries = config.entries.inline or []
issues = _inline_entries_issues(config, inline_entries)
return _EntriesValidation(mode="inline", count=len(inline_entries), issues=issues)
issues = _external_entries_issues(
config,
campaign_path=campaign_path,
check_files=check_files,
field_names=field_names,
field_definitions=field_definitions,
)
source_type = config.entries.source.type.value if config.entries.source else "unknown"
return _EntriesValidation(mode=f"external:{source_type}", count=None, issues=issues)
def _inline_entries_issues(config: CampaignConfig, inline_entries: list[EntryConfig]) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
if not inline_entries:
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
for index, entry in enumerate(inline_entries):
if entry.active:
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
return issues
def _external_entries_issues(
config: CampaignConfig,
*,
campaign_path: Path,
check_files: bool,
field_names: set[str],
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
mapping = config.entries.mapping or {}
if not mapping:
issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping"))
issues.extend(_mapping_issues(mapping, field_names, field_definitions))
if config.entries.defaults:
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
if check_files and config.entries.source:
issues.extend(_entries_source_file_issues(config, campaign_path, mapping))
return issues
def _mapping_issues(
mapping: dict[str, str],
field_names: set[str],
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
for target in mapping:
if not _mapping_target_known(target, field_names):
issues.append(
_issue(
Severity.WARNING,
"unknown_mapping_target",
f"mapping target {target!r} is not recognized by the current campaign model",
f"/entries/mapping/{target}",
)
)
field_name = _mapping_target_field_name(target)
field = field_definitions.get(field_name or "")
if field_name and field is not None and not getattr(field, "can_override", True):
issues.append(
_issue(
Severity.WARNING,
"mapping_target_not_overridable",
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
f"/entries/mapping/{target}",
)
)
return issues
def _entries_source_file_issues(config: CampaignConfig, campaign_path: Path, mapping: dict[str, str]) -> list[SemanticIssue]:
source = config.entries.source
if source is None:
return []
source_path = _resolve(campaign_path, source.path)
if not source_path.exists():
return [
_issue(
Severity.ERROR,
"entries_source_not_found",
f"entries source file does not exist: {source_path}",
"/entries/source/path",
)
]
if source.type != SourceType.CSV or not source.has_header:
return []
try:
header = _csv_header(source_path, source.delimiter, source.encoding)
except OSError as exc:
return [_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path")]
header_set = set(header or [])
missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set})
if not missing_columns:
return []
return [
_issue(
Severity.ERROR,
"mapping_columns_missing",
"CSV mapping refers to missing columns: " + ", ".join(missing_columns),
"/entries/mapping",
)
]
def _file_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
issues.extend(_attachment_file_check_issues(config, campaign_path))
issues.extend(_template_source_file_issues(config, campaign_path))
return issues
def _attachment_file_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
if config.attachments.base_paths:
return [
_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachment base path {base_path_config.name!r} does not exist: {_resolve(campaign_path, base_path_config.path)}",
f"/attachments/base_paths/{index}/path",
)
for index, base_path_config in enumerate(config.attachments.base_paths)
if not _resolve(campaign_path, base_path_config.path).exists()
]
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
if attachments_base_path.exists():
return []
return [
_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachments.base_path does not exist: {attachments_base_path}",
"/attachments/base_path",
)
]
def _template_source_file_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
for schema_path, raw_path in _iter_template_source_paths(config):
path = _resolve(campaign_path, raw_path)
if not path.exists():
issues.append(
_issue(
Severity.ERROR,
"template_source_not_found",
f"template source file does not exist: {path}",
schema_path,
)
)
return issues
def validate_campaign_config(
config: CampaignConfig,
*,
@@ -289,149 +565,29 @@ def validate_campaign_config(
field_definitions = {field.name: field for field in config.fields}
declared_names = set(field_definitions)
for key in config.global_values:
if declared_names and key not in declared_names:
issues.append(_issue(
Severity.WARNING,
"unknown_global_value",
f"global_values contains {key!r}, but it is not declared in fields",
f"/global_values/{key}",
))
issues.extend(_global_value_issues(config, declared_names))
issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_issues(config))
issues.extend(_delivery_issues(config))
runtime_imap = config.server.runtime_imap_config()
if config.delivery.imap_append_sent.enabled:
if runtime_imap is None:
issues.append(_issue(
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
"/delivery/imap_append_sent/enabled",
))
else:
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_imap, name) in (None, "")]
if missing:
issues.append(_issue(
Severity.ERROR,
"incomplete_imap_config",
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
"/server/imap",
))
runtime_smtp = config.server.runtime_smtp_config()
if config.campaign.mode == "send" and not runtime_smtp:
issues.append(_issue(
Severity.ERROR,
"missing_smtp_config",
"campaign mode is 'send', but no server.smtp configuration is present",
"/server/smtp",
))
if runtime_smtp:
missing = [name for name in ["host", "port"] if getattr(runtime_smtp, name) in (None, "")]
if missing:
issues.append(_issue(
Severity.WARNING,
"incomplete_smtp_config",
"SMTP settings are present, but these settings are missing: " + ", ".join(missing),
"/server/smtp",
))
if config.entries.is_inline:
inline_entries = config.entries.inline or []
entries_count = len(inline_entries)
entries_mode = "inline"
if entries_count == 0:
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
for index, entry in enumerate(inline_entries):
if entry.active:
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
else:
entries_count = None
entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}"
mapping = config.entries.mapping or {}
if not mapping:
issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping"))
for target in mapping:
if not _mapping_target_known(target, field_names):
issues.append(_issue(
Severity.WARNING,
"unknown_mapping_target",
f"mapping target {target!r} is not recognized by the current campaign model",
f"/entries/mapping/{target}",
))
field_name = _mapping_target_field_name(target)
if field_name and field_name in field_definitions and not field_definitions[field_name].can_override:
issues.append(_issue(
Severity.WARNING,
"mapping_target_not_overridable",
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
f"/entries/mapping/{target}",
))
if config.entries.defaults:
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
if check_files and config.entries.source:
source_path = _resolve(campaign_path, config.entries.source.path)
if not source_path.exists():
issues.append(_issue(
Severity.ERROR,
"entries_source_not_found",
f"entries source file does not exist: {source_path}",
"/entries/source/path",
))
elif config.entries.source.type == SourceType.CSV and config.entries.source.has_header:
try:
header = _csv_header(source_path, config.entries.source.delimiter, config.entries.source.encoding)
header_set = set(header or [])
missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set})
if missing_columns:
issues.append(_issue(
Severity.ERROR,
"mapping_columns_missing",
"CSV mapping refers to missing columns: " + ", ".join(missing_columns),
"/entries/mapping",
))
except OSError as exc:
issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path"))
entries = _entries_validation(
config,
campaign_path=campaign_path,
check_files=check_files,
field_names=field_names,
field_definitions=field_definitions,
)
issues.extend(entries.issues)
if check_files:
if config.attachments.base_paths:
for index, base_path_config in enumerate(config.attachments.base_paths):
attachments_base_path = _resolve(campaign_path, base_path_config.path)
if not attachments_base_path.exists():
issues.append(_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachment base path {base_path_config.name!r} does not exist: {attachments_base_path}",
f"/attachments/base_paths/{index}/path",
))
else:
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
if not attachments_base_path.exists():
issues.append(_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachments.base_path does not exist: {attachments_base_path}",
"/attachments/base_path",
))
for schema_path, raw_path in _iter_template_source_paths(config):
path = _resolve(campaign_path, raw_path)
if not path.exists():
issues.append(_issue(
Severity.ERROR,
"template_source_not_found",
f"template source file does not exist: {path}",
schema_path,
))
issues.extend(_file_check_issues(config, campaign_path))
report = SemanticReport(
campaign_id=config.campaign.id,
campaign_name=config.campaign.name,
issues=issues,
entries_mode=entries_mode,
entries_count=entries_count,
entries_mode=entries.mode,
entries_count=entries.count,
attachments_base_path=_attachment_base_path_report_value(config),
rate_limit=f"{config.delivery.rate_limit.messages_per_minute}/min, concurrency {config.delivery.rate_limit.concurrency}",
imap_append_enabled=config.delivery.imap_append_sent.enabled,

View File

@@ -0,0 +1,293 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from sqlalchemy import and_, or_
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
from govoplan_core.core.campaigns import (
CampaignAccessProvider,
CampaignDeliveryTaskProvider,
CampaignMailPolicyContext,
CampaignMailPolicyContextProvider,
CampaignPolicyContext,
CampaignPolicyContextProvider,
CampaignRetentionProvider,
)
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
READ_ACTIONS = {"campaigns:campaign:read", "campaign:read"}
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 explain_resource_provenance(
self,
session: object,
principal: PrincipalRef,
*,
resource_type: str,
resource_id: str,
action: str,
) -> tuple[AccessDecisionProvenance, ...]:
normalized_type = resource_type.lower().strip()
if normalized_type not in {"campaign", "campaign_object", "campaigns:campaign"}:
return ()
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
if campaign is None or (principal.tenant_id and campaign.tenant_id != principal.tenant_id):
return (
AccessDecisionProvenance(
kind="resource",
id=resource_id,
tenant_id=principal.tenant_id,
source="campaigns.not_found",
details={"resource_type": "campaign", "found": False},
),
)
items = [
AccessDecisionProvenance(
kind="resource",
id=campaign.id,
label=campaign.name,
tenant_id=campaign.tenant_id,
source="campaigns.campaign",
details={
"resource_type": "campaign",
"external_id": campaign.external_id,
"status": campaign.status,
},
)
]
items.extend(_owner_provenance(campaign, principal))
items.extend(_tenant_admin_provenance(principal))
permission_values = {"read", "write"} if action in READ_ACTIONS else {"write"}
shares = (
session.query(CampaignShare) # type: ignore[attr-defined]
.filter(
CampaignShare.tenant_id == campaign.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
CampaignShare.permission.in_(sorted(permission_values)),
or_(
(CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.membership_id),
(CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(sorted(principal.group_ids))),
),
)
.order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc())
.all()
)
for share in shares:
items.append(
AccessDecisionProvenance(
kind="share",
id=share.id,
label=share.permission,
tenant_id=share.tenant_id,
source="campaigns.share",
details={
"target_type": share.target_type,
"target_id": share.target_id,
"permission": share.permission,
},
)
)
return tuple(items)
def access_capability(context: object) -> CampaignAccessService:
return CampaignAccessService()
def _owner_provenance(campaign: Campaign, principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]:
if campaign.owner_user_id and campaign.owner_user_id == principal.membership_id:
return (
AccessDecisionProvenance(
kind="owner",
id=campaign.owner_user_id,
tenant_id=campaign.tenant_id,
source="campaigns.owner",
details={"owner_type": "user"},
),
)
if campaign.owner_group_id and campaign.owner_group_id in principal.group_ids:
return (
AccessDecisionProvenance(
kind="owner",
id=campaign.owner_group_id,
tenant_id=campaign.tenant_id,
source="campaigns.owner",
details={"owner_type": "group"},
),
)
return ()
def _tenant_admin_provenance(principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]:
if not scopes_grant_compatible(principal.scopes, "tenant:*"):
return ()
return (
AccessDecisionProvenance(
kind="policy",
id="tenant:*",
label="tenant:*",
tenant_id=principal.tenant_id,
source="campaigns.tenant_admin",
details={"grant": "tenant_admin"},
),
)
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

@@ -0,0 +1,330 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import event
from sqlalchemy.orm import Session as OrmSession
from govoplan_core.core.change_sequence import record_change
from govoplan_core.core.sqlalchemy_change_tracking import (
ensure_object_id,
has_attr_changes,
object_state,
operation_for_object,
previous_value,
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignShare,
CampaignVersion,
ImapAppendAttempt,
SendAttempt,
new_uuid,
)
CAMPAIGNS_MODULE_ID = "campaigns"
CAMPAIGNS_COLLECTION = "campaigns.campaigns"
CAMPAIGN_VERSIONS_COLLECTION = "campaigns.versions"
CAMPAIGN_JOBS_COLLECTION = "campaigns.jobs"
CAMPAIGN_ISSUES_COLLECTION = "campaigns.issues"
CAMPAIGN_ATTEMPTS_COLLECTION = "campaigns.attempts"
_REGISTERED = False
def register_campaign_change_tracking() -> None:
global _REGISTERED
if _REGISTERED:
return
event.listen(OrmSession, "before_flush", _record_campaign_changes)
_REGISTERED = True
def _record_campaign_changes(session: OrmSession, _flush_context: object, _instances: object) -> None:
for obj in tuple(session.new) + tuple(session.dirty) + tuple(session.deleted):
if isinstance(obj, Campaign):
_record_campaign_change(session, obj)
elif isinstance(obj, CampaignShare):
_record_share_visibility_change(session, obj)
elif isinstance(obj, CampaignVersion):
_record_version_change(session, obj)
elif isinstance(obj, CampaignJob):
_record_job_change(session, obj)
elif isinstance(obj, CampaignIssue):
_record_issue_change(session, obj)
elif isinstance(obj, (SendAttempt, ImapAppendAttempt)):
_record_attempt_change(session, obj)
def _record_campaign_change(session: OrmSession, campaign: Campaign) -> None:
operation = _operation_for_campaign(
campaign,
changed_attrs=(
"external_id",
"name",
"description",
"status",
"current_version_id",
"owner_user_id",
"owner_group_id",
"settings",
"mail_profile_policy",
),
)
if operation is None:
return
campaign_id = _ensure_id(campaign)
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGNS_COLLECTION,
resource_type="campaign",
resource_id=campaign_id,
operation=operation,
tenant_id=campaign.tenant_id,
actor_type="user" if campaign.created_by_user_id else None,
actor_id=campaign.created_by_user_id,
payload=_campaign_payload(campaign),
)
def _record_share_visibility_change(session: OrmSession, share: CampaignShare) -> None:
state = object_state(share)
if not share.campaign_id:
return
if not state.deleted and not state.pending and not has_attr_changes(
state,
("campaign_id", "target_type", "target_id", "permission", "revoked_at"),
):
return
_ensure_id(share)
operation = "deleted" if state.deleted or share.revoked_at is not None else "updated"
campaign = session.get(Campaign, share.campaign_id)
payload = _campaign_payload(campaign) if campaign is not None else {"campaign_id": share.campaign_id}
payload.update({
"share_id": share.id,
"share_target_type": share.target_type,
"share_target_id": share.target_id,
"share_permission": share.permission,
"share_revoked_at": _isoformat(share.revoked_at),
})
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGNS_COLLECTION,
resource_type="campaign",
resource_id=share.campaign_id,
operation=operation,
tenant_id=share.tenant_id,
actor_type="user" if share.created_by_user_id else None,
actor_id=share.created_by_user_id,
payload=payload,
)
def _record_version_change(session: OrmSession, version: CampaignVersion) -> None:
operation = _operation_for_object(
version,
changed_attrs=(
"raw_json",
"schema_version",
"source_filename",
"source_base_path",
"workflow_state",
"current_flow",
"current_step",
"is_complete",
"editor_state",
"autosaved_at",
"published_at",
"locked_at",
"locked_by_user_id",
"user_lock_state",
"user_locked_at",
"user_locked_by_user_id",
"validation_summary",
"build_summary",
"execution_snapshot_hash",
"execution_snapshot_at",
),
)
if operation is None:
return
version_id = _ensure_id(version)
campaign = session.get(Campaign, version.campaign_id) if version.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_VERSIONS_COLLECTION,
resource_type="campaign_version",
resource_id=version_id,
operation=operation,
tenant_id=campaign.tenant_id if campaign is not None else None,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": version.campaign_id}),
"version_id": version_id,
"version_number": version.version_number,
"workflow_state": version.workflow_state,
"current_flow": version.current_flow,
"current_step": version.current_step,
},
)
def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
operation = _operation_for_object(
job,
changed_attrs=(
"build_status",
"validation_status",
"queue_status",
"send_status",
"imap_status",
"attempt_count",
"last_error",
"queued_at",
"claimed_at",
"smtp_started_at",
"outcome_unknown_at",
"sent_at",
"resolved_recipients",
"resolved_attachments",
"issues_snapshot",
),
)
if operation is None:
return
job_id = _ensure_id(job)
campaign = session.get(Campaign, job.campaign_id) if job.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_JOBS_COLLECTION,
resource_type="campaign_job",
resource_id=job_id,
operation=operation,
tenant_id=job.tenant_id,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
"job_id": job_id,
"version_id": job.campaign_version_id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"build_status": job.build_status,
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"imap_status": job.imap_status,
},
)
def _record_issue_change(session: OrmSession, issue: CampaignIssue) -> None:
operation = _operation_for_object(issue, changed_attrs=("severity", "code", "message", "source", "behavior"))
if operation is None:
return
issue_id = _ensure_id(issue)
campaign = session.get(Campaign, issue.campaign_id) if issue.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_ISSUES_COLLECTION,
resource_type="campaign_issue",
resource_id=issue_id,
operation=operation,
tenant_id=issue.tenant_id,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": issue.campaign_id}),
"issue_id": issue_id,
"version_id": issue.campaign_version_id,
"job_id": issue.job_id,
"severity": issue.severity,
"code": issue.code,
},
)
def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppendAttempt) -> None:
operation = _operation_for_object(
attempt,
changed_attrs=("status", "claim_token", "smtp_status_code", "smtp_response", "error_type", "error_message", "folder"),
)
if operation is None:
return
attempt_id = _ensure_id(attempt)
job = session.get(CampaignJob, attempt.job_id) if attempt.job_id else None
if job is None:
return
campaign = session.get(Campaign, job.campaign_id) if job.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_ATTEMPTS_COLLECTION,
resource_type="campaign_attempt",
resource_id=attempt_id,
operation=operation,
tenant_id=job.tenant_id,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
"attempt_id": attempt_id,
"attempt_kind": "imap" if isinstance(attempt, ImapAppendAttempt) else "smtp",
"job_id": job.id,
"version_id": job.campaign_version_id,
},
)
def _operation_for_campaign(obj: Campaign, *, changed_attrs: tuple[str, ...]) -> str | None:
state = object_state(obj)
if state.pending:
return "created"
if state.deleted:
return "deleted"
if not has_attr_changes(state, changed_attrs):
return None
status_history = state.attrs.status.history
if status_history.has_changes() and any(value == "deleted" for value in status_history.added):
return "deleted"
return "updated"
def _operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> str | None:
return operation_for_object(obj, changed_attrs=changed_attrs)
def _ensure_id(obj: object) -> str:
return ensure_object_id(obj, new_uuid)
def _campaign_payload(campaign: Campaign | None) -> dict[str, Any]:
if campaign is None:
return {}
return {
"campaign_id": campaign.id,
"owner_user_id": campaign.owner_user_id,
"owner_group_id": campaign.owner_group_id,
"previous_owner_user_id": previous_value(campaign, "owner_user_id"),
"previous_owner_group_id": previous_value(campaign, "owner_group_id"),
"status": campaign.status,
"previous_status": previous_value(campaign, "status"),
"current_version_id": campaign.current_version_id,
"previous_current_version_id": previous_value(campaign, "current_version_id"),
}
def _isoformat(value: datetime | None) -> str | None:
return value.isoformat() if value else None
__all__ = [
"CAMPAIGNS_COLLECTION",
"CAMPAIGNS_MODULE_ID",
"CAMPAIGN_ATTEMPTS_COLLECTION",
"CAMPAIGN_ISSUES_COLLECTION",
"CAMPAIGN_JOBS_COLLECTION",
"CAMPAIGN_VERSIONS_COLLECTION",
"register_campaign_change_tracking",
]

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())
@@ -113,10 +108,10 @@ class Campaign(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),)
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)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
@@ -125,7 +120,6 @@ 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()
versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
@@ -135,15 +129,41 @@ class CampaignShare(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),)
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)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class RecipientImportMappingProfile(Base, TimestampMixin):
__tablename__ = "campaign_recipient_import_mapping_profiles"
__table_args__ = (
Index("ix_recipient_import_profiles_owner", "tenant_id", "owner_user_id"),
Index("ix_recipient_import_profiles_ordered_fp", "tenant_id", "owner_user_id", "ordered_header_fingerprint"),
Index("ix_recipient_import_profiles_unordered_fp", "tenant_id", "owner_user_id", "unordered_header_fingerprint"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
owner_user_id: Mapped[str] = mapped_column(ForeignKey("access_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)
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"),)
@@ -177,7 +197,7 @@ class CampaignVersion(Base, TimestampMixin):
autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
# Explicit user-requested lock. This is deliberately separate from
# locked_at, which represents the reversible validation lock used by the
@@ -185,7 +205,7 @@ class CampaignVersion(Base, TimestampMixin):
# RBAC permission for unlocking; permanent locks never unlock in place.
user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
@@ -201,7 +221,7 @@ class CampaignJob(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),)
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)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True)
entry_index: Mapped[int] = mapped_column(Integer, nullable=False)
@@ -241,7 +261,7 @@ class CampaignIssue(Base, TimestampMixin):
__tablename__ = "campaign_issues"
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)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True)
job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True)
@@ -257,7 +277,7 @@ class AttachmentBlob(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),)
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)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
mime_type: Mapped[str | None] = mapped_column(String(255))
@@ -269,8 +289,8 @@ class AttachmentInstance(Base, TimestampMixin):
__tablename__ = "attachment_instances"
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 | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True)
blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True)
logical_name: Mapped[str | None] = mapped_column(String(500))
@@ -319,7 +339,6 @@ __all__ = [
"CampaignVersion",
"CampaignVersionFlow",
"CampaignVersionWorkflowState",
"Group",
"ImapAppendAttempt",
"IssueSeverity",
"JobBuildStatus",
@@ -328,7 +347,4 @@ __all__ = [
"JobSendStatus",
"JobValidationStatus",
"SendAttempt",
"Tenant",
"User",
"UserGroupMembership",
]

View File

@@ -1,36 +1,54 @@
from __future__ import annotations
from dataclasses import dataclass
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
@dataclass(slots=True)
class _MockSendOutcome:
row: dict[str, Any]
sent_count: int = 0
failed_count: int = 0
skipped_count: int = 0
imap_appended_count: int = 0
imap_failed_count: int = 0
def _require_mock_mailbox() -> ModuleType:
@dataclass(slots=True)
class _MockSendBatch:
results: list[dict[str, Any]]
sent_count: int = 0
failed_count: int = 0
skipped_count: int = 0
imap_appended_count: int = 0
imap_failed_count: int = 0
@property
def attempted_count(self) -> int:
return self.sent_count + self.failed_count
def _mock_mailbox() -> Any | None:
return mail_integration().mock_mailbox()
def _require_mock_mailbox() -> Any:
mailbox = _mock_mailbox()
if mailbox is None:
raise MockCampaignSendError("Mail module is not available")
@@ -123,6 +141,163 @@ def _can_mock_send(
return False, f"Validation status is {message.validation_status.value}"
def _mock_send_batch(
*,
config: Any,
built_messages: list[Any],
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> _MockSendBatch:
batch = _MockSendBatch(results=[])
for built in built_messages:
outcome = _mock_send_message(
config=config,
built=built,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
batch.results.append(outcome.row)
batch.sent_count += outcome.sent_count
batch.failed_count += outcome.failed_count
batch.skipped_count += outcome.skipped_count
batch.imap_appended_count += outcome.imap_appended_count
batch.imap_failed_count += outcome.imap_failed_count
return batch
def _mock_send_message(
*,
config: Any,
built: Any,
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> _MockSendOutcome:
draft = built.draft
row = _mock_send_row(draft)
can_send, skip_reason = _can_mock_send(
draft,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
)
if not can_send or built.mime is None:
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
return _MockSendOutcome(row=row, skipped_count=1)
recipients = _recipient_emails(draft)
envelope_from = _envelope_from(draft)
if not recipients:
row.update({"status": "skipped", "message": "No envelope recipients"})
return _MockSendOutcome(row=row, skipped_count=1)
if not send:
row.update({
"status": "ready",
"message": f"Would send to {len(recipients)} recipient(s)",
"envelope_from": envelope_from,
"envelope_recipients": recipients,
})
return _MockSendOutcome(row=row)
return _send_mock_smtp_message(
config=config,
built=built,
mailbox=mailbox,
row=row,
recipients=recipients,
envelope_from=envelope_from,
append_sent=append_sent,
)
def _mock_send_row(draft: MessageDraft) -> dict[str, Any]:
return {
"entry_index": draft.entry_index,
"entry_id": draft.entry_id,
"subject": draft.subject,
"validation_status": draft.validation_status.value,
"build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status),
"to": _message_addresses_payload(draft.to),
"attachments": _attachment_payloads(draft),
"issues": _issue_payloads(draft),
}
def _send_mock_smtp_message(
*,
config: Any,
built: Any,
mailbox: Any | None,
row: dict[str, Any],
recipients: list[str],
envelope_from: str,
append_sent: bool,
) -> _MockSendOutcome:
try:
if mailbox is not None and mailbox.consume_fail_next_smtp():
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
rejected = _smtp_rejection_matches(recipients)
if rejected and len(rejected) == len(recipients):
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
accepted = [recipient for recipient in recipients if recipient not in rejected]
smtp_record = mailbox.record_smtp_delivery(
built.mime,
envelope_from=envelope_from,
envelope_recipients=accepted,
smtp_host="mock.smtp.local",
)
row.update({
"status": "sent",
"message": f"Mock SMTP captured as {smtp_record.id}",
"smtp_message_id": smtp_record.id,
"envelope_from": envelope_from,
"envelope_recipients": accepted,
"refused_recipients": rejected,
})
imap_appended, imap_failed = _append_mock_sent_message(config, built, mailbox, row, append_sent=append_sent)
return _MockSendOutcome(row=row, sent_count=1, imap_appended_count=imap_appended, imap_failed_count=imap_failed)
except Exception as exc:
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
return _MockSendOutcome(row=row, failed_count=1)
def _append_mock_sent_message(
config: Any,
built: Any,
mailbox: Any | None,
row: dict[str, Any],
*,
append_sent: bool,
) -> tuple[int, int]:
if not append_sent:
return 0, 0
try:
if mailbox is not None and mailbox.consume_fail_next_imap():
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
folder = _mock_sent_folder(config)
imap_record = mailbox.record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
return 1, 0
except Exception as exc:
row.update({"imap_status": "failed", "imap_error": str(exc)})
return 0, 1
def _mock_sent_folder(config: Any) -> str:
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
return config.delivery.imap_append_sent.folder
if config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
return config.server.imap.sent_folder
return "Sent"
def run_mock_campaign_send(
session: Session,
*,
@@ -165,7 +340,7 @@ def run_mock_campaign_send(
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True,
prefix="multimailer-mock-send-",
prefix="govoplan-mock-send-",
) as prepared:
prepared_raw = load_campaign_json(prepared.path)
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
@@ -173,86 +348,15 @@ def run_mock_campaign_send(
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
files.annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
send_results: list[dict[str, Any]] = []
sent_count = 0
failed_count = 0
skipped_count = 0
imap_appended_count = 0
imap_failed_count = 0
for built in build_result.built_messages:
draft = built.draft
can_send, skip_reason = _can_mock_send(draft, include_warnings=include_warnings, include_needs_review=include_needs_review)
row: dict[str, Any] = {
"entry_index": draft.entry_index,
"entry_id": draft.entry_id,
"subject": draft.subject,
"validation_status": draft.validation_status.value,
"build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status),
"to": _message_addresses_payload(draft.to),
"attachments": _attachment_payloads(draft),
"issues": _issue_payloads(draft),
}
if not can_send or built.mime is None:
skipped_count += 1
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
send_results.append(row)
continue
recipients = _recipient_emails(draft)
envelope_from = _envelope_from(draft)
if not recipients:
skipped_count += 1
row.update({"status": "skipped", "message": "No envelope recipients"})
send_results.append(row)
continue
if not send:
row.update({"status": "ready", "message": f"Would send to {len(recipients)} recipient(s)", "envelope_from": envelope_from, "envelope_recipients": recipients})
send_results.append(row)
continue
try:
if mailbox is not None and mailbox.consume_fail_next_smtp():
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
rejected = _smtp_rejection_matches(recipients)
if rejected and len(rejected) == len(recipients):
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
accepted = [recipient for recipient in recipients if recipient not in rejected]
smtp_record = mailbox.record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
sent_count += 1
row.update({
"status": "sent",
"message": f"Mock SMTP captured as {smtp_record.id}",
"smtp_message_id": smtp_record.id,
"envelope_from": envelope_from,
"envelope_recipients": accepted,
"refused_recipients": rejected,
})
if append_sent:
try:
if mailbox is not None and mailbox.consume_fail_next_imap():
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
folder = "Sent"
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
folder = config.delivery.imap_append_sent.folder
elif config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
folder = config.server.imap.sent_folder
imap_record = mailbox.record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
imap_appended_count += 1
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
except Exception as exc:
imap_failed_count += 1
row.update({"imap_status": "failed", "imap_error": str(exc)})
except Exception as exc:
failed_count += 1
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
send_results.append(row)
send_batch = _mock_send_batch(
config=config,
built_messages=build_result.built_messages,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
validation_json = validation_report.model_dump(mode="json")
validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count})
@@ -268,7 +372,6 @@ def run_mock_campaign_send(
"messages": [_message_payload(message) for message in build_report.messages],
})
attempted_count = sum(1 for row in send_results if row.get("status") in {"sent", "failed"})
return {
"campaign_id": campaign.id,
"version_id": version.id,
@@ -280,19 +383,19 @@ def run_mock_campaign_send(
"steps": [
{"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json},
{"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}},
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if failed_count == 0 else "needs_review"), "summary": {"attempted": attempted_count, "sent": sent_count, "failed": failed_count, "skipped": skipped_count}},
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if imap_failed_count == 0 else "needs_review"), "summary": {"appended": imap_appended_count, "failed": imap_failed_count}},
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if send_batch.failed_count == 0 else "needs_review"), "summary": {"attempted": send_batch.attempted_count, "sent": send_batch.sent_count, "failed": send_batch.failed_count, "skipped": send_batch.skipped_count}},
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if send_batch.imap_failed_count == 0 else "needs_review"), "summary": {"appended": send_batch.imap_appended_count, "failed": send_batch.imap_failed_count}},
],
"validation": validation_json,
"build": build_json,
"send": {
"attempted_count": attempted_count,
"sent_count": sent_count,
"failed_count": failed_count,
"skipped_count": skipped_count,
"imap_appended_count": imap_appended_count,
"imap_failed_count": imap_failed_count,
"results": send_results,
"attempted_count": send_batch.attempted_count,
"sent_count": send_batch.sent_count,
"failed_count": send_batch.failed_count,
"skipped_count": send_batch.skipped_count,
"imap_appended_count": send_batch.imap_appended_count,
"imap_failed_count": send_batch.imap_failed_count,
"results": send_batch.results,
},
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
}

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,10 +2,32 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
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,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
register_campaign_change_tracking()
def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
@@ -97,30 +119,75 @@ 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="1.0.0",
dependencies=("access",),
optional_dependencies=("files", "mail"),
version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("files", "mail", "notifications", "addresses"),
provides_interfaces=(
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="files.campaign_attachments",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="mail.campaign_delivery",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="addresses.lookup",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="addresses.recipient_source",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
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(
path="/operator",
label="Operator Queue",
icon="activity",
icon="radio-tower",
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
@@ -130,7 +197,7 @@ manifest = ModuleManifest(
),
order=30,
),
NavItem(path="/reports", label="Reports", icon="reports", required_any=("campaigns:report:read",), order=70),
NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70),
),
frontend=FrontendModule(
module_id="campaigns",
@@ -140,7 +207,7 @@ manifest = ModuleManifest(
NavItem(
path="/operator",
label="Operator Queue",
icon="activity",
icon="radio-tower",
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
@@ -150,19 +217,69 @@ manifest = ModuleManifest(
),
order=30,
),
NavItem(path="/reports", label="Reports", icon="reports", required_any=("campaigns:report:read",), order=70),
NavItem(path="/address-book", label="Address Book", icon="users", order=80),
NavItem(path="/templates", label="Templates", icon="form", order=90),
NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70),
NavItem(path="/templates", label="Templates", icon="layout-template", order=90),
),
),
migration_spec=MigrationSpec(
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),
},
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -81,6 +81,33 @@ class CampaignBuildResult:
built_messages: list[BuiltMessage]
@dataclass(slots=True)
class _EntryMessageContext:
resolution: EntryAttachmentResolution
senders: list[RecipientConfig]
sender: RecipientConfig | None
recipients: dict[str, list[RecipientConfig]]
issues: list[MessageIssue]
validation_status: MessageValidationStatus
@dataclass(slots=True)
class _RenderedMessageTemplate:
subject: str
text_body: str | None
html_body: str | None
body_mode: str
values: dict[str, Any]
@dataclass(slots=True)
class _MimeBuildResult:
message: EmailMessage | None
build_status: BuildStatus
validation_status: MessageValidationStatus
attachment_count: int
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
campaign_path = Path(campaign_file).resolve()
path = Path(raw_path).expanduser()
@@ -431,81 +458,200 @@ def _write_eml(message: EmailMessage, output_dir: Path, entry: EntryConfig, entr
return str(path), path.stat().st_size
def build_entry_message(
def _entry_message_context(
*,
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
entry_index: int,
output_dir: Path | None = None,
write_eml: bool = False,
work_dir: Path | None = None,
attachment_match_index: AttachmentMatchIndex | None = None,
) -> BuiltMessage:
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index, match_index=attachment_match_index)
attachment_match_index: AttachmentMatchIndex | None,
) -> _EntryMessageContext:
resolution = resolve_entry_attachments(
config=config,
campaign_file=campaign_file,
entry=entry,
entry_index=entry_index,
match_index=attachment_match_index,
)
effective_addresses = effective_address_lists(config, entry)
senders = effective_addresses["from"]
sender = senders[0] if senders else None
recipients = {key: value for key, value in effective_addresses.items() if key != "from"}
issues = _message_issues_from_attachment_resolution(resolution)
validation_status = _validation_status_from_attachment_status(resolution.status)
validation_status = _apply_ignored_field_override_issues(config, entry, issues, validation_status)
return _EntryMessageContext(
resolution=resolution,
senders=senders,
sender=sender,
recipients=recipients,
issues=issues,
validation_status=validation_status,
)
def _apply_ignored_field_override_issues(
config: CampaignConfig,
entry: EntryConfig,
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
if ignored_field_overrides:
if not ignored_field_overrides:
return validation_status
issues.append(
MessageIssue(
severity="warning",
code="field_override_not_allowed",
message="Recipient field value(s) ignored because the campaign field does not allow overrides: " + ", ".join(ignored_field_overrides),
message=(
"Recipient field value(s) ignored because the campaign field does not allow overrides: "
+ ", ".join(ignored_field_overrides)
),
behavior="warn",
source="fields",
)
)
if validation_status == MessageValidationStatus.READY:
validation_status = MessageValidationStatus.WARNING
return MessageValidationStatus.WARNING
return validation_status
if not entry.active:
draft = MessageDraft(
def _message_draft(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
context: _EntryMessageContext,
build_status: BuildStatus,
validation_status: MessageValidationStatus,
send_status: SendStatus = SendStatus.DRAFT,
imap_status: ImapStatus | None = None,
subject: str | None = None,
attachment_count: int = 0,
issues: list[MessageIssue] | None = None,
eml_path: str | None = None,
eml_size: int | None = None,
) -> MessageDraft:
if imap_status is None:
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED
return MessageDraft(
entry_index=entry_index,
entry_id=entry.id,
active=False,
active=entry.active,
build_status=build_status,
validation_status=validation_status,
send_status=send_status,
imap_status=imap_status,
subject=subject,
from_=_message_address(context.sender),
from_all=_message_addresses(context.senders),
to=_message_addresses(context.recipients["to"]),
cc=_message_addresses(context.recipients["cc"]),
bcc=_message_addresses(context.recipients["bcc"]),
reply_to=_message_addresses(context.recipients["reply_to"]),
bounce_to=_message_addresses(context.recipients["bounce_to"]),
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
attachment_count=attachment_count,
attachments=_attachment_summaries(context.resolution),
issues=issues if issues is not None else context.issues,
eml_path=eml_path,
eml_size_bytes=eml_size,
)
def _inactive_entry_message(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
context: _EntryMessageContext,
) -> BuiltMessage:
draft = _message_draft(
config=config,
entry=entry,
entry_index=entry_index,
context=context,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.INACTIVE,
send_status=SendStatus.DRAFT,
imap_status=ImapStatus.SKIPPED,
from_=_message_address(sender),
from_all=_message_addresses(senders),
to=_message_addresses(recipients["to"]),
cc=_message_addresses(recipients["cc"]),
bcc=_message_addresses(recipients["bcc"]),
reply_to=_message_addresses(recipients["reply_to"]),
bounce_to=_message_addresses(recipients["bounce_to"]),
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
attachments=_attachment_summaries(resolution),
issues=[MessageIssue(severity="info", code="inactive_entry", message="Entry is inactive", behavior=config.validation_policy.inactive_entry.value, source="entry")],
issues=[
MessageIssue(
severity="info",
code="inactive_entry",
message="Entry is inactive",
behavior=config.validation_policy.inactive_entry.value,
source="entry",
)
],
)
return BuiltMessage(draft=draft, mime=None)
if not recipients["to"]:
behavior = config.validation_policy.missing_email.value
issues.append(_issue_from_behavior(code="missing_email", message="No effective To recipient is configured", behavior=behavior, source="recipients"))
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
def _validate_required_recipients(
config: CampaignConfig,
recipients: dict[str, list[RecipientConfig]],
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
if recipients["to"]:
return validation_status
behavior = config.validation_policy.missing_email.value
issues.append(
_issue_from_behavior(
code="missing_email",
message="No effective To recipient is configured",
behavior=behavior,
source="recipients",
)
)
if behavior == MissingAddressBehavior.BLOCK.value:
return MessageValidationStatus.BLOCKED
return MessageValidationStatus.EXCLUDED
def _render_message_template(
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
) -> _RenderedMessageTemplate:
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
body_mode = _template_body_mode(config)
values = build_template_values(config, entry)
keep_missing_placeholders = not config.validation_policy.ignore_empty_fields
subject = _render_template(subject_template, values, keep_missing=keep_missing_placeholders)
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders) if text_template is not None else None
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders) if html_template is not None else None
text_body = None
html_body = None
if text_template is not None:
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders)
if html_template is not None:
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders)
return _RenderedMessageTemplate(
subject=subject,
text_body=text_body,
html_body=html_body,
body_mode=body_mode,
values=values,
)
unresolved_fields = _find_unresolved_placeholders(subject)
if body_mode in {TemplateBodyMode.TEXT.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(text_body)
if body_mode in {TemplateBodyMode.HTML.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(html_body)
unresolved = sorted(unresolved_fields)
if unresolved:
def _unresolved_template_fields(rendered: _RenderedMessageTemplate) -> list[str]:
unresolved_fields = _find_unresolved_placeholders(rendered.subject)
if rendered.body_mode in {TemplateBodyMode.TEXT.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(rendered.text_body)
if rendered.body_mode in {TemplateBodyMode.HTML.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(rendered.html_body)
return sorted(unresolved_fields)
def _validate_rendered_template(
config: CampaignConfig,
rendered: _RenderedMessageTemplate,
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
unresolved = _unresolved_template_fields(rendered)
if not unresolved:
return validation_status
behavior = config.validation_policy.template_error.value
issues.append(
_issue_from_behavior(
@@ -515,10 +661,18 @@ def build_entry_message(
source="template",
)
)
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
if behavior == MissingAddressBehavior.BLOCK.value:
return MessageValidationStatus.BLOCKED
return MessageValidationStatus.EXCLUDED
message = EmailMessage()
try:
def _populate_message_headers(
message: EmailMessage,
*,
senders: list[RecipientConfig],
recipients: dict[str, list[RecipientConfig]],
subject: str,
) -> None:
message["Date"] = formatdate(localtime=True)
message["Message-ID"] = make_msgid()
if senders:
@@ -536,75 +690,152 @@ def build_entry_message(
if recipients["reply_to"]:
message["Reply-To"] = _format_recipient_header(recipients["reply_to"])
if recipients["disposition_notification_to"]:
message["Disposition-Notification-To"] = _format_recipient_header(recipients["disposition_notification_to"])
message["Disposition-Notification-To"] = _format_recipient_header(
recipients["disposition_notification_to"]
)
# bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender.
message["Subject"] = subject
effective_html_body = html_body if html_body and html_body.strip() else None
if body_mode == TemplateBodyMode.HTML.value:
def _populate_message_body(message: EmailMessage, rendered: _RenderedMessageTemplate) -> None:
effective_html_body = rendered.html_body if rendered.html_body and rendered.html_body.strip() else None
if rendered.body_mode == TemplateBodyMode.HTML.value:
message.set_content(effective_html_body or "", subtype="html")
elif body_mode == TemplateBodyMode.TEXT.value:
message.set_content(text_body or "")
elif rendered.body_mode == TemplateBodyMode.TEXT.value:
message.set_content(rendered.text_body or "")
elif effective_html_body is not None:
message.set_content(text_body or "")
message.set_content(rendered.text_body or "")
message.add_alternative(effective_html_body, subtype="html")
else:
message.set_content(text_body or "")
message.set_content(rendered.text_body or "")
def _build_mime_message(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
output_dir: Path | None,
work_dir: Path | None,
context: _EntryMessageContext,
rendered: _RenderedMessageTemplate,
) -> _MimeBuildResult:
message = EmailMessage()
try:
_populate_message_headers(
message,
senders=context.senders,
recipients=context.recipients,
subject=rendered.subject,
)
_populate_message_body(message, rendered)
if work_dir is None:
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="multimailer-build-"))
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
attachment_count = _attach_files(
message=message,
config=config,
entry=entry,
entry_index=entry_index,
resolution=resolution,
values=values,
resolution=context.resolution,
values=rendered.values,
work_dir=work_dir,
)
build_status = BuildStatus.BUILT
return _MimeBuildResult(
message=message,
build_status=BuildStatus.BUILT,
validation_status=context.validation_status,
attachment_count=attachment_count,
)
except ZipBuildError as exc:
issues.append(MessageIssue(severity="error", code=exc.code, message=str(exc), behavior="block", source="attachments"))
validation_status = MessageValidationStatus.BLOCKED
build_status = BuildStatus.BUILD_FAILED
attachment_count = 0
message = None # type: ignore[assignment]
context.issues.append(
MessageIssue(
severity="error",
code=exc.code,
message=str(exc),
behavior="block",
source="attachments",
)
)
except Exception as exc:
issues.append(MessageIssue(severity="error", code="build_failed", message=str(exc), behavior="block", source="builder"))
validation_status = MessageValidationStatus.BLOCKED
build_status = BuildStatus.BUILD_FAILED
attachment_count = 0
message = None # type: ignore[assignment]
context.issues.append(
MessageIssue(
severity="error",
code="build_failed",
message=str(exc),
behavior="block",
source="builder",
)
)
return _MimeBuildResult(
message=None,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.BLOCKED,
attachment_count=0,
)
def build_entry_message(
*,
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
entry_index: int,
output_dir: Path | None = None,
write_eml: bool = False,
work_dir: Path | None = None,
attachment_match_index: AttachmentMatchIndex | None = None,
) -> BuiltMessage:
context = _entry_message_context(
config=config,
campaign_file=campaign_file,
entry=entry,
entry_index=entry_index,
attachment_match_index=attachment_match_index,
)
if not entry.active:
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
context.validation_status = _validate_required_recipients(
config,
context.recipients,
context.issues,
context.validation_status,
)
rendered = _render_message_template(config, campaign_file, entry)
context.validation_status = _validate_rendered_template(
config,
rendered,
context.issues,
context.validation_status,
)
mime_result = _build_mime_message(
config=config,
entry=entry,
entry_index=entry_index,
output_dir=output_dir,
work_dir=work_dir,
context=context,
rendered=rendered,
)
eml_path: str | None = None
eml_size: int | None = None
if write_eml and output_dir is not None and message is not None:
eml_path, eml_size = _write_eml(message, output_dir, entry, entry_index)
if write_eml and output_dir is not None and mime_result.message is not None:
eml_path, eml_size = _write_eml(mime_result.message, output_dir, entry, entry_index)
draft = MessageDraft(
draft = _message_draft(
config=config,
entry=entry,
entry_index=entry_index,
entry_id=entry.id,
active=entry.active,
build_status=build_status,
validation_status=validation_status,
send_status=SendStatus.DRAFT,
imap_status=_imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED,
subject=subject,
from_=_message_address(sender),
from_all=_message_addresses(senders),
to=_message_addresses(recipients["to"]),
cc=_message_addresses(recipients["cc"]),
bcc=_message_addresses(recipients["bcc"]),
reply_to=_message_addresses(recipients["reply_to"]),
bounce_to=_message_addresses(recipients["bounce_to"]),
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
attachment_count=attachment_count,
attachments=_attachment_summaries(resolution),
issues=issues,
context=context,
build_status=mime_result.build_status,
validation_status=mime_result.validation_status,
subject=rendered.subject,
attachment_count=mime_result.attachment_count,
eml_path=eml_path,
eml_size_bytes=eml_size,
eml_size=eml_size,
)
return BuiltMessage(draft=draft, mime=message)
return BuiltMessage(draft=draft, mime=mime_result.message)
@@ -680,7 +911,7 @@ def build_campaign_messages(
started = time.perf_counter()
attachment_match_index = AttachmentMatchIndex()
with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp:
with tempfile.TemporaryDirectory(prefix="govoplan-build-") as tmp:
work_dir = output_path or Path(tmp)
built_messages = [
build_entry_message(

View File

@@ -0,0 +1,71 @@
"""recipient import mapping profiles
Revision ID: 2c3d4e5f7081
Revises: 2e3f4a5b6c7d
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 = "2e3f4a5b6c7d"
branch_labels = None
depends_on = None
def _scope_fk_target(inspector) -> str:
tables = set(inspector.get_table_names())
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "campaign_recipient_import_mapping_profiles" in inspector.get_table_names():
return
scope_fk_target = _scope_fk_target(inspector)
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"], ["access_users.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_owner_user_id_users"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_scopes"), 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,51 @@
"""v0.1.7 campaign baseline
Revision ID: 2c3d4e5f7081
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '2c3d4e5f7081'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
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'], ['access_users.id'], name=op.f('fk_campaign_recipient_import_mapping_profiles_owner_user_id_access_users'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_campaign_recipient_import_mapping_profiles_tenant_id_scopes'), 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_ordered_header_fingerprint'), 'campaign_recipient_import_mapping_profiles', ['ordered_header_fingerprint'], 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_tenant_id'), 'campaign_recipient_import_mapping_profiles', ['tenant_id'], 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_ordered_fp', 'campaign_recipient_import_mapping_profiles', ['tenant_id', 'owner_user_id', 'ordered_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_unordered_fp', 'campaign_recipient_import_mapping_profiles', ['tenant_id', 'owner_user_id', 'unordered_header_fingerprint'], unique=False)
def downgrade() -> None:
op.drop_table('campaign_recipient_import_mapping_profiles')

View File

@@ -275,7 +275,7 @@ def validate_campaign_version(
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=False,
prefix="multimailer-managed-validate-",
prefix="govoplan-managed-validate-",
) as prepared:
managed_raw = load_campaign_json(prepared.path)
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
@@ -407,7 +407,7 @@ def build_campaign_version(
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True,
prefix="multimailer-managed-build-",
prefix="govoplan-managed-build-",
) as prepared:
managed_raw = load_campaign_json(prepared.path)
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import copy
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
@@ -31,6 +32,39 @@ class LockedCampaignVersionError(CampaignPersistenceError):
"""Raised when a caller tries to edit an immutable campaign version."""
@dataclass(slots=True)
class _PartialValidationCollector:
section: str | None
issues: list[dict[str, Any]] = field(default_factory=list)
def issue(self, severity: str, section: str, field: str, code: str, message: str) -> None:
if self.section is None or self.section == section:
self.issues.append({
"severity": severity,
"section": section,
"field": field,
"code": code,
"message": message,
})
def result(self) -> dict[str, Any]:
return {
"ok": not any(item["severity"] == "error" for item in self.issues),
"section": self.section,
"error_count": sum(1 for item in self.issues if item["severity"] == "error"),
"warning_count": sum(1 for item in self.issues if item["severity"] == "warning"),
"info_count": sum(1 for item in self.issues if item["severity"] == "info"),
"issues": self.issues,
}
def _require_campaign(session: Session, campaign_id: str) -> Campaign:
campaign = session.get(Campaign, campaign_id)
if campaign is None:
raise CampaignPersistenceError(f"Campaign not found: {campaign_id}")
return campaign
USER_LOCK_TEMPORARY = "temporary"
USER_LOCK_PERMANENT = "permanent"
USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT}
@@ -320,8 +354,7 @@ def fork_campaign_version_for_edit(
"""
source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
if campaign_has_active_working_version(session, campaign):
current = session.get(CampaignVersion, campaign.current_version_id)
@@ -429,8 +462,7 @@ def unlock_validated_campaign_version(
"""
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="unlock")
if is_temporary_user_locked_version(version):
@@ -490,8 +522,7 @@ def update_campaign_version(
autosave: bool = False,
) -> CampaignVersion:
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="edit")
if is_version_locked(version):
@@ -566,25 +597,45 @@ def update_campaign_review_state(
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="record review state for")
if is_version_final_locked(version):
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
build_token = _campaign_review_build_token(version)
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
if inspection_complete:
normalized_reviewed = _complete_campaign_review_keys(session, version, normalized_reviewed)
_write_campaign_review_state(
version,
build_token=build_token,
inspection_complete=inspection_complete,
reviewed_message_keys=normalized_reviewed,
user_id=user_id,
)
session.add(version)
session.commit()
return version
def _campaign_review_build_token(version: CampaignVersion) -> str:
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
if not build_summary:
raise CampaignPersistenceError("Build messages before recording review state.")
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip()
if not build_token:
# Backwards-compatible upgrade for build summaries created before
# review-state tokens were introduced.
if build_token:
return build_token
build_token = uuid4().hex
build_summary = copy.deepcopy(build_summary)
build_summary["build_token"] = build_token
version.build_summary = build_summary
updated_summary = copy.deepcopy(build_summary)
updated_summary["build_token"] = build_token
version.build_summary = updated_summary
return build_token
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
if inspection_complete:
def _complete_campaign_review_keys(
session: Session,
version: CampaignVersion,
reviewed_message_keys: list[str],
) -> list[str]:
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
@@ -594,36 +645,47 @@ def update_campaign_review_state(
blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"]
if blocking:
raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.")
reviewed = set(normalized_reviewed)
explicit = {
str(job.entry_id or job.entry_index)
for job in jobs
if job.validation_status == "needs_review"
}
missing = sorted(explicit - reviewed)
missing = sorted(_required_review_keys(jobs) - set(reviewed_message_keys))
if missing:
raise CampaignPersistenceError(
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
)
bulk_acceptable = [
return list(dict.fromkeys([*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]))
def _required_review_keys(jobs: list[CampaignJob]) -> set[str]:
return {
str(job.entry_id or job.entry_index)
for job in jobs
if job.validation_status == "needs_review"
}
def _bulk_acceptable_review_keys(jobs: list[CampaignJob]) -> list[str]:
return [
str(job.entry_id or job.entry_index)
for job in jobs
if job.validation_status in {"warning", "excluded"}
]
normalized_reviewed = list(dict.fromkeys([*normalized_reviewed, *bulk_acceptable]))
def _write_campaign_review_state(
version: CampaignVersion,
*,
build_token: str,
inspection_complete: bool,
reviewed_message_keys: list[str],
user_id: str | None,
) -> None:
editor_state = copy.deepcopy(version.editor_state or {})
editor_state["review_send"] = {
"build_token": build_token,
"inspection_complete": bool(inspection_complete),
"reviewed_message_keys": normalized_reviewed,
"reviewed_message_keys": reviewed_message_keys,
"updated_at": datetime.now(UTC).isoformat(),
"updated_by_user_id": user_id,
}
version.editor_state = editor_state
session.add(version)
session.commit()
return version
def lock_campaign_version_temporarily(
@@ -642,8 +704,7 @@ def lock_campaign_version_temporarily(
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="lock")
if is_version_final_locked(version):
raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.")
@@ -677,8 +738,7 @@ def unlock_user_locked_campaign_version(
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="unlock")
state = campaign_version_user_lock_state(version)
if state == USER_LOCK_PERMANENT:
@@ -716,8 +776,7 @@ def permanently_lock_campaign_version(
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="lock permanently")
if is_version_final_locked(version):
raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.")
@@ -761,79 +820,101 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
lets the WebUI autosave and validate one wizard step at a time.
"""
issues: list[dict[str, Any]] = []
collector = _PartialValidationCollector(section=section)
_validate_partial_basics(collector, _dict_value(raw_json, "campaign"))
recipients = _dict_value(raw_json, "recipients")
_validate_partial_sender(collector, recipients)
_validate_partial_recipients(collector, _dict_value(raw_json, "entries"))
_validate_partial_template(collector, _dict_value(raw_json, "template"))
_validate_partial_attachments(collector, _dict_value(raw_json, "attachments"))
_validate_partial_delivery(collector, _dict_value(raw_json, "delivery"))
return collector.result()
def issue(severity: str, sec: str, field: str, code: str, message: str) -> None:
if section is None or section == sec:
issues.append({
"severity": severity,
"section": sec,
"field": field,
"code": code,
"message": message,
})
campaign = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
def _dict_value(value: dict[str, Any], key: str) -> dict[str, Any]:
candidate = value.get(key)
return candidate if isinstance(candidate, dict) else {}
def _validate_partial_basics(collector: _PartialValidationCollector, campaign: dict[str, Any]) -> None:
if not campaign.get("id"):
issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
collector.issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
if not campaign.get("name"):
issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
collector.issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
recipients = raw_json.get("recipients") if isinstance(raw_json.get("recipients"), dict) else {}
def _validate_partial_sender(collector: _PartialValidationCollector, recipients: dict[str, Any]) -> None:
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
if not sender.get("email"):
issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
entries = raw_json.get("entries") if isinstance(raw_json.get("entries"), dict) else {}
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
has_inline = bool(entries.get("inline"))
has_source = isinstance(entries.get("source"), dict)
if not has_inline and not has_source:
issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
if has_source:
mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {}
if not any(key in mapping for key in ("to.0.email", "to.email", "email")):
issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
collector.issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
if has_source and not _entries_source_has_email_mapping(entries):
collector.issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {}
def _entries_source_has_email_mapping(entries: dict[str, Any]) -> bool:
mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {}
return any(key in mapping for key in ("to.0.email", "to.email", "email"))
def _validate_partial_template(collector: _PartialValidationCollector, template: dict[str, Any]) -> None:
source_template = template.get("source") if isinstance(template.get("source"), dict) else {}
if not template.get("subject") and not source_template.get("subject_path"):
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
explicit_body_mode = template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None
has_text_body = bool(template.get("text")) or bool(source_template.get("text_path"))
has_html_body = bool(template.get("html")) or bool(source_template.get("html_path"))
if explicit_body_mode == "text" and not has_text_body:
issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
elif explicit_body_mode == "html" and not has_html_body:
issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
elif explicit_body_mode == "both":
if not has_text_body:
issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
if not has_html_body:
issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
elif not has_text_body and not has_html_body and not source_template:
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
collector.issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
body_state = _partial_template_body_state(template, source_template)
_validate_partial_template_body(collector, body_state)
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {}
def _partial_template_body_state(template: dict[str, Any], source_template: dict[str, Any]) -> dict[str, Any]:
return {
"mode": template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None,
"has_text": bool(template.get("text")) or bool(source_template.get("text_path")),
"has_html": bool(template.get("html")) or bool(source_template.get("html_path")),
"has_source": bool(source_template),
}
def _validate_partial_template_body(collector: _PartialValidationCollector, state: dict[str, Any]) -> None:
mode = state["mode"]
has_text = bool(state["has_text"])
has_html = bool(state["has_html"])
if mode == "text" and not has_text:
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
elif mode == "html" and not has_html:
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
elif mode == "both":
_validate_partial_dual_body_template(collector, has_text=has_text, has_html=has_html)
elif not has_text and not has_html and not state["has_source"]:
collector.issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
def _validate_partial_dual_body_template(collector: _PartialValidationCollector, *, has_text: bool, has_html: bool) -> None:
if not has_text:
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
if not has_html:
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
def _validate_partial_attachments(collector: _PartialValidationCollector, attachments: dict[str, Any]) -> None:
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths)
if not has_named_base_path and not attachments.get("base_path"):
issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
collector.issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
delivery = raw_json.get("delivery") if isinstance(raw_json.get("delivery"), dict) else {}
def _validate_partial_delivery(collector: _PartialValidationCollector, delivery: dict[str, Any]) -> None:
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
messages_per_minute = rate_limit.get("messages_per_minute")
if messages_per_minute is not None:
if messages_per_minute is None:
return
try:
if int(messages_per_minute) < 1:
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
except (TypeError, ValueError):
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")
return {
"ok": not any(item["severity"] == "error" for item in issues),
"section": section,
"error_count": sum(1 for item in issues if item["severity"] == "error"),
"warning_count": sum(1 for item in issues if item["severity"] == "warning"),
"info_count": sum(1 for item in issues if item["severity"] == "info"),
"issues": issues,
}
collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")

View File

@@ -256,6 +256,99 @@ def _job_row(job: CampaignJob) -> dict[str, Any]:
}
def _address_summary(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, dict):
email = str(value.get("email") or "").strip()
name = str(value.get("name") or "").strip()
if name and email:
return f"{name} <{email}>"
return email or name
if isinstance(value, list):
return "; ".join(item for item in (_address_summary(item) for item in value) if item)
return str(value)
def _latest_by_job_id(attempts: list[Any]) -> dict[str, Any]:
latest: dict[str, Any] = {}
for attempt in attempts:
current = latest.get(attempt.job_id)
if current is None or (attempt.attempt_number or 0) >= (current.attempt_number or 0):
latest[attempt.job_id] = attempt
return latest
def _attachment_names(attachments: list[dict[str, Any]] | None) -> str:
names: list[str] = []
for attachment in attachments or []:
if not isinstance(attachment, dict):
continue
for key in ("message_filenames", "zip_entry_names"):
values = attachment.get(key)
if isinstance(values, list):
names.extend(str(value) for value in values if value)
zip_filename = attachment.get("zip_filename")
if zip_filename:
names.append(str(zip_filename))
matches = attachment.get("matches")
if isinstance(matches, list):
for match in matches:
if isinstance(match, dict):
filename = match.get("filename") or match.get("name") or match.get("display_name")
if filename:
names.append(str(filename))
elif match:
names.append(str(match).rsplit("/", 1)[-1])
seen: set[str] = set()
deduplicated = []
for name in names:
if name and name not in seen:
seen.add(name)
deduplicated.append(name)
return "; ".join(deduplicated)
def _job_evidence_row(
job: CampaignJob,
*,
latest_smtp: SendAttempt | None = None,
latest_imap: ImapAppendAttempt | None = None,
) -> dict[str, Any]:
row = _job_row(job)
recipients = job.resolved_recipients or {}
row.update({
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"message_id_header": job.message_id_header,
"eml_storage_key": job.eml_storage_key,
"eml_local_path": job.eml_local_path,
"from": _address_summary(recipients.get("from")),
"to": _address_summary(recipients.get("to")),
"cc": _address_summary(recipients.get("cc")),
"bcc": _address_summary(recipients.get("bcc")),
"reply_to": _address_summary(recipients.get("reply_to")),
"attachment_names": _attachment_names(job.resolved_attachments),
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None,
"latest_smtp_status": latest_smtp.status if latest_smtp else None,
"latest_smtp_status_code": latest_smtp.smtp_status_code if latest_smtp else None,
"latest_smtp_response": latest_smtp.smtp_response if latest_smtp else None,
"latest_smtp_error_type": latest_smtp.error_type if latest_smtp else None,
"latest_smtp_error_message": latest_smtp.error_message if latest_smtp else None,
"latest_smtp_started_at": latest_smtp.started_at.isoformat() if latest_smtp and latest_smtp.started_at else None,
"latest_smtp_finished_at": latest_smtp.finished_at.isoformat() if latest_smtp and latest_smtp.finished_at else None,
"latest_imap_attempt_number": latest_imap.attempt_number if latest_imap else None,
"latest_imap_status": latest_imap.status if latest_imap else None,
"latest_imap_folder": latest_imap.folder if latest_imap else None,
"latest_imap_error_message": latest_imap.error_message if latest_imap else None,
"latest_imap_created_at": latest_imap.created_at.isoformat() if latest_imap and latest_imap.created_at else None,
"latest_imap_updated_at": latest_imap.updated_at.isoformat() if latest_imap and latest_imap.updated_at else None,
})
return row
def generate_campaign_report(
session: Session,
*,
@@ -274,38 +367,127 @@ def generate_campaign_report(
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
version = _selected_version(session, campaign, version_id)
jobs = _report_jobs(session, tenant_id=tenant_id, campaign_id=campaign.id, version=version)
report = _campaign_report_payload(
session,
campaign=campaign,
version=version,
jobs=jobs,
tenant_id=tenant_id,
include_recent_failures=include_recent_failures,
)
if include_jobs:
report["jobs"] = [_job_row(job) for job in jobs]
return report
def _report_jobs(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> list[CampaignJob]:
jobs_query = session.query(CampaignJob).filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
CampaignJob.campaign_id == campaign_id,
)
if version:
jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id)
else:
jobs_query = jobs_query.filter(False)
jobs = (
jobs_query
.order_by(CampaignJob.entry_index.asc())
.all()
)
return jobs_query.order_by(CampaignJob.entry_index.asc()).all()
def _campaign_report_payload(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion | None,
jobs: list[CampaignJob],
tenant_id: str,
include_recent_failures: bool,
) -> dict[str, Any]:
job_ids = [job.id for job in jobs]
send_attempts = session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count() if job_ids else 0
imap_attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count() if job_ids else 0
report = {
"generated_at": _utcnow_iso(),
"campaign": _campaign_report_campaign_payload(campaign),
"current_version": _version_info(version),
"selected_version_id": version.id if version else None,
"cards": _campaign_report_cards(version, jobs),
"status_counts": _campaign_report_status_counts(version, jobs),
"issues": {
**_issue_summary_from_jobs(jobs),
"persisted_campaign_issue_count": _persisted_campaign_issue_count(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
},
"attachments": _attachment_summary(jobs),
"attempts": _campaign_report_attempt_counts(session, job_ids),
"delivery": _load_delivery_info(version, jobs),
}
if include_recent_failures:
report["recent_failures"] = _recent_failures(jobs)
return report
def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
return {
"id": campaign.id,
"external_id": campaign.external_id,
"name": campaign.name,
"description": campaign.description,
"status": campaign.status,
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
}
def _campaign_report_attempt_counts(session: Session, job_ids: list[str]) -> dict[str, int]:
return {
"send_attempts": int(session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
"imap_append_attempts": int(session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
}
def _persisted_campaign_issue_count(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> int:
issue_query = session.query(CampaignIssue).filter(
CampaignIssue.tenant_id == tenant_id,
CampaignIssue.campaign_id == campaign.id,
CampaignIssue.campaign_id == campaign_id,
)
if version:
issue_query = issue_query.filter(CampaignIssue.campaign_version_id == version.id)
else:
issue_query = issue_query.filter(False)
persisted_issues = issue_query.count()
return int(issue_query.count())
def _campaign_report_status_counts(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, dict[str, int]]:
validation_counts = _counter([job.validation_status for job in jobs])
queue_counts = _counter([job.queue_status for job in jobs])
inactive_entries = _inactive_entry_count(version)
if inactive_entries:
validation_counts["inactive"] = inactive_entries
return {
"build": _counter([job.build_status for job in jobs]),
"validation": validation_counts,
"queue": _counter([job.queue_status for job in jobs]),
"send": _counter([job.send_status for job in jobs]),
"imap": _counter([job.imap_status for job in jobs]),
}
def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, object]:
send_counts = _counter([job.send_status for job in jobs])
imap_counts = _counter([job.imap_status for job in jobs])
build_counts = _counter([job.build_status for job in jobs])
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built")
needs_attention = sum(
1
@@ -320,25 +502,8 @@ def generate_campaign_report(
not_attempted = send_counts.get("not_queued", 0)
queued = send_counts.get("queued", 0) + send_counts.get("claimed", 0) + send_counts.get("sending", 0)
cancelled = send_counts.get("cancelled", 0)
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
inactive_entries = int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
if inactive_entries:
validation_counts["inactive"] = inactive_entries
report: dict[str, Any] = {
"generated_at": _utcnow_iso(),
"campaign": {
"id": campaign.id,
"external_id": campaign.external_id,
"name": campaign.name,
"description": campaign.description,
"status": campaign.status,
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
},
"current_version": _version_info(version),
"selected_version_id": version.id if version else None,
"cards": {
inactive_entries = _inactive_entry_count(version)
return {
"jobs_total": len(jobs),
"inactive": inactive_entries,
"queueable": queueable,
@@ -353,30 +518,12 @@ def generate_campaign_report(
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)),
"imap_appended": imap_counts.get("appended", 0),
"imap_failed": imap_counts.get("failed", 0),
},
"status_counts": {
"build": build_counts,
"validation": validation_counts,
"queue": queue_counts,
"send": send_counts,
"imap": imap_counts,
},
"issues": {
**_issue_summary_from_jobs(jobs),
"persisted_campaign_issue_count": persisted_issues,
},
"attachments": _attachment_summary(jobs),
"attempts": {
"send_attempts": int(send_attempts),
"imap_append_attempts": int(imap_attempts),
},
"delivery": _load_delivery_info(version, jobs),
}
if include_recent_failures:
report["recent_failures"] = _recent_failures(jobs)
if include_jobs:
report["jobs"] = [_job_row(job) for job in jobs]
return report
def _inactive_entry_count(version: CampaignVersion | None) -> int:
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
return int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
def generate_jobs_csv(
@@ -401,13 +548,47 @@ def generate_jobs_csv(
.order_by(CampaignJob.entry_index.asc())
.all()
)
rows = [_job_row(job) for job in jobs]
job_ids = [job.id for job in jobs]
smtp_attempts = (
session.query(SendAttempt)
.filter(SendAttempt.job_id.in_(job_ids))
.order_by(SendAttempt.job_id.asc(), SendAttempt.attempt_number.asc())
.all()
if job_ids
else []
)
imap_attempts = (
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id.in_(job_ids))
.order_by(ImapAppendAttempt.job_id.asc(), ImapAppendAttempt.attempt_number.asc())
.all()
if job_ids
else []
)
latest_smtp = _latest_by_job_id(smtp_attempts)
latest_imap = _latest_by_job_id(imap_attempts)
rows = [
_job_evidence_row(
job,
latest_smtp=latest_smtp.get(job.id),
latest_imap=latest_imap.get(job.id),
)
for job in jobs
]
fieldnames = [
"job_id",
"campaign_id",
"campaign_version_id",
"entry_index",
"entry_id",
"recipient_email",
"from",
"to",
"cc",
"bcc",
"reply_to",
"subject",
"message_id_header",
"build_status",
"validation_status",
"queue_status",
@@ -422,9 +603,26 @@ def generate_jobs_csv(
"last_error",
"eml_size_bytes",
"eml_sha256",
"eml_storage_key",
"eml_local_path",
"issues_count",
"attachment_config_count",
"matched_file_count",
"attachment_names",
"latest_smtp_attempt_number",
"latest_smtp_status",
"latest_smtp_status_code",
"latest_smtp_response",
"latest_smtp_error_type",
"latest_smtp_error_message",
"latest_smtp_started_at",
"latest_smtp_finished_at",
"latest_imap_attempt_number",
"latest_imap_status",
"latest_imap_folder",
"latest_imap_error_message",
"latest_imap_created_at",
"latest_imap_updated_at",
]
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=fieldnames)

View File

@@ -105,7 +105,7 @@ def _text_summary(report: dict[str, Any]) -> str:
]
if delivery.get("estimated_remaining_send_human"):
lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"])
lines.extend(["", "This report was generated by MultiMailer."])
lines.extend(["", "This report was generated by GovOPlaN."])
return "\n".join(lines)
@@ -119,20 +119,20 @@ def build_report_message(
report_json: dict[str, Any] | None = None,
) -> EmailMessage:
from_email, from_name = _effective_from(config)
subject = f"MultiMailer report: {campaign.name}"
subject = f"GovOPlaN report: {campaign.name}"
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = formataddr((from_name or from_email, from_email))
msg["To"] = ", ".join(to)
msg["X-MultiMailer-Report"] = "campaign"
msg["X-GovOPlaN-Report"] = "campaign"
msg.set_content(_text_summary(report))
if jobs_csv is not None:
filename = f"multimailer-{campaign.external_id}-jobs.csv"
filename = f"govoplan-{campaign.external_id}-jobs.csv"
msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename)
if report_json is not None:
filename = f"multimailer-{campaign.external_id}-report.json"
filename = f"govoplan-{campaign.external_id}-report.json"
msg.add_attachment(
json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"),
maintype="application",

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),
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://multimailer.local/schema/campaign.schema.json",
"title": "MultiMailer Campaign",
"$id": "https://govoplan.local/schema/campaign.schema.json",
"title": "GovOPlaN Campaign",
"type": "object",
"required": [
"version",
@@ -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,174 @@
},
"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",
"addresses"
]
},
"source_id": {
"type": [
"string",
"null"
]
},
"source_label": {
"type": [
"string",
"null"
]
},
"source_revision": {
"type": [
"string",
"null"
]
},
"source_provenance": {
"type": "object",
"default": {}
},
"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_core.auth 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,8 +3,9 @@ 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.api.v1.schemas import DeltaDeletedItem
from govoplan_core.mail.config import ImapConfig, SmtpConfig
@@ -145,6 +146,21 @@ class CampaignWorkspaceResponse(BaseModel):
selected_version_id: str | None = None
class CampaignDeltaResponse(BaseModel):
campaigns: list[CampaignResponse] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CampaignShareItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -186,6 +202,129 @@ 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 CampaignAddressLookupCandidate(BaseModel):
contact_id: str
address_book_id: str
display_name: str
email: str | None = None
email_label: str | None = None
organization: str | None = None
role_title: str | None = None
tags: list[str] = Field(default_factory=list)
source_kind: str = "local"
source_ref: str | None = None
source_revision: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignAddressLookupResponse(BaseModel):
available: bool = False
candidates: list[CampaignAddressLookupCandidate] = Field(default_factory=list)
class CampaignRecipientAddressSource(BaseModel):
source_id: str
source_label: str
source_kind: str
source_revision: str
recipient_count: int = 0
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientAddressSourcesResponse(BaseModel):
available: bool = False
sources: list[CampaignRecipientAddressSource] = Field(default_factory=list)
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_id: str = Field(min_length=1)
class CampaignRecipientSnapshotItem(BaseModel):
contact_id: str
display_name: str
email: str
email_label: str | None = None
fields: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_id: str
source_label: str
source_kind: str
source_revision: str
generated_at: str
recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list)
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignJobsResponse(BaseModel):
jobs: list[dict[str, Any]]
page: int = 1
@@ -193,11 +332,20 @@ class CampaignJobsResponse(BaseModel):
total: int = 0
total_unfiltered: int = 0
pages: int = 0
cursor: str | None = None
next_cursor: str | None = None
counts: dict[str, dict[str, int]] = Field(default_factory=dict)
filtered_counts: dict[str, dict[str, int]] = Field(default_factory=dict)
review: dict[str, Any] = Field(default_factory=dict)
class CampaignJobsDeltaResponse(CampaignJobsResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CampaignJobDetailResponse(BaseModel):
job: dict[str, Any]
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)

View File

@@ -219,8 +219,10 @@ def create_execution_snapshot(
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
redacted_smtp = _redacted_transport_config(smtp)
redacted_imap = _redacted_transport_config(imap)
assert isinstance(redacted_smtp, SmtpConfig)
assert redacted_imap is None or isinstance(redacted_imap, ImapConfig)
if not isinstance(redacted_smtp, SmtpConfig):
raise ExecutionSnapshotError("Redacted SMTP configuration is invalid.")
if redacted_imap is not None and not isinstance(redacted_imap, ImapConfig):
raise ExecutionSnapshotError("Redacted IMAP configuration is invalid.")
payload = ExecutionSnapshot(
campaign_version_id=version.id,
campaign_json_sha256=_sha256(raw_json),

View File

@@ -12,6 +12,7 @@ from uuid import uuid4
from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import (
Campaign,
@@ -28,6 +29,7 @@ from govoplan_campaign.backend.db.models import (
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot, runtime_imap_config, runtime_smtp_config
from govoplan_campaign.backend.runtime import get_registry
from govoplan_campaign.backend.integrations import (
ImapAppendError,
ImapConfigurationError,
@@ -133,6 +135,25 @@ class AppendSentResult:
}
@dataclass(frozen=True, slots=True)
class _CampaignDeliveryCounts:
accepted: int
unknown: int
failed: int
active: int
cancelled: int
not_started: int
@dataclass(frozen=True, slots=True)
class _SendJobDeliveryContext:
version: CampaignVersion
snapshot: ExecutionSnapshot
message_bytes: bytes
envelope_from: str
envelope_recipients: list[str]
QUEUEABLE_VALIDATION_STATUSES = {
JobValidationStatus.READY.value,
JobValidationStatus.WARNING.value,
@@ -140,6 +161,15 @@ QUEUEABLE_VALIDATION_STATUSES = {
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
CAMPAIGN_STATUS_NOTIFICATION_EVENTS = {
CampaignStatus.QUEUED.value: ("campaign.queued", "Campaign queued", 1),
CampaignStatus.SENDING.value: ("campaign.sending", "Campaign sending started", 1),
CampaignStatus.SENT.value: ("campaign.sent", "Campaign sent", 1),
CampaignStatus.PARTIALLY_COMPLETED.value: ("campaign.partially_completed", "Campaign partially completed", 4),
CampaignStatus.OUTCOME_UNKNOWN.value: ("campaign.outcome_unknown", "Campaign requires review", 5),
CampaignStatus.FAILED.value: ("campaign.failed", "Campaign failed", 5),
CampaignStatus.CANCELLED.value: ("campaign.cancelled", "Campaign cancelled", 2),
}
def _version_user_lock_state(version: CampaignVersion) -> str | None:
@@ -210,16 +240,72 @@ def _should_enqueue_celery(enqueue_celery: bool) -> bool:
return bool(enqueue_celery and _celery_enabled())
def _campaign_notification_body(campaign: Campaign, status: str) -> str:
return {
CampaignStatus.QUEUED.value: f"{campaign.name} has been queued for delivery.",
CampaignStatus.SENDING.value: f"{campaign.name} started sending.",
CampaignStatus.SENT.value: f"{campaign.name} finished sending.",
CampaignStatus.PARTIALLY_COMPLETED.value: f"{campaign.name} finished with partial delivery results. Review the campaign report.",
CampaignStatus.OUTCOME_UNKNOWN.value: f"{campaign.name} has unresolved delivery outcomes and needs operator review.",
CampaignStatus.FAILED.value: f"{campaign.name} failed during delivery. Review the failed jobs.",
CampaignStatus.CANCELLED.value: f"{campaign.name} was cancelled.",
}.get(status, f"{campaign.name} changed status to {status}.")
def _emit_campaign_status_notification(
session: Session,
*,
campaign: Campaign,
status: str,
previous_status: str | None = None,
version_id: str | None = None,
message: str | None = None,
) -> None:
provider = notification_dispatch_provider(get_registry())
if provider is None:
return
event = CAMPAIGN_STATUS_NOTIFICATION_EVENTS.get(status)
if event is None:
return
event_kind, title, priority = event
try:
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=campaign.tenant_id,
source_module="campaigns",
source_resource_type="campaign",
source_resource_id=campaign.id,
event_kind=event_kind,
channel="inbox",
subject=f"{title}: {campaign.name}",
body_text=message or _campaign_notification_body(campaign, status),
action_url=f"/campaigns/{campaign.id}",
priority=priority,
payload={
"campaign_id": campaign.id,
"version_id": version_id or campaign.current_version_id,
"status": status,
"previous_status": previous_status,
},
metadata={"campaign_id": campaign.id, "version_id": version_id or campaign.current_version_id},
),
enqueue_delivery=False,
)
except Exception:
return
def _celery_enqueue_send_job(job_id: str) -> None:
from govoplan_core.celery_app import celery
celery.send_task("multimailer.send_email", args=[job_id], queue="send_email")
celery.send_task("govoplan.campaigns.send_email", args=[job_id], queue="send_email")
def _celery_enqueue_append_sent_job(job_id: str) -> None:
from govoplan_core.celery_app import celery
celery.send_task("multimailer.append_sent", args=[job_id], queue="append_sent")
celery.send_task("govoplan.campaigns.append_sent", args=[job_id], queue="append_sent")
def queue_campaign_jobs(
@@ -296,11 +382,20 @@ def queue_campaign_jobs(
if not dry_run:
if queued:
previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
if version.locked_at is None:
version.locked_at = _utcnow()
session.add(version)
if previous_status != campaign.status:
_emit_campaign_status_notification(
session,
campaign=campaign,
status=campaign.status,
previous_status=previous_status,
version_id=version.id,
)
session.add(campaign)
session.commit()
@@ -469,8 +564,16 @@ def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str,
job.send_status = JobSendStatus.QUEUED.value
session.add(job)
if jobs:
previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value
session.add(campaign)
if previous_status != campaign.status:
_emit_campaign_status_notification(
session,
campaign=campaign,
status=campaign.status,
previous_status=previous_status,
)
session.commit()
enqueued_count = 0
@@ -909,27 +1012,54 @@ def _update_campaign_after_job(session: Session, campaign_id: str, version_id: s
campaign = session.get(Campaign, campaign_id)
if not campaign:
return
base_filters = [CampaignJob.campaign_id == campaign_id]
if version_id:
base_filters.append(CampaignJob.campaign_version_id == version_id)
previous_status = campaign.status
version = session.get(CampaignVersion, version_id) if version_id else None
counts = _campaign_delivery_counts(session, campaign_id=campaign_id, version_id=version_id)
_apply_campaign_delivery_status(campaign, version, counts)
if version and (counts.accepted or counts.unknown):
if version.locked_at is None:
version.locked_at = _utcnow()
session.add(version)
session.add(campaign)
if campaign.status != previous_status:
_emit_campaign_status_notification(
session,
campaign=campaign,
status=campaign.status,
previous_status=previous_status,
version_id=version_id,
)
def _campaign_delivery_counts(session: Session, *, campaign_id: str, version_id: str | None) -> _CampaignDeliveryCounts:
base_filters = _campaign_delivery_base_filters(campaign_id=campaign_id, version_id=version_id)
counts = {
status: session.query(CampaignJob).filter(*base_filters, CampaignJob.send_status == status).count()
for status in {item.value for item in JobSendStatus}
}
accepted = sum(counts.get(status, 0) for status in SMTP_ACCEPTED_STATUSES)
unknown = counts.get(JobSendStatus.OUTCOME_UNKNOWN.value, 0)
failed = counts.get(JobSendStatus.FAILED_TEMPORARY.value, 0) + counts.get(JobSendStatus.FAILED_PERMANENT.value, 0)
return _CampaignDeliveryCounts(
accepted=sum(counts.get(status, 0) for status in SMTP_ACCEPTED_STATUSES),
unknown=counts.get(JobSendStatus.OUTCOME_UNKNOWN.value, 0),
failed=counts.get(JobSendStatus.FAILED_TEMPORARY.value, 0) + counts.get(JobSendStatus.FAILED_PERMANENT.value, 0),
active=(
counts.get(JobSendStatus.QUEUED.value, 0)
+ counts.get(JobSendStatus.CLAIMED.value, 0)
+ counts.get(JobSendStatus.SENDING.value, 0)
),
cancelled=counts.get(JobSendStatus.CANCELLED.value, 0),
not_started=_queueable_not_started_job_count(session, base_filters),
)
cancelled = counts.get(JobSendStatus.CANCELLED.value, 0)
# Blocked/excluded build rows are reportable but are not delivery attempts.
# Only a built, queueable message counts as an unattempted part of an
# execution when deriving a partial outcome.
not_started = (
def _campaign_delivery_base_filters(*, campaign_id: str, version_id: str | None) -> list[object]:
base_filters: list[object] = [CampaignJob.campaign_id == campaign_id]
if version_id:
base_filters.append(CampaignJob.campaign_version_id == version_id)
return base_filters
def _queueable_not_started_job_count(session: Session, base_filters: list[object]) -> int:
return (
session.query(CampaignJob)
.filter(
*base_filters,
@@ -940,37 +1070,35 @@ def _update_campaign_after_job(session: Session, campaign_id: str, version_id: s
.count()
)
version = session.get(CampaignVersion, version_id) if version_id else None
if active:
campaign.status = CampaignStatus.SENDING.value
if version:
version.workflow_state = CampaignVersionWorkflowState.SENDING.value
elif accepted and (failed or unknown or cancelled or not_started):
campaign.status = CampaignStatus.PARTIALLY_COMPLETED.value
if version:
version.workflow_state = CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value
elif unknown:
campaign.status = CampaignStatus.OUTCOME_UNKNOWN.value
if version:
version.workflow_state = CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value
elif failed:
campaign.status = CampaignStatus.FAILED.value
if version:
version.workflow_state = CampaignVersionWorkflowState.FAILED.value
elif accepted:
campaign.status = CampaignStatus.SENT.value
if version:
version.workflow_state = CampaignVersionWorkflowState.COMPLETED.value
elif cancelled:
campaign.status = CampaignStatus.CANCELLED.value
if version:
version.workflow_state = CampaignVersionWorkflowState.CANCELLED.value
if version and (accepted or unknown):
if version.locked_at is None:
version.locked_at = _utcnow()
session.add(version)
session.add(campaign)
def _apply_campaign_delivery_status(
campaign: Campaign,
version: CampaignVersion | None,
counts: _CampaignDeliveryCounts,
) -> None:
status_pair = _campaign_delivery_status_pair(counts)
if status_pair is None:
return
campaign_status, workflow_state = status_pair
campaign.status = campaign_status
if version:
version.workflow_state = workflow_state
def _campaign_delivery_status_pair(counts: _CampaignDeliveryCounts) -> tuple[str, str] | None:
if counts.active:
return CampaignStatus.SENDING.value, CampaignVersionWorkflowState.SENDING.value
if counts.accepted and (counts.failed or counts.unknown or counts.cancelled or counts.not_started):
return CampaignStatus.PARTIALLY_COMPLETED.value, CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value
if counts.unknown:
return CampaignStatus.OUTCOME_UNKNOWN.value, CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value
if counts.failed:
return CampaignStatus.FAILED.value, CampaignVersionWorkflowState.FAILED.value
if counts.accepted:
return CampaignStatus.SENT.value, CampaignVersionWorkflowState.COMPLETED.value
if counts.cancelled:
return CampaignStatus.CANCELLED.value, CampaignVersionWorkflowState.CANCELLED.value
return None
def send_campaign_job(
@@ -984,15 +1112,49 @@ def send_campaign_job(
job = session.get(CampaignJob, job_id)
if not job:
raise SendJobError(f"Job not found: {job_id}")
preflight_result = _preflight_send_campaign_job(session, job, dry_run=dry_run)
if preflight_result is not None:
return preflight_result
context = _send_job_delivery_context(session, job)
if dry_run:
return SendJobResult(
job_id=job.id,
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=f"Would send to {len(context.envelope_recipients)} recipient(s) from {context.envelope_from}",
)
claimed = _claimed_campaign_job_for_delivery(session, job)
if isinstance(claimed, SendJobResult):
return claimed
claimed_job, claim_token = claimed
return _send_claimed_campaign_job(
session,
job=claimed_job,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
def _preflight_send_campaign_job(
session: Session,
job: CampaignJob,
*,
dry_run: bool,
) -> SendJobResult | None:
if job.queue_status == JobQueueStatus.CANCELLED.value or job.send_status == JobSendStatus.CANCELLED.value:
return SendJobResult(job_id=job_id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run)
return SendJobResult(job_id=job.id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run)
if job.queue_status == JobQueueStatus.PAUSED.value:
return SendJobResult(job_id=job_id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run)
return SendJobResult(job_id=job.id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run)
if job.send_status in SMTP_ACCEPTED_STATUSES:
return SendJobResult(job_id=job_id, status="already_accepted", attempt_number=job.attempt_count, dry_run=dry_run)
return SendJobResult(job_id=job.id, status="already_accepted", attempt_number=job.attempt_count, dry_run=dry_run)
if job.send_status == JobSendStatus.OUTCOME_UNKNOWN.value:
return SendJobResult(
job_id=job_id,
job_id=job.id,
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=job.attempt_count,
dry_run=dry_run,
@@ -1014,7 +1176,10 @@ def send_campaign_job(
)
if job.queue_status != JobQueueStatus.QUEUED.value or job.send_status != JobSendStatus.QUEUED.value:
raise SendJobError(f"Job is not explicitly queued for delivery: queue={job.queue_status}, send={job.send_status}")
return None
def _send_job_delivery_context(session: Session, job: CampaignJob) -> _SendJobDeliveryContext:
version = session.get(CampaignVersion, job.campaign_version_id)
if not version:
raise SendJobError("Campaign version not found")
@@ -1028,18 +1193,30 @@ def send_campaign_job(
envelope_recipients = _recipients_from_job(job)
if not envelope_recipients:
raise SmtpConfigurationError("No envelope recipients could be determined")
if dry_run:
return SendJobResult(
job_id=job.id,
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=f"Would send to {len(envelope_recipients)} recipient(s) from {envelope_from}",
return _SendJobDeliveryContext(
version=version,
snapshot=snapshot,
message_bytes=message_bytes,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
def _claimed_campaign_job_for_delivery(
session: Session,
job: CampaignJob,
) -> tuple[CampaignJob, str] | SendJobResult:
claim_token = _claim_job_for_sending(session, job)
if claim_token is None:
return _not_claimed_send_job_result(session, job)
job = session.get(CampaignJob, job.id)
if job is None:
raise SendJobError("Claimed campaign job disappeared before send.")
return job, claim_token
def _not_claimed_send_job_result(session: Session, job: CampaignJob) -> SendJobResult:
current = session.get(CampaignJob, job.id)
if not current:
raise SendJobError(f"Job disappeared while claiming: {job.id}")
@@ -1056,39 +1233,69 @@ def send_campaign_job(
message=f"Job is no longer queueable: {current.send_status}",
)
job = session.get(CampaignJob, job.id)
assert job is not None
def _send_claimed_campaign_job(
session: Session,
*,
job: CampaignJob,
claim_token: str,
context: _SendJobDeliveryContext,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> SendJobResult:
mail_integration().wait_for_rate_limit(
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
messages_per_minute=snapshot.delivery.rate_limit.messages_per_minute,
messages_per_minute=context.snapshot.delivery.rate_limit.messages_per_minute,
enabled=use_rate_limit,
)
attempt = _record_attempt_start(session, job, claim_token)
try:
smtp_config = runtime_smtp_config(session, version, snapshot)
smtp_config = runtime_smtp_config(session, context.version, context.snapshot)
mail_integration().assert_mail_policy_allows_send(
session,
tenant_id=job.tenant_id,
campaign_id=job.campaign_id,
smtp=smtp_config,
envelope_sender=envelope_from,
from_header=_from_header_from_job(job, snapshot),
recipients=envelope_recipients,
envelope_sender=context.envelope_from,
from_header=_from_header_from_job(job, context.snapshot),
recipients=context.envelope_recipients,
)
result = mail_integration().send_email_bytes(
message_bytes,
context.message_bytes,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
envelope_from=context.envelope_from,
envelope_recipients=context.envelope_recipients,
)
return _record_smtp_send_success(
session,
job=job,
attempt=attempt,
snapshot=context.snapshot,
result=result,
enqueue_imap_task=enqueue_imap_task,
)
except SmtpSendError as exc:
outcome_unknown = _record_smtp_send_error(session, job=job, attempt=attempt, exc=exc)
if outcome_unknown is not None:
return outcome_unknown
raise
except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
_record_permanent_send_error(session, job=job, attempt=attempt, exc=exc)
raise
def _record_smtp_send_success(
session: Session,
*,
job: CampaignJob,
attempt: SendAttempt,
snapshot: ExecutionSnapshot,
result: object,
enqueue_imap_task: bool,
) -> SendJobResult:
if result.accepted_count <= 0:
raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False)
refused_warning = None
if result.refused_recipients:
refused_warning = (
f"SMTP accepted {result.accepted_count}/{len(result.envelope_recipients)} envelope recipient(s); "
f"refused recipients: {json.dumps(result.refused_recipients, default=str, sort_keys=True)}"
)
refused_warning = _refused_recipient_warning(result)
attempt.finished_at = _utcnow()
attempt.status = "smtp_accepted_with_refusals" if refused_warning else JobSendStatus.SMTP_ACCEPTED.value
attempt.smtp_response = json.dumps(asdict(result), default=str)
@@ -1097,10 +1304,7 @@ def send_campaign_job(
job.sent_at = _utcnow()
job.claim_token = None
job.outcome_unknown_at = None
if snapshot.delivery.imap_append_sent.enabled:
job.imap_status = JobImapStatus.PENDING.value
else:
job.imap_status = JobImapStatus.NOT_REQUESTED.value
job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value
job.last_error = refused_warning
files_integration().mark_job_attachment_uses_sent(session, job)
session.add(attempt)
@@ -1116,7 +1320,23 @@ def send_campaign_job(
message=refused_warning,
)
except SmtpSendError as exc:
def _refused_recipient_warning(result: object) -> str | None:
if not result.refused_recipients:
return None
return (
f"SMTP accepted {result.accepted_count}/{len(result.envelope_recipients)} envelope recipient(s); "
f"refused recipients: {json.dumps(result.refused_recipients, default=str, sort_keys=True)}"
)
def _record_smtp_send_error(
session: Session,
*,
job: CampaignJob,
attempt: SendAttempt,
exc: SmtpSendError,
) -> SendJobResult | None:
if getattr(exc, "outcome_unknown", False):
attempt.status = JobSendStatus.OUTCOME_UNKNOWN.value
attempt.finished_at = _utcnow()
@@ -1141,8 +1361,16 @@ def send_campaign_job(
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
session.commit()
raise
except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
return None
def _record_permanent_send_error(
session: Session,
*,
job: CampaignJob,
attempt: SendAttempt,
exc: Exception,
) -> None:
attempt.finished_at = _utcnow()
attempt.error_type = exc.__class__.__name__
attempt.error_message = str(exc)
@@ -1155,7 +1383,6 @@ def send_campaign_job(
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
session.commit()
raise
def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppendAttempt:

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.capabilities import CampaignAccessService
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.base import Base
TENANT_ID = "tenant-1"
USER_ID = "user-1"
OTHER_USER_ID = "user-2"
GROUP_ID = "group-1"
class CampaignAccessProviderTests(unittest.TestCase):
def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
session = _session()
_seed_access_subjects(session)
owned = Campaign(id="campaign-owned", tenant_id=TENANT_ID, owner_user_id=USER_ID, external_id="owned", name="Owned campaign")
shared = Campaign(id="campaign-shared", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="shared", name="Shared campaign")
session.add_all([
owned,
shared,
CampaignShare(id="share-group", tenant_id=TENANT_ID, campaign_id=shared.id, target_type="group", target_id=GROUP_ID, permission="read"),
])
session.commit()
service = CampaignAccessService()
owned_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id=owned.id, action="campaigns:campaign:read")
shared_items = service.explain_resource_provenance(session, _principal(group_ids={GROUP_ID}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read")
admin_items = service.explain_resource_provenance(session, _principal(scopes={"tenant:*"}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read")
missing_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id="missing-campaign", action="campaigns:campaign:read")
self.assertTrue(any(item.kind == "resource" and item.source == "campaigns.campaign" and item.id == owned.id for item in owned_items))
self.assertTrue(any(item.kind == "owner" and item.id == USER_ID for item in owned_items))
self.assertTrue(any(item.kind == "share" and item.id == "share-group" for item in shared_items))
self.assertTrue(any(item.kind == "policy" and item.id == "tenant:*" for item in admin_items))
self.assertEqual("campaigns.not_found", missing_items[0].source)
self.assertIs(missing_items[0].details["found"], False)
def test_campaign_access_provider_requires_write_share_for_write_actions(self) -> None:
session = _session()
_seed_access_subjects(session)
campaign = Campaign(id="campaign-write", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="write", name="Write campaign")
session.add_all([
campaign,
CampaignShare(id="share-read", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="group", target_id=GROUP_ID, permission="read"),
CampaignShare(id="share-write", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="user", target_id=USER_ID, permission="write"),
])
session.commit()
service = CampaignAccessService()
items = service.explain_resource_provenance(
session,
_principal(group_ids={GROUP_ID}),
resource_type="campaign",
resource_id=campaign.id,
action="campaigns:campaign:update",
)
self.assertTrue(any(item.kind == "share" and item.id == "share-write" for item in items))
self.assertFalse(any(item.kind == "share" and item.id == "share-read" for item in items))
def _session():
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, Campaign.__table__, CampaignShare.__table__])
return sessionmaker(bind=engine, future=True)()
def _seed_access_subjects(session) -> None:
session.add_all([
Account(id="account-1", email="one@example.test", normalized_email="one@example.test"),
Account(id="account-2", email="two@example.test", normalized_email="two@example.test"),
User(id=USER_ID, tenant_id=TENANT_ID, account_id="account-1", email="one@example.test"),
User(id=OTHER_USER_ID, tenant_id=TENANT_ID, account_id="account-2", email="two@example.test"),
Group(id=GROUP_ID, tenant_id=TENANT_ID, slug="group", name="Group"),
])
session.commit()
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
return PrincipalRef(
account_id="account-1",
membership_id=USER_ID,
tenant_id=TENANT_ID,
scopes=frozenset(scopes or {"campaigns:campaign:read"}),
group_ids=frozenset(group_ids or set()),
)

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id
def _dt() -> datetime:
return datetime(2026, 7, 8, 12, 30, tzinfo=UTC)
def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
job = SimpleNamespace(
id="job-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
entry_index=0,
entry_id="recipient-1",
recipient_email="recipient@govoplan.test",
subject="Evidence test",
message_id_header="<message-1@govoplan.test>",
eml_storage_key="eml/job-1.eml",
eml_local_path="/tmp/job-1.eml",
eml_size_bytes=1234,
eml_sha256="abc123",
build_status="built",
validation_status="ready",
queue_status="queued",
send_status="smtp_accepted",
imap_status="appended",
attempt_count=2,
queued_at=_dt(),
claimed_at=None,
smtp_started_at=_dt(),
outcome_unknown_at=None,
sent_at=_dt(),
last_error=None,
resolved_recipients={
"from": [{"name": "Sender", "email": "sender@govoplan.test"}],
"to": [{"name": "Recipient", "email": "recipient@govoplan.test"}],
"cc": [],
"bcc": [],
"reply_to": [{"email": "reply@govoplan.test"}],
},
resolved_attachments=[
{
"matches": [{"filename": "notice.pdf"}, "/tmp/evidence.xlsx"],
"zip_filename": "bundle.zip",
"message_filenames": ["notice.pdf"],
}
],
issues_snapshot=[],
created_at=_dt(),
updated_at=_dt(),
)
smtp = SimpleNamespace(
job_id="job-1",
attempt_number=2,
status="success",
smtp_status_code=250,
smtp_response="2.0.0 queued",
error_type=None,
error_message=None,
started_at=_dt(),
finished_at=_dt(),
)
imap = SimpleNamespace(
job_id="job-1",
attempt_number=1,
status="appended",
folder="Sent",
error_message=None,
created_at=_dt(),
updated_at=_dt(),
)
row = _job_evidence_row(job, latest_smtp=smtp, latest_imap=imap)
assert row["campaign_id"] == "campaign-1"
assert row["campaign_version_id"] == "version-1"
assert row["message_id_header"] == "<message-1@govoplan.test>"
assert row["from"] == "Sender <sender@govoplan.test>"
assert row["to"] == "Recipient <recipient@govoplan.test>"
assert row["reply_to"] == "reply@govoplan.test"
assert "bundle.zip" in row["attachment_names"]
assert "notice.pdf" in row["attachment_names"]
assert row["latest_smtp_status_code"] == 250
assert row["latest_smtp_response"] == "2.0.0 queued"
assert row["latest_imap_status"] == "appended"
assert row["latest_imap_folder"] == "Sent"
def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
attempts = [
SimpleNamespace(job_id="job-1", attempt_number=1),
SimpleNamespace(job_id="job-1", attempt_number=3),
SimpleNamespace(job_id="job-2", attempt_number=2),
]
latest = _latest_by_job_id(attempts)
assert latest["job-1"].attempt_number == 3
assert latest["job-2"].attempt_number == 2

View File

@@ -0,0 +1,151 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from govoplan_campaign.backend import router
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.persistence.versions import validate_campaign_partial
class CampaignPartialValidationTests(unittest.TestCase):
def test_partial_validation_reports_counts_across_sections(self) -> None:
result = validate_campaign_partial({
"campaign": {},
"recipients": {"from": {}},
"entries": {"source": {}, "mapping": {}},
"template": {"body_mode": "both"},
"attachments": {},
"delivery": {"rate_limit": {"messages_per_minute": 0}},
})
codes = {item["code"] for item in result["issues"]}
self.assertFalse(result["ok"])
self.assertEqual(3, result["error_count"])
self.assertIn("missing_campaign_id", codes)
self.assertIn("missing_email_mapping", codes)
self.assertIn("missing_template_text_body", codes)
self.assertIn("missing_attachment_base_path", codes)
self.assertIn("invalid_rate_limit", codes)
def test_partial_validation_filters_to_requested_section(self) -> None:
result = validate_campaign_partial(
{
"campaign": {},
"template": {"body_mode": "text"},
"delivery": {"rate_limit": {"messages_per_minute": "fast"}},
},
section="template",
)
self.assertTrue(result["ok"])
self.assertEqual("template", result["section"])
self.assertEqual(0, result["error_count"])
self.assertEqual({"missing_subject", "missing_template_text_body"}, {item["code"] for item in result["issues"]})
def test_address_lookup_returns_unavailable_when_addresses_capability_absent(self) -> None:
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(router, "_registry_capability", return_value=None):
response = router.lookup_campaign_addresses("campaign-1", query="", session=object(), principal=object())
self.assertFalse(response.available)
self.assertEqual(response.candidates, [])
def test_address_lookup_accepts_empty_query_for_suggestion_preload(self) -> None:
class LookupCapability:
def lookup(self, _session, _principal, *, query: str, limit: int):
self.query = query
self.limit = limit
return [{
"contact_id": "contact-1",
"address_book_id": "book-1",
"display_name": "Ada Lovelace",
"email": "ada@example.local",
"email_label": "work",
"tags": [],
"source_kind": "local",
"provenance": {"module": "addresses"},
}]
capability = LookupCapability()
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(router, "_registry_capability", return_value=capability):
response = router.lookup_campaign_addresses("campaign-1", query="", limit=100, session=object(), principal=object())
self.assertEqual(capability.query, "")
self.assertEqual(capability.limit, 100)
self.assertTrue(response.available)
self.assertEqual(response.candidates[0].email, "ada@example.local")
class CampaignSemanticValidationTests(unittest.TestCase):
def test_semantic_validation_reports_zip_delivery_and_external_mapping_issues(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "recipients.csv").write_text("email\nada@example.local\n", encoding="utf-8")
campaign_file = root / "campaign.json"
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
"fields": [
{"name": "pin", "type": "string", "can_override": False},
{"name": "secret", "type": "password"},
],
"global_values": {"unknown": "value"},
"recipients": {
"from": [{"email": "sender@example.local"}],
"to": [{"email": "recipient@example.local"}],
},
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
"attachments": {
"base_path": "attachments",
"zip": {
"enabled": True,
"archives": [
{
"id": "main",
"name": "bundle.zip",
"standard": True,
"password_enabled": True,
"password_field": "secret",
"password_scope": "global",
}
],
},
},
"entries": {
"source": {"type": "csv", "path": "recipients.csv", "has_header": True},
"mapping": {"fields.pin": "missing_pin", "fields.unknown": "missing_unknown"},
"defaults": {
"attachments": [
{
"base_dir": "",
"file_filter": "*.pdf",
"zip": {"archive_id": "missing-archive"},
}
]
},
},
"delivery": {"imap_append_sent": {"enabled": True}},
})
report = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
codes = {issue.code for issue in report.issues}
self.assertFalse(report.ok)
self.assertEqual("external:csv", report.entries_mode)
self.assertIsNone(report.entries_count)
self.assertIn("unknown_global_value", codes)
self.assertIn("zip_global_password_value_missing", codes)
self.assertIn("zip_archive_unknown", codes)
self.assertIn("delivery_imap_enabled_without_server_imap", codes)
self.assertIn("missing_smtp_config", codes)
self.assertIn("unknown_mapping_target", codes)
self.assertIn("mapping_target_not_overridable", codes)
self.assertIn("mapping_columns_missing", codes)
self.assertIn("attachments_base_path_not_found", codes)
if __name__ == "__main__":
unittest.main()

View File

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

View File

@@ -1,9 +1,9 @@
import type { ApiSettings, CampaignListItem } from "../types";
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
import { apiDownload, apiFetch } from "./client";
export type CampaignListResponse =
| CampaignListItem[]
| {
CampaignListItem[] |
{
campaigns?: CampaignListItem[];
items?: CampaignListItem[];
results?: CampaignListItem[];
@@ -20,8 +20,29 @@ export type CampaignShare = {
revoked_at?: string | null;
};
export type CampaignShareTarget = { id: string; name: string; secondary?: string | null };
export type CampaignShareTargets = { users: CampaignShareTarget[]; groups: CampaignShareTarget[] };
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
export type AccessExplanationUser = {
id: string;
account_id?: string | null;
email?: string | null;
display_name?: string | null;
};
export type AccessDecisionProvenanceItem = {
kind: string;
id?: string | null;
label?: string | null;
tenant_id?: string | null;
source?: string | null;
details?: Record<string, unknown>;
};
export type ResourceAccessExplanationResponse = {
user: AccessExplanationUser;
resource_type: string;
resource_id: string;
action: string;
provenance: AccessDecisionProvenanceItem[];
};
export type CampaignUpdatePayload = {
external_id?: string | null;
@@ -85,11 +106,113 @@ export type CampaignWorkspaceResponse = {
selected_version_id?: string | null;
};
export type CampaignDeltaResponse = {
campaigns: CampaignListItem[];
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type CampaignWorkspaceQuery = {
versionId?: string | null;
includeCurrentVersion?: boolean;
includeSummary?: boolean;
includeVersions?: boolean;
since?: string | null;
limit?: number;
};
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 CampaignAddressLookupCandidate = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
email_label?: string | null;
organization?: string | null;
role_title?: string | null;
tags: string[];
source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
};
export type CampaignAddressLookupResponse = {
available: boolean;
candidates: CampaignAddressLookupCandidate[];
};
export type CampaignRecipientAddressSource = {
source_id: string;
source_label: string;
source_kind: string;
source_revision: string;
recipient_count: number;
provenance: Record<string, unknown>;
};
export type CampaignRecipientAddressSourcesResponse = {
available: boolean;
sources: CampaignRecipientAddressSource[];
};
export type CampaignRecipientSnapshotItem = {
contact_id: string;
display_name: string;
email: string;
email_label?: string | null;
fields: Record<string, unknown>;
provenance: Record<string, unknown>;
};
export type CampaignRecipientAddressSourceSnapshot = {
source_id: string;
source_label: string;
source_kind: string;
source_revision: string;
generated_at: string;
recipients: CampaignRecipientSnapshotItem[];
provenance: Record<string, unknown>;
};
export type CampaignVersionUpdatePayload = {
@@ -247,6 +370,7 @@ export type CampaignJobsQuery = {
versionId?: string;
page?: number;
pageSize?: number;
cursor?: string | null;
sendStatus?: string[];
validationStatus?: string[];
imapStatus?: string[];
@@ -260,6 +384,8 @@ export type CampaignJobsResponse = {
total: number;
total_unfiltered: number;
pages: number;
cursor?: string | null;
next_cursor?: string | null;
counts: Record<string, Record<string, number>>;
filtered_counts: Record<string, Record<string, number>>;
review: {
@@ -271,6 +397,13 @@ export type CampaignJobsResponse = {
};
};
export type CampaignJobsDeltaResponse = CampaignJobsResponse & {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type CampaignJobDetailResponse = {
job: Record<string, unknown>;
attempts: {
@@ -298,6 +431,75 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
return response.campaigns ?? response.items ?? response.results ?? [];
}
export async function listCampaignsDelta(
settings: ApiSettings,
options: {since?: string | null;limit?: number;} = {})
: Promise<CampaignDeltaResponse> {
const params = new URLSearchParams();
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignDeltaResponse>(settings, `/api/v1/campaigns/delta${suffix}`);
}
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 lookupCampaignAddresses(
settings: ApiSettings,
campaignId: string,
query: string,
limit = 25)
: Promise<CampaignAddressLookupResponse> {
const params = new URLSearchParams({ query, limit: String(limit) });
return apiFetch<CampaignAddressLookupResponse>(settings, `/api/v1/campaigns/${campaignId}/address-lookup?${params.toString()}`);
}
export async function listCampaignRecipientAddressSources(
settings: ApiSettings,
campaignId: string)
: Promise<CampaignRecipientAddressSourcesResponse> {
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
}
export async function snapshotCampaignRecipientAddressSource(
settings: ApiSettings,
campaignId: string,
sourceId: string)
: Promise<CampaignRecipientAddressSourceSnapshot> {
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
method: "POST",
body: JSON.stringify({ source_id: sourceId })
});
}
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
}
@@ -305,8 +507,8 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
export async function updateCampaignMetadata(
settings: ApiSettings,
campaignId: string,
payload: CampaignUpdatePayload
): Promise<CampaignListItem> {
payload: CampaignUpdatePayload)
: Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
method: "PUT",
body: JSON.stringify(payload)
@@ -315,13 +517,13 @@ export async function updateCampaignMetadata(
export async function createNewCampaign(
settings: ApiSettings,
overrides: CampaignCreateMinimalPayload = {}
): Promise<CampaignCreateResponse> {
overrides: CampaignCreateMinimalPayload = {})
: Promise<CampaignCreateResponse> {
const now = new Date();
const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
const payload = {
external_id: overrides.external_id ?? `new-campaign-${stamp}`,
name: overrides.name ?? "New Campaign",
name: overrides.name ?? "i18n:govoplan-campaign.new_campaign.1f0d021c",
description: overrides.description ?? "",
current_flow: overrides.current_flow ?? "create",
current_step: overrides.current_step ?? "basics"
@@ -344,8 +546,8 @@ export async function getCampaignSchemaUi(settings: ApiSettings): Promise<unknow
export async function getCampaignWorkspace(
settings: ApiSettings,
campaignId: string,
options: CampaignWorkspaceQuery = {}
): Promise<CampaignWorkspaceResponse> {
options: CampaignWorkspaceQuery = {})
: Promise<CampaignWorkspaceResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
@@ -355,26 +557,42 @@ export async function getCampaignWorkspace(
return apiFetch<CampaignWorkspaceResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace${suffix}`);
}
export async function getCampaignWorkspaceDelta(
settings: ApiSettings,
campaignId: string,
options: CampaignWorkspaceQuery = {})
: Promise<CampaignWorkspaceDeltaResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
if (options.includeSummary !== undefined) params.set("include_summary", String(options.includeSummary));
if (options.includeVersions !== undefined) params.set("include_versions", String(options.includeVersions));
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`);
}
export async function listCampaignVersions(
settings: ApiSettings,
campaignId: string
): Promise<CampaignVersionListItem[]> {
campaignId: string)
: Promise<CampaignVersionListItem[]> {
return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`);
}
export async function getCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
versionId: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
}
export async function unlockCampaignVersionValidation(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
versionId: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, {
method: "POST"
});
@@ -383,8 +601,8 @@ export async function unlockCampaignVersionValidation(
export async function lockCampaignVersionTemporarily(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
versionId: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, {
method: "POST"
});
@@ -393,8 +611,8 @@ export async function lockCampaignVersionTemporarily(
export async function unlockCampaignVersionUserLock(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
versionId: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, {
method: "POST"
});
@@ -403,8 +621,8 @@ export async function unlockCampaignVersionUserLock(
export async function lockCampaignVersionPermanently(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
versionId: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, {
method: "POST"
});
@@ -414,8 +632,8 @@ export async function updateCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload
): Promise<CampaignVersionDetail> {
payload: CampaignVersionUpdatePayload)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
method: "PUT",
body: JSON.stringify(payload)
@@ -426,8 +644,8 @@ export async function forkCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload = {}
): Promise<CampaignCreateResponse> {
payload: CampaignVersionUpdatePayload = {})
: Promise<CampaignCreateResponse> {
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
method: "POST",
body: JSON.stringify(payload)
@@ -438,8 +656,8 @@ export async function autosaveCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload
): Promise<CampaignVersionDetail> {
payload: CampaignVersionUpdatePayload)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
method: "POST",
body: JSON.stringify(payload)
@@ -451,8 +669,8 @@ export async function setCampaignVersionStep(
campaignId: string,
versionId: string,
currentStep: string,
currentFlow?: string | null
): Promise<CampaignVersionDetail> {
currentFlow?: string | null)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, {
method: "POST",
body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep })
@@ -463,8 +681,8 @@ export async function validatePartial(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignPartialValidationPayload = {}
): Promise<CampaignPartialValidationResponse> {
payload: CampaignPartialValidationPayload = {})
: Promise<CampaignPartialValidationResponse> {
return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, {
method: "POST",
body: JSON.stringify(payload)
@@ -474,8 +692,8 @@ export async function validatePartial(
export async function publishCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
versionId: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, {
method: "POST"
});
@@ -484,8 +702,8 @@ export async function publishCampaignVersion(
export async function validateVersion(
settings: ApiSettings,
versionId: string,
checkFiles = false
): Promise<Record<string, unknown>> {
checkFiles = false)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
method: "POST",
body: JSON.stringify({ check_files: checkFiles })
@@ -495,8 +713,8 @@ export async function validateVersion(
export async function buildVersion(
settings: ApiSettings,
versionId: string,
writeEml = true
): Promise<Record<string, unknown>> {
writeEml = true)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
method: "POST",
body: JSON.stringify({ write_eml: writeEml })
@@ -508,8 +726,8 @@ export function previewCampaignAttachments(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignAttachmentPreviewPayload = {}
): Promise<CampaignAttachmentPreviewResponse> {
payload: CampaignAttachmentPreviewPayload = {})
: Promise<CampaignAttachmentPreviewResponse> {
return apiFetch<CampaignAttachmentPreviewResponse>(
settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`,
@@ -520,8 +738,8 @@ export function previewCampaignAttachments(
export async function getCampaignSummary(
settings: ApiSettings,
campaignId: string,
versionId?: string
): Promise<CampaignSummary> {
versionId?: string)
: Promise<CampaignSummary> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
}
@@ -529,12 +747,13 @@ export async function getCampaignSummary(
export async function getCampaignJobs(
settings: ApiSettings,
campaignId: string,
options: CampaignJobsQuery = {}
): Promise<CampaignJobsResponse> {
options: CampaignJobsQuery = {})
: Promise<CampaignJobsResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
for (const value of options.sendStatus ?? []) params.append("send_status", value);
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
@@ -543,19 +762,39 @@ export async function getCampaignJobs(
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
}
export async function getCampaignJobsDelta(
settings: ApiSettings,
campaignId: string,
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {})
: Promise<CampaignJobsDeltaResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
for (const value of options.sendStatus ?? []) params.append("send_status", value);
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
if (options.query?.trim()) params.set("q", options.query.trim());
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`);
}
export async function getCampaignJobDetail(
settings: ApiSettings,
campaignId: string,
jobId: string
): Promise<CampaignJobDetailResponse> {
jobId: string)
: Promise<CampaignJobDetailResponse> {
return apiFetch<CampaignJobDetailResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`);
}
export async function getCampaignReport(
settings: ApiSettings,
campaignId: string,
versionId?: string
): Promise<CampaignSummary> {
versionId?: string)
: Promise<CampaignSummary> {
const params = new URLSearchParams({ include_jobs: "false" });
if (versionId) params.set("version_id", versionId);
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
@@ -564,8 +803,8 @@ export async function getCampaignReport(
export async function downloadCampaignJobsCsv(
settings: ApiSettings,
campaignId: string,
versionId?: string
): Promise<void> {
versionId?: string)
: Promise<void> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
await apiDownload(
settings,
@@ -577,8 +816,8 @@ export async function downloadCampaignJobsCsv(
export async function emailCampaignReport(
settings: ApiSettings,
campaignId: string,
payload: CampaignReportEmailPayload
): Promise<Record<string, unknown>> {
payload: CampaignReportEmailPayload)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/report/email`, {
method: "POST",
body: JSON.stringify(payload)
@@ -588,8 +827,8 @@ export async function emailCampaignReport(
export async function retryCampaignJobs(
settings: ApiSettings,
campaignId: string,
payload: Record<string, unknown> = {}
): Promise<Record<string, unknown>> {
payload: Record<string, unknown> = {})
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, {
method: "POST",
body: JSON.stringify(payload)
@@ -599,8 +838,8 @@ export async function retryCampaignJobs(
export async function sendUnattemptedCampaignJobs(
settings: ApiSettings,
campaignId: string,
payload: Record<string, unknown> = {}
): Promise<Record<string, unknown>> {
payload: Record<string, unknown> = {})
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, {
method: "POST",
body: JSON.stringify(payload)
@@ -612,8 +851,8 @@ export async function resolveCampaignJobOutcome(
campaignId: string,
jobId: string,
decision: "smtp_accepted" | "not_sent",
note?: string
): Promise<Record<string, unknown>> {
note?: string)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
method: "POST",
body: JSON.stringify({ decision, note: note || null })
@@ -624,8 +863,8 @@ export async function updateCampaignReviewState(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignReviewStatePayload
): Promise<CampaignVersionDetail> {
payload: CampaignReviewStatePayload)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, {
method: "POST",
body: JSON.stringify(payload)
@@ -635,8 +874,8 @@ export async function updateCampaignReviewState(
export async function queueCampaign(
settings: ApiSettings,
campaignId: string,
payload: CampaignQueuePayload = {}
): Promise<Record<string, unknown>> {
payload: CampaignQueuePayload = {})
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, {
method: "POST",
body: JSON.stringify(payload)
@@ -646,8 +885,8 @@ export async function queueCampaign(
export async function sendCampaignNow(
settings: ApiSettings,
campaignId: string,
payload: CampaignSendNowPayload = {}
): Promise<Record<string, unknown>> {
payload: CampaignSendNowPayload = {})
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, {
method: "POST",
body: JSON.stringify(payload)
@@ -658,8 +897,8 @@ export async function sendCampaignNow(
export async function mockSendCampaign(
settings: ApiSettings,
campaignId: string,
payload: CampaignMockSendPayload = {}
): Promise<Record<string, unknown>> {
payload: CampaignMockSendPayload = {})
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, {
method: "POST",
body: JSON.stringify(payload)
@@ -681,8 +920,8 @@ export async function cancelCampaign(settings: ApiSettings, campaignId: string):
export async function appendSent(
settings: ApiSettings,
campaignId: string,
payload: CampaignAppendSentPayload = {}
): Promise<Record<string, unknown>> {
payload: CampaignAppendSentPayload = {})
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
method: "POST",
body: JSON.stringify({
@@ -697,24 +936,38 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
}
export function fetchResourceAccessExplanation(
settings: ApiSettings,
options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
: Promise<ResourceAccessExplanationResponse> {
const params = new URLSearchParams({
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action
});
if (options.tenantId) params.set("tenant_id", options.tenantId);
return apiFetch<ResourceAccessExplanationResponse>(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
}
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
const response = await apiFetch<{ shares: CampaignShare[] }>(settings, `/api/v1/campaigns/${campaignId}/shares`);
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
return response.shares;
}
export async function updateCampaignOwner(
settings: ApiSettings,
campaignId: string,
payload: { owner_user_id?: string | null; owner_group_id?: string | null }
): Promise<CampaignListItem> {
payload: {owner_user_id?: string | null;owner_group_id?: string | null;})
: Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) });
}
export async function upsertCampaignShare(
settings: ApiSettings,
campaignId: string,
payload: { target_type: "user" | "group"; target_id: string; permission: "read" | "write" }
): Promise<CampaignShare> {
payload: {target_type: "user" | "group";target_id: string;permission: "read" | "write";})
: Promise<CampaignShare> {
return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) });
}

View File

@@ -1 +1 @@
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";
export { apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "@govoplan/core-webui";

View File

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

View File

@@ -1,142 +1,55 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
import type {
ApiSettings,
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapTestPayload,
MailProfilePolicy,
MailProfilePolicyResponse,
MailProfileScope,
MailSecurity,
MailServerProfile,
MailServerProfilePayload,
MailSmtpTestPayload,
MockMailboxMessageResponse
} from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
export type MailSecurity = "plain" | "tls" | "starttls";
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
const profileActionEndpoints = {
smtp: "test-smtp",
imap: "test-imap",
folders: "list-imap-folders"
} as const;
export type MailSmtpTestPayload = {
host?: string | null;
port?: number | null;
username?: string | null;
password?: string | null;
security?: MailSecurity;
timeout_seconds?: number;
};
const rawSettingsEndpoints = {
smtp: "/api/v1/mail/test-smtp",
imap: "/api/v1/mail/test-imap",
folders: "/api/v1/mail/list-imap-folders"
} as const;
export type MailImapTestPayload = MailSmtpTestPayload & {
sent_folder?: string | null;
};
function runProfileAction<TResponse>(
settings: ApiSettings,
profileId: string,
action: keyof typeof profileActionEndpoints
): Promise<TResponse> {
return apiPost<TResponse>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`
);
}
export type MailTransportCredentialsPayload = {
username?: string | null;
password?: string | null;
};
export type MailServerProfileCredentialsPayload = {
smtp?: MailTransportCredentialsPayload;
imap?: MailTransportCredentialsPayload;
};
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
details?: Record<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
details?: Record<string, unknown>;
};
export type MailServerProfile = {
id: string;
tenant_id?: string | null;
scope_type: MailProfileScope;
scope_id?: string | null;
name: string;
slug: string;
description?: string | null;
is_active: boolean;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
smtp_password_configured: boolean;
imap_password_configured: boolean;
created_at: string;
updated_at: string;
};
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const;
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailCredentialPolicy = {
inherit?: boolean | null;
allow_override?: boolean | null;
};
export type MailProfilePolicy = {
allowed_profile_ids?: string[] | null;
allow_user_profiles?: boolean | null;
allow_group_profiles?: boolean | null;
allow_campaign_profiles?: boolean | null;
smtp_credentials?: MailCredentialPolicy | null;
imap_credentials?: MailCredentialPolicy | null;
whitelist?: MailProfilePatternRules | null;
blacklist?: MailProfilePatternRules | null;
};
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
parent_policy?: MailProfilePolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
};
function runRawSettingsAction<TResponse, TPayload>(
settings: ApiSettings,
payload: TPayload,
action: keyof typeof rawSettingsEndpoints
): Promise<TResponse> {
return apiPostJson<TResponse>(settings, rawSettingsEndpoints[action], payload);
}
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
const params = new URLSearchParams();
if (includeInactive) params.set("include_inactive", "true");
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
return response.profiles ?? [];
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
include_inactive: includeInactive ? true : undefined,
campaign_id: campaignId
});
}
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
@@ -152,71 +65,40 @@ export async function getMailProfilePolicy(
scopeId?: string | null,
campaignId?: string | null
): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
scope_id: scopeId,
campaign_id: campaignId
}));
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
}
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap");
}
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
}
export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
method: "POST",
body: JSON.stringify(payload)
});
return runRawSettingsAction<MailConnectionTestResponse, MailSmtpTestPayload>(settings, payload, "smtp");
}
export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
method: "POST",
body: JSON.stringify(payload)
});
return runRawSettingsAction<MailConnectionTestResponse, MailImapTestPayload>(settings, payload, "imap");
}
export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
method: "POST",
body: JSON.stringify(payload)
});
return runRawSettingsAction<MailImapFolderListResponse, MailImapTestPayload>(settings, payload, "folders");
}
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
}
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
export type { MailConnectionTestResponse, MailCredentialPolicy, MailImapFolderListResponse, MailImapFolderResponse, MailImapTestPayload, MailProfilePatternKey, MailProfilePatternRules, MailProfilePolicy, MailProfilePolicyLimitKey, MailProfilePolicyLimitPermissions, MailProfilePolicyResponse, MailProfileScope, MailSecurity, MailServerProfile, MailServerProfileCredentialsPayload, MailServerProfileListResponse, MailServerProfilePayload, MailSmtpTestPayload, MailTransportCredentialsPayload, MockMailboxMessage, MockMailboxMessageResponse } from "@govoplan/core-webui";
export type { MailPolicySourceStep as PolicySourceStep } from "@govoplan/core-webui";

View File

@@ -1,97 +0,0 @@
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
const personalContacts = [
{ name: "Ada Lovelace", email: "ada@example.local", source: "Personal", tags: "Used recently" },
{ name: "Grace Hopper", email: "grace@example.local", source: "Personal", tags: "Favorite" }
];
const groupContacts = [
{ name: "Project Office", email: "project-office@example.local", source: "Group", tags: "Shared" },
{ name: "Finance Team", email: "finance@example.local", source: "Group", tags: "Shared list" }
];
const tenantContacts = [
{ name: "Helpdesk", email: "helpdesk@example.local", source: "Tenant", tags: "Directory" },
{ name: "Data Protection", email: "privacy@example.local", source: "Tenant", tags: "Directory" }
];
function contactColumns(): DataGridColumn<{ name: string; email: string; source: string; tags: string }>[] {
return [
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
{ id: "email", header: "Email", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
{ id: "scope", header: "Scope", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "Personal" }, { value: "Group", label: "Group" }, { value: "Tenant", label: "Tenant" }] }, value: (contact) => contact.source },
{ id: "tags", header: "Tags", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "Mock" }], display: "pill" }, render: () => <StatusBadge status="mock" />, value: () => "mock" }
];
}
export default function AddressBookPage() {
const allContacts = [...personalContacts, ...groupContacts, ...tenantContacts];
return (
<div className="content-pad workspace-data-page module-entry-page address-book-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>Address Book</PageTitle>
<p>Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Import</Button>
<Button variant="primary" disabled>Add contact</Button>
</div>
</div>
<div className="metric-grid">
<Card title="Personal"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">Private contacts and remembered addresses.</p></Card>
<Card title="Group"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">Shared group address books and lists.</p></Card>
<Card title="Tenant"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">Tenant directory and approved shared contacts.</p></Card>
<Card title="Sync"><strong className="module-big-number">Mock</strong><p className="muted">CardDAV/LDAP/import connectors can be added later.</p></Card>
</div>
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Address book scopes">
<div className="address-book-scope-list">
<AddressBookScope title="Personal address book" description="Private contacts, remembered recipients and personal aliases." status="Local" />
<AddressBookScope title="Group address books" description="Shared contact sets for teams, departments or campaign groups." status="Shared" />
<AddressBookScope title="Tenant directory" description="Tenant-wide contacts, functional mailboxes and approved lists." status="Directory" />
</div>
</Card>
<Card title="Planned address actions">
<div className="placeholder-stack">
<span>Choose addresses from personal, group or tenant source</span>
<span>Remember addresses used in campaigns after opt-in</span>
<span>Share selected contacts with a group</span>
<span>Use contacts in To, CC, BCC, sender and Reply-To fields</span>
</div>
</Card>
</div>
<Card title="Contacts" actions={<Button disabled>Manage sources</Button>}>
<DataGrid
id="address-book-contacts"
rows={allContacts}
columns={contactColumns()}
getRowKey={(contact) => contact.source + "-" + contact.email}
emptyText="No contacts found."
className="compact-table-wrap module-table"
/>
</Card>
</div>
);
}
function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) {
return (
<div className="address-book-scope-card">
<div>
<strong>{title}</strong>
<p>{description}</p>
</div>
<StatusBadge status={status} />
</div>
);
}

View File

@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import { useGuardedNavigate, 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,21 +17,25 @@ 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";
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
type PathChooserState = { index: number };
type IndividualDisableState = { index: number; usageCount: number };
type PathChooserState = {index: number;};
type IndividualDisableState = {index: number;usageCount: number;};
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
export default function AttachmentsDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const navigate = useGuardedNavigate();
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;
@@ -45,8 +48,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
reload,
setError,
currentStep: "files",
unsavedTitle: "Unsaved attachment settings",
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
unsavedTitle: "i18n:govoplan-campaign.unsaved_attachment_settings.a6045f67",
unsavedMessage: "i18n:govoplan-campaign.attachment_settings_have_unsaved_changes_save_th.b9b415bb"
});
const attachments = asRecord(displayDraft.attachments);
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
@@ -61,18 +64,26 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
const attachmentPreviewEntry = useMemo(
() => asArray(asRecord(displayDraft.entries).inline).map(asRecord).find((entry) => entry.active !== false) ?? {},
[displayDraft.entries]
);
const attachmentPreviewContext = useMemo(
() => buildTemplatePreviewContext(displayDraft, attachmentPreviewEntry),
[attachmentPreviewEntry, displayDraft]
);
useEffect(() => {
if (!filesModuleInstalled) {
if (!listManagedFileSpaces) {
setFileSpaces([]);
return;
}
let cancelled = false;
void listFileSpaces(settings)
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
.catch(() => { if (!cancelled) setFileSpaces([]); });
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;
@@ -143,9 +154,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
}
function setZipEnabled(enabled: boolean) {
const archives = enabled && zipConfig.archives.length === 0
? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)]
: zipConfig.archives;
const archives = enabled && zipConfig.archives.length === 0 ?
[createAttachmentZipArchive("{{local:id}}-attachments.zip", true)] :
zipConfig.archives;
patchZipCollection({ enabled, archives });
}
@@ -185,9 +196,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
const resetRule = (value: unknown) => {
const rule = asRecord(value);
const ruleZip = asRecord(rule.zip);
return String(ruleZip.archive_id ?? "") === removed.id
? { ...rule, zip: { ...ruleZip, archive_id: "inherit" } }
: rule;
return String(ruleZip.archive_id ?? "") === removed.id ?
{ ...rule, zip: { ...ruleZip, archive_id: "inherit" } } :
rule;
};
setDraft((current) => {
const source = asRecord(current ?? {});
@@ -222,44 +233,44 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Attachments</PageTitle>
<PageTitle loading={loading}>i18n:govoplan-campaign.attachments.6771ade6</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
{filesModuleInstalled && <Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>}
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft">
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<Card title="Attachment sources">
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
<div className="admin-table-surface attachment-sources-table-surface">
<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" />}
className="attachment-sources-table-wrap attachment-sources-table"
/>
emptyText="i18n:govoplan-campaign.no_attachment_sources_configured.48664606"
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />}
className="attachment-sources-table-wrap attachment-sources-table" />
</div>
</Card>
<Card title="ZIP attachments" collapsible>
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
<div className="attachment-zip-master-toggle">
<ToggleSwitch
label="Enable ZIP attachments"
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
checked={zipConfig.enabled}
disabled={locked}
onChange={setZipEnabled}
/>
onChange={setZipEnabled} />
</div>
<div className="admin-table-surface attachment-zip-table-surface">
<DataGrid
@@ -278,43 +289,48 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
removeArchive: removeZipArchive
})}
getRowKey={(archive) => archive.id}
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."}
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined}
className="attachment-zip-table-wrap"
/>
emptyText={zipConfig.enabled ? "i18n:govoplan-campaign.no_zip_attachments_configured.29c16424" : "i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d"}
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_zip_attachment.f64cc332" /> : undefined}
className="attachment-zip-table-wrap" />
</div>
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && (
<DismissibleAlert tone="warning">No password field exists. Add one under Fields with field type <strong>Password</strong>.</DismissibleAlert>
)}
{zipArchiveNameValidation.message && (
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) &&
<DismissibleAlert tone="warning">i18n:govoplan-campaign.no_password_field_exists_add_one_under_fields_wi.2b6d4a7f <strong>i18n:govoplan-campaign.password.8be3c943</strong>.</DismissibleAlert>
}
{zipArchiveNameValidation.message &&
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
)}
<p className="muted small-note">Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.</p>
}
<p className="muted small-note">i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1</p>
</Card>
<Card title="Global Attachments" collapsible>
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
<AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`}
rules={globalRules}
disabled={locked}
emptyText="No global attachments are configured yet. Add files here only if every message should include them."
emptyText="i18n:govoplan-campaign.no_global_attachments_are_configured_yet_add_fil.d9fdfbb8"
basePaths={basePaths}
settings={settings}
campaignId={campaignId}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
onChange={(rules) => patch(["attachments", "global"], rules)}
/>
filesModuleInstalled={managedFilesAvailable}
previewContext={attachmentPreviewContext}
onChange={(rules) => patch(["attachments", "global"], rules)} />
</Card>
<Card title="Statistics" collapsible>
<Card title="i18n:govoplan-campaign.statistics.2086b21f" collapsible>
<dl className="detail-list">
<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>i18n:govoplan-campaign.base_paths.edf35a97</dt><dd>{basePaths.length}</dd></div>
<div><dt>i18n:govoplan-campaign.global_attachments.438263a1</dt><dd>direct: {globalSummary.direct} i18n:govoplan-campaign.rules.e5d36074 {globalSummary.rules}</dd></div>
<div><dt>i18n:govoplan-campaign.per_recipient_patterns.53da30da</dt><dd>{individualRulesCount}</dd></div>
<div><dt>i18n:govoplan-campaign.upload_support.1c54931c</dt><dd>{managedFilesAvailable ? "i18n:govoplan-campaign.connected_through_files.95007112" : "i18n:govoplan-campaign.manual_paths.8e20627a"}</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 ?
"i18n:govoplan-campaign.files_are_managed_in_the_top_level_files_module_.4b370222" :
filesModuleInstalled ?
"i18n:govoplan-campaign.managed_file_browsing_is_unavailable_use_manual_.782867fe" :
"i18n:govoplan-campaign.the_files_module_is_not_installed_use_manual_pat.49abc725"}</p>
</Card>
</>
@@ -322,13 +338,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<TemplateExpressionEditorDialog
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
title="Edit ZIP archive filename"
value={zipNameEditorIndex !== null ? (zipConfig.archives[zipNameEditorIndex]?.name ?? "") : ""}
title="i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"
value={zipNameEditorIndex !== null ? zipConfig.archives[zipNameEditorIndex]?.name ?? "" : ""}
localFields={filenameFieldOptions.filter((field) => field.namespace === "local").map(({ name, label }) => ({ name, label }))}
globalFields={filenameFieldOptions.filter((field) => field.namespace === "global").map(({ name, label }) => ({ name, label }))}
placeholder="attachments.zip"
description="Use a fixed filename or combine text with recipient and campaign fields. The .zip suffix is added automatically when omitted."
saveLabel="Save filename"
description="i18n:govoplan-campaign.use_a_fixed_filename_or_combine_text_with_recipi.43a091fd"
saveLabel="i18n:govoplan-campaign.save_filename.2dbd2273"
validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)}
onCancel={() => setZipNameEditorIndex(null)}
onSave={(name) => {
@@ -342,17 +358,17 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
if (existingFields.some((field) => String(field.name || field.id || "").trim() === name)) return;
patch(["fields"], [
...existingFields,
{ name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }
]);
{ name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }]
);
}}
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
/>
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}
@@ -363,30 +379,30 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
patchBasePath(pathChooser.index, {
source: selection.source,
path: selection.folderPath || ".",
name: !current?.name || current.name === "New attachment source" || current.name === "Campaign files"
? `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}`
: current.name
name: !current?.name || current.name === "i18n:govoplan-campaign.new_attachment_source.563145ba" || current.name === "i18n:govoplan-campaign.campaign_files.96e7004b" ?
`${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}` :
current.name
});
setPathChooser(null);
}}
/>
)}
}} />
}
<ConfirmDialog
open={Boolean(individualDisable)}
title="Disable individual attachments for this source?"
message={individualDisable
? `${individualDisable.usageCount} individual attachment ${individualDisable.usageCount === 1 ? "entry uses" : "entries use"} this source. Disabling it will remove those recipient-specific attachment entries.`
: ""}
confirmLabel="Remove individual attachments"
cancelLabel="Cancel"
title="i18n:govoplan-campaign.disable_individual_attachments_for_this_source.185f9a95"
message={individualDisable ? i18nMessage("i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7", { value0:
individualDisable.usageCount, value1: individualDisable.usageCount === 1 ? "i18n:govoplan-campaign.entry_uses.5f269cab" : "i18n:govoplan-campaign.entries_use.e9169679" }) :
""}
confirmLabel="i18n:govoplan-campaign.remove_individual_attachments.9e5f0ad3"
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
tone="danger"
onConfirm={confirmIndividualDisable}
onCancel={() => setIndividualDisable(null)}
/>
</div>
);
onCancel={() => setIndividualDisable(null)} />
</div>);
}
type ZipArchiveColumnContext = {
@@ -405,72 +421,72 @@ type ZipArchiveColumnContext = {
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
return [
{
id: "name", header: "Archive name", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
render: (archive, index) => (
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
render: (archive, index) =>
<button
type="button"
className="attachment-zip-name-button"
disabled={disabled}
aria-invalid={invalidNameIndexes.has(index) || undefined}
aria-label={`Edit ZIP archive filename ${archive.name || index + 1}`}
title={invalidNameIndexes.has(index) ? "Archive filenames must be present and unique." : "Edit ZIP archive filename"}
onClick={() => onEditName(index)}
>
<span className="attachment-zip-name-value">{archive.name || "Unnamed ZIP attachment"}</span>
aria-label={i18nMessage("i18n:govoplan-campaign.edit_zip_archive_filename_value.deb41dfc", { value0: archive.name || index + 1 })}
title={invalidNameIndexes.has(index) ? "i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639" : "i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"}
onClick={() => onEditName(index)}>
<span className="attachment-zip-name-value">{archive.name || "i18n:govoplan-campaign.unnamed_zip_attachment.54f515bb"}</span>
<Pencil size={15} aria-hidden="true" />
</button>
),
</button>,
value: (archive) => archive.name
},
{
id: "standard", header: "Standard", width: 150, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "standard", label: "Standard" }, { value: "optional", label: "Optional" }] },
render: (archive, index) => <ToggleSwitch label="Standard" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
id: "standard", header: "i18n:govoplan-campaign.standard.2dfa6607", width: 150, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "standard", label: "i18n:govoplan-campaign.standard.2dfa6607" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] },
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.standard.2dfa6607" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
value: (archive) => archive.standard ? "standard" : "optional"
},
{
id: "password_enabled", header: "Password", width: 165, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "protected", label: "Protected" }, { value: "none", label: "No password" }] },
render: (archive, index) => <ToggleSwitch label="Protected" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
id: "password_enabled", header: "i18n:govoplan-campaign.password.8be3c943", width: 165, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "protected", label: "i18n:govoplan-campaign.protected.28531336" }, { value: "none", label: "i18n:govoplan-campaign.no_password.18518368" }] },
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.protected.28531336" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
value: (archive) => archive.password_enabled ? "protected" : "none"
},
{
id: "password_field", header: "Password field", width: 230, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "", label: "No field" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
render: (archive, index) => (
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
render: (archive, index) =>
<select value={archive.password_field} disabled={disabled || !archive.password_enabled || passwordFields.length === 0} onChange={(event) => patchArchive(index, { password_field: event.target.value })}>
<option value="">Select password field</option>
<option value="">i18n:govoplan-campaign.select_password_field.4007aeb0</option>
{passwordFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
),
</select>,
value: (archive) => archive.password_field
},
{
id: "password_scope", header: "Value scope", width: 190, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "local", label: "Recipient / local" }, { value: "global", label: "Campaign / global" }] },
render: (archive, index) => (
id: "password_scope", header: "i18n:govoplan-campaign.value_scope.5e1e16b2", width: 190, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "local", label: "i18n:govoplan-campaign.recipient_local.418a289f" }, { value: "global", label: "i18n:govoplan-campaign.campaign_global.ba944948" }] },
render: (archive, index) =>
<select value={archive.password_scope} disabled={disabled || !archive.password_enabled} onChange={(event) => patchArchive(index, { password_scope: event.target.value === "global" ? "global" : "local" })}>
<option value="local">Recipient / local</option>
<option value="global">Campaign / global</option>
</select>
),
<option value="local">i18n:govoplan-campaign.recipient_local.418a289f</option>
<option value="global">i18n:govoplan-campaign.campaign_global.ba944948</option>
</select>,
value: (archive) => archive.password_scope
},
{
id: "actions", header: "Actions", width: 180, sticky: "end",
id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 180, sticky: "end",
render: (_archive, index) => <DataGridRowActions
disabled={disabled}
onAddBelow={() => addArchive(index)}
onRemove={() => removeArchive(index)}
onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined}
onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined}
addLabel="Add ZIP attachment below"
removeLabel="Remove ZIP attachment"
moveUpLabel="Move ZIP attachment up"
moveDownLabel="Move ZIP attachment down"
/>
}
];
addLabel="i18n:govoplan-campaign.add_zip_attachment_below.776dabc1"
removeLabel="i18n:govoplan-campaign.remove_zip_attachment.d2bdf662"
moveUpLabel="i18n:govoplan-campaign.move_zip_attachment_up.63446ac0"
moveDownLabel="i18n:govoplan-campaign.move_zip_attachment_down.4ead4805" />
}];
}
type ZipFilenameNamespace = "local" | "global";
@@ -496,19 +512,19 @@ function buildZipFilenameFieldOptions(draft: Record<string, unknown>): ZipFilena
const filenameFields = [...fieldDefinitions.values()].filter((field) => field.type !== "password");
const labels = new Map(filenameFields.map((field) => [field.name, field.label || field.name]));
const localNames = uniqueStrings(["id", "name", "email", ...recipientAddressTemplateFieldOptions().map((field) => field.name), ...filenameFields.map((field) => field.name)]);
const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))])
.filter((name) => fieldDefinitions.get(name)?.type !== "password");
const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))]).
filter((name) => fieldDefinitions.get(name)?.type !== "password");
return [
...localNames.map((name) => ({ namespace: "local" as const, name, label: labels.get(name) || humanizeFieldName(name) })),
...globalNames.map((name) => ({ namespace: "global" as const, name, label: labels.get(name) || humanizeFieldName(name) }))
];
...globalNames.map((name) => ({ namespace: "global" as const, name, label: labels.get(name) || humanizeFieldName(name) }))];
}
function describeZipArchiveNameValueProblem(archives: AttachmentZipArchive[], index: number, value: string): string {
const normalized = normalizeZipArchiveName(value);
if (!normalized) return "Archive filename must not be empty.";
if (!normalized) return "i18n:govoplan-campaign.archive_filename_must_not_be_empty.4234b817";
const duplicate = archives.some((archive, currentIndex) => currentIndex !== index && normalizeZipArchiveName(archive.name) === normalized);
return duplicate ? "Another ZIP attachment already uses this effective filename." : "";
return duplicate ? "i18n:govoplan-campaign.another_zip_attachment_already_uses_this_effecti.d05eb098" : "";
}
function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNameValidation {
@@ -530,14 +546,14 @@ function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNa
}
if (invalidIndexes.size === 0) return EMPTY_ZIP_ARCHIVE_NAME_VALIDATION;
const hasEmpty = archives.some((archive, index) => invalidIndexes.has(index) && !archive.name.trim());
const duplicateNames = [...byName.entries()]
.filter(([, indexes]) => indexes.length > 1)
.map(([, indexes]) => archives[indexes[0]]?.name.trim())
.filter(Boolean);
const duplicateNames = [...byName.entries()].
filter(([, indexes]) => indexes.length > 1).
map(([, indexes]) => archives[indexes[0]]?.name.trim()).
filter(Boolean);
const parts: string[] = [];
if (hasEmpty) parts.push("Archive filenames must not be empty");
if (hasEmpty) parts.push("i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427");
if (duplicateNames.length > 0) parts.push(`Archive filenames must be unique: ${duplicateNames.join(", ")}`);
return { invalidIndexes, message: `${parts.join(". ")}. Saving is disabled until this is corrected.` };
return { invalidIndexes, message: i18nMessage("i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0", { value0: parts.join(". ") }) };
}
function normalizeZipArchiveName(value: string): string {
@@ -553,8 +569,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;
@@ -563,49 +579,49 @@ 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 },
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="i18n:govoplan-campaign.campaign_files.96e7004b" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
{
id: "path",
header: "Path",
header: "i18n:govoplan-campaign.path.519e3913",
width: "minmax(260px, 1fr)",
resizable: true,
sortable: true,
filterable: true,
render: (basePath, index) => (
render: (basePath, index) =>
<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}
placeholder="attachments"
readOnly={managedFilesAvailable}
tabIndex={managedFilesAvailable ? -1 : undefined}
placeholder="i18n:govoplan-campaign.attachments_placeholder"
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>}
</div>
),
}} />
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>i18n:govoplan-campaign.choose_folder.1838a415</Button>}
</div>,
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
},
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "Individual" }, { value: "global only", label: "Global only" }] }, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "Warn" }, { value: "off", label: "Off" }] }, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
{ id: "individual", header: "i18n:govoplan-campaign.individual_attachments.8164fc7a", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "i18n:govoplan-campaign.individual.a7abed83" }, { value: "global_only", label: "i18n:govoplan-campaign.global_only.4641751c" }] }, render: (basePath, index) => <ToggleSwitch label="i18n:govoplan-campaign.individual.a7abed83" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global_only" },
{ id: "unsent_warning", header: "i18n:govoplan-campaign.unsent_warning.11bdfbf7", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "i18n:govoplan-campaign.warn.3009d557" }, { value: "off", label: "i18n:govoplan-campaign.off.e3de5ab0" }] }, render: (basePath, index) => <ToggleSwitch label="i18n:govoplan-campaign.unsent.0d09fa0d" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
{
id: "actions",
header: "Actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180,
sticky: "end",
render: (_basePath, index) => (
render: (_basePath, index) =>
<DataGridRowActions
disabled={locked}
removeDisabled={basePaths.length <= 1}
@@ -613,22 +629,22 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleIns
onRemove={() => removeBasePath(index)}
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
addLabel="Add attachment source below"
removeLabel="Remove attachment source"
moveUpLabel="Move attachment source up"
moveDownLabel="Move attachment source down"
/>
)
addLabel="i18n:govoplan-campaign.add_attachment_source_below.635c6a61"
removeLabel="i18n:govoplan-campaign.remove_attachment_source.95855c67"
moveUpLabel="i18n:govoplan-campaign.move_attachment_source_up.956ec756"
moveDownLabel="i18n:govoplan-campaign.move_attachment_source_down.cbf9ebd8" />
}];
}
];
}
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)
: undefined;
const rootLabel = matchingSpace?.label
|| (parsedSource?.ownerType === "user" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : "");
const matchingSpace = parsedSource ?
spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId) :
undefined;
const rootLabel = matchingSpace?.label || (
parsedSource?.ownerType === "user" ? "i18n:govoplan-campaign.my_files.71d01a41" : parsedSource?.ownerType === "group" ? "i18n:govoplan-campaign.group_files.1aacad0b" : "");
const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, "");
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
return `${relativePath || "."}/`;

View File

@@ -7,7 +7,7 @@ import VersionLine from "./components/VersionLine";
import { LoadingFrame } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
@@ -15,21 +15,21 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Audit log</PageTitle>
<PageTitle loading={loading}>i18n:govoplan-campaign.audit_log.3cfc5f1c</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading audit data">
<Card title="Recent audit events">
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
<p className="muted">i18n:govoplan-campaign.campaign_specific_audit_api_integration_will_be_.e53c8280</p>
</Card>
</LoadingFrame>
</div>
);
</div>);
}

View File

@@ -15,8 +15,8 @@ import FieldValueInput from "./components/FieldValueInput";
import { DismissibleAlert } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const fieldValueKeys = useRef<string[]>([]);
@@ -30,8 +30,8 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
reload,
setError,
currentStep: "campaign-fields",
unsavedTitle: "Unsaved fields",
unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.",
unsavedTitle: "i18n:govoplan-campaign.unsaved_fields.c0913c13",
unsavedMessage: "i18n:govoplan-campaign.campaign_fields_have_unsaved_changes_save_them_b.c7e992f1",
transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)),
onLoaded: (_loadedVersion, loadedDraft) => {
fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name);
@@ -142,7 +142,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
const fieldProblem = describeFieldNameProblem(fields);
if (fieldProblem) {
setLocalError(fieldProblem);
setSaveState("Save blocked");
setSaveState("i18n:govoplan-campaign.save_blocked.c09570a4");
return false;
}
return saveDraft("manual");
@@ -153,39 +153,39 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Fields</PageTitle>
<PageTitle loading={loading}>i18n:govoplan-campaign.fields.e8b68527</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft">
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<Card title="Campaign fields">
<Card title="i18n:govoplan-campaign.campaign_fields.969e7d80">
<div className="admin-table-surface campaign-fields-table-surface">
<DataGrid
id={`campaign-${campaignId}-fields`}
rows={fields}
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
getRowKey={(_field, index) => `field-row-${index}`}
emptyText="No campaign fields configured yet."
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="Add first field" />}
className="field-editor-table-wrap field-editor-table"
/>
emptyText="i18n:govoplan-campaign.no_campaign_fields_configured_yet.6772f948"
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_field.9fef7186" />}
className="field-editor-table-wrap field-editor-table" />
</div>
</Card>
</>
</LoadingFrame>
</div>
);
</div>);
}
type FieldColumnContext = {
@@ -203,32 +203,32 @@ type FieldColumnContext = {
function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] {
return [
{ id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
{ id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
{ id: "type", header: "Type", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => <select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>, value: (field) => field.type },
{ id: "required", header: "Required", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (field, index) => <ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
{ id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
{ id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can override", label: "Can override" }, { value: "locked", label: "Global only" }] }, render: (field, index) => <ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" },
{ id: "name", header: "i18n:govoplan-campaign.field_id.4ee26ffd", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
{ id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="i18n:govoplan-campaign.display_label.d747868d" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
{ id: "type", header: "i18n:govoplan-campaign.type.3deb7456", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => <select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>, value: (field) => field.type },
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (field, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
{ id: "global_value", header: "i18n:govoplan-campaign.global_value.ab230e7f", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="i18n:govoplan-campaign.optional_default.e04a3bfc" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
{ id: "override", header: "i18n:govoplan-campaign.recipient_override.4d84be6d", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can_override", label: "i18n:govoplan-campaign.can_override.c37b61c4" }, { value: "locked", label: "i18n:govoplan-campaign.global_only.4641751c" }] }, render: (field, index) => <ToggleSwitch label="i18n:govoplan-campaign.can_override.c37b61c4" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can_override" : "locked" },
{
id: "actions",
header: "Actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180,
sticky: "end",
render: (_field, index) => (
render: (_field, index) =>
<DataGridRowActions
disabled={locked}
onAddBelow={() => addField(index)}
onRemove={() => deleteField(index)}
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
addLabel="Add field below"
removeLabel="Remove field"
moveUpLabel="Move field up"
moveDownLabel="Move field down"
/>
)
}
];
addLabel="i18n:govoplan-campaign.add_field_below.c087fdd7"
removeLabel="i18n:govoplan-campaign.remove_field.f4e03605"
moveUpLabel="i18n:govoplan-campaign.move_field_up.d98ba824"
moveDownLabel="i18n:govoplan-campaign.move_field_down.8f27ccb2" />
}];
}
function normalizeFields(value: unknown): CampaignFieldDefinition[] {
@@ -279,7 +279,7 @@ function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState:
function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
const names = fields.map((field) => field.name.trim());
if (names.some((name) => !name)) {
return "Field IDs must not be empty before saving.";
return "i18n:govoplan-campaign.field_ids_must_not_be_empty_before_saving.3ac7b4ca";
}
const seen = new Set<string>();
@@ -290,7 +290,7 @@ function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
}
if (duplicates.size === 0) return "";
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`;
return i18nMessage("i18n:govoplan-campaign.duplicate_field_id_value_value_field_ids_must_be.9343a5e9", { value0: duplicates.size === 1 ? "" : "s", value1: [...duplicates].sort().join(", ") });
}
function uniqueFieldName(fields: CampaignFieldDefinition[]): string {

View File

@@ -9,7 +9,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, getCampaignJson } from "./utils/campaignView";
import { downloadJson, safeFileStem } from "./utils/draftEditor";
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
export default function CampaignJsonView({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const campaignJson = getCampaignJson(version);
@@ -20,20 +20,20 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>JSON</PageTitle>
<PageTitle loading={loading}>i18n:govoplan-campaign.json.031a4e76</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>Download JSON</Button>
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading JSON…">
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50">
<Card>
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
</Card>
</LoadingFrame>
</div>
);
</div>);
}

View File

@@ -1,32 +1,41 @@
import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react";
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate } from "@govoplan/core-webui";
import { Link, useNavigate } from "react-router-dom";
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
export default function CampaignListPage({ settings }: { settings: ApiSettings }) {
const navigate = useNavigate();
export default function CampaignListPage({ settings }: {settings: ApiSettings;}) {
const navigate = useGuardedNavigate();
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState<string | null>(null);
async function load() {
async function load(forcedSince: string | null | undefined = campaignDeltaWatermark) {
setLoading(true);
setError("");
try {
const data = await listCampaigns(settings);
setCampaigns(data);
let nextWatermark = forcedSince ?? null;
let nextCampaigns = campaigns;
let response: CampaignDeltaResponse;
do {
response = await listCampaignsDelta(settings, { since: nextWatermark });
nextCampaigns = mergeCampaignDelta(nextCampaigns, response);
nextWatermark = response.watermark ?? null;
} while (response.has_more);
setCampaigns(nextCampaigns);
setCampaignDeltaWatermark(nextWatermark);
setLastLoadedAt(formatLoadedAt(new Date()));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -49,41 +58,42 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
}
useEffect(() => {
load();
setCampaignDeltaWatermark(null);
load(null);
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const columns: DataGridColumn<CampaignListItem>[] = [
{
id: "campaign",
header: "Campaign",
header: "i18n:govoplan-campaign.campaign.69390e16",
width: "minmax(260px, 1.8fr)",
sortable: true,
filterable: true,
sticky: "start",
render: (campaign) => (
render: (campaign) =>
<div>
<Link className="table-primary-link" to={`/campaigns/${campaign.id}`}>
{campaign.name || campaign.external_id || campaign.id}
</Link>
<div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div>
</div>
),
</div>,
value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}`
},
{
id: "status",
header: "Status",
header: "i18n:govoplan-campaign.status.bae7d5be",
width: 150,
sortable: true,
filterable: true,
columnType: "from-list",
list: {
options: [
{ value: "draft", label: "Draft" },
{ value: "active", label: "Active" },
{ value: "completed", label: "Completed" },
{ value: "archived", label: "Archived" }
],
{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" },
{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" },
{ value: "completed", label: "i18n:govoplan-campaign.completed.1798b3ba" },
{ value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }],
display: "pill"
},
render: (campaign) => <StatusBadge status={campaign.status || "draft"} />,
@@ -91,7 +101,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
},
{
id: "current_version",
header: "Current version",
header: "i18n:govoplan-campaign.current_version.7be72582",
width: 170,
sortable: true,
filterable: true,
@@ -101,7 +111,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
},
{
id: "updated",
header: "Updated",
header: "i18n:govoplan-campaign.updated.f2f8570d",
width: 190,
sortable: true,
filterable: true,
@@ -112,51 +122,51 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
},
{
id: "actions",
header: "Actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 70,
sticky: "end",
align: "right",
render: (campaign) => (
render: (campaign) =>
<Link
to={`/campaigns/${campaign.id}`}
className="btn btn-primary admin-icon-button"
aria-label={`Open ${campaign.name || campaign.external_id || campaign.id}`}
title={`Open ${campaign.name || campaign.external_id || campaign.id}`}
>
aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}
title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}>
<ExternalLink aria-hidden="true" />
</Link>
)
}
];
}];
return (
<div className="content-pad campaigns-page">
<div className="content-pad workspace-data-page campaigns-page">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>All campaigns</PageTitle>
<p className="mono-small">{lastLoadedAt ? `Last loaded: ${lastLoadedAt}` : "Not loaded yet"}</p>
<PageTitle loading={loading}>i18n:govoplan-campaign.all_campaigns.2bd1ee3a</PageTitle>
<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={load} disabled={loading}>Reload</Button>
<Button onClick={load} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={create} disabled={creating}>
{creating ? "Creating…" : "New campaign"}
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
</Button>
</div>
</div>
<Card title="Campaigns">
<LoadingFrame loading={loading} label="Loading campaigns">
{campaigns.length === 0 ? (
<Card title="i18n:govoplan-campaign.campaigns.01a23a28">
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaigns.90a466bb">
{campaigns.length === 0 ?
<div className="empty-state">
<h2>No campaigns yet</h2>
<p>Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.</p>
<h2>i18n:govoplan-campaign.no_campaigns_yet.926409b3</h2>
<p>i18n:govoplan-campaign.start_with_a_guided_campaign_draft_the_webui_wil.c8a675d0</p>
<Button variant="primary" onClick={create} disabled={creating}>
Create first campaign
i18n:govoplan-campaign.create_first_campaign.9be974a4
</Button>
</div>
) : (
</div> :
<div className="admin-table-surface campaigns-table-surface">
<DataGrid
id="campaigns"
@@ -165,14 +175,14 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
getRowKey={(campaign) => campaign.id}
initialSort={{ columnId: "updated", direction: "desc" }}
className="campaign-table-wrap"
emptyText="No campaigns found."
/>
emptyText="i18n:govoplan-campaign.no_campaigns_found.22d297f9" />
</div>
)}
}
</LoadingFrame>
</Card>
</div>
);
</div>);
}
function shortId(value: string): string {
@@ -187,3 +197,17 @@ function formatDateTime(value?: string): string {
function formatLoadedAt(value: Date): string {
return formatDateTimeFromDate(value, { second: "2-digit" });
}
function mergeCampaignDelta(current: CampaignListItem[], response: CampaignDeltaResponse): CampaignListItem[] {
if (response.full) return response.campaigns;
return mergeDeltaRows(current, response.campaigns, response.deleted, (campaign) => campaign.id, {
deletedResourceType: "campaign",
sort: sortCampaignsByUpdatedDesc
});
}
function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")
);
}

View File

@@ -10,7 +10,7 @@ import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import {
lockCampaignVersionPermanently,
@@ -18,8 +18,8 @@ import {
unlockCampaignVersionUserLock,
updateCampaignMetadata,
type CampaignVersionDetail,
type CampaignVersionListItem,
} from "../../api/campaigns";
type CampaignVersionListItem } from
"../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
asArray,
@@ -31,15 +31,15 @@ import {
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
isVersionReadyForDelivery,
summaryValue,
} from "./utils/campaignView";
summaryValue } from
"./utils/campaignView";
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
const campaignModeOptions = ["draft", "test", "send"];
type LockAction = "temporary" | "unlock" | "permanent";
type PendingLockAction = { version: CampaignVersionListItem; action: LockAction } | null;
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const campaign = data.campaign;
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
@@ -51,13 +51,28 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
const [message, setMessage] = useState("");
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
useUnsavedDraftGuard({
dirty: identityDirty,
onSave: saveIdentity,
onDiscard: () => {
if (!campaign) return;
setIdentity({
external_id: campaign.external_id ?? "",
name: campaign.name ?? "",
status: campaign.status ?? "",
description: campaign.description ?? ""
});
setIdentityDirty(false);
}
});
useEffect(() => {
if (!campaign || identityDirty) return;
setIdentity({
external_id: campaign.external_id ?? "",
name: campaign.name ?? "",
status: campaign.status ?? "",
description: campaign.description ?? "",
description: campaign.description ?? ""
});
}, [campaign, identityDirty]);
@@ -67,8 +82,8 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
setMessage("");
}
async function saveIdentity() {
if (!campaign || savingIdentity || !identityDirty) return;
async function saveIdentity(): Promise<boolean> {
if (!campaign || savingIdentity || !identityDirty) return false;
setSavingIdentity(true);
setError("");
setMessage("");
@@ -77,12 +92,14 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
external_id: identity.external_id,
name: identity.name,
status: identity.status,
description: identity.description,
description: identity.description
});
setIdentityDirty(false);
await reload();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setSavingIdentity(false);
}
@@ -97,13 +114,13 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
try {
if (pending.action === "temporary") {
await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} temporarily locked.`);
setMessage(i18nMessage("i18n:govoplan-campaign.version_value_temporarily_locked.4ed4ffa7", { value0: pending.version.version_number }));
} else if (pending.action === "unlock") {
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
setMessage(`Temporary lock removed from version #${pending.version.version_number}.`);
setMessage(i18nMessage("i18n:govoplan-campaign.temporary_lock_removed_from_version_value.9a05bcfb", { value0: pending.version.version_number }));
} else {
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} permanently locked.`);
setMessage(i18nMessage("i18n:govoplan-campaign.version_value_permanently_locked.60595f45", { value0: pending.version.version_number }));
}
setPendingLockAction(null);
await reload();
@@ -118,70 +135,70 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
<p className="mono-small">Campaign overview · version-independent identity and version history</p>
<PageTitle loading={loading}>{campaign?.name || "i18n:govoplan-campaign.overview.0efc2e6b"}</PageTitle>
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>Reload</Button>
<Link to="wizard/create"><Button>Edit with wizard</Button></Link>
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "Saving…" : "Save"}</Button>
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>i18n:govoplan-campaign.reload.cce71553</Button>
<Link to="wizard/create"><Button>i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Button></Link>
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading campaign overview">
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
<div className="metric-grid campaign-overview-metrics current-version-metrics">
<MetricCard label="Version" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
<MetricCard label="Fields" value={versionMetrics.fieldCount} tone="info" />
<MetricCard label="Recipients" value={versionMetrics.recipientCount} tone="neutral" detail="Active inline recipients" />
<MetricCard label="Template health" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
</div>
<div className="metric-grid campaign-overview-metrics">
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
<MetricCard label="Sent" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="SMTP success" />
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" />
<MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
</div>
<Card title="Current version state" actions={<Link
<Card title="i18n:govoplan-campaign.current_version_state.f581778f" actions={<Link
to={`send?version=${campaign?.current_version_id}`}
className={`btn btn-primary`}
aria-label={`Open curent version`}
title={`Open curent version`}
>
Open
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
i18n:govoplan-campaign.open.cf9b7706
</Link>}>
<div className="summary-grid overview-summary-grid">
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
<SummaryTile label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
</div>
</Card>
<Card title="Campaign identity" collapsible>
<Card title="i18n:govoplan-campaign.campaign_identity.a00ca574" collapsible>
<div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID">
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
</FormField>
<FormField label="Mode">
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
<select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}>
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
<FormField label="Name">
<FormField label="i18n:govoplan-campaign.name.709a2322">
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
</FormField>
<FormField label="Description">
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
</FormField>
</div>
</Card>
<Card title="Version history" collapsible>
<Card title="i18n:govoplan-campaign.version_history.91f86581" collapsible>
<div className="admin-table-surface">
<DataGrid
id={`campaign-${campaignId}-versions`}
@@ -189,10 +206,10 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }}
emptyText="No versions found."
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
className="version-history-table"
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined}
/>
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
</div>
</Card>
</LoadingFrame>
@@ -205,10 +222,10 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
busy={lockBusy}
onCancel={() => setPendingLockAction(null)}
onConfirm={() => void applyLockAction()}
/>
</div>
);
onConfirm={() => void applyLockAction()} />
</div>);
}
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
@@ -253,10 +270,10 @@ function campaignVersionMetrics(version: CampaignVersionDetail | null): Campaign
templateHealthValue: `${score}/3`,
templateHealthTone: score === 3 ? "good" : score === 2 ? "warning" : "danger",
templateHealthDetail: [
subjectOk ? "Subject OK" : "Subject missing",
bodyOk ? "Body OK" : "Body missing",
placeholdersOk ? "Placeholders OK" : `${undefinedPlaceholders.length} undefined`
].join(" · ")
subjectOk ? "i18n:govoplan-campaign.subject_ok.3dbdc3ed" : "i18n:govoplan-campaign.subject_missing.f545bf65",
bodyOk ? "i18n:govoplan-campaign.body_ok.cdb33005" : "i18n:govoplan-campaign.body_missing.bd58622f",
placeholdersOk ? "i18n:govoplan-campaign.placeholders_ok.3a07f3bc" : `${undefinedPlaceholders.length} undefined`].
join(" · ")
};
}
@@ -270,15 +287,15 @@ function textValue(value: unknown, fallback = ""): string {
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
return [
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "State", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
{ id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Current working version", label: "Current working version" }, { value: "Historical version", label: "Historical version" }, { value: "Temporarily locked", label: "Temporarily locked" }, { value: "Permanently locked", label: "Permanently locked" }, { value: "Delivery locked", label: "Delivery locked" }] }, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
{ id: "validation", header: "Validation", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not validated", label: "Not validated" }, { value: "Valid", label: "Valid" }, { value: "Validation issues", label: "Validation issues" }] }, render: validationLabel, value: validationLabel },
{ id: "build", header: "Build", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not built", label: "Not built" }, { value: "Built", label: "Built" }, { value: "Build issues", label: "Build issues" }] }, render: buildLabel, value: buildLabel },
{ id: "updated", header: "Updated", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
{ id: "lock", header: "i18n:govoplan-campaign.lock.891ebccd", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Current working version", label: "i18n:govoplan-campaign.current_working_version.eda99d70" }, { value: "Historical version", label: "i18n:govoplan-campaign.historical_version.8880931f" }, { value: "Temporarily locked", label: "i18n:govoplan-campaign.temporarily_locked.1716dc95" }, { value: "Permanently locked", label: "i18n:govoplan-campaign.permanently_locked.327d59fd" }, { value: "Delivery locked", label: "i18n:govoplan-campaign.delivery_locked.2b664305" }] }, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not validated", label: "i18n:govoplan-campaign.not_validated.11bb4178" }, { value: "Valid", label: "i18n:govoplan-campaign.valid.a4aefa35" }, { value: "Validation issues", label: "i18n:govoplan-campaign.validation_issues.528305b1" }] }, render: validationLabel, value: validationLabel },
{ id: "build", header: "i18n:govoplan-campaign.build.bbd80cf7", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not built", label: "i18n:govoplan-campaign.not_built.88bbe87a" }, { value: "Built", label: "i18n:govoplan-campaign.built.a6ad3f82" }, { value: "Build issues", label: "i18n:govoplan-campaign.build_issues.3e03bf8e" }] }, render: buildLabel, value: buildLabel },
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
{
id: "actions",
header: "Actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 260,
sticky: "end",
render: (version) => {
@@ -288,88 +305,88 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
<Link
to={`send?version=${version.id}`}
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
aria-label={`Open version ${version.version_number}`}
title={`Open version ${version.version_number}`}
>
aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}
title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}>
<ExternalLink aria-hidden="true" />
</Link>
{isCurrent && (isTemporaryUserLockedVersion(version) ? (
{isCurrent && (isTemporaryUserLockedVersion(version) ?
<>
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
</>
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>i18n:govoplan-campaign.unlock.1526a17e</Button>
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>i18n:govoplan-campaign.lock_permanently.cc0ce9e7</Button>
</> :
!isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ?
<Button
className="admin-icon-button"
onClick={() => setPendingLockAction({ version, action: "temporary" })}
aria-label={`Temporarily lock version ${version.version_number}`}
title="Temporarily lock version"
>
aria-label={i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number })}
title="i18n:govoplan-campaign.temporarily_lock_version.82b31149">
<LockKeyhole aria-hidden="true" />
</Button>
) : null)}
</div>
);
},
},
];
</Button> :
null)}
</div>);
}
}];
}
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
if (currentVersionId && version.id !== currentVersionId) return "Historical · review-only";
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
if (isFinalLockedVersion(version)) return "Permanent delivery lock";
if (canUnlockValidationVersion(version)) return "Temporary validation lock";
if (version.locked_at) return "Temporarily locked";
return "Editable";
if (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82";
if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
if (isFinalLockedVersion(version)) return "i18n:govoplan-campaign.permanent_delivery_lock.07932206";
if (canUnlockValidationVersion(version)) return "i18n:govoplan-campaign.temporary_validation_lock.fdc5545d";
if (version.locked_at) return "i18n:govoplan-campaign.temporarily_locked.1716dc95";
return "i18n:govoplan-campaign.editable.b91faec0";
}
function validationLabel(version: CampaignVersionListItem): string {
const validation = version.validation_summary ?? {};
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
if (validation.ok === false) return "Needs attention";
if (validation.ok === true) return "Passed, unavailable while user-locked";
return "Not validated";
if (validation.ok === true && isVersionReadyForDelivery(version)) return "i18n:govoplan-campaign.passed.271d60f4";
if (validation.ok === false) return "i18n:govoplan-campaign.needs_attention.a126722e";
if (validation.ok === true) return "i18n:govoplan-campaign.passed_unavailable_while_user_locked.d6771ff4";
return "i18n:govoplan-campaign.not_validated.11bb4178";
}
function buildLabel(version: CampaignVersionListItem): string {
const build = version.build_summary ?? {};
return String(build.built_count ?? build.ready_count ?? "Not built");
return String(build.built_count ?? build.ready_count ?? "i18n:govoplan-campaign.not_built.88bbe87a");
}
function lockDialogTitle(pending: PendingLockAction): string {
if (pending?.action === "temporary") return "Temporarily lock version?";
if (pending?.action === "unlock") return "Unlock version?";
if (pending?.action === "permanent") return "Lock version permanently?";
return "Confirm lock action";
if (pending?.action === "temporary") return "i18n:govoplan-campaign.temporarily_lock_version.8ccc5708";
if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock_version.ebd2fd9a";
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_version_permanently.a8625753";
return "i18n:govoplan-campaign.confirm_lock_action.617986ee";
}
function lockDialogMessage(pending: PendingLockAction): string {
if (pending?.action === "temporary") {
return "This makes the version read-only without making it final. An authorized user may unlock it later or make the lock permanent.";
return "i18n:govoplan-campaign.this_makes_the_version_read_only_without_making_.9d86667b";
}
if (pending?.action === "unlock") {
return "This removes the temporary user lock and makes the version editable again. Existing validation/build state is otherwise retained.";
return "i18n:govoplan-campaign.this_removes_the_temporary_user_lock_and_makes_t.fdabd47a";
}
if (pending?.action === "permanent") {
return "This lock cannot be removed by any role. The version remains reviewable for audit purposes; future changes require an editable copy.";
return "i18n:govoplan-campaign.this_lock_cannot_be_removed_by_any_role_the_vers.14ae9b80";
}
return "Continue?";
return "i18n:govoplan-campaign.continue.4e6302ed";
}
function lockDialogLabel(pending: PendingLockAction): string {
if (pending?.action === "temporary") return "Lock temporarily";
if (pending?.action === "unlock") return "Unlock";
if (pending?.action === "permanent") return "Lock permanently";
return "Confirm";
if (pending?.action === "temporary") return "i18n:govoplan-campaign.lock_temporarily.3f91d346";
if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock.1526a17e";
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
return "i18n:govoplan-campaign.confirm.04a21221";
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
function SummaryTile({ label, value }: {label: string;value: string | number;}) {
return (
<div className="summary-tile">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
</div>);
}

View File

@@ -1,16 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import {
downloadCampaignJobsCsv,
emailCampaignReport,
getCampaignJobDetail,
getCampaignJobs,
getCampaignJobsDelta,
resolveCampaignJobOutcome,
retryCampaignJobs,
sendUnattemptedCampaignJobs,
type CampaignJobDetailResponse,
type CampaignJobsResponse,
} from "../../api/campaigns";
type CampaignJobsResponse } from
"../../api/campaigns";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
@@ -20,9 +20,10 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine";
import { LoadingFrame } from "@govoplan/core-webui";
import { LoadingFrame, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, humanize, stringifyPreview } from "./utils/campaignView";
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"not_queued",
@@ -34,33 +35,35 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"outcome_unknown",
"failed_temporary",
"failed_permanent",
"cancelled",
].map((value) => ({ value, label: humanize(value) }));
"cancelled"].
map((value) => ({ value, label: humanize(value) }));
type ReconcileRequest = { jobId: string; decision: "smtp_accepted" | "not_sent" } | null;
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"pending",
"appended",
"failed",
"skipped"].
map((value) => ({ value, label: humanize(value) }));
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
export default function CampaignReportPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const version = data.currentVersion;
const cards = data.summary?.cards;
const delivery = asRecord(data.summary?.delivery);
const rateLimit = asRecord(delivery.rate_limit);
const imapPolicy = asRecord(delivery.imap_append_sent);
const [jobs, setJobs] = useState<CampaignJobsResponse>({
jobs: [],
page: 1,
page_size: 50,
total: 0,
total_unfiltered: 0,
pages: 0,
counts: {},
filtered_counts: {},
review: {},
});
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
const jobsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
const jobPageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
const [jobsLoading, setJobsLoading] = useState(false);
const [page, setPage] = useState(1);
const [sendStatus, setSendStatus] = useState("");
const [imapStatus, setImapStatus] = useState("");
const [query, setQuery] = useState("");
const [appliedQuery, setAppliedQuery] = useState("");
const [actionMessage, setActionMessage] = useState("");
@@ -81,30 +84,75 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
return () => window.clearTimeout(handle);
}, [query]);
useEffect(() => {
void loadJobs();
}, [campaignId, version?.id, page, sendStatus, appliedQuery]);
const jobsQueryKey = useMemo(
() => JSON.stringify({
campaignId,
versionId: version?.id ?? null,
page,
pageSize: 50,
sendStatus,
imapStatus,
appliedQuery,
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
accessToken: settings.accessToken
}),
[campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
);
async function loadJobs() {
useEffect(() => {
jobPageCursorsRef.current = { 1: null };
}, [campaignId, version?.id, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const loadJobs = useCallback(async () => {
if (!campaignId) return;
setJobsLoading(true);
setActionError("");
try {
const response = await getCampaignJobs(settings, campaignId, {
let nextWatermark = getDeltaWatermark(jobsQueryKey);
let merged = jobsRef.current;
let hasMore = false;
const pageCursor = page === 1 ? null : jobPageCursorsRef.current[page];
do {
const response = await getCampaignJobsDelta(settings, campaignId, {
versionId: version?.id,
page,
pageSize: 50,
cursor: pageCursor,
sendStatus: sendStatus ? [sendStatus] : undefined,
imapStatus: imapStatus ? [imapStatus] : undefined,
query: appliedQuery || undefined,
since: nextWatermark
});
setJobs(response);
if (response.pages > 0 && page > response.pages) setPage(response.pages);
merged = mergeCampaignJobsDelta(merged, response);
if (response.cursor !== undefined) jobPageCursorsRef.current[page] = response.cursor ?? null;
if (response.next_cursor !== undefined) {
if (response.next_cursor) jobPageCursorsRef.current[page + 1] = response.next_cursor;
else delete jobPageCursorsRef.current[page + 1];
}
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(jobsQueryKey, nextWatermark);
jobsRef.current = merged;
setJobs(merged);
if (merged.pages > 0 && page > merged.pages) setPage(merged.pages);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setJobsLoading(false);
}
}
}, [settings, campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, jobsQueryKey, getDeltaWatermark, setDeltaWatermark]);
useEffect(() => {
resetDeltaWatermark(jobsQueryKey);
jobsRef.current = emptyCampaignJobsResponse();
setJobs(emptyCampaignJobsResponse());
}, [jobsQueryKey, resetDeltaWatermark]);
useEffect(() => {
void loadJobs();
}, [loadJobs]);
async function reloadAll() {
await Promise.all([reload(), loadJobs()]);
@@ -116,9 +164,9 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
setActionError("");
setActionMessage("");
try {
const response = action === "retry"
? await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true })
: await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
const response = action === "retry" ?
await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }) :
await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
const result = asRecord(response.result ?? response);
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
await reloadAll();
@@ -135,9 +183,9 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
setActionError("");
try {
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
setActionMessage(reconcile.decision === "smtp_accepted"
? "The job was recorded as SMTP accepted and is protected from retry."
: "The job was recorded as not sent. It is now an explicit retry candidate.");
setActionMessage(reconcile.decision === "smtp_accepted" ?
"i18n:govoplan-campaign.the_job_was_recorded_as_smtp_accepted_and_is_pro.12ee72b6" :
"i18n:govoplan-campaign.the_job_was_recorded_as_not_sent_it_is_now_an_ex.2cea8409");
setReconcile(null);
await reloadAll();
} catch (err) {
@@ -164,7 +212,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
setActionError("");
try {
await downloadCampaignJobsCsv(settings, campaignId, version?.id);
setActionMessage("Campaign job CSV downloaded.");
setActionMessage("i18n:govoplan-campaign.campaign_job_csv_downloaded.08af6930");
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
@@ -183,7 +231,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
version_id: version?.id,
include_jobs: false,
attach_jobs_csv: attachCsv,
attach_report_json: attachJson,
attach_report_json: attachJson
});
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
setEmailOpen(false);
@@ -195,17 +243,55 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
}
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => [
{ id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) + 1 },
{ id: "recipient", header: "Recipient", width: 240, resizable: true, sortable: true, filterable: true, value: (row) => String(row.recipient_email ?? "—") },
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
{ id: "send", header: "SMTP", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, value: (row) => String(row.send_status ?? "unknown") },
{ id: "imap", header: "IMAP", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") },
{ id: "attempts", header: "Attempts", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
{ id: "error", header: "Last result", width: "minmax(220px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "—") },
{ id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) || 1 },
{
id: "recipient",
header: "i18n:govoplan-campaign.recipient.90343260",
width: 260,
resizable: true,
sortable: true,
filterable: true,
render: (row) =>
<div className="recipient-outcome-cell">
<strong>{String(row.recipient_email ?? "—")}</strong>
<span>{String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}</span>
</div>,
value: (row) => String(row.recipient_email ?? "—")
},
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} />, value: (row) => String(row.send_status ?? "unknown") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") },
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
{
id: "evidence",
header: "i18n:govoplan-campaign.evidence.7ea014de",
width: 240,
resizable: true,
filterable: true,
render: (row) =>
<div className="recipient-outcome-cell">
<span title={String(row.message_id_header ?? "")}>{row.message_id_header ? i18nMessage("i18n:govoplan-campaign.message_id_value_value.24027e70", { value0: String(row.message_id_header).slice(0, 28), value1: String(row.message_id_header).length > 28 ? "..." : "" }) : "i18n:govoplan-campaign.no_message_id.43390ef7"}</span>
<span>{String(row.attachment_count ?? 0)} i18n:govoplan-campaign.attachment_rule_s.0e5ee66a {String(row.matched_file_count ?? 0)} i18n:govoplan-campaign.file_s.4bc4cc05</span>
</div>,
value: (row) => `${String(row.message_id_header ?? "")} ${String(row.eml_sha256 ?? "")}`
},
{
id: "error",
header: "i18n:govoplan-campaign.last_result.110b888b",
width: "minmax(220px, 1fr)",
resizable: true,
filterable: true,
render: (row) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>,
value: (row) => String(row.last_error ?? "—")
},
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 165, sortable: true, value: (row) => formatDateTime(String(row.updated_at ?? row.sent_at ?? row.queued_at ?? "")) },
{
id: "actions",
header: "Actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 250,
sticky: "end",
render: (row) => {
@@ -213,145 +299,196 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
const status = String(row.send_status ?? "");
return (
<div className="button-row compact-actions">
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>Details</Button>
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>Accepted</Button>}
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>Not sent</Button>}
</div>
);
},
},
], [busyAction]);
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
</div>);
}
}],
[busyAction]);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Report</PageTitle>
<PageTitle loading={loading}>i18n:govoplan-campaign.report.ee45c303</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>Download CSV</Button>
<Button onClick={() => setEmailOpen(true)}>Email report</Button>
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Reload</Button>
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>
</div>
</div>
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
{actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading report data">
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_report_data.0908ade5">
<div className="dashboard-grid">
<Card title="Delivery outcome">
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
<dl className="detail-list">
<div><dt>Generated</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
<div><dt>Jobs total</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
<div><dt>SMTP accepted</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
<div><dt>Failed</dt><dd>{cards?.failed ?? 0}</dd></div>
<div><dt>Outcome unknown</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
<div><dt>Not attempted</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
<div><dt>Cancelled</dt><dd>{cards?.cancelled ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
<div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
<div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div>
</dl>
</Card>
<Card title="IMAP and execution plan">
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
<dl className="detail-list">
<div><dt>IMAP appended</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
<div><dt>IMAP failed</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
<div><dt>Append policy</dt><dd>{imapPolicy.enabled === true ? `Enabled (${String(imapPolicy.folder ?? "auto")})` : "Disabled"}</dd></div>
<div><dt>Rate limit</dt><dd>{rateLimit.messages_per_minute ? `${String(rateLimit.messages_per_minute)} / minute` : "—"}</dd></div>
<div><dt>Minimum remaining duration</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
<div><dt>Execution snapshot</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? `${String(delivery.execution_snapshot_hash).slice(0, 12)}` : "Missing"}</dd></div>
<div><dt>i18n:govoplan-campaign.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
<div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</dd></div>
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.execution_snapshot.5a67f098</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}</dd></div>
</dl>
</Card>
<Card title="Explicit delivery actions">
<p className="muted">These actions never include SMTP-accepted or unresolved jobs. Uncertain outcomes must be reconciled first.</p>
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
<div className="button-row compact-actions">
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>Retry temporary failures</Button>
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>Send unattempted jobs</Button>
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
</div>
</Card>
</div>
<Card title="Recipient delivery jobs">
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
<div className="page-heading split">
<div className="button-row compact-actions">
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search recipient, subject or entry ID" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5" />
<select value={sendStatus} onChange={(event) => {setSendStatus(event.target.value);setPage(1);}}>
<option value="">All SMTP states</option>
<option value="">i18n:govoplan-campaign.all_smtp_states.739597b1</option>
{SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select value={imapStatus} onChange={(event) => {setImapStatus(event.target.value);setPage(1);}}>
<option value="">i18n:govoplan-campaign.all_imap_states.8546b84c</option>
{IMAP_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
<span className="muted">{jobs.total} matching of {jobs.total_unfiltered} total job(s)</span>
<span className="muted">{jobs.total} i18n:govoplan-campaign.matching_of.66a3778e {jobs.total_unfiltered} i18n:govoplan-campaign.total_job_s.c94b7d20</span>
</div>
<LoadingFrame loading={jobsLoading} label="Loading delivery jobs">
<LoadingFrame loading={jobsLoading} label="i18n:govoplan-campaign.loading_delivery_jobs.20ecc37e">
<DataGrid<Record<string, unknown>>
id={`campaign-report-jobs-${campaignId}`}
rows={jobs.jobs}
columns={columns}
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
emptyText="No jobs match the current filters."
/>
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5" />
</LoadingFrame>
<div className="button-row compact-actions">
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>Previous</Button>
<span>Page {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>Next</Button>
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>i18n:govoplan-campaign.previous.50f94286</Button>
<span>i18n:govoplan-campaign.page.fb06270f {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>i18n:govoplan-campaign.next.bc981983</Button>
</div>
</Card>
</LoadingFrame>
<Dialog
open={emailOpen}
title="Email campaign report"
title="i18n:govoplan-campaign.email_campaign_report.61a2989d"
onClose={() => setEmailOpen(false)}
closeDisabled={busyAction === "email"}
footer={(
footer={
<div className="button-row">
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>Cancel</Button>
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>Send report</Button>
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>i18n:govoplan-campaign.send_report.a5b32af9</Button>
</div>
)}
>
}>
<label className="field-stack">
<span>Recipients</span>
<span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
</label>
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> Attach job CSV</label>
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> Attach JSON report</label>
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label>
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label>
</Dialog>
<Dialog
open={Boolean(detail)}
title="Campaign job detail"
title="i18n:govoplan-campaign.campaign_job_detail.81dc68e1"
onClose={() => setDetail(null)}
className="dialog-panel-wide"
>
{detail && (
className="dialog-panel-wide">
{detail &&
<div className="stacked-sections">
<dl className="detail-list">
<div><dt>Recipient</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
<div><dt>Subject</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
<div><dt>SMTP state</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
<div><dt>IMAP state</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div>
<div><dt>Attachments</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div>
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
</dl>
<h3>SMTP attempts</h3>
<pre className="json-preview">{stringifyPreview(detail.attempts.smtp ?? [], 12000)}</pre>
<h3>IMAP attempts</h3>
<pre className="json-preview">{stringifyPreview(detail.attempts.imap ?? [], 12000)}</pre>
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
</div>
)}
}
</Dialog>
<ConfirmDialog
open={Boolean(reconcile)}
title={reconcile?.decision === "smtp_accepted" ? "Record SMTP acceptance?" : "Record message as not sent?"}
message={reconcile?.decision === "smtp_accepted"
? "Use this only after checking the SMTP server or recipient-side evidence. The job will be protected from retry and may proceed to IMAP append."
: "Use this only when you have evidence that SMTP did not accept the message. The job will become a failed-temporary retry candidate, but it will not be retried automatically."}
confirmLabel={reconcile?.decision === "smtp_accepted" ? "Record accepted" : "Record not sent"}
title={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_smtp_acceptance.c40f8c9d" : "i18n:govoplan-campaign.record_message_as_not_sent.42e4faf8"}
message={reconcile?.decision === "smtp_accepted" ?
"i18n:govoplan-campaign.use_this_only_after_checking_the_smtp_server_or_.6f4396e1" :
"i18n:govoplan-campaign.use_this_only_when_you_have_evidence_that_smtp_d.aa48f4ad"}
confirmLabel={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_accepted.023d6747" : "i18n:govoplan-campaign.record_not_sent.b376b4ed"}
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
busy={busyAction === "reconcile"}
onConfirm={() => void reconcileOutcome()}
onCancel={() => setReconcile(null)}
/>
</div>
);
onCancel={() => setReconcile(null)} />
</div>);
}
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record<string, unknown>[];}) {
const title = kind === "smtp" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" : "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
if (rows.length === 0) {
return (
<section className="attempt-history-section">
<h3>{title}</h3>
<p className="muted small-note">i18n:govoplan-campaign.no.816c52fd {kind === "smtp" ? "i18n:govoplan-campaign.smtp.efff9cca" : "i18n:govoplan-campaign.imap.271f9ef2"} i18n:govoplan-campaign.attempt_has_been_recorded_for_this_job.e4050f01</p>
</section>);
}
return (
<section className="attempt-history-section">
<h3>{title}</h3>
<div className="attempt-history-wrap">
<table className="attempt-history-table">
<thead>
<tr>
<th>#</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
{kind === "imap" && <th>i18n:govoplan-campaign.folder.30baa249</th>}
{kind === "smtp" && <th>i18n:govoplan-campaign.code.adac6937</th>}
<th>i18n:govoplan-campaign.started.faa9e7e7</th>
<th>i18n:govoplan-campaign.finished.355bcc57</th>
<th>i18n:govoplan-campaign.result.5faa59d4</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) =>
<tr key={String(row.id ?? `${kind}-${index}`)}>
<td>{String(row.attempt_number ?? index + 1)}</td>
<td><StatusBadge status={String(row.status ?? "unknown")} /></td>
{kind === "imap" && <td>{String(row.folder ?? "—")}</td>}
{kind === "smtp" && <td>{String(row.smtp_status_code ?? "—")}</td>}
<td>{formatDateTime(String(row.started_at ?? row.created_at ?? ""))}</td>
<td>{formatDateTime(String(row.finished_at ?? row.updated_at ?? ""))}</td>
<td title={String(row.smtp_response ?? row.error_message ?? "")}>
{String(row.smtp_response ?? row.error_message ?? "—")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import { useGuardedNavigate } from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar";
import CampaignOverviewPage from "./CampaignOverviewPage";
@@ -17,7 +18,6 @@ import SendWizard from "./wizard/SendWizard";
import CampaignJsonView from "./CampaignJsonView";
import CampaignReportPage from "./CampaignReportPage";
import CampaignAuditPage from "./CampaignAuditPage";
import { useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "",
@@ -44,7 +44,7 @@ export default function CampaignWorkspace({ settings, auth }: { settings: ApiSet
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const { campaignId } = useParams();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const guardedNavigate = useGuardedNavigate();
const location = useLocation();
const active = sectionFromPath(location.pathname);
const urlVersionId = new URLSearchParams(location.search).get("version");
@@ -73,7 +73,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
const query = params.toString();
const target = query ? `${pathname}?${query}` : pathname;
if (`${location.pathname}${location.search}` === target) return;
requestNavigation(() => navigate(target));
guardedNavigate(target);
}
return (

View File

@@ -1,4 +1,4 @@
import { useState, type ReactNode } from "react";
import { useState } from "react";
import type { ApiSettings, AuthInfo } from "../../types";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
@@ -6,6 +6,8 @@ import { FormField } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { PolicyRow } from "@govoplan/core-webui";
import { PolicyTable } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import CampaignAccessCard from "./components/CampaignAccessCard";
import VersionLine from "./components/VersionLine";
@@ -44,10 +46,10 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
reload,
setError,
currentStep: isPolicyView ? "policies" : "global-settings",
unsavedTitle: isPolicyView ? "Unsaved campaign policy changes" : "Unsaved campaign settings",
unsavedMessage: isPolicyView
? "Campaign policies have unsaved changes. Save them before leaving, or discard them and continue."
: "Campaign settings have unsaved changes. Save them before leaving, or discard them and continue.",
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_campaign_policy_changes.181f3426" : "i18n:govoplan-campaign.unsaved_campaign_settings.0555d713",
unsavedMessage: isPolicyView ?
"i18n:govoplan-campaign.campaign_policies_have_unsaved_changes_save_them.7b07827f" :
"i18n:govoplan-campaign.campaign_settings_have_unsaved_changes_save_them.c1c5f65f",
extraPayload: () => ({ editor_state: editorState }),
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
@@ -61,7 +63,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
const optIns = asRecord(editorState.opt_ins);
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
const pageTitle = isPolicyView ? "Campaign policies" : "Campaign settings";
const pageTitle = isPolicyView ? "i18n:govoplan-campaign.campaign_policies.0b5de1f5" : "i18n:govoplan-campaign.campaign_settings.efffec26";
function patchEditor(path: string[], value: unknown) {
if (locked) return;
@@ -77,171 +79,185 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft">
{isPolicyView ? (
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
{isPolicyView ?
<>
{canReadRetentionPolicy && (
{canReadRetentionPolicy &&
<RetentionPolicyEditor
settings={settings}
scopeType="campaign"
scopeId={campaignId}
title="Campaign retention policy"
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
title="i18n:govoplan-campaign.campaign_retention_policy.fcc9897c"
description="i18n:govoplan-campaign.campaign_level_retention_limits_applied_after_sy.e1a5fd45"
canWrite={canWriteRetentionPolicy}
locked={locked}
/>
)}
locked={locked} />
}
<div className="dashboard-grid below-grid">
<Card title="Validation policy" collapsible>
<PolicyTable>
<Card title="i18n:govoplan-campaign.validation_policy.57dcc756" collapsible>
<PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
<PolicyRow
label="Missing required attachment"
note="Required attachment rule found no matching file."
className="campaign-policy-row"
labelClassName="campaign-policy-label"
controlClassName="campaign-policy-control"
effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.missing_required_attachment.a8aa73e0"
note="i18n:govoplan-campaign.required_attachment_rule_found_no_matching_file.d947b52b"
control={<PolicySelectControl value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))}
/>
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))} />
<PolicyRow
label="Missing optional attachment"
note="Optional attachment rule found no matching file."
className="campaign-policy-row"
labelClassName="campaign-policy-label"
controlClassName="campaign-policy-control"
effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.missing_optional_attachment.36640fa6"
note="i18n:govoplan-campaign.optional_attachment_rule_found_no_matching_file.709a2a13"
control={<PolicySelectControl value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))}
/>
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))} />
<PolicyRow
label="Ambiguous attachment match"
note="Attachment rule matched more files than expected."
className="campaign-policy-row"
labelClassName="campaign-policy-label"
controlClassName="campaign-policy-control"
effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.ambiguous_attachment_match.8043ddfa"
note="i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8"
control={<PolicySelectControl value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))}
/>
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))} />
<PolicyRow
label="Unsent attachment file"
note="A watched attachment directory contains files that were not selected."
className="campaign-policy-row"
labelClassName="campaign-policy-label"
controlClassName="campaign-policy-control"
effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.unsent_attachment_file.a25263ce"
note="i18n:govoplan-campaign.a_watched_attachment_directory_contains_files_th.03f58a42"
control={<PolicySelectControl value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))}
/>
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))} />
<PolicyRow
label="Missing email address"
note="The effective recipient list has no To address."
className="campaign-policy-row"
labelClassName="campaign-policy-label"
controlClassName="campaign-policy-control"
effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.missing_email_address.2a94b6d8"
note="i18n:govoplan-campaign.the_effective_recipient_list_has_no_to_address.df4e24f7"
control={<PolicySelectControl value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />}
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))}
/>
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))} />
<PolicyRow
label="Template error"
note="A selected template body contains unresolved placeholders."
className="campaign-policy-row"
labelClassName="campaign-policy-label"
controlClassName="campaign-policy-control"
effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.template_error.50b92586"
note="i18n:govoplan-campaign.a_selected_template_body_contains_unresolved_pla.d061acad"
control={<PolicySelectControl value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />}
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))}
/>
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))} />
<PolicyRow
label="Ignore empty fields"
note="Controls how missing placeholder values are rendered."
control={<ToggleSwitch label="Enabled" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />}
effective={getBool(validationPolicy, "ignore_empty_fields") ? "Missing values render as empty text." : "Missing values remain visible and can trigger template errors."}
/>
className="campaign-policy-row"
labelClassName="campaign-policy-label"
controlClassName="campaign-policy-control"
effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.ignore_empty_fields.1ffcdc5d"
note="i18n:govoplan-campaign.controls_how_missing_placeholder_values_are_rend.c1ad492a"
control={<ToggleSwitch label="i18n:govoplan-campaign.enabled.df174a3f" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />}
effective={getBool(validationPolicy, "ignore_empty_fields") ? "i18n:govoplan-campaign.missing_values_render_as_empty_text.0a6bce45" : "i18n:govoplan-campaign.missing_values_remain_visible_and_can_trigger_te.31464ee2"} />
</PolicyTable>
</Card>
<Card title="Attachment policy" collapsible>
<PolicyTable>
<Card title="i18n:govoplan-campaign.attachment_policy.2fd471d4" collapsible>
<PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
<PolicyRow
label="Default missing behavior"
note="Used by attachment rules that do not override missing-file behavior."
className="campaign-policy-row"
labelClassName="campaign-policy-label"
controlClassName="campaign-policy-control"
effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.default_missing_behavior.ffbd9cd7"
note="i18n:govoplan-campaign.used_by_attachment_rules_that_do_not_override_mi.ab8bc0d5"
control={<PolicySelectControl value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "missing_behavior"], value)} />}
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))}
/>
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))} />
<PolicyRow
label="Default ambiguous behavior"
note="Used by attachment rules that do not override ambiguous-match behavior."
className="campaign-policy-row"
labelClassName="campaign-policy-label"
controlClassName="campaign-policy-control"
effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.default_ambiguous_behavior.878b9345"
note="i18n:govoplan-campaign.used_by_attachment_rules_that_do_not_override_am.c31b4e9f"
control={<PolicySelectControl value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "ambiguous_behavior"], value)} />}
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))}
/>
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))} />
<PolicyRow
label="Send without attachments"
note="Campaign-wide fallback when no attachment is available."
control={<ToggleSwitch label="Allowed" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
effective={getBool(attachments, "send_without_attachments", true) ? "Messages may be sent when attachment rules allow it." : "Missing attachment coverage blocks sending."}
/>
className="campaign-policy-row"
labelClassName="campaign-policy-label"
controlClassName="campaign-policy-control"
effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.send_without_attachments.ead6d030"
note="i18n:govoplan-campaign.campaign_wide_fallback_when_no_attachment_is_ava.84ffa0bc"
control={<ToggleSwitch label="i18n:govoplan-campaign.allowed.77c7b490" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
effective={getBool(attachments, "send_without_attachments", true) ? "i18n:govoplan-campaign.messages_may_be_sent_when_attachment_rules_allow.e6bf1aed" : "i18n:govoplan-campaign.missing_attachment_coverage_blocks_sending.05ff02a1"} />
</PolicyTable>
</Card>
</div>
</>
) : (
</> :
<>
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
{data.campaign && <CampaignAccessCard settings={settings} auth={auth} campaign={data.campaign} onChanged={reload} onError={setError} />}
<div className="dashboard-grid below-grid">
<Card title="Delivery defaults" collapsible>
<Card title="i18n:govoplan-campaign.delivery_defaults.33cc3b97" collapsible>
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div>
</Card>
<Card title="Opt-ins and local assistance" collapsible>
<Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
<div className="toggle-grid">
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
<ToggleSwitch label="i18n:govoplan-campaign.suggest_addresses_from_this_campaign.5ebe2aea" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
<ToggleSwitch label="i18n:govoplan-campaign.remember_newly_used_addresses.aebe8adb" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
<ToggleSwitch label="i18n:govoplan-campaign.show_guided_warnings_while_editing.bc5dba85" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
</div>
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
<p className="muted small-note">i18n:govoplan-campaign.these_opt_ins_are_stored_in_the_draft_editor_met.dc6ffbfd</p>
</Card>
</div>
</>
)}
}
</LoadingFrame>
</div>
);
</div>);
}
function PolicyTable({ children }: { children: ReactNode }) {
return (
<div className="campaign-policy-table policy-table">
<div className="campaign-policy-row policy-row campaign-policy-row-header policy-row-header">
<span>Policy</span>
<span>Setting</span>
<span>Effective behavior</span>
</div>
{children}
</div>
);
}
function PolicyRow({ label, note, control, effective }: { label: string; note: string; control: ReactNode; effective: string }) {
return (
<div className="campaign-policy-row policy-row">
<div className="campaign-policy-label policy-field-label">
<strong>{label}</strong>
<small>{note}</small>
</div>
<div className="campaign-policy-control policy-control">{control}</div>
<p className="muted small-note campaign-policy-effective-note policy-effective-note">{effective}</p>
</div>
);
}
function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: { value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: {value: string;disabled?: boolean;onChange: (value: string) => void;options?: string[];}) {
return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
);
</select>);
}
function behaviorSummary(value: string): string {
if (value === "block") return "Blocks the affected message.";
if (value === "ask") return "Marks the message for review.";
if (value === "drop") return "Excludes the affected message.";
if (value === "continue") return "Continues without a review issue.";
if (value === "warn") return "Keeps sending possible with a warning.";
return "Uses the configured behavior.";
if (value === "block") return "i18n:govoplan-campaign.blocks_the_affected_message.0157342a";
if (value === "ask") return "i18n:govoplan-campaign.marks_the_message_for_review.d63beb24";
if (value === "drop") return "i18n:govoplan-campaign.excludes_the_affected_message.73be963f";
if (value === "continue") return "i18n:govoplan-campaign.continues_without_a_review_issue.99ab0684";
if (value === "warn") return "i18n:govoplan-campaign.keeps_sending_possible_with_a_warning.66c84a54";
return "i18n:govoplan-campaign.uses_the_configured_behavior.ea6e82a3";
}

View File

@@ -8,7 +8,7 @@ import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { DismissibleAlert } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import {
createMailServerProfile,
getMailProfilePolicy,
@@ -21,12 +21,12 @@ import {
testSmtpSettings,
type MailProfilePolicy,
type MailSecurity,
type MailServerProfile
} from "../../api/mail";
type MailServerProfile } from
"../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool, getNumber, getText } from "./utils/draftEditor";
import { cloneJson, getBool, getNumber, getText } from "./utils/draftEditor";
import { campaignMailSettingsPolicyState } from "./policyUi";
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
@@ -67,10 +67,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
reload,
setError,
currentStep: isPolicyView ? "mail-policy" : "mail-settings",
unsavedTitle: isPolicyView ? "Unsaved mail policy changes" : "Unsaved mail settings",
unsavedMessage: isPolicyView
? "Mail policy changes have unsaved draft changes. Save them before leaving, or discard them and continue."
: "Mail settings have unsaved changes. Save them before leaving, or discard them and continue."
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_mail_policy_changes.c9327491" : "i18n:govoplan-campaign.unsaved_mail_settings.38e1536b",
unsavedMessage: isPolicyView ?
"i18n:govoplan-campaign.mail_policy_changes_have_unsaved_draft_changes_s.5aee7d4e" :
"i18n:govoplan-campaign.mail_settings_have_unsaved_changes_save_them_bef.52644559",
transformDraftBeforeSave: normalizeMailSettingsBeforeSave
});
const server = asRecord(displayDraft.server);
const smtp = asRecord(server.smtp);
@@ -92,9 +93,9 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || (usingMailProfile && smtpCredentialsInherited);
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || usingMailProfile && smtpCredentialsInherited;
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile;
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || (usingMailProfile && imapCredentialsInherited);
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || usingMailProfile && imapCredentialsInherited;
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
const imapAppendEnabled = getBool(imapAppend, "enabled");
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
@@ -128,7 +129,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
sent_folder: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.sent_folder ?? "auto" : getText(imap, "sent_folder", "auto"),
timeout_seconds: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.timeout_seconds ?? 30 : getNumber(imap, "timeout_seconds", 30)
};
const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || (selectedProfileHasImap && !imapCredentialsInherited));
const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || selectedProfileHasImap && !imapCredentialsInherited);
useEffect(() => {
if (!mailModuleInstalled) {
@@ -150,8 +151,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
listMailServerProfiles(settings, false, campaignId),
listMailServerProfiles(settings, true),
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)
]);
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)]
);
setMailProfiles(allowedProfiles);
setPolicyProfiles(visibleProfiles);
setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
@@ -165,6 +166,37 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
}
}
function normalizeMailSettingsBeforeSave(value: Record<string, unknown>): Record<string, unknown> {
if (mailModuleInstalled === false) return value;
const next = cloneJson(value);
const nextServer = { ...asRecord(next.server) };
const profileId = getText(nextServer, "mail_profile_id");
if (profileId.length === 0) return next;
const nextCredentials = { ...asRecord(nextServer.credentials) };
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "smtp", effectiveMailPolicy?.smtp_credentials?.inherit === false ? false : true);
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "imap", effectiveMailPolicy?.imap_credentials?.inherit === false ? false : true);
nextServer.credentials = nextCredentials;
next.server = nextServer;
return next;
}
function normalizeProfileCredentialProtocol(
serverValue: Record<string, unknown>,
credentialsValue: Record<string, unknown>,
protocol: "smtp" | "imap",
inherit: boolean)
{
serverValue["inherit_" + protocol + "_credentials"] = inherit;
if (inherit === false) return;
const transport = { ...asRecord(serverValue[protocol]) };
delete transport.username;
delete transport.password;
serverValue[protocol] = transport;
credentialsValue[protocol] = {};
}
function selectMailProfile(profileId: string) {
if (!mailModuleInstalled || locked) return;
if (!profileId && !campaignProfilesAllowed) {
@@ -251,21 +283,21 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
function smtpServerPayload() {
return mailSmtpSettingsPayload<MailSecurity>(
{ host: getText(smtp, "host"), port: getNumber(smtp, "port", 587), security: getText(smtp, "security", "starttls"), timeout_seconds: getNumber(smtp, "timeout_seconds", 30) },
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions },
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions }
);
}
function imapServerPayload() {
return mailImapSettingsPayload<MailSecurity>(
{ host: getText(imap, "host"), port: getNumber(imap, "port", 993), security: getText(imap, "security", "tls"), sent_folder: getText(imap, "sent_folder", "auto"), timeout_seconds: getNumber(imap, "timeout_seconds", 30) },
{ fallbackSecurity: "tls", allowedSecurity: securityOptions },
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
);
}
function mailProfileCredentialsPayload(preserveBlankPassword: boolean) {
return {
smtp: mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, preserveBlankPassword),
imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword),
imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword)
};
}
@@ -292,16 +324,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
async function runSmtpTest() {
if (!mailModuleInstalled) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: "Install and enable the Mail module to test SMTP settings.", details: {} });
setSmtpTestResult({ ok: false, protocol: "smtp", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_smtp_.a7ce04e1", details: {} });
return;
}
if (locked || inlineMailSettingsBlocked) return;
setMailActionState("smtp");
setLocalError("");
try {
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited
? await testMailProfileSmtp(settings, selectedProfileId)
: await testSmtpSettings(settings, rawSmtpPayload()));
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited ?
await testMailProfileSmtp(settings, selectedProfileId) :
await testSmtpSettings(settings, rawSmtpPayload()));
} catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
@@ -311,16 +343,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
async function runImapTest() {
if (!mailModuleInstalled) {
setImapTestResult({ ok: false, protocol: "imap", message: "Install and enable the Mail module to test IMAP settings.", details: {} });
setImapTestResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_imap_.d6537dfd", details: {} });
return;
}
if (imapDisabled) return;
setMailActionState("imap");
setLocalError("");
try {
setImapTestResult(selectedProfileId && imapCredentialsInherited
? await testMailProfileImap(settings, selectedProfileId)
: await testImapSettings(settings, rawImapPayload()));
setImapTestResult(selectedProfileId && imapCredentialsInherited ?
await testMailProfileImap(settings, selectedProfileId) :
await testImapSettings(settings, rawImapPayload()));
} catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
@@ -330,16 +362,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
async function runFolderLookup() {
if (!mailModuleInstalled) {
setFolderResult({ ok: false, protocol: "imap", message: "Install and enable the Mail module to inspect IMAP folders.", folders: [], details: {} });
setFolderResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_inspect_im.52535774", folders: [], details: {} });
return;
}
if (appendTargetFolderDisabled) return;
setMailActionState("folders");
setLocalError("");
try {
setFolderResult(selectedProfileId && imapCredentialsInherited
? await listMailProfileImapFolders(settings, selectedProfileId)
: await listImapFolders(settings, rawImapPayload()));
setFolderResult(selectedProfileId && imapCredentialsInherited ?
await listMailProfileImapFolders(settings, selectedProfileId) :
await listImapFolders(settings, rawImapPayload()));
} catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally {
@@ -357,28 +389,28 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{isPolicyView ? "Mail policy" : "Mail settings"}</PageTitle>
<PageTitle loading={loading}>{isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</Button>}
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft">
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
{!mailModuleInstalled && (
{!mailModuleInstalled &&
<DismissibleAlert tone="info" dismissible={false}>
The Mail module is not installed. Inline SMTP and IMAP values can be edited in the campaign draft, but reusable profiles, connection tests and sending require the Mail module.
i18n:govoplan-campaign.the_mail_module_is_not_installed_inline_smtp_and.8e0f802e
</DismissibleAlert>
)}
}
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && (
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor &&
<MailProfilePolicyEditor
settings={settings}
scopeType="campaign"
@@ -389,57 +421,57 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
ownerGroupId={data.campaign?.owner_group_id}
canWrite={!locked}
locked={locked}
title="Campaign-local mail policy"
description="Campaign-local mail limits applied after system, tenant and owner policies."
onSaved={refreshMailProfiles}
/>
)}
title="i18n:govoplan-campaign.campaign_local_mail_policy.94f59da0"
description="i18n:govoplan-campaign.campaign_local_mail_limits_applied_after_system_.e7f9bb31"
onSaved={refreshMailProfiles} />
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor && (
<DismissibleAlert tone="warning" dismissible={false}>The Mail module did not expose profile-management UI capabilities.</DismissibleAlert>
)}
}
{!isPolicyView && mailModuleInstalled && (
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor &&
<DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_mail_module_did_not_expose_profile_managemen.2cdb57e1</DismissibleAlert>
}
{!isPolicyView && mailModuleInstalled &&
<Card
title="Reusable mail profile"
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
actions={
<div className="button-row compact-actions">
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "Loading…" : "Reload profiles"}</Button>
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button>
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save_current_settings_as_profile.7578a50b"}</Button>
</div>
}
>
}>
<div className="form-grid compact responsive-form-grid">
<FormField label="Profile">
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>Inline SMTP/IMAP settings</option>
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>i18n:govoplan-campaign.inline_smtp_imap_settings.cf16c421</option>
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
</select>
</FormField>
<FormField label="New profile name">
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} campaign-local profile` : "Campaign-local profile"} />
<FormField label="i18n:govoplan-campaign.new_profile_name.393313b6">
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? i18nMessage("i18n:govoplan-campaign.value_campaign_local_profile.70cd9d43", { value0: data.campaign.name }) : "i18n:govoplan-campaign.campaign_local_profile.c24cf2d1"} />
</FormField>
</div>
{!campaignProfilesAllowed && <p className="muted small-note">Campaign-local mail settings are blocked by the effective mail policy. Select an allowed reusable profile.</p>}
{selectedProfile && (
<p className="muted small-note">Using {selectedProfile.name} ({profileScopeLabel(selectedProfile)}). SMTP credentials: {smtpCredentialsInherited ? "inherited from profile" : "local credentials required"}. IMAP credentials: {selectedProfile.imap ? (imapCredentialsInherited ? "inherited from profile" : "local credentials required") : "not configured"}.</p>
)}
{selectedProfileNeedsLocalCredentials && (
<p className="muted small-note">The selected profile supplies the server settings. Enter the required campaign-local credentials in the mail server settings panel below.</p>
)}
{!campaignProfilesAllowed && <p className="muted small-note">i18n:govoplan-campaign.campaign_local_mail_settings_are_blocked_by_the_.0b2510aa</p>}
{selectedProfile &&
<p className="muted small-note">i18n:govoplan-campaign.using.c25de2e8 {selectedProfile.name} ({profileScopeLabel(selectedProfile)}i18n:govoplan-campaign.smtp_credentials.10f75c8a {smtpCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c"}i18n:govoplan-campaign.imap_credentials.7442b238 {selectedProfile.imap ? imapCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c" : "i18n:govoplan-campaign.not_configured.67f2141f"}.</p>
}
{selectedProfileNeedsLocalCredentials &&
<p className="muted small-note">i18n:govoplan-campaign.the_selected_profile_supplies_the_server_setting.bdc830db</p>
}
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissStorageKey={`campaign:${campaignId}:mail-settings:profile-error`} floating>{profileError}</DismissibleAlert>}
</Card>
)}
}
{!isPolicyView && (
<Card title="Mail server settings" collapsible>
{inlinePolicyMessages.length > 0 && (
{!isPolicyView &&
<Card title="i18n:govoplan-campaign.mail_server_settings.6db620b0" collapsible>
{inlinePolicyMessages.length > 0 &&
<DismissibleAlert tone="warning" resetKey={inlinePolicyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
<strong>Effective mail policy blocks the current inline settings.</strong>
<strong>i18n:govoplan-campaign.effective_mail_policy_blocks_the_current_inline_.99c34c6b</strong>
<ul>{inlinePolicyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul>
</DismissibleAlert>
)}
}
<MailServerSettingsPanel
smtp={displayedSmtp}
imap={displayedImap}
@@ -465,8 +497,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
}}
smtpTestLabel={usingMailProfile ? "Test profile SMTP" : "Test SMTP login"}
imapTestLabel={usingMailProfile ? "Test profile IMAP" : "Test IMAP login"}
smtpTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_smtp.884a0e66" : "i18n:govoplan-campaign.test_smtp_login.a1359755"}
imapTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_imap.e1cec0e0" : "i18n:govoplan-campaign.test_imap_login.c32e316e"}
busyAction={mailActionState}
onTestSmtp={runSmtpTest}
onTestImap={runImapTest}
@@ -476,15 +508,15 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
folderLookupResult={folderResult}
onUseDetectedFolder={useDetectedSentFolder}
useDetectedFolderDisabled={appendTargetFolderDisabled}
floatingResults
/>
floatingResults />
</Card>
)}
}
</>
</LoadingFrame>
</div>
);
</div>);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,10 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -15,18 +12,24 @@ import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import { getDraftFields } from "./utils/fieldDefinitions";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { materializeRecipientImportWithAttachmentDefaults, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
import { RecipientImportDialog } from "./RecipientDataPage";
import { addressesFromValue } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
export default function RecipientDetailsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const filesModuleInstalled = usePlatformModuleInstalled("files");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false);
const [recipientDataPage, setRecipientDataPage] = useState(1);
const [recipientDataPageSize, setRecipientDataPageSize] = useState(10);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
@@ -34,8 +37,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
reload,
setError,
currentStep: "recipient-data",
unsavedTitle: "Unsaved recipient data changes",
unsavedMessage: "Recipient field values or attachments have unsaved changes. Save them before leaving, or discard them and continue."
unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_data_changes.c810507e",
unsavedMessage: "i18n:govoplan-campaign.recipient_field_values_or_attachments_have_unsav.49ab1870"
});
const entries = asRecord(displayDraft.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
@@ -44,6 +47,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
const attachmentSection = asRecord(displayDraft.attachments);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection, true), [attachmentSection]);
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]);
@@ -70,53 +75,82 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
if (locked || !draft) return;
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode }));
markDirty();
setImportOpen(false);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Recipient data</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Recipient field values and attachments">
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.</p>}
{inlineEntries.length === 0 && Boolean(source.type) && (
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</DismissibleAlert>
)}
{inlineEntries.length > 0 && (
<CampaignDraftPageScaffold
settings={settings}
campaignId={campaignId}
title="i18n:govoplan-campaign.recipient_data.c2baaf10"
loading={loading}
version={version}
versions={data.versions}
saveState={saveState}
onReload={reload}
onSave={() => saveDraft("manual")}
dirty={dirty}
locked={locked}
draft={draft}
error={error}
localError={localError}
currentVersionId={data.campaign?.current_version_id}>
<Card title="i18n:govoplan-campaign.recipient_field_values_and_attachments.96e670ca" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
{inlineEntries.length === 0 && !source.type && <p className="muted">i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224</p>}
{inlineEntries.length === 0 && Boolean(source.type) &&
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.0d1ae252</DismissibleAlert>
}
{inlineEntries.length > 0 &&
<div className="admin-table-surface recipient-data-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-data`}
rows={inlineEntries.slice(0, 100)}
columns={recipientDataColumns({ settings, campaignId, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
rows={inlineEntries}
columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient data found."
emptyText="i18n:govoplan-campaign.no_recipient_data_found.a12be7d0"
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
/>
pagination={{
page: recipientDataPage,
pageSize: recipientDataPageSize,
pageSizeOptions: [10, 25, 50, 100, 250],
onPageChange: setRecipientDataPage,
onPageSizeChange: (pageSize) => {
setRecipientDataPageSize(pageSize);
setRecipientDataPage(1);
}
}} />
</div>
)}
}
</Card>
</>
</LoadingFrame>
</div>
);
</CampaignDraftPageScaffold>
{importOpen &&
<RecipientImportDialog
settings={settings}
campaignId={campaignId}
existingEntries={inlineEntries}
existingFields={fieldDefinitions}
defaultAttachmentBasePath={defaultImportBasePath}
onCancel={() => setImportOpen(false)}
onImport={applyRecipientImport} />
}
</>);
}
type RecipientDataColumnContext = {
settings: ApiSettings;
campaignId: string;
draft: Record<string, unknown>;
locked: boolean;
filesModuleInstalled: boolean;
fieldDefinitions: ReturnType<typeof getDraftFields>;
@@ -126,35 +160,35 @@ type RecipientDataColumnContext = {
updateEntryField: (index: number, field: string, value: unknown) => void;
};
function recipientDataColumns({ settings, campaignId, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
{
id: "recipient",
header: "Recipient",
header: "i18n:govoplan-campaign.recipient.90343260",
width: 260,
resizable: true,
sortable: true,
filterable: true,
sticky: "start",
render: (entry) => (
<Link className="recipient-data-identity" to="../recipients" title="Open recipient address profile">
<span className="recipient-data-address">{firstRecipientEmail(entry) || "No To address"}</span>
render: (entry) =>
<Link className="recipient-data-identity" to="../recipients" title="i18n:govoplan-campaign.open_recipient_address_profile.f7daa676">
<span className="recipient-data-address">{firstRecipientEmail(entry) || "i18n:govoplan-campaign.no_to_address.683350f9"}</span>
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
</Link>
),
</Link>,
value: firstRecipientEmail
},
{
id: "attachments",
header: "Attachments",
header: "i18n:govoplan-campaign.attachments.6771ade6",
width: 180,
filterable: true,
render: (entry, index) => {
const attachments = normalizeAttachmentRules(entry.attachments);
return (
<AttachmentRulesOverlay
title={`Attachments for recipient ${index + 1}`}
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
rules={attachments}
settings={settings}
campaignId={campaignId}
@@ -163,9 +197,10 @@ function recipientDataColumns({ settings, campaignId, locked, filesModuleInstall
basePaths={individualAttachmentBasePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
);
previewContext={buildTemplatePreviewContext(draft, entry)}
onChange={(rules) => updateEntryAttachments(index, rules)} />);
},
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
},
@@ -185,14 +220,14 @@ function recipientDataColumns({ settings, campaignId, locked, filesModuleInstall
fieldType={field.type}
value={fields[field.name]}
disabled={locked || field.can_override === false}
placeholder={field.can_override === false ? "Uses global value" : undefined}
onChange={(value) => updateEntryField(index, field.name, value)}
/>
);
placeholder={field.can_override === false ? "i18n:govoplan-campaign.uses_global_value.98eae5e0" : undefined}
onChange={(value) => updateEntryField(index, field.name, value)} />);
},
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
}))
];
}))];
}
function firstRecipientEmail(entry: Record<string, unknown>): string {

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { DismissibleAlert, SegmentedControl, i18nMessage } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
@@ -17,13 +17,13 @@ import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils
import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, replacePlaceholderInText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
type TemplateBodyMode = "text" | "html" | "both";
type BodyEditorMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html";
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [activeBodyEditor, setActiveBodyEditor] = useState<BodyEditorMode>("text");
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
@@ -47,9 +47,9 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
reload,
setError,
currentStep: "template",
unsavedTitle: "Unsaved template changes",
unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "Loaded",
unsavedTitle: "i18n:govoplan-campaign.unsaved_template_changes.4209cdec",
unsavedMessage: "i18n:govoplan-campaign.the_template_has_unsaved_changes_save_them_befor.89f74fc1",
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a",
onLoaded: () => setPreviewIndex(0)
});
const template = asRecord(displayDraft.template);
@@ -80,8 +80,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const templateText = [
getText(template, "subject"),
templateBodyMode !== "html" ? getText(template, "text") : "",
templateBodyMode !== "text" ? getText(template, "html") : ""
].join("\n");
templateBodyMode !== "text" ? getText(template, "html") : ""].
join("\n");
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
const undefinedPlaceholders = useMemo(
@@ -122,17 +122,17 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
void previewCampaignAttachments(settings, campaignId, version.id, {
include_unmatched: false,
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
})
.then((response) => {
}).
then((response) => {
if (!cancelled) setAttachmentPreviewRules(response.rules);
})
.catch((reason: unknown) => {
}).
catch((reason: unknown) => {
if (!cancelled) {
setAttachmentPreviewRules([]);
setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason));
}
})
.finally(() => {
}).
finally(() => {
if (!cancelled) setAttachmentPreviewLoading(false);
});
}, 120);
@@ -186,8 +186,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
type: "string",
required: false,
can_override: true
}
]);
}]
);
}
setUndefinedDialog(null);
}
@@ -207,111 +207,144 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
setUndefinedDialog(null);
}
function replacePlaceholder(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) {
if (locked) return;
const replacement = `{{${namespace}:${name}}}`;
setDraft((current) => {
const next = cloneJson(current ?? {});
const nextTemplate = { ...asRecord(next.template) };
nextTemplate.subject = replacePlaceholderInText(getText(nextTemplate, "subject"), field.raw, replacement);
nextTemplate.text = replacePlaceholderInText(getText(nextTemplate, "text"), field.raw, replacement);
nextTemplate.html = replacePlaceholderInText(getText(nextTemplate, "html"), field.raw, replacement);
next.template = nextTemplate;
return next;
});
markDirty();
setUndefinedDialog(null);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Template</PageTitle>
<PageTitle loading={loading}>i18n:govoplan-campaign.template.3ec1ae06</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button disabled>Manage templates</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft">
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<div className="dashboard-grid template-editor-grid">
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
<Card title="i18n:govoplan-campaign.editable_template.5e747a1e" actions={<Button onClick={() => setPreviewOpen(true)}>i18n:govoplan-campaign.preview.f1fbb2b4</Button>}>
<div className="form-grid">
<FormField label="Subject">
<FormField label="i18n:govoplan-campaign.subject.8d183dbd">
<input
ref={subjectRef}
value={getText(template, "subject")}
disabled={locked}
onFocus={() => setActiveEditor("subject")}
onChange={(event) => patchTemplateText("subject", event.target.value)}
onChange={(event) => patchTemplateText("subject", event.target.value)} />
</FormField>
<FormField label="i18n:govoplan-campaign.message_body_format.5fec42d2">
<SegmentedControl
className="template-body-mode"
size="content"
width="inline"
ariaLabel="i18n:govoplan-campaign.message_body_format.5fec42d2"
value={templateBodyMode}
disabled={locked}
onChange={patchTemplateBodyMode}
options={[
{ id: "text", label: "i18n:govoplan-campaign.text_only.9ccbd022" },
{ id: "html", label: "i18n:govoplan-campaign.html_only.1c4fbcb1" },
{ id: "both", label: "i18n:govoplan-campaign.both.1f469838" }
]}
/>
</FormField>
<FormField label="Message body format">
<div className="template-body-mode" role="tablist" aria-label="Message body format">
<button type="button" className={templateBodyMode === "text" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("text")}>Text only</button>
<button type="button" className={templateBodyMode === "html" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("html")}>HTML only</button>
<button type="button" className={templateBodyMode === "both" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("both")}>Both</button>
</div>
</FormField>
{templateBodyMode === "both" && (
<div className="template-body-mode template-editor-mode" role="tablist" aria-label="Body editor">
<button type="button" className={visibleBodyEditor === "text" ? "active" : ""} onClick={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}>Plain text</button>
<button type="button" className={visibleBodyEditor === "html" ? "active" : ""} onClick={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}>HTML</button>
</div>
)}
{visibleBodyEditor === "text" && (
<FormField label="Plain text body">
{templateBodyMode === "both" &&
<SegmentedControl
className="template-body-mode template-editor-mode"
size="content"
width="inline"
ariaLabel="i18n:govoplan-campaign.body_editor.8615dd7e"
value={visibleBodyEditor}
onChange={(mode) => {setActiveBodyEditor(mode);setActiveEditor(mode);}}
options={[
{ id: "text", label: "i18n:govoplan-campaign.plain_text.9580fcbc" },
{ id: "html", label: "i18n:govoplan-campaign.html.9f738ce8" }
]}
/>
}
{visibleBodyEditor === "text" &&
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0">
<textarea
ref={textRef}
rows={16}
value={getText(template, "text")}
disabled={locked}
onFocus={() => {setActiveBodyEditor("text");setActiveEditor("text");}}
onChange={(event) => patchTemplateText("text", event.target.value)}
/>
onChange={(event) => patchTemplateText("text", event.target.value)} />
</FormField>
)}
{visibleBodyEditor === "html" && (
<FormField label="HTML body">
}
{visibleBodyEditor === "html" &&
<FormField label="i18n:govoplan-campaign.html_body.77b5ba37">
<textarea
ref={htmlRef}
rows={16}
value={getText(template, "html")}
disabled={locked}
onFocus={() => {setActiveBodyEditor("html");setActiveEditor("html");}}
onChange={(event) => patchTemplateText("html", event.target.value)}
/>
onChange={(event) => patchTemplateText("html", event.target.value)} />
</FormField>
)}
}
<div className="button-row template-editor-actions">
<Button disabled>Load from library</Button>
<Button disabled>Save to library</Button>
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
<Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button>
</div>
</div>
</Card>
<div className="template-side-stack">
<Card title="Fields">
{invalidNamespacePlaceholders.length > 0 && (
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>Undefined placeholder namespace detected: {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
)}
{usedPlaceholders.length === 0 && <p className="muted">No template placeholders detected yet.</p>}
<p className="muted small-note">Click a field to insert it at the current cursor position as a namespaced placeholder.</p>
<Card title="i18n:govoplan-campaign.fields.e8b68527">
{invalidNamespacePlaceholders.length > 0 &&
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282 {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
}
{usedPlaceholders.length === 0 && <p className="muted">i18n:govoplan-campaign.no_template_placeholders_detected_yet.56bba9d1</p>}
<p className="muted small-note">i18n:govoplan-campaign.click_a_field_to_insert_it_at_the_current_cursor.643aa7bc</p>
<h3 className="section-mini-heading">Global fields</h3>
<h3 className="section-mini-heading">i18n:govoplan-campaign.global_fields.07f84ea4</h3>
<TemplateFieldChipList
namespace="global"
fields={globalFieldOptions}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values defined."
onInsert={insertPlaceholder}
/>
empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_defined.3888623d"
onInsert={insertPlaceholder} />
<h3 className="section-mini-heading">Local fields</h3>
<p className="muted small-note">Address fields use the effective merged or overridden headers. Use <code>to[2]</code> or <code>to[2].email</code> for a specific additional address.</p>
<h3 className="section-mini-heading">i18n:govoplan-campaign.local_fields.c81bb4de</h3>
<p className="muted small-note">i18n:govoplan-campaign.address_fields_use_the_effective_merged_or_overr.d6930da6 <code>i18n:govoplan-campaign.to_2.8c1db0c7</code> or <code>i18n:govoplan-campaign.to_2_email.5ad61c1a</code> i18n:govoplan-campaign.for_a_specific_additional_address.1199883f</p>
<TemplateFieldChipList
namespace="local"
fields={localFieldOptions}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields defined."
onInsert={insertPlaceholder}
/>
empty="i18n:govoplan-campaign.no_campaign_fields_defined.8c2ea4b2"
onInsert={insertPlaceholder} />
<h3 className="section-mini-heading">Used in template, but undefined</h3>
<h3 className="section-mini-heading">i18n:govoplan-campaign.used_in_template_but_undefined.57b5f3da</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Card>
</div>
@@ -320,16 +353,16 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
</>
</LoadingFrame>
{previewOpen && (
{previewOpen &&
<CampaignMessagePreviewOverlay
title="Template preview"
title="i18n:govoplan-campaign.template_preview.ea0aa8b2"
bodyMode={visibleBodyEditor}
subject={previewSubject}
text={templateBodyMode === "html" ? null : previewText}
html={templateBodyMode === "text" ? null : previewHtml}
metaItems={templatePreviewMetaItems(previewContext)}
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "Global preview"}
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "No recipient preview is available." : "No inline recipients are available yet."}
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "i18n:govoplan-campaign.global_preview.f81f09b2"}
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "i18n:govoplan-campaign.no_recipient_preview_is_available.9e93a97d" : "i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb"}
attachments={previewAttachments}
navigation={{
index: Math.min(previewIndex, previewEntries.length - 1),
@@ -339,62 +372,70 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)),
onLast: () => setPreviewIndex(previewEntries.length - 1)
}}
onClose={() => setPreviewOpen(false)}
/>
)}
onClose={() => setPreviewOpen(false)} />
}
<UndefinedPlaceholderDecisionDialog
field={undefinedDialog}
contextLabel="template"
removeLabel="Remove from template"
removeLabel="i18n:govoplan-campaign.remove_from_template.5fb2827b"
onCancel={() => setUndefinedDialog(null)}
onRemove={removePlaceholder}
onReplace={replacePlaceholder}
onAddField={addUndefinedField}
/>
</div>
);
localFields={localFieldOptions}
globalFields={globalFieldOptions} />
</div>);
}
function mapResolvedAttachmentsToPreviewBoxes(
rules: CampaignAttachmentPreviewRule[],
loading: boolean,
error: string,
draft: Record<string, unknown>
): CampaignMessagePreviewAttachment[] {
draft: Record<string, unknown>)
: CampaignMessagePreviewAttachment[] {
if (loading) {
return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }];
return [{
filename: i18nMessage("i18n:govoplan-campaign.resolving_attachment_patterns.87d7d21b"),
detail: i18nMessage("i18n:govoplan-campaign.managed_files_are_being_checked_for_this_recipie.489ecefd")
}];
}
if (error) {
return [{ filename: "Attachment preview unavailable", detail: error }];
return [{ filename: i18nMessage("i18n:govoplan-campaign.attachment_preview_unavailable.62211756"), detail: error }];
}
return rules.flatMap((rule) => {
const zipProtection = zipProtectionForRule(rule, draft);
const detailParts = [
rule.source === "global" ? "Global" : "Recipient",
rule.source === "global" ? i18nMessage("i18n:govoplan-campaign.global.5f1184f7") : i18nMessage("i18n:govoplan-campaign.recipient.90343260"),
rule.label,
rule.required ? "required" : "optional",
rule.pattern
].filter(Boolean);
rule.required ? i18nMessage("i18n:govoplan-campaign.required.eed6bfb4") : i18nMessage("i18n:govoplan-campaign.optional.0c6c4102"),
rule.pattern].
filter(Boolean);
const detail = detailParts.join(" · ");
const fallbackArchiveLabel = i18nMessage("i18n:govoplan-campaign.recipient_attachments_zip.79d436c7");
if (rule.matches.length > 0) {
return rule.matches.map((match) => ({
filename: match.filename || match.display_path,
label: rule.label,
detail: `${detail}${match.display_path ? ` · ${match.display_path}` : ""}`,
detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: match.display_path ? ` · ${match.display_path}` : "" }),
contentType: match.content_type,
sizeBytes: match.size_bytes,
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
protected: zipProtection.protected,
protectionNote: zipProtection.note
}));
}
const pattern = rule.pattern || i18nMessage("i18n:govoplan-campaign.attachment_pattern.3540c952");
return [{
filename: `No file matched ${rule.pattern || "attachment pattern"}`,
filename: i18nMessage("i18n:govoplan-campaign.no_file_matched_value", { value0: pattern }),
label: rule.label,
detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`,
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: rule.status !== "ok" ? ` · ${rule.status}` : "" }),
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
protected: zipProtection.protected,
protectionNote: zipProtection.note
}];
@@ -403,22 +444,22 @@ function mapResolvedAttachmentsToPreviewBoxes(
function templatePreviewMetaItems(context: Record<string, string>) {
return [
{ label: "From", value: context["local:from"] || null },
{ label: "To", value: context["local:all_to"] || null },
{ label: "Reply-To", value: context["local:all_reply_to"] || null },
{ label: "Cc", value: context["local:all_cc"] || null },
{ label: "Bcc", value: context["local:all_bcc"] || null }
];
{ label: "i18n:govoplan-campaign.from.3f66052a", value: context["local:from"] || null },
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: context["local:all_to"] || null },
{ label: "i18n:govoplan-campaign.reply_to.c1733667", value: context["local:all_reply_to"] || null },
{ label: "i18n:govoplan-campaign.cc.1fd6a880", value: context["local:all_cc"] || null },
{ label: "i18n:govoplan-campaign.bcc.8431acad", value: context["local:all_bcc"] || null }];
}
function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record<string, unknown>): { protected: boolean; note: string | null } {
function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record<string, unknown>): {protected: boolean;note: string | null;} {
if (!rule.zip_included) return { protected: false, note: null };
const zipConfig = asRecord(asRecord(draft.attachments).zip);
const archives = asArray(zipConfig.archives).map(asRecord);
const archive = archives.find((item) => {
const id = getText(item, "id");
const name = getText(item, "name", getText(item, "filename_template"));
return (rule.zip_archive_id && id === rule.zip_archive_id) || (rule.zip_filename && name === rule.zip_filename);
return rule.zip_archive_id && id === rule.zip_archive_id || rule.zip_filename && name === rule.zip_filename;
}) ?? archives.find((item) => getBool(item, "standard")) ?? archives[0];
if (!archive) return { protected: false, note: null };
const legacyMode = getText(archive, "password_mode");
@@ -426,13 +467,18 @@ function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record
if (!protectedArchive) return { protected: false, note: null };
const field = getText(archive, "password_field");
const scope = getText(archive, "password_scope") === "global" ? "global" : "local";
const method = getText(archive, "method", "aes") === "zip_standard" ? "ZipCrypto" : "AES";
const source = field ? `${humanizeScope(scope)} field "${field}"` : "";
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") };
const method = getText(archive, "method", "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6";
const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : "";
return {
protected: true,
note: source ?
i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) :
i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
};
}
function humanizeScope(scope: string): string {
return scope === "global" ? "Global" : "Local";
return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d";
}
function recipientLabel(entry: Record<string, unknown>, index: number): string {
@@ -441,7 +487,7 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
if (name && email) return `${name} <${email}>`;
if (name) return name;
if (email) return email;
return `Recipient ${index + 1}`;
return i18nMessage("i18n:govoplan-campaign.recipient_value.d0233fb6", { value0: index + 1 });
}
function normalizeTemplateBodyMode(value: string): TemplateBodyMode {

View File

@@ -2,13 +2,15 @@ import { useMemo, useState } from "react";
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 { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
import { asRecord } from "../utils/campaignView";
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
@@ -23,6 +25,7 @@ type AttachmentRulesOverlayProps = {
basePaths?: AttachmentBasePath[];
zipConfig?: AttachmentZipCollection;
filesModuleInstalled?: boolean;
previewContext?: Record<string, string>;
onChange: (rules: AttachmentRule[]) => void;
};
@@ -37,6 +40,7 @@ type AttachmentRulesTableProps = {
activeChooserRuleIndex?: number | null;
zipConfig?: AttachmentZipCollection;
filesModuleInstalled?: boolean;
previewContext?: Record<string, string>;
onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
@@ -53,10 +57,11 @@ export default function AttachmentRulesOverlay({
campaignId,
disabled = false,
buttonLabel,
emptyText = "No attachment files or matching rules configured yet.",
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
basePaths = [],
zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false,
previewContext,
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
@@ -80,13 +85,19 @@ export default function AttachmentRulesOverlay({
}
const dialog = open ? createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="attachment-rules-title">{title}</h2>
<button className="modal-close" aria-label="Cancel attachment changes" onClick={cancelOverlay}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<Dialog
open
title={title}
className="attachment-rules-modal"
bodyClassName="attachment-rules-body"
onClose={cancelOverlay}
footer={
<>
<Button onClick={cancelOverlay}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>i18n:govoplan-campaign.save.efc007a3</Button>
</>
}>
<AttachmentRulesTable
rules={draftRules}
settings={settings}
@@ -96,27 +107,22 @@ export default function AttachmentRulesOverlay({
basePaths={basePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={previewContext}
activeChooserRuleIndex={null}
onChange={setDraftRules}
/>
</div>
<footer className="modal-footer">
<Button onClick={cancelOverlay}>Cancel</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
</footer>
</div>
</div>,
onChange={setDraftRules} />
</Dialog>,
document.body
) : null;
return (
<>
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={`${summary.direct} direct file(s), ${summary.rules} rule(s) / pattern(s)`}>
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={i18nMessage("i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9", { value0: summary.direct, value1: summary.rules })}>
{label}
</Button>
{dialog}
</>
);
</>);
}
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
@@ -135,8 +141,8 @@ export function AttachmentRulesTable({
<div className="attachment-rules-main">
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
</div>
</div>
);
</div>);
}
export function AttachmentRulesDataGrid({
@@ -144,16 +150,20 @@ export function AttachmentRulesDataGrid({
settings,
campaignId,
disabled = false,
emptyText = "No attachment files or matching rules configured yet.",
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
basePaths = [],
id = "attachment-rules",
activeChooserRuleIndex = null,
zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false,
previewContext,
onOpenFileChooser,
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));
@@ -177,7 +187,7 @@ export function AttachmentRulesDataGrid({
}
function openFileChooser(ruleIndex: number) {
if (!filesModuleInstalled) return;
if (!managedFilesAvailable) return;
if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex);
return;
@@ -186,15 +196,15 @@ export function AttachmentRulesDataGrid({
const currentPath = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
const basePath = basePaths.find((item) => item.id === currentBasePathId)
?? basePaths.find((item) => item.path === currentPath)
?? (!explicitlyReferenced ? basePaths[0] : undefined)
?? null;
const basePath = basePaths.find((item) => item.id === currentBasePathId) ??
basePaths.find((item) => item.path === currentPath) ?? (
!explicitlyReferenced ? basePaths[0] : undefined) ??
null;
if (!basePath) return;
setFileChooser({ ruleIndex, basePath });
}
function selectAttachment(selection: ManagedAttachmentSelection) {
function selectAttachment(selection: FilesManagedAttachmentSelection) {
if (!fileChooser) return;
const currentRule = rules[fileChooser.ruleIndex] ?? {};
patchRule(fileChooser.ruleIndex, {
@@ -213,29 +223,32 @@ 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" />}
emptyText={basePaths.length === 0 ? "i18n:govoplan-campaign.no_attachment_source_is_enabled_for_individual_a.818a2820" : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="i18n:govoplan-campaign.add_first_attachment.025fbf31" />}
className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/>
{fileChooser && filesModuleInstalled && (
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined} />
{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}
/>
)}
</>
);
onSelectAttachment={selectAttachment} />
}
</>);
}
type AttachmentRuleColumnContext = {
@@ -254,10 +267,10 @@ type AttachmentRuleColumnContext = {
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
return [
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{ id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="i18n:govoplan-campaign.attachment_label.a340f70e" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{
id: "base_path",
header: "Base path",
header: "i18n:govoplan-campaign.base_path.6a4867ca",
width: 250,
sortable: true,
filterable: true,
@@ -267,9 +280,9 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
const currentBasePathValue = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId)
?? basePaths.find((basePath) => basePath.path === currentBasePathValue)
?? (!explicitlyReferenced ? basePaths[0] : undefined);
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ??
basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? (
!explicitlyReferenced ? basePaths[0] : undefined);
return (
<select
value={selectedBasePath?.id ?? ""}
@@ -277,25 +290,25 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
onChange={(event) => {
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
}}
>
{!selectedBasePath && (
<option value="" disabled>{basePaths.length === 0 ? "No individual attachment source" : "Unavailable attachment source"}</option>
)}
}}>
{!selectedBasePath &&
<option value="" disabled>{basePaths.length === 0 ? "i18n:govoplan-campaign.no_individual_attachment_source.6b54176a" : "i18n:govoplan-campaign.unavailable_attachment_source.7ff1096e"}</option>
}
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
</select>
);
</select>);
},
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
},
{
id: "file_filter",
header: "File / pattern",
header: "i18n:govoplan-campaign.file_pattern.86320b07",
width: "minmax(260px, 1fr)",
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) => (
render: (rule, index) =>
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
@@ -303,7 +316,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
disabled={disabled || basePaths.length === 0}
readOnly={filesModuleInstalled}
tabIndex={filesModuleInstalled ? -1 : undefined}
placeholder={filesModuleInstalled ? "Choose a managed file or pattern" : "file.pdf or **/*.pdf"}
placeholder={filesModuleInstalled ? "i18n:govoplan-campaign.choose_a_managed_file_or_pattern.96bb3bfb" : "file.pdf or **/*.pdf"}
onChange={(event) => {
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
}}
@@ -313,26 +326,58 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
event.preventDefault();
openFileChooser(index);
}
}}
/>
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>}
</div>
),
}} />
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>i18n:govoplan-campaign.choose.78b7c9f6</Button>}
</div>,
value: (rule) => getText(rule, "file_filter")
},
{
id: "message_filename_template",
header: "i18n:govoplan-campaign.message_filename_template.0b7ac934",
width: 230,
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) =>
<input
value={getText(rule, "message_filename_template")}
disabled={disabled}
placeholder="i18n:govoplan-campaign.keep_original_filename.9b805045"
onChange={(event) => patchRule(index, { message_filename_template: event.target.value })} />,
value: (rule) => getText(rule, "message_filename_template")
},
{
id: "zip_entry_name_template",
header: "i18n:govoplan-campaign.zip_entry_name_template.83772a73",
width: 230,
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) =>
<input
value={getText(rule, "zip_entry_name_template")}
disabled={disabled}
placeholder="i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4"
onChange={(event) => patchRule(index, { zip_entry_name_template: event.target.value })} />,
value: (rule) => getText(rule, "zip_entry_name_template")
},
...(zipConfig.enabled ? [{
id: "zip",
header: "ZIP archive",
header: "i18n:govoplan-campaign.zip_archive.5a2430dd",
width: 240,
sortable: true,
filterable: true,
columnType: "from-list",
list: {
options: [
{ value: "exclude", label: "Exclude from ZIP" },
{ value: "inherit", label: "Campaign standard" },
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))
]
{ value: "exclude", label: "i18n:govoplan-campaign.exclude_from_zip.a6422da1" },
{ value: "inherit", label: "i18n:govoplan-campaign.campaign_standard.c88ce44c" },
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))]
},
render: (rule: AttachmentRule, index: number) => {
const selection = attachmentRuleZipSelection(rule);
@@ -340,36 +385,36 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
<select
value={selection}
disabled={disabled || zipConfig.archives.length === 0}
aria-label={`ZIP archive for ${getText(rule, "label") || `attachment ${index + 1}`}`}
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}
>
<option value="exclude">Exclude from ZIP</option>
<option value="inherit">Campaign standard</option>
aria-label={i18nMessage("i18n:govoplan-campaign.zip_archive_for_value.3dfaf812", { value0: getText(rule, "label") || `attachment ${index + 1}` })}
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}>
<option value="exclude">i18n:govoplan-campaign.exclude_from_zip.a6422da1</option>
<option value="inherit">i18n:govoplan-campaign.campaign_standard.c88ce44c</option>
{zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)}
</select>
);
</select>);
},
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
} satisfies DataGridColumn<AttachmentRule>] : []),
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (rule, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
{
id: "actions",
header: "Actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180,
sticky: "end",
render: (_rule, index) => (
render: (_rule, index) =>
<DataGridRowActions
disabled={disabled}
onAddBelow={() => addRule(index)}
onRemove={() => removeRule(index)}
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
addLabel="Add attachment below"
removeLabel="Remove attachment"
moveUpLabel="Move attachment up"
moveDownLabel="Move attachment down"
/>
)
}
];
addLabel="i18n:govoplan-campaign.add_attachment_below.c7b66ffd"
removeLabel="i18n:govoplan-campaign.remove_attachment.0fcc6594"
moveUpLabel="i18n:govoplan-campaign.move_attachment_up.28614804"
moveDownLabel="i18n:govoplan-campaign.move_attachment_down.a4d6604f" />
}];
}

View File

@@ -1,34 +1,38 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, CampaignListItem } from "../../../types";
import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types";
import { KeyRound } from "lucide-react";
import {
fetchResourceAccessExplanation,
getCampaignShares,
getCampaignShareTargets,
revokeCampaignShare,
updateCampaignOwner,
upsertCampaignShare,
type CampaignShare,
type CampaignShareTargets
} from "../../../api/campaigns";
type CampaignShareTargets,
type ResourceAccessExplanationResponse } from
"../../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { StatusBadge, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
type TargetType = "user" | "group";
export default function CampaignAccessCard({
settings,
auth,
campaign,
onChanged,
onError
}: {
settings: ApiSettings;
campaign: CampaignListItem;
onChanged: () => Promise<void>;
onError: (message: string) => void;
}) {
}: {settings: ApiSettings;auth: AuthInfo;campaign: CampaignListItem;onChanged: () => Promise<void>;onError: (message: string) => void;}) {
const [available, setAvailable] = useState<boolean | null>(null);
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
const [shares, setShares] = useState<CampaignShare[]>([]);
@@ -40,21 +44,42 @@ export default function CampaignAccessCard({
const [shareType, setShareType] = useState<TargetType>("user");
const [shareTargetId, setShareTargetId] = useState("");
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
const [accessExplanationOpen, setAccessExplanationOpen] = useState(false);
const [accessExplanation, setAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
const [busy, setBusy] = useState(false);
const currentOwnerType: TargetType = campaign.owner_group_id ? "group" : "user";
const currentOwnerId = campaign.owner_group_id || campaign.owner_user_id || "";
const ownerDirty = ownerOpen && (ownerType !== currentOwnerType || ownerId !== currentOwnerId);
const shareDirty = shareOpen && (shareType !== "user" || Boolean(shareTargetId) || sharePermission !== "read");
const canExplainResourceAccess =
hasScope(auth, "admin:users:read") ||
hasScope(auth, "admin:roles:read") ||
hasScope(auth, "access:membership:read") ||
hasScope(auth, "access:role:read");
useUnsavedDraftGuard({
dirty: ownerDirty || shareDirty,
onSave: saveActiveDialog,
onDiscard: () => {
closeOwnerDialog();
closeShareDialog();
}
});
async function load() {
try {
const [nextTargets, nextShares] = await Promise.all([
getCampaignShareTargets(settings, campaign.id),
getCampaignShares(settings, campaign.id)
]);
getCampaignShares(settings, campaign.id)]
);
setTargets(nextTargets);
setShares(nextShares.filter((item) => !item.revoked_at));
setAvailable(true);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.startsWith("403 ")) setAvailable(false);
else onError(message);
if (message.startsWith("403 ")) setAvailable(false);else
onError(message);
}
}
@@ -68,37 +93,58 @@ export default function CampaignAccessCard({
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
const targetMap = useMemo(() => new Map([
...targets.users.map((item) => [`user:${item.id}`, item] as const),
...targets.groups.map((item) => [`group:${item.id}`, item] as const)
]), [targets]);
...targets.groups.map((item) => [`group:${item.id}`, item] as const)]
), [targets]);
const ownerLabel = campaign.owner_group_id
? targetMap.get(`group:${campaign.owner_group_id}`)?.name || "Group owner"
: targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "User owner";
const ownerLabel = campaign.owner_group_id ?
targetMap.get(`group:${campaign.owner_group_id}`)?.name || "i18n:govoplan-campaign.group_owner.55630670" :
targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "i18n:govoplan-campaign.user_owner.86e3faab";
async function saveOwner() {
if (!ownerId) return;
function closeOwnerDialog() {
setOwnerOpen(false);
setOwnerType(currentOwnerType);
setOwnerId(currentOwnerId);
}
function closeShareDialog() {
setShareOpen(false);
setShareType("user");
setShareTargetId("");
setSharePermission("read");
}
async function saveActiveDialog(): Promise<boolean> {
if (ownerDirty) return saveOwner();
if (shareDirty) return saveShare();
return true;
}
async function saveOwner(): Promise<boolean> {
if (!ownerId) return false;
setBusy(true);
try {
await updateCampaignOwner(settings, campaign.id, ownerType === "user"
? { owner_user_id: ownerId, owner_group_id: null }
: { owner_user_id: null, owner_group_id: ownerId });
await updateCampaignOwner(settings, campaign.id, ownerType === "user" ?
{ owner_user_id: ownerId, owner_group_id: null } :
{ owner_user_id: null, owner_group_id: ownerId });
setOwnerOpen(false);
await onChanged();
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
return true;
} catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
{setBusy(false);}
}
async function saveShare() {
if (!shareTargetId) return;
async function saveShare(): Promise<boolean> {
if (!shareTargetId) return false;
setBusy(true);
try {
await upsertCampaignShare(settings, campaign.id, { target_type: shareType, target_id: shareTargetId, permission: sharePermission });
setShareOpen(false);
setShareTargetId("");
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
return true;
} catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
{setBusy(false);}
}
async function revoke() {
@@ -108,14 +154,35 @@ export default function CampaignAccessCard({
await revokeCampaignShare(settings, campaign.id, revokeTarget.id);
setRevokeTarget(null);
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
} catch (err) {onError(err instanceof Error ? err.message : String(err));} finally
{setBusy(false);}
}
async function openAccessExplanation() {
if (!auth.user?.id) return;
setAccessExplanationOpen(true);
setAccessExplanation(null);
setAccessExplanationLoading(true);
try {
setAccessExplanation(await fetchResourceAccessExplanation(settings, {
userId: auth.user.id,
resourceType: "campaign",
resourceId: campaign.id,
action: "campaigns:campaign:read",
tenantId: (auth.active_tenant ?? auth.tenant)?.id
}));
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
setAccessExplanationOpen(false);
} finally {
setAccessExplanationLoading(false);
}
}
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
{
id: "target",
header: "Shared with",
header: "i18n:govoplan-campaign.shared_with.6203f449",
width: "minmax(220px, 1fr)",
minWidth: 190,
resizable: true,
@@ -125,35 +192,105 @@ export default function CampaignAccessCard({
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
render: (row) => {
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? ` · ${target.secondary}` : ""}</div></div>;
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.secondary }) : ""}</div></div>;
}
},
{ id: "permission", header: "Access", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "Can edit" : "Can view"} /> },
{ id: "actions", header: "Actions", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>Remove</Button> }
], [targetMap]);
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>i18n:govoplan-campaign.remove.e963907d</Button> }],
[targetMap]);
if (available === false) return null;
return (
<>
<Card title="Ownership and sharing" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>Change owner</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>Share</Button></div>}>
<p><strong>Owner:</strong> {ownerLabel}</p>
<p className="muted small-note">Permissions define what a role may do; ownership and explicit shares determine which campaigns that permission applies to.</p>
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "Loading campaign access…" : "This campaign has no explicit shares."} />
<Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.change_owner.d3ce16a8</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button><Button onClick={() => void openAccessExplanation()} disabled={available !== true || !canExplainResourceAccess}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-campaign.explain_access.4d5fac37</Button></div>}>
<p><strong>i18n:govoplan-campaign.owner.719379ae</strong> {ownerLabel}</p>
<p className="muted small-note">i18n:govoplan-campaign.permissions_define_what_a_role_may_do_ownership_.9f8baaa2</p>
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "i18n:govoplan-campaign.loading_campaign_access.056299e3" : "i18n:govoplan-campaign.this_campaign_has_no_explicit_shares.978012d1"} />
</Card>
<Dialog open={ownerOpen} className="campaign-access-dialog" title="Change campaign owner" onClose={() => !busy && setOwnerOpen(false)} footer={<><Button onClick={() => setOwnerOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>Save owner</Button></>}>
<FormField label="Owner type"><select value={ownerType} onChange={(event) => { const next = event.target.value as TargetType; setOwnerType(next); setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
<FormField label="Owner"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
<Dialog open={ownerOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.change_campaign_owner.63f80aef" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>i18n:govoplan-campaign.save_owner.b6763847</Button></>}>
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.owner.89ff3122"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
<p className="muted small-note">Changing the owner clears a selected reusable mail profile from the editable current version and requires profile reselection plus validation before live delivery.</p>
</Dialog>
<Dialog open={shareOpen} className="campaign-access-dialog" title="Share campaign" onClose={() => !busy && setShareOpen(false)} footer={<><Button onClick={() => setShareOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>Save share</Button></>}>
<FormField label="Target type"><select value={shareType} onChange={(event) => { const next = event.target.value as TargetType; setShareType(next); setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
<FormField label="User or group"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">Select</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
<FormField label="Access"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">Can view</option><option value="write">Can edit and operate</option></select></FormField>
<Dialog open={shareOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.share_campaign.b605982b" onClose={() => !busy && closeShareDialog()} footer={<><Button onClick={closeShareDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>i18n:govoplan-campaign.save_share.bcf6ed94</Button></>}>
<FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.user_or_group.53406ef0"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">i18n:govoplan-campaign.select.349ac8fb</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
</Dialog>
<ConfirmDialog open={Boolean(revokeTarget)} title="Remove campaign share" message="Remove this user's or group's explicit access to the campaign? Ownership and other group shares still apply." confirmLabel="Remove share" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
</>
);
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<ResourceAccessExplanationContent loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} />
</Dialog>
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
</>);
}
function ResourceAccessExplanationContent({
loading,
explanation,
fallbackResourceLabel
}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) {
if (loading) return <p className="muted small-note">i18n:govoplan-campaign.loading_access_explanation.04a7c934</p>;
if (!explanation) return null;
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
return (
<>
<div className="form-grid compact responsive-form-grid">
<div><span className="form-label">i18n:govoplan-campaign.user.9f8a2389</span><p>{userLabel}</p></div>
<div><span className="form-label">i18n:govoplan-campaign.resource.d1c626a9</span><p>{resourceLabel}</p></div>
<div><span className="form-label">i18n:govoplan-campaign.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
<div><span className="form-label">i18n:govoplan-campaign.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
</div>
{explanation.provenance.length === 0 ?
<p className="muted small-note">i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e</p> :
<div className="admin-assignment-grid">
{explanation.provenance.map((item, index) =>
<div key={`${item.kind}:${item.id ?? index}`}>
<strong>{provenanceKindLabel(item.kind)}</strong>
<div className="muted small-note">
{item.source || "i18n:govoplan-campaign.no_source.6dcf9723"}
{item.id && <> · <code>{item.id}</code></>}
</div>
{item.label && <p>{item.label}</p>}
{Object.keys(item.details ?? {}).length > 0 && <p className="muted small-note">{formatProvenanceDetails(item.details ?? {})}</p>}
</div>
)}
</div>
}
</>);
}
function provenanceKindLabel(kind: string): string {
switch (kind) {
case "resource":
return "i18n:govoplan-campaign.resource.d1c626a9";
case "owner":
return "i18n:govoplan-campaign.owner.89ff3122";
case "share":
return "i18n:govoplan-campaign.share.09ca55ca";
case "policy":
return "i18n:govoplan-campaign.policy.0b779a05";
case "role":
return "i18n:govoplan-campaign.role.b5b4a5a2";
case "right":
return "i18n:govoplan-campaign.permission.2f81a22d";
default:
return kind;
}
}
function formatProvenanceDetails(details: Record<string, unknown>): string {
return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
}
function formatProvenanceValue(value: unknown): string {
if (value === null || value === undefined) return "i18n:govoplan-campaign.none.6eef6648";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}

View File

@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import { Button, DismissibleAlert, LoadingFrame, PageTitle } from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import LockedVersionNotice from "./LockedVersionNotice";
import VersionLine from "./VersionLine";
type CampaignDraftPageScaffoldProps = {
settings: ApiSettings;
campaignId: string;
title: string;
loading: boolean;
version: CampaignVersionDetail | null;
versions: CampaignVersionListItem[];
saveState: string;
onReload: () => Promise<void>;
onSave: () => void;
dirty: boolean;
locked: boolean;
draft: Record<string, unknown> | null;
error?: string | null;
localError?: string | null;
currentVersionId?: string | null;
children: ReactNode;
};
export default function CampaignDraftPageScaffold({
settings,
campaignId,
title,
loading,
version,
versions,
saveState,
onReload,
onSave,
dirty,
locked,
draft,
error,
localError,
currentVersionId,
children
}: CampaignDraftPageScaffoldProps) {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{title}</PageTitle>
<VersionLine version={version} versions={versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={onReload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={onSave} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={currentVersionId} reload={onReload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>{children}</>
</LoadingFrame>
</div>);
}

View File

@@ -9,16 +9,16 @@ import {
unlockCampaignVersionUserLock,
unlockCampaignVersionValidation,
type CampaignVersionDetail,
type CampaignVersionListItem,
} from "../../../api/campaigns";
type CampaignVersionListItem } from
"../../../api/campaigns";
import {
canUnlockValidationVersion,
formatDateTime,
isFinalLockedVersion,
isHistoricalCampaignVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
} from "../utils/campaignView";
isTemporaryUserLockedVersion } from
"../utils/campaignView";
type LockedVersionNoticeProps = {
settings: ApiSettings;
@@ -48,7 +48,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
validationLock,
temporaryUserLock,
permanentUserLock,
finalLock,
finalLock
});
async function runAction(action: Exclude<ConfirmAction, null>) {
@@ -59,13 +59,13 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
try {
if (action === "unlock-validation") {
await unlockCampaignVersionValidation(settings, campaignId, version.id);
setLocalMessage("Validation lock removed. Validation, build and generated queue state were invalidated.");
setLocalMessage("i18n:govoplan-campaign.validation_lock_removed_validation_build_and_gen.ea26975f");
} else if (action === "unlock-user") {
await unlockCampaignVersionUserLock(settings, campaignId, version.id);
setLocalMessage("Temporary user lock removed. The version is editable again.");
setLocalMessage("i18n:govoplan-campaign.temporary_user_lock_removed_the_version_is_edita.0ca4c587");
} else {
await lockCampaignVersionPermanently(settings, campaignId, version.id);
setLocalMessage("Version permanently locked. Future changes require an editable copy.");
setLocalMessage("i18n:govoplan-campaign.version_permanently_locked_future_changes_requir.fd718a84");
}
await reload();
} catch (err) {
@@ -83,7 +83,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
try {
const result = await forkCampaignVersion(settings, campaignId, version.id, {
current_flow: "manual",
current_step: version.current_step ?? null,
current_step: version.current_step ?? null
});
setLocalMessage(`Created editable version #${result.version.version_number}.`);
await reload();
@@ -105,26 +105,26 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
{localError && <span className="locked-version-error"> {localError}</span>}
</div>
<div className="button-row compact-actions locked-version-actions">
{validationLock && (
{validationLock &&
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock validation"}
{busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock_validation.f01952b6"}
</Button>
)}
{temporaryUserLock && (
}
{temporaryUserLock &&
<>
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock"}
{busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock.1526a17e"}
</Button>
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
Lock permanently
i18n:govoplan-campaign.lock_permanently.cc0ce9e7
</Button>
</>
)}
{canCreateEditableCopy && (
}
{canCreateEditableCopy &&
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
{busy ? "Creating copy…" : "Create editable copy"}
{busy ? "i18n:govoplan-campaign.creating_copy.4247f587" : "i18n:govoplan-campaign.create_editable_copy.92d6d91b"}
</Button>
)}
}
</div>
<ConfirmDialog
@@ -139,10 +139,10 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
const action = confirmAction;
setConfirmAction(null);
if (action) void runAction(action);
}}
/>
</DismissibleAlert>
);
}} />
</DismissibleAlert>);
}
type LockFlags = {
@@ -155,81 +155,81 @@ type LockFlags = {
function lockPresentation(
version: CampaignVersionDetail | CampaignVersionListItem | null,
flags: LockFlags,
): { kind: string; title: string; description: string; info: string } {
flags: LockFlags)
: {kind: string;title: string;description: string;info: string;} {
if (flags.historicalVersion) {
return {
kind: "historical",
title: "Historical version.",
description: "This version is review-only. Only the campaign's current working version may be edited in place.",
info: `Version #${version?.version_number ?? "—"}.`,
title: "i18n:govoplan-campaign.historical_version.adac5dba",
description: "i18n:govoplan-campaign.this_version_is_review_only_only_the_campaign_s_.9e68f6a0",
info: `Version #${version?.version_number ?? "—"}.`
};
}
if (flags.temporaryUserLock) {
return {
kind: "temporary-user",
title: "Temporarily locked version.",
description: "Campaign data is read-only, but an authorized user may unlock it or make the lock permanent.",
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : "",
title: "i18n:govoplan-campaign.temporarily_locked_version.696f85e3",
description: "i18n:govoplan-campaign.campaign_data_is_read_only_but_an_authorized_use.f2e513b1",
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : ""
};
}
if (flags.permanentUserLock) {
return {
kind: "permanent-user",
title: "Permanently locked version.",
description: "This user-requested snapshot cannot be unlocked. Create an editable copy for future changes.",
info: version?.user_locked_at || version?.published_at
? `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.`
: "",
title: "i18n:govoplan-campaign.permanently_locked_version.248c2eeb",
description: "i18n:govoplan-campaign.this_user_requested_snapshot_cannot_be_unlocked_.9e91210c",
info: version?.user_locked_at || version?.published_at ?
`Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.` :
""
};
}
if (flags.validationLock) {
return {
kind: "validation",
title: "Temporarily locked after validation.",
description: "This protects the validated build input. Unlocking invalidates validation, build and generated queue state.",
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : "",
title: "i18n:govoplan-campaign.temporarily_locked_after_validation.c558ce56",
description: "i18n:govoplan-campaign.this_protects_the_validated_build_input_unlockin.ed890772",
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : ""
};
}
if (flags.finalLock) {
return {
kind: "delivery",
title: "Permanently locked delivery snapshot.",
description: "Delivery has started or the version is in a final state, so it cannot be unlocked in place.",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
title: "i18n:govoplan-campaign.permanently_locked_delivery_snapshot.c3670daa",
description: "i18n:govoplan-campaign.delivery_has_started_or_the_version_is_in_a_fina.571e86ed",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
};
}
return {
kind: "other",
title: "Locked version.",
description: "This version is read-only. Create an editable copy to continue working.",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
title: "i18n:govoplan-campaign.locked_version.e755b179",
description: "i18n:govoplan-campaign.this_version_is_read_only_create_an_editable_cop.9e0afd29",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
};
}
function confirmDialogTitle(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation?";
if (action === "unlock-user") return "Unlock temporary lock?";
if (action === "permanent") return "Lock permanently?";
return "Confirm action";
if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.e3066247";
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468";
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.24d5b74d";
return "i18n:govoplan-campaign.confirm_action.50649c5c";
}
function confirmDialogMessage(action: ConfirmAction): string {
if (action === "unlock-validation") {
return "This makes the version editable and clears validation, build results, review acknowledgement and generated jobs for this version.";
return "i18n:govoplan-campaign.this_makes_the_version_editable_and_clears_valid.89a6cd1b";
}
if (action === "unlock-user") {
return "This removes the reversible user lock. Campaign data and existing workflow results are not otherwise changed.";
return "i18n:govoplan-campaign.this_removes_the_reversible_user_lock_campaign_d.fcf8cb1d";
}
if (action === "permanent") {
return "This lock cannot be removed by any role. The version remains available for review and audit, but future changes require an editable copy.";
return "i18n:govoplan-campaign.this_lock_cannot_be_removed_by_any_role_the_vers.3054b076";
}
return "Continue?";
return "i18n:govoplan-campaign.continue.4e6302ed";
}
function confirmDialogLabel(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation";
if (action === "unlock-user") return "Unlock";
if (action === "permanent") return "Lock permanently";
return "Confirm";
if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.f01952b6";
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock.1526a17e";
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
return "i18n:govoplan-campaign.confirm.04a21221";
}

View File

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

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { Button, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
@@ -41,7 +41,7 @@ export type CampaignMessagePreviewOverlayProps = {
};
export default function CampaignMessagePreviewOverlay({
title = "Message preview",
title = "i18n:govoplan-campaign.message_preview.58de1450",
subject,
bodyMode = "text",
text,
@@ -51,14 +51,42 @@ export default function CampaignMessagePreviewOverlay({
metaItems = [],
attachments = [],
raw,
rawLabel = "Raw MIME",
rawLabel = "i18n:govoplan-campaign.raw_mime.82c612d4",
navigation,
closeLabel = "Close",
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
onClose
}: CampaignMessagePreviewOverlayProps) {
const shownSubject = subject?.trim() || "No subject";
const shownSubject = subject?.trim() || "i18n:govoplan-campaign.no_subject.7b4e8035";
const fields = metaItems.map((item) => ({ label: item.label, value: item.value }));
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (!navigation) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
if (navigation.index > 0) navigation.onPrevious();
} else if (event.key === "ArrowRight") {
event.preventDefault();
if (navigation.index < navigation.total - 1) navigation.onNext();
} else if (event.key === "Home") {
event.preventDefault();
if (navigation.index > 0) navigation.onFirst();
} else if (event.key === "End") {
event.preventDefault();
if (navigation.index < navigation.total - 1) navigation.onLast();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [navigation, onClose]);
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
<div className="modal-panel template-preview-modal message-preview-modal">
@@ -67,23 +95,23 @@ export default function CampaignMessagePreviewOverlay({
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
{(recipientLabel || recipientNote || navigation) && (
{(recipientLabel || recipientNote || navigation) &&
<div className="template-preview-toolbar">
<div>
{recipientLabel && <strong>{recipientLabel}</strong>}
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
</div>
{navigation && (
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
{navigation &&
<div className="button-row compact-actions template-preview-nav" aria-label="i18n:govoplan-campaign.preview_message_navigation.d28a8dc0">
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.first_message.ffc124fd" aria-label="i18n:govoplan-campaign.first_message.ffc124fd"><ArrowBigLeftDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.previous_message.93261bd8" aria-label="i18n:govoplan-campaign.previous_message.93261bd8"><ArrowBigLeft aria-hidden="true" /></button>
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.next_message.e3960a5d" aria-label="i18n:govoplan-campaign.next_message.e3960a5d"><ArrowBigRight aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
</div>
)}
}
</div>
)}
}
<MessageDisplayPanel
title={shownSubject}
@@ -93,20 +121,25 @@ export default function CampaignMessagePreviewOverlay({
preferredBodyMode={bodyMode}
deriveTextFromHtml={false}
attachments={attachments}
emptyText="No message content is available."
/>
emptyText="i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6" />
{raw && (
{raw &&
<details className="message-preview-raw">
<summary>{rawLabel}</summary>
<pre className="mock-message-raw">{raw}</pre>
</details>
)}
}
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
</div>
</div>
);
</div>);
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || ["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName);
}

View File

@@ -7,15 +7,16 @@ import {
buildUndefinedPlaceholders,
extractTemplatePlaceholders,
removePlaceholderFromText,
replacePlaceholderInText,
type TemplateNamespace,
type UndefinedPlaceholder
} from "../utils/templatePlaceholders";
type UndefinedPlaceholder } from
"../utils/templatePlaceholders";
import {
TemplateFieldChipList,
UndefinedPlaceholderDecisionDialog,
UndefinedPlaceholderList,
type TemplateFieldOption
} from "./TemplatePlaceholderControls";
type TemplateFieldOption } from
"./TemplatePlaceholderControls";
export default function TemplateExpressionEditorDialog({
open,
@@ -25,27 +26,27 @@ export default function TemplateExpressionEditorDialog({
globalFields,
placeholder,
description,
saveLabel = "Save",
saveLabel = "i18n:govoplan-campaign.save.efc007a3",
validate,
onCancel,
onSave,
onAddField,
canAddField = () => true
}: {
open: boolean;
title: string;
value: string;
localFields: TemplateFieldOption[];
globalFields: TemplateFieldOption[];
placeholder?: string;
description?: string;
saveLabel?: string;
validate?: (value: string) => string;
onCancel: () => void;
onSave: (value: string) => void;
onAddField: (name: string) => void;
canAddField?: (name: string) => boolean;
}) {
}: {open: boolean;title: string;value: string;localFields: TemplateFieldOption[];globalFields: TemplateFieldOption[];placeholder?: string;description?: string;saveLabel?: string;validate?: (value: string) => string;onCancel: () => void;onSave: (value: string) => void;onAddField: (name: string) => void;canAddField?: (name: string) => boolean;}) {
const [draftValue, setDraftValue] = useState(value);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -100,6 +101,11 @@ export default function TemplateExpressionEditorDialog({
setUndefinedDialog(null);
}
function replaceUndefined(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) {
setDraftValue((current) => replacePlaceholderInText(current, field.raw, `{{${namespace}:${name}}}`));
setUndefinedDialog(null);
}
if (!open || typeof document === "undefined") return null;
return createPortal(
@@ -107,22 +113,22 @@ export default function TemplateExpressionEditorDialog({
<Dialog
open={open && !undefinedDialog}
title={title}
closeLabel="Cancel filename changes"
closeLabel="i18n:govoplan-campaign.cancel_filename_changes.b558598e"
className="template-expression-dialog"
headerClassName="template-expression-dialog-header"
bodyClassName="template-expression-dialog-body"
footerClassName="template-expression-dialog-footer"
onClose={onCancel}
footer={(
footer={
<>
<Button onClick={onCancel}>Cancel</Button>
<Button onClick={onCancel}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={requestSave} disabled={Boolean(validationMessage)}>{saveLabel}</Button>
</>
)}
>
}>
{description && <p className="muted template-expression-description">{description}</p>}
<label className="template-expression-value-field">
<span>Archive filename</span>
<span>i18n:govoplan-campaign.archive_filename.47becf3d</span>
<input
ref={inputRef}
value={draftValue}
@@ -134,43 +140,46 @@ export default function TemplateExpressionEditorDialog({
event.preventDefault();
requestSave();
}
}}
/>
}} />
</label>
{validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
<h3 className="section-mini-heading">Recipient fields</h3>
<p className="muted small-note">Select a field to insert it at the current cursor position. Address fields represent the first effective address; use <code>to[2]</code> or <code>to[2].email</code> for another single value.</p>
<h3 className="section-mini-heading">i18n:govoplan-campaign.recipient_fields.fdbcd95b</h3>
<p className="muted small-note">i18n:govoplan-campaign.select_a_field_to_insert_it_at_the_current_curso.1d1b51be <code>i18n:govoplan-campaign.to_2.8c1db0c7</code> or <code>i18n:govoplan-campaign.to_2_email.5ad61c1a</code> i18n:govoplan-campaign.for_another_single_value.ce57d76a</p>
<TemplateFieldChipList
namespace="local"
fields={localFields}
usedPlaceholders={usedPlaceholders}
empty="No recipient fields are available."
onInsert={insertPlaceholder}
/>
empty="i18n:govoplan-campaign.no_recipient_fields_are_available.5b7943ad"
onInsert={insertPlaceholder} />
<h3 className="section-mini-heading">Campaign fields</h3>
<h3 className="section-mini-heading">i18n:govoplan-campaign.campaign_fields.969e7d80</h3>
<TemplateFieldChipList
namespace="global"
fields={globalFields}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values are available."
onInsert={insertPlaceholder}
/>
empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_are_availabl.b2e4089d"
onInsert={insertPlaceholder} />
<h3 className="section-mini-heading">Used, but undefined</h3>
<h3 className="section-mini-heading">i18n:govoplan-campaign.used_but_undefined.3b829043</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Dialog>
<UndefinedPlaceholderDecisionDialog
field={undefinedDialog}
contextLabel="archive filename"
removeLabel="Remove from filename"
contextLabel="i18n:govoplan-campaign.archive_filename.de958cbd"
removeLabel="i18n:govoplan-campaign.remove_from_filename.560f7f42"
onCancel={() => setUndefinedDialog(null)}
onRemove={removeUndefined}
onReplace={replaceUndefined}
onAddField={addUndefined}
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
/>
localFields={localFields}
globalFields={globalFields}
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")} />
</>,
document.body
);

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
export type TemplateFieldOption = {
@@ -15,14 +16,14 @@ export function TemplateFieldChipList({
empty,
disabled = false,
onInsert
}: {
namespace: TemplateNamespace;
fields: TemplateFieldOption[];
usedPlaceholders?: TemplatePlaceholder[];
empty: string;
disabled?: boolean;
onInsert: (namespace: TemplateNamespace, name: string) => void;
}) {
}: {namespace: TemplateNamespace;fields: TemplateFieldOption[];usedPlaceholders?: TemplatePlaceholder[];empty: string;disabled?: boolean;onInsert: (namespace: TemplateNamespace, name: string) => void;}) {
if (fields.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
@@ -34,85 +35,128 @@ export function TemplateFieldChipList({
className={`field-chip field-chip-button ${used ? "used" : ""}`}
key={`${namespace}:${field.name}`}
disabled={disabled}
onClick={() => onInsert(namespace, field.name)}
>
onClick={() => onInsert(namespace, field.name)}>
<span className="field-chip-namespace">{namespace}</span><span title={field.label !== field.name ? field.label : undefined}>{field.name}</span>
</button>
);
</button>);
})}
</div>
);
</div>);
}
export function UndefinedPlaceholderList({
items,
empty = "No undefined placeholders detected.",
empty = "i18n:govoplan-campaign.no_undefined_placeholders_detected.1cbdf41b",
onSelect
}: {
items: UndefinedPlaceholder[];
empty?: string;
onSelect: (item: UndefinedPlaceholder) => void;
}) {
}: {items: UndefinedPlaceholder[];empty?: string;onSelect: (item: UndefinedPlaceholder) => void;}) {
if (items.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{items.map((item) => (
{items.map((item) =>
<button
type="button"
className="field-chip field-chip-button undefined"
key={`${item.raw}:${item.reason}`}
onClick={() => onSelect(item)}
>
onClick={() => onSelect(item)}>
{item.display}
</button>
))}
</div>
);
)}
</div>);
}
export function UndefinedPlaceholderDecisionDialog({
field,
contextLabel = "template",
removeLabel = "Remove placeholder",
removeLabel = "i18n:govoplan-campaign.remove_placeholder.3f575c72",
localFields = [],
globalFields = [],
onCancel,
onRemove,
onReplace,
onAddField,
addDisabled = false
}: {
field: UndefinedPlaceholder | null;
contextLabel?: string;
removeLabel?: string;
onCancel: () => void;
onRemove: (field: UndefinedPlaceholder) => void;
onAddField: (field: UndefinedPlaceholder) => void;
addDisabled?: boolean;
}) {
}: {field: UndefinedPlaceholder | null;contextLabel?: string;removeLabel?: string;localFields?: TemplateFieldOption[];globalFields?: TemplateFieldOption[];onCancel: () => void;onRemove: (field: UndefinedPlaceholder) => void;onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;onAddField: (field: UndefinedPlaceholder) => void;addDisabled?: boolean;}) {
const replacementOptions = useMemo(
() => [
...localFields.map((item) => ({ namespace: "local" as const, ...item })),
...globalFields.map((item) => ({ namespace: "global" as const, ...item }))],
[globalFields, localFields]
);
const [replacement, setReplacement] = useState("");
const firstReplacement = replacementOptions[0];
const effectiveReplacement = replacement || (firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
useEffect(() => {
setReplacement(firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
}, [field?.raw, firstReplacement?.namespace, firstReplacement?.name]);
function replaceWithSelected() {
if (!field || !onReplace || !effectiveReplacement) return;
const separator = effectiveReplacement.indexOf(":");
if (separator < 0) return;
const namespace = effectiveReplacement.slice(0, separator) as TemplateNamespace;
const name = effectiveReplacement.slice(separator + 1);
onReplace(field, namespace, name);
}
return (
<Dialog
open={Boolean(field)}
title="Undefined field reference"
closeLabel="Return to editing"
title="i18n:govoplan-campaign.undefined_field_reference.4ff8e266"
closeLabel="i18n:govoplan-campaign.return_to_editing.37d4a5f0"
className="template-action-dialog"
onClose={onCancel}
footer={field ? (
footer={field ?
<>
<Button onClick={onCancel}>Continue editing</Button>
<Button onClick={onCancel}>i18n:govoplan-campaign.continue_editing.b101465f</Button>
<Button onClick={() => onRemove(field)}>{removeLabel}</Button>
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
</>
) : undefined}
>
{field && (
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>i18n:govoplan-campaign.replace.a7cf7b25</Button>}
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>i18n:govoplan-campaign.add_field.039c6315</Button>
</> :
undefined}>
{field &&
<>
<p>The {contextLabel} uses <code>{`{{${field.raw}}}`}</code>, but it cannot be matched to a known field.</p>
{field.reason === "invalid-namespace" && (
<DismissibleAlert tone="warning">Use the namespace <code>global:</code> or <code>local:</code>.</DismissibleAlert>
)}
{field.reason === "missing-field" && (
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p>
)}
</>
)}
</Dialog>
);
<p>i18n:govoplan-campaign.the.93ef0dd8 {contextLabel} uses <code>{i18nMessage("i18n:govoplan-campaign.value.91317e63", { value0: field.raw })}</code>i18n:govoplan-campaign.but_it_cannot_be_matched_to_a_known_field.e75c4dde</p>
{field.reason === "invalid-namespace" &&
<DismissibleAlert tone="warning">i18n:govoplan-campaign.use_the_namespace.a4c2669d <code>global:</code> or <code>local:</code>.</DismissibleAlert>
}
{field.reason === "missing-field" &&
<p className="muted">i18n:govoplan-campaign.you_can_add.66093419 <strong>{field.name}</strong> i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8</p>
}
{onReplace && replacementOptions.length > 0 &&
<label className="template-replacement-field">
<span>i18n:govoplan-campaign.replace_by_existing_field.ee3dc0eb</span>
<select value={effectiveReplacement} onChange={(event) => setReplacement(event.target.value)}>
{replacementOptions.map((option) =>
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
{option.namespace}:{option.label || option.name}
</option>
)}
</select>
</label>
}
</>
}
</Dialog>);
}

View File

@@ -1,7 +1,7 @@
import { i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { useLocation } from "react-router-dom";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import { useCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
import { formatDateTime } from "../utils/campaignView";
type VersionLineProps = {
@@ -13,8 +13,7 @@ type VersionLineProps = {
export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) {
const location = useLocation();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const navigate = useGuardedNavigate();
const sorted = versions.slice().sort((a, b) => (a.version_number ?? 0) - (b.version_number ?? 0));
const currentIndex = version ? sorted.findIndex((item) => item.id === version.id) : -1;
const first = currentIndex > 0 ? sorted[0] : null;
@@ -25,7 +24,7 @@ export default function VersionLine({ version, versions = [], status, loadedAt }
function openVersion(target: CampaignVersionListItem) {
const targetUrl = versionTarget(location.pathname, location.search, target.id);
requestNavigation(() => navigate(targetUrl));
navigate(targetUrl);
}
return (
@@ -33,32 +32,32 @@ export default function VersionLine({ version, versions = [], status, loadedAt }
<VersionArrow
icon="first"
target={first}
fallbackLabel="Already at first version"
onOpen={openVersion}
/>
fallbackLabel="i18n:govoplan-campaign.already_at_first_version.b90fad64"
onOpen={openVersion} />
<VersionArrow
icon="previous"
target={previous}
fallbackLabel="No older version"
onOpen={openVersion}
/>
<span>Version {version ? `#${version.version_number}` : "—"}</span>
fallbackLabel="i18n:govoplan-campaign.no_older_version.4355dee8"
onOpen={openVersion} />
<span>i18n:govoplan-campaign.version.2da600bf {version ? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: version.version_number }) : "—"}</span>
<VersionArrow
icon="next"
target={next}
fallbackLabel="No newer version"
onOpen={openVersion}
/>
fallbackLabel="i18n:govoplan-campaign.no_newer_version.b210a41c"
onOpen={openVersion} />
<VersionArrow
icon="latest"
target={latest}
fallbackLabel="Already at latest version"
onOpen={openVersion}
/>
fallbackLabel="i18n:govoplan-campaign.already_at_latest_version.7533bd9d"
onOpen={openVersion} />
<span className="version-line-separator">·</span>
<span>{suffix}</span>
</p>
);
</p>);
}
type VersionArrowProps = {
@@ -76,8 +75,8 @@ function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps
return (
<span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}>
<Icon aria-hidden="true" />
</span>
);
</span>);
}
return (
@@ -86,11 +85,11 @@ function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps
className="version-arrow"
onClick={() => onOpen(target)}
title={actionLabel}
aria-label={actionLabel}
>
aria-label={actionLabel}>
<Icon aria-hidden="true" />
</button>
);
</button>);
}
function getVersionIcon(icon: VersionArrowProps["icon"]) {
@@ -110,13 +109,13 @@ function getVersionActionLabel(icon: VersionArrowProps["icon"], target: Campaign
const versionLabel = target ? `version #${target.version_number}` : "version";
switch (icon) {
case "first":
return `Open first ${versionLabel}`;
return i18nMessage("i18n:govoplan-campaign.open_first_value.ea8f5c5b", { value0: versionLabel });
case "previous":
return `Open previous ${versionLabel}`;
return i18nMessage("i18n:govoplan-campaign.open_previous_value.e8d4794b", { value0: versionLabel });
case "next":
return `Open next ${versionLabel}`;
return i18nMessage("i18n:govoplan-campaign.open_next_value.1315037e", { value0: versionLabel });
case "latest":
return `Open latest ${versionLabel}`;
return i18nMessage("i18n:govoplan-campaign.open_latest_value.40bdb320", { value0: versionLabel });
}
}

View File

@@ -22,6 +22,7 @@ type UseCampaignDraftEditorOptions = {
unsavedMessage: string;
loadedLabel?: (version: CampaignVersionDetail) => string;
transformLoadedDraft?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => Record<string, unknown>;
transformDraftBeforeSave?: (draft: Record<string, unknown>) => Record<string, unknown>;
extraPayload?: () => Partial<CampaignVersionUpdatePayload>;
onLoaded?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => void;
onSaved?: (version: CampaignVersionDetail) => void;
@@ -32,7 +33,7 @@ function resolveStep(value: StepValue): string | null | undefined {
}
function defaultLoadedLabel(version: CampaignVersionDetail): string {
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded";
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a";
}
export function useCampaignDraftEditor({
@@ -50,23 +51,26 @@ export function useCampaignDraftEditor({
unsavedMessage,
loadedLabel = defaultLoadedLabel,
transformLoadedDraft,
transformDraftBeforeSave,
extraPayload,
onLoaded,
onSaved
}: UseCampaignDraftEditorOptions) {
const loadedLabelRef = useRef(loadedLabel);
const transformLoadedDraftRef = useRef(transformLoadedDraft);
const transformDraftBeforeSaveRef = useRef(transformDraftBeforeSave);
const onLoadedRef = useRef(onLoaded);
useEffect(() => {
loadedLabelRef.current = loadedLabel;
transformLoadedDraftRef.current = transformLoadedDraft;
transformDraftBeforeSaveRef.current = transformDraftBeforeSave;
onLoadedRef.current = onLoaded;
}, [loadedLabel, transformLoadedDraft, onLoaded]);
}, [loadedLabel, transformLoadedDraft, transformDraftBeforeSave, onLoaded]);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [saveState, setSaveState] = useState("i18n:govoplan-campaign.loaded.6db90a0a");
const [localError, setLocalError] = useState("");
useEffect(() => {
@@ -93,12 +97,13 @@ export function useCampaignDraftEditor({
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setSaveState("i18n:govoplan-campaign.saving.56a2285c");
setError("");
setLocalError("");
try {
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
campaign_json: draftToSave,
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,
@@ -114,7 +119,7 @@ export function useCampaignDraftEditor({
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
return false;
}
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);

View File

@@ -1,8 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useDeltaWatermarks } from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import {
getCampaignWorkspace
getCampaignWorkspaceDelta,
type CampaignWorkspaceDeltaResponse
} from "../../../api/campaigns";
import type { CampaignWorkspaceData } from "../utils/campaignView";
@@ -34,6 +36,21 @@ export function useCampaignWorkspaceData(
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const dataRef = useRef<CampaignWorkspaceData>(initialData);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const queryKey = useMemo(
() => JSON.stringify({
campaignId,
selectedVersionId,
includeCurrentVersion,
includeSummary,
includeVersions,
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
accessToken: settings.accessToken
}),
[campaignId, selectedVersionId, includeCurrentVersion, includeSummary, includeVersions, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
);
const reload = useCallback(async () => {
if (!campaignId) return;
@@ -41,26 +58,39 @@ export function useCampaignWorkspaceData(
setError("");
try {
const shouldLoadVersions = includeCurrentVersion || includeVersions;
const response = await getCampaignWorkspace(settings, campaignId, {
let nextWatermark = getDeltaWatermark(queryKey);
let merged: CampaignWorkspaceData = dataRef.current;
let hasMore = false;
do {
const response = await getCampaignWorkspaceDelta(settings, campaignId, {
versionId: selectedVersionId,
includeCurrentVersion,
includeSummary,
includeVersions: shouldLoadVersions,
since: nextWatermark,
});
setData({
campaign: response.campaign,
versions: response.versions,
currentVersion: response.current_version,
summary: response.summary
});
merged = mergeWorkspaceDelta(merged, response);
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(queryKey, nextWatermark);
dataRef.current = merged;
setData(merged);
} catch (err) {
dataRef.current = initialData;
setData(initialData);
resetDeltaWatermark(queryKey);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId]);
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId, queryKey, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
useEffect(() => {
resetDeltaWatermark(queryKey);
dataRef.current = initialData;
setData(initialData);
}, [queryKey, resetDeltaWatermark]);
useEffect(() => {
reload();
@@ -68,3 +98,34 @@ export function useCampaignWorkspaceData(
return { data, loading, error, reload, setError };
}
function mergeWorkspaceDelta(current: CampaignWorkspaceData, response: CampaignWorkspaceDeltaResponse): CampaignWorkspaceData {
if (response.full) {
return {
campaign: response.campaign,
versions: response.versions,
currentVersion: response.current_version,
summary: response.summary
};
}
const deletedVersionIds = new Set(
response.deleted
.filter((item) => item.resource_type === "campaign_version")
.map((item) => item.id)
);
const versionMap = new Map(current.versions.map((version) => [version.id, version]));
for (const version of response.versions) {
versionMap.set(version.id, version);
}
for (const versionId of deletedVersionIds) {
versionMap.delete(versionId);
}
const currentVersionDeleted = current.currentVersion ? deletedVersionIds.has(current.currentVersion.id) : false;
return {
campaign: response.campaign ?? current.campaign,
versions: Array.from(versionMap.values()).sort((left, right) => (right.version_number ?? 0) - (left.version_number ?? 0)),
currentVersion: response.current_version ?? (currentVersionDeleted ? null : current.currentVersion),
summary: response.summary ?? current.summary
};
}

View File

@@ -1,4 +1,4 @@
export const INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE = "Inline SMTP/IMAP settings are blocked by the effective mail policy. Select an allowed reusable profile.";
export const INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE = "i18n:govoplan-campaign.inline_smtp_imap_settings_are_blocked_by_the_eff.90c94538";
export type CampaignMailPolicy = {
allow_campaign_profiles?: boolean | null;
@@ -17,11 +17,11 @@ export function campaignMailSettingsPolicyState({
effectivePolicy,
selectedProfileId,
locked = false
}: {
effectivePolicy: CampaignMailPolicy | null | undefined;
selectedProfileId: string | null | undefined;
locked?: boolean;
}): CampaignMailSettingsPolicyState {
}: {effectivePolicy: CampaignMailPolicy | null | undefined;selectedProfileId: string | null | undefined;locked?: boolean;}): CampaignMailSettingsPolicyState {
const campaignProfilesAllowed = effectivePolicy?.allow_campaign_profiles === true;
const usingMailProfile = Boolean(selectedProfileId);
const inlineMailSettingsBlocked = !campaignProfilesAllowed && !usingMailProfile;

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}`;
}
@@ -108,7 +112,7 @@ export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentS
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null;
const [, ownerType, ...ownerIdParts] = value.split(":");
const ownerId = ownerIdParts.join(":").trim();
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
if (ownerType !== "user" && ownerType !== "group" || !ownerId) return null;
return { ownerType, ownerId };
}
@@ -131,12 +135,12 @@ export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
};
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
{ label: "Campaign attachments", path: "attachments" },
{ label: "Campaign root", path: "." },
{ label: "Shared group files", path: "group/shared" },
{ label: "Tenant templates", path: "tenant/templates" },
{ label: "Personal upload area", path: "user/uploads" }
];
{ label: "i18n:govoplan-campaign.campaign_attachments.926bcbe6", path: "attachments" },
{ label: "i18n:govoplan-campaign.campaign_root.7eccd949", path: "." },
{ label: "i18n:govoplan-campaign.shared_group_files.fffa6e27", path: "group/shared" },
{ label: "i18n:govoplan-campaign.tenant_templates.ac6653bf", path: "tenant/templates" },
{ label: "i18n:govoplan-campaign.personal_upload_area.babd7b7f", path: "user/uploads" }];
@@ -201,11 +205,11 @@ export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: Attac
export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number {
const entries = asRecord(entriesValue);
return asArray(entries.inline)
.map(asRecord)
.flatMap((entry) => normalizeAttachmentRules(entry.attachments))
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath))
.length;
return asArray(entries.inline).
map(asRecord).
flatMap((entry) => normalizeAttachmentRules(entry.attachments)).
filter((rule) => attachmentRuleUsesBasePath(rule, basePath)).
length;
}
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
@@ -257,10 +261,10 @@ export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSum
export function countIndividualAttachmentRules(entriesValue: unknown): number {
const entries = asRecord(entriesValue);
return asArray(entries.inline)
.map(asRecord)
.flatMap((entry) => asArray(entry.attachments))
.length;
return asArray(entries.inline).
map(asRecord).
flatMap((entry) => asArray(entry.attachments)).
length;
}
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {

View File

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

View File

@@ -1,4 +1,4 @@
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
import { formatDateTime as formatPlatformDateTime, i18nMessage } from "@govoplan/core-webui";
import type { CampaignListItem } from "../../../types";
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
@@ -21,6 +21,10 @@ export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export function isSafeObjectPathSegment(segment: string): boolean {
return segment !== "__proto__" && segment !== "prototype" && segment !== "constructor";
}
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
return version?.raw_json ?? version?.campaign_json ?? {};
}
@@ -54,8 +58,8 @@ export function getFields(version: CampaignVersionDetail | null): unknown[] {
}
export function userLockState(
version: CampaignVersionDetail | CampaignVersionListItem | null
): "temporary" | "permanent" | null {
version: CampaignVersionDetail | CampaignVersionListItem | null)
: "temporary" | "permanent" | null {
if (!version) return null;
if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") {
return version.user_lock_state;
@@ -89,8 +93,8 @@ export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVe
"failed_partial",
"partially_sent",
"archived",
"cancelled"
].includes((version.workflow_state ?? "").toLowerCase());
"cancelled"].
includes((version.workflow_state ?? "").toLowerCase());
}
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
@@ -109,15 +113,15 @@ export function canUnlockValidationVersion(version: CampaignVersionDetail | Camp
export function isHistoricalCampaignVersion(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): boolean {
currentVersionId?: string | null)
: boolean {
return Boolean(version && currentVersionId && version.id !== currentVersionId);
}
export function isAuditLockedVersion(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): boolean {
currentVersionId?: string | null)
: boolean {
if (!version) return false;
if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
if (version.locked_at || isUserLockedVersion(version)) return true;
@@ -126,30 +130,30 @@ export function isAuditLockedVersion(
export function versionLockReason(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): string {
if (!version) return "No campaign version is loaded.";
currentVersionId?: string | null)
: string {
if (!version) return "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b";
if (isHistoricalCampaignVersion(version, currentVersionId)) {
return "Historical campaign versions are review-only. Continue work in the current version or create a new working copy after the current version becomes immutable.";
return "i18n:govoplan-campaign.historical_campaign_versions_are_review_only_con.c35b96af";
}
if (isTemporaryUserLockedVersion(version)) {
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
return i18nMessage("i18n:govoplan-campaign.temporarily_user_locked_at_value_authorized_user.7154865b", { value0: formatDateTime(version.user_locked_at) });
}
if (isPermanentUserLockedVersion(version)) {
return `Permanently user-locked at ${formatDateTime(version.user_locked_at ?? version.published_at)}. It cannot be unlocked; create an editable copy.`;
return i18nMessage("i18n:govoplan-campaign.permanently_user_locked_at_value_it_cannot_be_un.bf843a0f", { value0: formatDateTime(version.user_locked_at ?? version.published_at) });
}
if (canUnlockValidationVersion(version)) {
return `Temporarily locked by validation at ${formatDateTime(version.locked_at)}. Unlocking invalidates validation, build and queue state.`;
return i18nMessage("i18n:govoplan-campaign.temporarily_locked_by_validation_at_value_unlock.2f1ace87", { value0: formatDateTime(version.locked_at) });
}
if (isFinalLockedVersion(version)) return `Permanently locked by delivery/final state: ${humanize(version.workflow_state ?? "locked")}.`;
if (version.locked_at) return `Temporarily locked at ${formatDateTime(version.locked_at)}.`;
return "Editable working version.";
if (isFinalLockedVersion(version)) return i18nMessage("i18n:govoplan-campaign.permanently_locked_by_delivery_final_state_value.e31278ae", { value0: humanize(version.workflow_state ?? "locked") });
if (version.locked_at) return i18nMessage("i18n:govoplan-campaign.temporarily_locked_at_value.3ce9192e", { value0: formatDateTime(version.locked_at) });
return "i18n:govoplan-campaign.editable_working_version.379f898a";
}
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
if (!version) return "—";
const flow = version.current_flow || "manual";
const step = version.current_step || "not set";
const step = version.current_step || "i18n:govoplan-campaign.not_set.ef374c57";
return `${humanize(flow)} / ${humanize(step)}`;
}
@@ -179,10 +183,11 @@ export function getString(record: Record<string, unknown>, key: string, fallback
}
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
if (!path.every(isSafeObjectPathSegment)) return fallback;
let current: unknown = record;
for (const part of path) {
if (!isRecord(current)) return fallback;
current = current[part];
current = Object.getOwnPropertyDescriptor(current, part)?.value;
}
if (typeof current === "string" && current.trim()) return current;
if (typeof current === "number" || typeof current === "boolean") return String(current);
@@ -199,8 +204,8 @@ export function stringifyPreview(value: unknown, maxLength = 220): string {
export function cloneCampaignJsonForCopy(
source: Record<string, unknown>,
campaign: CampaignListItem | null,
stamp: string
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } {
stamp: string)
: {externalId: string;name: string;description: string;rawJson: Record<string, unknown>;} {
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
const campaignSection = asRecord(rawJson.campaign);
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");

View File

@@ -1,5 +1,5 @@
import type { CampaignVersionDetail } from "../../../api/campaigns";
import { asRecord, getCampaignJson, isRecord } from "./campaignView";
import { asRecord, getCampaignJson, isRecord, isSafeObjectPathSegment } from "./campaignView";
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
@@ -46,19 +46,32 @@ export function updateNested(
path: string[],
value: unknown
): Record<string, unknown> {
if (!path.length || !path.every(isSafeObjectPathSegment)) return cloneJson(draft);
const next = cloneJson(draft);
let current: Record<string, unknown> = next;
path.forEach((segment, index) => {
for (const [index, segment] of path.entries()) {
if (index === path.length - 1) {
current[segment] = value;
return;
}
const existing = current[segment];
if (!isRecord(existing)) {
current[segment] = {};
}
current = current[segment] as Record<string, unknown>;
Object.defineProperty(current, segment, {
configurable: true,
enumerable: true,
value,
writable: true
});
break;
}
const existing = Object.getOwnPropertyDescriptor(current, segment)?.value;
if (!isRecord(existing)) {
Object.defineProperty(current, segment, {
configurable: true,
enumerable: true,
value: {},
writable: true
});
}
const child = Object.getOwnPropertyDescriptor(current, segment)?.value;
if (!isRecord(child)) return next;
current = child;
}
return next;
}

View File

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

View File

@@ -0,0 +1,53 @@
import { mergeDeltaRows } from "@govoplan/core-webui";
import type { CampaignJobsDeltaResponse, CampaignJobsResponse } from "../../../api/campaigns";
export function emptyCampaignJobsResponse(): CampaignJobsResponse {
return {
jobs: [],
page: 1,
page_size: 50,
total: 0,
total_unfiltered: 0,
pages: 0,
cursor: null,
next_cursor: null,
counts: {},
filtered_counts: {},
review: {}
};
}
export function mergeCampaignJobsDelta(current: CampaignJobsResponse, response: CampaignJobsDeltaResponse): CampaignJobsResponse {
if (response.full) return campaignJobsResponseFromDelta(response);
return {
...campaignJobsResponseFromDelta(response),
jobs: mergeDeltaRows(current.jobs, response.jobs, response.deleted, (row) => String(row.id ?? ""), {
deletedResourceType: "campaign_job",
sort: sortJobsByEntry
}),
cursor: response.cursor ?? current.cursor,
next_cursor: response.next_cursor ?? current.next_cursor
};
}
export function campaignJobsResponseFromDelta(response: CampaignJobsDeltaResponse): CampaignJobsResponse {
return {
jobs: response.jobs,
page: response.page,
page_size: response.page_size,
total: response.total,
total_unfiltered: response.total_unfiltered,
pages: response.pages,
cursor: response.cursor,
next_cursor: response.next_cursor,
counts: response.counts,
filtered_counts: response.filtered_counts,
review: response.review
};
}
function sortJobsByEntry(left: Record<string, unknown>, right: Record<string, unknown>): number {
const indexDelta = Number(left.entry_index ?? 0) - Number(right.entry_index ?? 0);
if (indexDelta !== 0) return indexDelta;
return String(left.id ?? "").localeCompare(String(right.id ?? ""));
}

View File

@@ -13,10 +13,48 @@ export function recipientAddressTemplateFieldOptions(): Array<{ name: string; la
}
export function isRecipientAddressPlaceholderName(name: string): boolean {
const field = "(?:from|to|reply_to|cc|bcc)";
return new RegExp(`^(?:all_)?${field}(?:\\.(?:email|name))?$`).test(name)
|| new RegExp(`^${field}\\[[1-9]\\d*\\](?:\\.(?:email|name))?$`).test(name)
|| new RegExp(`^${field}\\.\\d+\\.(?:email|name|type)$`).test(name);
const normalized = name.trim();
const allPrefix = "all_";
const fieldName = recipientAddressFieldFromPlaceholder(normalized);
if (!fieldName) return false;
if (normalized === fieldName || normalized === `${allPrefix}${fieldName}`) return true;
if (["email", "name"].some((suffix) => normalized === `${fieldName}.${suffix}` || normalized === `${allPrefix}${fieldName}.${suffix}`)) return true;
if (isIndexedRecipientAddressPlaceholder(normalized, fieldName)) return true;
return isDottedRecipientAddressPlaceholder(normalized, fieldName);
}
function recipientAddressFieldFromPlaceholder(value: string): string | null {
const candidates = [...RECIPIENT_ADDRESS_FIELD_IDS].sort((left, right) => right.length - left.length);
for (const field of candidates) {
if (value === field || value.startsWith(`${field}.`) || value.startsWith(`${field}[`)) return field;
const allField = `all_${field}`;
if (value === allField || value.startsWith(`${allField}.`)) return field;
}
return null;
}
function isPositiveInteger(value: string): boolean {
return value.length > 0 && value[0] !== "0" && [...value].every((char) => char >= "0" && char <= "9");
}
function isNonNegativeInteger(value: string): boolean {
return value.length > 0 && [...value].every((char) => char >= "0" && char <= "9");
}
function isIndexedRecipientAddressPlaceholder(value: string, fieldName: string): boolean {
if (!value.startsWith(`${fieldName}[`)) return false;
const closeIndex = value.indexOf("]", fieldName.length + 1);
if (closeIndex < 0) return false;
const index = value.slice(fieldName.length + 1, closeIndex);
const suffix = value.slice(closeIndex + 1);
return isPositiveInteger(index) && (suffix === "" || suffix === ".email" || suffix === ".name");
}
function isDottedRecipientAddressPlaceholder(value: string, fieldName: string): boolean {
const prefix = `${fieldName}.`;
if (!value.startsWith(prefix)) return false;
const [index, suffix, extra] = value.slice(prefix.length).split(".");
return extra === undefined && isNonNegativeInteger(index) && ["email", "name", "type"].includes(suffix);
}
export type TemplatePlaceholder = {
@@ -211,8 +249,20 @@ export function valueToPreview(value: unknown): string {
export function removePlaceholderFromText(text: string, raw: string): string {
if (!text) return text;
const escaped = escapeRegExp(raw.trim());
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
return replaceMatchingPlaceholders(text, raw, "");
}
export function replacePlaceholderInText(text: string, raw: string, replacement: string): string {
if (!text) return text;
return replaceMatchingPlaceholders(text, raw, replacement);
}
function replaceMatchingPlaceholders(text: string, raw: string, replacement: string): string {
const target = raw.trim();
if (!target) return text;
return text.replace(/\{\{\s*([^}]+?)\s*\}\}|\$\{\s*([^}]+?)\s*\}/g, (match, braceRaw: string | undefined, dollarRaw: string | undefined) =>
(braceRaw ?? dollarRaw ?? "").trim() === target ? replacement : match
);
}
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
@@ -249,7 +299,3 @@ function previewValueFor(raw: string, context: Record<string, string>, ignoreEmp
if (value !== undefined) return value;
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -14,17 +14,17 @@ import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps";
const steps: WizardStep[] = [
{ id: "basics", label: "Basics", description: "Name and scenario" },
{ id: "sender", label: "Sender", description: "Mail account and headers" },
{ id: "fields", label: "Fields", description: "Define campaign data" },
{ id: "recipients", label: "Recipients", description: "Import and map source data" },
{ id: "template", label: "Template", description: "Subject and body" },
{ id: "attachments", label: "Attachments", description: "Rules and ZIP options" },
{ id: "review", label: "Review", description: "Validate before build" },
{ id: "send", label: "Send", description: "Test and queue" }
];
{ id: "basics", label: "i18n:govoplan-campaign.basics.5fcebeef", description: "i18n:govoplan-campaign.name_and_scenario.f2bc5241" },
{ id: "sender", label: "i18n:govoplan-campaign.sender.17b874d2", description: "i18n:govoplan-campaign.mail_account_and_headers.9e269243" },
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527", description: "i18n:govoplan-campaign.define_campaign_data.7045c0a4" },
{ id: "recipients", label: "i18n:govoplan-campaign.recipients.78cbf8eb", description: "i18n:govoplan-campaign.import_and_map_source_data.b875d471" },
{ id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06", description: "i18n:govoplan-campaign.subject_and_body.915da39b" },
{ id: "attachments", label: "i18n:govoplan-campaign.attachments.6771ade6", description: "i18n:govoplan-campaign.rules_and_zip_options.e3656774" },
{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", description: "i18n:govoplan-campaign.validate_before_build.27723ad7" },
{ id: "send", label: "i18n:govoplan-campaign.send.9bc2575c", description: "i18n:govoplan-campaign.test_and_queue.c3c940e4" }];
export default function CreateWizard({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
export default function CreateWizard({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const [activeStep, setActiveStep] = useState("basics");
const [localError, setLocalError] = useState("");
const [validationMessage, setValidationMessage] = useState("");
@@ -41,8 +41,8 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
setError: setLocalError,
currentFlow: "create",
currentStep: () => activeStep,
unsavedTitle: "Unsaved wizard changes",
unsavedMessage: "This campaign wizard has unsaved changes. Save them before leaving, or discard them and continue.",
unsavedTitle: "i18n:govoplan-campaign.unsaved_wizard_changes.ba907144",
unsavedMessage: "i18n:govoplan-campaign.this_campaign_wizard_has_unsaved_changes_save_th.7e4be033",
onLoaded: (loadedVersion) => {
if (loadedVersion.current_step && steps.some((step) => step.id === loadedVersion.current_step)) {
setActiveStep(loadedVersion.current_step);
@@ -68,7 +68,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
async function validateCurrentStep() {
if (!version || !draft) return;
setValidationMessage("Validating…");
setValidationMessage("i18n:govoplan-campaign.validating.c07434c9");
try {
const result = await validatePartial(settings, campaignId, version.id, { campaign_json: draft, section: activeStep });
setValidationMessage(`${result.error_count} errors, ${result.warning_count} warnings, ${result.info_count} info messages.`);
@@ -84,9 +84,9 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
<div className="wizard-body standalone-wizard-body">
<div className="wizard-heading">
<div>
<PageTitle>Create campaign</PageTitle>
<PageTitle>i18n:govoplan-campaign.create_campaign.59812bbc</PageTitle>
</div>
<div className="save-state">Locked</div>
<div className="save-state">i18n:govoplan-campaign.locked.a798882f</div>
</div>
<Card>
<LockedVersionNotice
@@ -95,16 +95,16 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
version={data.currentVersion}
currentVersionId={data.campaign?.current_version_id}
reload={reload}
message="This wizard is read-only for the selected version."
/>
message="i18n:govoplan-campaign.this_wizard_is_read_only_for_the_selected_versio.b0865947" />
<div className="button-row">
<Link to="../.."><Button>Back to overview</Button></Link>
<Link to="../.."><Button>i18n:govoplan-campaign.back_to_overview.ec986cba</Button></Link>
</div>
</Card>
</div>
</div>
</div>
);
</div>);
}
return (
@@ -114,7 +114,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
<div className="wizard-body">
<div className="wizard-heading">
<div>
<PageTitle loading={loading}>Create campaign</PageTitle>
<PageTitle loading={loading}>i18n:govoplan-campaign.create_campaign.59812bbc</PageTitle>
</div>
<div className="save-state">{saveState}</div>
</div>
@@ -131,13 +131,13 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
{draft && activeStep === "send" && <SendStep draft={draft} patch={patch} />}
</Card>
<div className="wizard-footer">
<Button onClick={previousStep}>Back</Button>
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "Save now" : "Saved"}</Button>
<Button onClick={validateCurrentStep}>Validate step</Button>
<Button variant="primary" onClick={nextStep}>Continue</Button>
<Button onClick={previousStep}>i18n:govoplan-campaign.back.b52b36b7</Button>
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
<Button onClick={validateCurrentStep}>i18n:govoplan-campaign.validate_step.6a8b527c</Button>
<Button variant="primary" onClick={nextStep}>i18n:govoplan-campaign.continue.2e026239</Button>
</div>
</div>
</div>
</div>
);
</div>);
}

View File

@@ -3,20 +3,20 @@ import { MetricCard } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
export default function ReviewWizard() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Review Wizard</h1>
<div className="content-pad workspace-data-page">
<div className="page-heading workspace-heading">
<h1>i18n:govoplan-campaign.review_wizard.3d8bf0aa</h1>
</div>
<div className="metric-grid">
<MetricCard label="Needs review" value="—" tone="warning" />
<MetricCard label="Missing attachments" value="—" tone="warning" />
<MetricCard label="Ambiguous matches" value="—" tone="info" />
<MetricCard label="Blocked" value="—" tone="danger" />
<MetricCard label="i18n:govoplan-campaign.needs_review.33a506cf" value="—" tone="warning" />
<MetricCard label="i18n:govoplan-campaign.missing_attachments.729ad125" value="—" tone="warning" />
<MetricCard label="i18n:govoplan-campaign.ambiguous_matches.dc658a9c" value="—" tone="info" />
<MetricCard label="i18n:govoplan-campaign.blocked.99613c74" value="—" tone="danger" />
</div>
<Card title="Resolution workflow">
<p className="muted">This wizard will guide users through issues one class at a time.</p>
<Button variant="primary">Start review</Button>
<Card title="i18n:govoplan-campaign.resolution_workflow.708d6c0b">
<p className="muted">i18n:govoplan-campaign.this_wizard_will_guide_users_through_issues_one_.5cb212c3</p>
<Button variant="primary">i18n:govoplan-campaign.start_review.d0bc5cdf</Button>
</Card>
</div>
);
</div>);
}

View File

@@ -2,20 +2,20 @@ import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
export default function SendWizard() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Send Wizard</h1>
<div className="content-pad workspace-data-page">
<div className="page-heading workspace-heading">
<h1>i18n:govoplan-campaign.send_wizard.3c137422</h1>
</div>
<div className="dashboard-grid">
<Card title="Test send">
<p className="muted">Send one generated message to a test address.</p>
<Button>Open test-send dialog</Button>
<Card title="i18n:govoplan-campaign.test_send.03dfff38">
<p className="muted">i18n:govoplan-campaign.send_one_generated_message_to_a_test_address.bc0a4e47</p>
<Button>i18n:govoplan-campaign.open_test_send_dialog.661db713</Button>
</Card>
<Card title="Queue estimate">
<p className="muted">Estimated duration will be based on ready jobs and rate limits.</p>
<Button variant="primary">Queue dry run</Button>
<Card title="i18n:govoplan-campaign.queue_estimate.5480288a">
<p className="muted">i18n:govoplan-campaign.estimated_duration_will_be_based_on_ready_jobs_a.15b63283</p>
<Button variant="primary">i18n:govoplan-campaign.queue_dry_run.800e1606</Button>
</Card>
</div>
</div>
);
</div>);
}

View File

@@ -21,24 +21,24 @@ export function BasicsStep({ draft, patch }: WizardStepProps) {
const campaign = asRecord(draft.campaign);
return (
<div className="form-grid">
<FormField label="Campaign name" help="A human-readable name shown in lists and reports.">
<FormField label="i18n:govoplan-campaign.campaign_name.aa5d0e72" help="i18n:govoplan-campaign.a_human_readable_name_shown_in_lists_and_reports.afc23e7e">
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
</FormField>
<FormField label="Campaign ID" help="Stable technical identifier.">
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e" help="i18n:govoplan-campaign.stable_technical_identifier.28c406b2">
<input value={getText(campaign, "id")} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
</FormField>
<FormField label="Mode">
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
<select value={getText(campaign, "mode", "draft")} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
<option value="draft">Draft</option>
<option value="test">Test</option>
<option value="send">Send</option>
<option value="draft">i18n:govoplan-campaign.draft.23d33e22</option>
<option value="test">i18n:govoplan-campaign.test.640ab2ba</option>
<option value="send">i18n:govoplan-campaign.send.9bc2575c</option>
</select>
</FormField>
<FormField label="Description">
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
</FormField>
</div>
);
</div>);
}
export function SenderStep({ draft, patch }: WizardStepProps) {
@@ -55,86 +55,86 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
const imapAppend = asRecord(delivery.imap_append_sent);
return (
<div className="form-grid">
<FormField label="Default From address">
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
<EmailAddressInput
value={from}
suggestions={suggestions}
allowMultiple
addLabel="Add From"
emptyText="No global From address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses)}
/>
addLabel="i18n:govoplan-campaign.add_from.a095bb50"
emptyText="i18n:govoplan-campaign.no_global_from_address_configured.28141d2b"
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses)} />
</FormField>
<FormField label="Global recipients">
<FormField label="i18n:govoplan-campaign.global_recipients.d9aaa427">
<EmailAddressInput
value={globalTo}
suggestions={suggestions}
allowMultiple
addLabel="Add recipient"
emptyText="No global recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)}
/>
addLabel="i18n:govoplan-campaign.add_recipient.a989d1f1"
emptyText="i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92"
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)} />
</FormField>
<FormField label="CC">
<FormField label="i18n:govoplan-campaign.cc.c5a976de">
<EmailAddressInput
value={globalCc}
suggestions={suggestions}
allowMultiple
addLabel="Add CC"
emptyText="No global CC recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)}
/>
addLabel="i18n:govoplan-campaign.add_cc.bcb39ea3"
emptyText="i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1"
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)} />
</FormField>
<FormField label="BCC">
<FormField label="i18n:govoplan-campaign.bcc.4c0145a3">
<EmailAddressInput
value={globalBcc}
suggestions={suggestions}
allowMultiple
addLabel="Add BCC"
emptyText="No global BCC recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)}
/>
addLabel="i18n:govoplan-campaign.add_bcc.ae9feacc"
emptyText="i18n:govoplan-campaign.no_global_bcc_recipients_configured.86ef64ab"
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)} />
</FormField>
<FormField label="Reply-To">
<FormField label="i18n:govoplan-campaign.reply_to.c1733667">
<EmailAddressInput
value={globalReplyTo.slice(0, 1)}
suggestions={suggestions}
allowMultiple={false}
showAddButton={false}
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"}
emptyText="No Reply-To address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))}
/>
addLabel={globalReplyTo.length ? "i18n:govoplan-campaign.replace.a7cf7b25" : "i18n:govoplan-campaign.add_reply_to.84b195f4"}
emptyText="i18n:govoplan-campaign.no_reply_to_address_configured.0665c1d9"
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))} />
</FormField>
<FormField label="SMTP host"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="SMTP port"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<ToggleSwitch label="Append successful messages to Sent via IMAP" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
);
<FormField label="i18n:govoplan-campaign.smtp_host.2d4a434b"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="i18n:govoplan-campaign.smtp_port.65b5a108"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<ToggleSwitch label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>);
}
export function FieldsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
export function FieldsStep({ draft, patchRoot }: {draft: Record<string, unknown>;patchRoot: (key: string, value: unknown) => void;}) {
return (
<div>
<div className="step-intro">
<h2>Campaign fields</h2>
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p>
<h2>i18n:govoplan-campaign.campaign_fields.969e7d80</h2>
<p>i18n:govoplan-campaign.define_reusable_fields_for_templates_attachment_.6424f892</p>
</div>
<JsonEditor value={draft.fields ?? []} onValid={(value) => patchRoot("fields", value)} />
</div>
);
</div>);
}
export function RecipientsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
export function RecipientsStep({ draft, patchRoot }: {draft: Record<string, unknown>;patchRoot: (key: string, value: unknown) => void;}) {
return (
<div>
<div className="step-intro">
<h2>Recipients</h2>
<p>Store inline recipients or source/mapping configuration. A table editor will replace this JSON editor in the recipient section pass.</p>
<h2>i18n:govoplan-campaign.recipients.78cbf8eb</h2>
<p>i18n:govoplan-campaign.store_inline_recipients_or_source_mapping_config.77bbe98f</p>
</div>
<JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} />
</div>
);
</div>);
}
export function TemplateStep({ draft, patch }: WizardStepProps) {
@@ -142,16 +142,16 @@ export function TemplateStep({ draft, patch }: WizardStepProps) {
return (
<div>
<div className="step-intro">
<h2>Template</h2>
<p>Compose the subject and body. Merge fields can later be inserted from the field picker.</p>
<h2>i18n:govoplan-campaign.template.3ec1ae06</h2>
<p>i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b</p>
</div>
<div className="form-grid">
<FormField label="Subject"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
<FormField label="Plain text body"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
<FormField label="HTML body"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField>
<FormField label="i18n:govoplan-campaign.subject.8d183dbd"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
<FormField label="i18n:govoplan-campaign.html_body.77b5ba37"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField>
</div>
</div>
);
</div>);
}
export function AttachmentsStep({ draft, patch }: WizardStepProps) {
@@ -159,36 +159,36 @@ export function AttachmentsStep({ draft, patch }: WizardStepProps) {
return (
<div>
<div className="step-intro">
<h2>Attachments</h2>
<p>Configure campaign-wide attachment behavior and global matching rules.</p>
<h2>i18n:govoplan-campaign.attachments.6771ade6</h2>
<p>i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2</p>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Campaign attachment base path"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
<FormField label="Missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
<FormField label="i18n:govoplan-campaign.campaign_attachment_base_path.84827619"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
<FormField label="i18n:govoplan-campaign.missing_behavior.de3d1ac7"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="i18n:govoplan-campaign.ambiguous_behavior.e86e724b"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<ToggleSwitch label="i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
</div>
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
</div>
);
</div>);
}
export function ReviewStep({ version, onValidate }: { version: unknown; onValidate: () => void }) {
export function ReviewStep({ version, onValidate }: {version: unknown;onValidate: () => void;}) {
const record = asRecord(version);
return (
<div>
<div className="step-intro">
<h2>Review setup</h2>
<p>Validate the campaign definition before building message drafts.</p>
<h2>i18n:govoplan-campaign.review_setup.d0059acb</h2>
<p>i18n:govoplan-campaign.validate_the_campaign_definition_before_building.d36ee1dc</p>
</div>
<div className="metric-grid inside">
<MetricCard label="Errors" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
<MetricCard label="Warnings" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
<MetricCard label="Built" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
<MetricCard label="i18n:govoplan-campaign.errors.805e86a8" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.built.a6ad3f82" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
</div>
<Button variant="primary" onClick={onValidate}>Validate campaign</Button>
</div>
);
<Button variant="primary" onClick={onValidate}>i18n:govoplan-campaign.validate_campaign.6934c1c2</Button>
</div>);
}
export function SendStep({ draft, patch }: WizardStepProps) {
@@ -197,19 +197,19 @@ export function SendStep({ draft, patch }: WizardStepProps) {
return (
<div>
<div className="step-intro">
<h2>Send preparation</h2>
<p>Configure rate limits and prepare the final send workflow.</p>
<h2>i18n:govoplan-campaign.send_preparation.6e078f43</h2>
<p>i18n:govoplan-campaign.configure_rate_limits_and_prepare_the_final_send.af3d4e48</p>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
</div>
<p className="muted">Test send and queue actions remain in the Send Wizard for now.</p>
</div>
);
<p className="muted">i18n:govoplan-campaign.test_send_and_queue_actions_remain_in_the_send_w.60dee716</p>
</div>);
}
function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unknown) => void }) {
function JsonEditor({ value, onValid }: {value: unknown;onValid: (value: unknown) => void;}) {
const [text, setText] = useState(stringifyJson(value));
const [error, setError] = useState("");
@@ -228,8 +228,8 @@ function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unkno
return (
<div className="json-edit-block">
<textarea rows={12} value={text} onChange={(event) => change(event.target.value)} />
{error ? <p className="form-help danger-text">Invalid JSON: {error}</p> : <p className="form-help">Valid JSON is saved with the wizard draft.</p>}
{Array.isArray(value) && value.length > 0 && <p className="form-help">Preview: {stringifyPreview(asArray(value)[0], 140)}</p>}
</div>
);
{error ? <p className="form-help danger-text">i18n:govoplan-campaign.invalid_json.03f61ae0 {error}</p> : <p className="form-help">i18n:govoplan-campaign.valid_json_is_saved_with_the_wizard_draft.16f5d341</p>}
{Array.isArray(value) && value.length > 0 && <p className="form-help">i18n:govoplan-campaign.preview.4bf30626 {stringifyPreview(asArray(value)[0], 140)}</p>}
</div>);
}

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

@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { ApiSettings, CampaignListItem } from "../../types";
import { getCampaignSummary, listCampaigns, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
@@ -10,7 +9,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { StatusBadge, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
type OperatorRow = {
@@ -25,29 +24,40 @@ type OperatorRow = {
needsAttention: number;
};
export default function OperatorQueuePage({ settings }: { settings: ApiSettings }) {
const navigate = useNavigate();
export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}) {
const navigate = useGuardedNavigate();
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [rows, setRows] = useState<OperatorRow[]>([]);
const campaignsRef = useRef<CampaignListItem[]>([]);
const summariesRef = useRef<Record<string, CampaignSummary | null>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState("");
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey]);
const settingsKey = useMemo(
() => JSON.stringify({
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
accessToken: settings.accessToken
}),
[settings.apiBaseUrl, settings.apiKey, settings.accessToken]
);
useEffect(() => {
campaignsRef.current = [];
summariesRef.current = {};
resetDeltaWatermark();
void load();
}, [settingsKey, resetDeltaWatermark]);
async function load() {
setLoading(true);
setError("");
try {
const campaigns = await listCampaigns(settings);
const summaries = await Promise.all(campaigns.map(async (campaign) => {
try {
return await getCampaignSummary(settings, campaign.id);
} catch {
return null;
}
}));
setRows(campaigns.map((campaign, index) => toRow(campaign, summaries[index] ?? null)));
const campaigns = await loadCampaignsDelta();
const summaries = await loadCampaignSummariesDelta(campaigns);
setRows(campaigns.map((campaign) => toRow(campaign, summaries[campaign.id] ?? null)));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -61,11 +71,12 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
setError("");
setMessage("");
try {
const response = action === "retry"
? await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true })
: await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
const response = action === "retry" ?
await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true }) :
await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
const result = asRecord(response.result ?? response);
setMessage(`${row.campaign.name}: ${humanize(String(result.action ?? action))}, ${String(result.enqueued_count ?? 0)} enqueued.`);
setMessage(i18nMessage("i18n:govoplan-campaign.value_value_value_enqueued.35b33f6d", { value0: row.campaign.name, value1: humanize(String(result.action ?? action)), value2: String(result.enqueued_count ?? 0) }));
resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id));
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -74,49 +85,109 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
}
}
async function loadCampaignsDelta(): Promise<CampaignListItem[]> {
const key = operatorCampaignListKey();
let nextWatermark = getDeltaWatermark(key);
let campaigns = campaignsRef.current;
let hasMore = false;
do {
const response = await listCampaignsDelta(settings, { since: nextWatermark });
campaigns = mergeDeltaRows(campaigns, response.campaigns, response.deleted, (campaign) => campaign.id, {
deletedResourceType: "campaign",
sort: sortCampaignsByUpdatedDesc
});
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
const campaignIds = new Set(campaigns.map((campaign) => campaign.id));
for (const campaignId of Object.keys(summariesRef.current)) {
if (!campaignIds.has(campaignId)) delete summariesRef.current[campaignId];
}
campaignsRef.current = campaigns;
setDeltaWatermark(key, nextWatermark);
return campaigns;
}
async function loadCampaignSummariesDelta(campaigns: CampaignListItem[]): Promise<Record<string, CampaignSummary | null>> {
const summaries = { ...summariesRef.current };
await Promise.all(campaigns.map(async (campaign) => {
const key = operatorCampaignSummaryKey(campaign.id);
let nextWatermark = getDeltaWatermark(key);
let summary = summaries[campaign.id] ?? null;
let hasMore = false;
try {
do {
const response = await getCampaignWorkspaceDelta(settings, campaign.id, {
includeCurrentVersion: false,
includeVersions: false,
includeSummary: true,
since: nextWatermark
});
if (response.full || response.summary) summary = response.summary;
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(key, nextWatermark);
summaries[campaign.id] = summary;
} catch {
summaries[campaign.id] = summary;
}
}));
summariesRef.current = summaries;
return summaries;
}
function operatorCampaignListKey(): string {
return JSON.stringify({ scope: "operator-campaigns", settingsKey });
}
function operatorCampaignSummaryKey(campaignId: string): string {
return JSON.stringify({ scope: "operator-campaign-summary", campaignId, settingsKey });
}
const totals = rows.reduce((acc, row) => ({
failed: acc.failed + row.failed,
outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown,
notAttempted: acc.notAttempted + row.notAttempted,
queuedOrActive: acc.queuedOrActive + row.queuedOrActive,
imapFailed: acc.imapFailed + row.imapFailed,
imapFailed: acc.imapFailed + row.imapFailed
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 0, imapFailed: 0 });
const columns = useMemo<DataGridColumn<OperatorRow>[]>(() => [
{ id: "campaign", header: "Campaign", width: "minmax(260px, 1.2fr)", sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name },
{ id: "status", header: "Status", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status },
{ id: "attention", header: "Attention", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention },
{ id: "failed", header: "Failed", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed },
{ id: "unknown", header: "Unknown", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown },
{ id: "unattempted", header: "Unattempted", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted },
{ id: "queued", header: "Queued/active", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
{ id: "updated", header: "Updated", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.campaign.updated_at), sortValue: (row) => row.campaign.updated_at ?? "" },
{ id: "campaign", header: "i18n:govoplan-campaign.campaign.69390e16", width: "minmax(260px, 1.2fr)", sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status },
{ id: "attention", header: "i18n:govoplan-campaign.attention.74e0b9c8", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention },
{ id: "failed", header: "i18n:govoplan-campaign.failed.09fef5d8", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed },
{ id: "unknown", header: "i18n:govoplan-campaign.unknown.bc7819b3", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown },
{ id: "unattempted", header: "i18n:govoplan-campaign.unattempted.e7411dd6", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted },
{ id: "queued", header: "i18n:govoplan-campaign.queued_active.b08bef73", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.campaign.updated_at), sortValue: (row) => row.campaign.updated_at ?? "" },
{
id: "actions",
header: "Actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 270,
sticky: "end",
render: (row) => (
render: (row) =>
<div className="button-row compact-actions">
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={`Open queue for ${row.campaign.name}`} title={`Open queue for ${row.campaign.name}`}>
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })} title={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })}>
<ExternalLink />
</Button>
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>Retry</Button>
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>Queue unsent</Button>
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>i18n:govoplan-campaign.retry.9f5cd8a2</Button>
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>i18n:govoplan-campaign.queue_unsent.b0e98610</Button>
</div>
)
}
], [busy, navigate]);
}],
[busy, navigate]);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Operator queue</PageTitle>
<p>Queue state, retry candidates and reconciliation entry points across accessible campaigns.</p>
<PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
<p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => void load()} disabled={loading}>Refresh</Button>
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
</div>
</div>
@@ -124,30 +195,30 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">Queue pressure</h2>
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
<QueuePressureGrid items={[
{ label: "Failed", value: totals.failed, tone: "danger" },
{ label: "Outcome unknown", value: totals.outcomeUnknown, tone: "warning" },
{ label: "Unattempted", value: totals.notAttempted, tone: "info" },
{ label: "Queued/active", value: totals.queuedOrActive, tone: "neutral" },
{ label: "IMAP failed", value: totals.imapFailed, tone: "warning" },
]} />
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: totals.failed, tone: "danger" },
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: totals.outcomeUnknown, tone: "warning" },
{ label: "i18n:govoplan-campaign.unattempted.e7411dd6", value: totals.notAttempted, tone: "info" },
{ label: "i18n:govoplan-campaign.queued_active.b08bef73", value: totals.queuedOrActive, tone: "neutral" },
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
} />
</section>
<LoadingFrame loading={loading} label="Loading operator queue">
<Card title="Campaign queues">
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
<Card title="i18n:govoplan-campaign.campaign_queues.657785f4">
<DataGrid
id="operator-queue-campaigns"
rows={rows}
columns={columns}
getRowKey={(row) => row.campaign.id}
emptyText="No accessible campaigns."
className="data-table compact-table"
/>
emptyText="i18n:govoplan-campaign.no_accessible_campaigns.9e080190"
className="data-table compact-table" />
</Card>
</LoadingFrame>
</div>
);
</div>);
}
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
@@ -161,7 +232,7 @@ function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): Ope
queuedOrActive: numberValue(cards.queued_or_active),
imapFailed: numberValue(cards.imap_failed),
queueable: numberValue(cards.queueable),
needsAttention: numberValue(cards.needs_attention),
needsAttention: numberValue(cards.needs_attention)
};
}
@@ -169,12 +240,18 @@ function numberValue(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function QueuePressureGrid({ items }: { items: Array<{ label: string; value: number; tone: "neutral" | "good" | "warning" | "danger" | "info" }> }) {
return (
<div className="metric-grid queue-pressure-grid">
{items.map((item) => (
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
))}
</div>
function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")
);
}
function QueuePressureGrid({ items }: {items: Array<{label: string;value: number;tone: "neutral" | "good" | "warning" | "danger" | "info";}>;}) {
return (
<div className="metric-grid queue-pressure-grid">
{items.map((item) =>
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
)}
</div>);
}

View File

@@ -4,7 +4,8 @@ import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { StatusBadge, i18nMessage } from "@govoplan/core-webui";
import { usePlatformLanguage } from "@govoplan/core-webui";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
@@ -27,91 +28,96 @@ type TemplateDetailSection = "overview" | "content" | "fields" | "preview" | "us
const templateRecords: TemplateRecord[] = [
{
id: "monthly-statement",
name: "Monthly statement",
description: "Reusable subject and body for monthly statement mailings.",
type: "Plain text",
name: "i18n:govoplan-campaign.monthly_statement.74029609",
description: "i18n:govoplan-campaign.reusable_subject_and_body_for_monthly_statement_.9754240d",
type: "plain_text",
status: "ready",
fields: ["recipient_name", "period", "amount"],
updatedAt: "2026-06-08 16:42",
usedBy: "2 campaigns",
subject: "Your statement for {{period}}",
body: "Hello {{recipient_name}},\n\nplease find your statement for {{period}} attached.",
usedBy: "i18n:govoplan-campaign.2_campaigns.35b84804",
subject: "i18n:govoplan-campaign.subject_monthly_statement",
body: "i18n:govoplan-campaign.hello_value_please_find_your_statement_for_value.48d94596",
versions: 4
},
{
id: "deadline-reminder",
name: "Deadline reminder",
description: "Short reminder template with one deadline field.",
type: "Plain text",
name: "i18n:govoplan-campaign.deadline_reminder.84977a7f",
description: "i18n:govoplan-campaign.short_reminder_template_with_one_deadline_field.5f15d110",
type: "plain_text",
status: "draft",
fields: ["recipient_name", "deadline"],
updatedAt: "2026-06-07 11:18",
usedBy: "Not used yet",
subject: "Reminder: {{deadline}}",
body: "Hello {{recipient_name}},\n\nthis is a reminder that the deadline is {{deadline}}.",
usedBy: "i18n:govoplan-campaign.not_used_yet.962b7bbd",
subject: "i18n:govoplan-campaign.subject_deadline_reminder",
body: "i18n:govoplan-campaign.hello_value_this_is_a_reminder_that_the_deadline.c4fbdbd0",
versions: 1
},
{
id: "attachment-notice",
name: "Attachment notice",
description: "Generic note for campaigns where every recipient receives a file.",
type: "HTML-ready",
name: "i18n:govoplan-campaign.attachment_notice.b73a59fe",
description: "i18n:govoplan-campaign.generic_note_for_campaigns_where_every_recipient.f21254fa",
type: "html_ready",
status: "ready",
fields: ["recipient_name", "file_label", "contact_email"],
updatedAt: "2026-06-05 09:05",
usedBy: "1 campaign",
subject: "Documents for {{recipient_name}}",
body: "Hello {{recipient_name}},\n\nyour {{file_label}} is attached. Please contact {{contact_email}} if anything is missing.",
usedBy: "i18n:govoplan-campaign.1_campaign.ccd70074",
subject: "i18n:govoplan-campaign.subject_documents_for_recipient",
body: "i18n:govoplan-campaign.hello_value_your_value_is_attached_please_contac.da32415e",
versions: 3
}
];
}];
const templateSubnav = (onBack: () => void): ModuleSubnavGroup<TemplateDetailSection>[] => [
{
items: [{ actionId: "template-library", label: "← Template library", primary: true, onClick: onBack }]
items: [{ actionId: "template-library", label: "i18n:govoplan-campaign.template_library.6742c898", primary: true, onClick: onBack }]
},
{
title: "TEMPLATE",
title: "i18n:govoplan-campaign.template.4c31d4ef",
items: [
{ id: "overview", label: "Overview" },
{ id: "content", label: "Content" },
{ id: "fields", label: "Fields" },
{ id: "preview", label: "Preview" },
{ id: "usage", label: "Usage" },
{ id: "versions", label: "Versions" }
]
}
];
{ id: "overview", label: "i18n:govoplan-campaign.overview.0efc2e6b" },
{ id: "content", label: "i18n:govoplan-campaign.content.4f9be057" },
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
{ id: "preview", label: "i18n:govoplan-campaign.preview.f1fbb2b4" },
{ id: "usage", label: "i18n:govoplan-campaign.usage.0bb18642" },
{ id: "versions", label: "i18n:govoplan-campaign.versions.a239107e" }]
}];
function templateLibraryColumns(openTemplate: (templateId: string) => void): DataGridColumn<TemplateRecord>[] {
return [
{ id: "template", header: "Template", width: "minmax(240px, 1.4fr)", sortable: true, filterable: true, sticky: "start", render: (template) => <div className="module-title-cell"><strong>{template.name}</strong><span>{template.description}</span></div>, value: (template) => `${template.name} ${template.description}` },
{ id: "type", header: "Type", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Campaign", label: "Campaign" }, { value: "Notification", label: "Notification" }, { value: "Attachment", label: "Attachment" }] }, value: (template) => template.type },
{ id: "fields", header: "Fields", width: 240, filterable: true, render: (template) => <div className="chip-row compact-chip-row">{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}</div>, value: (template) => template.fields.join(", ") },
{ id: "updated", header: "Updated", width: 170, sortable: true, filterable: true, filterType: "date", render: (template) => <span className="muted small-text">{template.updatedAt}</span>, value: (template) => template.updatedAt },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }, { value: "archived", label: "Archived" }], display: "pill" }, render: (template) => <StatusBadge status={template.status} />, value: (template) => template.status },
{ id: "template", header: "i18n:govoplan-campaign.template.3ec1ae06", width: "minmax(240px, 1.4fr)", sortable: true, filterable: true, sticky: "start", render: (template) => <div className="module-title-cell"><strong>{template.name}</strong><span>{template.description}</span></div>, value: (template) => `${template.name} ${template.description}` },
{ id: "type", header: "i18n:govoplan-campaign.type.3deb7456", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "plain_text", label: "i18n:govoplan-campaign.plain_text_template_type" }, { value: "html_ready", label: "i18n:govoplan-campaign.html_ready_template_type" }] }, value: (template) => template.type },
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 240, filterable: true, render: (template) => <div className="chip-row compact-chip-row">{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}</div>, value: (template) => template.fields.join(", ") },
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 170, sortable: true, filterable: true, filterType: "date", render: (template) => <span className="muted small-text">{template.updatedAt}</span>, value: (template) => template.updatedAt },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" }, { value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }], display: "pill" }, render: (template) => <StatusBadge status={template.status} />, value: (template) => template.status },
{
id: "actions",
header: "Actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 70,
sticky: "end",
align: "right",
render: (template) => (
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={`Open ${template.name}`} title={`Open ${template.name}`}>
render: (template) =>
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })} title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })}>
<ExternalLink />
</Button>
)
}
];
}];
}
function templateFieldColumns(): DataGridColumn<{ id: string; field: string; source: string; required: string }>[] {
function templateTypeLabel(type: string): string {
if (type === "html_ready") return "i18n:govoplan-campaign.html_ready_template_type";
return "i18n:govoplan-campaign.plain_text_template_type";
}
function templateFieldColumns(): DataGridColumn<{id: string;field: string;source: string;required: string;}>[] {
return [
{ id: "field", header: "Field", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
{ id: "source", header: "Source", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Detected placeholder", label: "Detected placeholder" }] }, value: (row) => row.source },
{ id: "required", header: "Required", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }] }, value: (row) => row.required },
{ id: "actions", header: "Actions", width: 130, sticky: "end", render: () => <Button disabled>Configure</Button> }
];
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
{ id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source },
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 130, sticky: "end", render: () => <Button disabled>i18n:govoplan-campaign.configure.792c81a4</Button> }];
}
export default function TemplatesPage() {
@@ -139,8 +145,8 @@ export default function TemplatesPage() {
<p>{selectedTemplate.description}</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Duplicate</Button>
<Button variant="primary" disabled>Use in campaign</Button>
<Button disabled>i18n:govoplan-campaign.duplicate.972d5737</Button>
<Button variant="primary" disabled>i18n:govoplan-campaign.use_in_campaign.779163bc</Button>
</div>
</div>
@@ -152,133 +158,144 @@ export default function TemplatesPage() {
{active === "versions" && <TemplateVersions template={selectedTemplate} />}
</div>
</section>
</div>
);
</div>);
}
return (
<div className="content-pad workspace-data-page module-entry-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>Templates</PageTitle>
<p>Reusable message templates. Open a template to edit content, fields, preview and usage.</p>
<PageTitle>i18n:govoplan-campaign.templates.f25b700e</PageTitle>
<p>i18n:govoplan-campaign.reusable_message_templates_open_a_template_to_ed.792e2afb</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Import</Button>
<Button variant="primary" disabled>Export</Button>
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
<Button variant="primary" disabled>i18n:govoplan-campaign.export.f3e4fadb</Button>
</div>
</div>
<Card
title={
<div className="module-card-heading">
<h2>All templates</h2>
<span>Last loaded: local demo data</span>
<h2>i18n:govoplan-campaign.all_templates.0bba114c</h2>
<span>i18n:govoplan-campaign.last_loaded_local_demo_data.62ee2f5d</span>
</div>
}
actions={<Button disabled>Refresh</Button>}
>
actions={<Button disabled>i18n:govoplan-campaign.refresh.56e3badc</Button>}>
<DataGrid
id="template-library"
rows={templateRecords}
columns={templateLibraryColumns(openTemplate)}
getRowKey={(template) => template.id}
emptyText="No templates found."
className="compact-table-wrap module-table-wrap module-entry-table"
/>
emptyText="i18n:govoplan-campaign.no_templates_found.326abcdd"
className="compact-table-wrap module-table-wrap module-entry-table" />
</Card>
</div>
);
</div>);
}
function TemplateOverview({ template }: { template: TemplateRecord }) {
function TemplateOverview({ template }: {template: TemplateRecord;}) {
return (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Template status">
<Card title="i18n:govoplan-campaign.template_status.8bec97cf">
<dl className="detail-list compact-detail-list">
<div><dt>Status</dt><dd><StatusBadge status={template.status} /></dd></div>
<div><dt>Type</dt><dd>{template.type}</dd></div>
<div><dt>Updated</dt><dd>{template.updatedAt}</dd></div>
<div><dt>Versions</dt><dd>{template.versions}</dd></div>
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={template.status} /></dd></div>
<div><dt>i18n:govoplan-campaign.type.3deb7456</dt><dd>{templateTypeLabel(template.type)}</dd></div>
<div><dt>i18n:govoplan-campaign.updated.f2f8570d</dt><dd>{template.updatedAt}</dd></div>
<div><dt>i18n:govoplan-campaign.versions.a239107e</dt><dd>{template.versions}</dd></div>
</dl>
</Card>
<Card title="Compatibility notes">
<p className="muted">Campaigns can reuse this template, but each campaign still needs a field matching check because campaign fields and template placeholders can drift.</p>
<Card title="i18n:govoplan-campaign.compatibility_notes.7d648a6d">
<p className="muted">i18n:govoplan-campaign.campaigns_can_reuse_this_template_but_each_campa.bdb25009</p>
<div className="placeholder-stack">
<span>Subject placeholders are detected</span>
<span>Body placeholders are compared with campaign fields</span>
<span>A mapping wizard can be added later</span>
<span>i18n:govoplan-campaign.subject_placeholders_are_detected.96663276</span>
<span>i18n:govoplan-campaign.body_placeholders_are_compared_with_campaign_fie.bbd1d67f</span>
<span>i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3</span>
</div>
</Card>
</div>
);
</div>);
}
function TemplateContent({ template }: { template: TemplateRecord }) {
function TemplateContent({ template }: {template: TemplateRecord;}) {
const { translateText } = usePlatformLanguage();
const subject = translateText(template.subject);
const body = translateText(template.body);
return (
<Card title="Template content" actions={<Button disabled>Save changes</Button>}>
<Card title="i18n:govoplan-campaign.template_content.48630575" actions={<Button disabled>i18n:govoplan-campaign.save_changes.179359b3</Button>}>
<div className="form-grid compact responsive-form-grid">
<label className="form-field"><FieldLabel className="form-label" help="Read-only subject stored in this reusable template record.">Subject</FieldLabel><input value={template.subject} readOnly /></label>
<label className="form-field full-span"><FieldLabel className="form-label" help="Read-only body stored in this reusable template record.">Body</FieldLabel><textarea rows={10} value={template.body} readOnly /></label>
<label className="form-field"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_subject_stored_in_this_reusable_templa.549ae246">i18n:govoplan-campaign.subject.8d183dbd</FieldLabel><input value={subject} readOnly /></label>
<label className="form-field full-span"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_body_stored_in_this_reusable_template_.98fbf178">i18n:govoplan-campaign.body.718a7e8a</FieldLabel><textarea rows={10} value={body} readOnly /></label>
</div>
</Card>
);
</Card>);
}
function TemplateFields({ template }: { template: TemplateRecord }) {
function TemplateFields({ template }: {template: TemplateRecord;}) {
return (
<Card title="Template fields" actions={<Button disabled>Add field hint</Button>}>
<Card title="i18n:govoplan-campaign.template_fields.e6d7d8ac" actions={<Button disabled>i18n:govoplan-campaign.add_field_hint.b5029047</Button>}>
<DataGrid
id={`template-${template.id}-fields`}
rows={template.fields.map((field) => ({ id: field, field, source: "Detected placeholder", required: "Yes" }))}
rows={template.fields.map((field) => ({ id: field, field, source: "detected_placeholder", required: "yes" }))}
columns={templateFieldColumns()}
getRowKey={(field) => field.id}
emptyText="No fields detected."
className="compact-table-wrap module-table-wrap module-table"
/>
</Card>
);
emptyText="i18n:govoplan-campaign.no_fields_detected.0cb30100"
className="compact-table-wrap module-table-wrap module-table" />
</Card>);
}
function TemplatePreview({ template }: { template: TemplateRecord }) {
const preview = template.body
.replace(/\{\{recipient_name\}\}/g, "Jane Example")
.replace(/\{\{period\}\}/g, "May 2026")
.replace(/\{\{amount\}\}/g, "123.45 EUR")
.replace(/\{\{deadline\}\}/g, "30 June 2026")
.replace(/\{\{file_label\}\}/g, "statement")
.replace(/\{\{contact_email\}\}/g, "support@example.org");
function TemplatePreview({ template }: {template: TemplateRecord;}) {
const { translateText } = usePlatformLanguage();
const subject = translateText(template.subject);
const body = translateText(template.body);
const sampleRecipientName = translateText("i18n:govoplan-campaign.sample_recipient_name");
const samplePeriod = translateText("i18n:govoplan-campaign.sample_period");
const sampleAmount = translateText("i18n:govoplan-campaign.sample_amount");
const sampleDeadline = translateText("i18n:govoplan-campaign.sample_deadline");
const sampleFileLabel = translateText("i18n:govoplan-campaign.sample_file_label");
const preview = body.
replace(/\{\{recipient_name\}\}/g, sampleRecipientName).
replace(/\{\{period\}\}/g, samplePeriod).
replace(/\{\{amount\}\}/g, sampleAmount).
replace(/\{\{deadline\}\}/g, sampleDeadline).
replace(/\{\{file_label\}\}/g, sampleFileLabel).
replace(/\{\{contact_email\}\}/g, "support@example.org");
return (
<Card title="Preview" actions={<Button disabled>Change sample data</Button>}>
<Card title="i18n:govoplan-campaign.preview.f1fbb2b4" actions={<Button disabled>i18n:govoplan-campaign.change_sample_data.e6903542</Button>}>
<div className="message-preview">
<strong>{template.subject.replace(/\{\{period\}\}/g, "May 2026").replace(/\{\{deadline\}\}/g, "30 June 2026").replace(/\{\{recipient_name\}\}/g, "Jane Example")}</strong>
<strong>{subject.replace(/\{\{period\}\}/g, samplePeriod).replace(/\{\{deadline\}\}/g, sampleDeadline).replace(/\{\{recipient_name\}\}/g, sampleRecipientName)}</strong>
<pre>{preview}</pre>
</div>
</Card>
);
</Card>);
}
function TemplateUsage({ template }: { template: TemplateRecord }) {
function TemplateUsage({ template }: {template: TemplateRecord;}) {
return (
<Card title="Usage">
<p className="muted">This view will list campaigns using the template once the backend template model is available.</p>
<Card title="i18n:govoplan-campaign.usage.0bb18642">
<p className="muted">i18n:govoplan-campaign.this_view_will_list_campaigns_using_the_template.116f7ee0</p>
<dl className="detail-list compact-detail-list">
<div><dt>Currently used by</dt><dd>{template.usedBy}</dd></div>
<div><dt>Safe to edit</dt><dd>Requires versioning once templates are shared</dd></div>
<div><dt>i18n:govoplan-campaign.currently_used_by.a065cfbe</dt><dd>{template.usedBy}</dd></div>
<div><dt>i18n:govoplan-campaign.safe_to_edit.57b64df5</dt><dd>i18n:govoplan-campaign.requires_versioning_once_templates_are_shared.e020b221</dd></div>
</dl>
</Card>
);
</Card>);
}
function TemplateVersions({ template }: { template: TemplateRecord }) {
function TemplateVersions({ template }: {template: TemplateRecord;}) {
return (
<Card title="Versions and import/export" actions={<div className="button-row compact-actions"><Button disabled>Import</Button><Button disabled>Export</Button></div>}>
<Card title="i18n:govoplan-campaign.versions_and_import_export.cc05cb43" actions={<div className="button-row compact-actions"><Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button><Button disabled>i18n:govoplan-campaign.export.f3e4fadb</Button></div>}>
<div className="placeholder-stack">
<span>{template.versions} local versions in the planned model</span>
<span>Sent campaigns should keep a fixed template snapshot</span>
<span>Draft campaigns can update to a newer template version later</span>
<span>{template.versions} i18n:govoplan-campaign.local_versions_in_the_planned_model.c1bf26cb</span>
<span>i18n:govoplan-campaign.sent_campaigns_should_keep_a_fixed_template_snap.167d56c2</span>
<span>i18n:govoplan-campaign.draft_campaigns_can_update_to_a_newer_template_v.0f854608</span>
</div>
</Card>
);
</Card>);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,7 @@
import "./styles/campaign-workspace.css";
export { default } from "./module";
export * from "./module";
export * from "./api/campaigns";
export * from "./features/campaigns/policyUi";
export { default as AddressBookPage } from "./features/addressbook/AddressBookPage";
export { default as CampaignListPage } from "./features/campaigns/CampaignListPage";
export { default as CampaignWorkspace } from "./features/campaigns/CampaignWorkspace";
export { default as OperatorQueuePage } from "./features/operator/OperatorQueuePage";

View File

@@ -3,57 +3,57 @@ import ModuleSubnav, { type ModuleSubnavGroup } from "./ModuleSubnav";
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
{
items: [{ id: "overview", label: "Overview", primary: true }]
items: [{ id: "overview", label: "i18n:govoplan-campaign.overview.0efc2e6b", primary: true }]
},
{
title: "CAMPAIGN",
title: "i18n:govoplan-campaign.campaign.9e28fcfc",
items: [
{ id: "fields", label: "Fields" },
{ id: "files", label: "Attachments" },
{ id: "recipients", label: "Sender & Recipients" },
{ id: "recipient-data", label: "Recipient data" },
{ id: "template", label: "Template" }
]
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
{ id: "files", label: "i18n:govoplan-campaign.attachments.6771ade6" },
{ id: "recipients", label: "i18n:govoplan-campaign.sender_recipients.922c6d24" },
{ id: "recipient-data", label: "i18n:govoplan-campaign.recipient_data.c2baaf10" },
{ id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06" }]
},
{
title: "SETTINGS",
title: "i18n:govoplan-campaign.settings.c4b92432",
items: [
{ id: "mail-settings", label: "Mail settings" },
{ id: "global-settings", label: "Campaign settings" }
]
{ id: "mail-settings", label: "i18n:govoplan-campaign.mail_settings.19e07f55" },
{ id: "global-settings", label: "i18n:govoplan-campaign.campaign_settings.efffec26" }]
},
{
title: "POLICIES",
title: "i18n:govoplan-campaign.policies.f03ff937",
items: [
{ id: "mail-policy", label: "Mail policy" },
{ id: "policies", label: "Campaign policies" }
]
{ id: "mail-policy", label: "i18n:govoplan-campaign.mail_policy.3eb5d32a" },
{ id: "policies", label: "i18n:govoplan-campaign.campaign_policies.0b5de1f5" }]
},
{
title: "SEND",
title: "i18n:govoplan-campaign.send.a2469c47",
items: [
{ id: "review", label: "Review & Send" }
]
{ id: "review", label: "i18n:govoplan-campaign.review_send.1627617d" }]
},
{
title: "REPORT",
title: "i18n:govoplan-campaign.report.7b8ddb90",
items: [
{ id: "report", label: "Report" },
{ id: "audit", label: "Audit log" }
]
{ id: "report", label: "i18n:govoplan-campaign.report.ee45c303" },
{ id: "audit", label: "i18n:govoplan-campaign.audit_log.3cfc5f1c" }]
},
{
title: "ADVANCED",
items: [{ id: "json", label: "JSON", subtle: true }]
}
];
title: "i18n:govoplan-campaign.advanced.6052c880",
items: [{ id: "json", label: "i18n:govoplan-campaign.json.031a4e76", subtle: true }]
}];
export default function SectionSidebar({
active,
onSelect
}: {
active: CampaignWorkspaceSection;
onSelect: (section: CampaignWorkspaceSection) => void;
}) {
}: {active: CampaignWorkspaceSection;onSelect: (section: CampaignWorkspaceSection) => void;}) {
return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />;
}

View File

@@ -2,8 +2,9 @@ import { createElement, lazy, useCallback } from "react";
import { useParams } from "react-router-dom";
import { Card, ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui";
import { getCampaign } from "./api/campaigns";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/campaign-workspace.css";
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage"));
@@ -11,37 +12,40 @@ const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
const campaignRead = ["campaigns:campaign:read"];
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const campaignModule: PlatformWebModule = {
id: "campaigns",
label: "Campaigns",
label: "i18n:govoplan-campaign.campaigns.01a23a28",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["files", "mail"],
translations,
navItems: [
{ to: "/campaigns", label: "Campaigns", iconName: "campaign", anyOf: campaignRead, order: 20 },
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignRead, order: 20 },
{
to: "/operator",
label: "Operator Queue",
iconName: "activity",
label: "i18n:govoplan-campaign.operator_queue.ddf23260",
iconName: "radio-tower",
anyOf: operatorScopes,
order: 30
},
{ to: "/reports", label: "Reports", iconName: "reports", anyOf: ["campaigns:report:read"], order: 70 },
{ to: "/address-book", label: "Address Book", iconName: "users", order: 80 },
{ to: "/templates", label: "Templates", iconName: "form", order: 90 }
],
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }],
routes: [
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
{ path: "/address-book", order: 80, render: () => createElement(AddressBookPage) },
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }
]
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
};
function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
function CampaignResourceRoute({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const { campaignId = "" } = useParams();
const tenantId = (auth.active_tenant ?? auth.tenant).id;
const probe = useCallback(() => getCampaign(settings, campaignId), [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, tenantId]);
@@ -57,14 +61,14 @@ function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth
function ReportsPage() {
return createElement(
"div",
{ className: "content-pad" },
{ className: "content-pad workspace-data-page" },
createElement(
"div",
{ className: "page-heading" },
createElement("h1", null, "Reports"),
createElement("p", null, "This module is prepared but not implemented yet.")
{ className: "page-heading workspace-heading" },
createElement("h1", null, "i18n:govoplan-campaign.reports"),
createElement("p", null, "i18n:govoplan-campaign.reports_module_prepared")
),
createElement(Card, null, createElement("p", { className: "muted" }, "Next passes will add functionality here."))
createElement(Card, null, createElement("p", { className: "muted" }, "i18n:govoplan-campaign.next_passes_will_add_functionality_here"))
);
}

Some files were not shown because too many files have changed in this diff Show More