diff --git a/.gitignore b/.gitignore index 598efec..8aa86d8 100644 --- a/.gitignore +++ b/.gitignore @@ -331,3 +331,4 @@ cython_debug/ # GovOPlaN WebUI test output webui/.policy-test-build/ webui/.template-preview-test-build/ +webui/.import-test-build/ diff --git a/README.md b/README.md index 0674c49..28b69de 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,11 @@ 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 diff --git a/dev/mail-testbed/.env.example b/dev/mail-testbed/.env.example new file mode 100644 index 0000000..fa566e4 --- /dev/null +++ b/dev/mail-testbed/.env.example @@ -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 diff --git a/dev/mail-testbed/README.md b/dev/mail-testbed/README.md new file mode 100644 index 0000000..59b41d2 --- /dev/null +++ b/dev/mail-testbed/README.md @@ -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 +``` diff --git a/dev/mail-testbed/docker-compose.yml b/dev/mail-testbed/docker-compose.yml new file mode 100644 index 0000000..9aedd4e --- /dev/null +++ b/dev/mail-testbed/docker-compose.yml @@ -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" diff --git a/dev/mail-testbed/run_transport_smoke.py b/dev/mail-testbed/run_transport_smoke.py new file mode 100644 index 0000000..f36de61 --- /dev/null +++ b/dev/mail-testbed/run_transport_smoke.py @@ -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()) diff --git a/docs/CAMPAIGN_DELIVERY_RUNBOOK.md b/docs/CAMPAIGN_DELIVERY_RUNBOOK.md index caa00f9..02b134e 100644 --- a/docs/CAMPAIGN_DELIVERY_RUNBOOK.md +++ b/docs/CAMPAIGN_DELIVERY_RUNBOOK.md @@ -15,6 +15,8 @@ been validated, built, reviewed, and locked. ## 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. @@ -23,6 +25,23 @@ been validated, built, reviewed, and locked. - 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. @@ -73,5 +92,7 @@ bed where possible: - Accepted and unknown jobs must not appear in retry selections. - Reconciled accepted jobs must remain protected from resend. - Reconciled not-sent jobs must appear only as explicit retry candidates. -- The final report should include SMTP attempts, IMAP append attempts, and any - reconciliation notes before the campaign is considered operationally closed. +- 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. diff --git a/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md b/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..6b6c489 --- /dev/null +++ b/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md @@ -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. diff --git a/docs/RECIPIENT_ADDRESS_BOUNDARY.md b/docs/RECIPIENT_ADDRESS_BOUNDARY.md new file mode 100644 index 0000000..e6e9193 --- /dev/null +++ b/docs/RECIPIENT_ADDRESS_BOUNDARY.md @@ -0,0 +1,64 @@ +# Recipient And Address Management Boundary + +Campaigns currently own campaign-local recipient entries because sending and +reporting need a frozen recipient snapshot. Long-lived address management is a +separate domain and should move to `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` Should Own + +- 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 should provide stable DTOs and capabilities that campaigns, +mail, forms, reporting, portal, and postbox modules can consume without direct +imports. + +## Integration Contract + +The campaign module should ask the platform whether the addresses module is +installed. When present, campaign can offer address-source choices through a +capability such as `addresses.recipientSource`. + +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. + +## 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. diff --git a/docs/RECIPIENT_IMPORT_GUIDE.md b/docs/RECIPIENT_IMPORT_GUIDE.md new file mode 100644 index 0000000..d672e03 --- /dev/null +++ b/docs/RECIPIENT_IMPORT_GUIDE.md @@ -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 today: +reusable address books and Adrema-style address management belong in the future +`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 until + `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. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5487722 --- /dev/null +++ b/examples/README.md @@ -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`. diff --git a/package.json b/package.json index b407f66..b0d013a 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" diff --git a/pyproject.toml b/pyproject.toml index 9879fe5..37c565a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "jsonschema>=4,<5", "pydantic>=2,<3", "SQLAlchemy>=2,<3", - "pyzipper>=0.3,<1", + "pyzipper>=0.4,<1", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_campaign/backend/change_tracking.py b/src/govoplan_campaign/backend/change_tracking.py new file mode 100644 index 0000000..f913767 --- /dev/null +++ b/src/govoplan_campaign/backend/change_tracking.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import event, inspect +from sqlalchemy.orm import Session as OrmSession + +from govoplan_core.core.change_sequence import record_change +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 = inspect(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 = inspect(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: + state = inspect(obj) + if state.pending: + return "created" + if state.deleted: + return "deleted" + if not _has_attr_changes(state, changed_attrs): + return None + return "updated" + + +def _has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool: + return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs) + + +def _previous_value(obj: object, attr_name: str) -> str | None: + state = inspect(obj) + if attr_name not in state.attrs: + return None + history = state.attrs[attr_name].history + if not history.has_changes() or not history.deleted: + return None + value = history.deleted[0] + return str(value) if value is not None else None + + +def _ensure_id(obj: object) -> str: + resource_id = getattr(obj, "id", None) + if resource_id: + return str(resource_id) + resource_id = new_uuid() + setattr(obj, "id", resource_id) + return resource_id + + +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", +] diff --git a/src/govoplan_campaign/backend/db/models.py b/src/govoplan_campaign/backend/db/models.py index cc097fe..3d4b6e5 100644 --- a/src/govoplan_campaign/backend/db/models.py +++ b/src/govoplan_campaign/backend/db/models.py @@ -108,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(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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) @@ -130,12 +130,12 @@ 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(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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) @@ -148,8 +148,8 @@ class RecipientImportMappingProfile(Base, TimestampMixin): ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) - owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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) @@ -199,7 +199,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 @@ -207,7 +207,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) @@ -223,7 +223,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(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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) @@ -263,7 +263,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(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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) @@ -279,7 +279,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(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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)) @@ -291,8 +291,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(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), 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)) diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 5fafe3b..b155e93 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -12,8 +12,11 @@ from govoplan_core.core.campaigns import ( from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.db.base import Base +from govoplan_campaign.backend.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) diff --git a/src/govoplan_campaign/backend/migrations/versions/2c3d4e5f7081_recipient_import_mapping_profiles.py b/src/govoplan_campaign/backend/migrations/versions/2c3d4e5f7081_recipient_import_mapping_profiles.py index 5e12ed3..7afeed5 100644 --- a/src/govoplan_campaign/backend/migrations/versions/2c3d4e5f7081_recipient_import_mapping_profiles.py +++ b/src/govoplan_campaign/backend/migrations/versions/2c3d4e5f7081_recipient_import_mapping_profiles.py @@ -1,7 +1,7 @@ """recipient import mapping profiles Revision ID: 2c3d4e5f7081 -Revises: 1b2c3d4e5f70 +Revises: 2e3f4a5b6c7d Create Date: 2026-06-26 00:00:00.000000 """ from __future__ import annotations @@ -11,7 +11,7 @@ import sqlalchemy as sa revision = "2c3d4e5f7081" -down_revision = "1b2c3d4e5f70" +down_revision = "2e3f4a5b6c7d" branch_labels = None depends_on = None @@ -38,8 +38,8 @@ def upgrade() -> None: sa.Column("mappings", sa.JSON(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_owner_user_id_users"), ondelete="CASCADE"), - sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_tenants"), ondelete="CASCADE"), + sa.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"], ["tenancy_tenants.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_tenants"), ondelete="CASCADE"), sa.PrimaryKeyConstraint("id", name=op.f("pk_campaign_recipient_import_mapping_profiles")), ) op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), "campaign_recipient_import_mapping_profiles", ["tenant_id"], unique=False) diff --git a/src/govoplan_campaign/backend/reports/campaigns.py b/src/govoplan_campaign/backend/reports/campaigns.py index af7c0bc..dbe97df 100644 --- a/src/govoplan_campaign/backend/reports/campaigns.py +++ b/src/govoplan_campaign/backend/reports/campaigns.py @@ -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, *, @@ -401,13 +494,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 +549,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) diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index 8a64022..029d9b8 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field from govoplan_campaign.backend.schemas import ( BuildCampaignRequest, + CampaignDeltaResponse, CampaignCreateRequest, CampaignUpdateRequest, CampaignCreateResponse, @@ -23,6 +24,7 @@ from govoplan_campaign.backend.schemas import ( RecipientImportMappingProfilePayload, RecipientImportMappingProfileResponse, CampaignJobsResponse, + CampaignJobsDeltaResponse, CampaignJobDetailResponse, CampaignRetryJobsRequest, CampaignSendUnattemptedRequest, @@ -31,6 +33,7 @@ from govoplan_campaign.backend.schemas import ( CampaignResponse, CampaignVersionDetailResponse, CampaignVersionResponse, + CampaignWorkspaceDeltaResponse, CampaignWorkspaceResponse, CampaignVersionSetStepRequest, CampaignReviewStateRequest, @@ -41,11 +44,39 @@ from govoplan_campaign.backend.schemas import ( ReportEmailRequest, ReportEmailResponse, ) -from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_scope +from govoplan_access.auth import ApiPrincipal, has_scope, require_scope from govoplan_core.audit.logging import audit_from_principal from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory +from govoplan_core.core.change_sequence import ( + decode_sequence_watermark, + encode_sequence_watermark, + max_sequence_id, + sequence_entries_since, + sequence_watermark_is_expired, +) +from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint from govoplan_core.core.runtime import get_registry -from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, ImapAppendAttempt, RecipientImportMappingProfile, SendAttempt +from govoplan_core.api.v1.schemas import DeltaDeletedItem +from govoplan_campaign.backend.change_tracking import ( + CAMPAIGNS_COLLECTION, + CAMPAIGNS_MODULE_ID, + CAMPAIGN_ATTEMPTS_COLLECTION, + CAMPAIGN_ISSUES_COLLECTION, + CAMPAIGN_JOBS_COLLECTION, + CAMPAIGN_VERSIONS_COLLECTION, +) +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignIssue, + CampaignJob, + CampaignShare, + CampaignStatus, + CampaignVersion, + CampaignVersionWorkflowState, + ImapAppendAttempt, + RecipientImportMappingProfile, + SendAttempt, +) from govoplan_core.db.session import get_session from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email @@ -77,9 +108,12 @@ from govoplan_campaign.backend.persistence.versions import ( update_campaign_review_state, validate_campaign_partial, ) +from govoplan_campaign.backend.sending.execution import clear_execution_snapshot router = APIRouter(prefix="/campaigns", tags=["campaigns"]) +CAMPAIGN_JOBS_CURSOR_SCOPE = "campaign.jobs" + def _access_directory() -> AccessDirectory: registry = get_registry() @@ -192,6 +226,14 @@ def _require_permission(principal: ApiPrincipal, scope: str) -> None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") +def _campaign_query_for_principal(session: Session, principal: ApiPrincipal): + query = session.query(Campaign).filter(Campaign.tenant_id == principal.tenant_id, Campaign.status != "deleted") + acl_filter = _campaign_acl_filter(session, principal) + if acl_filter is not None: + query = query.filter(acl_filter) + return query + + def _get_recipient_import_profile_for_principal(session: Session, profile_id: str, principal: ApiPrincipal) -> RecipientImportMappingProfile: profile = session.get(RecipientImportMappingProfile, profile_id) if not profile or profile.tenant_id != principal.tenant_id or profile.owner_user_id != principal.user.id: @@ -292,6 +334,75 @@ def _sync_campaign_metadata_to_current_version(session: Session, campaign: Campa session.add(version) +def _clear_current_version_mail_profile_for_owner_transfer(session: Session, campaign: Campaign) -> bool: + """Force explicit profile reselection after campaign ownership changes. + + User/group-scoped reusable mail profiles are evaluated against the current + owner. Instead of trying to keep a stale selection across an ownership + transfer, clear the profile from the editable current version and invalidate + validation/build state so the operator has to reselect and revalidate. + """ + + if not campaign.current_version_id: + return False + + version = session.get(CampaignVersion, campaign.current_version_id) + if not version or version.campaign_id != campaign.id: + return False + + raw_json = copy.deepcopy(version.raw_json if isinstance(version.raw_json, dict) else {}) + server = raw_json.get("server") if isinstance(raw_json.get("server"), dict) else None + if not isinstance(server, dict): + return False + + profile_id = _campaign_mail_profile_id(raw_json) + if not profile_id: + return False + + if is_version_final_locked(version) or is_user_locked_version(version): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Change owner only after creating an editable campaign version; the current version has a selected mail profile and is locked.", + ) + + next_server = dict(server) + next_server.pop("mail_profile_id", None) + next_server.pop("profile_id", None) + raw_json["server"] = next_server + + version.raw_json = raw_json + version.validation_summary = None + version.build_summary = None + clear_execution_snapshot(version) + version.locked_at = None + version.locked_by_user_id = None + version.workflow_state = CampaignVersionWorkflowState.EDITING.value + version.is_complete = False + + editor_state = copy.deepcopy(version.editor_state or {}) + editor_state.pop("review_send", None) + version.editor_state = editor_state + + session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False) + session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False) + campaign.status = CampaignStatus.DRAFT.value + session.add(version) + _write_current_version_snapshot_if_available(version) + return True + + +def _write_current_version_snapshot_if_available(version: CampaignVersion) -> None: + try: + from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot + + _write_campaign_snapshot(version) + except Exception: + # The database state is authoritative for the WebUI. Snapshot writing is + # best-effort here because ownership changes should not fail due to an + # unavailable local runtime directory. + return + + @router.post("", response_model=CampaignCreateResponse) def create_campaign( payload: CampaignCreateRequest, @@ -322,7 +433,7 @@ def create_campaign( except HTTPException: raise except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version)) @@ -361,7 +472,7 @@ def create_minimal_campaign_endpoint( ) return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version)) except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @@ -370,14 +481,145 @@ def list_campaigns( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), ): - query = session.query(Campaign).filter(Campaign.tenant_id == principal.tenant_id, Campaign.status != "deleted") - acl_filter = _campaign_acl_filter(session, principal) - if acl_filter is not None: - query = query.filter(acl_filter) - campaigns = query.order_by(Campaign.updated_at.desc()).all() + campaigns = _campaign_query_for_principal(session, principal).order_by(Campaign.updated_at.desc()).all() return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns]) +_CAMPAIGN_LIST_DELTA_COLLECTIONS = (CAMPAIGNS_COLLECTION,) +_CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS = ( + CAMPAIGNS_COLLECTION, + CAMPAIGN_VERSIONS_COLLECTION, + CAMPAIGN_JOBS_COLLECTION, + CAMPAIGN_ISSUES_COLLECTION, + CAMPAIGN_ATTEMPTS_COLLECTION, +) + + +def _campaign_delta_watermark(session: Session, tenant_id: str, collections: tuple[str, ...]) -> str: + return encode_sequence_watermark( + max_sequence_id( + session, + tenant_id=tenant_id, + module_id=CAMPAIGNS_MODULE_ID, + collections=collections, + ) + ) + + +def _campaign_entry_matches_principal(session: Session, principal: ApiPrincipal, payload: dict[str, object]) -> bool: + if has_scope(principal, "tenant:*"): + return True + user_ids = { + value + for value in (payload.get("owner_user_id"), payload.get("previous_owner_user_id")) + if isinstance(value, str) + } + if principal.user.id in user_ids: + return True + group_ids = _principal_group_ids(session, principal) + owner_group_ids = { + value + for value in (payload.get("owner_group_id"), payload.get("previous_owner_group_id")) + if isinstance(value, str) + } + if owner_group_ids.intersection(group_ids): + return True + share_target_type = payload.get("share_target_type") + share_target_id = payload.get("share_target_id") + if share_target_type == "user" and share_target_id == principal.user.id: + return True + if share_target_type == "group" and isinstance(share_target_id, str) and share_target_id in group_ids: + return True + return False + + +def _campaign_list_full_delta_response(session: Session, principal: ApiPrincipal) -> CampaignDeltaResponse: + campaigns = _campaign_query_for_principal(session, principal).order_by(Campaign.updated_at.desc()).all() + return CampaignDeltaResponse( + campaigns=[CampaignResponse.model_validate(item) for item in campaigns], + deleted=[], + watermark=_campaign_delta_watermark(session, principal.tenant_id, _CAMPAIGN_LIST_DELTA_COLLECTIONS), + has_more=False, + full=True, + ) + + +def _campaign_list_delta_response(session: Session, principal: ApiPrincipal, *, since: str, limit: int) -> CampaignDeltaResponse: + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if sequence_watermark_is_expired( + session, + since=since_sequence, + tenant_id=principal.tenant_id, + module_id=CAMPAIGNS_MODULE_ID, + collections=_CAMPAIGN_LIST_DELTA_COLLECTIONS, + ): + return _campaign_list_full_delta_response(session, principal) + + entries_plus_one = sequence_entries_since( + session, + since=since_sequence, + tenant_id=principal.tenant_id, + module_id=CAMPAIGNS_MODULE_ID, + collections=_CAMPAIGN_LIST_DELTA_COLLECTIONS, + limit=limit + 1, + ) + has_more = len(entries_plus_one) > limit + entries = entries_plus_one[:limit] + changed_ids = list(dict.fromkeys(entry.resource_id for entry in entries if entry.resource_type == "campaign")) + visible_campaigns = { + campaign.id: campaign + for campaign in ( + _campaign_query_for_principal(session, principal) + .filter(Campaign.id.in_(changed_ids)) + .order_by(Campaign.updated_at.desc()) + .all() + if changed_ids + else [] + ) + } + deleted: dict[tuple[str, str], DeltaDeletedItem] = {} + for entry in entries: + if entry.resource_type != "campaign" or entry.resource_id in visible_campaigns: + continue + payload = entry.payload or {} + if not _campaign_entry_matches_principal(session, principal, payload): + continue + deleted[(entry.resource_type, entry.resource_id)] = DeltaDeletedItem( + id=entry.resource_id, + resource_type=entry.resource_type, + revision=encode_sequence_watermark(entry.id), + deleted_at=entry.created_at if entry.operation == "deleted" else None, + ) + + watermark = ( + encode_sequence_watermark(entries[-1].id) + if has_more and entries + else _campaign_delta_watermark(session, principal.tenant_id, _CAMPAIGN_LIST_DELTA_COLLECTIONS) + ) + return CampaignDeltaResponse( + campaigns=[CampaignResponse.model_validate(item) for item in visible_campaigns.values()], + deleted=list(deleted.values()), + watermark=watermark, + has_more=has_more, + full=False, + ) + + +@router.get("/delta", response_model=CampaignDeltaResponse) +def list_campaigns_delta( + since: str | None = None, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + if since is None: + return _campaign_list_full_delta_response(session, principal) + return _campaign_list_delta_response(session, principal, since=since, limit=limit) + + @router.get("/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse) def list_recipient_import_mapping_profiles( session: Session = Depends(get_session), @@ -490,16 +732,16 @@ def get_campaign( return CampaignResponse.model_validate(_get_campaign_for_principal(session, campaign_id, principal)) -@router.get("/{campaign_id}/workspace", response_model=CampaignWorkspaceResponse) -def get_campaign_workspace( +def _campaign_workspace_response( + session: Session, + principal: ApiPrincipal, + *, campaign_id: str, - version_id: str | None = None, - include_current_version: bool = True, - include_summary: bool = False, - include_versions: bool = True, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), -): + version_id: str | None, + include_current_version: bool, + include_summary: bool, + include_versions: bool, +) -> CampaignWorkspaceResponse: campaign = _get_campaign_for_principal(session, campaign_id, principal) versions: list[CampaignVersion] = [] @@ -546,6 +788,223 @@ def get_campaign_workspace( ) +@router.get("/{campaign_id}/workspace", response_model=CampaignWorkspaceResponse) +def get_campaign_workspace( + campaign_id: str, + version_id: str | None = None, + include_current_version: bool = True, + include_summary: bool = False, + include_versions: bool = True, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + return _campaign_workspace_response( + session, + principal, + campaign_id=campaign_id, + version_id=version_id, + include_current_version=include_current_version, + include_summary=include_summary, + include_versions=include_versions, + ) + + +def _workspace_delta_deleted_item(entry) -> DeltaDeletedItem: + return DeltaDeletedItem( + id=entry.resource_id, + resource_type=entry.resource_type, + revision=encode_sequence_watermark(entry.id), + deleted_at=entry.created_at if entry.operation == "deleted" else None, + ) + + +def _entry_belongs_to_campaign(entry, campaign_id: str) -> bool: + payload = entry.payload or {} + if payload.get("campaign_id") == campaign_id: + return True + return entry.resource_type == "campaign" and entry.resource_id == campaign_id + + +def _campaign_workspace_full_delta_response( + session: Session, + principal: ApiPrincipal, + *, + campaign_id: str, + version_id: str | None, + include_current_version: bool, + include_summary: bool, + include_versions: bool, +) -> CampaignWorkspaceDeltaResponse: + payload = _campaign_workspace_response( + session, + principal, + campaign_id=campaign_id, + version_id=version_id, + include_current_version=include_current_version, + include_summary=include_summary, + include_versions=include_versions, + ) + return CampaignWorkspaceDeltaResponse( + **payload.model_dump(), + deleted=[], + watermark=_campaign_delta_watermark(session, principal.tenant_id, _CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS), + has_more=False, + full=True, + ) + + +def _campaign_workspace_delta_response( + session: Session, + principal: ApiPrincipal, + *, + campaign_id: str, + version_id: str | None, + include_current_version: bool, + include_summary: bool, + include_versions: bool, + since: str, + limit: int, +) -> CampaignWorkspaceDeltaResponse: + campaign = _get_campaign_for_principal(session, campaign_id, principal) + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if sequence_watermark_is_expired( + session, + since=since_sequence, + tenant_id=principal.tenant_id, + module_id=CAMPAIGNS_MODULE_ID, + collections=_CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS, + ): + return _campaign_workspace_full_delta_response( + session, + principal, + campaign_id=campaign_id, + version_id=version_id, + include_current_version=include_current_version, + include_summary=include_summary, + include_versions=include_versions, + ) + + entries_plus_one = sequence_entries_since( + session, + since=since_sequence, + tenant_id=principal.tenant_id, + module_id=CAMPAIGNS_MODULE_ID, + collections=_CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS, + limit=limit + 1, + ) + has_more = len(entries_plus_one) > limit + entries = entries_plus_one[:limit] + relevant_entries = [entry for entry in entries if _entry_belongs_to_campaign(entry, campaign.id)] + + changed_campaign = any(entry.resource_type == "campaign" for entry in relevant_entries) + changed_version_ids = { + entry.resource_id + for entry in relevant_entries + if entry.resource_type == "campaign_version" and entry.operation != "deleted" + } + deleted = [ + _workspace_delta_deleted_item(entry) + for entry in relevant_entries + if entry.operation == "deleted" and entry.resource_type != "campaign" + ] + selected_version_id = version_id or campaign.current_version_id + changed_current_version = bool(selected_version_id and selected_version_id in changed_version_ids) + summary_invalidated = any( + entry.collection in {CAMPAIGN_JOBS_COLLECTION, CAMPAIGN_ISSUES_COLLECTION, CAMPAIGN_ATTEMPTS_COLLECTION} + for entry in relevant_entries + ) + + campaign_payload = CampaignResponse.model_validate(campaign) if changed_campaign else None + version_payload: list[CampaignVersionResponse] = [] + if include_versions and changed_version_ids: + versions = ( + session.query(CampaignVersion) + .filter(CampaignVersion.campaign_id == campaign.id, CampaignVersion.id.in_(changed_version_ids)) + .order_by(CampaignVersion.version_number.desc()) + .all() + ) + version_payload = [CampaignVersionResponse.model_validate(item) for item in versions] + + current_version_payload: CampaignVersionDetailResponse | None = None + if include_current_version and selected_version_id and (changed_campaign or changed_current_version): + _require_permission(principal, "campaigns:recipient:read") + current_version = ( + session.query(CampaignVersion) + .filter(CampaignVersion.id == selected_version_id, CampaignVersion.campaign_id == campaign.id) + .one_or_none() + ) + if current_version is not None: + current_version_payload = CampaignVersionDetailResponse.model_validate(current_version) + + summary_payload: dict[str, object] | None = None + if include_summary and (changed_campaign or changed_version_ids or summary_invalidated): + try: + summary_payload = generate_campaign_report( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=selected_version_id, + include_jobs=False, + ) + except CampaignReportError: + summary_payload = None + + watermark = ( + encode_sequence_watermark(entries[-1].id) + if has_more and entries + else _campaign_delta_watermark(session, principal.tenant_id, _CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS) + ) + return CampaignWorkspaceDeltaResponse( + campaign=campaign_payload, + versions=version_payload, + current_version=current_version_payload, + summary=summary_payload, + selected_version_id=selected_version_id, + deleted=deleted, + watermark=watermark, + has_more=has_more, + full=False, + ) + + +@router.get("/{campaign_id}/workspace/delta", response_model=CampaignWorkspaceDeltaResponse) +def get_campaign_workspace_delta( + campaign_id: str, + version_id: str | None = None, + include_current_version: bool = True, + include_summary: bool = False, + include_versions: bool = True, + since: str | None = None, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + if since is None: + return _campaign_workspace_full_delta_response( + session, + principal, + campaign_id=campaign_id, + version_id=version_id, + include_current_version=include_current_version, + include_summary=include_summary, + include_versions=include_versions, + ) + return _campaign_workspace_delta_response( + session, + principal, + campaign_id=campaign_id, + version_id=version_id, + include_current_version=include_current_version, + include_summary=include_summary, + include_versions=include_versions, + since=since, + limit=limit, + ) + + @router.put("/{campaign_id}", response_model=CampaignResponse) def update_campaign_metadata_endpoint( campaign_id: str, @@ -557,7 +1016,7 @@ def update_campaign_metadata_endpoint( if payload.external_id is not None: value = payload.external_id.strip() if not value: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign ID cannot be empty") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Campaign ID cannot be empty") duplicate = ( session.query(Campaign) .filter(Campaign.tenant_id == principal.tenant_id, Campaign.external_id == value, Campaign.id != campaign.id) @@ -569,7 +1028,7 @@ def update_campaign_metadata_endpoint( if payload.name is not None: value = payload.name.strip() if not value: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign name cannot be empty") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Campaign name cannot be empty") campaign.name = value if payload.status is not None: campaign.status = payload.status @@ -908,7 +1367,7 @@ def update_version_detail( except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/versions/{version_id}/autosave", response_model=CampaignVersionDetailResponse) @@ -955,7 +1414,7 @@ def autosave_version( except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/versions/{version_id}/set-step", response_model=CampaignVersionDetailResponse) @@ -1020,7 +1479,7 @@ def set_version_review_state( except LockedCampaignVersionError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse) @@ -1123,7 +1582,7 @@ def validate_version( except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/versions/{version_id}/build") @@ -1156,7 +1615,7 @@ def build_version( except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc def _job_review_key(job: CampaignJob) -> str: @@ -1176,6 +1635,7 @@ def _job_summary_payload( "entry_id": job.entry_id, "recipient_email": job.recipient_email, "subject": job.subject, + "message_id_header": job.message_id_header, "build_status": job.build_status, "validation_status": job.validation_status, "queue_status": job.queue_status, @@ -1190,6 +1650,8 @@ def _job_summary_payload( "smtp_started_at": job.smtp_started_at, "outcome_unknown_at": job.outcome_unknown_at, "sent_at": job.sent_at, + "created_at": job.created_at, + "updated_at": job.updated_at, "issues_count": len(job.issues_snapshot or []), "attachment_count": len(job.resolved_attachments or []), "review_key": review_key, @@ -1283,27 +1745,19 @@ def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[st return result -@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse) -def list_jobs( +def _campaign_jobs_query_context( + session: Session, + principal: ApiPrincipal, + *, campaign_id: str, - version_id: str | None = None, - page: int = Query(default=1, ge=1), - page_size: int = Query(default=50, ge=1, le=200), - send_status: list[str] | None = Query(default=None), - validation_status: list[str] | None = Query(default=None), - imap_status: list[str] | None = Query(default=None), - query_text: str | None = Query(default=None, alias="q", max_length=200), - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), -): + version_id: str | None, + send_status: list[str] | None, + validation_status: list[str] | None, + imap_status: list[str] | None, + query_text: str | None, +) -> tuple[Campaign, list[object], list[object], dict[str, object], set[str]]: _get_campaign_for_principal(session, campaign_id, principal) _require_permission(principal, "campaigns:recipient:read") - """Return a lightweight, paginated job list with server-side filters. - - Complete recipients, attachment metadata, issues and attempt history are - available from the separate job-detail endpoint. - """ - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) base_filters: list[object] = [CampaignJob.campaign_id == campaign.id, CampaignJob.tenant_id == principal.tenant_id] selected_version: CampaignVersion | None = None @@ -1329,18 +1783,73 @@ def list_jobs( CampaignJob.subject.ilike(pattern), CampaignJob.entry_id.ilike(pattern), )) + return campaign, base_filters, filtered, review_metadata, reviewed_keys + +def _campaign_jobs_page_response( + session: Session, + *, + campaign_id: str, + version_id: str | None, + base_filters: list[object], + filtered: list[object], + reviewed_keys: set[str], + review_metadata: dict[str, object], + page: int, + page_size: int, + send_status: list[str] | None = None, + validation_status: list[str] | None = None, + imap_status: list[str] | None = None, + query_text: str | None = None, + cursor: str | None = None, + changed_job_ids: set[str] | None = None, +) -> CampaignJobsResponse: total_unfiltered = int(session.query(func.count(CampaignJob.id)).filter(*base_filters).scalar() or 0) total = int(session.query(func.count(CampaignJob.id)).filter(*filtered).scalar() or 0) pages = (total + page_size - 1) // page_size if total else 0 - jobs = ( - session.query(CampaignJob) - .filter(*filtered) + fingerprint = _campaign_jobs_cursor_fingerprint( + campaign_id=campaign_id, + version_id=version_id, + page_size=page_size, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + ) + ordered_query = session.query(CampaignJob).filter(*filtered).order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc()) + start_cursor: str | None = None + effective_offset = 0 + if cursor: + try: + cursor_values = decode_keyset_cursor(CAMPAIGN_JOBS_CURSOR_SCOPE, cursor, fingerprint=fingerprint) + if cursor_values is None: + raise KeysetCursorError("Invalid pagination cursor") + page_query = session.query(CampaignJob).filter(*filtered).filter(_campaign_jobs_cursor_condition(cursor_values)) + except KeysetCursorError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + start_cursor = cursor + else: + effective_offset = (page - 1) * page_size + page_query = session.query(CampaignJob).filter(*filtered) + if effective_offset > 0: + previous_row = ordered_query.offset(effective_offset - 1).limit(1).first() + if previous_row is not None: + start_cursor = _campaign_jobs_cursor_for_row(previous_row, fingerprint=fingerprint) + + rows_plus_one = ( + page_query .order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc()) - .offset((page - 1) * page_size) - .limit(page_size) + .offset(effective_offset) + .limit(page_size + 1) .all() ) + jobs = rows_plus_one[:page_size] + next_cursor = ( + _campaign_jobs_cursor_for_row(jobs[-1], fingerprint=fingerprint) + if changed_job_ids is None and len(rows_plus_one) > page_size and jobs else None + ) + if changed_job_ids is not None: + jobs = [job for job in jobs if job.id in changed_job_ids] return CampaignJobsResponse( jobs=[_job_summary_payload(job, reviewed_keys=reviewed_keys) for job in jobs], page=page, @@ -1348,12 +1857,319 @@ def list_jobs( total=total, total_unfiltered=total_unfiltered, pages=pages, + cursor=start_cursor, + next_cursor=next_cursor, counts=_status_counts(session, base_filters), filtered_counts=_status_counts(session, filtered), review=review_metadata, ) +def _campaign_jobs_cursor_fingerprint( + *, + campaign_id: str, + version_id: str | None, + page_size: int, + send_status: list[str] | None, + validation_status: list[str] | None, + imap_status: list[str] | None, + query_text: str | None, +) -> str: + return keyset_query_fingerprint( + CAMPAIGN_JOBS_CURSOR_SCOPE, + { + "campaign_id": campaign_id, + "version_id": version_id or "", + "page_size": page_size, + "send_status": sorted(send_status or []), + "validation_status": sorted(validation_status or []), + "imap_status": sorted(imap_status or []), + "query": (query_text or "").strip(), + "order": ["entry_index:asc", "id:asc"], + }, + ) + + +def _campaign_jobs_cursor_for_row(row: CampaignJob, *, fingerprint: str) -> str: + return encode_keyset_cursor( + CAMPAIGN_JOBS_CURSOR_SCOPE, + fingerprint=fingerprint, + values={"id": row.id, "entry_index": row.entry_index}, + ) + + +def _campaign_jobs_cursor_condition(cursor_values: dict[str, object]): + cursor_id = cursor_values.get("id") + cursor_index = cursor_values.get("entry_index") + if not isinstance(cursor_id, str) or not cursor_id: + raise KeysetCursorError("Invalid pagination cursor") + try: + entry_index = int(cursor_index) + except (TypeError, ValueError) as exc: + raise KeysetCursorError("Invalid pagination cursor") from exc + return or_(CampaignJob.entry_index > entry_index, and_(CampaignJob.entry_index == entry_index, CampaignJob.id > cursor_id)) + + +def _campaign_jobs_delta_watermark(session: Session, tenant_id: str) -> str: + return encode_sequence_watermark( + max_sequence_id( + session, + tenant_id=tenant_id, + module_id=CAMPAIGNS_MODULE_ID, + collections=(CAMPAIGN_JOBS_COLLECTION,), + ) + ) + + +@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse) +def list_jobs( + campaign_id: str, + version_id: str | None = None, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + cursor: str | None = Query(default=None), + send_status: list[str] | None = Query(default=None), + validation_status: list[str] | None = Query(default=None), + imap_status: list[str] | None = Query(default=None), + query_text: str | None = Query(default=None, alias="q", max_length=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + """Return a lightweight, paginated job list with server-side filters. + + Complete recipients, attachment metadata, issues and attempt history are + available from the separate job-detail endpoint. + """ + + _campaign, base_filters, filtered, review_metadata, reviewed_keys = _campaign_jobs_query_context( + session, + principal, + campaign_id=campaign_id, + version_id=version_id, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + ) + return _campaign_jobs_page_response( + session, + campaign_id=campaign_id, + version_id=version_id, + base_filters=base_filters, + filtered=filtered, + reviewed_keys=reviewed_keys, + review_metadata=review_metadata, + page=page, + page_size=page_size, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + cursor=cursor, + ) + + +def _campaign_jobs_full_delta_response( + session: Session, + *, + principal: ApiPrincipal, + campaign_id: str, + version_id: str | None, + page: int, + page_size: int, + send_status: list[str] | None, + validation_status: list[str] | None, + imap_status: list[str] | None, + query_text: str | None, + cursor: str | None = None, +) -> CampaignJobsDeltaResponse: + _campaign, base_filters, filtered, review_metadata, reviewed_keys = _campaign_jobs_query_context( + session, + principal, + campaign_id=campaign_id, + version_id=version_id, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + ) + payload = _campaign_jobs_page_response( + session, + campaign_id=campaign_id, + version_id=version_id, + base_filters=base_filters, + filtered=filtered, + reviewed_keys=reviewed_keys, + review_metadata=review_metadata, + page=page, + page_size=page_size, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + cursor=cursor, + ) + return CampaignJobsDeltaResponse( + **payload.model_dump(), + deleted=[], + watermark=_campaign_jobs_delta_watermark(session, principal.tenant_id), + has_more=False, + full=True, + ) + + +def _job_filter_membership_can_shift( + *, + send_status: list[str] | None, + validation_status: list[str] | None, + imap_status: list[str] | None, + query_text: str | None, +) -> bool: + return bool(send_status or validation_status or imap_status or (query_text and query_text.strip())) + + +@router.get("/{campaign_id}/jobs/delta", response_model=CampaignJobsDeltaResponse) +def list_jobs_delta( + campaign_id: str, + version_id: str | None = None, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + cursor: str | None = Query(default=None), + send_status: list[str] | None = Query(default=None), + validation_status: list[str] | None = Query(default=None), + imap_status: list[str] | None = Query(default=None), + query_text: str | None = Query(default=None, alias="q", max_length=200), + since: str | None = None, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + if since is None: + return _campaign_jobs_full_delta_response( + session, + principal=principal, + campaign_id=campaign_id, + version_id=version_id, + page=page, + page_size=page_size, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + cursor=cursor, + ) + + campaign, base_filters, filtered, review_metadata, reviewed_keys = _campaign_jobs_query_context( + session, + principal, + campaign_id=campaign_id, + version_id=version_id, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + ) + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if sequence_watermark_is_expired( + session, + since=since_sequence, + tenant_id=principal.tenant_id, + module_id=CAMPAIGNS_MODULE_ID, + collections=(CAMPAIGN_JOBS_COLLECTION,), + ): + return _campaign_jobs_full_delta_response( + session, + principal=principal, + campaign_id=campaign_id, + version_id=version_id, + page=page, + page_size=page_size, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + cursor=cursor, + ) + + entries_plus_one = sequence_entries_since( + session, + since=since_sequence, + tenant_id=principal.tenant_id, + module_id=CAMPAIGNS_MODULE_ID, + collections=(CAMPAIGN_JOBS_COLLECTION,), + limit=limit + 1, + ) + has_more = len(entries_plus_one) > limit + entries = entries_plus_one[:limit] + relevant_entries = [ + entry + for entry in entries + if (entry.payload or {}).get("campaign_id") == campaign.id + and (not version_id or (entry.payload or {}).get("version_id") == version_id) + ] + + if relevant_entries and ( + _job_filter_membership_can_shift( + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + ) + or any(entry.operation in {"created", "deleted"} for entry in relevant_entries) + ): + return _campaign_jobs_full_delta_response( + session, + principal=principal, + campaign_id=campaign_id, + version_id=version_id, + page=page, + page_size=page_size, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + cursor=cursor, + ) + + changed_job_ids = { + entry.resource_id + for entry in relevant_entries + if entry.resource_type == "campaign_job" and entry.operation != "deleted" + } + payload = _campaign_jobs_page_response( + session, + campaign_id=campaign_id, + version_id=version_id, + base_filters=base_filters, + filtered=filtered, + reviewed_keys=reviewed_keys, + review_metadata=review_metadata, + page=page, + page_size=page_size, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + cursor=cursor, + changed_job_ids=changed_job_ids, + ) + watermark = ( + encode_sequence_watermark(entries[-1].id) + if has_more and entries + else _campaign_jobs_delta_watermark(session, principal.tenant_id) + ) + return CampaignJobsDeltaResponse( + **payload.model_dump(), + deleted=[], + watermark=watermark, + has_more=has_more, + full=False, + ) + + @router.get("/{campaign_id}/jobs/{job_id}", response_model=CampaignJobDetailResponse) def get_job_detail( campaign_id: str, @@ -1526,7 +2342,7 @@ def email_campaign_report( except CampaignReportError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except (CampaignReportEmailError, Exception) as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @@ -1572,7 +2388,7 @@ def update_campaign_owner( ): campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) if payload.owner_user_id and payload.owner_group_id: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Choose either a user owner or a group owner, not both") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Choose either a user owner or a group owner, not both") directory = _access_directory() if payload.owner_user_id: owner = directory.get_user(payload.owner_user_id) @@ -1586,10 +2402,23 @@ def update_campaign_owner( group = None if group is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found") + owner_changed = campaign.owner_user_id != payload.owner_user_id or campaign.owner_group_id != payload.owner_group_id + mail_profile_reselection_required = False + if owner_changed: + mail_profile_reselection_required = _clear_current_version_mail_profile_for_owner_transfer(session, campaign) + campaign.owner_user_id = payload.owner_user_id campaign.owner_group_id = payload.owner_group_id session.add(campaign) - audit_from_principal(session, principal, action="campaign.owner_updated", object_type="campaign", object_id=campaign.id, details=payload.model_dump(), commit=True) + audit_from_principal( + session, + principal, + action="campaign.owner_updated", + object_type="campaign", + object_id=campaign.id, + details={**payload.model_dump(), "mail_profile_reselection_required": mail_profile_reselection_required}, + commit=True, + ) return CampaignResponse.model_validate(campaign) @@ -1709,7 +2538,7 @@ def queue_campaign( ) return QueueCampaignResponse(**result.as_dict()) except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/jobs/retry", response_model=CampaignActionResponse) @@ -1746,7 +2575,7 @@ def retry_campaign_jobs( ) return CampaignActionResponse(result=result) except (QueueingError, ExecutionSnapshotError) as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/jobs/send-unattempted", response_model=CampaignActionResponse) @@ -1781,7 +2610,7 @@ def send_unattempted_campaign_jobs( ) return CampaignActionResponse(result=result) except (QueueingError, ExecutionSnapshotError) as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/jobs/{job_id}/resolve-outcome", response_model=CampaignActionResponse) @@ -1814,7 +2643,7 @@ def resolve_campaign_job_outcome( ) return CampaignActionResponse(result=result) except (QueueingError, ExecutionSnapshotError) as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse) @@ -1864,9 +2693,9 @@ def mock_send_campaign( ) return MockCampaignSendResponse(result=result) except MockCampaignSendError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse) @@ -1889,7 +2718,7 @@ def send_campaign_now_endpoint( campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) version_id = payload.version_id or campaign.current_version_id if not version_id: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign has no current version") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Campaign has no current version") version = _get_version_for_tenant(session, version_id, principal.tenant_id) _require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {}) @@ -1902,12 +2731,12 @@ def send_campaign_now_endpoint( ) if not version.locked_at or not validation_result or validation_result.get("ok") is not True: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Campaign version must be validated and locked before dry-run or sending.", ) if not build_result: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Campaign version must be built before dry-run or sending.", ) @@ -1936,9 +2765,9 @@ def send_campaign_now_endpoint( except HTTPException: raise except (CampaignPersistenceError, QueueingError) as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @@ -1954,7 +2783,7 @@ def pause_campaign( audit_from_principal(session, principal, action="campaign.paused", object_type="campaign", object_id=campaign_id, details=result, commit=True) return CampaignActionResponse(result=result) except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/resume", response_model=CampaignActionResponse) @@ -1969,7 +2798,7 @@ def resume_campaign( audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True) return CampaignActionResponse(result=result) except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse) @@ -1984,7 +2813,7 @@ def cancel_campaign( audit_from_principal(session, principal, action="campaign.cancelled", object_type="campaign", object_id=campaign_id, details=result, commit=True) return CampaignActionResponse(result=result) except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse) @@ -2017,7 +2846,7 @@ def append_sent( ) return CampaignActionResponse(result=result) except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc class CampaignAttachmentPreviewRequest(BaseModel): diff --git a/src/govoplan_campaign/backend/schema_router.py b/src/govoplan_campaign/backend/schema_router.py index 9620c6f..7a4bd5e 100644 --- a/src/govoplan_campaign/backend/schema_router.py +++ b/src/govoplan_campaign/backend/schema_router.py @@ -4,7 +4,7 @@ from typing import Any from fastapi import APIRouter, Depends -from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_any_scope +from govoplan_access.auth import ApiPrincipal, require_any_scope from govoplan_campaign.backend.campaign.loader import load_campaign_schema, load_campaign_schema_ui diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index ce46aad..dff2526 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -5,6 +5,7 @@ from typing import Any, Literal 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) @@ -257,11 +273,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) diff --git a/tests/test_campaign_reports.py b/tests/test_campaign_reports.py new file mode 100644 index 0000000..8969f67 --- /dev/null +++ b/tests/test_campaign_reports.py @@ -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="", + 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"] == "" + assert row["from"] == "Sender " + assert row["to"] == "Recipient " + 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 diff --git a/webui/package.json b/webui/package.json index b9d6856..744229d 100644 --- a/webui/package.json +++ b/webui/package.json @@ -18,7 +18,7 @@ }, "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 3a0bd8f..d3e9123 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -1,13 +1,13 @@ -import type { ApiSettings, CampaignListItem } from "../types"; +import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types"; import { apiDownload, apiFetch } from "./client"; export type CampaignListResponse = - | CampaignListItem[] - | { - campaigns?: CampaignListItem[]; - items?: CampaignListItem[]; - results?: CampaignListItem[]; - }; +CampaignListItem[] | +{ + campaigns?: CampaignListItem[]; + items?: CampaignListItem[]; + results?: CampaignListItem[]; +}; @@ -20,8 +20,8 @@ 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 CampaignUpdatePayload = { external_id?: string | null; @@ -85,11 +85,28 @@ 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"; @@ -279,6 +296,7 @@ export type CampaignJobsQuery = { versionId?: string; page?: number; pageSize?: number; + cursor?: string | null; sendStatus?: string[]; validationStatus?: string[]; imapStatus?: string[]; @@ -292,6 +310,8 @@ export type CampaignJobsResponse = { total: number; total_unfiltered: number; pages: number; + cursor?: string | null; + next_cursor?: string | null; counts: Record>; filtered_counts: Record>; review: { @@ -303,6 +323,13 @@ export type CampaignJobsResponse = { }; }; +export type CampaignJobsDeltaResponse = CampaignJobsResponse & { + deleted: DeltaDeletedItem[]; + watermark?: string | null; + has_more: boolean; + full: boolean; +}; + export type CampaignJobDetailResponse = { job: Record; attempts: { @@ -330,15 +357,26 @@ export async function listCampaigns(settings: ApiSettings): Promise { + 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(settings, `/api/v1/campaigns/delta${suffix}`); +} + export async function listRecipientImportMappingProfiles(settings: ApiSettings): Promise { const response = await apiFetch(settings, "/api/v1/campaigns/recipient-import/mapping-profiles"); return response.profiles ?? []; } export async function createRecipientImportMappingProfile( - settings: ApiSettings, - payload: RecipientImportMappingProfilePayload -): Promise { +settings: ApiSettings, +payload: RecipientImportMappingProfilePayload) +: Promise { return apiFetch(settings, "/api/v1/campaigns/recipient-import/mapping-profiles", { method: "POST", body: JSON.stringify(payload) @@ -346,10 +384,10 @@ export async function createRecipientImportMappingProfile( } export async function updateRecipientImportMappingProfile( - settings: ApiSettings, - profileId: string, - payload: RecipientImportMappingProfilePayload -): Promise { +settings: ApiSettings, +profileId: string, +payload: RecipientImportMappingProfilePayload) +: Promise { return apiFetch(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { method: "PUT", body: JSON.stringify(payload) @@ -365,10 +403,10 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr } export async function updateCampaignMetadata( - settings: ApiSettings, - campaignId: string, - payload: CampaignUpdatePayload -): Promise { +settings: ApiSettings, +campaignId: string, +payload: CampaignUpdatePayload) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}`, { method: "PUT", body: JSON.stringify(payload) @@ -376,14 +414,14 @@ export async function updateCampaignMetadata( } export async function createNewCampaign( - settings: ApiSettings, - overrides: CampaignCreateMinimalPayload = {} -): Promise { +settings: ApiSettings, +overrides: CampaignCreateMinimalPayload = {}) +: Promise { 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" @@ -404,10 +442,10 @@ export async function getCampaignSchemaUi(settings: ApiSettings): Promise { +settings: ApiSettings, +campaignId: string, +options: CampaignWorkspaceQuery = {}) +: Promise { 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)); @@ -417,67 +455,83 @@ export async function getCampaignWorkspace( return apiFetch(settings, `/api/v1/campaigns/${campaignId}/workspace${suffix}`); } +export async function getCampaignWorkspaceDelta( +settings: ApiSettings, +campaignId: string, +options: CampaignWorkspaceQuery = {}) +: Promise { + 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(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`); +} + export async function listCampaignVersions( - settings: ApiSettings, - campaignId: string -): Promise { +settings: ApiSettings, +campaignId: string) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions`); } export async function getCampaignVersion( - settings: ApiSettings, - campaignId: string, - versionId: string -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`); } export async function unlockCampaignVersionValidation( - settings: ApiSettings, - campaignId: string, - versionId: string -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, { method: "POST" }); } export async function lockCampaignVersionTemporarily( - settings: ApiSettings, - campaignId: string, - versionId: string -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, { method: "POST" }); } export async function unlockCampaignVersionUserLock( - settings: ApiSettings, - campaignId: string, - versionId: string -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, { method: "POST" }); } export async function lockCampaignVersionPermanently( - settings: ApiSettings, - campaignId: string, - versionId: string -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, { method: "POST" }); } export async function updateCampaignVersion( - settings: ApiSettings, - campaignId: string, - versionId: string, - payload: CampaignVersionUpdatePayload -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string, +payload: CampaignVersionUpdatePayload) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, { method: "PUT", body: JSON.stringify(payload) @@ -485,11 +539,11 @@ export async function updateCampaignVersion( } export async function forkCampaignVersion( - settings: ApiSettings, - campaignId: string, - versionId: string, - payload: CampaignVersionUpdatePayload = {} -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string, +payload: CampaignVersionUpdatePayload = {}) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, { method: "POST", body: JSON.stringify(payload) @@ -497,11 +551,11 @@ export async function forkCampaignVersion( } export async function autosaveCampaignVersion( - settings: ApiSettings, - campaignId: string, - versionId: string, - payload: CampaignVersionUpdatePayload -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string, +payload: CampaignVersionUpdatePayload) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, { method: "POST", body: JSON.stringify(payload) @@ -509,12 +563,12 @@ export async function autosaveCampaignVersion( } export async function setCampaignVersionStep( - settings: ApiSettings, - campaignId: string, - versionId: string, - currentStep: string, - currentFlow?: string | null -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string, +currentStep: string, +currentFlow?: string | null) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, { method: "POST", body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep }) @@ -522,11 +576,11 @@ export async function setCampaignVersionStep( } export async function validatePartial( - settings: ApiSettings, - campaignId: string, - versionId: string, - payload: CampaignPartialValidationPayload = {} -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string, +payload: CampaignPartialValidationPayload = {}) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, { method: "POST", body: JSON.stringify(payload) @@ -534,20 +588,20 @@ export async function validatePartial( } export async function publishCampaignVersion( - settings: ApiSettings, - campaignId: string, - versionId: string -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, { method: "POST" }); } export async function validateVersion( - settings: ApiSettings, - versionId: string, - checkFiles = false -): Promise> { +settings: ApiSettings, +versionId: string, +checkFiles = false) +: Promise> { return apiFetch>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, { method: "POST", body: JSON.stringify({ check_files: checkFiles }) @@ -555,10 +609,10 @@ export async function validateVersion( } export async function buildVersion( - settings: ApiSettings, - versionId: string, - writeEml = true -): Promise> { +settings: ApiSettings, +versionId: string, +writeEml = true) +: Promise> { return apiFetch>(settings, `/api/v1/campaigns/versions/${versionId}/build`, { method: "POST", body: JSON.stringify({ write_eml: writeEml }) @@ -567,11 +621,11 @@ export async function buildVersion( export function previewCampaignAttachments( - settings: ApiSettings, - campaignId: string, - versionId: string, - payload: CampaignAttachmentPreviewPayload = {} -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string, +payload: CampaignAttachmentPreviewPayload = {}) +: Promise { return apiFetch( settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`, @@ -580,23 +634,24 @@ export function previewCampaignAttachments( } export async function getCampaignSummary( - settings: ApiSettings, - campaignId: string, - versionId?: string -): Promise { +settings: ApiSettings, +campaignId: string, +versionId?: string) +: Promise { const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; return apiFetch(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`); } export async function getCampaignJobs( - settings: ApiSettings, - campaignId: string, - options: CampaignJobsQuery = {} -): Promise { +settings: ApiSettings, +campaignId: string, +options: CampaignJobsQuery = {}) +: Promise { 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); @@ -605,29 +660,49 @@ export async function getCampaignJobs( return apiFetch(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`); } +export async function getCampaignJobsDelta( +settings: ApiSettings, +campaignId: string, +options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {}) +: Promise { + 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(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`); +} + export async function getCampaignJobDetail( - settings: ApiSettings, - campaignId: string, - jobId: string -): Promise { +settings: ApiSettings, +campaignId: string, +jobId: string) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`); } export async function getCampaignReport( - settings: ApiSettings, - campaignId: string, - versionId?: string -): Promise { +settings: ApiSettings, +campaignId: string, +versionId?: string) +: Promise { const params = new URLSearchParams({ include_jobs: "false" }); if (versionId) params.set("version_id", versionId); return apiFetch(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`); } export async function downloadCampaignJobsCsv( - settings: ApiSettings, - campaignId: string, - versionId?: string -): Promise { +settings: ApiSettings, +campaignId: string, +versionId?: string) +: Promise { const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; await apiDownload( settings, @@ -637,10 +712,10 @@ export async function downloadCampaignJobsCsv( } export async function emailCampaignReport( - settings: ApiSettings, - campaignId: string, - payload: CampaignReportEmailPayload -): Promise> { +settings: ApiSettings, +campaignId: string, +payload: CampaignReportEmailPayload) +: Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/report/email`, { method: "POST", body: JSON.stringify(payload) @@ -648,10 +723,10 @@ export async function emailCampaignReport( } export async function retryCampaignJobs( - settings: ApiSettings, - campaignId: string, - payload: Record = {} -): Promise> { +settings: ApiSettings, +campaignId: string, +payload: Record = {}) +: Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, { method: "POST", body: JSON.stringify(payload) @@ -659,10 +734,10 @@ export async function retryCampaignJobs( } export async function sendUnattemptedCampaignJobs( - settings: ApiSettings, - campaignId: string, - payload: Record = {} -): Promise> { +settings: ApiSettings, +campaignId: string, +payload: Record = {}) +: Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, { method: "POST", body: JSON.stringify(payload) @@ -670,12 +745,12 @@ export async function sendUnattemptedCampaignJobs( } export async function resolveCampaignJobOutcome( - settings: ApiSettings, - campaignId: string, - jobId: string, - decision: "smtp_accepted" | "not_sent", - note?: string -): Promise> { +settings: ApiSettings, +campaignId: string, +jobId: string, +decision: "smtp_accepted" | "not_sent", +note?: string) +: Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, { method: "POST", body: JSON.stringify({ decision, note: note || null }) @@ -683,11 +758,11 @@ export async function resolveCampaignJobOutcome( } export async function updateCampaignReviewState( - settings: ApiSettings, - campaignId: string, - versionId: string, - payload: CampaignReviewStatePayload -): Promise { +settings: ApiSettings, +campaignId: string, +versionId: string, +payload: CampaignReviewStatePayload) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, { method: "POST", body: JSON.stringify(payload) @@ -695,10 +770,10 @@ export async function updateCampaignReviewState( } export async function queueCampaign( - settings: ApiSettings, - campaignId: string, - payload: CampaignQueuePayload = {} -): Promise> { +settings: ApiSettings, +campaignId: string, +payload: CampaignQueuePayload = {}) +: Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/queue`, { method: "POST", body: JSON.stringify(payload) @@ -706,10 +781,10 @@ export async function queueCampaign( } export async function sendCampaignNow( - settings: ApiSettings, - campaignId: string, - payload: CampaignSendNowPayload = {} -): Promise> { +settings: ApiSettings, +campaignId: string, +payload: CampaignSendNowPayload = {}) +: Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/send-now`, { method: "POST", body: JSON.stringify(payload) @@ -718,10 +793,10 @@ export async function sendCampaignNow( export async function mockSendCampaign( - settings: ApiSettings, - campaignId: string, - payload: CampaignMockSendPayload = {} -): Promise> { +settings: ApiSettings, +campaignId: string, +payload: CampaignMockSendPayload = {}) +: Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, { method: "POST", body: JSON.stringify(payload) @@ -741,10 +816,10 @@ export async function cancelCampaign(settings: ApiSettings, campaignId: string): } export async function appendSent( - settings: ApiSettings, - campaignId: string, - payload: CampaignAppendSentPayload = {} -): Promise> { +settings: ApiSettings, +campaignId: string, +payload: CampaignAppendSentPayload = {}) +: Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, { method: "POST", body: JSON.stringify({ @@ -760,23 +835,23 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId: } export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise { - 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 { +settings: ApiSettings, +campaignId: string, +payload: {owner_user_id?: string | null;owner_group_id?: string | null;}) +: Promise { return apiFetch(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 { +settings: ApiSettings, +campaignId: string, +payload: {target_type: "user" | "group";target_id: string;permission: "read" | "write";}) +: Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) }); } diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx index 719978e..ce6c36e 100644 --- a/webui/src/features/addressbook/AddressBookPage.tsx +++ b/webui/src/features/addressbook/AddressBookPage.tsx @@ -5,28 +5,28 @@ 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" } -]; +{ name: "i18n:govoplan-campaign.ada_lovelace.a69a9f8a", email: "ada@example.local", source: "Personal", tags: "i18n:govoplan-campaign.used_recently.af75cea7" }, +{ name: "i18n:govoplan-campaign.grace_hopper.d97e6939", email: "grace@example.local", source: "Personal", tags: "i18n:govoplan-campaign.favorite.6b90b6a1" }]; + 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" } -]; +{ name: "i18n:govoplan-campaign.project_office.c35aa9ca", email: "project-office@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared.50d0d8dd" }, +{ name: "i18n:govoplan-campaign.finance_team.ff353e5c", email: "finance@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared_list.b3c94b39" }]; + const tenantContacts = [ - { name: "Helpdesk", email: "helpdesk@example.local", source: "Tenant", tags: "Directory" }, - { name: "Data Protection", email: "privacy@example.local", source: "Tenant", tags: "Directory" } -]; +{ name: "i18n:govoplan-campaign.helpdesk.191815bf", email: "helpdesk@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" }, +{ name: "i18n:govoplan-campaign.data_protection.06e87cd3", email: "privacy@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" }]; -function contactColumns(): DataGridColumn<{ name: string; email: string; source: string; tags: string }>[] { + +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) => {contact.name}, 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: () => , value: () => "mock" } - ]; + { id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => {contact.name}, value: (contact) => contact.name }, + { id: "email", header: "i18n:govoplan-campaign.email.84add5b2", width: 240, sortable: true, filterable: true, value: (contact) => contact.email }, + { id: "scope", header: "i18n:govoplan-campaign.scope.4651a34e", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "i18n:govoplan-campaign.personal.40f07323" }, { value: "Group", label: "i18n:govoplan-campaign.group.171a0606" }, { value: "Tenant", label: "i18n:govoplan-campaign.tenant.3ca93c78" }] }, value: (contact) => contact.source }, + { id: "tags", header: "i18n:govoplan-campaign.tags.848eed0f", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags }, + { id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "i18n:govoplan-campaign.mock.3bba2a47" }], display: "pill" }, render: () => , value: () => "mock" }]; + } export default function AddressBookPage() { @@ -36,55 +36,55 @@ export default function AddressBookPage() {
- Address Book -

Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.

+ i18n:govoplan-campaign.address_book.f6327f59 +

i18n:govoplan-campaign.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4

- - + +
- {personalContacts.length}

Private contacts and remembered addresses.

- {groupContacts.length}

Shared group address books and lists.

- {tenantContacts.length}

Tenant directory and approved shared contacts.

- Mock

CardDAV/LDAP/import connectors can be added later.

+ {personalContacts.length}

i18n:govoplan-campaign.private_contacts_and_remembered_addresses.2bd71556

+ {groupContacts.length}

i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d

+ {tenantContacts.length}

i18n:govoplan-campaign.tenant_directory_and_approved_shared_contacts.fa671f1b

+ i18n:govoplan-campaign.mock.3bba2a47

i18n:govoplan-campaign.carddav_ldap_import_connectors_can_be_added_late.0471f513

- +
- - - + + +
- +
- Choose addresses from personal, group or tenant source - Remember addresses used in campaigns after opt-in - Share selected contacts with a group - Use contacts in To, CC, BCC, sender and Reply-To fields + i18n:govoplan-campaign.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4 + i18n:govoplan-campaign.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529 + i18n:govoplan-campaign.share_selected_contacts_with_a_group.604d1464 + i18n:govoplan-campaign.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a
- Manage sources}> + i18n:govoplan-campaign.manage_sources.ec758de0}> contact.source + "-" + contact.email} - emptyText="No contacts found." - className="compact-table-wrap module-table" - /> + emptyText="i18n:govoplan-campaign.no_contacts_found.ad977b09" + className="compact-table-wrap module-table" /> + -
- ); + ); + } -function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) { +function AddressBookScope({ title, description, status }: {title: string;description: string;status: string;}) { return (
@@ -92,6 +92,6 @@ function AddressBookScope({ title, description, status }: { title: string; descr

{description}

-
- ); + ); + } diff --git a/webui/src/features/campaigns/AttachmentsDataPage.tsx b/webui/src/features/campaigns/AttachmentsDataPage.tsx index 9e43e8d..f75c57b 100644 --- a/webui/src/features/campaigns/AttachmentsDataPage.tsx +++ b/webui/src/features/campaigns/AttachmentsDataPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { Pencil } from "lucide-react"; -import { usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui"; +import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui"; import type { ApiSettings } from "../../types"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; @@ -19,14 +19,15 @@ import { updateNested } from "./utils/draftEditor"; import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay"; 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 { 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("files.fileExplorer"); const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null; @@ -47,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]); @@ -78,10 +79,10 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings return; } let cancelled = false; - void listManagedFileSpaces(settings) - .then((response) => { if (!cancelled) setFileSpaces(response.spaces); }) - .catch(() => { if (!cancelled) setFileSpaces([]); }); - return () => { cancelled = true; }; + void listManagedFileSpaces(settings). + then((response) => {if (!cancelled) setFileSpaces(response.spaces);}). + catch(() => {if (!cancelled) setFileSpaces([]);}); + return () => {cancelled = true;}; }, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); function patchBasePaths(paths: AttachmentBasePath[]) { @@ -153,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 }); } @@ -195,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 ?? {}); @@ -232,44 +233,44 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
- Attachments + i18n:govoplan-campaign.attachments.6771ade6
- {filesModuleInstalled && } - - + {filesModuleInstalled && } + +
{error && {error}} {localError && {localError}} - {locked && } + {locked && } - + <> - +
basePath.id} - emptyText="No attachment sources configured." - emptyAction={ 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={ addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />} + className="attachment-sources-table-wrap attachment-sources-table" /> +
- +
+ onChange={setZipEnabled} /> +
archive.id} - emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."} - emptyAction={zipConfig.enabled ? 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 ? addZipArchive(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_zip_attachment.f64cc332" /> : undefined} + className="attachment-zip-table-wrap" /> +
- {zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && ( - No password field exists. Add one under Fields with field type Password. - )} - {zipArchiveNameValidation.message && ( - {zipArchiveNameValidation.message} - )} -

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.

+ {zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && + i18n:govoplan-campaign.no_password_field_exists_add_one_under_fields_wi.2b6d4a7f i18n:govoplan-campaign.password.8be3c943. + } + {zipArchiveNameValidation.message && + {zipArchiveNameValidation.message} + } +

i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1

- + patch(["attachments", "global"], rules)} - /> + onChange={(rules) => patch(["attachments", "global"], rules)} /> + - +
-
Base paths
{basePaths.length}
-
Global attachments
direct: {globalSummary.direct} / rules: {globalSummary.rules}
-
Per-recipient patterns
{individualRulesCount}
-
Upload support
{managedFilesAvailable ? "Connected through Files" : "Manual paths"}
+
i18n:govoplan-campaign.base_paths.edf35a97
{basePaths.length}
+
i18n:govoplan-campaign.global_attachments.438263a1
direct: {globalSummary.direct} i18n:govoplan-campaign.rules.e5d36074 {globalSummary.rules}
+
i18n:govoplan-campaign.per_recipient_patterns.53da30da
{individualRulesCount}
+
i18n:govoplan-campaign.upload_support.1c54931c
{managedFilesAvailable ? "i18n:govoplan-campaign.connected_through_files.95007112" : "i18n:govoplan-campaign.manual_paths.8e20627a"}
-

{managedFilesAvailable - ? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build." - : filesModuleInstalled - ? "Managed file browsing is unavailable. Use manual paths and patterns until the Files module exposes the file explorer capability." - : "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}

+

{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"}

@@ -337,13 +338,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings 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) => { @@ -356,52 +357,52 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings const existingFields = asArray(draft.fields).map(asRecord); 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 } - ]); + ...existingFields, + { 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 && ManagedFileChooser && ( - setPathChooser(null)} - onSelectFolder={(selection) => { - const current = basePaths[pathChooser.index]; - const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label; - 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 - }); - setPathChooser(null); - }} - /> - )} + {pathChooser && ManagedFileChooser && + setPathChooser(null)} + onSelectFolder={(selection) => { + const current = basePaths[pathChooser.index]; + const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label; + patchBasePath(pathChooser.index, { + source: selection.source, + path: selection.folderPath || ".", + 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); + }} /> + + } setIndividualDisable(null)} - /> -
- ); + onCancel={() => setIndividualDisable(null)} /> + + ); + } type ZipArchiveColumnContext = { @@ -419,73 +420,73 @@ type ZipArchiveColumnContext = { function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn[] { return [ - { - id: "name", header: "Archive name", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start", - render: (archive, index) => ( - - ), - 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) => 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) => 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) => ( - patchArchive(index, { password_field: event.target.value })}> + {passwordFields.map((field) => )} - - ), - 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) => ( - - ), - value: (archive) => archive.password_scope - }, - { - id: "actions", header: "Actions", width: 180, sticky: "end", - render: (_archive, index) => 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" - /> - } - ]; + , + + value: (archive) => archive.password_field + }, + { + 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) => + , + + value: (archive) => archive.password_scope + }, + { + id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 180, sticky: "end", + render: (_archive, index) => addArchive(index)} + onRemove={() => removeArchive(index)} + onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined} + onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined} + 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"; @@ -511,19 +512,19 @@ function buildZipFilenameFieldOptions(draft: Record): 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) })) - ]; + ...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) }))]; + } 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 { @@ -545,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 { @@ -580,70 +581,70 @@ type AttachmentSourceColumnContext = { function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn[] { return [ - { id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name }, - { - id: "path", - header: "Path", - width: "minmax(260px, 1fr)", - resizable: true, - sortable: true, - filterable: true, - render: (basePath, index) => ( -
+ { id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name }, + { + id: "path", + header: "i18n:govoplan-campaign.path.519e3913", + width: "minmax(260px, 1fr)", + resizable: true, + sortable: true, + filterable: true, + render: (basePath, index) => +
{ - if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" }); - }} - onClick={() => managedFilesAvailable && !locked && setPathChooser({ index })} - onKeyDown={(event) => { - if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) { - event.preventDefault(); - setPathChooser({ index }); - } - }} - /> - {managedFilesAvailable && } -
- ), - 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) => 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) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" }, - { - id: "actions", - header: "Actions", - width: 180, - sticky: "end", - render: (_basePath, index) => ( - addBasePath(index)} - 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" - /> - ) - } - ]; + className="chooser-display-input" + value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path} + disabled={locked} + readOnly={managedFilesAvailable} + tabIndex={managedFilesAvailable ? -1 : undefined} + placeholder="i18n:govoplan-campaign.attachments_placeholder" + onChange={(event) => { + if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" }); + }} + onClick={() => managedFilesAvailable && !locked && setPathChooser({ index })} + onKeyDown={(event) => { + if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + setPathChooser({ index }); + } + }} /> + + {managedFilesAvailable && } +
, + + value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces) + }, + { 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) => 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) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" }, + { + id: "actions", + header: "i18n:govoplan-campaign.actions.c3cd636a", + width: 180, + sticky: "end", + render: (_basePath, index) => + addBasePath(index)} + onRemove={() => removeBasePath(index)} + onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined} + onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined} + 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: 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 || "."}/`; diff --git a/webui/src/features/campaigns/CampaignAuditPage.tsx b/webui/src/features/campaigns/CampaignAuditPage.tsx index 014a755..8d21e48 100644 --- a/webui/src/features/campaigns/CampaignAuditPage.tsx +++ b/webui/src/features/campaigns/CampaignAuditPage.tsx @@ -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:
- Audit log + i18n:govoplan-campaign.audit_log.3cfc5f1c
- +
{error && {error}} - - -

Campaign-specific audit API integration will be added in the audit section pass.

+ + +

i18n:govoplan-campaign.campaign_specific_audit_api_integration_will_be_.e53c8280

-
- ); -} + ); + +} \ No newline at end of file diff --git a/webui/src/features/campaigns/CampaignFieldsPage.tsx b/webui/src/features/campaigns/CampaignFieldsPage.tsx index 6809426..75a230e 100644 --- a/webui/src/features/campaigns/CampaignFieldsPage.tsx +++ b/webui/src/features/campaigns/CampaignFieldsPage.tsx @@ -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([]); @@ -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:
- Fields + i18n:govoplan-campaign.fields.e8b68527
- - + +
{error && {error}} {localError && {localError}} {fieldNameWarning && {fieldNameWarning}} - {locked && } + {locked && } - + <> - +
`field-row-${index}`} - emptyText="No campaign fields configured yet." - emptyAction={ 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={ addField(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_field.9fef7186" />} + className="field-editor-table-wrap field-editor-table" /> +
-
- ); + ); + } type FieldColumnContext = { @@ -203,32 +203,32 @@ type FieldColumnContext = { function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn[] { return [ - { id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => renameField(index, event.target.value)} />, value: (field) => field.name }, - { id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => 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) => , 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) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" }, - { id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => 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) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" }, - { - id: "actions", - header: "Actions", - width: 180, - sticky: "end", - render: (_field, index) => ( - 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" - /> - ) - } - ]; + { id: "name", header: "i18n:govoplan-campaign.field_id.4ee26ffd", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => 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) => 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) => , 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) => 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) => 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) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can_override" : "locked" }, + { + id: "actions", + header: "i18n:govoplan-campaign.actions.c3cd636a", + width: 180, + sticky: "end", + render: (_field, index) => + addField(index)} + onRemove={() => deleteField(index)} + onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined} + onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined} + 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, 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(); @@ -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 { diff --git a/webui/src/features/campaigns/CampaignJsonView.tsx b/webui/src/features/campaigns/CampaignJsonView.tsx index 58f5beb..40b6eef 100644 --- a/webui/src/features/campaigns/CampaignJsonView.tsx +++ b/webui/src/features/campaigns/CampaignJsonView.tsx @@ -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
- JSON + i18n:govoplan-campaign.json.031a4e76
- - + +
{error && {error}} - + {!loading || version ?
{JSON.stringify(campaignJson, null, 2)}
:
{"{}"}
}
-
- ); -} + ); + +} \ No newline at end of file diff --git a/webui/src/features/campaigns/CampaignListPage.tsx b/webui/src/features/campaigns/CampaignListPage.tsx index fec7327..47d96d5 100644 --- a/webui/src/features/campaigns/CampaignListPage.tsx +++ b/webui/src/features/campaigns/CampaignListPage.tsx @@ -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([]); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [creating, setCreating] = useState(false); const [lastLoadedAt, setLastLoadedAt] = useState(""); + const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState(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,114 +58,115 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings } } useEffect(() => { - load(); + setCampaignDeltaWatermark(null); + load(null); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); const columns: DataGridColumn[] = [ - { - id: "campaign", - header: "Campaign", - width: "minmax(260px, 1.8fr)", - sortable: true, - filterable: true, - sticky: "start", - render: (campaign) => ( -
+ { + id: "campaign", + header: "i18n:govoplan-campaign.campaign.69390e16", + width: "minmax(260px, 1.8fr)", + sortable: true, + filterable: true, + sticky: "start", + render: (campaign) => +
{campaign.name || campaign.external_id || campaign.id}
{campaign.description || campaign.external_id || campaign.id}
-
- ), - value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}` +
, + + value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}` + }, + { + id: "status", + header: "i18n:govoplan-campaign.status.bae7d5be", + width: 150, + 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: "completed", label: "i18n:govoplan-campaign.completed.1798b3ba" }, + { value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }], + + display: "pill" }, - { - id: "status", - header: "Status", - 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" } - ], - display: "pill" - }, - render: (campaign) => , - value: (campaign) => campaign.status || "draft" - }, - { - id: "current_version", - header: "Current version", - width: 170, - sortable: true, - filterable: true, - className: "version-cell mono-small", - render: (campaign) => {campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}, - value: (campaign) => campaign.current_version_id ?? "" - }, - { - id: "updated", - header: "Updated", - width: 190, - sortable: true, - filterable: true, - filterType: "date", - className: "updated-cell", - render: (campaign) => formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at), - value: (campaign) => campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at ?? "" - }, - { - id: "actions", - header: "Actions", - width: 70, - sticky: "end", - align: "right", - render: (campaign) => ( - + render: (campaign) => , + value: (campaign) => campaign.status || "draft" + }, + { + id: "current_version", + header: "i18n:govoplan-campaign.current_version.7be72582", + width: 170, + sortable: true, + filterable: true, + className: "version-cell mono-small", + render: (campaign) => {campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}, + value: (campaign) => campaign.current_version_id ?? "" + }, + { + id: "updated", + header: "i18n:govoplan-campaign.updated.f2f8570d", + width: 190, + sortable: true, + filterable: true, + filterType: "date", + className: "updated-cell", + render: (campaign) => formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at), + value: (campaign) => campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at ?? "" + }, + { + id: "actions", + header: "i18n:govoplan-campaign.actions.c3cd636a", + width: 70, + sticky: "end", + align: "right", + render: (campaign) => + +