Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5775347746 | |||
| 083c089003 | |||
| 54c9881a25 | |||
| da4c8731fc | |||
| 9a6b6c7e8e | |||
| 52e6ed49c5 | |||
| 57f6066bf7 | |||
| 533c4c8fe3 | |||
| fb6fb67d45 | |||
| ab2343aa88 | |||
| 23318c709a |
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: "Bug"
|
||||||
|
about: "Report a reproducible defect, regression, or incorrect behavior"
|
||||||
|
title: "[Bug] "
|
||||||
|
labels:
|
||||||
|
- type/bug
|
||||||
|
- status/triage
|
||||||
|
- module/campaign
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Area/module:
|
||||||
|
- Affected version or commit:
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
Actual:
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Logs, screenshots, traces, or failing test output:
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or workflow that should pass when fixed:
|
||||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Docs / workflow"
|
||||||
|
about: "Request documentation, process, or developer workflow changes"
|
||||||
|
title: "[Docs] "
|
||||||
|
labels:
|
||||||
|
- type/docs
|
||||||
|
- status/triage
|
||||||
|
- module/campaign
|
||||||
|
- area/docs
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Document or workflow:
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
What is missing, unclear, duplicated, or stale?
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
What should the docs or workflow make clear?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
How should this be checked?
|
||||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: "Feature"
|
||||||
|
about: "Propose new user-visible behavior or platform capability"
|
||||||
|
title: "[Feature] "
|
||||||
|
labels:
|
||||||
|
- type/feature
|
||||||
|
- status/triage
|
||||||
|
- module/campaign
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
What user, operator, or developer problem should this solve?
|
||||||
|
|
||||||
|
## Proposed Capability
|
||||||
|
|
||||||
|
What should exist when this is done?
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- Related module repositories:
|
||||||
|
- Extension point or integration boundary:
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command, scenario, or UI flow that should prove completion:
|
||||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: "Task"
|
||||||
|
about: "Track implementation, maintenance, or migration work"
|
||||||
|
title: "[Task] "
|
||||||
|
labels:
|
||||||
|
- type/task
|
||||||
|
- status/triage
|
||||||
|
- module/campaign
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
What needs to be completed?
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- In-scope:
|
||||||
|
- Out-of-scope:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or manual check:
|
||||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: "Tech debt"
|
||||||
|
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
|
||||||
|
title: "[Debt] "
|
||||||
|
labels:
|
||||||
|
- type/debt
|
||||||
|
- status/triage
|
||||||
|
- module/campaign
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Cost
|
||||||
|
|
||||||
|
What does this make harder, riskier, slower, or more fragile?
|
||||||
|
|
||||||
|
## Desired Shape
|
||||||
|
|
||||||
|
What should the code, tests, or architecture look like afterwards?
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
What behavior, compatibility, or module boundary must be preserved?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Focused checks that should pass:
|
||||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -327,3 +327,23 @@ cython_debug/
|
|||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
**/runtime/
|
**/runtime/
|
||||||
|
|
||||||
|
# GovOPlaN WebUI test output
|
||||||
|
webui/.policy-test-build/
|
||||||
|
webui/.template-preview-test-build/
|
||||||
|
webui/.import-test-build/
|
||||||
|
|
||||||
|
# GovOPlaN shared ignore rules from govoplan-core
|
||||||
|
# Local WebUI test/build scratch directories
|
||||||
|
.component-test-build/
|
||||||
|
.module-test-build/
|
||||||
|
.policy-test-build/
|
||||||
|
.template-preview-test-build/
|
||||||
|
.import-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
webui/.module-test-build/
|
||||||
|
*.db
|
||||||
|
# GovOPlaN local runtime state
|
||||||
|
runtime/
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
|||||||
38
AGENTS.md
Normal file
38
AGENTS.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# GovOPlaN Campaign Codex Guide
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This repository owns the `campaigns` module: campaign authoring, validation, message building, attachment resolution, queue/review/send control, reports, campaign module manifest, and `@govoplan/campaign-webui`.
|
||||||
|
|
||||||
|
Core owns auth, tenants, RBAC, database/session primitives, CSRF/API helpers, shared components, and shell layout. Files and mail integrations are optional and must be accessed through core module metadata or capabilities.
|
||||||
|
|
||||||
|
## Local Commands
|
||||||
|
|
||||||
|
Install and run through core:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Focused WebUI tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-campaign/webui
|
||||||
|
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:policy-ui
|
||||||
|
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:template-preview
|
||||||
|
```
|
||||||
|
|
||||||
|
For combined checks, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
./scripts/check-focused.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Working Rules
|
||||||
|
|
||||||
|
- Do not add required files/mail imports for campaign startup.
|
||||||
|
- Use core-provided capabilities or module metadata for optional file chooser, managed file usage, mail profile, delivery, and mailbox behavior.
|
||||||
|
- Shared WebUI components belong in core; campaign WebUI should consume them through `@govoplan/core-webui`.
|
||||||
|
- Avoid DataGrid changes unless explicitly requested.
|
||||||
42
README.md
42
README.md
@@ -13,17 +13,36 @@ This repository owns:
|
|||||||
- WebUI package `@govoplan/campaign-webui`
|
- WebUI package `@govoplan/campaign-webui`
|
||||||
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, `/address-book`, and `/templates`
|
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, `/address-book`, and `/templates`
|
||||||
|
|
||||||
Core owns auth, tenants, RBAC evaluation, database/session primitives, CSRF/API helpers, shell layout, and route rendering. Files and mail own their respective storage and transport capabilities.
|
Core owns the auth facade, RBAC/capability contracts, database/session
|
||||||
|
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
|
||||||
|
optional platform module for tenant administration and tenant resolver behavior.
|
||||||
|
Files and mail own their respective storage and transport capabilities.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
The module has runtime dependencies on:
|
The module has one required runtime dependency:
|
||||||
|
|
||||||
- `govoplan-core` for platform services
|
- `govoplan-core` for platform services, auth, RBAC, DB/session lifecycle, migrations, and WebUI shell integration
|
||||||
- `govoplan-files` for managed attachment integration
|
|
||||||
- `govoplan-mail` for SMTP/IMAP profile and delivery integration
|
|
||||||
|
|
||||||
Optional UI behavior can still check installed module availability through core module metadata, but the current backend package declares files and mail as required.
|
Files and mail are optional module integrations declared in the campaign manifest:
|
||||||
|
|
||||||
|
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Without it, campaigns can still use legacy/local attachment paths where configured.
|
||||||
|
- `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable.
|
||||||
|
|
||||||
|
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.
|
||||||
|
|
||||||
|
Campaign also provides narrow kernel capabilities so other modules and core
|
||||||
|
services can cooperate without importing campaign internals:
|
||||||
|
|
||||||
|
- `campaigns.access` for campaign share/existence checks
|
||||||
|
- `campaigns.mailPolicyContext` for campaign-scoped mail policy and owner
|
||||||
|
context
|
||||||
|
- `campaigns.policyContext` for retention/policy provenance
|
||||||
|
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
|
||||||
|
- `campaigns.retention` for campaign-owned retention cleanup
|
||||||
|
|
||||||
|
Keep these capability payloads narrow: stable ids, policy payloads, and task
|
||||||
|
results only.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -58,6 +77,15 @@ Frontend package:
|
|||||||
|
|
||||||
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
|
||||||
|
- [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
|
## Release packaging
|
||||||
|
|
||||||
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. The campaign WebUI package depends on the tagged files and mail WebUI packages for release builds.
|
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. Files and mail WebUI packages remain optional product-composition dependencies supplied by the core host build, not required campaign package dependencies.
|
||||||
|
|||||||
11
dev/mail-testbed/.env.example
Normal file
11
dev/mail-testbed/.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
GOVOPLAN_MAIL_TEST_LOCAL_PART=campaign-test
|
||||||
|
GOVOPLAN_MAIL_TEST_DOMAIN=govoplan.test
|
||||||
|
GOVOPLAN_MAIL_TEST_USER=campaign-test@govoplan.test
|
||||||
|
GOVOPLAN_MAIL_TEST_PASSWORD=campaign-test-password
|
||||||
|
GOVOPLAN_MAIL_TEST_RECIPIENT=campaign-test@govoplan.test
|
||||||
|
GOVOPLAN_MAIL_TEST_FROM=campaign-test@govoplan.test
|
||||||
|
GOVOPLAN_MAIL_TEST_SMTP_PORT=3025
|
||||||
|
GOVOPLAN_MAIL_TEST_IMAP_PORT=3143
|
||||||
|
GOVOPLAN_MAIL_TEST_SENT_FOLDER=Sent
|
||||||
|
GOVOPLAN_MAIL_TEST_ZIP_PASSWORD=zip-test-password
|
||||||
|
GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS=45
|
||||||
69
dev/mail-testbed/README.md
Normal file
69
dev/mail-testbed/README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Campaign SMTP/IMAP Test Bed
|
||||||
|
|
||||||
|
This test bed provides dedicated non-production SMTP/IMAP infrastructure for
|
||||||
|
campaign delivery checks. It uses GreenMail and must never be configured with
|
||||||
|
production recipients or credentials.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose --env-file .env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Default endpoints:
|
||||||
|
|
||||||
|
- SMTP: `127.0.0.1:3025`, plain transport
|
||||||
|
- IMAP: `127.0.0.1:3143`, plain transport
|
||||||
|
- GreenMail API/UI endpoint: `127.0.0.1:38080`
|
||||||
|
|
||||||
|
## Smoke Test
|
||||||
|
|
||||||
|
Run the transport smoke from the core virtual environment after installing the
|
||||||
|
campaign and mail modules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
/mnt/DATA/git/govoplan-core/.venv/bin/python run_transport_smoke.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke sends and appends three messages:
|
||||||
|
|
||||||
|
- no attachment
|
||||||
|
- one normal attachment
|
||||||
|
- one password-protected AES ZIP attachment
|
||||||
|
|
||||||
|
It verifies SMTP authentication, IMAP authentication, folder listing, delivery
|
||||||
|
to `INBOX`, and append-to-Sent behavior.
|
||||||
|
|
||||||
|
If the smoke is started immediately after `docker compose up -d`, GreenMail may
|
||||||
|
bind the SMTP/IMAP ports before the services are fully ready. The smoke retries
|
||||||
|
login and folder setup for `GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS`.
|
||||||
|
|
||||||
|
## Use With A Campaign
|
||||||
|
|
||||||
|
Use the same settings in a campaign mail profile:
|
||||||
|
|
||||||
|
- SMTP host `127.0.0.1`, port `3025`, security `plain`
|
||||||
|
- IMAP host `127.0.0.1`, port `3143`, security `plain`
|
||||||
|
- username and password from `.env`
|
||||||
|
- append folder from `GOVOPLAN_MAIL_TEST_SENT_FOLDER`
|
||||||
|
|
||||||
|
When changing the mailbox, keep `GOVOPLAN_MAIL_TEST_USER` equal to
|
||||||
|
`${GOVOPLAN_MAIL_TEST_LOCAL_PART}@${GOVOPLAN_MAIL_TEST_DOMAIN}` because the
|
||||||
|
compose file creates the GreenMail account from local part, password, and
|
||||||
|
domain while login uses the full email address.
|
||||||
|
|
||||||
|
Then run the controlled sending checklist from
|
||||||
|
`docs/CAMPAIGN_DELIVERY_RUNBOOK.md` for no attachment, one attachment, and
|
||||||
|
password-protected ZIP campaign variants.
|
||||||
|
|
||||||
|
## Stop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env down -v
|
||||||
|
```
|
||||||
17
dev/mail-testbed/docker-compose.yml
Normal file
17
dev/mail-testbed/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
greenmail:
|
||||||
|
image: greenmail/standalone:2.1.9
|
||||||
|
container_name: govoplan-campaign-mail-testbed
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GREENMAIL_OPTS: >-
|
||||||
|
-Dgreenmail.setup.test.all
|
||||||
|
-Dgreenmail.hostname=0.0.0.0
|
||||||
|
-Dgreenmail.auth.disabled=false
|
||||||
|
-Dgreenmail.users=${GOVOPLAN_MAIL_TEST_LOCAL_PART:-campaign-test}:${GOVOPLAN_MAIL_TEST_PASSWORD:-campaign-test-password}@${GOVOPLAN_MAIL_TEST_DOMAIN:-govoplan.test}
|
||||||
|
-Dgreenmail.users.login=email
|
||||||
|
-Dgreenmail.verbose
|
||||||
|
ports:
|
||||||
|
- "${GOVOPLAN_MAIL_TEST_SMTP_PORT:-3025}:3025"
|
||||||
|
- "${GOVOPLAN_MAIL_TEST_IMAP_PORT:-3143}:3143"
|
||||||
|
- "127.0.0.1:38080:8080"
|
||||||
167
dev/mail-testbed/run_transport_smoke.py
Normal file
167
dev/mail-testbed/run_transport_smoke.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import imaplib
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formatdate, make_msgid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
|
||||||
|
from govoplan_mail.backend.sending.imap import append_message_to_sent, list_imap_folders, list_imap_messages, test_imap_login
|
||||||
|
from govoplan_mail.backend.sending.smtp import send_email_message, test_smtp_login
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
||||||
|
|
||||||
|
|
||||||
|
def _env(name: str, default: str) -> str:
|
||||||
|
return os.environ.get(name, default).strip() or default
|
||||||
|
|
||||||
|
|
||||||
|
SMTP_HOST = _env("GOVOPLAN_MAIL_TEST_SMTP_HOST", "127.0.0.1")
|
||||||
|
IMAP_HOST = _env("GOVOPLAN_MAIL_TEST_IMAP_HOST", "127.0.0.1")
|
||||||
|
SMTP_PORT = int(_env("GOVOPLAN_MAIL_TEST_SMTP_PORT", "3025"))
|
||||||
|
IMAP_PORT = int(_env("GOVOPLAN_MAIL_TEST_IMAP_PORT", "3143"))
|
||||||
|
USERNAME = _env("GOVOPLAN_MAIL_TEST_USER", "campaign-test@govoplan.test")
|
||||||
|
PASSWORD = _env("GOVOPLAN_MAIL_TEST_PASSWORD", "campaign-test-password")
|
||||||
|
SENDER = _env("GOVOPLAN_MAIL_TEST_FROM", USERNAME)
|
||||||
|
RECIPIENT = _env("GOVOPLAN_MAIL_TEST_RECIPIENT", USERNAME)
|
||||||
|
SENT_FOLDER = _env("GOVOPLAN_MAIL_TEST_SENT_FOLDER", "Sent")
|
||||||
|
ZIP_PASSWORD = _env("GOVOPLAN_MAIL_TEST_ZIP_PASSWORD", "zip-test-password")
|
||||||
|
SERVICE_READY_TIMEOUT_SECONDS = int(_env("GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS", "45"))
|
||||||
|
|
||||||
|
|
||||||
|
def _smtp_config() -> SmtpConfig:
|
||||||
|
return SmtpConfig(
|
||||||
|
host=SMTP_HOST,
|
||||||
|
port=SMTP_PORT,
|
||||||
|
security=TransportSecurity.PLAIN,
|
||||||
|
username=USERNAME,
|
||||||
|
password=PASSWORD,
|
||||||
|
timeout_seconds=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _imap_config() -> ImapConfig:
|
||||||
|
return ImapConfig(
|
||||||
|
host=IMAP_HOST,
|
||||||
|
port=IMAP_PORT,
|
||||||
|
security=TransportSecurity.PLAIN,
|
||||||
|
username=USERNAME,
|
||||||
|
password=PASSWORD,
|
||||||
|
sent_folder=SENT_FOLDER,
|
||||||
|
timeout_seconds=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message(subject: str, body: str, attachments: list[Path] | None = None) -> EmailMessage:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = SENDER
|
||||||
|
msg["To"] = RECIPIENT
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["Date"] = formatdate(localtime=False)
|
||||||
|
msg["Message-ID"] = make_msgid(domain="govoplan.test")
|
||||||
|
msg.set_content(body)
|
||||||
|
|
||||||
|
for attachment in attachments or []:
|
||||||
|
content_type = mimetypes.guess_type(attachment.name)[0] or "application/octet-stream"
|
||||||
|
maintype, subtype = content_type.split("/", 1)
|
||||||
|
msg.add_attachment(attachment.read_bytes(), maintype=maintype, subtype=subtype, filename=attachment.name)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_folder(config: ImapConfig, folder: str) -> None:
|
||||||
|
client = imaplib.IMAP4(host=config.host or "", port=config.port or 143, timeout=config.timeout_seconds)
|
||||||
|
try:
|
||||||
|
if config.username and config.password:
|
||||||
|
client.login(config.username, config.password)
|
||||||
|
typ, data = client.create(folder)
|
||||||
|
if typ not in {"OK", "NO"}:
|
||||||
|
raise RuntimeError(f"Could not create IMAP folder {folder!r}: {data!r}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_delivery(config: ImapConfig, *, before_count: int, expected_increment: int) -> None:
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
last_count = before_count
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
result = list_imap_messages(imap_config=config, folder="INBOX", limit=10)
|
||||||
|
last_count = result.total_count
|
||||||
|
if last_count >= before_count + expected_increment:
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Expected at least {expected_increment} new INBOX message(s), "
|
||||||
|
f"but count moved from {before_count} to {last_count}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_service(label: str, check):
|
||||||
|
deadline = time.monotonic() + SERVICE_READY_TIMEOUT_SECONDS
|
||||||
|
last_error: Exception | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
return check()
|
||||||
|
except Exception as exc: # GreenMail can bind before SMTP/IMAP is ready.
|
||||||
|
last_error = exc
|
||||||
|
time.sleep(1)
|
||||||
|
raise RuntimeError(f"{label} did not become ready within {SERVICE_READY_TIMEOUT_SECONDS}s: {last_error}") from last_error
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
smtp = _smtp_config()
|
||||||
|
imap = _imap_config()
|
||||||
|
|
||||||
|
smtp_login = _wait_for_service("SMTP", lambda: test_smtp_login(smtp_config=smtp))
|
||||||
|
imap_login = _wait_for_service("IMAP", lambda: test_imap_login(imap_config=imap))
|
||||||
|
print(f"SMTP login OK: {smtp_login.host}:{smtp_login.port} authenticated={smtp_login.authenticated}")
|
||||||
|
print(f"IMAP login OK: {imap_login.host}:{imap_login.port} authenticated={imap_login.authenticated}")
|
||||||
|
|
||||||
|
_wait_for_service("IMAP folder creation", lambda: _ensure_folder(imap, SENT_FOLDER))
|
||||||
|
folders = _wait_for_service("IMAP folder listing", lambda: list_imap_folders(imap_config=imap))
|
||||||
|
print("IMAP folders: " + ", ".join(folder.name for folder in folders.folders))
|
||||||
|
|
||||||
|
before_inbox = list_imap_messages(imap_config=imap, folder="INBOX", limit=10).total_count
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-campaign-mail-test-") as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
attachment = root / "single-attachment.txt"
|
||||||
|
attachment.write_text("GovOPlaN single attachment smoke test\n", encoding="utf-8")
|
||||||
|
zip_source = root / "zip-source.txt"
|
||||||
|
zip_source.write_text("GovOPlaN password-protected ZIP smoke test\n", encoding="utf-8")
|
||||||
|
zip_path = create_zip_archive(root / "password-protected.zip", [zip_source], ZIP_PASSWORD)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
_message("[GovOPlaN test] no attachment", "No attachment SMTP/IMAP smoke."),
|
||||||
|
_message("[GovOPlaN test] one attachment", "One attachment SMTP/IMAP smoke.", [attachment]),
|
||||||
|
_message("[GovOPlaN test] password ZIP", "Password-protected ZIP SMTP/IMAP smoke.", [zip_path]),
|
||||||
|
]
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
result = send_email_message(
|
||||||
|
message,
|
||||||
|
smtp_config=smtp,
|
||||||
|
envelope_from=SENDER,
|
||||||
|
envelope_recipients=[RECIPIENT],
|
||||||
|
)
|
||||||
|
print(f"SMTP accepted {result.accepted_count} recipient(s): {message['Subject']}")
|
||||||
|
append = append_message_to_sent(bytes(message), imap_config=imap, folder=SENT_FOLDER)
|
||||||
|
print(f"IMAP appended {append.bytes_appended} byte(s) to {append.folder}: {message['Subject']}")
|
||||||
|
|
||||||
|
_wait_for_delivery(imap, before_count=before_inbox, expected_increment=3)
|
||||||
|
sent_count = list_imap_messages(imap_config=imap, folder=SENT_FOLDER, limit=10).total_count
|
||||||
|
if sent_count < 3:
|
||||||
|
raise RuntimeError(f"Expected at least 3 messages in {SENT_FOLDER!r}, found {sent_count}.")
|
||||||
|
print("Transport smoke OK: no attachment, one attachment, and password-protected ZIP messages verified.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
98
docs/CAMPAIGN_DELIVERY_RUNBOOK.md
Normal file
98
docs/CAMPAIGN_DELIVERY_RUNBOOK.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Campaign Delivery Runbook
|
||||||
|
|
||||||
|
This runbook covers controlled campaign delivery after a campaign version has
|
||||||
|
been validated, built, reviewed, and locked.
|
||||||
|
|
||||||
|
## Operating Modes
|
||||||
|
|
||||||
|
- Local direct send: `CELERY_ENABLED=false`. Queueing stores jobs in the DB, and
|
||||||
|
small development runs can be processed with "Send queued now".
|
||||||
|
- Worker send: `CELERY_ENABLED=true` with Redis/Celery workers running. Queueing
|
||||||
|
publishes delivery tasks, and the Review & Send page polls summary counters.
|
||||||
|
- Mock send: use only for development review. It does not prove real SMTP/IMAP
|
||||||
|
credentials or server policy.
|
||||||
|
|
||||||
|
## Before First Live Use
|
||||||
|
|
||||||
|
- Use dedicated non-production SMTP/IMAP credentials.
|
||||||
|
- Start the repository test bed in `dev/mail-testbed/` when a local
|
||||||
|
production-like SMTP/IMAP server is sufficient.
|
||||||
|
- Use a dedicated mailbox/folder for append-to-Sent tests.
|
||||||
|
- Confirm policy allows the SMTP host, envelope sender, recipients, and optional
|
||||||
|
IMAP append target.
|
||||||
|
- Run one campaign each for no attachment, one attachment, and password-protected
|
||||||
|
ZIP before using production recipients.
|
||||||
|
- Keep the report page open during tests; it is the operational source of truth
|
||||||
|
for attempts, outcomes, and reconciliation.
|
||||||
|
|
||||||
|
## Deliverability Preflight
|
||||||
|
|
||||||
|
Before the first live send for a sender domain or mail-server profile:
|
||||||
|
|
||||||
|
- Confirm the selected SMTP identity matches the visible From/envelope sender
|
||||||
|
policy.
|
||||||
|
- Confirm SPF, DKIM, and DMARC are handled by the sending infrastructure or
|
||||||
|
documented as out of scope for the selected test environment.
|
||||||
|
- Confirm rate limits are explicitly set for the expected provider and recipient
|
||||||
|
volume.
|
||||||
|
- Confirm bounce/reply/notification addresses are monitored by an operational
|
||||||
|
mailbox or intentionally disabled.
|
||||||
|
- Confirm large attachments and password-protected ZIPs are acceptable for the
|
||||||
|
recipient systems.
|
||||||
|
- Confirm owner transfer or policy changes force profile reselection and
|
||||||
|
revalidation before live delivery.
|
||||||
|
|
||||||
|
## Queue And Send
|
||||||
|
|
||||||
|
1. Validate the version with file checks enabled.
|
||||||
|
2. Build the version and inspect all blocking review items.
|
||||||
|
3. Queue only after the selected version is the intended immutable execution
|
||||||
|
version.
|
||||||
|
4. In local mode, use "Send queued now" for small test runs.
|
||||||
|
5. In worker mode, verify queue counters move from queued/claimed/sending to a
|
||||||
|
terminal SMTP state.
|
||||||
|
|
||||||
|
## Outcome Handling
|
||||||
|
|
||||||
|
- `smtp_accepted`: Do not retry. If IMAP append is enabled and pending, run or
|
||||||
|
enqueue the append action.
|
||||||
|
- `failed_temporary`: Retry explicitly after checking the error and retry count.
|
||||||
|
- `failed_permanent`: Retry only if the operator has corrected the root cause and
|
||||||
|
intentionally includes permanent failures.
|
||||||
|
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
|
||||||
|
provider control panels, then reconcile as accepted or not sent.
|
||||||
|
- `claimed` or `sending` that does not progress: treat as a worker interruption.
|
||||||
|
Re-run worker handling or reconcile if SMTP may already have accepted the
|
||||||
|
message.
|
||||||
|
|
||||||
|
## Reconciliation
|
||||||
|
|
||||||
|
- Choose "Accepted" only with external evidence that SMTP accepted the message.
|
||||||
|
The job becomes protected from retry and may proceed to IMAP append.
|
||||||
|
- Choose "Not sent" only when SMTP did not accept the message. The job becomes a
|
||||||
|
temporary failure and can be selected by explicit retry.
|
||||||
|
- Add a note that identifies the evidence used, for example SMTP log line,
|
||||||
|
provider message ID, or operator ticket.
|
||||||
|
|
||||||
|
## Fault Injection Checklist
|
||||||
|
|
||||||
|
Use mock infrastructure first, then repeat against the non-production real test
|
||||||
|
bed where possible:
|
||||||
|
|
||||||
|
- SMTP temporary failure.
|
||||||
|
- SMTP permanent failure.
|
||||||
|
- Recipient refusal after partial SMTP acceptance.
|
||||||
|
- Connection drop or worker interruption during SMTP.
|
||||||
|
- IMAP append failure after SMTP acceptance.
|
||||||
|
- Worker restart with queued, claimed, and sending jobs.
|
||||||
|
|
||||||
|
## Reporting Checks
|
||||||
|
|
||||||
|
- Partial delivery must show accepted, failed, and unknown counts separately.
|
||||||
|
- Accepted and unknown jobs must not appear in retry selections.
|
||||||
|
- Reconciled accepted jobs must remain protected from resend.
|
||||||
|
- Reconciled not-sent jobs must appear only as explicit retry candidates.
|
||||||
|
- The final CSV export should include message id, resolved envelope headers,
|
||||||
|
attachment evidence, EML reference/checksum, latest SMTP response/error, and
|
||||||
|
latest IMAP folder/error before the campaign is considered operationally
|
||||||
|
closed.
|
||||||
76
docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md
Normal file
76
docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Example Campaigns And Release Checklist
|
||||||
|
|
||||||
|
This document defines the campaign examples and release gates that should be
|
||||||
|
kept working before a GovOPlaN Campaign release is tagged.
|
||||||
|
|
||||||
|
## Example Campaign Set
|
||||||
|
|
||||||
|
Maintain fixtures or guided examples for these scenarios. The canonical
|
||||||
|
scenario catalogue lives in `examples/README.md`; committed fixture files should
|
||||||
|
be added under `examples/` only when they validate against the current campaign
|
||||||
|
schema and are safe to run in non-production environments.
|
||||||
|
|
||||||
|
- simple announcement with one active recipient and no attachments
|
||||||
|
- multi-recipient message with To, CC, BCC, Reply-To, bounce, and disposition
|
||||||
|
notification fields
|
||||||
|
- campaign with global attachments and recipient-specific attachment rules
|
||||||
|
- campaign with password-protected ZIP attachments
|
||||||
|
- campaign using a reusable mail profile from the mail module
|
||||||
|
- campaign using inline SMTP/IMAP settings where policy allows campaign-local
|
||||||
|
settings
|
||||||
|
- campaign with validation warnings that may be sent only after explicit review
|
||||||
|
- campaign with blocked recipients or attachment errors that must not be sent
|
||||||
|
- mock delivery campaign that captures SMTP and IMAP append messages in the mail
|
||||||
|
development mailbox
|
||||||
|
- real non-production delivery campaign against the GreenMail test bed
|
||||||
|
|
||||||
|
## Fixture Rules
|
||||||
|
|
||||||
|
- Examples must not contain production recipient data or production credentials.
|
||||||
|
- Attachment examples should use deterministic small files and checksums.
|
||||||
|
- Secret values must be represented through saved-credential placeholders or
|
||||||
|
secret references.
|
||||||
|
- Examples that require optional modules must declare the required modules and
|
||||||
|
capabilities in their README or fixture metadata.
|
||||||
|
- Examples must stay valid when files or mail modules are physically absent,
|
||||||
|
with optional behavior disabled instead of import failures.
|
||||||
|
|
||||||
|
## Release Gates
|
||||||
|
|
||||||
|
Before tagging a campaign release:
|
||||||
|
|
||||||
|
- Review `examples/README.md` and update the scenario catalogue when a release
|
||||||
|
adds or removes delivery behavior.
|
||||||
|
- Run core module permutation tests with campaign installed both with and
|
||||||
|
without files/mail.
|
||||||
|
- Validate and build each maintained example campaign.
|
||||||
|
- Run the mock delivery example when the mail development mailbox capability is
|
||||||
|
enabled.
|
||||||
|
- Run the GreenMail SMTP/IMAP smoke for a non-production real delivery path.
|
||||||
|
- Confirm reusable mail profile selection is revalidated after campaign owner
|
||||||
|
transfer.
|
||||||
|
- Confirm inline SMTP/IMAP settings are hidden or blocked when policy disables
|
||||||
|
campaign-local mail settings.
|
||||||
|
- Confirm delivery reports include SMTP outcome, IMAP append outcome, latest
|
||||||
|
error, generated EML reference, and attachment evidence.
|
||||||
|
- Confirm retries cannot resend messages already accepted by SMTP unless an
|
||||||
|
explicit reconciliation path allows it.
|
||||||
|
|
||||||
|
## Ownership Transfer Check
|
||||||
|
|
||||||
|
Reusable user/group mail profiles are owner-context-sensitive. When campaign
|
||||||
|
ownership changes, the editable current version must require profile reselection
|
||||||
|
and validation before live delivery. A locked delivery-final version should not
|
||||||
|
be silently rewritten.
|
||||||
|
|
||||||
|
## Delivery Checklist
|
||||||
|
|
||||||
|
Use the Review & Send preflight panel and the delivery runbook together:
|
||||||
|
|
||||||
|
1. Validate and resolve all blocking policy/data issues.
|
||||||
|
2. Build exact messages.
|
||||||
|
3. Review warnings, generated recipients, body content, and attachment evidence.
|
||||||
|
4. Run mock delivery if available for the release channel.
|
||||||
|
5. Test SMTP and IMAP settings against non-production infrastructure.
|
||||||
|
6. Send only after queue, rate limit, and append-to-Sent behavior are understood.
|
||||||
|
7. Reconcile failed, unknown, or pending jobs from the report/audit surfaces.
|
||||||
64
docs/RECIPIENT_ADDRESS_BOUNDARY.md
Normal file
64
docs/RECIPIENT_ADDRESS_BOUNDARY.md
Normal file
@@ -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.
|
||||||
87
docs/RECIPIENT_IMPORT_GUIDE.md
Normal file
87
docs/RECIPIENT_IMPORT_GUIDE.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Recipient Import Guide
|
||||||
|
|
||||||
|
Recipient import lets campaign authors turn spreadsheet-like source data into
|
||||||
|
campaign-local recipient entries. It is intentionally campaign-local 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.
|
||||||
45
examples/README.md
Normal file
45
examples/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# GovOPlaN Campaign Examples
|
||||||
|
|
||||||
|
These examples are the maintained scenario catalogue for campaign release
|
||||||
|
checks. They are intentionally small and credential-free. Add concrete fixture
|
||||||
|
files next to this README only when they can be validated by the current
|
||||||
|
campaign schema and do not require production data.
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
|
||||||
|
| Scenario | Required Modules | Release Check |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `simple-announcement` | core, access, campaigns | Validate and build one active recipient without attachments. |
|
||||||
|
| `addressing-matrix` | core, access, campaigns | Exercise To, CC, BCC, Reply-To, bounce, and disposition-notification fields. |
|
||||||
|
| `global-attachment` | core, access, campaigns; optional files | Build one deterministic attachment and verify evidence. |
|
||||||
|
| `recipient-attachment-rules` | core, access, campaigns; optional files | Match recipient-specific attachment rules and verify per-recipient evidence. |
|
||||||
|
| `zip-protected` | core, access, campaigns | Build password-protected AES ZIP output and verify password-source metadata. |
|
||||||
|
| `mail-profile-send` | core, access, campaigns, mail | Select a reusable mail profile and send through the GreenMail test bed. |
|
||||||
|
| `inline-mail-settings` | core, access, campaigns, mail | Use campaign-local SMTP/IMAP settings only when policy allows it. |
|
||||||
|
| `warnings-review` | core, access, campaigns | Require explicit review before queueing jobs with warnings. |
|
||||||
|
| `blocked-send` | core, access, campaigns | Confirm blocked recipients or missing attachments cannot be queued. |
|
||||||
|
| `mock-delivery` | core, access, campaigns, mail with dev capability | Capture messages in the development mailbox. |
|
||||||
|
| `greenmail-delivery` | core, access, campaigns, mail | Send no-attachment, normal attachment, and ZIP attachment variants through `dev/mail-testbed`. |
|
||||||
|
|
||||||
|
## Fixture Rules
|
||||||
|
|
||||||
|
- Do not commit real recipients, mail credentials, or production attachment
|
||||||
|
names.
|
||||||
|
- Keep attachments deterministic and small.
|
||||||
|
- Store secrets as placeholders, saved-credential references, or local `.env`
|
||||||
|
values consumed by the test bed.
|
||||||
|
- Declare optional module requirements in fixture metadata.
|
||||||
|
- Fixtures must not import files or mail modules directly; optional behavior is
|
||||||
|
discovered through core module metadata and capabilities.
|
||||||
|
|
||||||
|
## Validation Flow
|
||||||
|
|
||||||
|
Before a release tag:
|
||||||
|
|
||||||
|
1. Run module permutation startup checks from core.
|
||||||
|
2. Validate every committed example fixture against the current campaign schema.
|
||||||
|
3. Build exact messages for each fixture.
|
||||||
|
4. Run the mock-delivery example when the dev mailbox capability is enabled.
|
||||||
|
5. Run `dev/mail-testbed/run_transport_smoke.py`.
|
||||||
|
6. Execute the delivery checklist in
|
||||||
|
`docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md`.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,12 +19,11 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1",
|
"read-excel-file": "9.2.0"
|
||||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.1"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.1",
|
"@govoplan/core-webui": "^0.1.6",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
|
|||||||
@@ -4,20 +4,18 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-campaign"
|
name = "govoplan-campaign"
|
||||||
version = "0.1.1"
|
version = "0.1.6"
|
||||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.1",
|
"govoplan-core>=0.1.6",
|
||||||
"govoplan-files>=0.1.1",
|
|
||||||
"govoplan-mail>=0.1.1",
|
|
||||||
"jsonschema>=4,<5",
|
"jsonschema>=4,<5",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
"pyzipper>=0.3,<1",
|
"pyzipper>=0.4,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
@@ -69,6 +71,10 @@ class ResolvedAttachment(BaseModel):
|
|||||||
zip_mode: ZipRuleMode = ZipRuleMode.INHERIT
|
zip_mode: ZipRuleMode = ZipRuleMode.INHERIT
|
||||||
zip_archive_id: str | None = None
|
zip_archive_id: str | None = None
|
||||||
zip_filename: str | None = None
|
zip_filename: str | None = None
|
||||||
|
message_filename_template: str | None = None
|
||||||
|
zip_entry_name_template: str | None = None
|
||||||
|
message_filenames: list[str] = Field(default_factory=list)
|
||||||
|
zip_entry_names: list[str] = Field(default_factory=list)
|
||||||
status: AttachmentMatchStatus
|
status: AttachmentMatchStatus
|
||||||
behavior: Behavior | None = None
|
behavior: Behavior | None = None
|
||||||
matches: list[str] = Field(default_factory=list)
|
matches: list[str] = Field(default_factory=list)
|
||||||
@@ -99,6 +105,7 @@ class AttachmentResolutionReport(BaseModel):
|
|||||||
attachments_base_path: str
|
attachments_base_path: str
|
||||||
entries_count: int
|
entries_count: int
|
||||||
entries: list[EntryAttachmentResolution] = Field(default_factory=list)
|
entries: list[EntryAttachmentResolution] = Field(default_factory=list)
|
||||||
|
profile: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ready_count(self) -> int:
|
def ready_count(self) -> int:
|
||||||
@@ -304,7 +311,55 @@ def _resolve_attachment_directory(
|
|||||||
return (legacy_root / rendered_base_dir).resolve(), None
|
return (legacy_root / rendered_base_dir).resolve(), None
|
||||||
|
|
||||||
|
|
||||||
def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
|
@dataclass(slots=True)
|
||||||
|
class AttachmentMatchIndex:
|
||||||
|
_files_by_directory: dict[tuple[str, bool], list[Path]] = field(default_factory=dict)
|
||||||
|
filesystem_scans: int = 0
|
||||||
|
indexed_files: int = 0
|
||||||
|
fallback_globs: int = 0
|
||||||
|
|
||||||
|
def _directory_key(self, directory: Path, *, recursive: bool) -> tuple[str, bool]:
|
||||||
|
return str(directory.resolve()), recursive
|
||||||
|
|
||||||
|
def _indexed_files(self, directory: Path, *, recursive: bool) -> list[Path]:
|
||||||
|
key = self._directory_key(directory, recursive=recursive)
|
||||||
|
if key not in self._files_by_directory:
|
||||||
|
iterator = directory.rglob("*") if recursive else directory.iterdir()
|
||||||
|
files = sorted(path for path in iterator if path.is_file())
|
||||||
|
self._files_by_directory[key] = files
|
||||||
|
self.filesystem_scans += 1
|
||||||
|
self.indexed_files += len(files)
|
||||||
|
return self._files_by_directory[key]
|
||||||
|
|
||||||
|
def iter_files(self, directory: Path, *, recursive: bool = True) -> list[Path]:
|
||||||
|
if not directory.exists() or not directory.is_dir():
|
||||||
|
return []
|
||||||
|
return list(self._indexed_files(directory, recursive=recursive))
|
||||||
|
|
||||||
|
def match_files(self, directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
|
||||||
|
if not directory.exists() or not directory.is_dir():
|
||||||
|
return []
|
||||||
|
if not include_subdirs and ("/" in file_filter or "\\" in file_filter):
|
||||||
|
# Preserve pathlib.glob semantics for legacy filters that include a
|
||||||
|
# relative path segment instead of only a filename pattern.
|
||||||
|
self.fallback_globs += 1
|
||||||
|
return sorted(path for path in directory.glob(file_filter) if path.is_file())
|
||||||
|
return sorted(path for path in self._indexed_files(directory, recursive=include_subdirs) if fnmatch.fnmatch(path.name, file_filter))
|
||||||
|
|
||||||
|
def stats(self, *, duration_ms: float, rules_resolved: int) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"duration_ms": round(duration_ms, 2),
|
||||||
|
"rules_resolved": rules_resolved,
|
||||||
|
"indexed_directories": len(self._files_by_directory),
|
||||||
|
"filesystem_scans": self.filesystem_scans,
|
||||||
|
"indexed_files": self.indexed_files,
|
||||||
|
"fallback_globs": self.fallback_globs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _match_files(directory: Path, file_filter: str, include_subdirs: bool, match_index: AttachmentMatchIndex | None = None) -> list[Path]:
|
||||||
|
if match_index is not None:
|
||||||
|
return match_index.match_files(directory, file_filter, include_subdirs)
|
||||||
if not directory.exists() or not directory.is_dir():
|
if not directory.exists() or not directory.is_dir():
|
||||||
return []
|
return []
|
||||||
if include_subdirs:
|
if include_subdirs:
|
||||||
@@ -343,6 +398,7 @@ def _resolve_one_config(
|
|||||||
scope: AttachmentScope,
|
scope: AttachmentScope,
|
||||||
index: int,
|
index: int,
|
||||||
config: AttachmentConfig,
|
config: AttachmentConfig,
|
||||||
|
match_index: AttachmentMatchIndex | None = None,
|
||||||
) -> ResolvedAttachment:
|
) -> ResolvedAttachment:
|
||||||
rendered_base_dir = _rendered_base_dir(config, values)
|
rendered_base_dir = _rendered_base_dir(config, values)
|
||||||
rendered_file_filter = _render_template(config.file_filter, values)
|
rendered_file_filter = _render_template(config.file_filter, values)
|
||||||
@@ -352,7 +408,7 @@ def _resolve_one_config(
|
|||||||
attachment_config=config,
|
attachment_config=config,
|
||||||
rendered_base_dir=rendered_base_dir,
|
rendered_base_dir=rendered_base_dir,
|
||||||
)
|
)
|
||||||
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)
|
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
|
||||||
allow_multiple = _rule_allows_multiple(config, rendered_file_filter)
|
allow_multiple = _rule_allows_multiple(config, rendered_file_filter)
|
||||||
|
|
||||||
issues: list[AttachmentIssue] = []
|
issues: list[AttachmentIssue] = []
|
||||||
@@ -389,6 +445,8 @@ def _resolve_one_config(
|
|||||||
zip_mode=config.zip.mode,
|
zip_mode=config.zip.mode,
|
||||||
zip_archive_id=archive.id if archive else None,
|
zip_archive_id=archive.id if archive else None,
|
||||||
zip_filename=_render_zip_filename(archive, values),
|
zip_filename=_render_zip_filename(archive, values),
|
||||||
|
message_filename_template=config.message_filename_template,
|
||||||
|
zip_entry_name_template=config.zip_entry_name_template,
|
||||||
status=status,
|
status=status,
|
||||||
behavior=behavior,
|
behavior=behavior,
|
||||||
matches=[str(path) for path in matches],
|
matches=[str(path) for path in matches],
|
||||||
@@ -417,6 +475,7 @@ def resolve_entry_attachments(
|
|||||||
campaign_file: str | Path,
|
campaign_file: str | Path,
|
||||||
entry: EntryConfig,
|
entry: EntryConfig,
|
||||||
entry_index: int,
|
entry_index: int,
|
||||||
|
match_index: AttachmentMatchIndex | None = None,
|
||||||
) -> EntryAttachmentResolution:
|
) -> EntryAttachmentResolution:
|
||||||
values = build_template_values(config, entry)
|
values = build_template_values(config, entry)
|
||||||
resolved: list[ResolvedAttachment] = []
|
resolved: list[ResolvedAttachment] = []
|
||||||
@@ -431,6 +490,7 @@ def resolve_entry_attachments(
|
|||||||
scope=scope,
|
scope=scope,
|
||||||
index=index,
|
index=index,
|
||||||
config=attachment_config,
|
config=attachment_config,
|
||||||
|
match_index=match_index,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -448,10 +508,13 @@ def resolve_entry_attachments(
|
|||||||
def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport:
|
def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport:
|
||||||
entries = load_campaign_entries(config, campaign_file=campaign_file)
|
entries = load_campaign_entries(config, campaign_file=campaign_file)
|
||||||
base_path = _resolve_path(campaign_file, config.attachments.base_paths[0].path if config.attachments.base_paths else config.attachments.base_path)
|
base_path = _resolve_path(campaign_file, config.attachments.base_paths[0].path if config.attachments.base_paths else config.attachments.base_path)
|
||||||
|
started = time.perf_counter()
|
||||||
|
match_index = AttachmentMatchIndex()
|
||||||
resolved_entries = [
|
resolved_entries = [
|
||||||
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index)
|
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index, match_index=match_index)
|
||||||
for index, entry in enumerate(entries, start=1)
|
for index, entry in enumerate(entries, start=1)
|
||||||
]
|
]
|
||||||
|
rules_resolved = sum(len(entry.attachments) for entry in resolved_entries)
|
||||||
return AttachmentResolutionReport(
|
return AttachmentResolutionReport(
|
||||||
campaign_id=config.campaign.id,
|
campaign_id=config.campaign.id,
|
||||||
campaign_name=config.campaign.name,
|
campaign_name=config.campaign.name,
|
||||||
@@ -459,4 +522,5 @@ def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str |
|
|||||||
attachments_base_path=str(base_path),
|
attachments_base_path=str(base_path),
|
||||||
entries_count=len(entries),
|
entries_count=len(entries),
|
||||||
entries=resolved_entries,
|
entries=resolved_entries,
|
||||||
|
profile=match_index.stats(duration_ms=(time.perf_counter() - started) * 1000, rules_resolved=rules_resolved),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,11 +47,20 @@ def _default_schema_path() -> Path:
|
|||||||
return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.json"
|
return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_schema_ui_path() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.ui.json"
|
||||||
|
|
||||||
|
|
||||||
def load_campaign_schema(schema_path: str | Path | None = None) -> dict[str, Any]:
|
def load_campaign_schema(schema_path: str | Path | None = None) -> dict[str, Any]:
|
||||||
path = Path(schema_path) if schema_path else _default_schema_path()
|
path = Path(schema_path) if schema_path else _default_schema_path()
|
||||||
return load_campaign_json(path)
|
return load_campaign_json(path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_campaign_schema_ui(schema_path: str | Path | None = None) -> dict[str, Any]:
|
||||||
|
path = Path(schema_path) if schema_path else _default_schema_ui_path()
|
||||||
|
return load_campaign_json(path)
|
||||||
|
|
||||||
|
|
||||||
def validate_against_schema(data: dict[str, Any], schema_path: str | Path | None = None) -> None:
|
def validate_against_schema(data: dict[str, Any], schema_path: str | Path | None = None) -> None:
|
||||||
schema = load_campaign_schema(schema_path)
|
schema = load_campaign_schema(schema_path)
|
||||||
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
|
from govoplan_core.mail.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials, TransportSecurity
|
||||||
|
|
||||||
|
|
||||||
class StrictModel(BaseModel):
|
class StrictModel(BaseModel):
|
||||||
@@ -108,12 +108,58 @@ class FieldDefinition(StrictModel):
|
|||||||
can_override: bool = True
|
can_override: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class MailServerCredentials(StrictModel):
|
||||||
|
smtp: TransportCredentials = Field(default_factory=TransportCredentials)
|
||||||
|
imap: TransportCredentials = Field(default_factory=TransportCredentials)
|
||||||
|
|
||||||
|
|
||||||
class ServerConfig(StrictModel):
|
class ServerConfig(StrictModel):
|
||||||
mail_profile_id: str | None = None
|
mail_profile_id: str | None = None
|
||||||
inherit_smtp_credentials: bool = True
|
inherit_smtp_credentials: bool = True
|
||||||
inherit_imap_credentials: bool = True
|
inherit_imap_credentials: bool = True
|
||||||
smtp: SmtpConfig | None = None
|
smtp: SmtpServerConfig | None = None
|
||||||
imap: ImapConfig | None = None
|
imap: ImapServerConfig | None = None
|
||||||
|
credentials: MailServerCredentials = Field(default_factory=MailServerCredentials)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_legacy_credentials(cls, value: Any) -> Any:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
data = dict(value)
|
||||||
|
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
|
||||||
|
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
|
||||||
|
for protocol in ("smtp", "imap"):
|
||||||
|
transport = data.get(protocol)
|
||||||
|
if not isinstance(transport, dict):
|
||||||
|
continue
|
||||||
|
next_transport = dict(transport)
|
||||||
|
next_credentials = dict(credentials.get(protocol) or {})
|
||||||
|
for field in ("username", "password"):
|
||||||
|
if field in next_transport and field not in next_credentials:
|
||||||
|
next_credentials[field] = next_transport[field]
|
||||||
|
next_transport.pop(field, None)
|
||||||
|
next_transport.pop("enabled", None)
|
||||||
|
data[protocol] = next_transport
|
||||||
|
if next_credentials:
|
||||||
|
credentials[protocol] = next_credentials
|
||||||
|
if credentials:
|
||||||
|
data["credentials"] = credentials
|
||||||
|
return data
|
||||||
|
|
||||||
|
def runtime_smtp_config(self) -> SmtpConfig | None:
|
||||||
|
if self.smtp is None:
|
||||||
|
return None
|
||||||
|
payload = self.smtp.model_dump(mode="json")
|
||||||
|
payload.update(self.credentials.smtp.model_dump(mode="json", exclude_none=True))
|
||||||
|
return SmtpConfig.model_validate(payload)
|
||||||
|
|
||||||
|
def runtime_imap_config(self) -> ImapConfig | None:
|
||||||
|
if self.imap is None:
|
||||||
|
return None
|
||||||
|
payload = self.imap.model_dump(mode="json")
|
||||||
|
payload.update(self.credentials.imap.model_dump(mode="json", exclude_none=True))
|
||||||
|
return ImapConfig.model_validate(payload)
|
||||||
|
|
||||||
|
|
||||||
class RecipientConfig(StrictModel):
|
class RecipientConfig(StrictModel):
|
||||||
@@ -180,10 +226,17 @@ class TemplateSourceConfig(StrictModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateBodyMode(StrEnum):
|
||||||
|
TEXT = "text"
|
||||||
|
HTML = "html"
|
||||||
|
BOTH = "both"
|
||||||
|
|
||||||
|
|
||||||
class TemplateConfig(StrictModel):
|
class TemplateConfig(StrictModel):
|
||||||
subject: str | None = None
|
subject: str | None = None
|
||||||
text: str | None = None
|
text: str | None = None
|
||||||
html: str | None = None
|
html: str | None = None
|
||||||
|
body_mode: TemplateBodyMode = TemplateBodyMode.BOTH
|
||||||
source: TemplateSourceConfig | None = None
|
source: TemplateSourceConfig | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
@@ -343,6 +396,8 @@ class AttachmentConfig(StrictModel):
|
|||||||
include_subdirs: bool = False
|
include_subdirs: bool = False
|
||||||
required: bool = True
|
required: bool = True
|
||||||
allow_multiple: bool = False
|
allow_multiple: bool = False
|
||||||
|
message_filename_template: str | None = None
|
||||||
|
zip_entry_name_template: str | None = None
|
||||||
|
|
||||||
@field_validator("type_", mode="before")
|
@field_validator("type_", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -434,6 +489,35 @@ class EntryConfig(StrictModel):
|
|||||||
last_sent: str | None = None
|
last_sent: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ImportColumnProvenance(StrictModel):
|
||||||
|
column_index: int = Field(ge=0)
|
||||||
|
header: str
|
||||||
|
kind: str
|
||||||
|
field_name: str | None = None
|
||||||
|
new_field_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ImportProvenance(StrictModel):
|
||||||
|
id: str
|
||||||
|
imported_at: str
|
||||||
|
mode: Literal["append", "replace"]
|
||||||
|
source_type: Literal["csv", "xlsx", "text"]
|
||||||
|
filename: str | None = None
|
||||||
|
sheet_name: str | None = None
|
||||||
|
encoding: str | None = None
|
||||||
|
delimiter: str | None = None
|
||||||
|
header_rows: int = Field(default=0, ge=0)
|
||||||
|
quoted: bool | None = None
|
||||||
|
value_separators: str | None = None
|
||||||
|
rows_total: int = Field(default=0, ge=0)
|
||||||
|
valid_rows: int = Field(default=0, ge=0)
|
||||||
|
invalid_rows: int = Field(default=0, ge=0)
|
||||||
|
imported_rows: int = Field(default=0, ge=0)
|
||||||
|
field_names_created: list[str] = Field(default_factory=list)
|
||||||
|
attachment_patterns: int = Field(default=0, ge=0)
|
||||||
|
mapping: list[ImportColumnProvenance] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class SourceConfig(StrictModel):
|
class SourceConfig(StrictModel):
|
||||||
type: SourceType
|
type: SourceType
|
||||||
path: str
|
path: str
|
||||||
@@ -447,6 +531,7 @@ class EntriesConfig(StrictModel):
|
|||||||
source: SourceConfig | None = None
|
source: SourceConfig | None = None
|
||||||
mapping: dict[str, str] | None = None
|
mapping: dict[str, str] | None = None
|
||||||
defaults: EntryConfig | None = None
|
defaults: EntryConfig | None = None
|
||||||
|
imports: list[ImportProvenance] = Field(default_factory=list)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def inline_or_external(self) -> "EntriesConfig":
|
def inline_or_external(self) -> "EntriesConfig":
|
||||||
|
|||||||
@@ -301,8 +301,17 @@ def validate_campaign_config(
|
|||||||
issues.extend(_attachment_path_issues(config))
|
issues.extend(_attachment_path_issues(config))
|
||||||
issues.extend(_zip_configuration_issues(config))
|
issues.extend(_zip_configuration_issues(config))
|
||||||
|
|
||||||
if config.server.imap and config.server.imap.enabled:
|
runtime_imap = config.server.runtime_imap_config()
|
||||||
missing = [name for name in ["host", "port", "username", "password"] if getattr(config.server.imap, name) in (None, "")]
|
if config.delivery.imap_append_sent.enabled:
|
||||||
|
if runtime_imap is None:
|
||||||
|
issues.append(_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"delivery_imap_enabled_without_server_imap",
|
||||||
|
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
|
||||||
|
"/delivery/imap_append_sent/enabled",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_imap, name) in (None, "")]
|
||||||
if missing:
|
if missing:
|
||||||
issues.append(_issue(
|
issues.append(_issue(
|
||||||
Severity.ERROR,
|
Severity.ERROR,
|
||||||
@@ -311,15 +320,8 @@ def validate_campaign_config(
|
|||||||
"/server/imap",
|
"/server/imap",
|
||||||
))
|
))
|
||||||
|
|
||||||
if config.delivery.imap_append_sent.enabled and not (config.server.imap and config.server.imap.enabled):
|
runtime_smtp = config.server.runtime_smtp_config()
|
||||||
issues.append(_issue(
|
if config.campaign.mode == "send" and not runtime_smtp:
|
||||||
Severity.WARNING,
|
|
||||||
"delivery_imap_enabled_without_server_imap",
|
|
||||||
"delivery.imap_append_sent is enabled, but server.imap.enabled is not true",
|
|
||||||
"/delivery/imap_append_sent/enabled",
|
|
||||||
))
|
|
||||||
|
|
||||||
if config.campaign.mode == "send" and not config.server.smtp:
|
|
||||||
issues.append(_issue(
|
issues.append(_issue(
|
||||||
Severity.ERROR,
|
Severity.ERROR,
|
||||||
"missing_smtp_config",
|
"missing_smtp_config",
|
||||||
@@ -327,8 +329,8 @@ def validate_campaign_config(
|
|||||||
"/server/smtp",
|
"/server/smtp",
|
||||||
))
|
))
|
||||||
|
|
||||||
if config.server.smtp:
|
if runtime_smtp:
|
||||||
missing = [name for name in ["host", "port"] if getattr(config.server.smtp, name) in (None, "")]
|
missing = [name for name in ["host", "port"] if getattr(runtime_smtp, name) in (None, "")]
|
||||||
if missing:
|
if missing:
|
||||||
issues.append(_issue(
|
issues.append(_issue(
|
||||||
Severity.WARNING,
|
Severity.WARNING,
|
||||||
|
|||||||
293
src/govoplan_campaign/backend/capabilities.py
Normal file
293
src/govoplan_campaign/backend/capabilities.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
|
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
|
||||||
|
from govoplan_core.core.campaigns import (
|
||||||
|
CampaignAccessProvider,
|
||||||
|
CampaignDeliveryTaskProvider,
|
||||||
|
CampaignMailPolicyContext,
|
||||||
|
CampaignMailPolicyContextProvider,
|
||||||
|
CampaignPolicyContext,
|
||||||
|
CampaignPolicyContextProvider,
|
||||||
|
CampaignRetentionProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||||
|
|
||||||
|
|
||||||
|
READ_ACTIONS = {"campaigns:campaign:read", "campaign:read"}
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider):
|
||||||
|
def get_campaign_mail_policy_context(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
) -> CampaignMailPolicyContext | None:
|
||||||
|
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||||
|
if campaign is None or campaign.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
return CampaignMailPolicyContext(
|
||||||
|
id=campaign.id,
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
owner_user_id=campaign.owner_user_id,
|
||||||
|
owner_group_id=campaign.owner_group_id,
|
||||||
|
mail_profile_policy=dict(campaign.mail_profile_policy or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_campaign_mail_profile_policy(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
policy: Mapping[str, object],
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||||
|
if campaign is None or campaign.tenant_id != tenant_id:
|
||||||
|
raise ValueError("Campaign policy not found")
|
||||||
|
payload = dict(policy)
|
||||||
|
campaign.mail_profile_policy = payload
|
||||||
|
session.add(campaign) # type: ignore[attr-defined]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def mail_policy_context_capability(context: object) -> CampaignMailPolicyContextService:
|
||||||
|
return CampaignMailPolicyContextService()
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignAccessService(CampaignAccessProvider):
|
||||||
|
def campaign_exists(self, session: object, *, tenant_id: str, campaign_id: str) -> bool:
|
||||||
|
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||||
|
return bool(campaign and campaign.tenant_id == tenant_id)
|
||||||
|
|
||||||
|
def can_read_campaign(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
user_id: str,
|
||||||
|
group_ids: Iterable[str] = (),
|
||||||
|
tenant_admin: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||||
|
if campaign is None or campaign.tenant_id != tenant_id:
|
||||||
|
return False
|
||||||
|
if tenant_admin or campaign.owner_user_id == user_id:
|
||||||
|
return True
|
||||||
|
group_id_set = {str(group_id) for group_id in group_ids}
|
||||||
|
if campaign.owner_group_id and campaign.owner_group_id in group_id_set:
|
||||||
|
return True
|
||||||
|
share_clauses = [
|
||||||
|
and_(
|
||||||
|
CampaignShare.target_type == "user",
|
||||||
|
CampaignShare.target_id == user_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if group_id_set:
|
||||||
|
share_clauses.append(
|
||||||
|
and_(
|
||||||
|
CampaignShare.target_type == "group",
|
||||||
|
CampaignShare.target_id.in_(group_id_set),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
share = (
|
||||||
|
session.query(CampaignShare) # type: ignore[attr-defined]
|
||||||
|
.filter(
|
||||||
|
CampaignShare.tenant_id == tenant_id,
|
||||||
|
CampaignShare.campaign_id == campaign.id,
|
||||||
|
CampaignShare.revoked_at.is_(None),
|
||||||
|
or_(*share_clauses),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return share is not None
|
||||||
|
|
||||||
|
def explain_resource_provenance(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
action: str,
|
||||||
|
) -> tuple[AccessDecisionProvenance, ...]:
|
||||||
|
normalized_type = resource_type.lower().strip()
|
||||||
|
if normalized_type not in {"campaign", "campaign_object", "campaigns:campaign"}:
|
||||||
|
return ()
|
||||||
|
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
|
||||||
|
if campaign is None or (principal.tenant_id and campaign.tenant_id != principal.tenant_id):
|
||||||
|
return (
|
||||||
|
AccessDecisionProvenance(
|
||||||
|
kind="resource",
|
||||||
|
id=resource_id,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source="campaigns.not_found",
|
||||||
|
details={"resource_type": "campaign", "found": False},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
items = [
|
||||||
|
AccessDecisionProvenance(
|
||||||
|
kind="resource",
|
||||||
|
id=campaign.id,
|
||||||
|
label=campaign.name,
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
source="campaigns.campaign",
|
||||||
|
details={
|
||||||
|
"resource_type": "campaign",
|
||||||
|
"external_id": campaign.external_id,
|
||||||
|
"status": campaign.status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
items.extend(_owner_provenance(campaign, principal))
|
||||||
|
items.extend(_tenant_admin_provenance(principal))
|
||||||
|
permission_values = {"read", "write"} if action in READ_ACTIONS else {"write"}
|
||||||
|
shares = (
|
||||||
|
session.query(CampaignShare) # type: ignore[attr-defined]
|
||||||
|
.filter(
|
||||||
|
CampaignShare.tenant_id == campaign.tenant_id,
|
||||||
|
CampaignShare.campaign_id == campaign.id,
|
||||||
|
CampaignShare.revoked_at.is_(None),
|
||||||
|
CampaignShare.permission.in_(sorted(permission_values)),
|
||||||
|
or_(
|
||||||
|
(CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.membership_id),
|
||||||
|
(CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(sorted(principal.group_ids))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for share in shares:
|
||||||
|
items.append(
|
||||||
|
AccessDecisionProvenance(
|
||||||
|
kind="share",
|
||||||
|
id=share.id,
|
||||||
|
label=share.permission,
|
||||||
|
tenant_id=share.tenant_id,
|
||||||
|
source="campaigns.share",
|
||||||
|
details={
|
||||||
|
"target_type": share.target_type,
|
||||||
|
"target_id": share.target_id,
|
||||||
|
"permission": share.permission,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(items)
|
||||||
|
|
||||||
|
|
||||||
|
def access_capability(context: object) -> CampaignAccessService:
|
||||||
|
return CampaignAccessService()
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_provenance(campaign: Campaign, principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]:
|
||||||
|
if campaign.owner_user_id and campaign.owner_user_id == principal.membership_id:
|
||||||
|
return (
|
||||||
|
AccessDecisionProvenance(
|
||||||
|
kind="owner",
|
||||||
|
id=campaign.owner_user_id,
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
source="campaigns.owner",
|
||||||
|
details={"owner_type": "user"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if campaign.owner_group_id and campaign.owner_group_id in principal.group_ids:
|
||||||
|
return (
|
||||||
|
AccessDecisionProvenance(
|
||||||
|
kind="owner",
|
||||||
|
id=campaign.owner_group_id,
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
source="campaigns.owner",
|
||||||
|
details={"owner_type": "group"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_admin_provenance(principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]:
|
||||||
|
if not scopes_grant_compatible(principal.scopes, "tenant:*"):
|
||||||
|
return ()
|
||||||
|
return (
|
||||||
|
AccessDecisionProvenance(
|
||||||
|
kind="policy",
|
||||||
|
id="tenant:*",
|
||||||
|
label="tenant:*",
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source="campaigns.tenant_admin",
|
||||||
|
details={"grant": "tenant_admin"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignPolicyContextService(CampaignPolicyContextProvider):
|
||||||
|
def get_campaign_policy_context(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
campaign_id: str,
|
||||||
|
) -> CampaignPolicyContext | None:
|
||||||
|
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||||
|
if campaign is None or (tenant_id is not None and campaign.tenant_id != tenant_id):
|
||||||
|
return None
|
||||||
|
return CampaignPolicyContext(
|
||||||
|
id=campaign.id,
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
owner_user_id=campaign.owner_user_id,
|
||||||
|
owner_group_id=campaign.owner_group_id,
|
||||||
|
settings=dict(campaign.settings or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_campaign_settings(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
settings: Mapping[str, object],
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||||
|
if campaign is None or campaign.tenant_id != tenant_id:
|
||||||
|
raise ValueError("Campaign privacy policy not found")
|
||||||
|
payload = dict(settings)
|
||||||
|
campaign.settings = payload
|
||||||
|
session.add(campaign) # type: ignore[attr-defined]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def policy_context_capability(context: object) -> CampaignPolicyContextService:
|
||||||
|
return CampaignPolicyContextService()
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider):
|
||||||
|
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
|
||||||
|
from govoplan_campaign.backend.sending.jobs import send_campaign_job
|
||||||
|
|
||||||
|
return send_campaign_job(session, job_id=job_id, enqueue_imap_task=enqueue_imap_task).as_dict() # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def append_sent_for_job(self, session: object, *, job_id: str) -> Mapping[str, object]:
|
||||||
|
from govoplan_campaign.backend.sending.jobs import append_sent_for_job
|
||||||
|
|
||||||
|
return append_sent_for_job(session, job_id=job_id).as_dict() # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService:
|
||||||
|
return CampaignDeliveryTaskService()
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignRetentionService(CampaignRetentionProvider):
|
||||||
|
def apply_retention(self, session: object, *, dry_run, now, policy_for_campaign_id):
|
||||||
|
from govoplan_campaign.backend.retention import apply_campaign_retention
|
||||||
|
|
||||||
|
return apply_campaign_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def retention_capability(context: object) -> CampaignRetentionService:
|
||||||
|
return CampaignRetentionService()
|
||||||
350
src/govoplan_campaign/backend/change_tracking.py
Normal file
350
src/govoplan_campaign/backend/change_tracking.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
@@ -10,11 +10,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
|
|
||||||
from govoplan_core.db.base import Base, TimestampMixin
|
from govoplan_core.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
try:
|
|
||||||
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership
|
|
||||||
except (ImportError, ModuleNotFoundError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def new_uuid() -> str:
|
def new_uuid() -> str:
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
@@ -113,10 +108,10 @@ class Campaign(Base, TimestampMixin):
|
|||||||
__table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),)
|
__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)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
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)
|
||||||
owner_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("access_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)
|
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)
|
external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -125,7 +120,6 @@ class Campaign(Base, TimestampMixin):
|
|||||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
tenant: Mapped[Tenant] = relationship()
|
|
||||||
versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
|
versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
|
||||||
jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
|
jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
|
||||||
|
|
||||||
@@ -135,15 +129,41 @@ class CampaignShare(Base, TimestampMixin):
|
|||||||
__table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),)
|
__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)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
campaign_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_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
target_id: Mapped[str] = mapped_column(String(36), 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)
|
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)
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipientImportMappingProfile(Base, TimestampMixin):
|
||||||
|
__tablename__ = "campaign_recipient_import_mapping_profiles"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_recipient_import_profiles_owner", "tenant_id", "owner_user_id"),
|
||||||
|
Index("ix_recipient_import_profiles_ordered_fp", "tenant_id", "owner_user_id", "ordered_header_fingerprint"),
|
||||||
|
Index("ix_recipient_import_profiles_unordered_fp", "tenant_id", "owner_user_id", "unordered_header_fingerprint"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
owner_user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
column_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
normalized_headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
ordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
unordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
delimiter: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
header_rows: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
quoted: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
value_separators: Mapped[str] = mapped_column(String(50), default=",;|", nullable=False)
|
||||||
|
mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
|
||||||
|
owner: Mapped["User"] = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class CampaignVersion(Base, TimestampMixin):
|
class CampaignVersion(Base, TimestampMixin):
|
||||||
__tablename__ = "campaign_versions"
|
__tablename__ = "campaign_versions"
|
||||||
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)
|
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)
|
||||||
@@ -177,7 +197,7 @@ class CampaignVersion(Base, TimestampMixin):
|
|||||||
autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
published_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_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
|
# Explicit user-requested lock. This is deliberately separate from
|
||||||
# locked_at, which represents the reversible validation lock used by the
|
# locked_at, which represents the reversible validation lock used by the
|
||||||
@@ -185,7 +205,7 @@ class CampaignVersion(Base, TimestampMixin):
|
|||||||
# RBAC permission for unlocking; permanent locks never unlock in place.
|
# 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_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_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)
|
validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
@@ -201,7 +221,7 @@ class CampaignJob(Base, TimestampMixin):
|
|||||||
__table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),)
|
__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)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
campaign_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)
|
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)
|
entry_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
@@ -241,7 +261,7 @@ class CampaignIssue(Base, TimestampMixin):
|
|||||||
__tablename__ = "campaign_issues"
|
__tablename__ = "campaign_issues"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
campaign_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)
|
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)
|
job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
@@ -257,7 +277,7 @@ class AttachmentBlob(Base, TimestampMixin):
|
|||||||
__table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),)
|
__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)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
mime_type: Mapped[str | None] = mapped_column(String(255))
|
mime_type: Mapped[str | None] = mapped_column(String(255))
|
||||||
@@ -269,8 +289,8 @@ class AttachmentInstance(Base, TimestampMixin):
|
|||||||
__tablename__ = "attachment_instances"
|
__tablename__ = "attachment_instances"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
owner_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("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)
|
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)
|
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))
|
logical_name: Mapped[str | None] = mapped_column(String(500))
|
||||||
@@ -319,7 +339,6 @@ __all__ = [
|
|||||||
"CampaignVersion",
|
"CampaignVersion",
|
||||||
"CampaignVersionFlow",
|
"CampaignVersionFlow",
|
||||||
"CampaignVersionWorkflowState",
|
"CampaignVersionWorkflowState",
|
||||||
"Group",
|
|
||||||
"ImapAppendAttempt",
|
"ImapAppendAttempt",
|
||||||
"IssueSeverity",
|
"IssueSeverity",
|
||||||
"JobBuildStatus",
|
"JobBuildStatus",
|
||||||
@@ -328,7 +347,4 @@ __all__ = [
|
|||||||
"JobSendStatus",
|
"JobSendStatus",
|
||||||
"JobValidationStatus",
|
"JobValidationStatus",
|
||||||
"SendAttempt",
|
"SendAttempt",
|
||||||
"Tenant",
|
|
||||||
"User",
|
|
||||||
"UserGroupMembership",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,26 +12,24 @@ from govoplan_campaign.backend.campaign.validation import validate_campaign_conf
|
|||||||
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
|
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
|
||||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||||
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
|
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
|
||||||
from govoplan_files.backend.storage.campaign_attachments import (
|
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||||
annotate_built_messages_with_managed_files,
|
|
||||||
prepared_campaign_snapshot,
|
|
||||||
public_attachment_summary_payload,
|
|
||||||
)
|
|
||||||
from govoplan_mail.backend.dev.mock_mailbox import (
|
|
||||||
clear_records,
|
|
||||||
consume_fail_next_imap,
|
|
||||||
consume_fail_next_smtp,
|
|
||||||
get_failures,
|
|
||||||
list_records,
|
|
||||||
record_imap_append,
|
|
||||||
record_smtp_delivery,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MockCampaignSendError(RuntimeError):
|
class MockCampaignSendError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_mailbox() -> Any | None:
|
||||||
|
return mail_integration().mock_mailbox()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mock_mailbox() -> Any:
|
||||||
|
mailbox = _mock_mailbox()
|
||||||
|
if mailbox is None:
|
||||||
|
raise MockCampaignSendError("Mail module is not available")
|
||||||
|
return mailbox
|
||||||
|
|
||||||
|
|
||||||
def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None:
|
def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None:
|
||||||
if address is None:
|
if address is None:
|
||||||
return None
|
return None
|
||||||
@@ -47,7 +45,7 @@ def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]:
|
def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]:
|
||||||
return [public_attachment_summary_payload(attachment) for attachment in message.attachments]
|
return [files_integration().public_attachment_summary_payload(attachment) for attachment in message.attachments]
|
||||||
|
|
||||||
|
|
||||||
def _message_payload(message: MessageDraft) -> dict[str, Any]:
|
def _message_payload(message: MessageDraft) -> dict[str, Any]:
|
||||||
@@ -92,7 +90,8 @@ def _raw_message_bytes(message: EmailMessage) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def _smtp_rejection_matches(recipients: list[str]) -> list[str]:
|
def _smtp_rejection_matches(recipients: list[str]) -> list[str]:
|
||||||
needle = (get_failures().get("smtp_reject_recipients_containing") or "").strip().lower()
|
mailbox = _mock_mailbox()
|
||||||
|
needle = ((mailbox.get_failures() if mailbox else {}).get("smtp_reject_recipients_containing") or "").strip().lower()
|
||||||
if not needle:
|
if not needle:
|
||||||
return []
|
return []
|
||||||
return [recipient for recipient in recipients if needle in recipient.lower()]
|
return [recipient for recipient in recipients if needle in recipient.lower()]
|
||||||
@@ -148,10 +147,12 @@ def run_mock_campaign_send(
|
|||||||
if not version or version.campaign_id != campaign.id:
|
if not version or version.campaign_id != campaign.id:
|
||||||
raise MockCampaignSendError("Campaign version not found or not part of campaign")
|
raise MockCampaignSendError("Campaign version not found or not part of campaign")
|
||||||
|
|
||||||
if clear_mailbox:
|
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
|
||||||
clear_records()
|
if clear_mailbox and mailbox is not None:
|
||||||
|
mailbox.clear_records()
|
||||||
|
|
||||||
with prepared_campaign_snapshot(
|
files = files_integration()
|
||||||
|
with files.prepared_campaign_snapshot(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
@@ -163,7 +164,7 @@ def run_mock_campaign_send(
|
|||||||
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
|
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
|
||||||
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files)
|
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files)
|
||||||
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
|
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
|
||||||
annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
|
files.annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
|
||||||
|
|
||||||
send_results: list[dict[str, Any]] = []
|
send_results: list[dict[str, Any]] = []
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
@@ -206,14 +207,14 @@ def run_mock_campaign_send(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if consume_fail_next_smtp():
|
if mailbox is not None and mailbox.consume_fail_next_smtp():
|
||||||
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
|
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
|
||||||
rejected = _smtp_rejection_matches(recipients)
|
rejected = _smtp_rejection_matches(recipients)
|
||||||
if rejected and len(rejected) == len(recipients):
|
if rejected and len(rejected) == len(recipients):
|
||||||
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
|
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
|
||||||
|
|
||||||
accepted = [recipient for recipient in recipients if recipient not in rejected]
|
accepted = [recipient for recipient in recipients if recipient not in rejected]
|
||||||
smtp_record = record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
|
smtp_record = mailbox.record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
row.update({
|
row.update({
|
||||||
"status": "sent",
|
"status": "sent",
|
||||||
@@ -226,14 +227,14 @@ def run_mock_campaign_send(
|
|||||||
|
|
||||||
if append_sent:
|
if append_sent:
|
||||||
try:
|
try:
|
||||||
if consume_fail_next_imap():
|
if mailbox is not None and mailbox.consume_fail_next_imap():
|
||||||
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
|
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
|
||||||
folder = "Sent"
|
folder = "Sent"
|
||||||
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
|
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
|
||||||
folder = config.delivery.imap_append_sent.folder
|
folder = config.delivery.imap_append_sent.folder
|
||||||
elif config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
|
elif config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
|
||||||
folder = config.server.imap.sent_folder
|
folder = config.server.imap.sent_folder
|
||||||
imap_record = record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
|
imap_record = mailbox.record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
|
||||||
imap_appended_count += 1
|
imap_appended_count += 1
|
||||||
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
|
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -286,5 +287,5 @@ def run_mock_campaign_send(
|
|||||||
"imap_failed_count": imap_failed_count,
|
"imap_failed_count": imap_failed_count,
|
||||||
"results": send_results,
|
"results": send_results,
|
||||||
},
|
},
|
||||||
"mailbox": {"messages": list_records(limit=200)},
|
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from enum import StrEnum
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .fields import Field, FieldConfiguration, FieldContents
|
|
||||||
from .recipients import Recipient, RecipientList
|
|
||||||
from .template import MailTemplate
|
|
||||||
|
|
||||||
|
|
||||||
class TransportSecurity(StrEnum):
|
|
||||||
PLAIN = "plain"
|
|
||||||
TLS = "tls"
|
|
||||||
STARTTLS = "starttls"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def standard_port(self) -> int:
|
|
||||||
return 465 if self == TransportSecurity.TLS else 587
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MailServerSettings:
|
|
||||||
server: str = ""
|
|
||||||
port: int | None = None
|
|
||||||
username: str = ""
|
|
||||||
password: str = ""
|
|
||||||
transport_security: TransportSecurity = TransportSecurity.PLAIN
|
|
||||||
|
|
||||||
def use_plain(self) -> "MailServerSettings":
|
|
||||||
self.transport_security = TransportSecurity.PLAIN
|
|
||||||
self.port = self.port or self.transport_security.standard_port
|
|
||||||
return self
|
|
||||||
|
|
||||||
def use_tls(self) -> "MailServerSettings":
|
|
||||||
self.transport_security = TransportSecurity.TLS
|
|
||||||
self.port = self.port or self.transport_security.standard_port
|
|
||||||
return self
|
|
||||||
|
|
||||||
def use_starttls(self) -> "MailServerSettings":
|
|
||||||
self.transport_security = TransportSecurity.STARTTLS
|
|
||||||
self.port = self.port or self.transport_security.standard_port
|
|
||||||
return self
|
|
||||||
|
|
||||||
def resolved_port(self) -> int:
|
|
||||||
return self.port or self.transport_security.standard_port
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class MailAttachmentConfig:
|
|
||||||
base_dir: Path
|
|
||||||
file_filter: str = "*"
|
|
||||||
include_subdirs: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MailEntry:
|
|
||||||
field_config: FieldConfiguration
|
|
||||||
is_active: bool = True
|
|
||||||
from_recipient: Recipient | None = None
|
|
||||||
to: RecipientList = field(default_factory=RecipientList)
|
|
||||||
cc: RecipientList = field(default_factory=RecipientList)
|
|
||||||
bcc: RecipientList = field(default_factory=RecipientList)
|
|
||||||
combine_to: bool = True
|
|
||||||
combine_cc: bool = True
|
|
||||||
combine_bcc: bool = True
|
|
||||||
attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
|
|
||||||
combine_attachments: bool = True
|
|
||||||
field_contents: FieldContents = field(init=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
self.field_contents = FieldContents(self.field_config)
|
|
||||||
|
|
||||||
def add_to(self, recipient: Recipient) -> "MailEntry":
|
|
||||||
self.to.add_recipient(recipient)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def add_cc(self, recipient: Recipient) -> "MailEntry":
|
|
||||||
self.cc.add_recipient(recipient)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def add_bcc(self, recipient: Recipient) -> "MailEntry":
|
|
||||||
self.bcc.add_recipient(recipient)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def no_combine_to(self) -> "MailEntry":
|
|
||||||
self.combine_to = False
|
|
||||||
return self
|
|
||||||
|
|
||||||
def combine_to_recipients(self) -> "MailEntry":
|
|
||||||
self.combine_to = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
def no_combine_attachments(self) -> "MailEntry":
|
|
||||||
self.combine_attachments = False
|
|
||||||
return self
|
|
||||||
|
|
||||||
def combine_attachments_with_global(self) -> "MailEntry":
|
|
||||||
self.combine_attachments = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
def add_mail_attachment_config(self, config: MailAttachmentConfig) -> "MailEntry":
|
|
||||||
self.attachment_configs.append(config)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailEntry":
|
|
||||||
if not self.field_contents.set_field_content_for_name(name, value):
|
|
||||||
raise KeyError(f"unknown field: {name}")
|
|
||||||
return self
|
|
||||||
|
|
||||||
def get_field_content_from_name(self, name: str) -> Field:
|
|
||||||
return self.field_contents.get_field_content_from_name(name)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MailCampaign:
|
|
||||||
mail_server_settings: MailServerSettings | None = None
|
|
||||||
global_from: Recipient | None = None
|
|
||||||
global_to: RecipientList = field(default_factory=RecipientList)
|
|
||||||
global_cc: RecipientList = field(default_factory=RecipientList)
|
|
||||||
global_bcc: RecipientList = field(default_factory=RecipientList)
|
|
||||||
individual_from: bool = False
|
|
||||||
individual_to: bool = False
|
|
||||||
individual_cc: bool = False
|
|
||||||
individual_bcc: bool = False
|
|
||||||
base_attachment_path: Path = Path(".")
|
|
||||||
global_attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
|
|
||||||
individual_attachments: bool = False
|
|
||||||
send_without_attachments: bool = True
|
|
||||||
field_config: FieldConfiguration = field(default_factory=FieldConfiguration)
|
|
||||||
field_contents: FieldContents = field(init=False)
|
|
||||||
subject_template: MailTemplate = field(default_factory=MailTemplate)
|
|
||||||
mail_template: MailTemplate = field(default_factory=MailTemplate)
|
|
||||||
mail_entries: list[MailEntry] = field(default_factory=list)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
self.field_contents = FieldContents(self.field_config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def with_server_settings(cls, settings: MailServerSettings) -> "MailCampaign":
|
|
||||||
return cls(mail_server_settings=settings)
|
|
||||||
|
|
||||||
def add_field(self, name: str, field_type) -> "MailCampaign":
|
|
||||||
from .fields import FieldDescription
|
|
||||||
self.field_config.add_field_at_end(FieldDescription(name, field_type))
|
|
||||||
self.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
|
|
||||||
for entry in self.mail_entries:
|
|
||||||
entry.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
|
|
||||||
return self
|
|
||||||
|
|
||||||
def set_from(self, recipient: Recipient) -> "MailCampaign":
|
|
||||||
self.global_from = recipient
|
|
||||||
return self
|
|
||||||
|
|
||||||
def add_to(self, recipient: Recipient) -> "MailCampaign":
|
|
||||||
self.global_to.add_recipient(recipient)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def allow_individual_to(self) -> "MailCampaign":
|
|
||||||
self.individual_to = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
def disallow_individual_to(self) -> "MailCampaign":
|
|
||||||
self.individual_to = False
|
|
||||||
return self
|
|
||||||
|
|
||||||
def allow_individual_attachments(self) -> "MailCampaign":
|
|
||||||
self.individual_attachments = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
def disallow_individual_attachments(self) -> "MailCampaign":
|
|
||||||
self.individual_attachments = False
|
|
||||||
return self
|
|
||||||
|
|
||||||
def dont_send_without_attachments(self) -> "MailCampaign":
|
|
||||||
self.send_without_attachments = False
|
|
||||||
return self
|
|
||||||
|
|
||||||
def send_without_attachments_allowed(self) -> "MailCampaign":
|
|
||||||
self.send_without_attachments = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
def add_new_mail_entry(self) -> MailEntry:
|
|
||||||
entry = MailEntry(self.field_config)
|
|
||||||
self.mail_entries.append(entry)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailCampaign":
|
|
||||||
if not self.field_contents.set_field_content_for_name(name, value):
|
|
||||||
raise KeyError(f"unknown field: {name}")
|
|
||||||
return self
|
|
||||||
|
|
||||||
def get_field_content_from_name(self, name: str) -> Field:
|
|
||||||
return self.field_contents.get_field_content_from_name(name)
|
|
||||||
|
|
||||||
def all_recipients_for(self, entry: MailEntry) -> list[Recipient]:
|
|
||||||
recipients: list[Recipient] = []
|
|
||||||
if not self.individual_to or entry.combine_to:
|
|
||||||
recipients.extend(self.global_to.recipients)
|
|
||||||
if not self.individual_cc or entry.combine_cc:
|
|
||||||
recipients.extend(self.global_cc.recipients)
|
|
||||||
if not self.individual_bcc or entry.combine_bcc:
|
|
||||||
recipients.extend(self.global_bcc.recipients)
|
|
||||||
if self.individual_to:
|
|
||||||
recipients.extend(entry.to.recipients)
|
|
||||||
if self.individual_cc:
|
|
||||||
recipients.extend(entry.cc.recipients)
|
|
||||||
if self.individual_bcc:
|
|
||||||
recipients.extend(entry.bcc.recipients)
|
|
||||||
return recipients
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import date, datetime
|
|
||||||
from enum import StrEnum
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class FieldType(StrEnum):
|
|
||||||
STRING = "string"
|
|
||||||
INTEGER = "integer"
|
|
||||||
DOUBLE = "double"
|
|
||||||
DATE = "date"
|
|
||||||
PASSWORD = "password"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class FieldDescription:
|
|
||||||
name: str
|
|
||||||
type: FieldType = FieldType.STRING
|
|
||||||
can_override: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class Field:
|
|
||||||
content: Any
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def with_content(cls, content: Any) -> "Field":
|
|
||||||
if content is None:
|
|
||||||
raise ValueError("content must not be None")
|
|
||||||
return cls(content=content)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def type(self) -> FieldType:
|
|
||||||
if isinstance(self.content, bool):
|
|
||||||
return FieldType.STRING
|
|
||||||
if isinstance(self.content, int):
|
|
||||||
return FieldType.INTEGER
|
|
||||||
if isinstance(self.content, float):
|
|
||||||
return FieldType.DOUBLE
|
|
||||||
if isinstance(self.content, (date, datetime)):
|
|
||||||
return FieldType.DATE
|
|
||||||
if isinstance(self.content, (bytes, bytearray)):
|
|
||||||
return FieldType.PASSWORD
|
|
||||||
return FieldType.STRING
|
|
||||||
|
|
||||||
def as_string(self) -> str:
|
|
||||||
if isinstance(self.content, (bytes, bytearray)):
|
|
||||||
return self.content.decode("utf-8")
|
|
||||||
if isinstance(self.content, (date, datetime)):
|
|
||||||
return self.content.isoformat()
|
|
||||||
return str(self.content)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FieldConfiguration:
|
|
||||||
fields: list[FieldDescription] = field(default_factory=list)
|
|
||||||
|
|
||||||
def add_field_at_end(self, field_description: FieldDescription) -> "FieldConfiguration":
|
|
||||||
return self.add_field_at_position(len(self.fields), field_description)
|
|
||||||
|
|
||||||
def add_field_at_start(self, field_description: FieldDescription) -> "FieldConfiguration":
|
|
||||||
return self.add_field_at_position(0, field_description)
|
|
||||||
|
|
||||||
def add_field_at_position(self, position: int, field_description: FieldDescription) -> "FieldConfiguration":
|
|
||||||
if self.has_field(field_description.name):
|
|
||||||
raise ValueError(f"field already exists: {field_description.name}")
|
|
||||||
position = max(0, min(position, len(self.fields)))
|
|
||||||
self.fields.insert(position, field_description)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def has_field(self, name: str) -> bool:
|
|
||||||
return any(f.name == name for f in self.fields)
|
|
||||||
|
|
||||||
def get_field_description(self, name: str) -> FieldDescription | None:
|
|
||||||
return next((f for f in self.fields if f.name == name), None)
|
|
||||||
|
|
||||||
def get_field_names(self) -> list[str]:
|
|
||||||
return [f.name for f in self.fields]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FieldContents:
|
|
||||||
field_config: FieldConfiguration
|
|
||||||
field_map: dict[str, Field] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for field_description in self.field_config.fields:
|
|
||||||
self.ensure_field(field_description)
|
|
||||||
|
|
||||||
def ensure_field(self, field_description: FieldDescription) -> None:
|
|
||||||
if field_description.name in self.field_map:
|
|
||||||
return
|
|
||||||
match field_description.type:
|
|
||||||
case FieldType.INTEGER:
|
|
||||||
value = 0
|
|
||||||
case FieldType.DOUBLE:
|
|
||||||
value = 0.0
|
|
||||||
case FieldType.DATE:
|
|
||||||
value = date.today()
|
|
||||||
case FieldType.PASSWORD:
|
|
||||||
value = b""
|
|
||||||
case _:
|
|
||||||
value = ""
|
|
||||||
self.field_map[field_description.name] = Field.with_content(value)
|
|
||||||
|
|
||||||
def get_field_content_from_name(self, name: str) -> Field:
|
|
||||||
try:
|
|
||||||
return self.field_map[name]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise KeyError(f"unknown field: {name}") from exc
|
|
||||||
|
|
||||||
def set_field_content_for_name(self, name: str, value: Field | Any) -> bool:
|
|
||||||
if name not in self.field_map:
|
|
||||||
return False
|
|
||||||
if not isinstance(value, Field):
|
|
||||||
value = Field.with_content(value)
|
|
||||||
expected = self.field_map[name].type
|
|
||||||
if expected != value.type and expected != FieldType.PASSWORD:
|
|
||||||
raise TypeError(f"field {name!r} expects {expected}, got {value.type}")
|
|
||||||
self.field_map[name] = value
|
|
||||||
return True
|
|
||||||
|
|
||||||
def as_value_map(self, prefix: str) -> dict[str, str]:
|
|
||||||
return {f"{prefix}::{name}": field.as_string() for name, field in self.field_map.items()}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from email.message import EmailMessage
|
|
||||||
from typing import Iterator
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MailQueue:
|
|
||||||
messages: list[EmailMessage] = field(default_factory=list)
|
|
||||||
|
|
||||||
def add_mail(self, message: EmailMessage) -> None:
|
|
||||||
self.messages.append(message)
|
|
||||||
|
|
||||||
def remove_mail(self, message: EmailMessage) -> bool:
|
|
||||||
if message in self.messages:
|
|
||||||
self.messages.remove(message)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mail_count(self) -> int:
|
|
||||||
return len(self.messages)
|
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
|
||||||
return not self.messages
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[EmailMessage]:
|
|
||||||
return iter(self.messages)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from email.utils import formataddr
|
|
||||||
from enum import StrEnum
|
|
||||||
|
|
||||||
|
|
||||||
class RecipientType(StrEnum):
|
|
||||||
TO = "to"
|
|
||||||
CC = "cc"
|
|
||||||
BCC = "bcc"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Recipient:
|
|
||||||
address: str
|
|
||||||
name: str | None = None
|
|
||||||
type: RecipientType = RecipientType.TO
|
|
||||||
|
|
||||||
def formatted(self) -> str:
|
|
||||||
return formataddr((self.name or self.address, self.address))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RecipientList:
|
|
||||||
recipients: list[Recipient] = field(default_factory=list)
|
|
||||||
|
|
||||||
def add_recipient(self, recipient: Recipient) -> "RecipientList":
|
|
||||||
if recipient not in self.recipients:
|
|
||||||
self.recipients.append(recipient)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def add_multiple_recipients(self, recipients: list[Recipient] | tuple[Recipient, ...]) -> "RecipientList":
|
|
||||||
for recipient in recipients:
|
|
||||||
self.add_recipient(recipient)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def clear_all_recipients(self) -> "RecipientList":
|
|
||||||
self.recipients.clear()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def by_type(self, recipient_type: RecipientType) -> list[Recipient]:
|
|
||||||
return [r for r in self.recipients if r.type == recipient_type]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MailTemplate:
|
|
||||||
template_string: str = ""
|
|
||||||
|
|
||||||
def set_template_string(self, template: str) -> "MailTemplate":
|
|
||||||
self.template_string = template
|
|
||||||
return self
|
|
||||||
|
|
||||||
def get_used_fields(self) -> set[str]:
|
|
||||||
return set(_FIELD_PATTERN.findall(self.template_string))
|
|
||||||
|
|
||||||
def apply_values(self, values: dict[str, str], *, keep_missing: bool = True) -> str:
|
|
||||||
def replace(match: re.Match[str]) -> str:
|
|
||||||
key = match.group(1)
|
|
||||||
if key in values:
|
|
||||||
return values[key]
|
|
||||||
return match.group(0) if keep_missing else ""
|
|
||||||
|
|
||||||
rendered = _FIELD_PATTERN.sub(replace, self.template_string)
|
|
||||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
|
||||||
237
src/govoplan_campaign/backend/integrations.py
Normal file
237
src/govoplan_campaign/backend/integrations.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.runtime import capability
|
||||||
|
|
||||||
|
|
||||||
|
FILES_CAPABILITY = "files.campaign_attachments"
|
||||||
|
MAIL_CAPABILITY = "mail.campaign_delivery"
|
||||||
|
|
||||||
|
|
||||||
|
class OptionalModuleUnavailable(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpConfigurationError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpSendError(RuntimeError):
|
||||||
|
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.temporary = temporary
|
||||||
|
self.outcome_unknown = outcome_unknown
|
||||||
|
|
||||||
|
|
||||||
|
class ImapConfigurationError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ImapAppendError(RuntimeError):
|
||||||
|
def __init__(self, message: str, *, temporary: bool | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.temporary = temporary
|
||||||
|
|
||||||
|
|
||||||
|
class MailProfileError(OptionalModuleUnavailable):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _PreparedCampaignSnapshot:
|
||||||
|
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
|
||||||
|
self._directory = directory
|
||||||
|
self.path = path
|
||||||
|
self.raw_json = raw_json
|
||||||
|
self.managed_files_by_local_path: dict[str, Any] = {}
|
||||||
|
self.shared_assets: list[Any] = []
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
shutil.rmtree(self._directory, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FilesCampaignIntegration:
|
||||||
|
def __init__(self, delegate: Any | None = None) -> None:
|
||||||
|
self._delegate = delegate
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self._delegate is not None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def prepared_campaign_snapshot(self, *args: Any, **kwargs: Any) -> Iterator[Any]:
|
||||||
|
if self._delegate is not None:
|
||||||
|
with self._delegate.prepared_campaign_snapshot(*args, **kwargs) as prepared:
|
||||||
|
yield prepared
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_json = kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {}
|
||||||
|
prefix = str(kwargs.get("prefix") or "govoplan-campaign-")
|
||||||
|
directory = Path(tempfile.mkdtemp(prefix=prefix))
|
||||||
|
snapshot = _PreparedCampaignSnapshot(directory, directory / "campaign.json", raw_json)
|
||||||
|
snapshot.path.write_text(json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
try:
|
||||||
|
yield snapshot
|
||||||
|
finally:
|
||||||
|
snapshot.cleanup()
|
||||||
|
|
||||||
|
def managed_match_payloads(self, matches: Any, managed_files_by_local_path: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
if self._delegate is None:
|
||||||
|
return []
|
||||||
|
return self._delegate.managed_match_payloads(matches, managed_files_by_local_path)
|
||||||
|
|
||||||
|
def public_attachment_summary_payload(self, attachment: Any) -> dict[str, Any]:
|
||||||
|
if self._delegate is not None:
|
||||||
|
return self._delegate.public_attachment_summary_payload(attachment)
|
||||||
|
if hasattr(attachment, "model_dump"):
|
||||||
|
return attachment.model_dump(mode="json")
|
||||||
|
if isinstance(attachment, dict):
|
||||||
|
return dict(attachment)
|
||||||
|
return {"path": str(attachment)}
|
||||||
|
|
||||||
|
def annotate_built_messages_with_managed_files(self, built_messages: Any, managed_files_by_local_path: dict[str, Any]) -> None:
|
||||||
|
if self._delegate is not None:
|
||||||
|
self._delegate.annotate_built_messages_with_managed_files(built_messages, managed_files_by_local_path)
|
||||||
|
|
||||||
|
def record_campaign_attachment_uses_for_jobs(self, session: Any, jobs: Any, *, stage: str) -> None:
|
||||||
|
if self._delegate is not None:
|
||||||
|
self._delegate.record_campaign_attachment_uses_for_jobs(session, jobs, stage=stage)
|
||||||
|
|
||||||
|
def current_version_and_blob(self, session: Any, asset: Any) -> tuple[Any, Any]:
|
||||||
|
if self._delegate is None:
|
||||||
|
raise OptionalModuleUnavailable("Files module is not available")
|
||||||
|
return self._delegate.current_version_and_blob(session, asset)
|
||||||
|
|
||||||
|
def mark_job_attachment_uses_sent(self, session: Any, job: Any) -> None:
|
||||||
|
if self._delegate is not None:
|
||||||
|
self._delegate.mark_job_attachment_uses_sent(session, job)
|
||||||
|
|
||||||
|
|
||||||
|
class MailCampaignIntegration:
|
||||||
|
def __init__(self, delegate: Any | None = None) -> None:
|
||||||
|
self._delegate = delegate
|
||||||
|
if delegate is not None:
|
||||||
|
self.MailProfileError = getattr(delegate, "MailProfileError", MailProfileError)
|
||||||
|
|
||||||
|
MailProfileError = MailProfileError
|
||||||
|
SmtpConfigurationError = SmtpConfigurationError
|
||||||
|
SmtpSendError = SmtpSendError
|
||||||
|
ImapConfigurationError = ImapConfigurationError
|
||||||
|
ImapAppendError = ImapAppendError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self._delegate is not None
|
||||||
|
|
||||||
|
def _require(self) -> Any:
|
||||||
|
if self._delegate is None:
|
||||||
|
raise MailProfileError("Mail module is not available")
|
||||||
|
return self._delegate
|
||||||
|
|
||||||
|
def materialize_campaign_mail_profile_config(self, session: Any, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
if self._delegate is None:
|
||||||
|
raw_json = kwargs.get("raw_json")
|
||||||
|
if self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {}):
|
||||||
|
raise MailProfileError("Campaign mail-server profiles require the mail module")
|
||||||
|
return dict(raw_json) if isinstance(raw_json, dict) else {}
|
||||||
|
try:
|
||||||
|
return self._delegate.materialize_campaign_mail_profile_config(session, **kwargs)
|
||||||
|
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
|
def assert_campaign_mail_policy_allows_json(self, session: Any, **kwargs: Any) -> None:
|
||||||
|
if self._delegate is None:
|
||||||
|
raw_json = kwargs.get("raw_json")
|
||||||
|
profile_id = self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {})
|
||||||
|
if profile_id:
|
||||||
|
raise MailProfileError("Campaign mail-server profiles require the mail module")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self._delegate.assert_campaign_mail_policy_allows_json(session, **kwargs)
|
||||||
|
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
|
def assert_mail_policy_allows_send(self, session: Any, **kwargs: Any) -> None:
|
||||||
|
delegate = self._require()
|
||||||
|
try:
|
||||||
|
return delegate.assert_mail_policy_allows_send(session, **kwargs)
|
||||||
|
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
|
def mail_profile_id_from_campaign_json(self, raw_json: dict[str, Any]) -> str | None:
|
||||||
|
if self._delegate is not None:
|
||||||
|
return self._delegate.mail_profile_id_from_campaign_json(raw_json)
|
||||||
|
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||||
|
profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
|
||||||
|
if profile_id is None and isinstance(server, dict):
|
||||||
|
profile_id = server.get("profile_id")
|
||||||
|
return str(profile_id).strip() if profile_id else None
|
||||||
|
|
||||||
|
def ensure_mail_profile_allowed_for_campaign(self, session: Any, **kwargs: Any) -> Any:
|
||||||
|
delegate = self._require()
|
||||||
|
try:
|
||||||
|
return delegate.ensure_mail_profile_allowed_for_campaign(session, **kwargs)
|
||||||
|
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
|
def smtp_config_from_profile(self, profile: Any) -> Any:
|
||||||
|
return self._require().smtp_config_from_profile(profile)
|
||||||
|
|
||||||
|
def imap_config_from_profile(self, profile: Any) -> Any:
|
||||||
|
return self._require().imap_config_from_profile(profile)
|
||||||
|
|
||||||
|
def effective_profile_credentials_inherited(self, session: Any, **kwargs: Any) -> bool:
|
||||||
|
return self._require().effective_profile_credentials_inherited(session, **kwargs)
|
||||||
|
|
||||||
|
def apply_campaign_credentials(self, profile_payload: dict[str, Any], server: dict[str, Any], protocol: str) -> dict[str, Any]:
|
||||||
|
return self._require().apply_campaign_credentials(profile_payload, server, protocol)
|
||||||
|
|
||||||
|
def wait_for_rate_limit(self, **kwargs: Any) -> None:
|
||||||
|
if self._delegate is None:
|
||||||
|
return None
|
||||||
|
return self._delegate.wait_for_rate_limit(**kwargs)
|
||||||
|
|
||||||
|
def send_email_bytes(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
delegate = self._require()
|
||||||
|
try:
|
||||||
|
return delegate.send_email_bytes(*args, **kwargs)
|
||||||
|
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
|
||||||
|
raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc
|
||||||
|
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
|
||||||
|
raise SmtpConfigurationError(str(exc)) from exc
|
||||||
|
|
||||||
|
def append_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
delegate = self._require()
|
||||||
|
try:
|
||||||
|
return delegate.append_message_to_sent(*args, **kwargs)
|
||||||
|
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
|
||||||
|
raise ImapAppendError(str(exc), temporary=getattr(exc, "temporary", None)) from exc
|
||||||
|
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
|
||||||
|
raise ImapConfigurationError(str(exc)) from exc
|
||||||
|
|
||||||
|
def send_email_message(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
delegate = self._require()
|
||||||
|
try:
|
||||||
|
return delegate.send_email_message(*args, **kwargs)
|
||||||
|
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
|
||||||
|
raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc
|
||||||
|
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
|
||||||
|
raise SmtpConfigurationError(str(exc)) from exc
|
||||||
|
|
||||||
|
def mock_mailbox(self) -> Any | None:
|
||||||
|
if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"):
|
||||||
|
return None
|
||||||
|
return self._delegate.mock_mailbox()
|
||||||
|
|
||||||
|
|
||||||
|
def files_integration() -> FilesCampaignIntegration:
|
||||||
|
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
|
||||||
|
|
||||||
|
|
||||||
|
def mail_integration() -> MailCampaignIntegration:
|
||||||
|
return MailCampaignIntegration(capability(MAIL_CAPABILITY))
|
||||||
@@ -2,10 +2,32 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||||
|
from govoplan_core.core.campaigns import (
|
||||||
|
CAPABILITY_CAMPAIGNS_ACCESS,
|
||||||
|
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
|
||||||
|
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
||||||
|
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||||
|
CAPABILITY_CAMPAIGNS_RETENTION,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
FrontendModule,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_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
|
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:
|
def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
@@ -97,22 +119,57 @@ ROLE_TEMPLATES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _campaigns_router(context: ModuleContext):
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
del context
|
from govoplan_campaign.backend.db.models import Campaign
|
||||||
from govoplan_campaign.backend.router import router
|
|
||||||
|
|
||||||
return router
|
return {"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count()}
|
||||||
|
|
||||||
|
|
||||||
|
def _campaigns_router(context: ModuleContext):
|
||||||
|
from govoplan_campaign.backend.runtime import configure_runtime
|
||||||
|
|
||||||
|
configure_runtime(registry=context.registry, settings=context.settings)
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from govoplan_campaign.backend.router import router
|
||||||
|
from govoplan_campaign.backend.schema_router import router as schema_router
|
||||||
|
|
||||||
|
aggregate = APIRouter()
|
||||||
|
aggregate.include_router(router)
|
||||||
|
aggregate.include_router(schema_router)
|
||||||
|
return aggregate
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="campaigns",
|
id="campaigns",
|
||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="1.0.0",
|
version="0.1.6",
|
||||||
dependencies=("access", "files", "mail"),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=(),
|
optional_dependencies=("files", "mail"),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||||
|
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
|
||||||
|
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
||||||
|
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
|
||||||
|
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
|
||||||
|
),
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="files.campaign_attachments",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="mail.campaign_delivery",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_campaigns_router,
|
route_factory=_campaigns_router,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
nav_items=(
|
nav_items=(
|
||||||
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20),
|
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20),
|
||||||
NavItem(
|
NavItem(
|
||||||
@@ -157,10 +214,61 @@ manifest = ModuleManifest(
|
|||||||
module_id="campaigns",
|
module_id="campaigns",
|
||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
campaign_models.Campaign,
|
||||||
|
campaign_models.CampaignShare,
|
||||||
|
campaign_models.RecipientImportMappingProfile,
|
||||||
|
campaign_models.CampaignVersion,
|
||||||
|
campaign_models.CampaignJob,
|
||||||
|
campaign_models.CampaignIssue,
|
||||||
|
campaign_models.AttachmentBlob,
|
||||||
|
campaign_models.AttachmentInstance,
|
||||||
|
campaign_models.SendAttempt,
|
||||||
|
campaign_models.ImapAppendAttempt,
|
||||||
|
label="Campaigns",
|
||||||
),
|
),
|
||||||
|
retirement_notes="Destructive retirement drops campaign-owned database tables after the installer captures a database snapshot.",
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
campaign_models.Campaign,
|
||||||
|
campaign_models.CampaignShare,
|
||||||
|
campaign_models.RecipientImportMappingProfile,
|
||||||
|
campaign_models.CampaignVersion,
|
||||||
|
campaign_models.CampaignJob,
|
||||||
|
campaign_models.CampaignIssue,
|
||||||
|
campaign_models.AttachmentBlob,
|
||||||
|
campaign_models.AttachmentInstance,
|
||||||
|
campaign_models.SendAttempt,
|
||||||
|
campaign_models.ImapAppendAttempt,
|
||||||
|
label="Campaigns",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__(
|
||||||
|
"govoplan_campaign.backend.capabilities",
|
||||||
|
fromlist=["access_capability"],
|
||||||
|
).access_capability(context),
|
||||||
|
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS: lambda context: __import__(
|
||||||
|
"govoplan_campaign.backend.capabilities",
|
||||||
|
fromlist=["delivery_tasks_capability"],
|
||||||
|
).delivery_tasks_capability(context),
|
||||||
|
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT: lambda context: __import__(
|
||||||
|
"govoplan_campaign.backend.capabilities",
|
||||||
|
fromlist=["mail_policy_context_capability"],
|
||||||
|
).mail_policy_context_capability(context),
|
||||||
|
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT: lambda context: __import__(
|
||||||
|
"govoplan_campaign.backend.capabilities",
|
||||||
|
fromlist=["policy_context_capability"],
|
||||||
|
).policy_context_capability(context),
|
||||||
|
CAPABILITY_CAMPAIGNS_RETENTION: lambda context: __import__(
|
||||||
|
"govoplan_campaign.backend.capabilities",
|
||||||
|
fromlist=["retention_capability"],
|
||||||
|
).retention_capability(context),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import make_msgid, formatdate
|
from email.utils import make_msgid, formatdate
|
||||||
@@ -10,6 +11,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from govoplan_campaign.backend.attachments.resolver import (
|
from govoplan_campaign.backend.attachments.resolver import (
|
||||||
|
AttachmentMatchIndex,
|
||||||
AttachmentMatchStatus,
|
AttachmentMatchStatus,
|
||||||
EntryAttachmentResolution,
|
EntryAttachmentResolution,
|
||||||
MessageAttachmentStatus,
|
MessageAttachmentStatus,
|
||||||
@@ -27,6 +29,7 @@ from govoplan_campaign.backend.campaign.models import (
|
|||||||
MissingAddressBehavior,
|
MissingAddressBehavior,
|
||||||
RecipientConfig,
|
RecipientConfig,
|
||||||
SendStatus,
|
SendStatus,
|
||||||
|
TemplateBodyMode,
|
||||||
ZipArchiveConfig,
|
ZipArchiveConfig,
|
||||||
ZipPasswordMode,
|
ZipPasswordMode,
|
||||||
ZipPasswordScope,
|
ZipPasswordScope,
|
||||||
@@ -144,6 +147,13 @@ def _load_template_parts(config: CampaignConfig, campaign_file: str | Path) -> t
|
|||||||
return template.subject or "", template.text, template.html
|
return template.subject or "", template.text, template.html
|
||||||
|
|
||||||
|
|
||||||
|
def _template_body_mode(config: CampaignConfig) -> str:
|
||||||
|
mode = config.template.body_mode
|
||||||
|
if isinstance(mode, TemplateBodyMode):
|
||||||
|
return mode.value
|
||||||
|
return str(mode or TemplateBodyMode.BOTH.value)
|
||||||
|
|
||||||
|
|
||||||
def _issue_from_behavior(*, code: str, message: str, behavior: str, source: str) -> MessageIssue:
|
def _issue_from_behavior(*, code: str, message: str, behavior: str, source: str) -> MessageIssue:
|
||||||
severity = "error" if behavior == "block" else "warning"
|
severity = "error" if behavior == "block" else "warning"
|
||||||
return MessageIssue(severity=severity, code=code, message=message, behavior=behavior, source=source)
|
return MessageIssue(severity=severity, code=code, message=message, behavior=behavior, source=source)
|
||||||
@@ -181,6 +191,10 @@ def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[Message
|
|||||||
zip_mode=attachment.zip_mode.value,
|
zip_mode=attachment.zip_mode.value,
|
||||||
zip_archive_id=attachment.zip_archive_id,
|
zip_archive_id=attachment.zip_archive_id,
|
||||||
zip_filename=attachment.zip_filename,
|
zip_filename=attachment.zip_filename,
|
||||||
|
message_filename_template=attachment.message_filename_template,
|
||||||
|
zip_entry_name_template=attachment.zip_entry_name_template,
|
||||||
|
message_filenames=attachment.message_filenames,
|
||||||
|
zip_entry_names=attachment.zip_entry_names,
|
||||||
base_path_name=attachment.base_path_name,
|
base_path_name=attachment.base_path_name,
|
||||||
base_path=attachment.base_path,
|
base_path=attachment.base_path,
|
||||||
file_filter=attachment.file_filter,
|
file_filter=attachment.file_filter,
|
||||||
@@ -267,18 +281,6 @@ def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_i
|
|||||||
return filename if filename.lower().endswith(".zip") else f"{filename}.zip"
|
return filename if filename.lower().endswith(".zip") else f"{filename}.zip"
|
||||||
|
|
||||||
|
|
||||||
def _deduplicated_paths(paths: list[Path]) -> list[Path]:
|
|
||||||
unique: list[Path] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for path in paths:
|
|
||||||
key = str(path.resolve())
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
unique.append(path)
|
|
||||||
return unique
|
|
||||||
|
|
||||||
|
|
||||||
def _unique_attachment_filename(filename: str, used: set[str]) -> str:
|
def _unique_attachment_filename(filename: str, used: set[str]) -> str:
|
||||||
candidate = filename
|
candidate = filename
|
||||||
path = Path(filename)
|
path = Path(filename)
|
||||||
@@ -290,6 +292,59 @@ def _unique_attachment_filename(filename: str, used: set[str]) -> str:
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicated_archive_members(members: list[tuple[Path, str]]) -> list[tuple[Path, str]]:
|
||||||
|
unique: list[tuple[Path, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for path, archive_name in members:
|
||||||
|
key = str(path.resolve())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
unique.append((path, archive_name))
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_filename_values(
|
||||||
|
*,
|
||||||
|
values: dict[str, Any],
|
||||||
|
attachment: ResolvedAttachment,
|
||||||
|
path: Path,
|
||||||
|
position: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
filename_values = dict(values)
|
||||||
|
filename_values.update({
|
||||||
|
"file.name": path.name,
|
||||||
|
"file.stem": path.stem,
|
||||||
|
"file.suffix": path.suffix,
|
||||||
|
"file.ext": path.suffix[1:] if path.suffix.startswith(".") else path.suffix,
|
||||||
|
"file.index": position,
|
||||||
|
"attachment.id": attachment.attachment_id or "",
|
||||||
|
"attachment.label": attachment.label or "",
|
||||||
|
})
|
||||||
|
return filename_values
|
||||||
|
|
||||||
|
|
||||||
|
def _render_attachment_filename(
|
||||||
|
*,
|
||||||
|
template: str | None,
|
||||||
|
attachment: ResolvedAttachment,
|
||||||
|
path: Path,
|
||||||
|
values: dict[str, Any],
|
||||||
|
position: int,
|
||||||
|
) -> str:
|
||||||
|
if not template:
|
||||||
|
return path.name
|
||||||
|
rendered = _render_template(
|
||||||
|
template,
|
||||||
|
_attachment_filename_values(values=values, attachment=attachment, path=path, position=position),
|
||||||
|
keep_missing=False,
|
||||||
|
)
|
||||||
|
filename = _safe_filename(rendered, path.name)
|
||||||
|
if path.suffix and not Path(filename).suffix:
|
||||||
|
filename = f"{filename}{path.suffix}"
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
def _attach_files(
|
def _attach_files(
|
||||||
*,
|
*,
|
||||||
message: EmailMessage,
|
message: EmailMessage,
|
||||||
@@ -301,26 +356,50 @@ def _attach_files(
|
|||||||
work_dir: Path,
|
work_dir: Path,
|
||||||
) -> int:
|
) -> int:
|
||||||
attached_count = 0
|
attached_count = 0
|
||||||
archive_members: dict[str, list[Path]] = {}
|
archive_members: dict[str, list[tuple[Path, str]]] = {}
|
||||||
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
||||||
used_message_filenames: set[str] = set()
|
used_message_filenames: set[str] = set()
|
||||||
|
used_zip_member_filenames: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
for attachment in resolution.attachments:
|
||||||
|
attachment.message_filenames = []
|
||||||
|
attachment.zip_entry_names = []
|
||||||
|
|
||||||
for attachment in resolution.attachments:
|
for attachment in resolution.attachments:
|
||||||
if attachment.status != AttachmentMatchStatus.OK or not attachment.matches:
|
if attachment.status != AttachmentMatchStatus.OK or not attachment.matches:
|
||||||
continue
|
continue
|
||||||
match_paths = [Path(match) for match in attachment.matches]
|
match_paths = [Path(match) for match in attachment.matches]
|
||||||
if attachment.zip_enabled and attachment.zip_archive_id:
|
if attachment.zip_enabled and attachment.zip_archive_id:
|
||||||
archive_members.setdefault(attachment.zip_archive_id, []).extend(match_paths)
|
used_archive_names = used_zip_member_filenames.setdefault(attachment.zip_archive_id, set())
|
||||||
|
for position, path in enumerate(match_paths, start=1):
|
||||||
|
requested = _render_attachment_filename(
|
||||||
|
template=attachment.zip_entry_name_template,
|
||||||
|
attachment=attachment,
|
||||||
|
path=path,
|
||||||
|
values=values,
|
||||||
|
position=position,
|
||||||
|
)
|
||||||
|
archive_name = _unique_attachment_filename(requested, used_archive_names)
|
||||||
|
archive_members.setdefault(attachment.zip_archive_id, []).append((path, archive_name))
|
||||||
|
attachment.zip_entry_names.append(archive_name)
|
||||||
archive_attachments.setdefault(attachment.zip_archive_id, []).append(attachment)
|
archive_attachments.setdefault(attachment.zip_archive_id, []).append(attachment)
|
||||||
continue
|
continue
|
||||||
for path in match_paths:
|
for position, path in enumerate(match_paths, start=1):
|
||||||
filename = _unique_attachment_filename(path.name, used_message_filenames)
|
requested = _render_attachment_filename(
|
||||||
|
template=attachment.message_filename_template,
|
||||||
|
attachment=attachment,
|
||||||
|
path=path,
|
||||||
|
values=values,
|
||||||
|
position=position,
|
||||||
|
)
|
||||||
|
filename = _unique_attachment_filename(requested, used_message_filenames)
|
||||||
|
attachment.message_filenames.append(filename)
|
||||||
data, maintype, subtype = _attachment_bytes(path)
|
data, maintype, subtype = _attachment_bytes(path)
|
||||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||||
attached_count += 1
|
attached_count += 1
|
||||||
|
|
||||||
for archive in config.attachments.zip.archives:
|
for archive in config.attachments.zip.archives:
|
||||||
members = _deduplicated_paths(archive_members.get(archive.id, []))
|
members = _deduplicated_archive_members(archive_members.get(archive.id, []))
|
||||||
if not members:
|
if not members:
|
||||||
continue
|
continue
|
||||||
filename = _unique_attachment_filename(_archive_filename(archive, values, entry_index), used_message_filenames)
|
filename = _unique_attachment_filename(_archive_filename(archive, values, entry_index), used_message_filenames)
|
||||||
@@ -361,8 +440,9 @@ def build_entry_message(
|
|||||||
output_dir: Path | None = None,
|
output_dir: Path | None = None,
|
||||||
write_eml: bool = False,
|
write_eml: bool = False,
|
||||||
work_dir: Path | None = None,
|
work_dir: Path | None = None,
|
||||||
|
attachment_match_index: AttachmentMatchIndex | None = None,
|
||||||
) -> BuiltMessage:
|
) -> BuiltMessage:
|
||||||
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index)
|
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index, match_index=attachment_match_index)
|
||||||
effective_addresses = effective_address_lists(config, entry)
|
effective_addresses = effective_address_lists(config, entry)
|
||||||
senders = effective_addresses["from"]
|
senders = effective_addresses["from"]
|
||||||
sender = senders[0] if senders else None
|
sender = senders[0] if senders else None
|
||||||
@@ -412,17 +492,19 @@ def build_entry_message(
|
|||||||
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
|
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
|
||||||
|
|
||||||
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
|
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
|
||||||
|
body_mode = _template_body_mode(config)
|
||||||
values = build_template_values(config, entry)
|
values = build_template_values(config, entry)
|
||||||
keep_missing_placeholders = not config.validation_policy.ignore_empty_fields
|
keep_missing_placeholders = not config.validation_policy.ignore_empty_fields
|
||||||
subject = _render_template(subject_template, values, keep_missing=keep_missing_placeholders)
|
subject = _render_template(subject_template, values, keep_missing=keep_missing_placeholders)
|
||||||
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders) if text_template is not None else None
|
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders) if text_template is not None else None
|
||||||
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders) if html_template is not None else None
|
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders) if html_template is not None else None
|
||||||
|
|
||||||
unresolved = sorted(
|
unresolved_fields = _find_unresolved_placeholders(subject)
|
||||||
_find_unresolved_placeholders(subject)
|
if body_mode in {TemplateBodyMode.TEXT.value, TemplateBodyMode.BOTH.value}:
|
||||||
| _find_unresolved_placeholders(text_body)
|
unresolved_fields |= _find_unresolved_placeholders(text_body)
|
||||||
| _find_unresolved_placeholders(html_body)
|
if body_mode in {TemplateBodyMode.HTML.value, TemplateBodyMode.BOTH.value}:
|
||||||
)
|
unresolved_fields |= _find_unresolved_placeholders(html_body)
|
||||||
|
unresolved = sorted(unresolved_fields)
|
||||||
if unresolved:
|
if unresolved:
|
||||||
behavior = config.validation_policy.template_error.value
|
behavior = config.validation_policy.template_error.value
|
||||||
issues.append(
|
issues.append(
|
||||||
@@ -458,9 +540,14 @@ def build_entry_message(
|
|||||||
# bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender.
|
# bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender.
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
|
|
||||||
if html_body is not None:
|
effective_html_body = html_body if html_body and html_body.strip() else None
|
||||||
|
if body_mode == TemplateBodyMode.HTML.value:
|
||||||
|
message.set_content(effective_html_body or "", subtype="html")
|
||||||
|
elif body_mode == TemplateBodyMode.TEXT.value:
|
||||||
message.set_content(text_body or "")
|
message.set_content(text_body or "")
|
||||||
message.add_alternative(html_body, subtype="html")
|
elif effective_html_body is not None:
|
||||||
|
message.set_content(text_body or "")
|
||||||
|
message.add_alternative(effective_html_body, subtype="html")
|
||||||
else:
|
else:
|
||||||
message.set_content(text_body or "")
|
message.set_content(text_body or "")
|
||||||
|
|
||||||
@@ -526,6 +613,7 @@ def _unsent_attachment_issues(
|
|||||||
config: CampaignConfig,
|
config: CampaignConfig,
|
||||||
campaign_file: str | Path,
|
campaign_file: str | Path,
|
||||||
built_messages: list[BuiltMessage],
|
built_messages: list[BuiltMessage],
|
||||||
|
attachment_match_index: AttachmentMatchIndex | None = None,
|
||||||
) -> list[MessageIssue]:
|
) -> list[MessageIssue]:
|
||||||
behavior = config.validation_policy.unsent_attachment_files.value
|
behavior = config.validation_policy.unsent_attachment_files.value
|
||||||
if behavior == Behavior.CONTINUE.value:
|
if behavior == Behavior.CONTINUE.value:
|
||||||
@@ -545,6 +633,9 @@ def _unsent_attachment_issues(
|
|||||||
directory = _resolve(campaign_file, base_path.path)
|
directory = _resolve(campaign_file, base_path.path)
|
||||||
if not directory.exists() or not directory.is_dir():
|
if not directory.exists() or not directory.is_dir():
|
||||||
continue
|
continue
|
||||||
|
if attachment_match_index is not None:
|
||||||
|
all_files = sorted(path.resolve() for path in attachment_match_index.iter_files(directory, recursive=True))
|
||||||
|
else:
|
||||||
all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file())
|
all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file())
|
||||||
unsent = [path for path in all_files if path not in matched_files]
|
unsent = [path for path in all_files if path not in matched_files]
|
||||||
if not unsent:
|
if not unsent:
|
||||||
@@ -587,6 +678,8 @@ def build_campaign_messages(
|
|||||||
entries = load_campaign_entries(config, campaign_file=campaign_path)
|
entries = load_campaign_entries(config, campaign_file=campaign_path)
|
||||||
output_path = Path(output_dir).resolve() if output_dir is not None else None
|
output_path = Path(output_dir).resolve() if output_dir is not None else None
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
attachment_match_index = AttachmentMatchIndex()
|
||||||
with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp:
|
with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp:
|
||||||
work_dir = output_path or Path(tmp)
|
work_dir = output_path or Path(tmp)
|
||||||
built_messages = [
|
built_messages = [
|
||||||
@@ -598,15 +691,22 @@ def build_campaign_messages(
|
|||||||
output_dir=output_path,
|
output_dir=output_path,
|
||||||
write_eml=write_eml,
|
write_eml=write_eml,
|
||||||
work_dir=work_dir,
|
work_dir=work_dir,
|
||||||
|
attachment_match_index=attachment_match_index,
|
||||||
)
|
)
|
||||||
for index, entry in enumerate(entries, start=1)
|
for index, entry in enumerate(entries, start=1)
|
||||||
if entry.active
|
if entry.active
|
||||||
]
|
]
|
||||||
_apply_campaign_level_issues(
|
_apply_campaign_level_issues(
|
||||||
built_messages,
|
built_messages,
|
||||||
_unsent_attachment_issues(config=config, campaign_file=campaign_path, built_messages=built_messages),
|
_unsent_attachment_issues(
|
||||||
|
config=config,
|
||||||
|
campaign_file=campaign_path,
|
||||||
|
built_messages=built_messages,
|
||||||
|
attachment_match_index=attachment_match_index,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
|
||||||
report = CampaignBuildReport(
|
report = CampaignBuildReport(
|
||||||
campaign_id=config.campaign.id,
|
campaign_id=config.campaign.id,
|
||||||
campaign_name=config.campaign.name,
|
campaign_name=config.campaign.name,
|
||||||
@@ -614,5 +714,9 @@ def build_campaign_messages(
|
|||||||
entries_count=len(entries),
|
entries_count=len(entries),
|
||||||
inactive_entries_count=sum(1 for entry in entries if not entry.active),
|
inactive_entries_count=sum(1 for entry in entries if not entry.active),
|
||||||
messages=[built.draft for built in built_messages],
|
messages=[built.draft for built in built_messages],
|
||||||
|
attachment_resolution_profile=attachment_match_index.stats(
|
||||||
|
duration_ms=(time.perf_counter() - started) * 1000,
|
||||||
|
rules_resolved=rules_resolved,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return CampaignBuildResult(report=report, built_messages=built_messages)
|
return CampaignBuildResult(report=report, built_messages=built_messages)
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ class MessageAttachmentSummary(BaseModel):
|
|||||||
zip_mode: str = "inherit"
|
zip_mode: str = "inherit"
|
||||||
zip_archive_id: str | None = None
|
zip_archive_id: str | None = None
|
||||||
zip_filename: str | None = None
|
zip_filename: str | None = None
|
||||||
|
message_filename_template: str | None = None
|
||||||
|
zip_entry_name_template: str | None = None
|
||||||
|
message_filenames: list[str] = Field(default_factory=list)
|
||||||
|
zip_entry_names: list[str] = Field(default_factory=list)
|
||||||
base_path_name: str | None = None
|
base_path_name: str | None = None
|
||||||
base_path: str | None = None
|
base_path: str | None = None
|
||||||
file_filter: str
|
file_filter: str
|
||||||
@@ -109,6 +113,7 @@ class CampaignBuildReport(BaseModel):
|
|||||||
entries_count: int
|
entries_count: int
|
||||||
inactive_entries_count: int = 0
|
inactive_entries_count: int = 0
|
||||||
messages: list[MessageDraft] = Field(default_factory=list)
|
messages: list[MessageDraft] = Field(default_factory=list)
|
||||||
|
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def built_count(self) -> int:
|
def built_count(self) -> int:
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""recipient import mapping profiles
|
||||||
|
|
||||||
|
Revision ID: 2c3d4e5f7081
|
||||||
|
Revises: 2e3f4a5b6c7d
|
||||||
|
Create Date: 2026-06-26 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "2c3d4e5f7081"
|
||||||
|
down_revision = "2e3f4a5b6c7d"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_fk_target(inspector) -> str:
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "campaign_recipient_import_mapping_profiles" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
scope_fk_target = _scope_fk_target(inspector)
|
||||||
|
op.create_table(
|
||||||
|
"campaign_recipient_import_mapping_profiles",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("owner_user_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("column_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("headers", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("normalized_headers", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("ordered_header_fingerprint", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("unordered_header_fingerprint", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("delimiter", sa.String(length=8), nullable=False),
|
||||||
|
sa.Column("header_rows", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("quoted", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("value_separators", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("mappings", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_owner_user_id_users"), ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_scopes"), ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_campaign_recipient_import_mapping_profiles")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), "campaign_recipient_import_mapping_profiles", ["tenant_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_owner_user_id"), "campaign_recipient_import_mapping_profiles", ["owner_user_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint"), "campaign_recipient_import_mapping_profiles", ["ordered_header_fingerprint"], unique=False)
|
||||||
|
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint"), "campaign_recipient_import_mapping_profiles", ["unordered_header_fingerprint"], unique=False)
|
||||||
|
op.create_index("ix_recipient_import_profiles_owner", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id"], unique=False)
|
||||||
|
op.create_index("ix_recipient_import_profiles_ordered_fp", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id", "ordered_header_fingerprint"], unique=False)
|
||||||
|
op.create_index("ix_recipient_import_profiles_unordered_fp", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id", "unordered_header_fingerprint"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "campaign_recipient_import_mapping_profiles" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.drop_index("ix_recipient_import_profiles_unordered_fp", table_name="campaign_recipient_import_mapping_profiles")
|
||||||
|
op.drop_index("ix_recipient_import_profiles_ordered_fp", table_name="campaign_recipient_import_mapping_profiles")
|
||||||
|
op.drop_index("ix_recipient_import_profiles_owner", table_name="campaign_recipient_import_mapping_profiles")
|
||||||
|
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint"), table_name="campaign_recipient_import_mapping_profiles")
|
||||||
|
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint"), table_name="campaign_recipient_import_mapping_profiles")
|
||||||
|
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_owner_user_id"), table_name="campaign_recipient_import_mapping_profiles")
|
||||||
|
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), table_name="campaign_recipient_import_mapping_profiles")
|
||||||
|
op.drop_table("campaign_recipient_import_mapping_profiles")
|
||||||
@@ -31,13 +31,7 @@ from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
|||||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
from govoplan_campaign.backend.messages.models import MessageDraft
|
||||||
from govoplan_campaign.backend.sending.execution import create_execution_snapshot
|
from govoplan_campaign.backend.sending.execution import create_execution_snapshot
|
||||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||||
from govoplan_mail.backend.mail_profiles import materialize_campaign_mail_profile_config
|
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||||
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs
|
|
||||||
from govoplan_files.backend.storage.campaign_attachments import (
|
|
||||||
annotate_built_messages_with_managed_files,
|
|
||||||
prepared_campaign_snapshot,
|
|
||||||
public_attachment_summary_payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
|
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
|
||||||
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
|
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
|
||||||
@@ -64,7 +58,7 @@ def load_campaign_config_from_json(
|
|||||||
owner_user_id: str | None = None,
|
owner_user_id: str | None = None,
|
||||||
owner_group_id: str | None = None,
|
owner_group_id: str | None = None,
|
||||||
) -> CampaignConfig:
|
) -> CampaignConfig:
|
||||||
materialized = materialize_campaign_mail_profile_config(
|
materialized = mail_integration().materialize_campaign_mail_profile_config(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
raw_json=raw_json,
|
raw_json=raw_json,
|
||||||
@@ -274,7 +268,8 @@ def validate_campaign_version(
|
|||||||
raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.")
|
raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.")
|
||||||
|
|
||||||
if check_files:
|
if check_files:
|
||||||
with prepared_campaign_snapshot(
|
files = files_integration()
|
||||||
|
with files.prepared_campaign_snapshot(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
@@ -379,7 +374,7 @@ def _job_from_message(
|
|||||||
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
|
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
|
||||||
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
|
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
|
||||||
},
|
},
|
||||||
resolved_attachments=[public_attachment_summary_payload(item) for item in message.attachments],
|
resolved_attachments=[files_integration().public_attachment_summary_payload(item) for item in message.attachments],
|
||||||
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
|
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
|
||||||
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
|
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
|
||||||
)
|
)
|
||||||
@@ -405,7 +400,8 @@ def build_campaign_version(
|
|||||||
_ensure_version_validated_and_locked(version)
|
_ensure_version_validated_and_locked(version)
|
||||||
|
|
||||||
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
|
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
|
||||||
with prepared_campaign_snapshot(
|
files = files_integration()
|
||||||
|
with files.prepared_campaign_snapshot(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
@@ -416,11 +412,11 @@ def build_campaign_version(
|
|||||||
managed_raw = load_campaign_json(prepared.path)
|
managed_raw = load_campaign_json(prepared.path)
|
||||||
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
|
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
|
||||||
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
|
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
|
||||||
annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
|
files.annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
|
||||||
report_json = result.report.model_dump(mode="json", by_alias=True)
|
report_json = result.report.model_dump(mode="json", by_alias=True)
|
||||||
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
|
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
|
||||||
if isinstance(message_payload, dict):
|
if isinstance(message_payload, dict):
|
||||||
message_payload["attachments"] = [public_attachment_summary_payload(item) for item in message.attachments]
|
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
|
||||||
report_json["built_at"] = datetime.now(UTC).isoformat()
|
report_json["built_at"] = datetime.now(UTC).isoformat()
|
||||||
report_json["build_token"] = uuid4().hex
|
report_json["build_token"] = uuid4().hex
|
||||||
report_json.update({
|
report_json.update({
|
||||||
@@ -459,17 +455,18 @@ def build_campaign_version(
|
|||||||
# records in bulk. This avoids one flush plus several metadata queries per
|
# records in bulk. This avoids one flush plus several metadata queries per
|
||||||
# recipient for large campaigns.
|
# recipient for large campaigns.
|
||||||
session.flush()
|
session.flush()
|
||||||
record_campaign_attachment_uses_for_jobs(
|
files.record_campaign_attachment_uses_for_jobs(
|
||||||
session,
|
session,
|
||||||
[job for job, _message in job_build_pairs],
|
[job for job, _message in job_build_pairs],
|
||||||
stage="built",
|
stage="built",
|
||||||
)
|
)
|
||||||
if not managed_config.server.smtp:
|
runtime_smtp = managed_config.server.runtime_smtp_config()
|
||||||
|
if not runtime_smtp:
|
||||||
raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created")
|
raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created")
|
||||||
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
|
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
|
||||||
version,
|
version,
|
||||||
smtp=managed_config.server.smtp,
|
smtp=runtime_smtp,
|
||||||
imap=managed_config.server.imap,
|
imap=managed_config.server.runtime_imap_config(),
|
||||||
delivery=managed_config.delivery,
|
delivery=managed_config.delivery,
|
||||||
jobs=[job for job, _message in job_build_pairs],
|
jobs=[job for job, _message in job_build_pairs],
|
||||||
build_summary=report_json,
|
build_summary=report_json,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
JobSendStatus,
|
JobSendStatus,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
|
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
|
||||||
from govoplan_mail.backend.mail_profiles import assert_campaign_mail_policy_allows_json
|
from govoplan_campaign.backend.integrations import mail_integration
|
||||||
from govoplan_campaign.backend.persistence.campaigns import (
|
from govoplan_campaign.backend.persistence.campaigns import (
|
||||||
CampaignPersistenceError,
|
CampaignPersistenceError,
|
||||||
_next_version_number,
|
_next_version_number,
|
||||||
@@ -77,19 +77,18 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
|
|||||||
"smtp": {
|
"smtp": {
|
||||||
"host": "",
|
"host": "",
|
||||||
"port": 587,
|
"port": 587,
|
||||||
"username": "",
|
|
||||||
"password": "",
|
|
||||||
"security": "starttls",
|
"security": "starttls",
|
||||||
},
|
},
|
||||||
"imap": {
|
"imap": {
|
||||||
"enabled": False,
|
|
||||||
"host": "",
|
"host": "",
|
||||||
"port": 993,
|
"port": 993,
|
||||||
"username": "",
|
|
||||||
"password": "",
|
|
||||||
"security": "tls",
|
"security": "tls",
|
||||||
"sent_folder": "auto",
|
"sent_folder": "auto",
|
||||||
},
|
},
|
||||||
|
"credentials": {
|
||||||
|
"smtp": {"username": "", "password": ""},
|
||||||
|
"imap": {"username": "", "password": ""},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": [],
|
"from": [],
|
||||||
@@ -339,7 +338,7 @@ def fork_campaign_version_for_edit(
|
|||||||
|
|
||||||
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
|
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
|
||||||
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
|
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
|
||||||
assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
||||||
|
|
||||||
new_version = CampaignVersion(
|
new_version = CampaignVersion(
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
@@ -502,7 +501,7 @@ def update_campaign_version(
|
|||||||
|
|
||||||
if raw_json is not None:
|
if raw_json is not None:
|
||||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
||||||
assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
||||||
version.raw_json = runtime_json
|
version.raw_json = runtime_json
|
||||||
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
||||||
_apply_campaign_metadata(campaign, runtime_json)
|
_apply_campaign_metadata(campaign, runtime_json)
|
||||||
@@ -796,9 +795,22 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
|
|||||||
issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
|
issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
|
||||||
|
|
||||||
template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {}
|
template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {}
|
||||||
if not template.get("subject") and not (isinstance(template.get("source"), dict) and template["source"].get("subject_path")):
|
source_template = template.get("source") if isinstance(template.get("source"), dict) else {}
|
||||||
|
if not template.get("subject") and not source_template.get("subject_path"):
|
||||||
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
|
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
|
||||||
if not template.get("text") and not template.get("html") and not isinstance(template.get("source"), dict):
|
explicit_body_mode = template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None
|
||||||
|
has_text_body = bool(template.get("text")) or bool(source_template.get("text_path"))
|
||||||
|
has_html_body = bool(template.get("html")) or bool(source_template.get("html_path"))
|
||||||
|
if explicit_body_mode == "text" and not has_text_body:
|
||||||
|
issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
|
||||||
|
elif explicit_body_mode == "html" and not has_html_body:
|
||||||
|
issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
|
||||||
|
elif explicit_body_mode == "both":
|
||||||
|
if not has_text_body:
|
||||||
|
issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
|
||||||
|
if not has_html_body:
|
||||||
|
issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
|
||||||
|
elif not has_text_body and not has_html_body and not source_template:
|
||||||
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
|
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
|
||||||
|
|
||||||
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {}
|
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {}
|
||||||
|
|||||||
@@ -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(
|
def generate_campaign_report(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -401,13 +494,47 @@ def generate_jobs_csv(
|
|||||||
.order_by(CampaignJob.entry_index.asc())
|
.order_by(CampaignJob.entry_index.asc())
|
||||||
.all()
|
.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 = [
|
fieldnames = [
|
||||||
"job_id",
|
"job_id",
|
||||||
|
"campaign_id",
|
||||||
|
"campaign_version_id",
|
||||||
"entry_index",
|
"entry_index",
|
||||||
"entry_id",
|
"entry_id",
|
||||||
"recipient_email",
|
"recipient_email",
|
||||||
|
"from",
|
||||||
|
"to",
|
||||||
|
"cc",
|
||||||
|
"bcc",
|
||||||
|
"reply_to",
|
||||||
"subject",
|
"subject",
|
||||||
|
"message_id_header",
|
||||||
"build_status",
|
"build_status",
|
||||||
"validation_status",
|
"validation_status",
|
||||||
"queue_status",
|
"queue_status",
|
||||||
@@ -422,9 +549,26 @@ def generate_jobs_csv(
|
|||||||
"last_error",
|
"last_error",
|
||||||
"eml_size_bytes",
|
"eml_size_bytes",
|
||||||
"eml_sha256",
|
"eml_sha256",
|
||||||
|
"eml_storage_key",
|
||||||
|
"eml_local_path",
|
||||||
"issues_count",
|
"issues_count",
|
||||||
"attachment_config_count",
|
"attachment_config_count",
|
||||||
"matched_file_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()
|
buffer = io.StringIO()
|
||||||
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
|
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from govoplan_campaign.backend.campaign.loader import load_campaign_config
|
|||||||
from govoplan_campaign.backend.campaign.models import CampaignConfig, SmtpConfig
|
from govoplan_campaign.backend.campaign.models import CampaignConfig, SmtpConfig
|
||||||
from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot
|
from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot
|
||||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||||
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendResult, send_email_message
|
from govoplan_campaign.backend.integrations import SmtpConfigurationError, mail_integration
|
||||||
|
|
||||||
|
|
||||||
class CampaignReportEmailError(RuntimeError):
|
class CampaignReportEmailError(RuntimeError):
|
||||||
@@ -70,8 +70,9 @@ def _load_config(version: CampaignVersion) -> CampaignConfig:
|
|||||||
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
|
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
|
||||||
if config.recipients.from_:
|
if config.recipients.from_:
|
||||||
return config.recipients.from_[0].email, config.recipients.from_[0].name
|
return config.recipients.from_[0].email, config.recipients.from_[0].name
|
||||||
if config.server.smtp and config.server.smtp.username and "@" in config.server.smtp.username:
|
smtp_config = config.server.runtime_smtp_config()
|
||||||
return config.server.smtp.username, None
|
if smtp_config and smtp_config.username and "@" in smtp_config.username:
|
||||||
|
return smtp_config.username, None
|
||||||
raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address")
|
raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address")
|
||||||
|
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ def send_campaign_report_email(
|
|||||||
|
|
||||||
version = _selected_version(session, campaign, version_id)
|
version = _selected_version(session, campaign, version_id)
|
||||||
config = _load_config(version)
|
config = _load_config(version)
|
||||||
smtp_config: SmtpConfig | None = config.server.smtp
|
smtp_config: SmtpConfig | None = config.server.runtime_smtp_config()
|
||||||
if smtp_config is None:
|
if smtp_config is None:
|
||||||
raise SmtpConfigurationError("Campaign has no SMTP configuration")
|
raise SmtpConfigurationError("Campaign has no SMTP configuration")
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ def send_campaign_report_email(
|
|||||||
smtp_port=smtp_config.port,
|
smtp_port=smtp_config.port,
|
||||||
)
|
)
|
||||||
|
|
||||||
result: SmtpSendResult = send_email_message(
|
result = mail_integration().send_email_message(
|
||||||
message,
|
message,
|
||||||
smtp_config=smtp_config,
|
smtp_config=smtp_config,
|
||||||
envelope_from=envelope_from,
|
envelope_from=envelope_from,
|
||||||
|
|||||||
191
src/govoplan_campaign/backend/retention.py
Normal file
191
src/govoplan_campaign/backend/retention.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
|
||||||
|
|
||||||
|
FINAL_VERSION_STATES = {
|
||||||
|
"completed",
|
||||||
|
"partially_completed",
|
||||||
|
"outcome_unknown",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
"archived",
|
||||||
|
}
|
||||||
|
FINAL_EML_SEND_STATUSES = {
|
||||||
|
"smtp_accepted",
|
||||||
|
"sent",
|
||||||
|
"failed_permanent",
|
||||||
|
"cancelled",
|
||||||
|
"outcome_unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cutoff(days: int | None, *, now: datetime) -> datetime | None:
|
||||||
|
if days is None:
|
||||||
|
return None
|
||||||
|
return now - timedelta(days=days)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_before_cutoff(value: datetime | None, cutoff: datetime | None) -> bool:
|
||||||
|
if value is None or cutoff is None:
|
||||||
|
return False
|
||||||
|
candidate = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
|
||||||
|
return candidate < cutoff
|
||||||
|
|
||||||
|
|
||||||
|
def _json_sha256(value: Any) -> str:
|
||||||
|
payload = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
||||||
|
return hashlib.sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_was_redacted(summary: Any) -> bool:
|
||||||
|
return isinstance(summary, dict) and bool(summary.get("_retention", {}).get("report_detail_redacted"))
|
||||||
|
|
||||||
|
|
||||||
|
def _redacted_report_summary(summary: Any, *, now: datetime) -> tuple[dict[str, Any] | None, bool]:
|
||||||
|
if not isinstance(summary, dict):
|
||||||
|
return summary, False
|
||||||
|
if _summary_was_redacted(summary):
|
||||||
|
return summary, False
|
||||||
|
detail_keys = {"messages", "issues", "recent_failures", "jobs"}
|
||||||
|
if not any(key in summary for key in detail_keys):
|
||||||
|
return summary, False
|
||||||
|
next_summary = copy.deepcopy(summary)
|
||||||
|
for key in detail_keys:
|
||||||
|
next_summary.pop(key, None)
|
||||||
|
retention = dict(next_summary.get("_retention") or {})
|
||||||
|
retention.update({"report_detail_redacted": True, "redacted_at": now.isoformat()})
|
||||||
|
next_summary["_retention"] = retention
|
||||||
|
return next_summary, True
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_raw_json_retention(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
now: datetime,
|
||||||
|
policy_for_campaign_id: Callable[[str | None], object],
|
||||||
|
) -> dict[str, int]:
|
||||||
|
result = {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0}
|
||||||
|
versions = session.query(CampaignVersion).order_by(CampaignVersion.updated_at.asc()).all()
|
||||||
|
for version in versions:
|
||||||
|
policy = policy_for_campaign_id(version.campaign_id)
|
||||||
|
cutoff = _cutoff(0 if not policy.store_raw_campaign_json else policy.raw_campaign_json_retention_days, now=now)
|
||||||
|
if not _is_before_cutoff(version.updated_at, cutoff):
|
||||||
|
continue
|
||||||
|
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
|
if raw_json.get("_retention", {}).get("raw_json_redacted"):
|
||||||
|
result["already_redacted"] += 1
|
||||||
|
continue
|
||||||
|
if version.workflow_state not in FINAL_VERSION_STATES:
|
||||||
|
result["skipped_not_final"] += 1
|
||||||
|
continue
|
||||||
|
result["eligible"] += 1
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
snapshot = version.execution_snapshot if isinstance(version.execution_snapshot, dict) else {}
|
||||||
|
version.raw_json = {
|
||||||
|
"version": version.schema_version or "1.0",
|
||||||
|
"_retention": {
|
||||||
|
"raw_json_redacted": True,
|
||||||
|
"redacted_at": now.isoformat(),
|
||||||
|
"original_sha256": snapshot.get("campaign_json_sha256") or _json_sha256(raw_json),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
session.add(version)
|
||||||
|
result["redacted"] += 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_eml_retention(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
now: datetime,
|
||||||
|
policy_for_campaign_id: Callable[[str | None], object],
|
||||||
|
) -> dict[str, int]:
|
||||||
|
result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0}
|
||||||
|
jobs = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None)))
|
||||||
|
.order_by(CampaignJob.updated_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for job in jobs:
|
||||||
|
policy = policy_for_campaign_id(job.campaign_id)
|
||||||
|
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
|
||||||
|
if not _is_before_cutoff(job.updated_at, cutoff):
|
||||||
|
continue
|
||||||
|
if job.queue_status in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value} or job.send_status not in FINAL_EML_SEND_STATUSES:
|
||||||
|
result["skipped_not_final"] += 1
|
||||||
|
continue
|
||||||
|
if job.imap_status == JobImapStatus.PENDING.value:
|
||||||
|
result["skipped_not_final"] += 1
|
||||||
|
continue
|
||||||
|
result["eligible"] += 1
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
if job.eml_local_path:
|
||||||
|
path = Path(job.eml_local_path)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
result["files_deleted"] += 1
|
||||||
|
else:
|
||||||
|
result["files_missing"] += 1
|
||||||
|
job.eml_local_path = None
|
||||||
|
job.eml_storage_key = None
|
||||||
|
session.add(job)
|
||||||
|
result["metadata_cleared"] += 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_report_detail_retention(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
now: datetime,
|
||||||
|
policy_for_campaign_id: Callable[[str | None], object],
|
||||||
|
) -> dict[str, int]:
|
||||||
|
result = {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0}
|
||||||
|
versions = session.query(CampaignVersion).order_by(CampaignVersion.updated_at.asc()).all()
|
||||||
|
for version in versions:
|
||||||
|
policy = policy_for_campaign_id(version.campaign_id)
|
||||||
|
cutoff = _cutoff(policy.stored_report_detail_retention_days, now=now)
|
||||||
|
if not _is_before_cutoff(version.updated_at, cutoff):
|
||||||
|
continue
|
||||||
|
next_validation, validation_changed = _redacted_report_summary(version.validation_summary, now=now)
|
||||||
|
next_build, build_changed = _redacted_report_summary(version.build_summary, now=now)
|
||||||
|
if not validation_changed and not build_changed:
|
||||||
|
if _summary_was_redacted(version.validation_summary) or _summary_was_redacted(version.build_summary):
|
||||||
|
result["already_redacted"] += 1
|
||||||
|
continue
|
||||||
|
result["eligible_versions"] += 1
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
version.validation_summary = next_validation
|
||||||
|
version.build_summary = next_build
|
||||||
|
session.add(version)
|
||||||
|
result["summaries_redacted"] += int(validation_changed) + int(build_changed)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def apply_campaign_retention(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
now: datetime,
|
||||||
|
policy_for_campaign_id: Callable[[str | None], object],
|
||||||
|
) -> dict[str, dict[str, int]]:
|
||||||
|
return {
|
||||||
|
"raw_campaign_json": _apply_raw_json_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
|
||||||
|
"generated_eml": _apply_eml_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
|
||||||
|
"stored_report_detail": _apply_report_detail_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
36
src/govoplan_campaign/backend/runtime.py
Normal file
36
src/govoplan_campaign/backend/runtime.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_runtime_registry: object | None = None
|
||||||
|
_runtime_settings: object | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
|
||||||
|
global _runtime_registry, _runtime_settings
|
||||||
|
if registry is not None:
|
||||||
|
_runtime_registry = registry
|
||||||
|
if settings is not None:
|
||||||
|
_runtime_settings = settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> object | None:
|
||||||
|
return _runtime_registry
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> object | None:
|
||||||
|
return _runtime_settings
|
||||||
|
|
||||||
|
|
||||||
|
def capability(name: str) -> Any | None:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not hasattr(registry, "capability"):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
def has_capability(name: str) -> bool:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return False
|
||||||
|
return bool(registry.has_capability(name))
|
||||||
@@ -175,6 +175,40 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"smtp": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"username": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"imap": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"username": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"default": {
|
||||||
|
"smtp": {},
|
||||||
|
"imap": {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -301,6 +335,16 @@
|
|||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"body_mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"text",
|
||||||
|
"html",
|
||||||
|
"both"
|
||||||
|
],
|
||||||
|
"default": "both",
|
||||||
|
"description": "Which body parts should be generated for campaign messages."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -336,6 +380,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"body_mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"text",
|
||||||
|
"html",
|
||||||
|
"both"
|
||||||
|
],
|
||||||
|
"default": "both",
|
||||||
|
"description": "Which body parts should be generated for campaign messages."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -428,6 +482,13 @@
|
|||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"$ref": "#/$defs/entry"
|
"$ref": "#/$defs/entry"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/import_provenance"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -451,6 +512,13 @@
|
|||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"$ref": "#/$defs/entry"
|
"$ref": "#/$defs/entry"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/import_provenance"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -745,6 +813,22 @@
|
|||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"message_filename_template": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"description": "Optional recipient-rendered filename template used when this rule sends files directly as message attachments. If omitted, the source filename is used."
|
||||||
|
},
|
||||||
|
"zip_entry_name_template": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"description": "Optional recipient-rendered filename template used for this rule's files inside recipient ZIP archives. If omitted, the source filename is used."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -957,6 +1041,151 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"import_provenance": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"imported_at",
|
||||||
|
"mode",
|
||||||
|
"source_type",
|
||||||
|
"header_rows",
|
||||||
|
"rows_total",
|
||||||
|
"valid_rows",
|
||||||
|
"invalid_rows",
|
||||||
|
"imported_rows"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"imported_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"append",
|
||||||
|
"replace"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"source_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"csv",
|
||||||
|
"xlsx",
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sheet_name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"encoding": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delimiter": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"header_rows": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"quoted": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"value_separators": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows_total": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"valid_rows": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"invalid_rows": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"imported_rows": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"field_names_created": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"attachment_patterns": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"mapping": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"column_index",
|
||||||
|
"header",
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"column_index": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"field_name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"new_field_name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"attachment_base_path": {
|
"attachment_base_path": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
137
src/govoplan_campaign/backend/schema/campaign.schema.ui.json
Normal file
137
src/govoplan_campaign/backend/schema/campaign.schema.ui.json
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://govoplan.local/schema/campaign.schema.ui.json",
|
||||||
|
"$id": "https://govoplan.local/schema/campaign.schema.ui.json",
|
||||||
|
"schema": "campaign.schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "basics",
|
||||||
|
"label": "Basics",
|
||||||
|
"path": "/campaign",
|
||||||
|
"order": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fields",
|
||||||
|
"label": "Fields",
|
||||||
|
"path": "/fields",
|
||||||
|
"order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "global_values",
|
||||||
|
"label": "Global Values",
|
||||||
|
"path": "/global_values",
|
||||||
|
"order": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recipients",
|
||||||
|
"label": "Recipients",
|
||||||
|
"path": "/recipients",
|
||||||
|
"order": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "template",
|
||||||
|
"label": "Template",
|
||||||
|
"path": "/template",
|
||||||
|
"order": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attachments",
|
||||||
|
"label": "Attachments",
|
||||||
|
"path": "/attachments",
|
||||||
|
"order": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mail",
|
||||||
|
"label": "Mail",
|
||||||
|
"path": "/server",
|
||||||
|
"order": 70,
|
||||||
|
"requiresCapability": "mail.campaign_delivery"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "delivery",
|
||||||
|
"label": "Delivery",
|
||||||
|
"path": "/delivery",
|
||||||
|
"order": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "review",
|
||||||
|
"label": "Review & Send",
|
||||||
|
"path": "/review",
|
||||||
|
"order": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"/attachments/global[]/message_filename_template": {
|
||||||
|
"label": "Direct attachment filename",
|
||||||
|
"control": "text",
|
||||||
|
"placeholder": "{{ file.name }}",
|
||||||
|
"templateContext": [
|
||||||
|
"global values",
|
||||||
|
"recipient fields",
|
||||||
|
"file.name",
|
||||||
|
"file.stem",
|
||||||
|
"file.suffix",
|
||||||
|
"file.ext",
|
||||||
|
"file.index",
|
||||||
|
"attachment.id",
|
||||||
|
"attachment.label"
|
||||||
|
],
|
||||||
|
"description": "Optional filename template used when files are attached directly to the message."
|
||||||
|
},
|
||||||
|
"/attachments/global[]/zip_entry_name_template": {
|
||||||
|
"label": "ZIP entry filename",
|
||||||
|
"control": "text",
|
||||||
|
"placeholder": "{{ file.name }}",
|
||||||
|
"templateContext": [
|
||||||
|
"global values",
|
||||||
|
"recipient fields",
|
||||||
|
"file.name",
|
||||||
|
"file.stem",
|
||||||
|
"file.suffix",
|
||||||
|
"file.ext",
|
||||||
|
"file.index",
|
||||||
|
"attachment.id",
|
||||||
|
"attachment.label"
|
||||||
|
],
|
||||||
|
"description": "Optional filename template used for files inside generated ZIP archives."
|
||||||
|
},
|
||||||
|
"/entries/inline[]/attachments[]/message_filename_template": {
|
||||||
|
"label": "Direct attachment filename",
|
||||||
|
"control": "text",
|
||||||
|
"placeholder": "{{ file.name }}",
|
||||||
|
"templateContext": [
|
||||||
|
"global values",
|
||||||
|
"recipient fields",
|
||||||
|
"file.name",
|
||||||
|
"file.stem",
|
||||||
|
"file.suffix",
|
||||||
|
"file.ext",
|
||||||
|
"file.index",
|
||||||
|
"attachment.id",
|
||||||
|
"attachment.label"
|
||||||
|
],
|
||||||
|
"description": "Optional filename template used when files are attached directly to the message."
|
||||||
|
},
|
||||||
|
"/entries/inline[]/attachments[]/zip_entry_name_template": {
|
||||||
|
"label": "ZIP entry filename",
|
||||||
|
"control": "text",
|
||||||
|
"placeholder": "{{ file.name }}",
|
||||||
|
"templateContext": [
|
||||||
|
"global values",
|
||||||
|
"recipient fields",
|
||||||
|
"file.name",
|
||||||
|
"file.stem",
|
||||||
|
"file.suffix",
|
||||||
|
"file.ext",
|
||||||
|
"file.index",
|
||||||
|
"attachment.id",
|
||||||
|
"attachment.label"
|
||||||
|
],
|
||||||
|
"description": "Optional filename template used for files inside generated ZIP archives."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"files.campaign_attachments": "Enable managed file chooser and frozen file-version evidence.",
|
||||||
|
"mail.campaign_delivery": "Enable mail profile selection, SMTP/IMAP validation, queueing, and delivery."
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/govoplan_campaign/backend/schema_router.py
Normal file
31
src/govoplan_campaign/backend/schema_router.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.loader import load_campaign_schema, load_campaign_schema_ui
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/schemas", tags=["schemas"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/campaign")
|
||||||
|
def get_campaign_schema(
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return the authoritative campaign JSON Schema used by the backend."""
|
||||||
|
|
||||||
|
del principal
|
||||||
|
return load_campaign_schema()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/campaign/ui")
|
||||||
|
def get_campaign_schema_ui(
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return UI metadata paired with the campaign JSON Schema."""
|
||||||
|
|
||||||
|
del principal
|
||||||
|
return load_campaign_schema_ui()
|
||||||
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
from govoplan_core.mail.config import ImapConfig, SmtpConfig
|
||||||
|
|
||||||
|
|
||||||
class CampaignCreateRequest(BaseModel):
|
class CampaignCreateRequest(BaseModel):
|
||||||
@@ -137,6 +138,29 @@ class CampaignListResponse(BaseModel):
|
|||||||
campaigns: list[CampaignResponse]
|
campaigns: list[CampaignResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkspaceResponse(BaseModel):
|
||||||
|
campaign: CampaignResponse | None = None
|
||||||
|
versions: list[CampaignVersionResponse] = Field(default_factory=list)
|
||||||
|
current_version: CampaignVersionDetailResponse | None = None
|
||||||
|
summary: dict[str, Any] | None = None
|
||||||
|
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):
|
class CampaignShareItem(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -178,6 +202,70 @@ class CampaignOwnerUpdateRequest(BaseModel):
|
|||||||
owner_group_id: str | None = None
|
owner_group_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
RecipientImportColumnKind = Literal[
|
||||||
|
"ignore",
|
||||||
|
"id",
|
||||||
|
"active",
|
||||||
|
"name",
|
||||||
|
"from",
|
||||||
|
"to",
|
||||||
|
"cc",
|
||||||
|
"bcc",
|
||||||
|
"reply_to",
|
||||||
|
"field",
|
||||||
|
"new_field",
|
||||||
|
"attachment_pattern",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RecipientImportColumnMappingPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
|
column_index: int = Field(ge=0, alias="columnIndex")
|
||||||
|
kind: RecipientImportColumnKind
|
||||||
|
field_name: str | None = Field(default=None, max_length=255, alias="fieldName")
|
||||||
|
new_field_name: str | None = Field(default=None, max_length=255, alias="newFieldName")
|
||||||
|
|
||||||
|
|
||||||
|
class RecipientImportMappingProfilePayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
column_count: int = Field(ge=0, le=500, alias="columnCount")
|
||||||
|
headers: list[str] = Field(default_factory=list, max_length=500)
|
||||||
|
normalized_headers: list[str] = Field(default_factory=list, max_length=500, alias="normalizedHeaders")
|
||||||
|
ordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="orderedHeaderFingerprint")
|
||||||
|
unordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="unorderedHeaderFingerprint")
|
||||||
|
delimiter: Literal[",", ";", "\t"]
|
||||||
|
header_rows: int = Field(ge=0, le=10, alias="headerRows")
|
||||||
|
quoted: bool = True
|
||||||
|
value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators")
|
||||||
|
mappings: list[RecipientImportColumnMappingPayload] = Field(default_factory=list, max_length=500)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
|
||||||
|
if len(self.headers) != self.column_count:
|
||||||
|
raise ValueError("headers length must match columnCount")
|
||||||
|
if len(self.normalized_headers) != self.column_count:
|
||||||
|
raise ValueError("normalizedHeaders length must match columnCount")
|
||||||
|
for mapping in self.mappings:
|
||||||
|
if mapping.column_index >= self.column_count:
|
||||||
|
raise ValueError("mapping columnIndex exceeds columnCount")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RecipientImportMappingProfileResponse(RecipientImportMappingProfilePayload):
|
||||||
|
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
created_at: datetime = Field(alias="createdAt")
|
||||||
|
updated_at: datetime = Field(alias="updatedAt")
|
||||||
|
|
||||||
|
|
||||||
|
class RecipientImportMappingProfileListResponse(BaseModel):
|
||||||
|
profiles: list[RecipientImportMappingProfileResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CampaignJobsResponse(BaseModel):
|
class CampaignJobsResponse(BaseModel):
|
||||||
jobs: list[dict[str, Any]]
|
jobs: list[dict[str, Any]]
|
||||||
page: int = 1
|
page: int = 1
|
||||||
@@ -185,11 +273,20 @@ class CampaignJobsResponse(BaseModel):
|
|||||||
total: int = 0
|
total: int = 0
|
||||||
total_unfiltered: int = 0
|
total_unfiltered: int = 0
|
||||||
pages: int = 0
|
pages: int = 0
|
||||||
|
cursor: str | None = None
|
||||||
|
next_cursor: str | None = None
|
||||||
counts: dict[str, dict[str, int]] = Field(default_factory=dict)
|
counts: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||||
filtered_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)
|
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):
|
class CampaignJobDetailResponse(BaseModel):
|
||||||
job: dict[str, Any]
|
job: dict[str, Any]
|
||||||
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
||||||
@@ -241,8 +338,6 @@ class MailSmtpTestRequest(SmtpConfig):
|
|||||||
class MailImapTestRequest(ImapConfig):
|
class MailImapTestRequest(ImapConfig):
|
||||||
"""IMAP settings supplied directly from the WebUI mail settings form."""
|
"""IMAP settings supplied directly from the WebUI mail settings form."""
|
||||||
|
|
||||||
enabled: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -426,6 +521,7 @@ class AppendSentRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
enqueue_celery: bool = True
|
enqueue_celery: bool = True
|
||||||
|
run_inline: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
||||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig
|
from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig
|
||||||
from govoplan_mail.backend.mail_profiles import MailProfileError, apply_campaign_credentials, effective_profile_credentials_inherited, ensure_mail_profile_allowed_for_campaign, imap_config_from_profile, mail_profile_id_from_campaign_json, smtp_config_from_profile
|
from govoplan_campaign.backend.integrations import MailProfileError, mail_integration
|
||||||
|
|
||||||
SNAPSHOT_VERSION = "3"
|
SNAPSHOT_VERSION = "3"
|
||||||
|
|
||||||
@@ -83,8 +83,12 @@ def _redacted_transport_config(config: SmtpConfig | ImapConfig | None) -> SmtpCo
|
|||||||
|
|
||||||
def _transport_password_from_campaign_json(raw_json: dict[str, Any] | None, name: str) -> str | None:
|
def _transport_password_from_campaign_json(raw_json: dict[str, Any] | None, name: str) -> str | None:
|
||||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||||
config = server.get(name) if isinstance(server, dict) else None
|
credentials = server.get("credentials") if isinstance(server, dict) and isinstance(server.get("credentials"), dict) else None
|
||||||
|
config = credentials.get(name) if isinstance(credentials, dict) and isinstance(credentials.get(name), dict) else None
|
||||||
password = config.get("password") if isinstance(config, dict) else None
|
password = config.get("password") if isinstance(config, dict) else None
|
||||||
|
if password is None:
|
||||||
|
legacy_config = server.get(name) if isinstance(server, dict) else None
|
||||||
|
password = legacy_config.get("password") if isinstance(legacy_config, dict) else None
|
||||||
if password is None:
|
if password is None:
|
||||||
return None
|
return None
|
||||||
return str(password)
|
return str(password)
|
||||||
@@ -96,14 +100,15 @@ def _server_from_campaign_json(raw_json: dict[str, Any] | None) -> dict[str, Any
|
|||||||
|
|
||||||
|
|
||||||
def _profile_for_version(session: Session, version: CampaignVersion):
|
def _profile_for_version(session: Session, version: CampaignVersion):
|
||||||
profile_id = mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {})
|
mail = mail_integration()
|
||||||
|
profile_id = mail.mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||||
if not profile_id:
|
if not profile_id:
|
||||||
return None
|
return None
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if campaign is None:
|
if campaign is None:
|
||||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||||
try:
|
try:
|
||||||
return ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True)
|
return mail.ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
raise ExecutionSnapshotError(str(exc)) from exc
|
raise ExecutionSnapshotError(str(exc)) from exc
|
||||||
|
|
||||||
@@ -117,10 +122,11 @@ def runtime_smtp_config(session: Session, version: CampaignVersion, snapshot: Ex
|
|||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if campaign is None:
|
if campaign is None:
|
||||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||||
profile_payload = smtp_config_from_profile(profile).model_dump(mode="json")
|
mail = mail_integration()
|
||||||
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"):
|
profile_payload = mail.smtp_config_from_profile(profile).model_dump(mode="json")
|
||||||
|
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"):
|
||||||
return SmtpConfig.model_validate(profile_payload)
|
return SmtpConfig.model_validate(profile_payload)
|
||||||
return SmtpConfig.model_validate(apply_campaign_credentials(profile_payload, server, "smtp"))
|
return SmtpConfig.model_validate(mail.apply_campaign_credentials(profile_payload, server, "smtp"))
|
||||||
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp")
|
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp")
|
||||||
return SmtpConfig.model_validate(payload)
|
return SmtpConfig.model_validate(payload)
|
||||||
|
|
||||||
@@ -131,29 +137,31 @@ def runtime_imap_config(session: Session, version: CampaignVersion, snapshot: Ex
|
|||||||
profile = _profile_for_version(session, version)
|
profile = _profile_for_version(session, version)
|
||||||
if profile is None:
|
if profile is None:
|
||||||
return None
|
return None
|
||||||
imap = imap_config_from_profile(profile)
|
mail = mail_integration()
|
||||||
|
imap = mail.imap_config_from_profile(profile)
|
||||||
if imap is None:
|
if imap is None:
|
||||||
return None
|
return None
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if campaign is None:
|
if campaign is None:
|
||||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||||
imap_payload = imap.model_dump(mode="json")
|
imap_payload = imap.model_dump(mode="json")
|
||||||
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
||||||
return ImapConfig.model_validate(imap_payload)
|
return ImapConfig.model_validate(imap_payload)
|
||||||
return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap"))
|
return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap"))
|
||||||
payload = snapshot.imap.model_dump(mode="json")
|
payload = snapshot.imap.model_dump(mode="json")
|
||||||
if not payload.get("password"):
|
if not payload.get("password"):
|
||||||
profile = _profile_for_version(session, version)
|
profile = _profile_for_version(session, version)
|
||||||
if profile is not None:
|
if profile is not None:
|
||||||
imap = imap_config_from_profile(profile)
|
mail = mail_integration()
|
||||||
|
imap = mail.imap_config_from_profile(profile)
|
||||||
if imap is not None:
|
if imap is not None:
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if campaign is None:
|
if campaign is None:
|
||||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||||
imap_payload = imap.model_dump(mode="json")
|
imap_payload = imap.model_dump(mode="json")
|
||||||
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
||||||
return ImapConfig.model_validate(imap_payload)
|
return ImapConfig.model_validate(imap_payload)
|
||||||
return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap"))
|
return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap"))
|
||||||
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap")
|
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap")
|
||||||
return ImapConfig.model_validate(payload)
|
return ImapConfig.model_validate(payload)
|
||||||
|
|
||||||
@@ -250,7 +258,8 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
|
|||||||
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
||||||
|
|
||||||
_, _, config = load_version_config(session, version.id)
|
_, _, config = load_version_config(session, version.id)
|
||||||
if not config.server.smtp:
|
runtime_smtp = config.server.runtime_smtp_config()
|
||||||
|
if not runtime_smtp:
|
||||||
raise ExecutionSnapshotError("Campaign has no SMTP configuration")
|
raise ExecutionSnapshotError("Campaign has no SMTP configuration")
|
||||||
jobs = (
|
jobs = (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
@@ -262,8 +271,8 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
|
|||||||
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
||||||
payload, digest = create_execution_snapshot(
|
payload, digest = create_execution_snapshot(
|
||||||
version,
|
version,
|
||||||
smtp=config.server.smtp,
|
smtp=runtime_smtp,
|
||||||
imap=config.server.imap,
|
imap=config.server.runtime_imap_config(),
|
||||||
delivery=config.delivery,
|
delivery=config.delivery,
|
||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
||||||
|
|||||||
@@ -28,11 +28,15 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
SendAttempt,
|
SendAttempt,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot, runtime_imap_config, runtime_smtp_config
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot, runtime_imap_config, runtime_smtp_config
|
||||||
from govoplan_mail.backend.mail_profiles import MailProfileError, assert_mail_policy_allows_send
|
from govoplan_campaign.backend.integrations import (
|
||||||
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
|
ImapAppendError,
|
||||||
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes
|
ImapConfigurationError,
|
||||||
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
|
MailProfileError,
|
||||||
from govoplan_files.backend.storage.services import mark_job_attachment_uses_sent
|
SmtpConfigurationError,
|
||||||
|
SmtpSendError,
|
||||||
|
files_integration,
|
||||||
|
mail_integration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class QueueingError(RuntimeError):
|
class QueueingError(RuntimeError):
|
||||||
@@ -709,7 +713,7 @@ def reconcile_job_outcome(
|
|||||||
job.claim_token = None
|
job.claim_token = None
|
||||||
job.last_error = note or "Operator confirmed SMTP acceptance after an uncertain outcome."
|
job.last_error = note or "Operator confirmed SMTP acceptance after an uncertain outcome."
|
||||||
job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value
|
job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value
|
||||||
mark_job_attachment_uses_sent(session, job)
|
files_integration().mark_job_attachment_uses_sent(session, job)
|
||||||
attempt_status = "reconciled_smtp_accepted"
|
attempt_status = "reconciled_smtp_accepted"
|
||||||
elif decision == "not_sent":
|
elif decision == "not_sent":
|
||||||
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
|
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
|
||||||
@@ -1054,7 +1058,7 @@ def send_campaign_job(
|
|||||||
|
|
||||||
job = session.get(CampaignJob, job.id)
|
job = session.get(CampaignJob, job.id)
|
||||||
assert job is not None
|
assert job is not None
|
||||||
wait_for_rate_limit(
|
mail_integration().wait_for_rate_limit(
|
||||||
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
|
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
|
||||||
messages_per_minute=snapshot.delivery.rate_limit.messages_per_minute,
|
messages_per_minute=snapshot.delivery.rate_limit.messages_per_minute,
|
||||||
enabled=use_rate_limit,
|
enabled=use_rate_limit,
|
||||||
@@ -1062,7 +1066,7 @@ def send_campaign_job(
|
|||||||
attempt = _record_attempt_start(session, job, claim_token)
|
attempt = _record_attempt_start(session, job, claim_token)
|
||||||
try:
|
try:
|
||||||
smtp_config = runtime_smtp_config(session, version, snapshot)
|
smtp_config = runtime_smtp_config(session, version, snapshot)
|
||||||
assert_mail_policy_allows_send(
|
mail_integration().assert_mail_policy_allows_send(
|
||||||
session,
|
session,
|
||||||
tenant_id=job.tenant_id,
|
tenant_id=job.tenant_id,
|
||||||
campaign_id=job.campaign_id,
|
campaign_id=job.campaign_id,
|
||||||
@@ -1071,7 +1075,7 @@ def send_campaign_job(
|
|||||||
from_header=_from_header_from_job(job, snapshot),
|
from_header=_from_header_from_job(job, snapshot),
|
||||||
recipients=envelope_recipients,
|
recipients=envelope_recipients,
|
||||||
)
|
)
|
||||||
result = send_email_bytes(
|
result = mail_integration().send_email_bytes(
|
||||||
message_bytes,
|
message_bytes,
|
||||||
smtp_config=smtp_config,
|
smtp_config=smtp_config,
|
||||||
envelope_from=envelope_from,
|
envelope_from=envelope_from,
|
||||||
@@ -1098,7 +1102,7 @@ def send_campaign_job(
|
|||||||
else:
|
else:
|
||||||
job.imap_status = JobImapStatus.NOT_REQUESTED.value
|
job.imap_status = JobImapStatus.NOT_REQUESTED.value
|
||||||
job.last_error = refused_warning
|
job.last_error = refused_warning
|
||||||
mark_job_attachment_uses_sent(session, job)
|
files_integration().mark_job_attachment_uses_sent(session, job)
|
||||||
session.add(attempt)
|
session.add(attempt)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
|
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
|
||||||
@@ -1208,9 +1212,9 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
|
|||||||
session.commit()
|
session.commit()
|
||||||
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run)
|
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run)
|
||||||
imap_config = runtime_imap_config(session, version, snapshot)
|
imap_config = runtime_imap_config(session, version, snapshot)
|
||||||
if not imap_config or not imap_config.enabled:
|
if not imap_config:
|
||||||
job.imap_status = JobImapStatus.SKIPPED.value
|
job.imap_status = JobImapStatus.SKIPPED.value
|
||||||
job.last_error = "IMAP append requested, but the execution snapshot has no enabled IMAP configuration"
|
job.last_error = "IMAP append requested, but the execution snapshot has no IMAP configuration"
|
||||||
session.add(job)
|
session.add(job)
|
||||||
session.commit()
|
session.commit()
|
||||||
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error)
|
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error)
|
||||||
@@ -1230,14 +1234,14 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
|
|||||||
|
|
||||||
attempt = _record_imap_attempt_start(session, job)
|
attempt = _record_imap_attempt_start(session, job)
|
||||||
try:
|
try:
|
||||||
assert_mail_policy_allows_send(
|
mail_integration().assert_mail_policy_allows_send(
|
||||||
session,
|
session,
|
||||||
tenant_id=job.tenant_id,
|
tenant_id=job.tenant_id,
|
||||||
campaign_id=job.campaign_id,
|
campaign_id=job.campaign_id,
|
||||||
smtp=snapshot.smtp,
|
smtp=snapshot.smtp,
|
||||||
imap=imap_config,
|
imap=imap_config,
|
||||||
)
|
)
|
||||||
result = append_message_to_sent(
|
result = mail_integration().append_message_to_sent(
|
||||||
message_bytes,
|
message_bytes,
|
||||||
imap_config=imap_config,
|
imap_config=imap_config,
|
||||||
folder=None if folder == "auto" else folder,
|
folder=None if folder == "auto" else folder,
|
||||||
@@ -1246,7 +1250,7 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
|
|||||||
attempt.folder = result.folder
|
attempt.folder = result.folder
|
||||||
job.imap_status = JobImapStatus.APPENDED.value
|
job.imap_status = JobImapStatus.APPENDED.value
|
||||||
job.last_error = None
|
job.last_error = None
|
||||||
mark_job_attachment_uses_sent(session, job)
|
files_integration().mark_job_attachment_uses_sent(session, job)
|
||||||
session.add(attempt)
|
session.add(attempt)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -1263,7 +1267,15 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True, dry_run: bool = False) -> dict[str, Any]:
|
def enqueue_pending_imap_appends(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
enqueue_celery: bool = True,
|
||||||
|
run_inline: bool = False,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||||
jobs = (
|
jobs = (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
@@ -1276,15 +1288,39 @@ def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_i
|
|||||||
.order_by(CampaignJob.entry_index.asc())
|
.order_by(CampaignJob.entry_index.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run
|
should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run and not run_inline
|
||||||
if should_enqueue:
|
results: list[dict[str, Any]] = []
|
||||||
|
appended_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
if run_inline or dry_run:
|
||||||
|
for job in jobs:
|
||||||
|
try:
|
||||||
|
result = append_sent_for_job(session, job_id=job.id, dry_run=dry_run)
|
||||||
|
payload = result.as_dict()
|
||||||
|
results.append(payload)
|
||||||
|
if result.status == JobImapStatus.APPENDED.value:
|
||||||
|
appended_count += 1
|
||||||
|
elif result.status in {"skipped", "not_requested", "not_sent", "already_appended", "dry_run"}:
|
||||||
|
skipped_count += 1
|
||||||
|
except Exception as exc: # keep processing later jobs and expose per-job details
|
||||||
|
failed_count += 1
|
||||||
|
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
|
||||||
|
elif should_enqueue:
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
_celery_enqueue_append_sent_job(job.id)
|
_celery_enqueue_append_sent_job(job.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"campaign_id": campaign.id,
|
"campaign_id": campaign.id,
|
||||||
"pending_count": len(jobs),
|
"pending_count": len(jobs),
|
||||||
"enqueued_count": len(jobs) if should_enqueue else 0,
|
"enqueued_count": len(jobs) if should_enqueue else 0,
|
||||||
|
"processed_count": len(results) if run_inline and not dry_run else 0,
|
||||||
|
"appended_count": appended_count,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"skipped_count": skipped_count,
|
||||||
"dry_run": dry_run,
|
"dry_run": dry_run,
|
||||||
|
"run_inline": run_inline,
|
||||||
|
"results": results,
|
||||||
}
|
}
|
||||||
|
|
||||||
def next_retry_delay(snapshot: ExecutionSnapshot, attempt_count: int) -> int:
|
def next_retry_delay(snapshot: ExecutionSnapshot, attempt_count: int) -> int:
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from govoplan_campaign.backend.domain.campaign import MailAttachmentConfig
|
|
||||||
|
|
||||||
|
|
||||||
def match_files(base_path: Path, config: MailAttachmentConfig) -> list[Path]:
|
|
||||||
directory = base_path / config.base_dir
|
|
||||||
if not directory.exists():
|
|
||||||
return []
|
|
||||||
iterator = directory.rglob(config.file_filter) if config.include_subdirs else directory.glob(config.file_filter)
|
|
||||||
return sorted(path for path in iterator if path.is_file() and path.stat().st_size >= 0)
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import smtplib
|
|
||||||
from email.message import EmailMessage
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from govoplan_campaign.backend.domain.campaign import MailCampaign, MailEntry, MailServerSettings, TransportSecurity
|
|
||||||
from govoplan_campaign.backend.domain.queue import MailQueue
|
|
||||||
from govoplan_campaign.backend.domain.recipients import Recipient, RecipientType
|
|
||||||
from govoplan_campaign.backend.services.attachment_matching import match_files
|
|
||||||
from govoplan_campaign.backend.services.zip_service import create_encrypted_zip
|
|
||||||
|
|
||||||
|
|
||||||
def _recipient_header(recipients: Iterable[Recipient], recipient_type: RecipientType) -> str:
|
|
||||||
return ", ".join(r.formatted() for r in recipients if r.type == recipient_type)
|
|
||||||
|
|
||||||
|
|
||||||
def _recipient_values(recipients: list[Recipient]) -> dict[str, str]:
|
|
||||||
def rows(recipient_type: RecipientType) -> list[Recipient]:
|
|
||||||
return [r for r in recipients if r.type == recipient_type]
|
|
||||||
|
|
||||||
def joined(recipient_type: RecipientType, mode: str) -> str:
|
|
||||||
selected = rows(recipient_type)
|
|
||||||
if mode == "address":
|
|
||||||
return ", ".join(r.address for r in selected)
|
|
||||||
if mode == "name":
|
|
||||||
return ", ".join(r.name or r.address for r in selected)
|
|
||||||
return ", ".join(r.formatted() for r in selected)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"mm_recipients": joined(RecipientType.TO, "formatted"),
|
|
||||||
"mm_recipients_address": joined(RecipientType.TO, "address"),
|
|
||||||
"mm_recipients_name": joined(RecipientType.TO, "name"),
|
|
||||||
"mm_cc": joined(RecipientType.CC, "formatted"),
|
|
||||||
"mm_cc_address": joined(RecipientType.CC, "address"),
|
|
||||||
"mm_cc_name": joined(RecipientType.CC, "name"),
|
|
||||||
"mm_bcc": joined(RecipientType.BCC, "formatted"),
|
|
||||||
"mm_bcc_address": joined(RecipientType.BCC, "address"),
|
|
||||||
"mm_bcc_name": joined(RecipientType.BCC, "name"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _message_attachment_paths(campaign: MailCampaign, entry: MailEntry) -> list[Path]:
|
|
||||||
paths: list[Path] = []
|
|
||||||
if entry.combine_attachments:
|
|
||||||
for config in campaign.global_attachment_configs:
|
|
||||||
paths.extend(match_files(campaign.base_attachment_path, config))
|
|
||||||
if campaign.individual_attachments:
|
|
||||||
for config in entry.attachment_configs:
|
|
||||||
paths.extend(match_files(campaign.base_attachment_path, config))
|
|
||||||
return paths
|
|
||||||
|
|
||||||
|
|
||||||
def get_not_sent_files(campaign: MailCampaign) -> set[Path]:
|
|
||||||
"""Return files matching campaign attachment configs that are not referenced by any built entry."""
|
|
||||||
all_files: set[Path] = set()
|
|
||||||
used_files: set[Path] = set()
|
|
||||||
for config in campaign.global_attachment_configs:
|
|
||||||
all_files.update(match_files(campaign.base_attachment_path, config))
|
|
||||||
for entry in campaign.mail_entries:
|
|
||||||
files = set(_message_attachment_paths(campaign, entry))
|
|
||||||
used_files.update(files)
|
|
||||||
all_files.update(files)
|
|
||||||
return all_files - used_files
|
|
||||||
|
|
||||||
|
|
||||||
def build_message(campaign: MailCampaign, entry: MailEntry, *, zip_attachments: bool = True) -> EmailMessage | None:
|
|
||||||
recipients = campaign.all_recipients_for(entry)
|
|
||||||
attachments = _message_attachment_paths(campaign, entry)
|
|
||||||
if not attachments and not campaign.send_without_attachments:
|
|
||||||
return None
|
|
||||||
|
|
||||||
values = {}
|
|
||||||
values.update(_recipient_values(recipients))
|
|
||||||
values.update(campaign.field_contents.as_value_map("global"))
|
|
||||||
values.update(entry.field_contents.as_value_map("local"))
|
|
||||||
|
|
||||||
message = EmailMessage()
|
|
||||||
sender = entry.from_recipient if campaign.individual_from and entry.from_recipient else campaign.global_from
|
|
||||||
if sender:
|
|
||||||
message["From"] = sender.formatted()
|
|
||||||
to_header = _recipient_header(recipients, RecipientType.TO)
|
|
||||||
cc_header = _recipient_header(recipients, RecipientType.CC)
|
|
||||||
if to_header:
|
|
||||||
message["To"] = to_header
|
|
||||||
if cc_header:
|
|
||||||
message["Cc"] = cc_header
|
|
||||||
message["Subject"] = campaign.subject_template.apply_values(values)
|
|
||||||
message.set_content(campaign.mail_template.apply_values(values))
|
|
||||||
|
|
||||||
attachment_paths = attachments
|
|
||||||
|
|
||||||
if attachments and zip_attachments:
|
|
||||||
number = entry.get_field_content_from_name("number").as_string() if "number" in entry.field_contents.field_map else "attachments"
|
|
||||||
password = entry.get_field_content_from_name("password").as_string() if "password" in entry.field_contents.field_map else ""
|
|
||||||
zip_path = campaign.base_attachment_path / f"{number}.zip"
|
|
||||||
attachment_paths = [create_encrypted_zip(zip_path, attachments, password)]
|
|
||||||
|
|
||||||
for attachment_path in attachment_paths:
|
|
||||||
data = attachment_path.read_bytes()
|
|
||||||
message.add_attachment(data, maintype="application", subtype="octet-stream", filename=attachment_path.name)
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
def build_mail_queue(campaign: MailCampaign, *, zip_attachments: bool = True) -> MailQueue:
|
|
||||||
queue = MailQueue()
|
|
||||||
for entry in campaign.mail_entries:
|
|
||||||
if not entry.is_active:
|
|
||||||
continue
|
|
||||||
message = build_message(campaign, entry, zip_attachments=zip_attachments)
|
|
||||||
if message is not None:
|
|
||||||
queue.add_mail(message)
|
|
||||||
return queue
|
|
||||||
|
|
||||||
|
|
||||||
def send_mail_queue(settings: MailServerSettings, queue: MailQueue) -> MailQueue:
|
|
||||||
retry_queue = MailQueue()
|
|
||||||
if settings.transport_security == TransportSecurity.TLS:
|
|
||||||
smtp = smtplib.SMTP_SSL(settings.server, settings.resolved_port(), timeout=60)
|
|
||||||
else:
|
|
||||||
smtp = smtplib.SMTP(settings.server, settings.resolved_port(), timeout=60)
|
|
||||||
try:
|
|
||||||
if settings.transport_security == TransportSecurity.STARTTLS:
|
|
||||||
smtp.starttls()
|
|
||||||
if settings.username:
|
|
||||||
smtp.login(settings.username, settings.password)
|
|
||||||
for message in queue:
|
|
||||||
try:
|
|
||||||
smtp.send_message(message)
|
|
||||||
except Exception:
|
|
||||||
retry_queue.add_mail(message)
|
|
||||||
finally:
|
|
||||||
smtp.quit()
|
|
||||||
return retry_queue
|
|
||||||
95
tests/test_access_provider.py
Normal file
95
tests/test_access_provider.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
|
from govoplan_campaign.backend.capabilities import CampaignAccessService
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_ID = "tenant-1"
|
||||||
|
USER_ID = "user-1"
|
||||||
|
OTHER_USER_ID = "user-2"
|
||||||
|
GROUP_ID = "group-1"
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignAccessProviderTests(unittest.TestCase):
|
||||||
|
def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
owned = Campaign(id="campaign-owned", tenant_id=TENANT_ID, owner_user_id=USER_ID, external_id="owned", name="Owned campaign")
|
||||||
|
shared = Campaign(id="campaign-shared", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="shared", name="Shared campaign")
|
||||||
|
session.add_all([
|
||||||
|
owned,
|
||||||
|
shared,
|
||||||
|
CampaignShare(id="share-group", tenant_id=TENANT_ID, campaign_id=shared.id, target_type="group", target_id=GROUP_ID, permission="read"),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
service = CampaignAccessService()
|
||||||
|
owned_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id=owned.id, action="campaigns:campaign:read")
|
||||||
|
shared_items = service.explain_resource_provenance(session, _principal(group_ids={GROUP_ID}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read")
|
||||||
|
admin_items = service.explain_resource_provenance(session, _principal(scopes={"tenant:*"}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read")
|
||||||
|
missing_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id="missing-campaign", action="campaigns:campaign:read")
|
||||||
|
|
||||||
|
self.assertTrue(any(item.kind == "resource" and item.source == "campaigns.campaign" and item.id == owned.id for item in owned_items))
|
||||||
|
self.assertTrue(any(item.kind == "owner" and item.id == USER_ID for item in owned_items))
|
||||||
|
self.assertTrue(any(item.kind == "share" and item.id == "share-group" for item in shared_items))
|
||||||
|
self.assertTrue(any(item.kind == "policy" and item.id == "tenant:*" for item in admin_items))
|
||||||
|
self.assertEqual("campaigns.not_found", missing_items[0].source)
|
||||||
|
self.assertIs(missing_items[0].details["found"], False)
|
||||||
|
|
||||||
|
def test_campaign_access_provider_requires_write_share_for_write_actions(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
campaign = Campaign(id="campaign-write", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="write", name="Write campaign")
|
||||||
|
session.add_all([
|
||||||
|
campaign,
|
||||||
|
CampaignShare(id="share-read", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="group", target_id=GROUP_ID, permission="read"),
|
||||||
|
CampaignShare(id="share-write", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="user", target_id=USER_ID, permission="write"),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
service = CampaignAccessService()
|
||||||
|
items = service.explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
_principal(group_ids={GROUP_ID}),
|
||||||
|
resource_type="campaign",
|
||||||
|
resource_id=campaign.id,
|
||||||
|
action="campaigns:campaign:update",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(any(item.kind == "share" and item.id == "share-write" for item in items))
|
||||||
|
self.assertFalse(any(item.kind == "share" and item.id == "share-read" for item in items))
|
||||||
|
|
||||||
|
|
||||||
|
def _session():
|
||||||
|
engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, Campaign.__table__, CampaignShare.__table__])
|
||||||
|
return sessionmaker(bind=engine, future=True)()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_access_subjects(session) -> None:
|
||||||
|
session.add_all([
|
||||||
|
Account(id="account-1", email="one@example.test", normalized_email="one@example.test"),
|
||||||
|
Account(id="account-2", email="two@example.test", normalized_email="two@example.test"),
|
||||||
|
User(id=USER_ID, tenant_id=TENANT_ID, account_id="account-1", email="one@example.test"),
|
||||||
|
User(id=OTHER_USER_ID, tenant_id=TENANT_ID, account_id="account-2", email="two@example.test"),
|
||||||
|
Group(id=GROUP_ID, tenant_id=TENANT_ID, slug="group", name="Group"),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
|
||||||
|
return PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id=USER_ID,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
scopes=frozenset(scopes or {"campaigns:campaign:read"}),
|
||||||
|
group_ids=frozenset(group_ids or set()),
|
||||||
|
)
|
||||||
104
tests/test_campaign_reports.py
Normal file
104
tests/test_campaign_reports.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id
|
||||||
|
|
||||||
|
|
||||||
|
def _dt() -> datetime:
|
||||||
|
return datetime(2026, 7, 8, 12, 30, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
entry_index=0,
|
||||||
|
entry_id="recipient-1",
|
||||||
|
recipient_email="recipient@govoplan.test",
|
||||||
|
subject="Evidence test",
|
||||||
|
message_id_header="<message-1@govoplan.test>",
|
||||||
|
eml_storage_key="eml/job-1.eml",
|
||||||
|
eml_local_path="/tmp/job-1.eml",
|
||||||
|
eml_size_bytes=1234,
|
||||||
|
eml_sha256="abc123",
|
||||||
|
build_status="built",
|
||||||
|
validation_status="ready",
|
||||||
|
queue_status="queued",
|
||||||
|
send_status="smtp_accepted",
|
||||||
|
imap_status="appended",
|
||||||
|
attempt_count=2,
|
||||||
|
queued_at=_dt(),
|
||||||
|
claimed_at=None,
|
||||||
|
smtp_started_at=_dt(),
|
||||||
|
outcome_unknown_at=None,
|
||||||
|
sent_at=_dt(),
|
||||||
|
last_error=None,
|
||||||
|
resolved_recipients={
|
||||||
|
"from": [{"name": "Sender", "email": "sender@govoplan.test"}],
|
||||||
|
"to": [{"name": "Recipient", "email": "recipient@govoplan.test"}],
|
||||||
|
"cc": [],
|
||||||
|
"bcc": [],
|
||||||
|
"reply_to": [{"email": "reply@govoplan.test"}],
|
||||||
|
},
|
||||||
|
resolved_attachments=[
|
||||||
|
{
|
||||||
|
"matches": [{"filename": "notice.pdf"}, "/tmp/evidence.xlsx"],
|
||||||
|
"zip_filename": "bundle.zip",
|
||||||
|
"message_filenames": ["notice.pdf"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
issues_snapshot=[],
|
||||||
|
created_at=_dt(),
|
||||||
|
updated_at=_dt(),
|
||||||
|
)
|
||||||
|
smtp = SimpleNamespace(
|
||||||
|
job_id="job-1",
|
||||||
|
attempt_number=2,
|
||||||
|
status="success",
|
||||||
|
smtp_status_code=250,
|
||||||
|
smtp_response="2.0.0 queued",
|
||||||
|
error_type=None,
|
||||||
|
error_message=None,
|
||||||
|
started_at=_dt(),
|
||||||
|
finished_at=_dt(),
|
||||||
|
)
|
||||||
|
imap = SimpleNamespace(
|
||||||
|
job_id="job-1",
|
||||||
|
attempt_number=1,
|
||||||
|
status="appended",
|
||||||
|
folder="Sent",
|
||||||
|
error_message=None,
|
||||||
|
created_at=_dt(),
|
||||||
|
updated_at=_dt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = _job_evidence_row(job, latest_smtp=smtp, latest_imap=imap)
|
||||||
|
|
||||||
|
assert row["campaign_id"] == "campaign-1"
|
||||||
|
assert row["campaign_version_id"] == "version-1"
|
||||||
|
assert row["message_id_header"] == "<message-1@govoplan.test>"
|
||||||
|
assert row["from"] == "Sender <sender@govoplan.test>"
|
||||||
|
assert row["to"] == "Recipient <recipient@govoplan.test>"
|
||||||
|
assert row["reply_to"] == "reply@govoplan.test"
|
||||||
|
assert "bundle.zip" in row["attachment_names"]
|
||||||
|
assert "notice.pdf" in row["attachment_names"]
|
||||||
|
assert row["latest_smtp_status_code"] == 250
|
||||||
|
assert row["latest_smtp_response"] == "2.0.0 queued"
|
||||||
|
assert row["latest_imap_status"] == "appended"
|
||||||
|
assert row["latest_imap_folder"] == "Sent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
|
||||||
|
attempts = [
|
||||||
|
SimpleNamespace(job_id="job-1", attempt_number=1),
|
||||||
|
SimpleNamespace(job_id="job-1", attempt_number=3),
|
||||||
|
SimpleNamespace(job_id="job-2", attempt_number=2),
|
||||||
|
]
|
||||||
|
|
||||||
|
latest = _latest_by_job_id(attempts)
|
||||||
|
|
||||||
|
assert latest["job-1"].attempt_number == 3
|
||||||
|
assert latest["job-2"].attempt_number == 2
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,18 +14,19 @@
|
|||||||
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
|
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"read-excel-file": "9.2.0"
|
||||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.1",
|
"@govoplan/core-webui": "^0.1.6",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js"
|
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
|
||||||
|
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
|
||||||
|
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|||||||
@@ -1,519 +0,0 @@
|
|||||||
import type { ApiSettings } from "../types";
|
|
||||||
import { apiFetch } from "./client";
|
|
||||||
|
|
||||||
export type PermissionItem = {
|
|
||||||
scope: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
category: string;
|
|
||||||
level: "tenant" | "system";
|
|
||||||
system_template_id?: string | null;
|
|
||||||
system_required?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AdminOverview = {
|
|
||||||
active_tenant_id: string;
|
|
||||||
active_tenant_name: string;
|
|
||||||
tenant_count?: number | null;
|
|
||||||
system_account_count?: number | null;
|
|
||||||
system_group_template_count?: number | null;
|
|
||||||
system_role_template_count?: number | null;
|
|
||||||
user_count: number;
|
|
||||||
active_user_count: number;
|
|
||||||
group_count: number;
|
|
||||||
role_count: number;
|
|
||||||
active_api_key_count: number;
|
|
||||||
capabilities: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TenantAdminItem = {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
default_locale: string;
|
|
||||||
settings: Record<string, unknown>;
|
|
||||||
allow_custom_groups?: boolean | null;
|
|
||||||
allow_custom_roles?: boolean | null;
|
|
||||||
allow_api_keys?: boolean | null;
|
|
||||||
effective_governance: Record<string, boolean>;
|
|
||||||
is_active: boolean;
|
|
||||||
counts: Record<string, number>;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TenantOwnerCandidate = {
|
|
||||||
account_id: string;
|
|
||||||
email: string;
|
|
||||||
display_name?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RoleSummary = {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
permissions: string[];
|
|
||||||
effective_permission_count: number;
|
|
||||||
is_builtin: boolean;
|
|
||||||
is_assignable: boolean;
|
|
||||||
user_assignments: number;
|
|
||||||
group_assignments: number;
|
|
||||||
level: "tenant" | "system";
|
|
||||||
system_template_id?: string | null;
|
|
||||||
system_required?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GroupSummary = {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
member_count: number;
|
|
||||||
member_ids: string[];
|
|
||||||
roles: RoleSummary[];
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
system_template_id?: string | null;
|
|
||||||
system_required?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UserAdminItem = {
|
|
||||||
id: string;
|
|
||||||
account_id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
email: string;
|
|
||||||
display_name?: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
account_is_active: boolean;
|
|
||||||
password_reset_required: boolean;
|
|
||||||
last_login_at?: string | null;
|
|
||||||
groups: GroupSummary[];
|
|
||||||
roles: RoleSummary[];
|
|
||||||
effective_scopes: string[];
|
|
||||||
is_owner: boolean;
|
|
||||||
is_last_active_owner: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SystemAccountItem = {
|
|
||||||
account_id: string;
|
|
||||||
email: string;
|
|
||||||
display_name?: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>;
|
|
||||||
roles: RoleSummary[];
|
|
||||||
last_login_at?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export type SystemMembershipDraft = {
|
|
||||||
tenant_id: string;
|
|
||||||
is_active: boolean;
|
|
||||||
role_ids: string[];
|
|
||||||
group_ids: string[];
|
|
||||||
is_owner?: boolean;
|
|
||||||
is_last_active_owner?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PrivacyRetentionPolicyFieldKey =
|
|
||||||
| "store_raw_campaign_json"
|
|
||||||
| "raw_campaign_json_retention_days"
|
|
||||||
| "generated_eml_retention_days"
|
|
||||||
| "stored_report_detail_retention_days"
|
|
||||||
| "mock_mailbox_retention_days"
|
|
||||||
| "audit_detail_retention_days"
|
|
||||||
| "audit_detail_level";
|
|
||||||
|
|
||||||
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
|
|
||||||
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
|
|
||||||
|
|
||||||
export type PrivacyRetentionPolicy = {
|
|
||||||
store_raw_campaign_json: boolean;
|
|
||||||
raw_campaign_json_retention_days?: number | null;
|
|
||||||
generated_eml_retention_days?: number | null;
|
|
||||||
stored_report_detail_retention_days?: number | null;
|
|
||||||
mock_mailbox_retention_days?: number | null;
|
|
||||||
audit_detail_retention_days?: number | null;
|
|
||||||
audit_detail_level: "full" | "redacted" | "minimal";
|
|
||||||
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
|
|
||||||
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
|
|
||||||
};
|
|
||||||
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
|
|
||||||
|
|
||||||
export type PrivacyRetentionPolicyScopeResponse = {
|
|
||||||
scope_type: PrivacyRetentionPolicyScope;
|
|
||||||
scope_id?: string | null;
|
|
||||||
policy: PrivacyRetentionPolicyPatch;
|
|
||||||
effective_policy: PrivacyRetentionPolicy;
|
|
||||||
parent_policy?: PrivacyRetentionPolicy | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SystemSettingsItem = {
|
|
||||||
default_locale: string;
|
|
||||||
allow_tenant_custom_groups: boolean;
|
|
||||||
allow_tenant_custom_roles: boolean;
|
|
||||||
allow_tenant_api_keys: boolean;
|
|
||||||
privacy_retention_policy: PrivacyRetentionPolicy;
|
|
||||||
settings: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RetentionRunResponse = {
|
|
||||||
result: {
|
|
||||||
dry_run: boolean;
|
|
||||||
policy: PrivacyRetentionPolicy;
|
|
||||||
cutoffs: Record<string, string | null>;
|
|
||||||
effective_policy_scope?: string;
|
|
||||||
counts: Record<string, Record<string, number>>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GovernanceAssignment = {
|
|
||||||
tenant_id: string;
|
|
||||||
mode: "available" | "required";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GovernanceTemplateItem = {
|
|
||||||
id: string;
|
|
||||||
kind: "group" | "role";
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
permissions: string[];
|
|
||||||
effective_permission_count: number;
|
|
||||||
is_active: boolean;
|
|
||||||
assignments: GovernanceAssignment[];
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ApiKeyAdminItem = {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
user_email: string;
|
|
||||||
name: string;
|
|
||||||
prefix: string;
|
|
||||||
scopes: string[];
|
|
||||||
expires_at?: string | null;
|
|
||||||
last_used_at?: string | null;
|
|
||||||
revoked_at?: string | null;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuditAdminItem = {
|
|
||||||
id: string;
|
|
||||||
scope: "tenant" | "system";
|
|
||||||
tenant_id?: string | null;
|
|
||||||
actor_email?: string | null;
|
|
||||||
action: string;
|
|
||||||
object_type?: string | null;
|
|
||||||
object_id?: string | null;
|
|
||||||
details: Record<string, unknown>;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/overview");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
|
|
||||||
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
|
|
||||||
return response.permissions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
|
|
||||||
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
|
|
||||||
return response.tenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
|
|
||||||
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
|
|
||||||
return response.accounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTenant(settings: ApiSettings, payload: {
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
owner_account_id?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
default_locale?: string;
|
|
||||||
settings?: Record<string, unknown>;
|
|
||||||
allow_custom_groups?: boolean | null;
|
|
||||||
allow_custom_roles?: boolean | null;
|
|
||||||
allow_api_keys?: boolean | null;
|
|
||||||
}): Promise<TenantAdminItem> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
default_locale: string;
|
|
||||||
settings: Record<string, unknown>;
|
|
||||||
allow_custom_groups?: boolean | null;
|
|
||||||
allow_custom_roles?: boolean | null;
|
|
||||||
allow_api_keys?: boolean | null;
|
|
||||||
effective_governance: Record<string, boolean>;
|
|
||||||
is_active: boolean;
|
|
||||||
}>): Promise<TenantAdminItem> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
|
|
||||||
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
|
|
||||||
return response.users;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createUser(settings: ApiSettings, payload: {
|
|
||||||
email: string;
|
|
||||||
display_name?: string | null;
|
|
||||||
password?: string | null;
|
|
||||||
password_reset_required?: boolean;
|
|
||||||
is_active?: boolean;
|
|
||||||
group_ids?: string[];
|
|
||||||
role_ids?: string[];
|
|
||||||
}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{
|
|
||||||
display_name: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
group_ids: string[];
|
|
||||||
role_ids: string[];
|
|
||||||
}>): Promise<UserAdminItem> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
|
||||||
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
|
|
||||||
return response.groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGroup(settings: ApiSettings, payload: {
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
is_active?: boolean;
|
|
||||||
member_ids?: string[];
|
|
||||||
role_ids?: string[];
|
|
||||||
}): Promise<GroupSummary> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
member_ids: string[];
|
|
||||||
role_ids: string[];
|
|
||||||
}>): Promise<GroupSummary> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
|
||||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
|
|
||||||
return response.roles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRole(settings: ApiSettings, payload: {
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
permissions: string[];
|
|
||||||
}): Promise<RoleSummary> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateRole(settings: ApiSettings, roleId: string, payload: {
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
permissions: string[];
|
|
||||||
is_assignable: boolean;
|
|
||||||
}): Promise<RoleSummary> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteRole(settings: ApiSettings, roleId: string): Promise<void> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
|
||||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
|
|
||||||
return response.roles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSystemRole(settings: ApiSettings, payload: {
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
permissions: string[];
|
|
||||||
}): Promise<RoleSummary> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSystemRole(settings: ApiSettings, roleId: string, payload: {
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
permissions: string[];
|
|
||||||
is_assignable: boolean;
|
|
||||||
}): Promise<RoleSummary> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise<void> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/system/accounts");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
|
|
||||||
display_name?: string | null;
|
|
||||||
is_active?: boolean;
|
|
||||||
role_ids?: string[];
|
|
||||||
}): Promise<SystemAccountItem> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise<SystemAccountItem> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ role_ids: roleIds })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (includeRevoked) params.set("include_revoked", "true");
|
|
||||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
||||||
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
|
|
||||||
return response.api_keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createApiKey(settings: ApiSettings, payload: {
|
|
||||||
name: string;
|
|
||||||
user_id?: string | null;
|
|
||||||
scopes: string[];
|
|
||||||
expires_at?: string | null;
|
|
||||||
}): Promise<ApiKeyAdminItem & { secret: string }> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiKeyAdminItem> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuditQueryOptions = {
|
|
||||||
tenantId?: string | null;
|
|
||||||
allTenants?: boolean;
|
|
||||||
scope?: "tenant" | "system";
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
|
|
||||||
sortDirection?: "asc" | "desc";
|
|
||||||
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
|
||||||
if (options.allTenants) params.set("all_tenants", "true");
|
|
||||||
if (options.scope) params.set("scope", options.scope);
|
|
||||||
if (options.limit) params.set("limit", String(options.limit));
|
|
||||||
if (options.offset) params.set("offset", String(options.offset));
|
|
||||||
if (options.page) params.set("page", String(options.page));
|
|
||||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
|
||||||
if (options.sortBy) params.set("sort_by", options.sortBy);
|
|
||||||
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
|
|
||||||
for (const [column, value] of Object.entries(options.filters ?? {})) {
|
|
||||||
if (value?.trim()) params.set(`filter_${column}`, value);
|
|
||||||
}
|
|
||||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
||||||
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function createSystemAccount(settings: ApiSettings, payload: {
|
|
||||||
email: string;
|
|
||||||
display_name?: string | null;
|
|
||||||
password?: string | null;
|
|
||||||
password_reset_required?: boolean;
|
|
||||||
is_active?: boolean;
|
|
||||||
role_ids?: string[];
|
|
||||||
memberships?: SystemMembershipDraft[];
|
|
||||||
}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise<SystemAccountItem> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, {
|
|
||||||
method: "PUT", body: JSON.stringify({ memberships })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/system/settings");
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SystemSettingsUpdatePayload = {
|
|
||||||
default_locale: string;
|
|
||||||
allow_tenant_custom_groups: boolean;
|
|
||||||
allow_tenant_custom_roles: boolean;
|
|
||||||
allow_tenant_api_keys: boolean;
|
|
||||||
privacy_retention_policy?: PrivacyRetentionPolicy | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (scopeId) params.set("scope_id", scopeId);
|
|
||||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
||||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (scopeId) params.set("scope_id", scopeId);
|
|
||||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
||||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
|
||||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
|
||||||
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
|
|
||||||
return response.templates;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { ApiSettings, CampaignListItem } from "../types";
|
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
|
||||||
import { apiDownload, apiFetch } from "./client";
|
import { apiDownload, apiFetch } from "./client";
|
||||||
|
|
||||||
export type CampaignListResponse =
|
export type CampaignListResponse =
|
||||||
| CampaignListItem[]
|
CampaignListItem[] |
|
||||||
| {
|
{
|
||||||
campaigns?: CampaignListItem[];
|
campaigns?: CampaignListItem[];
|
||||||
items?: CampaignListItem[];
|
items?: CampaignListItem[];
|
||||||
results?: CampaignListItem[];
|
results?: CampaignListItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -20,8 +20,29 @@ export type CampaignShare = {
|
|||||||
revoked_at?: string | null;
|
revoked_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignShareTarget = { id: string; name: string; secondary?: string | null };
|
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
||||||
export type CampaignShareTargets = { users: CampaignShareTarget[]; groups: CampaignShareTarget[] };
|
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
||||||
|
export type AccessExplanationUser = {
|
||||||
|
id: string;
|
||||||
|
account_id?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
display_name?: string | null;
|
||||||
|
};
|
||||||
|
export type AccessDecisionProvenanceItem = {
|
||||||
|
kind: string;
|
||||||
|
id?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
tenant_id?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
export type ResourceAccessExplanationResponse = {
|
||||||
|
user: AccessExplanationUser;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
action: string;
|
||||||
|
provenance: AccessDecisionProvenanceItem[];
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignUpdatePayload = {
|
export type CampaignUpdatePayload = {
|
||||||
external_id?: string | null;
|
external_id?: string | null;
|
||||||
@@ -77,6 +98,70 @@ export type CampaignVersionDetail = CampaignVersionListItem & {
|
|||||||
campaign_json?: Record<string, unknown>;
|
campaign_json?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignWorkspaceResponse = {
|
||||||
|
campaign: CampaignListItem | null;
|
||||||
|
versions: CampaignVersionListItem[];
|
||||||
|
current_version: CampaignVersionDetail | null;
|
||||||
|
summary: CampaignSummary | null;
|
||||||
|
selected_version_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignDeltaResponse = {
|
||||||
|
campaigns: CampaignListItem[];
|
||||||
|
deleted: DeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & {
|
||||||
|
deleted: DeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignWorkspaceQuery = {
|
||||||
|
versionId?: string | null;
|
||||||
|
includeCurrentVersion?: boolean;
|
||||||
|
includeSummary?: boolean;
|
||||||
|
includeVersions?: boolean;
|
||||||
|
since?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipientImportColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
|
||||||
|
|
||||||
|
export type RecipientImportColumnMappingProfileItem = {
|
||||||
|
columnIndex: number;
|
||||||
|
kind: RecipientImportColumnKind;
|
||||||
|
fieldName?: string | null;
|
||||||
|
newFieldName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipientImportMappingProfile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
columnCount: number;
|
||||||
|
headers: string[];
|
||||||
|
normalizedHeaders: string[];
|
||||||
|
orderedHeaderFingerprint: string;
|
||||||
|
unorderedHeaderFingerprint: string;
|
||||||
|
delimiter: "," | ";" | "\t";
|
||||||
|
headerRows: number;
|
||||||
|
quoted: boolean;
|
||||||
|
valueSeparators: string;
|
||||||
|
mappings: RecipientImportColumnMappingProfileItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipientImportMappingProfilePayload = Omit<RecipientImportMappingProfile, "id" | "createdAt" | "updatedAt">;
|
||||||
|
|
||||||
|
export type RecipientImportMappingProfileListResponse = {
|
||||||
|
profiles: RecipientImportMappingProfile[];
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignVersionUpdatePayload = {
|
export type CampaignVersionUpdatePayload = {
|
||||||
campaign_json?: Record<string, unknown> | null;
|
campaign_json?: Record<string, unknown> | null;
|
||||||
current_flow?: string | null;
|
current_flow?: string | null;
|
||||||
@@ -157,6 +242,12 @@ export type CampaignSendNowPayload = {
|
|||||||
enqueue_imap_task?: boolean;
|
enqueue_imap_task?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignAppendSentPayload = {
|
||||||
|
dry_run?: boolean;
|
||||||
|
enqueue_celery?: boolean;
|
||||||
|
run_inline?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type CampaignAttachmentPreviewFile = {
|
export type CampaignAttachmentPreviewFile = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -226,6 +317,7 @@ export type CampaignJobsQuery = {
|
|||||||
versionId?: string;
|
versionId?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
cursor?: string | null;
|
||||||
sendStatus?: string[];
|
sendStatus?: string[];
|
||||||
validationStatus?: string[];
|
validationStatus?: string[];
|
||||||
imapStatus?: string[];
|
imapStatus?: string[];
|
||||||
@@ -239,6 +331,8 @@ export type CampaignJobsResponse = {
|
|||||||
total: number;
|
total: number;
|
||||||
total_unfiltered: number;
|
total_unfiltered: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
|
cursor?: string | null;
|
||||||
|
next_cursor?: string | null;
|
||||||
counts: Record<string, Record<string, number>>;
|
counts: Record<string, Record<string, number>>;
|
||||||
filtered_counts: Record<string, Record<string, number>>;
|
filtered_counts: Record<string, Record<string, number>>;
|
||||||
review: {
|
review: {
|
||||||
@@ -250,6 +344,13 @@ export type CampaignJobsResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignJobsDeltaResponse = CampaignJobsResponse & {
|
||||||
|
deleted: DeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignJobDetailResponse = {
|
export type CampaignJobDetailResponse = {
|
||||||
job: Record<string, unknown>;
|
job: Record<string, unknown>;
|
||||||
attempts: {
|
attempts: {
|
||||||
@@ -277,15 +378,56 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
|
|||||||
return response.campaigns ?? response.items ?? response.results ?? [];
|
return response.campaigns ?? response.items ?? response.results ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listCampaignsDelta(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: {since?: string | null;limit?: number;} = {})
|
||||||
|
: Promise<CampaignDeltaResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.since) params.set("since", options.since);
|
||||||
|
if (options.limit) params.set("limit", String(options.limit));
|
||||||
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
|
return apiFetch<CampaignDeltaResponse>(settings, `/api/v1/campaigns/delta${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRecipientImportMappingProfiles(settings: ApiSettings): Promise<RecipientImportMappingProfile[]> {
|
||||||
|
const response = await apiFetch<RecipientImportMappingProfileListResponse>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles");
|
||||||
|
return response.profiles ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRecipientImportMappingProfile(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: RecipientImportMappingProfilePayload)
|
||||||
|
: Promise<RecipientImportMappingProfile> {
|
||||||
|
return apiFetch<RecipientImportMappingProfile>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRecipientImportMappingProfile(
|
||||||
|
settings: ApiSettings,
|
||||||
|
profileId: string,
|
||||||
|
payload: RecipientImportMappingProfilePayload)
|
||||||
|
: Promise<RecipientImportMappingProfile> {
|
||||||
|
return apiFetch<RecipientImportMappingProfile>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecipientImportMappingProfile(settings: ApiSettings, profileId: string): Promise<void> {
|
||||||
|
await apiFetch<void>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
|
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
|
||||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCampaignMetadata(
|
export async function updateCampaignMetadata(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
payload: CampaignUpdatePayload
|
payload: CampaignUpdatePayload)
|
||||||
): Promise<CampaignListItem> {
|
: Promise<CampaignListItem> {
|
||||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
|
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -293,14 +435,14 @@ export async function updateCampaignMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createNewCampaign(
|
export async function createNewCampaign(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
overrides: CampaignCreateMinimalPayload = {}
|
overrides: CampaignCreateMinimalPayload = {})
|
||||||
): Promise<CampaignCreateResponse> {
|
: Promise<CampaignCreateResponse> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
||||||
const payload = {
|
const payload = {
|
||||||
external_id: overrides.external_id ?? `new-campaign-${stamp}`,
|
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 ?? "",
|
description: overrides.description ?? "",
|
||||||
current_flow: overrides.current_flow ?? "create",
|
current_flow: overrides.current_flow ?? "create",
|
||||||
current_step: overrides.current_step ?? "basics"
|
current_step: overrides.current_step ?? "basics"
|
||||||
@@ -316,67 +458,101 @@ export async function getCampaignSchema(settings: ApiSettings): Promise<unknown>
|
|||||||
return apiFetch(settings, "/api/v1/schemas/campaign");
|
return apiFetch(settings, "/api/v1/schemas/campaign");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCampaignSchemaUi(settings: ApiSettings): Promise<unknown> {
|
||||||
|
return apiFetch(settings, "/api/v1/schemas/campaign/ui");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCampaignWorkspace(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
options: CampaignWorkspaceQuery = {})
|
||||||
|
: Promise<CampaignWorkspaceResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.versionId) params.set("version_id", options.versionId);
|
||||||
|
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
|
||||||
|
if (options.includeSummary !== undefined) params.set("include_summary", String(options.includeSummary));
|
||||||
|
if (options.includeVersions !== undefined) params.set("include_versions", String(options.includeVersions));
|
||||||
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
|
return apiFetch<CampaignWorkspaceResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCampaignWorkspaceDelta(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
options: CampaignWorkspaceQuery = {})
|
||||||
|
: Promise<CampaignWorkspaceDeltaResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.versionId) params.set("version_id", options.versionId);
|
||||||
|
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
|
||||||
|
if (options.includeSummary !== undefined) params.set("include_summary", String(options.includeSummary));
|
||||||
|
if (options.includeVersions !== undefined) params.set("include_versions", String(options.includeVersions));
|
||||||
|
if (options.since) params.set("since", options.since);
|
||||||
|
if (options.limit) params.set("limit", String(options.limit));
|
||||||
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
|
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listCampaignVersions(
|
export async function listCampaignVersions(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string
|
campaignId: string)
|
||||||
): Promise<CampaignVersionListItem[]> {
|
: Promise<CampaignVersionListItem[]> {
|
||||||
return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`);
|
return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCampaignVersion(
|
export async function getCampaignVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string
|
versionId: string)
|
||||||
): Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unlockCampaignVersionValidation(
|
export async function unlockCampaignVersionValidation(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string
|
versionId: string)
|
||||||
): Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function lockCampaignVersionTemporarily(
|
export async function lockCampaignVersionTemporarily(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string
|
versionId: string)
|
||||||
): Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unlockCampaignVersionUserLock(
|
export async function unlockCampaignVersionUserLock(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string
|
versionId: string)
|
||||||
): Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function lockCampaignVersionPermanently(
|
export async function lockCampaignVersionPermanently(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string
|
versionId: string)
|
||||||
): Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCampaignVersion(
|
export async function updateCampaignVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
payload: CampaignVersionUpdatePayload
|
payload: CampaignVersionUpdatePayload)
|
||||||
): Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -384,11 +560,11 @@ export async function updateCampaignVersion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function forkCampaignVersion(
|
export async function forkCampaignVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
payload: CampaignVersionUpdatePayload = {}
|
payload: CampaignVersionUpdatePayload = {})
|
||||||
): Promise<CampaignCreateResponse> {
|
: Promise<CampaignCreateResponse> {
|
||||||
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
|
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -396,11 +572,11 @@ export async function forkCampaignVersion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function autosaveCampaignVersion(
|
export async function autosaveCampaignVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
payload: CampaignVersionUpdatePayload
|
payload: CampaignVersionUpdatePayload)
|
||||||
): Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -408,12 +584,12 @@ export async function autosaveCampaignVersion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setCampaignVersionStep(
|
export async function setCampaignVersionStep(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
currentStep: string,
|
currentStep: string,
|
||||||
currentFlow?: string | null
|
currentFlow?: string | null)
|
||||||
): Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep })
|
body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep })
|
||||||
@@ -421,11 +597,11 @@ export async function setCampaignVersionStep(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validatePartial(
|
export async function validatePartial(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
payload: CampaignPartialValidationPayload = {}
|
payload: CampaignPartialValidationPayload = {})
|
||||||
): Promise<CampaignPartialValidationResponse> {
|
: Promise<CampaignPartialValidationResponse> {
|
||||||
return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, {
|
return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -433,20 +609,20 @@ export async function validatePartial(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function publishCampaignVersion(
|
export async function publishCampaignVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string
|
versionId: string)
|
||||||
): Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateVersion(
|
export async function validateVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
checkFiles = false
|
checkFiles = false)
|
||||||
): Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ check_files: checkFiles })
|
body: JSON.stringify({ check_files: checkFiles })
|
||||||
@@ -454,10 +630,10 @@ export async function validateVersion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function buildVersion(
|
export async function buildVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
writeEml = true
|
writeEml = true)
|
||||||
): Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ write_eml: writeEml })
|
body: JSON.stringify({ write_eml: writeEml })
|
||||||
@@ -466,11 +642,11 @@ export async function buildVersion(
|
|||||||
|
|
||||||
|
|
||||||
export function previewCampaignAttachments(
|
export function previewCampaignAttachments(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
payload: CampaignAttachmentPreviewPayload = {}
|
payload: CampaignAttachmentPreviewPayload = {})
|
||||||
): Promise<CampaignAttachmentPreviewResponse> {
|
: Promise<CampaignAttachmentPreviewResponse> {
|
||||||
return apiFetch<CampaignAttachmentPreviewResponse>(
|
return apiFetch<CampaignAttachmentPreviewResponse>(
|
||||||
settings,
|
settings,
|
||||||
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`,
|
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`,
|
||||||
@@ -479,23 +655,24 @@ export function previewCampaignAttachments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCampaignSummary(
|
export async function getCampaignSummary(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId?: string
|
versionId?: string)
|
||||||
): Promise<CampaignSummary> {
|
: Promise<CampaignSummary> {
|
||||||
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
|
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCampaignJobs(
|
export async function getCampaignJobs(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
options: CampaignJobsQuery = {}
|
options: CampaignJobsQuery = {})
|
||||||
): Promise<CampaignJobsResponse> {
|
: Promise<CampaignJobsResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options.versionId) params.set("version_id", options.versionId);
|
if (options.versionId) params.set("version_id", options.versionId);
|
||||||
if (options.page) params.set("page", String(options.page));
|
if (options.page) params.set("page", String(options.page));
|
||||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
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.sendStatus ?? []) params.append("send_status", value);
|
||||||
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
|
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
|
||||||
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
|
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
|
||||||
@@ -504,29 +681,49 @@ export async function getCampaignJobs(
|
|||||||
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
|
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCampaignJobsDelta(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {})
|
||||||
|
: Promise<CampaignJobsDeltaResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.versionId) params.set("version_id", options.versionId);
|
||||||
|
if (options.page) params.set("page", String(options.page));
|
||||||
|
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||||
|
if (options.cursor) params.set("cursor", options.cursor);
|
||||||
|
for (const value of options.sendStatus ?? []) params.append("send_status", value);
|
||||||
|
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
|
||||||
|
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
|
||||||
|
if (options.query?.trim()) params.set("q", options.query.trim());
|
||||||
|
if (options.since) params.set("since", options.since);
|
||||||
|
if (options.limit) params.set("limit", String(options.limit));
|
||||||
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
|
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCampaignJobDetail(
|
export async function getCampaignJobDetail(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
jobId: string
|
jobId: string)
|
||||||
): Promise<CampaignJobDetailResponse> {
|
: Promise<CampaignJobDetailResponse> {
|
||||||
return apiFetch<CampaignJobDetailResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`);
|
return apiFetch<CampaignJobDetailResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCampaignReport(
|
export async function getCampaignReport(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId?: string
|
versionId?: string)
|
||||||
): Promise<CampaignSummary> {
|
: Promise<CampaignSummary> {
|
||||||
const params = new URLSearchParams({ include_jobs: "false" });
|
const params = new URLSearchParams({ include_jobs: "false" });
|
||||||
if (versionId) params.set("version_id", versionId);
|
if (versionId) params.set("version_id", versionId);
|
||||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
|
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadCampaignJobsCsv(
|
export async function downloadCampaignJobsCsv(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId?: string
|
versionId?: string)
|
||||||
): Promise<void> {
|
: Promise<void> {
|
||||||
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||||
await apiDownload(
|
await apiDownload(
|
||||||
settings,
|
settings,
|
||||||
@@ -536,10 +733,10 @@ export async function downloadCampaignJobsCsv(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function emailCampaignReport(
|
export async function emailCampaignReport(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
payload: CampaignReportEmailPayload
|
payload: CampaignReportEmailPayload)
|
||||||
): Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/report/email`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/report/email`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -547,10 +744,10 @@ export async function emailCampaignReport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function retryCampaignJobs(
|
export async function retryCampaignJobs(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
payload: Record<string, unknown> = {}
|
payload: Record<string, unknown> = {})
|
||||||
): Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -558,10 +755,10 @@ export async function retryCampaignJobs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sendUnattemptedCampaignJobs(
|
export async function sendUnattemptedCampaignJobs(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
payload: Record<string, unknown> = {}
|
payload: Record<string, unknown> = {})
|
||||||
): Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -569,12 +766,12 @@ export async function sendUnattemptedCampaignJobs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveCampaignJobOutcome(
|
export async function resolveCampaignJobOutcome(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
jobId: string,
|
jobId: string,
|
||||||
decision: "smtp_accepted" | "not_sent",
|
decision: "smtp_accepted" | "not_sent",
|
||||||
note?: string
|
note?: string)
|
||||||
): Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ decision, note: note || null })
|
body: JSON.stringify({ decision, note: note || null })
|
||||||
@@ -582,11 +779,11 @@ export async function resolveCampaignJobOutcome(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCampaignReviewState(
|
export async function updateCampaignReviewState(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
payload: CampaignReviewStatePayload
|
payload: CampaignReviewStatePayload)
|
||||||
): Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -594,10 +791,10 @@ export async function updateCampaignReviewState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function queueCampaign(
|
export async function queueCampaign(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
payload: CampaignQueuePayload = {}
|
payload: CampaignQueuePayload = {})
|
||||||
): Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -605,10 +802,10 @@ export async function queueCampaign(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sendCampaignNow(
|
export async function sendCampaignNow(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
payload: CampaignSendNowPayload = {}
|
payload: CampaignSendNowPayload = {})
|
||||||
): Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -617,10 +814,10 @@ export async function sendCampaignNow(
|
|||||||
|
|
||||||
|
|
||||||
export async function mockSendCampaign(
|
export async function mockSendCampaign(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
payload: CampaignMockSendPayload = {}
|
payload: CampaignMockSendPayload = {})
|
||||||
): Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -640,13 +837,17 @@ export async function cancelCampaign(settings: ApiSettings, campaignId: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function appendSent(
|
export async function appendSent(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
dryRun = false
|
payload: CampaignAppendSentPayload = {})
|
||||||
): Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ dry_run: dryRun })
|
body: JSON.stringify({
|
||||||
|
dry_run: payload.dry_run ?? false,
|
||||||
|
enqueue_celery: payload.enqueue_celery ?? true,
|
||||||
|
run_inline: payload.run_inline ?? false
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,24 +855,38 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
|
|||||||
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
|
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchResourceAccessExplanation(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
|
||||||
|
: Promise<ResourceAccessExplanationResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
user_id: options.userId,
|
||||||
|
resource_type: options.resourceType,
|
||||||
|
resource_id: options.resourceId,
|
||||||
|
action: options.action
|
||||||
|
});
|
||||||
|
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||||
|
return apiFetch<ResourceAccessExplanationResponse>(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
|
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
|
||||||
const response = await apiFetch<{ shares: CampaignShare[] }>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
||||||
return response.shares;
|
return response.shares;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCampaignOwner(
|
export async function updateCampaignOwner(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
payload: { owner_user_id?: string | null; owner_group_id?: string | null }
|
payload: {owner_user_id?: string | null;owner_group_id?: string | null;})
|
||||||
): Promise<CampaignListItem> {
|
: Promise<CampaignListItem> {
|
||||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) });
|
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertCampaignShare(
|
export async function upsertCampaignShare(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
payload: { target_type: "user" | "group"; target_id: string; permission: "read" | "write" }
|
payload: {target_type: "user" | "group";target_id: string;permission: "read" | "write";})
|
||||||
): Promise<CampaignShare> {
|
: Promise<CampaignShare> {
|
||||||
return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) });
|
return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@govoplan/files-webui";
|
|
||||||
@@ -1 +1,222 @@
|
|||||||
export * from "@govoplan/mail-webui";
|
import type { ApiSettings } from "../types";
|
||||||
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
|
export type MailSecurity = "plain" | "tls" | "starttls";
|
||||||
|
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
|
||||||
|
export type MailSmtpTestPayload = {
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
security?: MailSecurity;
|
||||||
|
timeout_seconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailImapTestPayload = MailSmtpTestPayload & {
|
||||||
|
sent_folder?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailTransportCredentialsPayload = {
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailServerProfileCredentialsPayload = {
|
||||||
|
smtp?: MailTransportCredentialsPayload;
|
||||||
|
imap?: MailTransportCredentialsPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailConnectionTestResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
protocol: "smtp" | "imap";
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
security?: MailSecurity | string | null;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailImapFolderResponse = {
|
||||||
|
name: string;
|
||||||
|
flags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailImapFolderListResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
protocol: "imap";
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
security?: MailSecurity | string | null;
|
||||||
|
message: string;
|
||||||
|
folders: MailImapFolderResponse[];
|
||||||
|
detected_sent_folder?: string | null;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailServerProfile = {
|
||||||
|
id: string;
|
||||||
|
tenant_id?: string | null;
|
||||||
|
scope_type: MailProfileScope;
|
||||||
|
scope_id?: string | null;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
smtp: MailSmtpTestPayload;
|
||||||
|
imap?: MailImapTestPayload | null;
|
||||||
|
credentials?: MailServerProfileCredentialsPayload | null;
|
||||||
|
smtp_password_configured: boolean;
|
||||||
|
imap_password_configured: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
||||||
|
|
||||||
|
export const mailProfilePatternKeys = [
|
||||||
|
"smtp_hosts",
|
||||||
|
"imap_hosts",
|
||||||
|
"envelope_senders",
|
||||||
|
"from_headers",
|
||||||
|
"recipient_domains"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
|
||||||
|
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||||
|
|
||||||
|
export type MailCredentialPolicy = {
|
||||||
|
inherit?: boolean | null;
|
||||||
|
allow_override?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailProfilePolicy = {
|
||||||
|
allowed_profile_ids?: string[] | null;
|
||||||
|
allow_user_profiles?: boolean | null;
|
||||||
|
allow_group_profiles?: boolean | null;
|
||||||
|
allow_campaign_profiles?: boolean | null;
|
||||||
|
smtp_credentials?: MailCredentialPolicy | null;
|
||||||
|
imap_credentials?: MailCredentialPolicy | null;
|
||||||
|
whitelist?: MailProfilePatternRules | null;
|
||||||
|
blacklist?: MailProfilePatternRules | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PolicySourceStep = {
|
||||||
|
scope_type: string;
|
||||||
|
scope_id?: string | null;
|
||||||
|
label: string;
|
||||||
|
applied_fields?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailProfilePolicyResponse = {
|
||||||
|
scope_type: MailProfileScope;
|
||||||
|
scope_id?: string | null;
|
||||||
|
policy: MailProfilePolicy;
|
||||||
|
effective_policy?: MailProfilePolicy | null;
|
||||||
|
parent_policy?: MailProfilePolicy | null;
|
||||||
|
effective_policy_sources?: PolicySourceStep[];
|
||||||
|
parent_policy_sources?: PolicySourceStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailServerProfilePayload = {
|
||||||
|
name: string;
|
||||||
|
slug?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
scope_type?: MailProfileScope;
|
||||||
|
scope_id?: string | null;
|
||||||
|
smtp: MailSmtpTestPayload;
|
||||||
|
imap?: MailImapTestPayload | null;
|
||||||
|
credentials?: MailServerProfileCredentialsPayload | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (includeInactive) params.set("include_inactive", "true");
|
||||||
|
if (campaignId) params.set("campaign_id", campaignId);
|
||||||
|
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||||
|
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
|
||||||
|
return response.profiles ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
|
||||||
|
return apiFetch<MailServerProfile>(settings, "/api/v1/mail/profiles", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMailProfilePolicy(
|
||||||
|
settings: ApiSettings,
|
||||||
|
scopeType: MailProfileScope,
|
||||||
|
scopeId?: string | null,
|
||||||
|
campaignId?: string | null
|
||||||
|
): Promise<MailProfilePolicyResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (scopeId) params.set("scope_id", scopeId);
|
||||||
|
if (campaignId) params.set("campaign_id", campaignId);
|
||||||
|
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||||
|
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||||
|
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||||
|
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
||||||
|
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise<MailConnectionTestResponse> {
|
||||||
|
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailConnectionTestResponse> {
|
||||||
|
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailImapFolderListResponse> {
|
||||||
|
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MockMailboxMessage = {
|
||||||
|
id: string;
|
||||||
|
kind: "smtp" | "imap_append" | string;
|
||||||
|
created_at: string;
|
||||||
|
envelope_from?: string | null;
|
||||||
|
envelope_recipients?: string[];
|
||||||
|
subject?: string | null;
|
||||||
|
from_header?: string | null;
|
||||||
|
to_header?: string | null;
|
||||||
|
cc_header?: string | null;
|
||||||
|
bcc_header?: string | null;
|
||||||
|
message_id?: string | null;
|
||||||
|
size_bytes?: number;
|
||||||
|
body_preview?: string | null;
|
||||||
|
attachment_count?: number;
|
||||||
|
folder?: string | null;
|
||||||
|
raw_eml?: string | null;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockMailboxMessageResponse = {
|
||||||
|
message: MockMailboxMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||||
|
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
import { Button } from "@govoplan/core-webui";
|
|
||||||
export default Button;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { Card } from "@govoplan/core-webui";
|
|
||||||
export default Card;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
|
||||||
export default ConfirmDialog;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { Dialog } from "@govoplan/core-webui";
|
|
||||||
export default Dialog;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
|
||||||
export default DismissibleAlert;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
|
||||||
export default FormField;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
|
||||||
export default LoadingFrame;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { LoadingIndicator } from "@govoplan/core-webui";
|
|
||||||
export default LoadingIndicator;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { MetricCard } from "@govoplan/core-webui";
|
|
||||||
export default MetricCard;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { PageTitle } from "@govoplan/core-webui";
|
|
||||||
export default PageTitle;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { StatusBadge } from "@govoplan/core-webui";
|
|
||||||
export default StatusBadge;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { Stepper } from "@govoplan/core-webui";
|
|
||||||
export default Stepper;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
|
||||||
export default ToggleSwitch;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { EmailAddressInput } from "@govoplan/core-webui";
|
|
||||||
export default EmailAddressInput;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { FieldLabel } from "@govoplan/core-webui";
|
|
||||||
export default FieldLabel;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { InlineHelp } from "@govoplan/core-webui";
|
|
||||||
export default InlineHelp;
|
|
||||||
@@ -1,32 +1,32 @@
|
|||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import Card from "../../components/Card";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
|
|
||||||
const personalContacts = [
|
const personalContacts = [
|
||||||
{ name: "Ada Lovelace", email: "ada@example.local", source: "Personal", tags: "Used recently" },
|
{ name: "i18n:govoplan-campaign.ada_lovelace.a69a9f8a", email: "ada@example.local", source: "Personal", tags: "i18n:govoplan-campaign.used_recently.af75cea7" },
|
||||||
{ name: "Grace Hopper", email: "grace@example.local", source: "Personal", tags: "Favorite" }
|
{ name: "i18n:govoplan-campaign.grace_hopper.d97e6939", email: "grace@example.local", source: "Personal", tags: "i18n:govoplan-campaign.favorite.6b90b6a1" }];
|
||||||
];
|
|
||||||
|
|
||||||
const groupContacts = [
|
const groupContacts = [
|
||||||
{ name: "Project Office", email: "project-office@example.local", source: "Group", tags: "Shared" },
|
{ name: "i18n:govoplan-campaign.project_office.c35aa9ca", email: "project-office@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared.50d0d8dd" },
|
||||||
{ name: "Finance Team", email: "finance@example.local", source: "Group", tags: "Shared list" }
|
{ name: "i18n:govoplan-campaign.finance_team.ff353e5c", email: "finance@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared_list.b3c94b39" }];
|
||||||
];
|
|
||||||
|
|
||||||
const tenantContacts = [
|
const tenantContacts = [
|
||||||
{ name: "Helpdesk", email: "helpdesk@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: "Data Protection", email: "privacy@example.local", source: "Tenant", tags: "Directory" }
|
{ 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 [
|
return [
|
||||||
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
|
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
|
||||||
{ id: "email", header: "Email", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
|
{ id: "email", header: "i18n:govoplan-campaign.email.84add5b2", 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: "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: "Tags", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
|
{ id: "tags", header: "i18n:govoplan-campaign.tags.848eed0f", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
|
||||||
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "Mock" }], display: "pill" }, render: () => <StatusBadge status="mock" />, value: () => "mock" }
|
{ 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: () => <StatusBadge status="mock" />, value: () => "mock" }];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddressBookPage() {
|
export default function AddressBookPage() {
|
||||||
@@ -36,55 +36,55 @@ export default function AddressBookPage() {
|
|||||||
<div className="content-pad workspace-data-page module-entry-page address-book-page">
|
<div className="content-pad workspace-data-page module-entry-page address-book-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle>Address Book</PageTitle>
|
<PageTitle>i18n:govoplan-campaign.address_book.f6327f59</PageTitle>
|
||||||
<p>Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.</p>
|
<p>i18n:govoplan-campaign.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button disabled>Import</Button>
|
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||||
<Button variant="primary" disabled>Add contact</Button>
|
<Button variant="primary" disabled>i18n:govoplan-campaign.add_contact.6da0b4b8</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="metric-grid">
|
<div className="metric-grid">
|
||||||
<Card title="Personal"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">Private contacts and remembered addresses.</p></Card>
|
<Card title="i18n:govoplan-campaign.personal.40f07323"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">i18n:govoplan-campaign.private_contacts_and_remembered_addresses.2bd71556</p></Card>
|
||||||
<Card title="Group"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">Shared group address books and lists.</p></Card>
|
<Card title="i18n:govoplan-campaign.group.171a0606"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d</p></Card>
|
||||||
<Card title="Tenant"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">Tenant directory and approved shared contacts.</p></Card>
|
<Card title="i18n:govoplan-campaign.tenant.3ca93c78"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">i18n:govoplan-campaign.tenant_directory_and_approved_shared_contacts.fa671f1b</p></Card>
|
||||||
<Card title="Sync"><strong className="module-big-number">Mock</strong><p className="muted">CardDAV/LDAP/import connectors can be added later.</p></Card>
|
<Card title="i18n:govoplan-campaign.sync.905f6309"><strong className="module-big-number">i18n:govoplan-campaign.mock.3bba2a47</strong><p className="muted">i18n:govoplan-campaign.carddav_ldap_import_connectors_can_be_added_late.0471f513</p></Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-grid settings-dashboard-grid">
|
<div className="dashboard-grid settings-dashboard-grid">
|
||||||
<Card title="Address book scopes">
|
<Card title="i18n:govoplan-campaign.address_book_scopes.b0d0efde">
|
||||||
<div className="address-book-scope-list">
|
<div className="address-book-scope-list">
|
||||||
<AddressBookScope title="Personal address book" description="Private contacts, remembered recipients and personal aliases." status="Local" />
|
<AddressBookScope title="i18n:govoplan-campaign.personal_address_book.e240066d" description="i18n:govoplan-campaign.private_contacts_remembered_recipients_and_perso.685cca95" status="Local" />
|
||||||
<AddressBookScope title="Group address books" description="Shared contact sets for teams, departments or campaign groups." status="Shared" />
|
<AddressBookScope title="i18n:govoplan-campaign.group_address_books.aed5a7c0" description="i18n:govoplan-campaign.shared_contact_sets_for_teams_departments_or_cam.4408edd7" status="Shared" />
|
||||||
<AddressBookScope title="Tenant directory" description="Tenant-wide contacts, functional mailboxes and approved lists." status="Directory" />
|
<AddressBookScope title="i18n:govoplan-campaign.tenant_directory.11b0e09c" description="i18n:govoplan-campaign.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9" status="Directory" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Planned address actions">
|
<Card title="i18n:govoplan-campaign.planned_address_actions.1d4a056a">
|
||||||
<div className="placeholder-stack">
|
<div className="placeholder-stack">
|
||||||
<span>Choose addresses from personal, group or tenant source</span>
|
<span>i18n:govoplan-campaign.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4</span>
|
||||||
<span>Remember addresses used in campaigns after opt-in</span>
|
<span>i18n:govoplan-campaign.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529</span>
|
||||||
<span>Share selected contacts with a group</span>
|
<span>i18n:govoplan-campaign.share_selected_contacts_with_a_group.604d1464</span>
|
||||||
<span>Use contacts in To, CC, BCC, sender and Reply-To fields</span>
|
<span>i18n:govoplan-campaign.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card title="Contacts" actions={<Button disabled>Manage sources</Button>}>
|
<Card title="i18n:govoplan-campaign.contacts.b0dd615c" actions={<Button disabled>i18n:govoplan-campaign.manage_sources.ec758de0</Button>}>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id="address-book-contacts"
|
id="address-book-contacts"
|
||||||
rows={allContacts}
|
rows={allContacts}
|
||||||
columns={contactColumns()}
|
columns={contactColumns()}
|
||||||
getRowKey={(contact) => contact.source + "-" + contact.email}
|
getRowKey={(contact) => contact.source + "-" + contact.email}
|
||||||
emptyText="No contacts found."
|
emptyText="i18n:govoplan-campaign.no_contacts_found.ad977b09"
|
||||||
className="compact-table-wrap module-table"
|
className="compact-table-wrap module-table" />
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) {
|
function AddressBookScope({ title, description, status }: {title: string;description: string;status: string;}) {
|
||||||
return (
|
return (
|
||||||
<div className="address-book-scope-card">
|
<div className="address-book-scope-card">
|
||||||
<div>
|
<div>
|
||||||
@@ -92,6 +92,6 @@ function AddressBookScope({ title, description, status }: { title: string; descr
|
|||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={status} />
|
<StatusBadge status={status} />
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,41 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
|
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import { listFileSpaces, type FileSpace } from "../../api/files";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import Button from "../../components/Button";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import Card from "../../components/Card";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
import { updateNested } from "./utils/draftEditor";
|
import { updateNested } from "./utils/draftEditor";
|
||||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||||
import ManagedFileChooser from "./components/ManagedFileChooser";
|
|
||||||
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
||||||
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
|
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
|
||||||
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||||
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
||||||
import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||||
|
|
||||||
type PathChooserState = { index: number };
|
type PathChooserState = {index: number;};
|
||||||
type IndividualDisableState = { index: number; usageCount: number };
|
type IndividualDisableState = {index: number;usageCount: number;};
|
||||||
|
|
||||||
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function AttachmentsDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
|
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||||
|
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||||
|
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
|
||||||
|
const managedFilesAvailable = Boolean(ManagedFileChooser);
|
||||||
|
const listManagedFileSpaces = filesModuleInstalled ? filesFileExplorer?.listFileSpaces : undefined;
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
||||||
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
|
const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
|
||||||
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
|
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
|
||||||
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
@@ -43,8 +48,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
reload,
|
reload,
|
||||||
setError,
|
setError,
|
||||||
currentStep: "files",
|
currentStep: "files",
|
||||||
unsavedTitle: "Unsaved attachment settings",
|
unsavedTitle: "i18n:govoplan-campaign.unsaved_attachment_settings.a6045f67",
|
||||||
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
|
unsavedMessage: "i18n:govoplan-campaign.attachment_settings_have_unsaved_changes_save_th.b9b415bb"
|
||||||
});
|
});
|
||||||
const attachments = asRecord(displayDraft.attachments);
|
const attachments = asRecord(displayDraft.attachments);
|
||||||
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
|
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
|
||||||
@@ -59,14 +64,26 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
|
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
|
||||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||||
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||||
|
const attachmentPreviewEntry = useMemo(
|
||||||
|
() => asArray(asRecord(displayDraft.entries).inline).map(asRecord).find((entry) => entry.active !== false) ?? {},
|
||||||
|
[displayDraft.entries]
|
||||||
|
);
|
||||||
|
const attachmentPreviewContext = useMemo(
|
||||||
|
() => buildTemplatePreviewContext(displayDraft, attachmentPreviewEntry),
|
||||||
|
[attachmentPreviewEntry, displayDraft]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!listManagedFileSpaces) {
|
||||||
|
setFileSpaces([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void listFileSpaces(settings)
|
void listManagedFileSpaces(settings).
|
||||||
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
|
then((response) => {if (!cancelled) setFileSpaces(response.spaces);}).
|
||||||
.catch(() => { if (!cancelled) setFileSpaces([]); });
|
catch(() => {if (!cancelled) setFileSpaces([]);});
|
||||||
return () => { cancelled = true; };
|
return () => {cancelled = true;};
|
||||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
@@ -137,9 +154,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setZipEnabled(enabled: boolean) {
|
function setZipEnabled(enabled: boolean) {
|
||||||
const archives = enabled && zipConfig.archives.length === 0
|
const archives = enabled && zipConfig.archives.length === 0 ?
|
||||||
? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)]
|
[createAttachmentZipArchive("{{local:id}}-attachments.zip", true)] :
|
||||||
: zipConfig.archives;
|
zipConfig.archives;
|
||||||
patchZipCollection({ enabled, archives });
|
patchZipCollection({ enabled, archives });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,9 +196,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
const resetRule = (value: unknown) => {
|
const resetRule = (value: unknown) => {
|
||||||
const rule = asRecord(value);
|
const rule = asRecord(value);
|
||||||
const ruleZip = asRecord(rule.zip);
|
const ruleZip = asRecord(rule.zip);
|
||||||
return String(ruleZip.archive_id ?? "") === removed.id
|
return String(ruleZip.archive_id ?? "") === removed.id ?
|
||||||
? { ...rule, zip: { ...ruleZip, archive_id: "inherit" } }
|
{ ...rule, zip: { ...ruleZip, archive_id: "inherit" } } :
|
||||||
: rule;
|
rule;
|
||||||
};
|
};
|
||||||
setDraft((current) => {
|
setDraft((current) => {
|
||||||
const source = asRecord(current ?? {});
|
const source = asRecord(current ?? {});
|
||||||
@@ -216,43 +233,46 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Attachments</PageTitle>
|
<PageTitle loading={loading}>i18n:govoplan-campaign.attachments.6771ade6</PageTitle>
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>
|
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
<Card title="Attachment sources">
|
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
|
||||||
|
<div className="admin-table-surface attachment-sources-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-attachment-sources`}
|
id={`campaign-${campaignId}-attachment-sources`}
|
||||||
rows={basePaths}
|
rows={basePaths}
|
||||||
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
||||||
getRowKey={(basePath) => basePath.id}
|
getRowKey={(basePath) => basePath.id}
|
||||||
emptyText="No attachment sources configured."
|
emptyText="i18n:govoplan-campaign.no_attachment_sources_configured.48664606"
|
||||||
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
|
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />}
|
||||||
className="attachment-sources-table-wrap attachment-sources-table"
|
className="attachment-sources-table-wrap attachment-sources-table" />
|
||||||
/>
|
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="ZIP attachments" collapsible>
|
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||||
<div className="attachment-zip-master-toggle">
|
<div className="attachment-zip-master-toggle">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Enable ZIP attachments"
|
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
||||||
checked={zipConfig.enabled}
|
checked={zipConfig.enabled}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
onChange={setZipEnabled}
|
onChange={setZipEnabled} />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-table-surface attachment-zip-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-zip-archives`}
|
id={`campaign-${campaignId}-zip-archives`}
|
||||||
rows={zipConfig.archives}
|
rows={zipConfig.archives}
|
||||||
@@ -269,58 +289,62 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
removeArchive: removeZipArchive
|
removeArchive: removeZipArchive
|
||||||
})}
|
})}
|
||||||
getRowKey={(archive) => archive.id}
|
getRowKey={(archive) => archive.id}
|
||||||
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."}
|
emptyText={zipConfig.enabled ? "i18n:govoplan-campaign.no_zip_attachments_configured.29c16424" : "i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d"}
|
||||||
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined}
|
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_zip_attachment.f64cc332" /> : undefined}
|
||||||
className="attachment-zip-table-wrap"
|
className="attachment-zip-table-wrap" />
|
||||||
/>
|
|
||||||
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && (
|
</div>
|
||||||
<DismissibleAlert tone="warning">No password field exists. Add one under Fields with field type <strong>Password</strong>.</DismissibleAlert>
|
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) &&
|
||||||
)}
|
<DismissibleAlert tone="warning">i18n:govoplan-campaign.no_password_field_exists_add_one_under_fields_wi.2b6d4a7f <strong>i18n:govoplan-campaign.password.8be3c943</strong>.</DismissibleAlert>
|
||||||
{zipArchiveNameValidation.message && (
|
}
|
||||||
|
{zipArchiveNameValidation.message &&
|
||||||
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
|
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
|
||||||
)}
|
}
|
||||||
<p className="muted small-note">Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.</p>
|
<p className="muted small-note">i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Global Attachments">
|
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
|
||||||
<AttachmentRulesDataGrid
|
<AttachmentRulesDataGrid
|
||||||
id={`campaign-${campaignId}-global-attachments`}
|
id={`campaign-${campaignId}-global-attachments`}
|
||||||
rules={globalRules}
|
rules={globalRules}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
emptyText="No global attachments are configured yet. Add files here only if every message should include them."
|
emptyText="i18n:govoplan-campaign.no_global_attachments_are_configured_yet_add_fil.d9fdfbb8"
|
||||||
basePaths={basePaths}
|
basePaths={basePaths}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
campaignId={campaignId}
|
||||||
zipConfig={zipConfig}
|
zipConfig={zipConfig}
|
||||||
onChange={(rules) => patch(["attachments", "global"], rules)}
|
filesModuleInstalled={managedFilesAvailable}
|
||||||
/>
|
previewContext={attachmentPreviewContext}
|
||||||
|
onChange={(rules) => patch(["attachments", "global"], rules)} />
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Statistics">
|
<Card title="i18n:govoplan-campaign.statistics.2086b21f" collapsible>
|
||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
|
<div><dt>i18n:govoplan-campaign.base_paths.edf35a97</dt><dd>{basePaths.length}</dd></div>
|
||||||
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
|
<div><dt>i18n:govoplan-campaign.global_attachments.438263a1</dt><dd>direct: {globalSummary.direct} i18n:govoplan-campaign.rules.e5d36074 {globalSummary.rules}</dd></div>
|
||||||
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
|
<div><dt>i18n:govoplan-campaign.per_recipient_patterns.53da30da</dt><dd>{individualRulesCount}</dd></div>
|
||||||
<div><dt>Upload support</dt><dd>Connected through Files</dd></div>
|
<div><dt>i18n:govoplan-campaign.upload_support.1c54931c</dt><dd>{managedFilesAvailable ? "i18n:govoplan-campaign.connected_through_files.95007112" : "i18n:govoplan-campaign.manual_paths.8e20627a"}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<p className="muted small-note">Files are now managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build.</p>
|
<p className="muted small-note">{managedFilesAvailable ?
|
||||||
|
"i18n:govoplan-campaign.files_are_managed_in_the_top_level_files_module_.4b370222" :
|
||||||
|
filesModuleInstalled ?
|
||||||
|
"i18n:govoplan-campaign.managed_file_browsing_is_unavailable_use_manual_.782867fe" :
|
||||||
|
"i18n:govoplan-campaign.the_files_module_is_not_installed_use_manual_pat.49abc725"}</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="button-row page-bottom-actions">
|
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
<TemplateExpressionEditorDialog
|
<TemplateExpressionEditorDialog
|
||||||
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
|
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
|
||||||
title="Edit ZIP archive filename"
|
title="i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"
|
||||||
value={zipNameEditorIndex !== null ? (zipConfig.archives[zipNameEditorIndex]?.name ?? "") : ""}
|
value={zipNameEditorIndex !== null ? zipConfig.archives[zipNameEditorIndex]?.name ?? "" : ""}
|
||||||
localFields={filenameFieldOptions.filter((field) => field.namespace === "local").map(({ name, label }) => ({ name, label }))}
|
localFields={filenameFieldOptions.filter((field) => field.namespace === "local").map(({ name, label }) => ({ name, label }))}
|
||||||
globalFields={filenameFieldOptions.filter((field) => field.namespace === "global").map(({ name, label }) => ({ name, label }))}
|
globalFields={filenameFieldOptions.filter((field) => field.namespace === "global").map(({ name, label }) => ({ name, label }))}
|
||||||
placeholder="attachments.zip"
|
placeholder="attachments.zip"
|
||||||
description="Use a fixed filename or combine text with recipient and campaign fields. The .zip suffix is added automatically when omitted."
|
description="i18n:govoplan-campaign.use_a_fixed_filename_or_combine_text_with_recipi.43a091fd"
|
||||||
saveLabel="Save filename"
|
saveLabel="i18n:govoplan-campaign.save_filename.2dbd2273"
|
||||||
validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)}
|
validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)}
|
||||||
onCancel={() => setZipNameEditorIndex(null)}
|
onCancel={() => setZipNameEditorIndex(null)}
|
||||||
onSave={(name) => {
|
onSave={(name) => {
|
||||||
@@ -334,17 +358,17 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
if (existingFields.some((field) => String(field.name || field.id || "").trim() === name)) return;
|
if (existingFields.some((field) => String(field.name || field.id || "").trim() === name)) return;
|
||||||
patch(["fields"], [
|
patch(["fields"], [
|
||||||
...existingFields,
|
...existingFields,
|
||||||
{ name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }
|
{ name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }]
|
||||||
]);
|
);
|
||||||
}}
|
}}
|
||||||
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
|
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)} />
|
||||||
/>
|
|
||||||
|
|
||||||
{pathChooser && (
|
|
||||||
|
{pathChooser && ManagedFileChooser &&
|
||||||
<ManagedFileChooser
|
<ManagedFileChooser
|
||||||
open
|
open
|
||||||
settings={settings}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
storageScope={campaignId}
|
||||||
mode="folder"
|
mode="folder"
|
||||||
source={basePaths[pathChooser.index]?.source}
|
source={basePaths[pathChooser.index]?.source}
|
||||||
basePath={basePaths[pathChooser.index]?.path}
|
basePath={basePaths[pathChooser.index]?.path}
|
||||||
@@ -355,30 +379,30 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
patchBasePath(pathChooser.index, {
|
patchBasePath(pathChooser.index, {
|
||||||
source: selection.source,
|
source: selection.source,
|
||||||
path: selection.folderPath || ".",
|
path: selection.folderPath || ".",
|
||||||
name: !current?.name || current.name === "New attachment source" || current.name === "Campaign files"
|
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}` : ""}`
|
`${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}` :
|
||||||
: current.name
|
current.name
|
||||||
});
|
});
|
||||||
setPathChooser(null);
|
setPathChooser(null);
|
||||||
}}
|
}} />
|
||||||
/>
|
|
||||||
)}
|
}
|
||||||
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(individualDisable)}
|
open={Boolean(individualDisable)}
|
||||||
title="Disable individual attachments for this source?"
|
title="i18n:govoplan-campaign.disable_individual_attachments_for_this_source.185f9a95"
|
||||||
message={individualDisable
|
message={individualDisable ? i18nMessage("i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7", { value0:
|
||||||
? `${individualDisable.usageCount} individual attachment ${individualDisable.usageCount === 1 ? "entry uses" : "entries use"} this source. Disabling it will remove those recipient-specific attachment entries.`
|
individualDisable.usageCount, value1: individualDisable.usageCount === 1 ? "i18n:govoplan-campaign.entry_uses.5f269cab" : "i18n:govoplan-campaign.entries_use.e9169679" }) :
|
||||||
: ""}
|
""}
|
||||||
confirmLabel="Remove individual attachments"
|
confirmLabel="i18n:govoplan-campaign.remove_individual_attachments.9e5f0ad3"
|
||||||
cancelLabel="Cancel"
|
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
|
||||||
tone="danger"
|
tone="danger"
|
||||||
onConfirm={confirmIndividualDisable}
|
onConfirm={confirmIndividualDisable}
|
||||||
onCancel={() => setIndividualDisable(null)}
|
onCancel={() => setIndividualDisable(null)} />
|
||||||
/>
|
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ZipArchiveColumnContext = {
|
type ZipArchiveColumnContext = {
|
||||||
@@ -397,72 +421,72 @@ type ZipArchiveColumnContext = {
|
|||||||
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
|
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "name", header: "Archive name", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
|
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
|
||||||
render: (archive, index) => (
|
render: (archive, index) =>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="attachment-zip-name-button"
|
className="attachment-zip-name-button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-invalid={invalidNameIndexes.has(index) || undefined}
|
aria-invalid={invalidNameIndexes.has(index) || undefined}
|
||||||
aria-label={`Edit ZIP archive filename ${archive.name || index + 1}`}
|
aria-label={i18nMessage("i18n:govoplan-campaign.edit_zip_archive_filename_value.deb41dfc", { value0: archive.name || index + 1 })}
|
||||||
title={invalidNameIndexes.has(index) ? "Archive filenames must be present and unique." : "Edit ZIP archive filename"}
|
title={invalidNameIndexes.has(index) ? "i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639" : "i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"}
|
||||||
onClick={() => onEditName(index)}
|
onClick={() => onEditName(index)}>
|
||||||
>
|
|
||||||
<span className="attachment-zip-name-value">{archive.name || "Unnamed ZIP attachment"}</span>
|
<span className="attachment-zip-name-value">{archive.name || "i18n:govoplan-campaign.unnamed_zip_attachment.54f515bb"}</span>
|
||||||
<Pencil size={15} aria-hidden="true" />
|
<Pencil size={15} aria-hidden="true" />
|
||||||
</button>
|
</button>,
|
||||||
),
|
|
||||||
value: (archive) => archive.name
|
value: (archive) => archive.name
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "standard", header: "Standard", width: 150, sortable: true, filterable: true,
|
id: "standard", header: "i18n:govoplan-campaign.standard.2dfa6607", width: 150, sortable: true, filterable: true,
|
||||||
columnType: "from-list", list: { options: [{ value: "standard", label: "Standard" }, { value: "optional", label: "Optional" }] },
|
columnType: "from-list", list: { options: [{ value: "standard", label: "i18n:govoplan-campaign.standard.2dfa6607" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] },
|
||||||
render: (archive, index) => <ToggleSwitch label="Standard" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
|
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.standard.2dfa6607" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
|
||||||
value: (archive) => archive.standard ? "standard" : "optional"
|
value: (archive) => archive.standard ? "standard" : "optional"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "password_enabled", header: "Password", width: 165, sortable: true, filterable: true,
|
id: "password_enabled", header: "i18n:govoplan-campaign.password.8be3c943", width: 165, sortable: true, filterable: true,
|
||||||
columnType: "from-list", list: { options: [{ value: "protected", label: "Protected" }, { value: "none", label: "No password" }] },
|
columnType: "from-list", list: { options: [{ value: "protected", label: "i18n:govoplan-campaign.protected.28531336" }, { value: "none", label: "i18n:govoplan-campaign.no_password.18518368" }] },
|
||||||
render: (archive, index) => <ToggleSwitch label="Protected" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
|
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.protected.28531336" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
|
||||||
value: (archive) => archive.password_enabled ? "protected" : "none"
|
value: (archive) => archive.password_enabled ? "protected" : "none"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "password_field", header: "Password field", width: 230, sortable: true, filterable: true,
|
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", 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 }))] },
|
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
||||||
render: (archive, index) => (
|
render: (archive, index) =>
|
||||||
<select value={archive.password_field} disabled={disabled || !archive.password_enabled || passwordFields.length === 0} onChange={(event) => patchArchive(index, { password_field: event.target.value })}>
|
<select value={archive.password_field} disabled={disabled || !archive.password_enabled || passwordFields.length === 0} onChange={(event) => patchArchive(index, { password_field: event.target.value })}>
|
||||||
<option value="">Select password field</option>
|
<option value="">i18n:govoplan-campaign.select_password_field.4007aeb0</option>
|
||||||
{passwordFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
{passwordFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
||||||
</select>
|
</select>,
|
||||||
),
|
|
||||||
value: (archive) => archive.password_field
|
value: (archive) => archive.password_field
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "password_scope", header: "Value scope", width: 190, sortable: true, filterable: true,
|
id: "password_scope", header: "i18n:govoplan-campaign.value_scope.5e1e16b2", width: 190, sortable: true, filterable: true,
|
||||||
columnType: "from-list", list: { options: [{ value: "local", label: "Recipient / local" }, { value: "global", label: "Campaign / global" }] },
|
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) => (
|
render: (archive, index) =>
|
||||||
<select value={archive.password_scope} disabled={disabled || !archive.password_enabled} onChange={(event) => patchArchive(index, { password_scope: event.target.value === "global" ? "global" : "local" })}>
|
<select value={archive.password_scope} disabled={disabled || !archive.password_enabled} onChange={(event) => patchArchive(index, { password_scope: event.target.value === "global" ? "global" : "local" })}>
|
||||||
<option value="local">Recipient / local</option>
|
<option value="local">i18n:govoplan-campaign.recipient_local.418a289f</option>
|
||||||
<option value="global">Campaign / global</option>
|
<option value="global">i18n:govoplan-campaign.campaign_global.ba944948</option>
|
||||||
</select>
|
</select>,
|
||||||
),
|
|
||||||
value: (archive) => archive.password_scope
|
value: (archive) => archive.password_scope
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions", header: "Actions", width: 180, sticky: "end",
|
id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 180, sticky: "end",
|
||||||
render: (_archive, index) => <DataGridRowActions
|
render: (_archive, index) => <DataGridRowActions
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onAddBelow={() => addArchive(index)}
|
onAddBelow={() => addArchive(index)}
|
||||||
onRemove={() => removeArchive(index)}
|
onRemove={() => removeArchive(index)}
|
||||||
onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined}
|
onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined}
|
||||||
onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined}
|
onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined}
|
||||||
addLabel="Add ZIP attachment below"
|
addLabel="i18n:govoplan-campaign.add_zip_attachment_below.776dabc1"
|
||||||
removeLabel="Remove ZIP attachment"
|
removeLabel="i18n:govoplan-campaign.remove_zip_attachment.d2bdf662"
|
||||||
moveUpLabel="Move ZIP attachment up"
|
moveUpLabel="i18n:govoplan-campaign.move_zip_attachment_up.63446ac0"
|
||||||
moveDownLabel="Move ZIP attachment down"
|
moveDownLabel="i18n:govoplan-campaign.move_zip_attachment_down.4ead4805" />
|
||||||
/>
|
|
||||||
}
|
}];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ZipFilenameNamespace = "local" | "global";
|
type ZipFilenameNamespace = "local" | "global";
|
||||||
@@ -488,19 +512,19 @@ function buildZipFilenameFieldOptions(draft: Record<string, unknown>): ZipFilena
|
|||||||
const filenameFields = [...fieldDefinitions.values()].filter((field) => field.type !== "password");
|
const filenameFields = [...fieldDefinitions.values()].filter((field) => field.type !== "password");
|
||||||
const labels = new Map(filenameFields.map((field) => [field.name, field.label || field.name]));
|
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 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))])
|
const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))]).
|
||||||
.filter((name) => fieldDefinitions.get(name)?.type !== "password");
|
filter((name) => fieldDefinitions.get(name)?.type !== "password");
|
||||||
return [
|
return [
|
||||||
...localNames.map((name) => ({ namespace: "local" 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) }))
|
...globalNames.map((name) => ({ namespace: "global" as const, name, label: labels.get(name) || humanizeFieldName(name) }))];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeZipArchiveNameValueProblem(archives: AttachmentZipArchive[], index: number, value: string): string {
|
function describeZipArchiveNameValueProblem(archives: AttachmentZipArchive[], index: number, value: string): string {
|
||||||
const normalized = normalizeZipArchiveName(value);
|
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);
|
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 {
|
function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNameValidation {
|
||||||
@@ -522,14 +546,14 @@ function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNa
|
|||||||
}
|
}
|
||||||
if (invalidIndexes.size === 0) return EMPTY_ZIP_ARCHIVE_NAME_VALIDATION;
|
if (invalidIndexes.size === 0) return EMPTY_ZIP_ARCHIVE_NAME_VALIDATION;
|
||||||
const hasEmpty = archives.some((archive, index) => invalidIndexes.has(index) && !archive.name.trim());
|
const hasEmpty = archives.some((archive, index) => invalidIndexes.has(index) && !archive.name.trim());
|
||||||
const duplicateNames = [...byName.entries()]
|
const duplicateNames = [...byName.entries()].
|
||||||
.filter(([, indexes]) => indexes.length > 1)
|
filter(([, indexes]) => indexes.length > 1).
|
||||||
.map(([, indexes]) => archives[indexes[0]]?.name.trim())
|
map(([, indexes]) => archives[indexes[0]]?.name.trim()).
|
||||||
.filter(Boolean);
|
filter(Boolean);
|
||||||
const parts: string[] = [];
|
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(", ")}`);
|
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 {
|
function normalizeZipArchiveName(value: string): string {
|
||||||
@@ -545,7 +569,8 @@ function uniqueStrings(values: string[]): string[] {
|
|||||||
type AttachmentSourceColumnContext = {
|
type AttachmentSourceColumnContext = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
basePaths: AttachmentBasePath[];
|
basePaths: AttachmentBasePath[];
|
||||||
fileSpaces: FileSpace[];
|
fileSpaces: FilesFileSpace[];
|
||||||
|
managedFilesAvailable: boolean;
|
||||||
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
||||||
setIndividualEligibility: (index: number, checked: boolean) => void;
|
setIndividualEligibility: (index: number, checked: boolean) => void;
|
||||||
addBasePath: (afterIndex?: number) => void;
|
addBasePath: (afterIndex?: number) => void;
|
||||||
@@ -554,46 +579,49 @@ type AttachmentSourceColumnContext = {
|
|||||||
setPathChooser: (state: PathChooserState | null) => void;
|
setPathChooser: (state: PathChooserState | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="i18n:govoplan-campaign.campaign_files.96e7004b" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
||||||
{
|
{
|
||||||
id: "path",
|
id: "path",
|
||||||
header: "Path",
|
header: "i18n:govoplan-campaign.path.519e3913",
|
||||||
width: "minmax(260px, 1fr)",
|
width: "minmax(260px, 1fr)",
|
||||||
resizable: true,
|
resizable: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
render: (basePath, index) => (
|
render: (basePath, index) =>
|
||||||
<div className="field-with-action split-field-action">
|
<div className="field-with-action split-field-action">
|
||||||
<input
|
<input
|
||||||
className="chooser-display-input"
|
className="chooser-display-input"
|
||||||
value={formatAttachmentSourcePath(basePath, fileSpaces)}
|
value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
readOnly
|
readOnly={managedFilesAvailable}
|
||||||
tabIndex={-1}
|
tabIndex={managedFilesAvailable ? -1 : undefined}
|
||||||
placeholder="attachments"
|
placeholder="i18n:govoplan-campaign.attachments_placeholder"
|
||||||
onClick={() => !locked && setPathChooser({ index })}
|
onChange={(event) => {
|
||||||
|
if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
|
||||||
|
}}
|
||||||
|
onClick={() => managedFilesAvailable && !locked && setPathChooser({ index })}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (!locked && (event.key === "Enter" || event.key === " ")) {
|
if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setPathChooser({ index });
|
setPathChooser({ index });
|
||||||
}
|
}
|
||||||
}}
|
}} />
|
||||||
/>
|
|
||||||
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>
|
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>i18n:govoplan-campaign.choose_folder.1838a415</Button>}
|
||||||
</div>
|
</div>,
|
||||||
),
|
|
||||||
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
|
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
|
||||||
},
|
},
|
||||||
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "Individual" }, { value: "global only", label: "Global only" }] }, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
|
{ id: "individual", header: "i18n:govoplan-campaign.individual_attachments.8164fc7a", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "i18n:govoplan-campaign.individual.a7abed83" }, { value: "global_only", label: "i18n:govoplan-campaign.global_only.4641751c" }] }, render: (basePath, index) => <ToggleSwitch label="i18n:govoplan-campaign.individual.a7abed83" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global_only" },
|
||||||
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "Warn" }, { value: "off", label: "Off" }] }, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
|
{ id: "unsent_warning", header: "i18n:govoplan-campaign.unsent_warning.11bdfbf7", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "i18n:govoplan-campaign.warn.3009d557" }, { value: "off", label: "i18n:govoplan-campaign.off.e3de5ab0" }] }, render: (basePath, index) => <ToggleSwitch label="i18n:govoplan-campaign.unsent.0d09fa0d" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 180,
|
width: 180,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (_basePath, index) => (
|
render: (_basePath, index) =>
|
||||||
<DataGridRowActions
|
<DataGridRowActions
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
removeDisabled={basePaths.length <= 1}
|
removeDisabled={basePaths.length <= 1}
|
||||||
@@ -601,22 +629,22 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath,
|
|||||||
onRemove={() => removeBasePath(index)}
|
onRemove={() => removeBasePath(index)}
|
||||||
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
|
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
|
||||||
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
|
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
|
||||||
addLabel="Add attachment source below"
|
addLabel="i18n:govoplan-campaign.add_attachment_source_below.635c6a61"
|
||||||
removeLabel="Remove attachment source"
|
removeLabel="i18n:govoplan-campaign.remove_attachment_source.95855c67"
|
||||||
moveUpLabel="Move attachment source up"
|
moveUpLabel="i18n:govoplan-campaign.move_attachment_source_up.956ec756"
|
||||||
moveDownLabel="Move attachment source down"
|
moveDownLabel="i18n:govoplan-campaign.move_attachment_source_down.cbf9ebd8" />
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string {
|
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FilesFileSpace[]): string {
|
||||||
const parsedSource = parseManagedAttachmentSource(basePath.source);
|
const parsedSource = parseManagedAttachmentSource(basePath.source);
|
||||||
const matchingSpace = parsedSource
|
const matchingSpace = parsedSource ?
|
||||||
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)
|
spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId) :
|
||||||
: undefined;
|
undefined;
|
||||||
const rootLabel = matchingSpace?.label
|
const rootLabel = matchingSpace?.label || (
|
||||||
|| (parsedSource?.ownerType === "user" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : "");
|
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, "");
|
const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, "");
|
||||||
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
|
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
|
||||||
return `${relativePath || "."}/`;
|
return `${relativePath || "."}/`;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import Card from "../../components/Card";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
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 { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
|
|
||||||
@@ -15,21 +15,21 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
|
|||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Audit log</PageTitle>
|
<PageTitle loading={loading}>i18n:govoplan-campaign.audit_log.3cfc5f1c</PageTitle>
|
||||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading audit data…">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
|
||||||
<Card title="Recent audit events">
|
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
|
||||||
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
|
<p className="muted">i18n:govoplan-campaign.campaign_specific_audit_api_integration_will_be_.e53c8280</p>
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import { Card } from "@govoplan/core-webui";
|
||||||
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
|
import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
|
||||||
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
||||||
import FieldValueInput from "./components/FieldValueInput";
|
import FieldValueInput from "./components/FieldValueInput";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
||||||
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||||
|
export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
|
|
||||||
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const fieldValueKeys = useRef<string[]>([]);
|
const fieldValueKeys = useRef<string[]>([]);
|
||||||
|
|
||||||
@@ -31,8 +30,8 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
reload,
|
reload,
|
||||||
setError,
|
setError,
|
||||||
currentStep: "campaign-fields",
|
currentStep: "campaign-fields",
|
||||||
unsavedTitle: "Unsaved fields",
|
unsavedTitle: "i18n:govoplan-campaign.unsaved_fields.c0913c13",
|
||||||
unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.",
|
unsavedMessage: "i18n:govoplan-campaign.campaign_fields_have_unsaved_changes_save_them_b.c7e992f1",
|
||||||
transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)),
|
transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)),
|
||||||
onLoaded: (_loadedVersion, loadedDraft) => {
|
onLoaded: (_loadedVersion, loadedDraft) => {
|
||||||
fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name);
|
fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name);
|
||||||
@@ -143,7 +142,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
const fieldProblem = describeFieldNameProblem(fields);
|
const fieldProblem = describeFieldNameProblem(fields);
|
||||||
if (fieldProblem) {
|
if (fieldProblem) {
|
||||||
setLocalError(fieldProblem);
|
setLocalError(fieldProblem);
|
||||||
setSaveState("Save blocked");
|
setSaveState("i18n:govoplan-campaign.save_blocked.c09570a4");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return saveDraft("manual");
|
return saveDraft("manual");
|
||||||
@@ -154,41 +153,39 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Fields</PageTitle>
|
<PageTitle loading={loading}>i18n:govoplan-campaign.fields.e8b68527</PageTitle>
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
|
<Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
|
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
|
<Card title="i18n:govoplan-campaign.campaign_fields.969e7d80">
|
||||||
<div className="admin-table-surface campaign-fields-table-surface">
|
<div className="admin-table-surface campaign-fields-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-fields`}
|
id={`campaign-${campaignId}-fields`}
|
||||||
rows={fields}
|
rows={fields}
|
||||||
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
|
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
|
||||||
getRowKey={(_field, index) => `field-row-${index}`}
|
getRowKey={(_field, index) => `field-row-${index}`}
|
||||||
emptyText="No campaign fields configured yet."
|
emptyText="i18n:govoplan-campaign.no_campaign_fields_configured_yet.6772f948"
|
||||||
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="Add first field" />}
|
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_field.9fef7186" />}
|
||||||
className="field-editor-table-wrap field-editor-table"
|
className="field-editor-table-wrap field-editor-table" />
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="button-row page-bottom-actions">
|
|
||||||
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FieldColumnContext = {
|
type FieldColumnContext = {
|
||||||
@@ -206,32 +203,32 @@ type FieldColumnContext = {
|
|||||||
|
|
||||||
function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] {
|
function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
|
{ id: "name", header: "i18n:govoplan-campaign.field_id.4ee26ffd", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
|
||||||
{ id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
|
{ id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="i18n:govoplan-campaign.display_label.d747868d" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
|
||||||
{ id: "type", header: "Type", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => <select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>, value: (field) => field.type },
|
{ id: "type", header: "i18n:govoplan-campaign.type.3deb7456", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => <select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>, value: (field) => field.type },
|
||||||
{ id: "required", header: "Required", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (field, index) => <ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
|
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (field, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
|
||||||
{ id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
|
{ id: "global_value", header: "i18n:govoplan-campaign.global_value.ab230e7f", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="i18n:govoplan-campaign.optional_default.e04a3bfc" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
|
||||||
{ id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can override", label: "Can override" }, { value: "locked", label: "Global only" }] }, render: (field, index) => <ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" },
|
{ id: "override", header: "i18n:govoplan-campaign.recipient_override.4d84be6d", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can_override", label: "i18n:govoplan-campaign.can_override.c37b61c4" }, { value: "locked", label: "i18n:govoplan-campaign.global_only.4641751c" }] }, render: (field, index) => <ToggleSwitch label="i18n:govoplan-campaign.can_override.c37b61c4" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can_override" : "locked" },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 180,
|
width: 180,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (_field, index) => (
|
render: (_field, index) =>
|
||||||
<DataGridRowActions
|
<DataGridRowActions
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
onAddBelow={() => addField(index)}
|
onAddBelow={() => addField(index)}
|
||||||
onRemove={() => deleteField(index)}
|
onRemove={() => deleteField(index)}
|
||||||
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
|
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
|
||||||
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
|
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
|
||||||
addLabel="Add field below"
|
addLabel="i18n:govoplan-campaign.add_field_below.c087fdd7"
|
||||||
removeLabel="Remove field"
|
removeLabel="i18n:govoplan-campaign.remove_field.f4e03605"
|
||||||
moveUpLabel="Move field up"
|
moveUpLabel="i18n:govoplan-campaign.move_field_up.d98ba824"
|
||||||
moveDownLabel="Move field down"
|
moveDownLabel="i18n:govoplan-campaign.move_field_down.8f27ccb2" />
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFields(value: unknown): CampaignFieldDefinition[] {
|
function normalizeFields(value: unknown): CampaignFieldDefinition[] {
|
||||||
@@ -282,7 +279,7 @@ function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState:
|
|||||||
function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
|
function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
|
||||||
const names = fields.map((field) => field.name.trim());
|
const names = fields.map((field) => field.name.trim());
|
||||||
if (names.some((name) => !name)) {
|
if (names.some((name) => !name)) {
|
||||||
return "Field IDs must not be empty before saving.";
|
return "i18n:govoplan-campaign.field_ids_must_not_be_empty_before_saving.3ac7b4ca";
|
||||||
}
|
}
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -293,7 +290,7 @@ function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (duplicates.size === 0) return "";
|
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 {
|
function uniqueFieldName(fields: CampaignFieldDefinition[]): string {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Card from "../../components/Card";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asRecord, getCampaignJson } from "./utils/campaignView";
|
import { asRecord, getCampaignJson } from "./utils/campaignView";
|
||||||
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
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 { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const campaignJson = getCampaignJson(version);
|
const campaignJson = getCampaignJson(version);
|
||||||
@@ -20,20 +20,20 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
|
|||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>JSON</PageTitle>
|
<PageTitle loading={loading}>i18n:govoplan-campaign.json.031a4e76</PageTitle>
|
||||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>Download JSON</Button>
|
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
<LoadingFrame loading={loading} label="Loading JSON…">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50">
|
||||||
<Card>
|
<Card>
|
||||||
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,31 +1,41 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate } from "@govoplan/core-webui";
|
import { ExternalLink } from "lucide-react";
|
||||||
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 type { ApiSettings } from "../../types";
|
||||||
import Card from "../../components/Card";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { DismissibleAlert, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
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";
|
import type { CampaignListItem } from "../../types";
|
||||||
|
|
||||||
export default function CampaignListPage({ settings }: { settings: ApiSettings }) {
|
export default function CampaignListPage({ settings }: {settings: ApiSettings;}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
|
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
|
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
|
||||||
|
const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState<string | null>(null);
|
||||||
|
|
||||||
async function load() {
|
async function load(forcedSince: string | null | undefined = campaignDeltaWatermark) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const data = await listCampaigns(settings);
|
let nextWatermark = forcedSince ?? null;
|
||||||
setCampaigns(data);
|
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()));
|
setLastLoadedAt(formatLoadedAt(new Date()));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -48,41 +58,42 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
setCampaignDeltaWatermark(null);
|
||||||
|
load(null);
|
||||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
const columns: DataGridColumn<CampaignListItem>[] = [
|
const columns: DataGridColumn<CampaignListItem>[] = [
|
||||||
{
|
{
|
||||||
id: "campaign",
|
id: "campaign",
|
||||||
header: "Campaign",
|
header: "i18n:govoplan-campaign.campaign.69390e16",
|
||||||
width: "minmax(260px, 1.8fr)",
|
width: "minmax(260px, 1.8fr)",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
sticky: "start",
|
sticky: "start",
|
||||||
render: (campaign) => (
|
render: (campaign) =>
|
||||||
<div>
|
<div>
|
||||||
<Link className="table-primary-link" to={`/campaigns/${campaign.id}`}>
|
<Link className="table-primary-link" to={`/campaigns/${campaign.id}`}>
|
||||||
{campaign.name || campaign.external_id || campaign.id}
|
{campaign.name || campaign.external_id || campaign.id}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div>
|
<div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div>
|
||||||
</div>
|
</div>,
|
||||||
),
|
|
||||||
value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}`
|
value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "Status",
|
header: "i18n:govoplan-campaign.status.bae7d5be",
|
||||||
width: 150,
|
width: 150,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
columnType: "from-list",
|
columnType: "from-list",
|
||||||
list: {
|
list: {
|
||||||
options: [
|
options: [
|
||||||
{ value: "draft", label: "Draft" },
|
{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" },
|
||||||
{ value: "active", label: "Active" },
|
{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" },
|
||||||
{ value: "completed", label: "Completed" },
|
{ value: "completed", label: "i18n:govoplan-campaign.completed.1798b3ba" },
|
||||||
{ value: "archived", label: "Archived" }
|
{ value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }],
|
||||||
],
|
|
||||||
display: "pill"
|
display: "pill"
|
||||||
},
|
},
|
||||||
render: (campaign) => <StatusBadge status={campaign.status || "draft"} />,
|
render: (campaign) => <StatusBadge status={campaign.status || "draft"} />,
|
||||||
@@ -90,7 +101,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "current_version",
|
id: "current_version",
|
||||||
header: "Current version",
|
header: "i18n:govoplan-campaign.current_version.7be72582",
|
||||||
width: 170,
|
width: 170,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
@@ -100,7 +111,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "updated",
|
id: "updated",
|
||||||
header: "Updated",
|
header: "i18n:govoplan-campaign.updated.f2f8570d",
|
||||||
width: 190,
|
width: 190,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
@@ -111,41 +122,52 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 110,
|
width: 70,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (campaign) => <Link to={`/campaigns/${campaign.id}`}><Button variant="primary">Open</Button></Link>
|
align: "right",
|
||||||
}
|
render: (campaign) =>
|
||||||
];
|
<Link
|
||||||
|
to={`/campaigns/${campaign.id}`}
|
||||||
|
className="btn btn-primary admin-icon-button"
|
||||||
|
aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}
|
||||||
|
title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}>
|
||||||
|
|
||||||
|
<ExternalLink aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
}];
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad campaigns-page">
|
<div className="content-pad workspace-data-page campaigns-page">
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
|
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>All campaigns</PageTitle>
|
<PageTitle loading={loading}>i18n:govoplan-campaign.all_campaigns.2bd1ee3a</PageTitle>
|
||||||
<p className="mono-small">{lastLoadedAt ? `Last loaded: ${lastLoadedAt}` : "Not loaded yet"}</p>
|
<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={load} disabled={loading}>Reload</Button>
|
<Button onClick={load} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
<Button variant="primary" onClick={create} disabled={creating}>
|
<Button variant="primary" onClick={create} disabled={creating}>
|
||||||
{creating ? "Creating…" : "New campaign"}
|
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card title="i18n:govoplan-campaign.campaigns.01a23a28">
|
||||||
<LoadingFrame loading={loading} label="Loading campaigns…">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaigns.90a466bb">
|
||||||
{campaigns.length === 0 ? (
|
{campaigns.length === 0 ?
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h2>No campaigns yet</h2>
|
<h2>i18n:govoplan-campaign.no_campaigns_yet.926409b3</h2>
|
||||||
<p>Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.</p>
|
<p>i18n:govoplan-campaign.start_with_a_guided_campaign_draft_the_webui_wil.c8a675d0</p>
|
||||||
<Button variant="primary" onClick={create} disabled={creating}>
|
<Button variant="primary" onClick={create} disabled={creating}>
|
||||||
Create first campaign
|
i18n:govoplan-campaign.create_first_campaign.9be974a4
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div> :
|
||||||
) : (
|
|
||||||
|
<div className="admin-table-surface campaigns-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id="campaigns"
|
id="campaigns"
|
||||||
rows={campaigns}
|
rows={campaigns}
|
||||||
@@ -153,13 +175,14 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
|||||||
getRowKey={(campaign) => campaign.id}
|
getRowKey={(campaign) => campaign.id}
|
||||||
initialSort={{ columnId: "updated", direction: "desc" }}
|
initialSort={{ columnId: "updated", direction: "desc" }}
|
||||||
className="campaign-table-wrap"
|
className="campaign-table-wrap"
|
||||||
emptyText="No campaigns found."
|
emptyText="i18n:govoplan-campaign.no_campaigns_found.22d297f9" />
|
||||||
/>
|
|
||||||
)}
|
</div>
|
||||||
|
}
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortId(value: string): string {
|
function shortId(value: string): string {
|
||||||
@@ -174,3 +197,17 @@ function formatDateTime(value?: string): string {
|
|||||||
function formatLoadedAt(value: Date): string {
|
function formatLoadedAt(value: Date): string {
|
||||||
return formatDateTimeFromDate(value, { second: "2-digit" });
|
return formatDateTimeFromDate(value, { second: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeCampaignDelta(current: CampaignListItem[], response: CampaignDeltaResponse): CampaignListItem[] {
|
||||||
|
if (response.full) return response.campaigns;
|
||||||
|
return mergeDeltaRows(current, response.campaigns, response.deleted, (campaign) => campaign.id, {
|
||||||
|
deletedResourceType: "campaign",
|
||||||
|
sort: sortCampaignsByUpdatedDesc
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
|
||||||
|
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
|
||||||
|
String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,39 +1,45 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { ExternalLink, LockKeyhole } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import Card from "../../components/Card";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import FormField from "../../components/FormField";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import MetricCard from "../../components/MetricCard";
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { DismissibleAlert, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
import {
|
import {
|
||||||
lockCampaignVersionPermanently,
|
lockCampaignVersionPermanently,
|
||||||
lockCampaignVersionTemporarily,
|
lockCampaignVersionTemporarily,
|
||||||
unlockCampaignVersionUserLock,
|
unlockCampaignVersionUserLock,
|
||||||
updateCampaignMetadata,
|
updateCampaignMetadata,
|
||||||
type CampaignVersionListItem,
|
type CampaignVersionDetail,
|
||||||
} from "../../api/campaigns";
|
type CampaignVersionListItem } from
|
||||||
|
"../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import {
|
import {
|
||||||
|
asArray,
|
||||||
|
asRecord,
|
||||||
canUnlockValidationVersion,
|
canUnlockValidationVersion,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
|
getCampaignJson,
|
||||||
isFinalLockedVersion,
|
isFinalLockedVersion,
|
||||||
isPermanentUserLockedVersion,
|
isPermanentUserLockedVersion,
|
||||||
isTemporaryUserLockedVersion,
|
isTemporaryUserLockedVersion,
|
||||||
isVersionReadyForDelivery,
|
isVersionReadyForDelivery,
|
||||||
summaryValue,
|
summaryValue } from
|
||||||
} from "./utils/campaignView";
|
"./utils/campaignView";
|
||||||
|
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||||
|
|
||||||
const campaignModeOptions = ["draft", "test", "send"];
|
const campaignModeOptions = ["draft", "test", "send"];
|
||||||
type LockAction = "temporary" | "unlock" | "permanent";
|
type LockAction = "temporary" | "unlock" | "permanent";
|
||||||
type PendingLockAction = { version: CampaignVersionListItem; action: LockAction } | null;
|
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
|
||||||
|
|
||||||
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
const campaign = data.campaign;
|
const campaign = data.campaign;
|
||||||
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
|
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
|
||||||
@@ -43,6 +49,22 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
||||||
const [lockBusy, setLockBusy] = useState(false);
|
const [lockBusy, setLockBusy] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: identityDirty,
|
||||||
|
onSave: saveIdentity,
|
||||||
|
onDiscard: () => {
|
||||||
|
if (!campaign) return;
|
||||||
|
setIdentity({
|
||||||
|
external_id: campaign.external_id ?? "",
|
||||||
|
name: campaign.name ?? "",
|
||||||
|
status: campaign.status ?? "",
|
||||||
|
description: campaign.description ?? ""
|
||||||
|
});
|
||||||
|
setIdentityDirty(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!campaign || identityDirty) return;
|
if (!campaign || identityDirty) return;
|
||||||
@@ -50,7 +72,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
external_id: campaign.external_id ?? "",
|
external_id: campaign.external_id ?? "",
|
||||||
name: campaign.name ?? "",
|
name: campaign.name ?? "",
|
||||||
status: campaign.status ?? "",
|
status: campaign.status ?? "",
|
||||||
description: campaign.description ?? "",
|
description: campaign.description ?? ""
|
||||||
});
|
});
|
||||||
}, [campaign, identityDirty]);
|
}, [campaign, identityDirty]);
|
||||||
|
|
||||||
@@ -60,8 +82,8 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
setMessage("");
|
setMessage("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveIdentity() {
|
async function saveIdentity(): Promise<boolean> {
|
||||||
if (!campaign || savingIdentity || !identityDirty) return;
|
if (!campaign || savingIdentity || !identityDirty) return false;
|
||||||
setSavingIdentity(true);
|
setSavingIdentity(true);
|
||||||
setError("");
|
setError("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
@@ -70,12 +92,14 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
external_id: identity.external_id,
|
external_id: identity.external_id,
|
||||||
name: identity.name,
|
name: identity.name,
|
||||||
status: identity.status,
|
status: identity.status,
|
||||||
description: identity.description,
|
description: identity.description
|
||||||
});
|
});
|
||||||
setIdentityDirty(false);
|
setIdentityDirty(false);
|
||||||
await reload();
|
await reload();
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setSavingIdentity(false);
|
setSavingIdentity(false);
|
||||||
}
|
}
|
||||||
@@ -90,13 +114,13 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
try {
|
try {
|
||||||
if (pending.action === "temporary") {
|
if (pending.action === "temporary") {
|
||||||
await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
|
await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
|
||||||
setMessage(`Version #${pending.version.version_number} temporarily locked.`);
|
setMessage(i18nMessage("i18n:govoplan-campaign.version_value_temporarily_locked.4ed4ffa7", { value0: pending.version.version_number }));
|
||||||
} else if (pending.action === "unlock") {
|
} else if (pending.action === "unlock") {
|
||||||
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
|
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
|
||||||
setMessage(`Temporary lock removed from version #${pending.version.version_number}.`);
|
setMessage(i18nMessage("i18n:govoplan-campaign.temporary_lock_removed_from_version_value.9a05bcfb", { value0: pending.version.version_number }));
|
||||||
} else {
|
} else {
|
||||||
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
|
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
|
||||||
setMessage(`Version #${pending.version.version_number} permanently locked.`);
|
setMessage(i18nMessage("i18n:govoplan-campaign.version_value_permanently_locked.60595f45", { value0: pending.version.version_number }));
|
||||||
}
|
}
|
||||||
setPendingLockAction(null);
|
setPendingLockAction(null);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -111,65 +135,81 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
|
<PageTitle loading={loading}>{campaign?.name || "i18n:govoplan-campaign.overview.0efc2e6b"}</PageTitle>
|
||||||
<p className="mono-small">Campaign overview · version-independent identity and version history</p>
|
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>Reload</Button>
|
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
<Link to="wizard/create"><Button>Edit with wizard</Button></Link>
|
<Link to="wizard/create"><Button>i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Button></Link>
|
||||||
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "Saving…" : "Save"}</Button>
|
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading campaign overview…">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
|
||||||
<div className="metric-grid campaign-overview-metrics">
|
<div className="metric-grid campaign-overview-metrics current-version-metrics">
|
||||||
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
|
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||||
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
|
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
||||||
<MetricCard label="Sent" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="SMTP success" />
|
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
|
||||||
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card title="Campaign identity">
|
<div className="metric-grid campaign-overview-metrics">
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-campaign.current_version_state.f581778f" actions={<Link
|
||||||
|
to={`send?version=${campaign?.current_version_id}`}
|
||||||
|
className={`btn btn-primary`}
|
||||||
|
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||||
|
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||||
|
|
||||||
|
i18n:govoplan-campaign.open.cf9b7706
|
||||||
|
</Link>}>
|
||||||
|
<div className="summary-grid overview-summary-grid">
|
||||||
|
<SummaryTile label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||||
|
<SummaryTile label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||||
|
<SummaryTile label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||||
|
<SummaryTile label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-campaign.campaign_identity.a00ca574" collapsible>
|
||||||
<div className="form-grid campaign-identity-grid">
|
<div className="form-grid campaign-identity-grid">
|
||||||
<FormField label="Campaign ID">
|
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
|
||||||
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Mode">
|
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
|
||||||
<select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}>
|
<select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}>
|
||||||
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
|
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Name">
|
<FormField label="i18n:govoplan-campaign.name.709a2322">
|
||||||
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
|
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Description">
|
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
|
||||||
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
|
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Version history">
|
<Card title="i18n:govoplan-campaign.version_history.91f86581" collapsible>
|
||||||
|
<div className="admin-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-versions`}
|
id={`campaign-${campaignId}-versions`}
|
||||||
rows={versions}
|
rows={versions}
|
||||||
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
|
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
|
||||||
getRowKey={(version) => version.id}
|
getRowKey={(version) => version.id}
|
||||||
initialSort={{ columnId: "version", direction: "desc" }}
|
initialSort={{ columnId: "version", direction: "desc" }}
|
||||||
emptyText="No versions found."
|
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
|
||||||
className="version-history-table"
|
className="version-history-table"
|
||||||
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined}
|
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Current version state">
|
|
||||||
<div className="summary-grid overview-summary-grid">
|
|
||||||
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
|
||||||
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
|
||||||
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
|
||||||
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
@@ -182,100 +222,171 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
|
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
|
||||||
busy={lockBusy}
|
busy={lockBusy}
|
||||||
onCancel={() => setPendingLockAction(null)}
|
onCancel={() => setPendingLockAction(null)}
|
||||||
onConfirm={() => void applyLockAction()}
|
onConfirm={() => void applyLockAction()} />
|
||||||
/>
|
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||||
|
|
||||||
|
type CampaignVersionMetrics = {
|
||||||
|
fieldCount: number;
|
||||||
|
recipientCount: number;
|
||||||
|
templateHealthValue: string;
|
||||||
|
templateHealthTone: TemplateHealthTone;
|
||||||
|
templateHealthDetail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function campaignVersionMetrics(version: CampaignVersionDetail | null): CampaignVersionMetrics {
|
||||||
|
const campaignJson = getCampaignJson(version);
|
||||||
|
const fields = asArray(campaignJson.fields).map(asRecord);
|
||||||
|
const entries = asRecord(campaignJson.entries);
|
||||||
|
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||||
|
const template = asRecord(campaignJson.template);
|
||||||
|
const globalValues = asRecord(campaignJson.global_values);
|
||||||
|
const bodyMode = normalizeTemplateBodyMode(textValue(template.body_mode, "both"));
|
||||||
|
const subject = textValue(template.subject);
|
||||||
|
const textBody = bodyMode !== "html" ? textValue(template.text) : "";
|
||||||
|
const htmlBody = bodyMode !== "text" ? textValue(template.html) : "";
|
||||||
|
const subjectOk = subject.trim().length > 0;
|
||||||
|
const bodyOk = bodyMode === "html" ? htmlBody.trim().length > 0 : bodyMode === "text" ? textBody.trim().length > 0 : Boolean(textBody.trim() || htmlBody.trim());
|
||||||
|
const localFieldNames = fields.map((field) => textValue(field.name || field.id)).filter(Boolean);
|
||||||
|
const globalFieldNames = Object.keys(globalValues).filter(Boolean);
|
||||||
|
const addressFieldNames = recipientAddressTemplateFieldOptions().map((field) => field.name);
|
||||||
|
const localAvailableNames = new Set([...localFieldNames, ...addressFieldNames]);
|
||||||
|
const globalAvailableNames = new Set(globalFieldNames);
|
||||||
|
const allAvailableNames = new Set([...localAvailableNames, ...globalAvailableNames]);
|
||||||
|
const placeholders = extractTemplatePlaceholders([subject, textBody, htmlBody].join("\n"));
|
||||||
|
const undefinedPlaceholders = buildUndefinedPlaceholders(placeholders, allAvailableNames, {
|
||||||
|
local: localAvailableNames,
|
||||||
|
global: globalAvailableNames
|
||||||
|
});
|
||||||
|
const placeholdersOk = undefinedPlaceholders.length === 0;
|
||||||
|
const score = [subjectOk, bodyOk, placeholdersOk].filter(Boolean).length;
|
||||||
|
return {
|
||||||
|
fieldCount: localFieldNames.length,
|
||||||
|
recipientCount: inlineEntries.filter((entry) => entry.active !== false).length,
|
||||||
|
templateHealthValue: `${score}/3`,
|
||||||
|
templateHealthTone: score === 3 ? "good" : score === 2 ? "warning" : "danger",
|
||||||
|
templateHealthDetail: [
|
||||||
|
subjectOk ? "i18n:govoplan-campaign.subject_ok.3dbdc3ed" : "i18n:govoplan-campaign.subject_missing.f545bf65",
|
||||||
|
bodyOk ? "i18n:govoplan-campaign.body_ok.cdb33005" : "i18n:govoplan-campaign.body_missing.bd58622f",
|
||||||
|
placeholdersOk ? "i18n:govoplan-campaign.placeholders_ok.3a07f3bc" : `${undefinedPlaceholders.length} undefined`].
|
||||||
|
join(" · ")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateBodyMode(value: string): "text" | "html" | "both" {
|
||||||
|
return value === "text" || value === "html" || value === "both" ? value : "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
function textValue(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
|
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
|
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
|
||||||
{ id: "state", header: "State", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
|
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
|
||||||
{ id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Current working version", label: "Current working version" }, { value: "Historical version", label: "Historical version" }, { value: "Temporarily locked", label: "Temporarily locked" }, { value: "Permanently locked", label: "Permanently locked" }, { value: "Delivery locked", label: "Delivery locked" }] }, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
|
{ id: "lock", header: "i18n:govoplan-campaign.lock.891ebccd", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Current working version", label: "i18n:govoplan-campaign.current_working_version.eda99d70" }, { value: "Historical version", label: "i18n:govoplan-campaign.historical_version.8880931f" }, { value: "Temporarily locked", label: "i18n:govoplan-campaign.temporarily_locked.1716dc95" }, { value: "Permanently locked", label: "i18n:govoplan-campaign.permanently_locked.327d59fd" }, { value: "Delivery locked", label: "i18n:govoplan-campaign.delivery_locked.2b664305" }] }, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
|
||||||
{ id: "validation", header: "Validation", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not validated", label: "Not validated" }, { value: "Valid", label: "Valid" }, { value: "Validation issues", label: "Validation issues" }] }, render: validationLabel, value: validationLabel },
|
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not validated", label: "i18n:govoplan-campaign.not_validated.11bb4178" }, { value: "Valid", label: "i18n:govoplan-campaign.valid.a4aefa35" }, { value: "Validation issues", label: "i18n:govoplan-campaign.validation_issues.528305b1" }] }, render: validationLabel, value: validationLabel },
|
||||||
{ id: "build", header: "Build", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not built", label: "Not built" }, { value: "Built", label: "Built" }, { value: "Build issues", label: "Build issues" }] }, render: buildLabel, value: buildLabel },
|
{ id: "build", header: "i18n:govoplan-campaign.build.bbd80cf7", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not built", label: "i18n:govoplan-campaign.not_built.88bbe87a" }, { value: "Built", label: "i18n:govoplan-campaign.built.a6ad3f82" }, { value: "Build issues", label: "i18n:govoplan-campaign.build_issues.3e03bf8e" }] }, render: buildLabel, value: buildLabel },
|
||||||
{ id: "updated", header: "Updated", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
|
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 310,
|
width: 260,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (version) => {
|
render: (version) => {
|
||||||
const isCurrent = version.id === currentVersionId;
|
const isCurrent = version.id === currentVersionId;
|
||||||
return (
|
return (
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Link to={`send?version=${version.id}`}><Button variant={isCurrent ? "primary" : "secondary"}>Open</Button></Link>
|
<Link
|
||||||
{isCurrent && (isTemporaryUserLockedVersion(version) ? (
|
to={`send?version=${version.id}`}
|
||||||
|
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
|
||||||
|
aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}
|
||||||
|
title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}>
|
||||||
|
|
||||||
|
<ExternalLink aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
{isCurrent && (isTemporaryUserLockedVersion(version) ?
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
|
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>i18n:govoplan-campaign.unlock.1526a17e</Button>
|
||||||
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
|
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>i18n:govoplan-campaign.lock_permanently.cc0ce9e7</Button>
|
||||||
</>
|
</> :
|
||||||
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
|
!isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ?
|
||||||
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
|
<Button
|
||||||
) : null)}
|
className="admin-icon-button"
|
||||||
</div>
|
onClick={() => setPendingLockAction({ version, action: "temporary" })}
|
||||||
);
|
aria-label={i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number })}
|
||||||
},
|
title="i18n:govoplan-campaign.temporarily_lock_version.82b31149">
|
||||||
},
|
|
||||||
];
|
<LockKeyhole aria-hidden="true" />
|
||||||
|
</Button> :
|
||||||
|
null)}
|
||||||
|
</div>);
|
||||||
|
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
|
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
|
||||||
if (currentVersionId && version.id !== currentVersionId) return "Historical · review-only";
|
if (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82";
|
||||||
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
|
if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
|
||||||
if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
|
if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
|
||||||
if (isFinalLockedVersion(version)) return "Permanent delivery lock";
|
if (isFinalLockedVersion(version)) return "i18n:govoplan-campaign.permanent_delivery_lock.07932206";
|
||||||
if (canUnlockValidationVersion(version)) return "Temporary validation lock";
|
if (canUnlockValidationVersion(version)) return "i18n:govoplan-campaign.temporary_validation_lock.fdc5545d";
|
||||||
if (version.locked_at) return "Temporarily locked";
|
if (version.locked_at) return "i18n:govoplan-campaign.temporarily_locked.1716dc95";
|
||||||
return "Editable";
|
return "i18n:govoplan-campaign.editable.b91faec0";
|
||||||
}
|
}
|
||||||
|
|
||||||
function validationLabel(version: CampaignVersionListItem): string {
|
function validationLabel(version: CampaignVersionListItem): string {
|
||||||
const validation = version.validation_summary ?? {};
|
const validation = version.validation_summary ?? {};
|
||||||
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
|
if (validation.ok === true && isVersionReadyForDelivery(version)) return "i18n:govoplan-campaign.passed.271d60f4";
|
||||||
if (validation.ok === false) return "Needs attention";
|
if (validation.ok === false) return "i18n:govoplan-campaign.needs_attention.a126722e";
|
||||||
if (validation.ok === true) return "Passed, unavailable while user-locked";
|
if (validation.ok === true) return "i18n:govoplan-campaign.passed_unavailable_while_user_locked.d6771ff4";
|
||||||
return "Not validated";
|
return "i18n:govoplan-campaign.not_validated.11bb4178";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLabel(version: CampaignVersionListItem): string {
|
function buildLabel(version: CampaignVersionListItem): string {
|
||||||
const build = version.build_summary ?? {};
|
const build = version.build_summary ?? {};
|
||||||
return String(build.built_count ?? build.ready_count ?? "Not built");
|
return String(build.built_count ?? build.ready_count ?? "i18n:govoplan-campaign.not_built.88bbe87a");
|
||||||
}
|
}
|
||||||
|
|
||||||
function lockDialogTitle(pending: PendingLockAction): string {
|
function lockDialogTitle(pending: PendingLockAction): string {
|
||||||
if (pending?.action === "temporary") return "Temporarily lock version?";
|
if (pending?.action === "temporary") return "i18n:govoplan-campaign.temporarily_lock_version.8ccc5708";
|
||||||
if (pending?.action === "unlock") return "Unlock version?";
|
if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock_version.ebd2fd9a";
|
||||||
if (pending?.action === "permanent") return "Lock version permanently?";
|
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_version_permanently.a8625753";
|
||||||
return "Confirm lock action";
|
return "i18n:govoplan-campaign.confirm_lock_action.617986ee";
|
||||||
}
|
}
|
||||||
|
|
||||||
function lockDialogMessage(pending: PendingLockAction): string {
|
function lockDialogMessage(pending: PendingLockAction): string {
|
||||||
if (pending?.action === "temporary") {
|
if (pending?.action === "temporary") {
|
||||||
return "This makes the version read-only without making it final. An authorized user may unlock it later or make the lock permanent.";
|
return "i18n:govoplan-campaign.this_makes_the_version_read_only_without_making_.9d86667b";
|
||||||
}
|
}
|
||||||
if (pending?.action === "unlock") {
|
if (pending?.action === "unlock") {
|
||||||
return "This removes the temporary user lock and makes the version editable again. Existing validation/build state is otherwise retained.";
|
return "i18n:govoplan-campaign.this_removes_the_temporary_user_lock_and_makes_t.fdabd47a";
|
||||||
}
|
}
|
||||||
if (pending?.action === "permanent") {
|
if (pending?.action === "permanent") {
|
||||||
return "This lock cannot be removed by any role. The version remains reviewable for audit purposes; future changes require an editable copy.";
|
return "i18n:govoplan-campaign.this_lock_cannot_be_removed_by_any_role_the_vers.14ae9b80";
|
||||||
}
|
}
|
||||||
return "Continue?";
|
return "i18n:govoplan-campaign.continue.4e6302ed";
|
||||||
}
|
}
|
||||||
|
|
||||||
function lockDialogLabel(pending: PendingLockAction): string {
|
function lockDialogLabel(pending: PendingLockAction): string {
|
||||||
if (pending?.action === "temporary") return "Lock temporarily";
|
if (pending?.action === "temporary") return "i18n:govoplan-campaign.lock_temporarily.3f91d346";
|
||||||
if (pending?.action === "unlock") return "Unlock";
|
if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock.1526a17e";
|
||||||
if (pending?.action === "permanent") return "Lock permanently";
|
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||||
return "Confirm";
|
return "i18n:govoplan-campaign.confirm.04a21221";
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryTile({ label, value }: { label: string; value: string | number }) {
|
function SummaryTile({ label, value }: {label: string;value: string | number;}) {
|
||||||
return (
|
return (
|
||||||
<div className="summary-tile">
|
<div className="summary-tile">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<strong>{value}</strong>
|
<strong>{value}</strong>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,69 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import {
|
import {
|
||||||
downloadCampaignJobsCsv,
|
downloadCampaignJobsCsv,
|
||||||
emailCampaignReport,
|
emailCampaignReport,
|
||||||
getCampaignJobDetail,
|
getCampaignJobDetail,
|
||||||
getCampaignJobs,
|
getCampaignJobsDelta,
|
||||||
resolveCampaignJobOutcome,
|
resolveCampaignJobOutcome,
|
||||||
retryCampaignJobs,
|
retryCampaignJobs,
|
||||||
sendUnattemptedCampaignJobs,
|
sendUnattemptedCampaignJobs,
|
||||||
type CampaignJobDetailResponse,
|
type CampaignJobDetailResponse,
|
||||||
type CampaignJobsResponse,
|
type CampaignJobsResponse } from
|
||||||
} from "../../api/campaigns";
|
"../../api/campaigns";
|
||||||
import Card from "../../components/Card";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
|
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
|
||||||
import Dialog from "../../components/Dialog";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import { LoadingFrame, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asRecord, formatDateTime, humanize, stringifyPreview } from "./utils/campaignView";
|
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
||||||
|
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
||||||
|
|
||||||
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||||
"not_queued",
|
"not_queued",
|
||||||
"queued",
|
"queued",
|
||||||
"claimed",
|
"claimed",
|
||||||
"sending",
|
"sending",
|
||||||
"smtp_accepted",
|
"smtp_accepted",
|
||||||
"sent",
|
"sent",
|
||||||
"outcome_unknown",
|
"outcome_unknown",
|
||||||
"failed_temporary",
|
"failed_temporary",
|
||||||
"failed_permanent",
|
"failed_permanent",
|
||||||
"cancelled",
|
"cancelled"].
|
||||||
].map((value) => ({ value, label: humanize(value) }));
|
map((value) => ({ value, label: humanize(value) }));
|
||||||
|
|
||||||
type ReconcileRequest = { jobId: string; decision: "smtp_accepted" | "not_sent" } | null;
|
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
|
||||||
|
"not_requested",
|
||||||
|
"pending",
|
||||||
|
"appended",
|
||||||
|
"failed",
|
||||||
|
"skipped"].
|
||||||
|
map((value) => ({ value, label: humanize(value) }));
|
||||||
|
|
||||||
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
|
||||||
|
|
||||||
|
export default function CampaignReportPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const cards = data.summary?.cards;
|
const cards = data.summary?.cards;
|
||||||
const delivery = asRecord(data.summary?.delivery);
|
const delivery = asRecord(data.summary?.delivery);
|
||||||
const rateLimit = asRecord(delivery.rate_limit);
|
const rateLimit = asRecord(delivery.rate_limit);
|
||||||
const imapPolicy = asRecord(delivery.imap_append_sent);
|
const imapPolicy = asRecord(delivery.imap_append_sent);
|
||||||
|
|
||||||
const [jobs, setJobs] = useState<CampaignJobsResponse>({
|
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
||||||
jobs: [],
|
const jobsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
|
||||||
page: 1,
|
const jobPageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
|
||||||
page_size: 50,
|
|
||||||
total: 0,
|
|
||||||
total_unfiltered: 0,
|
|
||||||
pages: 0,
|
|
||||||
counts: {},
|
|
||||||
filtered_counts: {},
|
|
||||||
review: {},
|
|
||||||
});
|
|
||||||
const [jobsLoading, setJobsLoading] = useState(false);
|
const [jobsLoading, setJobsLoading] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sendStatus, setSendStatus] = useState("");
|
const [sendStatus, setSendStatus] = useState("");
|
||||||
|
const [imapStatus, setImapStatus] = useState("");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [appliedQuery, setAppliedQuery] = useState("");
|
const [appliedQuery, setAppliedQuery] = useState("");
|
||||||
const [actionMessage, setActionMessage] = useState("");
|
const [actionMessage, setActionMessage] = useState("");
|
||||||
@@ -81,30 +84,75 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
return () => window.clearTimeout(handle);
|
return () => window.clearTimeout(handle);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
useEffect(() => {
|
const jobsQueryKey = useMemo(
|
||||||
void loadJobs();
|
() => JSON.stringify({
|
||||||
}, [campaignId, version?.id, page, sendStatus, appliedQuery]);
|
campaignId,
|
||||||
|
versionId: version?.id ?? null,
|
||||||
|
page,
|
||||||
|
pageSize: 50,
|
||||||
|
sendStatus,
|
||||||
|
imapStatus,
|
||||||
|
appliedQuery,
|
||||||
|
apiBaseUrl: settings.apiBaseUrl,
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
accessToken: settings.accessToken
|
||||||
|
}),
|
||||||
|
[campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
||||||
|
);
|
||||||
|
|
||||||
async function loadJobs() {
|
useEffect(() => {
|
||||||
|
jobPageCursorsRef.current = { 1: null };
|
||||||
|
}, [campaignId, version?.id, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
|
const loadJobs = useCallback(async () => {
|
||||||
if (!campaignId) return;
|
if (!campaignId) return;
|
||||||
setJobsLoading(true);
|
setJobsLoading(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const response = await getCampaignJobs(settings, campaignId, {
|
let nextWatermark = getDeltaWatermark(jobsQueryKey);
|
||||||
|
let merged = jobsRef.current;
|
||||||
|
let hasMore = false;
|
||||||
|
const pageCursor = page === 1 ? null : jobPageCursorsRef.current[page];
|
||||||
|
do {
|
||||||
|
const response = await getCampaignJobsDelta(settings, campaignId, {
|
||||||
versionId: version?.id,
|
versionId: version?.id,
|
||||||
page,
|
page,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
|
cursor: pageCursor,
|
||||||
sendStatus: sendStatus ? [sendStatus] : undefined,
|
sendStatus: sendStatus ? [sendStatus] : undefined,
|
||||||
|
imapStatus: imapStatus ? [imapStatus] : undefined,
|
||||||
query: appliedQuery || undefined,
|
query: appliedQuery || undefined,
|
||||||
|
since: nextWatermark
|
||||||
});
|
});
|
||||||
setJobs(response);
|
merged = mergeCampaignJobsDelta(merged, response);
|
||||||
if (response.pages > 0 && page > response.pages) setPage(response.pages);
|
if (response.cursor !== undefined) jobPageCursorsRef.current[page] = response.cursor ?? null;
|
||||||
|
if (response.next_cursor !== undefined) {
|
||||||
|
if (response.next_cursor) jobPageCursorsRef.current[page + 1] = response.next_cursor;
|
||||||
|
else delete jobPageCursorsRef.current[page + 1];
|
||||||
|
}
|
||||||
|
nextWatermark = response.watermark ?? null;
|
||||||
|
hasMore = response.has_more;
|
||||||
|
} while (hasMore);
|
||||||
|
setDeltaWatermark(jobsQueryKey, nextWatermark);
|
||||||
|
jobsRef.current = merged;
|
||||||
|
setJobs(merged);
|
||||||
|
if (merged.pages > 0 && page > merged.pages) setPage(merged.pages);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(err instanceof Error ? err.message : String(err));
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setJobsLoading(false);
|
setJobsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}, [settings, campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, jobsQueryKey, getDeltaWatermark, setDeltaWatermark]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetDeltaWatermark(jobsQueryKey);
|
||||||
|
jobsRef.current = emptyCampaignJobsResponse();
|
||||||
|
setJobs(emptyCampaignJobsResponse());
|
||||||
|
}, [jobsQueryKey, resetDeltaWatermark]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadJobs();
|
||||||
|
}, [loadJobs]);
|
||||||
|
|
||||||
async function reloadAll() {
|
async function reloadAll() {
|
||||||
await Promise.all([reload(), loadJobs()]);
|
await Promise.all([reload(), loadJobs()]);
|
||||||
@@ -116,9 +164,9 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
setActionError("");
|
setActionError("");
|
||||||
setActionMessage("");
|
setActionMessage("");
|
||||||
try {
|
try {
|
||||||
const response = action === "retry"
|
const response = action === "retry" ?
|
||||||
? await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true })
|
await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }) :
|
||||||
: await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
|
await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
|
||||||
const result = asRecord(response.result ?? response);
|
const result = asRecord(response.result ?? response);
|
||||||
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
||||||
await reloadAll();
|
await reloadAll();
|
||||||
@@ -135,9 +183,9 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
|
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
|
||||||
setActionMessage(reconcile.decision === "smtp_accepted"
|
setActionMessage(reconcile.decision === "smtp_accepted" ?
|
||||||
? "The job was recorded as SMTP accepted and is protected from retry."
|
"i18n:govoplan-campaign.the_job_was_recorded_as_smtp_accepted_and_is_pro.12ee72b6" :
|
||||||
: "The job was recorded as not sent. It is now an explicit retry candidate.");
|
"i18n:govoplan-campaign.the_job_was_recorded_as_not_sent_it_is_now_an_ex.2cea8409");
|
||||||
setReconcile(null);
|
setReconcile(null);
|
||||||
await reloadAll();
|
await reloadAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -164,7 +212,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
await downloadCampaignJobsCsv(settings, campaignId, version?.id);
|
await downloadCampaignJobsCsv(settings, campaignId, version?.id);
|
||||||
setActionMessage("Campaign job CSV downloaded.");
|
setActionMessage("i18n:govoplan-campaign.campaign_job_csv_downloaded.08af6930");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(err instanceof Error ? err.message : String(err));
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -183,7 +231,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
version_id: version?.id,
|
version_id: version?.id,
|
||||||
include_jobs: false,
|
include_jobs: false,
|
||||||
attach_jobs_csv: attachCsv,
|
attach_jobs_csv: attachCsv,
|
||||||
attach_report_json: attachJson,
|
attach_report_json: attachJson
|
||||||
});
|
});
|
||||||
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
|
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
|
||||||
setEmailOpen(false);
|
setEmailOpen(false);
|
||||||
@@ -195,17 +243,55 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => [
|
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => [
|
||||||
{ id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) + 1 },
|
{ id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) || 1 },
|
||||||
{ id: "recipient", header: "Recipient", width: 240, resizable: true, sortable: true, filterable: true, value: (row) => String(row.recipient_email ?? "—") },
|
{
|
||||||
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
id: "recipient",
|
||||||
{ id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
header: "i18n:govoplan-campaign.recipient.90343260",
|
||||||
{ id: "send", header: "SMTP", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, value: (row) => String(row.send_status ?? "unknown") },
|
width: 260,
|
||||||
{ id: "imap", header: "IMAP", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") },
|
resizable: true,
|
||||||
{ id: "attempts", header: "Attempts", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
|
sortable: true,
|
||||||
{ id: "error", header: "Last result", width: "minmax(220px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "—") },
|
filterable: true,
|
||||||
|
render: (row) =>
|
||||||
|
<div className="recipient-outcome-cell">
|
||||||
|
<strong>{String(row.recipient_email ?? "—")}</strong>
|
||||||
|
<span>{String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}</span>
|
||||||
|
</div>,
|
||||||
|
|
||||||
|
value: (row) => String(row.recipient_email ?? "—")
|
||||||
|
},
|
||||||
|
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||||
|
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
||||||
|
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
|
||||||
|
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} />, value: (row) => String(row.send_status ?? "unknown") },
|
||||||
|
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") },
|
||||||
|
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
|
||||||
|
{
|
||||||
|
id: "evidence",
|
||||||
|
header: "i18n:govoplan-campaign.evidence.7ea014de",
|
||||||
|
width: 240,
|
||||||
|
resizable: true,
|
||||||
|
filterable: true,
|
||||||
|
render: (row) =>
|
||||||
|
<div className="recipient-outcome-cell">
|
||||||
|
<span title={String(row.message_id_header ?? "")}>{row.message_id_header ? i18nMessage("i18n:govoplan-campaign.message_id_value_value.24027e70", { value0: String(row.message_id_header).slice(0, 28), value1: String(row.message_id_header).length > 28 ? "..." : "" }) : "i18n:govoplan-campaign.no_message_id.43390ef7"}</span>
|
||||||
|
<span>{String(row.attachment_count ?? 0)} i18n:govoplan-campaign.attachment_rule_s.0e5ee66a {String(row.matched_file_count ?? 0)} i18n:govoplan-campaign.file_s.4bc4cc05</span>
|
||||||
|
</div>,
|
||||||
|
|
||||||
|
value: (row) => `${String(row.message_id_header ?? "")} ${String(row.eml_sha256 ?? "")}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "error",
|
||||||
|
header: "i18n:govoplan-campaign.last_result.110b888b",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
resizable: true,
|
||||||
|
filterable: true,
|
||||||
|
render: (row) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>,
|
||||||
|
value: (row) => String(row.last_error ?? "—")
|
||||||
|
},
|
||||||
|
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 165, sortable: true, value: (row) => formatDateTime(String(row.updated_at ?? row.sent_at ?? row.queued_at ?? "")) },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 250,
|
width: 250,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
@@ -213,145 +299,196 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
const status = String(row.send_status ?? "");
|
const status = String(row.send_status ?? "");
|
||||||
return (
|
return (
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>Details</Button>
|
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
|
||||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>Accepted</Button>}
|
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
|
||||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>Not sent</Button>}
|
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
},
|
}
|
||||||
},
|
}],
|
||||||
], [busyAction]);
|
[busyAction]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Report</PageTitle>
|
<PageTitle loading={loading}>i18n:govoplan-campaign.report.ee45c303</PageTitle>
|
||||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
|
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>Download CSV</Button>
|
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
||||||
<Button onClick={() => setEmailOpen(true)}>Email report</Button>
|
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
|
||||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Reload</Button>
|
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
||||||
{actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
|
{actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
|
||||||
<LoadingFrame loading={loading} label="Loading report data…">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_report_data.0908ade5">
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<Card title="Delivery outcome">
|
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
|
||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
<div><dt>Generated</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
||||||
<div><dt>Jobs total</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
<div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
||||||
<div><dt>SMTP accepted</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
<div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
||||||
<div><dt>Failed</dt><dd>{cards?.failed ?? 0}</dd></div>
|
<div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div>
|
||||||
<div><dt>Outcome unknown</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
<div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
||||||
<div><dt>Not attempted</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
<div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
||||||
<div><dt>Cancelled</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
<div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="IMAP and execution plan">
|
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
|
||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
<div><dt>IMAP appended</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
<div><dt>i18n:govoplan-campaign.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
||||||
<div><dt>IMAP failed</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
|
<div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
|
||||||
<div><dt>Append policy</dt><dd>{imapPolicy.enabled === true ? `Enabled (${String(imapPolicy.folder ?? "auto")})` : "Disabled"}</dd></div>
|
<div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</dd></div>
|
||||||
<div><dt>Rate limit</dt><dd>{rateLimit.messages_per_minute ? `${String(rateLimit.messages_per_minute)} / minute` : "—"}</dd></div>
|
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
|
||||||
<div><dt>Minimum remaining duration</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
|
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
|
||||||
<div><dt>Execution snapshot</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? `${String(delivery.execution_snapshot_hash).slice(0, 12)}…` : "Missing"}</dd></div>
|
<div><dt>i18n:govoplan-campaign.execution_snapshot.5a67f098</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Explicit delivery actions">
|
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
|
||||||
<p className="muted">These actions never include SMTP-accepted or unresolved jobs. Uncertain outcomes must be reconciled first.</p>
|
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>Retry temporary failures</Button>
|
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
|
||||||
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>Send unattempted jobs</Button>
|
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card title="Recipient delivery jobs">
|
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
|
||||||
<div className="page-heading split">
|
<div className="page-heading split">
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search recipient, subject or entry ID" />
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5" />
|
||||||
<select value={sendStatus} onChange={(event) => { setSendStatus(event.target.value); setPage(1); }}>
|
<select value={sendStatus} onChange={(event) => {setSendStatus(event.target.value);setPage(1);}}>
|
||||||
<option value="">All SMTP states</option>
|
<option value="">i18n:govoplan-campaign.all_smtp_states.739597b1</option>
|
||||||
{SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
{SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
<select value={imapStatus} onChange={(event) => {setImapStatus(event.target.value);setPage(1);}}>
|
||||||
|
<option value="">i18n:govoplan-campaign.all_imap_states.8546b84c</option>
|
||||||
|
{IMAP_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<span className="muted">{jobs.total} matching of {jobs.total_unfiltered} total job(s)</span>
|
<span className="muted">{jobs.total} i18n:govoplan-campaign.matching_of.66a3778e {jobs.total_unfiltered} i18n:govoplan-campaign.total_job_s.c94b7d20</span>
|
||||||
</div>
|
</div>
|
||||||
<LoadingFrame loading={jobsLoading} label="Loading delivery jobs…">
|
<LoadingFrame loading={jobsLoading} label="i18n:govoplan-campaign.loading_delivery_jobs.20ecc37e">
|
||||||
<DataGrid<Record<string, unknown>>
|
<DataGrid<Record<string, unknown>>
|
||||||
id={`campaign-report-jobs-${campaignId}`}
|
id={`campaign-report-jobs-${campaignId}`}
|
||||||
rows={jobs.jobs}
|
rows={jobs.jobs}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
|
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
|
||||||
emptyText="No jobs match the current filters."
|
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5" />
|
||||||
/>
|
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>Previous</Button>
|
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>i18n:govoplan-campaign.previous.50f94286</Button>
|
||||||
<span>Page {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
|
<span>i18n:govoplan-campaign.page.fb06270f {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
|
||||||
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>Next</Button>
|
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>i18n:govoplan-campaign.next.bc981983</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={emailOpen}
|
open={emailOpen}
|
||||||
title="Email campaign report"
|
title="i18n:govoplan-campaign.email_campaign_report.61a2989d"
|
||||||
onClose={() => setEmailOpen(false)}
|
onClose={() => setEmailOpen(false)}
|
||||||
closeDisabled={busyAction === "email"}
|
closeDisabled={busyAction === "email"}
|
||||||
footer={(
|
footer={
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>Cancel</Button>
|
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||||
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>Send report</Button>
|
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>i18n:govoplan-campaign.send_report.a5b32af9</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}>
|
||||||
>
|
|
||||||
<label className="field-stack">
|
<label className="field-stack">
|
||||||
<span>Recipients</span>
|
<span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
|
||||||
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
|
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
|
||||||
</label>
|
</label>
|
||||||
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> Attach job CSV</label>
|
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label>
|
||||||
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> Attach JSON report</label>
|
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={Boolean(detail)}
|
open={Boolean(detail)}
|
||||||
title="Campaign job detail"
|
title="i18n:govoplan-campaign.campaign_job_detail.81dc68e1"
|
||||||
onClose={() => setDetail(null)}
|
onClose={() => setDetail(null)}
|
||||||
className="dialog-panel-wide"
|
className="dialog-panel-wide">
|
||||||
>
|
|
||||||
{detail && (
|
{detail &&
|
||||||
<div className="stacked-sections">
|
<div className="stacked-sections">
|
||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
<div><dt>Recipient</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
||||||
<div><dt>Subject</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
||||||
<div><dt>SMTP state</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
|
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
|
||||||
<div><dt>IMAP state</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div>
|
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
|
||||||
<div><dt>Attachments</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div>
|
||||||
|
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<h3>SMTP attempts</h3>
|
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
|
||||||
<pre className="json-preview">{stringifyPreview(detail.attempts.smtp ?? [], 12000)}</pre>
|
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
|
||||||
<h3>IMAP attempts</h3>
|
|
||||||
<pre className="json-preview">{stringifyPreview(detail.attempts.imap ?? [], 12000)}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(reconcile)}
|
open={Boolean(reconcile)}
|
||||||
title={reconcile?.decision === "smtp_accepted" ? "Record SMTP acceptance?" : "Record message as not sent?"}
|
title={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_smtp_acceptance.c40f8c9d" : "i18n:govoplan-campaign.record_message_as_not_sent.42e4faf8"}
|
||||||
message={reconcile?.decision === "smtp_accepted"
|
message={reconcile?.decision === "smtp_accepted" ?
|
||||||
? "Use this only after checking the SMTP server or recipient-side evidence. The job will be protected from retry and may proceed to IMAP append."
|
"i18n:govoplan-campaign.use_this_only_after_checking_the_smtp_server_or_.6f4396e1" :
|
||||||
: "Use this only when you have evidence that SMTP did not accept the message. The job will become a failed-temporary retry candidate, but it will not be retried automatically."}
|
"i18n:govoplan-campaign.use_this_only_when_you_have_evidence_that_smtp_d.aa48f4ad"}
|
||||||
confirmLabel={reconcile?.decision === "smtp_accepted" ? "Record accepted" : "Record not sent"}
|
confirmLabel={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_accepted.023d6747" : "i18n:govoplan-campaign.record_not_sent.b376b4ed"}
|
||||||
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
|
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
|
||||||
busy={busyAction === "reconcile"}
|
busy={busyAction === "reconcile"}
|
||||||
onConfirm={() => void reconcileOutcome()}
|
onConfirm={() => void reconcileOutcome()}
|
||||||
onCancel={() => setReconcile(null)}
|
onCancel={() => setReconcile(null)} />
|
||||||
/>
|
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record<string, unknown>[];}) {
|
||||||
|
const title = kind === "smtp" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" : "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="attempt-history-section">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p className="muted small-note">i18n:govoplan-campaign.no.816c52fd {kind === "smtp" ? "i18n:govoplan-campaign.smtp.efff9cca" : "i18n:govoplan-campaign.imap.271f9ef2"} i18n:govoplan-campaign.attempt_has_been_recorded_for_this_job.e4050f01</p>
|
||||||
|
</section>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="attempt-history-section">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<div className="attempt-history-wrap">
|
||||||
|
<table className="attempt-history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>i18n:govoplan-campaign.status.bae7d5be</th>
|
||||||
|
{kind === "imap" && <th>i18n:govoplan-campaign.folder.30baa249</th>}
|
||||||
|
{kind === "smtp" && <th>i18n:govoplan-campaign.code.adac6937</th>}
|
||||||
|
<th>i18n:govoplan-campaign.started.faa9e7e7</th>
|
||||||
|
<th>i18n:govoplan-campaign.finished.355bcc57</th>
|
||||||
|
<th>i18n:govoplan-campaign.result.5faa59d4</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) =>
|
||||||
|
<tr key={String(row.id ?? `${kind}-${index}`)}>
|
||||||
|
<td>{String(row.attempt_number ?? index + 1)}</td>
|
||||||
|
<td><StatusBadge status={String(row.status ?? "unknown")} /></td>
|
||||||
|
{kind === "imap" && <td>{String(row.folder ?? "—")}</td>}
|
||||||
|
{kind === "smtp" && <td>{String(row.smtp_status_code ?? "—")}</td>}
|
||||||
|
<td>{formatDateTime(String(row.started_at ?? row.created_at ?? ""))}</td>
|
||||||
|
<td>{formatDateTime(String(row.finished_at ?? row.updated_at ?? ""))}</td>
|
||||||
|
<td title={String(row.smtp_response ?? row.error_message ?? "")}>
|
||||||
|
{String(row.smtp_response ?? row.error_message ?? "—")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useGuardedNavigate } from "@govoplan/core-webui";
|
||||||
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
|
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
|
||||||
import SectionSidebar from "../../layout/SectionSidebar";
|
import SectionSidebar from "../../layout/SectionSidebar";
|
||||||
import CampaignOverviewPage from "./CampaignOverviewPage";
|
import CampaignOverviewPage from "./CampaignOverviewPage";
|
||||||
@@ -17,18 +18,19 @@ import SendWizard from "./wizard/SendWizard";
|
|||||||
import CampaignJsonView from "./CampaignJsonView";
|
import CampaignJsonView from "./CampaignJsonView";
|
||||||
import CampaignReportPage from "./CampaignReportPage";
|
import CampaignReportPage from "./CampaignReportPage";
|
||||||
import CampaignAuditPage from "./CampaignAuditPage";
|
import CampaignAuditPage from "./CampaignAuditPage";
|
||||||
import { CampaignUnsavedChangesProvider, useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
|
||||||
|
|
||||||
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||||
overview: "",
|
overview: "",
|
||||||
campaign: "recipients",
|
campaign: "recipients",
|
||||||
"global-settings": "global-settings",
|
"global-settings": "global-settings",
|
||||||
|
policies: "policies",
|
||||||
fields: "fields",
|
fields: "fields",
|
||||||
recipients: "recipients",
|
recipients: "recipients",
|
||||||
"recipient-data": "recipient-data",
|
"recipient-data": "recipient-data",
|
||||||
template: "template",
|
template: "template",
|
||||||
files: "files",
|
files: "files",
|
||||||
"mail-settings": "mail-settings",
|
"mail-settings": "mail-settings",
|
||||||
|
"mail-policy": "mail-policy",
|
||||||
review: "review",
|
review: "review",
|
||||||
report: "report",
|
report: "report",
|
||||||
audit: "audit",
|
audit: "audit",
|
||||||
@@ -36,17 +38,13 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function CampaignWorkspace({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
export default function CampaignWorkspace({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||||
return (
|
return <CampaignWorkspaceInner settings={settings} auth={auth} />;
|
||||||
<CampaignUnsavedChangesProvider>
|
|
||||||
<CampaignWorkspaceInner settings={settings} auth={auth} />
|
|
||||||
</CampaignUnsavedChangesProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||||
const { campaignId } = useParams();
|
const { campaignId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { requestNavigation } = useCampaignUnsavedChanges();
|
const guardedNavigate = useGuardedNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const active = sectionFromPath(location.pathname);
|
const active = sectionFromPath(location.pathname);
|
||||||
const urlVersionId = new URLSearchParams(location.search).get("version");
|
const urlVersionId = new URLSearchParams(location.search).get("version");
|
||||||
@@ -75,7 +73,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
const target = query ? `${pathname}?${query}` : pathname;
|
const target = query ? `${pathname}?${query}` : pathname;
|
||||||
if (`${location.pathname}${location.search}` === target) return;
|
if (`${location.pathname}${location.search}` === target) return;
|
||||||
requestNavigation(() => navigate(target));
|
guardedNavigate(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,9 +89,13 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
||||||
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="settings" />} />
|
||||||
|
<Route path="mail-policy" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="policy" />} />
|
||||||
|
<Route path="mail-policies" element={<Navigate to="../mail-policy" replace />} />
|
||||||
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
|
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
|
||||||
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="settings" />} />
|
||||||
|
<Route path="policies" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="policy" />} />
|
||||||
|
<Route path="policy" element={<Navigate to="../policies" replace />} />
|
||||||
<Route path="review" element={<ReviewSendPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="review" element={<ReviewSendPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="send" element={<Navigate to="../review" replace />} />
|
<Route path="send" element={<Navigate to="../review" replace />} />
|
||||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
@@ -122,12 +124,14 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
|||||||
if (!section || section === "wizard" || section === "create") return "overview";
|
if (!section || section === "wizard" || section === "create") return "overview";
|
||||||
if (section === "data" || section === "campaign") return "recipients";
|
if (section === "data" || section === "campaign") return "recipients";
|
||||||
if (section === "global-settings" || section === "settings") return "global-settings";
|
if (section === "global-settings" || section === "settings") return "global-settings";
|
||||||
|
if (section === "policies" || section === "policy") return "policies";
|
||||||
if (section === "fields") return "fields";
|
if (section === "fields") return "fields";
|
||||||
if (section === "recipients") return "recipients";
|
if (section === "recipients") return "recipients";
|
||||||
if (section === "recipient-data" || section === "recipient-details") return "recipient-data";
|
if (section === "recipient-data" || section === "recipient-details") return "recipient-data";
|
||||||
if (section === "template") return "template";
|
if (section === "template") return "template";
|
||||||
if (section === "files" || section === "attachments") return "files";
|
if (section === "files" || section === "attachments") return "files";
|
||||||
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
|
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
|
||||||
|
if (section === "mail-policy" || section === "mail-policies") return "mail-policy";
|
||||||
if (section === "review") return "review";
|
if (section === "review") return "review";
|
||||||
if (section === "send") return "review";
|
if (section === "send") return "review";
|
||||||
if (section === "report" || section === "reports") return "report";
|
if (section === "report" || section === "reports") return "report";
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ApiSettings, AuthInfo } from "../../types";
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import Card from "../../components/Card";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import FormField from "../../components/FormField";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
|
import { PolicyRow } from "@govoplan/core-webui";
|
||||||
|
import { PolicyTable } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import CampaignAccessCard from "./components/CampaignAccessCard";
|
import CampaignAccessCard from "./components/CampaignAccessCard";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { hasScope } from "../../utils/permissions";
|
import { hasScope } from "@govoplan/core-webui";
|
||||||
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement";
|
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
@@ -20,10 +22,19 @@ import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/dr
|
|||||||
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
||||||
|
|
||||||
type EditorState = Record<string, unknown>;
|
type EditorState = Record<string, unknown>;
|
||||||
|
type SettingsView = "settings" | "policy";
|
||||||
|
|
||||||
export default function GlobalSettingsPage({ settings, auth, campaignId }: { settings: ApiSettings; auth: AuthInfo; campaignId: string }) {
|
type GlobalSettingsPageProps = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
campaignId: string;
|
||||||
|
view?: SettingsView;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GlobalSettingsPage({ settings, auth, campaignId, view = "settings" }: GlobalSettingsPageProps) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const [editorState, setEditorState] = useState<EditorState>({});
|
const [editorState, setEditorState] = useState<EditorState>({});
|
||||||
|
const isPolicyView = view === "policy";
|
||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
@@ -34,9 +45,11 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
|
|||||||
locked,
|
locked,
|
||||||
reload,
|
reload,
|
||||||
setError,
|
setError,
|
||||||
currentStep: "global-settings",
|
currentStep: isPolicyView ? "policies" : "global-settings",
|
||||||
unsavedTitle: "Unsaved global settings",
|
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_campaign_policy_changes.181f3426" : "i18n:govoplan-campaign.unsaved_campaign_settings.0555d713",
|
||||||
unsavedMessage: "Policies have unsaved changes. Save them before leaving, or discard them and continue.",
|
unsavedMessage: isPolicyView ?
|
||||||
|
"i18n:govoplan-campaign.campaign_policies_have_unsaved_changes_save_them.7b07827f" :
|
||||||
|
"i18n:govoplan-campaign.campaign_settings_have_unsaved_changes_save_them.c1c5f65f",
|
||||||
extraPayload: () => ({ editor_state: editorState }),
|
extraPayload: () => ({ editor_state: editorState }),
|
||||||
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
|
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
|
||||||
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
|
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
|
||||||
@@ -50,8 +63,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
|
|||||||
const optIns = asRecord(editorState.opt_ins);
|
const optIns = asRecord(editorState.opt_ins);
|
||||||
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
|
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
|
||||||
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
|
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
|
||||||
|
const pageTitle = isPolicyView ? "i18n:govoplan-campaign.campaign_policies.0b5de1f5" : "i18n:govoplan-campaign.campaign_settings.efffec26";
|
||||||
|
|
||||||
|
|
||||||
function patchEditor(path: string[], value: unknown) {
|
function patchEditor(path: string[], value: unknown) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
@@ -59,100 +71,193 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Campaign settings</PageTitle>
|
<PageTitle loading={loading}>{pageTitle}</PageTitle>
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
|
{isPolicyView ?
|
||||||
<>
|
<>
|
||||||
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
|
{canReadRetentionPolicy &&
|
||||||
|
|
||||||
{canReadRetentionPolicy && (
|
|
||||||
<RetentionPolicyEditor
|
<RetentionPolicyEditor
|
||||||
settings={settings}
|
settings={settings}
|
||||||
scopeType="campaign"
|
scopeType="campaign"
|
||||||
scopeId={campaignId}
|
scopeId={campaignId}
|
||||||
title="Campaign retention policy"
|
title="i18n:govoplan-campaign.campaign_retention_policy.fcc9897c"
|
||||||
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
|
description="i18n:govoplan-campaign.campaign_level_retention_limits_applied_after_sy.e1a5fd45"
|
||||||
canWrite={canWriteRetentionPolicy}
|
canWrite={canWriteRetentionPolicy}
|
||||||
locked={locked}
|
locked={locked} />
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="dashboard-grid">
|
}
|
||||||
<Card title="Validation policy">
|
|
||||||
<PolicySelect label="Missing required attachment" value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />
|
|
||||||
<PolicySelect label="Missing optional attachment" value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />
|
|
||||||
<PolicySelect label="Ambiguous attachment match" value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />
|
|
||||||
<PolicySelect label="Unsent attachment file" value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />
|
|
||||||
<PolicySelect label="Missing email address" value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />
|
|
||||||
<PolicySelect label="Template error" value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />
|
|
||||||
<ToggleSwitch label="Ignore empty fields" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Attachment defaults">
|
|
||||||
<div className="form-grid compact responsive-form-grid">
|
|
||||||
<FormField label="Missing behavior">
|
|
||||||
<select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Ambiguous behavior">
|
|
||||||
<select value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
|
|
||||||
</FormField>
|
|
||||||
<ToggleSwitch label="Send without attachments" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />
|
|
||||||
</div>
|
|
||||||
<p className="muted small-note">The actual global and per-recipient attachment rules live in Attachments. These settings define campaign-wide behavior used by validation and review. Individual-attachment permission is configured per attachment base path.</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dashboard-grid below-grid">
|
<div className="dashboard-grid below-grid">
|
||||||
<Card title="Delivery defaults">
|
<Card title="i18n:govoplan-campaign.validation_policy.57dcc756" collapsible>
|
||||||
|
<PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
|
||||||
|
<PolicyRow
|
||||||
|
className="campaign-policy-row"
|
||||||
|
labelClassName="campaign-policy-label"
|
||||||
|
controlClassName="campaign-policy-control"
|
||||||
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
|
label="i18n:govoplan-campaign.missing_required_attachment.a8aa73e0"
|
||||||
|
note="i18n:govoplan-campaign.required_attachment_rule_found_no_matching_file.d947b52b"
|
||||||
|
control={<PolicySelectControl value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />}
|
||||||
|
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))} />
|
||||||
|
|
||||||
|
<PolicyRow
|
||||||
|
className="campaign-policy-row"
|
||||||
|
labelClassName="campaign-policy-label"
|
||||||
|
controlClassName="campaign-policy-control"
|
||||||
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
|
label="i18n:govoplan-campaign.missing_optional_attachment.36640fa6"
|
||||||
|
note="i18n:govoplan-campaign.optional_attachment_rule_found_no_matching_file.709a2a13"
|
||||||
|
control={<PolicySelectControl value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />}
|
||||||
|
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))} />
|
||||||
|
|
||||||
|
<PolicyRow
|
||||||
|
className="campaign-policy-row"
|
||||||
|
labelClassName="campaign-policy-label"
|
||||||
|
controlClassName="campaign-policy-control"
|
||||||
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
|
label="i18n:govoplan-campaign.ambiguous_attachment_match.8043ddfa"
|
||||||
|
note="i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8"
|
||||||
|
control={<PolicySelectControl value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />}
|
||||||
|
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))} />
|
||||||
|
|
||||||
|
<PolicyRow
|
||||||
|
className="campaign-policy-row"
|
||||||
|
labelClassName="campaign-policy-label"
|
||||||
|
controlClassName="campaign-policy-control"
|
||||||
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
|
label="i18n:govoplan-campaign.unsent_attachment_file.a25263ce"
|
||||||
|
note="i18n:govoplan-campaign.a_watched_attachment_directory_contains_files_th.03f58a42"
|
||||||
|
control={<PolicySelectControl value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />}
|
||||||
|
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))} />
|
||||||
|
|
||||||
|
<PolicyRow
|
||||||
|
className="campaign-policy-row"
|
||||||
|
labelClassName="campaign-policy-label"
|
||||||
|
controlClassName="campaign-policy-control"
|
||||||
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
|
label="i18n:govoplan-campaign.missing_email_address.2a94b6d8"
|
||||||
|
note="i18n:govoplan-campaign.the_effective_recipient_list_has_no_to_address.df4e24f7"
|
||||||
|
control={<PolicySelectControl value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />}
|
||||||
|
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))} />
|
||||||
|
|
||||||
|
<PolicyRow
|
||||||
|
className="campaign-policy-row"
|
||||||
|
labelClassName="campaign-policy-label"
|
||||||
|
controlClassName="campaign-policy-control"
|
||||||
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
|
label="i18n:govoplan-campaign.template_error.50b92586"
|
||||||
|
note="i18n:govoplan-campaign.a_selected_template_body_contains_unresolved_pla.d061acad"
|
||||||
|
control={<PolicySelectControl value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />}
|
||||||
|
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))} />
|
||||||
|
|
||||||
|
<PolicyRow
|
||||||
|
className="campaign-policy-row"
|
||||||
|
labelClassName="campaign-policy-label"
|
||||||
|
controlClassName="campaign-policy-control"
|
||||||
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
|
label="i18n:govoplan-campaign.ignore_empty_fields.1ffcdc5d"
|
||||||
|
note="i18n:govoplan-campaign.controls_how_missing_placeholder_values_are_rend.c1ad492a"
|
||||||
|
control={<ToggleSwitch label="i18n:govoplan-campaign.enabled.df174a3f" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />}
|
||||||
|
effective={getBool(validationPolicy, "ignore_empty_fields") ? "i18n:govoplan-campaign.missing_values_render_as_empty_text.0a6bce45" : "i18n:govoplan-campaign.missing_values_remain_visible_and_can_trigger_te.31464ee2"} />
|
||||||
|
|
||||||
|
</PolicyTable>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-campaign.attachment_policy.2fd471d4" collapsible>
|
||||||
|
<PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
|
||||||
|
<PolicyRow
|
||||||
|
className="campaign-policy-row"
|
||||||
|
labelClassName="campaign-policy-label"
|
||||||
|
controlClassName="campaign-policy-control"
|
||||||
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
|
label="i18n:govoplan-campaign.default_missing_behavior.ffbd9cd7"
|
||||||
|
note="i18n:govoplan-campaign.used_by_attachment_rules_that_do_not_override_mi.ab8bc0d5"
|
||||||
|
control={<PolicySelectControl value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "missing_behavior"], value)} />}
|
||||||
|
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))} />
|
||||||
|
|
||||||
|
<PolicyRow
|
||||||
|
className="campaign-policy-row"
|
||||||
|
labelClassName="campaign-policy-label"
|
||||||
|
controlClassName="campaign-policy-control"
|
||||||
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
|
label="i18n:govoplan-campaign.default_ambiguous_behavior.878b9345"
|
||||||
|
note="i18n:govoplan-campaign.used_by_attachment_rules_that_do_not_override_am.c31b4e9f"
|
||||||
|
control={<PolicySelectControl value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "ambiguous_behavior"], value)} />}
|
||||||
|
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))} />
|
||||||
|
|
||||||
|
<PolicyRow
|
||||||
|
className="campaign-policy-row"
|
||||||
|
labelClassName="campaign-policy-label"
|
||||||
|
controlClassName="campaign-policy-control"
|
||||||
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
|
label="i18n:govoplan-campaign.send_without_attachments.ead6d030"
|
||||||
|
note="i18n:govoplan-campaign.campaign_wide_fallback_when_no_attachment_is_ava.84ffa0bc"
|
||||||
|
control={<ToggleSwitch label="i18n:govoplan-campaign.allowed.77c7b490" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
|
||||||
|
effective={getBool(attachments, "send_without_attachments", true) ? "i18n:govoplan-campaign.messages_may_be_sent_when_attachment_rules_allow.e6bf1aed" : "i18n:govoplan-campaign.missing_attachment_coverage_blocks_sending.05ff02a1"} />
|
||||||
|
|
||||||
|
</PolicyTable>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</> :
|
||||||
|
|
||||||
|
<>
|
||||||
|
{data.campaign && <CampaignAccessCard settings={settings} auth={auth} campaign={data.campaign} onChanged={reload} onError={setError} />}
|
||||||
|
|
||||||
|
<div className="dashboard-grid below-grid">
|
||||||
|
<Card title="i18n:govoplan-campaign.delivery_defaults.33cc3b97" collapsible>
|
||||||
<div className="form-grid compact responsive-form-grid">
|
<div className="form-grid compact responsive-form-grid">
|
||||||
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
||||||
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
||||||
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
|
<FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
|
||||||
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
<ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Opt-ins and local assistance">
|
<Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
|
||||||
<div className="toggle-grid">
|
<div className="toggle-grid">
|
||||||
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
|
<ToggleSwitch label="i18n:govoplan-campaign.suggest_addresses_from_this_campaign.5ebe2aea" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
|
||||||
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
|
<ToggleSwitch label="i18n:govoplan-campaign.remember_newly_used_addresses.aebe8adb" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
|
||||||
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
|
<ToggleSwitch label="i18n:govoplan-campaign.show_guided_warnings_while_editing.bc5dba85" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
|
||||||
</div>
|
</div>
|
||||||
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
|
<p className="muted small-note">i18n:govoplan-campaign.these_opt_ins_are_stored_in_the_draft_editor_met.dc6ffbfd</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="button-row page-bottom-actions">
|
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
|
}
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PolicySelect({ label, value, disabled, onChange, options = behaviorOptions }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
|
function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: {value: string;disabled?: boolean;onChange: (value: string) => void;options?: string[];}) {
|
||||||
return (
|
return (
|
||||||
<FormField label={label}>
|
|
||||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
|
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
|
||||||
{options.map((option) => <option key={option} value={option}>{option}</option>)}
|
{options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
</select>
|
</select>);
|
||||||
</FormField>
|
|
||||||
);
|
}
|
||||||
|
|
||||||
|
function behaviorSummary(value: string): string {
|
||||||
|
if (value === "block") return "i18n:govoplan-campaign.blocks_the_affected_message.0157342a";
|
||||||
|
if (value === "ask") return "i18n:govoplan-campaign.marks_the_message_for_review.d63beb24";
|
||||||
|
if (value === "drop") return "i18n:govoplan-campaign.excludes_the_affected_message.73be963f";
|
||||||
|
if (value === "continue") return "i18n:govoplan-campaign.continues_without_a_review_issue.99ab0684";
|
||||||
|
if (value === "warn") return "i18n:govoplan-campaign.keeps_sending_possible_with_a_warning.66c84a54";
|
||||||
|
return "i18n:govoplan-campaign.uses_the_configured_behavior.ea6e82a3";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { MailServerSettingsPanel, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
|
import { MailServerSettingsPanel, addressesFromValue, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTransportCredentialsPayloadFromRecords, usePlatformModuleInstalled, usePlatformUiCapability, type MailProfilesUiCapability, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import Card from "../../components/Card";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import FormField from "../../components/FormField";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
||||||
import { MailProfilePolicyEditor } from "../mail/MailProfileManagement";
|
|
||||||
import {
|
import {
|
||||||
createMailServerProfile,
|
createMailServerProfile,
|
||||||
getMailProfilePolicy,
|
getMailProfilePolicy,
|
||||||
@@ -22,17 +21,29 @@ import {
|
|||||||
testSmtpSettings,
|
testSmtpSettings,
|
||||||
type MailProfilePolicy,
|
type MailProfilePolicy,
|
||||||
type MailSecurity,
|
type MailSecurity,
|
||||||
type MailServerProfile
|
type MailServerProfile } from
|
||||||
} from "../../api/mail";
|
"../../api/mail";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
import { getBool, getNumber, getText } from "./utils/draftEditor";
|
import { cloneJson, getBool, getNumber, getText } from "./utils/draftEditor";
|
||||||
import { campaignMailSettingsPolicyState } from "./policyUi";
|
import { campaignMailSettingsPolicyState } from "./policyUi";
|
||||||
|
|
||||||
const securityOptions = ["plain", "tls", "starttls"];
|
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
|
||||||
|
|
||||||
export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
type MailSettingsView = "settings" | "policy";
|
||||||
|
|
||||||
|
type MailSettingsPageProps = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
campaignId: string;
|
||||||
|
view?: MailSettingsView;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
|
||||||
|
const mailModuleInstalled = usePlatformModuleInstalled("mail");
|
||||||
|
const isPolicyView = view === "policy";
|
||||||
|
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||||
|
const MailProfilePolicyEditor = mailProfilesUi?.MailProfilePolicyEditor ?? null;
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
@@ -55,43 +66,93 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
locked,
|
locked,
|
||||||
reload,
|
reload,
|
||||||
setError,
|
setError,
|
||||||
currentStep: "mail-settings",
|
currentStep: isPolicyView ? "mail-policy" : "mail-settings",
|
||||||
unsavedTitle: "Unsaved server settings",
|
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_mail_policy_changes.c9327491" : "i18n:govoplan-campaign.unsaved_mail_settings.38e1536b",
|
||||||
unsavedMessage: "Server settings have unsaved changes. Save them before leaving, or discard them and continue."
|
unsavedMessage: isPolicyView ?
|
||||||
|
"i18n:govoplan-campaign.mail_policy_changes_have_unsaved_draft_changes_s.5aee7d4e" :
|
||||||
|
"i18n:govoplan-campaign.mail_settings_have_unsaved_changes_save_them_bef.52644559",
|
||||||
|
transformDraftBeforeSave: normalizeMailSettingsBeforeSave
|
||||||
});
|
});
|
||||||
const server = asRecord(displayDraft.server);
|
const server = asRecord(displayDraft.server);
|
||||||
const smtp = asRecord(server.smtp);
|
const smtp = asRecord(server.smtp);
|
||||||
const imap = asRecord(server.imap);
|
const imap = asRecord(server.imap);
|
||||||
|
const credentials = asRecord(server.credentials);
|
||||||
|
const smtpCredentials = asRecord(credentials.smtp);
|
||||||
|
const imapCredentials = asRecord(credentials.imap);
|
||||||
const delivery = asRecord(displayDraft.delivery);
|
const delivery = asRecord(displayDraft.delivery);
|
||||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||||
const imapEnabled = getBool(imap, "enabled");
|
const selectedProfileId = mailModuleInstalled ? getText(server, "mail_profile_id") : "";
|
||||||
const selectedProfileId = getText(server, "mail_profile_id");
|
const selectedProfile = mailModuleInstalled ? mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null : null;
|
||||||
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
const usingMailProfile = mailModuleInstalled && Boolean(selectedProfileId);
|
||||||
const usingMailProfile = Boolean(selectedProfileId);
|
|
||||||
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
|
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
|
||||||
const effectiveImapAvailable = usingMailProfile ? selectedProfileHasImap : imapEnabled;
|
const imapUnavailable = usingMailProfile && !selectedProfileHasImap;
|
||||||
const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false;
|
const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false;
|
||||||
const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false;
|
const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false;
|
||||||
const mailPolicyState = campaignMailSettingsPolicyState({ effectivePolicy: effectiveMailPolicy, selectedProfileId, locked });
|
const effectiveMailPolicyForState: MailProfilePolicy | null = mailModuleInstalled ? effectiveMailPolicy : { allow_campaign_profiles: true };
|
||||||
|
const mailPolicyState = campaignMailSettingsPolicyState({ effectivePolicy: effectiveMailPolicyForState, selectedProfileId, locked });
|
||||||
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
|
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
|
||||||
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
|
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
|
||||||
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
|
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
|
||||||
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || (usingMailProfile && smtpCredentialsInherited);
|
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || usingMailProfile && smtpCredentialsInherited;
|
||||||
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile || !imapEnabled;
|
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile;
|
||||||
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || !effectiveImapAvailable || (usingMailProfile && imapCredentialsInherited);
|
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || usingMailProfile && imapCredentialsInherited;
|
||||||
const imapDisabled = locked || inlineMailSettingsBlocked || !effectiveImapAvailable;
|
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
|
||||||
|
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||||
|
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
|
||||||
|
const inlinePolicyMessages = useMemo(() => {
|
||||||
|
const validateMailPolicy = mailProfilesUi?.validateMailPolicy;
|
||||||
|
if (!mailModuleInstalled || usingMailProfile || !validateMailPolicy || !effectiveMailPolicy) return [];
|
||||||
|
const recipients = asRecord(displayDraft.recipients);
|
||||||
|
const fromEmail = firstAddressEmail(recipients.from);
|
||||||
|
return validateMailPolicy(effectiveMailPolicy, {
|
||||||
|
smtpHost: getText(smtp, "host"),
|
||||||
|
imapHost: getText(imap, "host"),
|
||||||
|
envelopeSender: fromEmail || getText(smtpCredentials, "username", getText(smtp, "username")),
|
||||||
|
fromHeader: fromEmail,
|
||||||
|
recipientDomains: collectRecipientDomains(displayDraft)
|
||||||
|
});
|
||||||
|
}, [displayDraft, effectiveMailPolicy, imap, mailModuleInstalled, mailProfilesUi, smtp, smtpCredentials, usingMailProfile]);
|
||||||
|
const displayedSmtp = {
|
||||||
|
host: usingMailProfile && selectedProfile ? stringOrEmpty(selectedProfile.smtp.host) : getText(smtp, "host"),
|
||||||
|
port: usingMailProfile && selectedProfile ? selectedProfile.smtp.port ?? 587 : getNumber(smtp, "port", 587),
|
||||||
|
username: usingMailProfile && smtpCredentialsInherited ? profileUsername(selectedProfile, "smtp") : getText(smtpCredentials, "username", getText(smtp, "username")),
|
||||||
|
password: usingMailProfile && smtpCredentialsInherited ? "" : getText(smtpCredentials, "password", getText(smtp, "password")),
|
||||||
|
security: usingMailProfile && selectedProfile ? selectedProfile.smtp.security ?? "starttls" : getText(smtp, "security", "starttls"),
|
||||||
|
timeout_seconds: usingMailProfile && selectedProfile ? selectedProfile.smtp.timeout_seconds ?? 30 : getNumber(smtp, "timeout_seconds", 30)
|
||||||
|
};
|
||||||
|
const displayedImap = {
|
||||||
|
host: usingMailProfile && selectedProfile?.imap ? stringOrEmpty(selectedProfile.imap.host) : getText(imap, "host"),
|
||||||
|
port: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.port ?? 993 : getNumber(imap, "port", 993),
|
||||||
|
username: usingMailProfile && imapCredentialsInherited ? profileUsername(selectedProfile, "imap") : getText(imapCredentials, "username", getText(imap, "username")),
|
||||||
|
password: usingMailProfile && imapCredentialsInherited ? "" : getText(imapCredentials, "password", getText(imap, "password")),
|
||||||
|
security: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.security ?? "tls" : getText(imap, "security", "tls"),
|
||||||
|
sent_folder: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.sent_folder ?? "auto" : getText(imap, "sent_folder", "auto"),
|
||||||
|
timeout_seconds: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.timeout_seconds ?? 30 : getNumber(imap, "timeout_seconds", 30)
|
||||||
|
};
|
||||||
|
const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || selectedProfileHasImap && !imapCredentialsInherited);
|
||||||
|
|
||||||
useEffect(() => { void refreshMailProfiles(); }, [settings.apiBaseUrl, settings.apiKey, campaignId]);
|
useEffect(() => {
|
||||||
|
if (!mailModuleInstalled) {
|
||||||
|
setMailProfiles([]);
|
||||||
|
setPolicyProfiles([]);
|
||||||
|
setEffectiveMailPolicy({ allow_campaign_profiles: true });
|
||||||
|
setProfileError("");
|
||||||
|
setProfilesLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshMailProfiles();
|
||||||
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, mailModuleInstalled]);
|
||||||
|
|
||||||
async function refreshMailProfiles() {
|
async function refreshMailProfiles() {
|
||||||
|
if (!mailModuleInstalled) return;
|
||||||
setProfilesLoading(true);
|
setProfilesLoading(true);
|
||||||
setProfileError("");
|
setProfileError("");
|
||||||
try {
|
try {
|
||||||
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
|
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
|
||||||
listMailServerProfiles(settings, false, campaignId),
|
listMailServerProfiles(settings, false, campaignId),
|
||||||
listMailServerProfiles(settings, true),
|
listMailServerProfiles(settings, true),
|
||||||
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)
|
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)]
|
||||||
]);
|
);
|
||||||
setMailProfiles(allowedProfiles);
|
setMailProfiles(allowedProfiles);
|
||||||
setPolicyProfiles(visibleProfiles);
|
setPolicyProfiles(visibleProfiles);
|
||||||
setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
|
setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
|
||||||
@@ -105,8 +166,39 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMailSettingsBeforeSave(value: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
if (mailModuleInstalled === false) return value;
|
||||||
|
const next = cloneJson(value);
|
||||||
|
const nextServer = { ...asRecord(next.server) };
|
||||||
|
const profileId = getText(nextServer, "mail_profile_id");
|
||||||
|
if (profileId.length === 0) return next;
|
||||||
|
|
||||||
|
const nextCredentials = { ...asRecord(nextServer.credentials) };
|
||||||
|
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "smtp", effectiveMailPolicy?.smtp_credentials?.inherit === false ? false : true);
|
||||||
|
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "imap", effectiveMailPolicy?.imap_credentials?.inherit === false ? false : true);
|
||||||
|
nextServer.credentials = nextCredentials;
|
||||||
|
next.server = nextServer;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfileCredentialProtocol(
|
||||||
|
serverValue: Record<string, unknown>,
|
||||||
|
credentialsValue: Record<string, unknown>,
|
||||||
|
protocol: "smtp" | "imap",
|
||||||
|
inherit: boolean)
|
||||||
|
{
|
||||||
|
serverValue["inherit_" + protocol + "_credentials"] = inherit;
|
||||||
|
if (inherit === false) return;
|
||||||
|
|
||||||
|
const transport = { ...asRecord(serverValue[protocol]) };
|
||||||
|
delete transport.username;
|
||||||
|
delete transport.password;
|
||||||
|
serverValue[protocol] = transport;
|
||||||
|
credentialsValue[protocol] = {};
|
||||||
|
}
|
||||||
|
|
||||||
function selectMailProfile(profileId: string) {
|
function selectMailProfile(profileId: string) {
|
||||||
if (locked) return;
|
if (!mailModuleInstalled || locked) return;
|
||||||
if (!profileId && !campaignProfilesAllowed) {
|
if (!profileId && !campaignProfilesAllowed) {
|
||||||
setProfileError(mailPolicyState.inlineBlockedMessage);
|
setProfileError(mailPolicyState.inlineBlockedMessage);
|
||||||
return;
|
return;
|
||||||
@@ -117,6 +209,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
if (profileId) {
|
if (profileId) {
|
||||||
patch(["server", "smtp"], {});
|
patch(["server", "smtp"], {});
|
||||||
patch(["server", "imap"], {});
|
patch(["server", "imap"], {});
|
||||||
|
patch(["server", "credentials"], {});
|
||||||
setSmtpTestResult(null);
|
setSmtpTestResult(null);
|
||||||
setImapTestResult(null);
|
setImapTestResult(null);
|
||||||
setFolderResult(null);
|
setFolderResult(null);
|
||||||
@@ -124,7 +217,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveCurrentSettingsAsProfile() {
|
async function saveCurrentSettingsAsProfile() {
|
||||||
if (locked || usingMailProfile || !campaignProfilesAllowed) return;
|
if (!mailModuleInstalled || locked || usingMailProfile || !campaignProfilesAllowed) return;
|
||||||
setProfilesLoading(true);
|
setProfilesLoading(true);
|
||||||
setProfileMessage("");
|
setProfileMessage("");
|
||||||
setProfileError("");
|
setProfileError("");
|
||||||
@@ -133,14 +226,16 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
name: profileName.trim() || `${data.campaign?.name || "Campaign"} mail profile`,
|
name: profileName.trim() || `${data.campaign?.name || "Campaign"} mail profile`,
|
||||||
scope_type: "campaign",
|
scope_type: "campaign",
|
||||||
scope_id: campaignId,
|
scope_id: campaignId,
|
||||||
smtp: smtpPayload(),
|
smtp: smtpServerPayload(),
|
||||||
imap: imapEnabled ? imapPayload() : null,
|
imap: hasInlineImapSettings() ? imapServerPayload() : null,
|
||||||
|
credentials: mailProfileCredentialsPayload(false),
|
||||||
is_active: true
|
is_active: true
|
||||||
});
|
});
|
||||||
setMailProfiles((current) => [...current.filter((profile) => profile.id !== created.id), created].sort((a, b) => a.name.localeCompare(b.name)));
|
setMailProfiles((current) => [...current.filter((profile) => profile.id !== created.id), created].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
patch(["server", "mail_profile_id"], created.id);
|
patch(["server", "mail_profile_id"], created.id);
|
||||||
patch(["server", "smtp"], {});
|
patch(["server", "smtp"], {});
|
||||||
patch(["server", "imap"], {});
|
patch(["server", "imap"], {});
|
||||||
|
patch(["server", "credentials"], {});
|
||||||
setProfileName("");
|
setProfileName("");
|
||||||
setProfileMessage(`Saved profile ${created.name}.`);
|
setProfileMessage(`Saved profile ${created.name}.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -150,30 +245,30 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleImap(enabled: boolean) {
|
|
||||||
patch(["server", "imap", "enabled"], enabled);
|
|
||||||
if (!enabled) {
|
|
||||||
patch(["delivery", "imap_append_sent", "enabled"], false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchSmtpSettings(patchValue: Partial<MailServerSmtpSettings>) {
|
function patchSmtpSettings(patchValue: Partial<MailServerSmtpSettings>) {
|
||||||
if (patchValue.host !== undefined) patch(["server", "smtp", "host"], String(patchValue.host ?? ""));
|
if (patchValue.host !== undefined) patch(["server", "smtp", "host"], String(patchValue.host ?? ""));
|
||||||
if (patchValue.port !== undefined) patch(["server", "smtp", "port"], Number(patchValue.port || 0));
|
if (patchValue.port !== undefined) patch(["server", "smtp", "port"], mailNumberOrNull(patchValue.port));
|
||||||
if (patchValue.username !== undefined) patch(["server", "smtp", "username"], String(patchValue.username ?? ""));
|
|
||||||
if (patchValue.password !== undefined) patch(["server", "smtp", "password"], String(patchValue.password ?? ""));
|
|
||||||
if (patchValue.security !== undefined) patch(["server", "smtp", "security"], String(patchValue.security || "starttls"));
|
if (patchValue.security !== undefined) patch(["server", "smtp", "security"], String(patchValue.security || "starttls"));
|
||||||
if (patchValue.timeout_seconds !== undefined) patch(["server", "smtp", "timeout_seconds"], Number(patchValue.timeout_seconds || 0));
|
if (patchValue.timeout_seconds !== undefined) patch(["server", "smtp", "timeout_seconds"], mailNumberOrDefault(patchValue.timeout_seconds, 30));
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchImapSettings(patchValue: Partial<MailServerImapSettings>) {
|
function patchImapSettings(patchValue: Partial<MailServerImapSettings>) {
|
||||||
if (patchValue.host !== undefined) patch(["server", "imap", "host"], String(patchValue.host ?? ""));
|
if (patchValue.host !== undefined) patch(["server", "imap", "host"], String(patchValue.host ?? ""));
|
||||||
if (patchValue.port !== undefined) patch(["server", "imap", "port"], Number(patchValue.port || 0));
|
if (patchValue.port !== undefined) patch(["server", "imap", "port"], mailNumberOrNull(patchValue.port));
|
||||||
if (patchValue.username !== undefined) patch(["server", "imap", "username"], String(patchValue.username ?? ""));
|
|
||||||
if (patchValue.password !== undefined) patch(["server", "imap", "password"], String(patchValue.password ?? ""));
|
|
||||||
if (patchValue.security !== undefined) patch(["server", "imap", "security"], String(patchValue.security || "tls"));
|
if (patchValue.security !== undefined) patch(["server", "imap", "security"], String(patchValue.security || "tls"));
|
||||||
if (patchValue.sent_folder !== undefined) patch(["server", "imap", "sent_folder"], String(patchValue.sent_folder ?? ""));
|
if (patchValue.sent_folder !== undefined) patch(["server", "imap", "sent_folder"], String(patchValue.sent_folder ?? ""));
|
||||||
if (patchValue.timeout_seconds !== undefined) patch(["server", "imap", "timeout_seconds"], Number(patchValue.timeout_seconds || 0));
|
if (patchValue.timeout_seconds !== undefined) patch(["server", "imap", "timeout_seconds"], mailNumberOrDefault(patchValue.timeout_seconds, 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSmtpCredentials(patchValue: Partial<MailServerCredentialSettings>) {
|
||||||
|
if (patchValue.username !== undefined) patch(["server", "credentials", "smtp", "username"], String(patchValue.username ?? ""));
|
||||||
|
if (patchValue.password !== undefined) patch(["server", "credentials", "smtp", "password"], String(patchValue.password ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchImapCredentials(patchValue: Partial<MailServerCredentialSettings>) {
|
||||||
|
if (patchValue.username !== undefined) patch(["server", "credentials", "imap", "username"], String(patchValue.username ?? ""));
|
||||||
|
if (patchValue.password !== undefined) patch(["server", "credentials", "imap", "password"], String(patchValue.password ?? ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileScopeLabel(profile: MailServerProfile): string {
|
function profileScopeLabel(profile: MailServerProfile): string {
|
||||||
@@ -185,71 +280,60 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function emptyToNull(value: string, trim = true): string | null {
|
function smtpServerPayload() {
|
||||||
const normalized = trim ? value.trim() : value;
|
return mailSmtpSettingsPayload<MailSecurity>(
|
||||||
return normalized ? normalized : null;
|
{ host: getText(smtp, "host"), port: getNumber(smtp, "port", 587), security: getText(smtp, "security", "starttls"), timeout_seconds: getNumber(smtp, "timeout_seconds", 30) },
|
||||||
|
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
|
function imapServerPayload() {
|
||||||
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
|
return mailImapSettingsPayload<MailSecurity>(
|
||||||
|
{ host: getText(imap, "host"), port: getNumber(imap, "port", 993), security: getText(imap, "security", "tls"), sent_folder: getText(imap, "sent_folder", "auto"), timeout_seconds: getNumber(imap, "timeout_seconds", 30) },
|
||||||
|
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mailProfileCredentialsPayload(preserveBlankPassword: boolean) {
|
||||||
function smtpPayload() {
|
|
||||||
return {
|
return {
|
||||||
host: emptyToNull(getText(smtp, "host")),
|
smtp: mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, preserveBlankPassword),
|
||||||
port: getNumber(smtp, "port", 587),
|
imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword)
|
||||||
username: emptyToNull(getText(smtp, "username")),
|
|
||||||
password: emptyToNull(getText(smtp, "password"), false),
|
|
||||||
security: readSecurity(getText(smtp, "security", "starttls"), "starttls"),
|
|
||||||
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function imapPayload() {
|
function rawSmtpPayload() {
|
||||||
return {
|
const serverPayload = selectedProfile && !smtpCredentialsInherited ? selectedProfile.smtp : smtpServerPayload();
|
||||||
enabled: true,
|
return { ...serverPayload, ...mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, false) };
|
||||||
host: emptyToNull(getText(imap, "host")),
|
|
||||||
port: getNumber(imap, "port", 993),
|
|
||||||
username: emptyToNull(getText(imap, "username")),
|
|
||||||
password: emptyToNull(getText(imap, "password"), false),
|
|
||||||
security: readSecurity(getText(imap, "security", "tls"), "tls"),
|
|
||||||
sent_folder: emptyToNull(getText(imap, "sent_folder", "auto")),
|
|
||||||
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectiveSmtpPayload() {
|
function rawImapPayload() {
|
||||||
if (selectedProfile && !smtpCredentialsInherited) {
|
const serverPayload = selectedProfile?.imap && !imapCredentialsInherited ? selectedProfile.imap : imapServerPayload();
|
||||||
return {
|
return { ...serverPayload, ...mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, false) };
|
||||||
...selectedProfile.smtp,
|
|
||||||
username: emptyToNull(getText(smtp, "username")),
|
|
||||||
password: emptyToNull(getText(smtp, "password"), false)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return smtpPayload();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectiveImapPayload() {
|
function hasInlineImapSettings(): boolean {
|
||||||
if (selectedProfile?.imap && !imapCredentialsInherited) {
|
return hasMailImapSettings([getText(imap, "host"), getText(imapCredentials, "username", getText(imap, "username")), getText(imapCredentials, "password", getText(imap, "password"))]);
|
||||||
return {
|
|
||||||
...selectedProfile.imap,
|
|
||||||
enabled: true,
|
|
||||||
username: emptyToNull(getText(imap, "username")),
|
|
||||||
password: emptyToNull(getText(imap, "password"), false)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return imapPayload();
|
|
||||||
|
function profileUsername(profile: MailServerProfile | null, protocol: "smtp" | "imap"): string {
|
||||||
|
if (!profile) return "";
|
||||||
|
if (protocol === "smtp") return stringOrEmpty(profile.credentials?.smtp?.username ?? profile.smtp.username);
|
||||||
|
return stringOrEmpty(profile.credentials?.imap?.username ?? profile.imap?.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function runSmtpTest() {
|
async function runSmtpTest() {
|
||||||
|
if (!mailModuleInstalled) {
|
||||||
|
setSmtpTestResult({ ok: false, protocol: "smtp", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_smtp_.a7ce04e1", details: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (locked || inlineMailSettingsBlocked) return;
|
if (locked || inlineMailSettingsBlocked) return;
|
||||||
setMailActionState("smtp");
|
setMailActionState("smtp");
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
try {
|
try {
|
||||||
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited
|
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited ?
|
||||||
? await testMailProfileSmtp(settings, selectedProfileId)
|
await testMailProfileSmtp(settings, selectedProfileId) :
|
||||||
: await testSmtpSettings(settings, effectiveSmtpPayload()));
|
await testSmtpSettings(settings, rawSmtpPayload()));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
|
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -258,13 +342,17 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runImapTest() {
|
async function runImapTest() {
|
||||||
|
if (!mailModuleInstalled) {
|
||||||
|
setImapTestResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_imap_.d6537dfd", details: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (imapDisabled) return;
|
if (imapDisabled) return;
|
||||||
setMailActionState("imap");
|
setMailActionState("imap");
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
try {
|
try {
|
||||||
setImapTestResult(selectedProfileId && imapCredentialsInherited
|
setImapTestResult(selectedProfileId && imapCredentialsInherited ?
|
||||||
? await testMailProfileImap(settings, selectedProfileId)
|
await testMailProfileImap(settings, selectedProfileId) :
|
||||||
: await testImapSettings(settings, effectiveImapPayload()));
|
await testImapSettings(settings, rawImapPayload()));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
|
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -273,13 +361,17 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runFolderLookup() {
|
async function runFolderLookup() {
|
||||||
if (imapDisabled) return;
|
if (!mailModuleInstalled) {
|
||||||
|
setFolderResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_inspect_im.52535774", folders: [], details: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (appendTargetFolderDisabled) return;
|
||||||
setMailActionState("folders");
|
setMailActionState("folders");
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
try {
|
try {
|
||||||
setFolderResult(selectedProfileId && imapCredentialsInherited
|
setFolderResult(selectedProfileId && imapCredentialsInherited ?
|
||||||
? await listMailProfileImapFolders(settings, selectedProfileId)
|
await listMailProfileImapFolders(settings, selectedProfileId) :
|
||||||
: await listImapFolders(settings, effectiveImapPayload()));
|
await listImapFolders(settings, rawImapPayload()));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -289,34 +381,36 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
|
|
||||||
function useDetectedSentFolder() {
|
function useDetectedSentFolder() {
|
||||||
const folder = folderResult?.detected_sent_folder;
|
const folder = folderResult?.detected_sent_folder;
|
||||||
if (!folder || imapDisabled) return;
|
if (!folder || appendTargetFolderDisabled) return;
|
||||||
if (!usingMailProfile) {
|
|
||||||
patch(["server", "imap", "sent_folder"], folder);
|
|
||||||
}
|
|
||||||
if (!getText(imapAppend, "folder") || getText(imapAppend, "folder") === "auto") {
|
|
||||||
patch(["delivery", "imap_append_sent", "folder"], folder);
|
patch(["delivery", "imap_append_sent", "folder"], folder);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Server settings</PageTitle>
|
<PageTitle loading={loading}>{isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}</PageTitle>
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</Button>
|
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
|
{!mailModuleInstalled &&
|
||||||
|
<DismissibleAlert tone="info" dismissible={false}>
|
||||||
|
i18n:govoplan-campaign.the_mail_module_is_not_installed_inline_smtp_and.8e0f802e
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor &&
|
||||||
<MailProfilePolicyEditor
|
<MailProfilePolicyEditor
|
||||||
settings={settings}
|
settings={settings}
|
||||||
scopeType="campaign"
|
scopeType="campaign"
|
||||||
@@ -327,79 +421,84 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
ownerGroupId={data.campaign?.owner_group_id}
|
ownerGroupId={data.campaign?.owner_group_id}
|
||||||
canWrite={!locked}
|
canWrite={!locked}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
title="Campaign-local mail policy"
|
title="i18n:govoplan-campaign.campaign_local_mail_policy.94f59da0"
|
||||||
description="Campaign-local mail limits applied after system, tenant and owner policies."
|
description="i18n:govoplan-campaign.campaign_local_mail_limits_applied_after_system_.e7f9bb31"
|
||||||
onSaved={refreshMailProfiles}
|
onSaved={refreshMailProfiles} />
|
||||||
/>
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor &&
|
||||||
|
<DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_mail_module_did_not_expose_profile_managemen.2cdb57e1</DismissibleAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
{!isPolicyView && mailModuleInstalled &&
|
||||||
<Card
|
<Card
|
||||||
title="Reusable mail profile"
|
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
|
||||||
actions={
|
actions={
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "Loading…" : "Reload profiles"}</Button>
|
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
|
||||||
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button>
|
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save_current_settings_as_profile.7578a50b"}</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}>
|
||||||
>
|
|
||||||
<div className="form-grid compact responsive-form-grid">
|
<div className="form-grid compact responsive-form-grid">
|
||||||
<FormField label="Profile">
|
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
||||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
||||||
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>Inline SMTP/IMAP settings</option>
|
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>i18n:govoplan-campaign.inline_smtp_imap_settings.cf16c421</option>
|
||||||
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="New profile name">
|
<FormField label="i18n:govoplan-campaign.new_profile_name.393313b6">
|
||||||
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} campaign-local profile` : "Campaign-local profile"} />
|
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? i18nMessage("i18n:govoplan-campaign.value_campaign_local_profile.70cd9d43", { value0: data.campaign.name }) : "i18n:govoplan-campaign.campaign_local_profile.c24cf2d1"} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
{!campaignProfilesAllowed && <p className="muted small-note">Campaign-local mail settings are blocked by the effective mail policy. Select an allowed reusable profile.</p>}
|
{!campaignProfilesAllowed && <p className="muted small-note">i18n:govoplan-campaign.campaign_local_mail_settings_are_blocked_by_the_.0b2510aa</p>}
|
||||||
{selectedProfile && (
|
{selectedProfile &&
|
||||||
<p className="muted small-note">Using {selectedProfile.name} ({profileScopeLabel(selectedProfile)}). SMTP credentials: {smtpCredentialsInherited ? "inherited from profile" : "local credentials required"}. IMAP credentials: {selectedProfile.imap ? (imapCredentialsInherited ? "inherited from profile" : "local credentials required") : "not configured"}.</p>
|
<p className="muted small-note">i18n:govoplan-campaign.using.c25de2e8 {selectedProfile.name} ({profileScopeLabel(selectedProfile)}i18n:govoplan-campaign.smtp_credentials.10f75c8a {smtpCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c"}i18n:govoplan-campaign.imap_credentials.7442b238 {selectedProfile.imap ? imapCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c" : "i18n:govoplan-campaign.not_configured.67f2141f"}.</p>
|
||||||
)}
|
}
|
||||||
|
{selectedProfileNeedsLocalCredentials &&
|
||||||
|
<p className="muted small-note">i18n:govoplan-campaign.the_selected_profile_supplies_the_server_setting.bdc830db</p>
|
||||||
|
}
|
||||||
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
|
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
|
||||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissStorageKey={`campaign:${campaignId}:mail-settings:profile-error`} floating>{profileError}</DismissibleAlert>}
|
||||||
</Card>
|
</Card>
|
||||||
|
}
|
||||||
|
|
||||||
<Card title="Mail server settings">
|
{!isPolicyView &&
|
||||||
|
<Card title="i18n:govoplan-campaign.mail_server_settings.6db620b0" collapsible>
|
||||||
|
{inlinePolicyMessages.length > 0 &&
|
||||||
|
<DismissibleAlert tone="warning" resetKey={inlinePolicyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
|
||||||
|
<strong>i18n:govoplan-campaign.effective_mail_policy_blocks_the_current_inline_.99c34c6b</strong>
|
||||||
|
<ul>{inlinePolicyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul>
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
<MailServerSettingsPanel
|
<MailServerSettingsPanel
|
||||||
smtp={{
|
smtp={displayedSmtp}
|
||||||
host: getText(smtp, "host"),
|
imap={displayedImap}
|
||||||
port: getNumber(smtp, "port", 587),
|
|
||||||
username: getText(smtp, "username"),
|
|
||||||
password: getText(smtp, "password"),
|
|
||||||
security: getText(smtp, "security", "starttls"),
|
|
||||||
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
|
|
||||||
}}
|
|
||||||
imap={{
|
|
||||||
enabled: imapEnabled,
|
|
||||||
host: getText(imap, "host"),
|
|
||||||
port: getNumber(imap, "port", 993),
|
|
||||||
username: getText(imap, "username"),
|
|
||||||
password: getText(imap, "password"),
|
|
||||||
security: getText(imap, "security", "tls"),
|
|
||||||
sent_folder: getText(imap, "sent_folder", "auto"),
|
|
||||||
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
|
|
||||||
}}
|
|
||||||
onSmtpChange={patchSmtpSettings}
|
onSmtpChange={patchSmtpSettings}
|
||||||
onImapChange={patchImapSettings}
|
onImapChange={patchImapSettings}
|
||||||
onImapEnabledChange={toggleImap}
|
smtpCredentials={{ username: displayedSmtp.username, password: displayedSmtp.password }}
|
||||||
|
imapCredentials={{ username: displayedImap.username, password: displayedImap.password }}
|
||||||
|
onSmtpCredentialsChange={patchSmtpCredentials}
|
||||||
|
onImapCredentialsChange={patchImapCredentials}
|
||||||
smtpDisabled={smtpDisabled}
|
smtpDisabled={smtpDisabled}
|
||||||
smtpCredentialDisabled={smtpCredentialDisabled}
|
smtpCredentialDisabled={smtpCredentialDisabled}
|
||||||
smtpActionDisabled={locked || inlineMailSettingsBlocked}
|
smtpPasswordSaved={Boolean(usingMailProfile && smtpCredentialsInherited && selectedProfile?.smtp_password_configured)}
|
||||||
imapToggleDisabled={locked || usingMailProfile || inlineMailSettingsBlocked}
|
smtpActionDisabled={locked || inlineMailSettingsBlocked || !mailModuleInstalled}
|
||||||
imapServerDisabled={imapServerDisabled}
|
imapServerDisabled={imapServerDisabled}
|
||||||
imapCredentialDisabled={imapCredentialDisabled}
|
imapCredentialDisabled={imapCredentialDisabled}
|
||||||
imapActionDisabled={imapDisabled}
|
imapPasswordSaved={Boolean(usingMailProfile && imapCredentialsInherited && selectedProfile?.imap_password_configured)}
|
||||||
|
imapActionDisabled={imapDisabled || !mailModuleInstalled}
|
||||||
append={{
|
append={{
|
||||||
enabled: getBool(imapAppend, "enabled"),
|
enabled: imapAppendEnabled,
|
||||||
folder: getText(imapAppend, "folder", getText(imap, "sent_folder", "auto")),
|
folder: getText(imapAppend, "folder", getText(imap, "sent_folder", "auto")),
|
||||||
disabled: imapDisabled,
|
disabled: imapDisabled,
|
||||||
folderDisabled: imapDisabled || !getBool(imapAppend, "enabled"),
|
folderDisabled: appendTargetFolderDisabled,
|
||||||
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
|
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
|
||||||
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
|
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
|
||||||
}}
|
}}
|
||||||
smtpTestLabel={usingMailProfile ? "Test profile SMTP" : "Test SMTP login"}
|
smtpTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_smtp.884a0e66" : "i18n:govoplan-campaign.test_smtp_login.a1359755"}
|
||||||
imapTestLabel={usingMailProfile ? "Test profile IMAP" : "Test IMAP login"}
|
imapTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_imap.e1cec0e0" : "i18n:govoplan-campaign.test_imap_login.c32e316e"}
|
||||||
busyAction={mailActionState}
|
busyAction={mailActionState}
|
||||||
onTestSmtp={runSmtpTest}
|
onTestSmtp={runSmtpTest}
|
||||||
onTestImap={runImapTest}
|
onTestImap={runImapTest}
|
||||||
@@ -408,17 +507,44 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
imapTestResult={imapTestResult}
|
imapTestResult={imapTestResult}
|
||||||
folderLookupResult={folderResult}
|
folderLookupResult={folderResult}
|
||||||
onUseDetectedFolder={useDetectedSentFolder}
|
onUseDetectedFolder={useDetectedSentFolder}
|
||||||
useDetectedFolderDisabled={imapDisabled}
|
useDetectedFolderDisabled={appendTargetFolderDisabled}
|
||||||
floatingResults
|
floatingResults />
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
}
|
||||||
|
|
||||||
<div className="button-row page-bottom-actions">
|
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || inlineMailSettingsBlocked}>Save</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function stringOrEmpty(value: unknown): string {
|
||||||
|
return value === null || value === undefined ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstAddressEmail(value: unknown): string {
|
||||||
|
return addressesFromValue(value)[0]?.email ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRecipientDomains(draft: Record<string, unknown>): string[] {
|
||||||
|
const domains = new Set<string>();
|
||||||
|
const recipients = asRecord(draft.recipients);
|
||||||
|
for (const key of ["to", "cc", "bcc"]) addAddressDomains(domains, recipients[key]);
|
||||||
|
|
||||||
|
const entries = asArray(asRecord(draft.entries).inline).map(asRecord);
|
||||||
|
for (const entry of entries) {
|
||||||
|
for (const key of ["to", "cc", "bcc"]) addAddressDomains(domains, entry[key]);
|
||||||
|
addAddressDomains(domains, entry.recipient);
|
||||||
|
addAddressDomains(domains, entry);
|
||||||
|
}
|
||||||
|
return [...domains].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAddressDomains(domains: Set<string>, value: unknown) {
|
||||||
|
for (const address of addressesFromValue(value)) {
|
||||||
|
const domain = address.email.split("@").pop()?.trim().toLowerCase();
|
||||||
|
if (domain) domains.add(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import Card from "../../components/Card";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
@@ -13,18 +14,25 @@ import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
|||||||
import FieldValueInput from "./components/FieldValueInput";
|
import FieldValueInput from "./components/FieldValueInput";
|
||||||
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
||||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||||
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
|
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
|
||||||
import { addressesFromValue } from "../../utils/emailAddresses";
|
import { importedRowsNeedAttachmentSource, materializeRecipientImport, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
|
||||||
|
import { RecipientImportDialog } from "./RecipientDataPage";
|
||||||
|
import { addressesFromValue } from "@govoplan/core-webui";
|
||||||
|
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
|
|
||||||
|
|
||||||
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function RecipientDetailsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
|
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [recipientDataPage, setRecipientDataPage] = useState(1);
|
||||||
|
const [recipientDataPageSize, setRecipientDataPageSize] = useState(10);
|
||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -32,8 +40,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
|||||||
reload,
|
reload,
|
||||||
setError,
|
setError,
|
||||||
currentStep: "recipient-data",
|
currentStep: "recipient-data",
|
||||||
unsavedTitle: "Unsaved recipient data changes",
|
unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_data_changes.c810507e",
|
||||||
unsavedMessage: "Recipient field values or attachments have unsaved changes. Save them before leaving, or discard them and continue."
|
unsavedMessage: "i18n:govoplan-campaign.recipient_field_values_or_attachments_have_unsav.49ab1870"
|
||||||
});
|
});
|
||||||
const entries = asRecord(displayDraft.entries);
|
const entries = asRecord(displayDraft.entries);
|
||||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||||
@@ -42,6 +50,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
|||||||
const attachmentSection = asRecord(displayDraft.attachments);
|
const attachmentSection = asRecord(displayDraft.attachments);
|
||||||
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
|
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
|
||||||
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
|
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
|
||||||
|
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection, true), [attachmentSection]);
|
||||||
|
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
|
||||||
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]);
|
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]);
|
||||||
|
|
||||||
|
|
||||||
@@ -68,56 +78,110 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
|||||||
updateEntry(index, (entry) => ({ ...entry, attachments }));
|
updateEntry(index, (entry) => ({ ...entry, attachments }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
|
||||||
|
if (locked || !draft) return;
|
||||||
|
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
|
||||||
|
const currentAttachments = asRecord(draft.attachments);
|
||||||
|
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
|
||||||
|
let attachmentBasePath: AttachmentBasePath | null = null;
|
||||||
|
|
||||||
|
if (needsAttachmentSource) {
|
||||||
|
if (nextBasePaths.length === 0) {
|
||||||
|
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
|
||||||
|
}
|
||||||
|
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
|
||||||
|
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
|
||||||
|
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
|
||||||
|
const nextDraft = needsAttachmentSource ?
|
||||||
|
{
|
||||||
|
...importedDraft,
|
||||||
|
attachments: {
|
||||||
|
...asRecord(importedDraft.attachments),
|
||||||
|
base_paths: nextBasePaths,
|
||||||
|
base_path: nextBasePaths[0]?.path || "."
|
||||||
|
}
|
||||||
|
} :
|
||||||
|
importedDraft;
|
||||||
|
setDraft(nextDraft);
|
||||||
|
markDirty();
|
||||||
|
setImportOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Recipient data</PageTitle>
|
<PageTitle loading={loading}>i18n:govoplan-campaign.recipient_data.c2baaf10</PageTitle>
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
<Card title="Recipient field values and attachments">
|
<Card title="i18n:govoplan-campaign.recipient_field_values_and_attachments.96e670ca" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
|
||||||
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.</p>}
|
{inlineEntries.length === 0 && !source.type && <p className="muted">i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224</p>}
|
||||||
{inlineEntries.length === 0 && Boolean(source.type) && (
|
{inlineEntries.length === 0 && Boolean(source.type) &&
|
||||||
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</DismissibleAlert>
|
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.0d1ae252</DismissibleAlert>
|
||||||
)}
|
}
|
||||||
{inlineEntries.length > 0 && (
|
{inlineEntries.length > 0 &&
|
||||||
|
<div className="admin-table-surface recipient-data-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-recipient-data`}
|
id={`campaign-${campaignId}-recipient-data`}
|
||||||
rows={inlineEntries.slice(0, 100)}
|
rows={inlineEntries}
|
||||||
columns={recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
|
columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
|
||||||
getRowKey={(entry, index) => String(entry.id || index)}
|
getRowKey={(entry, index) => String(entry.id || index)}
|
||||||
emptyText="No recipient data found."
|
emptyText="i18n:govoplan-campaign.no_recipient_data_found.a12be7d0"
|
||||||
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
|
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
|
||||||
/>
|
pagination={{
|
||||||
)}
|
page: recipientDataPage,
|
||||||
</Card>
|
pageSize: recipientDataPageSize,
|
||||||
|
pageSizeOptions: [10, 25, 50, 100, 250],
|
||||||
|
onPageChange: setRecipientDataPage,
|
||||||
|
onPageSizeChange: (pageSize) => {
|
||||||
|
setRecipientDataPageSize(pageSize);
|
||||||
|
setRecipientDataPage(1);
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
|
||||||
<div className="button-row page-bottom-actions">
|
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>
|
|
||||||
);
|
{importOpen &&
|
||||||
|
<RecipientImportDialog
|
||||||
|
settings={settings}
|
||||||
|
campaignId={campaignId}
|
||||||
|
existingEntries={inlineEntries}
|
||||||
|
existingFields={fieldDefinitions}
|
||||||
|
defaultAttachmentBasePath={defaultImportBasePath}
|
||||||
|
onCancel={() => setImportOpen(false)}
|
||||||
|
onImport={applyRecipientImport} />
|
||||||
|
|
||||||
|
}
|
||||||
|
</div>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecipientDataColumnContext = {
|
type RecipientDataColumnContext = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
|
draft: Record<string, unknown>;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
filesModuleInstalled: boolean;
|
||||||
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
||||||
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
||||||
zipConfig: AttachmentZipCollection;
|
zipConfig: AttachmentZipCollection;
|
||||||
@@ -125,35 +189,35 @@ type RecipientDataColumnContext = {
|
|||||||
updateEntryField: (index: number, field: string, value: unknown) => void;
|
updateEntryField: (index: number, field: string, value: unknown) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
||||||
{
|
{
|
||||||
id: "recipient",
|
id: "recipient",
|
||||||
header: "Recipient",
|
header: "i18n:govoplan-campaign.recipient.90343260",
|
||||||
width: 260,
|
width: 260,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
sticky: "start",
|
sticky: "start",
|
||||||
render: (entry) => (
|
render: (entry) =>
|
||||||
<Link className="recipient-data-identity" to="../recipients" title="Open recipient address profile">
|
<Link className="recipient-data-identity" to="../recipients" title="i18n:govoplan-campaign.open_recipient_address_profile.f7daa676">
|
||||||
<span className="recipient-data-address">{firstRecipientEmail(entry) || "No To address"}</span>
|
<span className="recipient-data-address">{firstRecipientEmail(entry) || "i18n:govoplan-campaign.no_to_address.683350f9"}</span>
|
||||||
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
|
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
|
||||||
</Link>
|
</Link>,
|
||||||
),
|
|
||||||
value: firstRecipientEmail
|
value: firstRecipientEmail
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "attachments",
|
id: "attachments",
|
||||||
header: "Attachments",
|
header: "i18n:govoplan-campaign.attachments.6771ade6",
|
||||||
width: 180,
|
width: 180,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
render: (entry, index) => {
|
render: (entry, index) => {
|
||||||
const attachments = normalizeAttachmentRules(entry.attachments);
|
const attachments = normalizeAttachmentRules(entry.attachments);
|
||||||
return (
|
return (
|
||||||
<AttachmentRulesOverlay
|
<AttachmentRulesOverlay
|
||||||
title={`Attachments for recipient ${index + 1}`}
|
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
|
||||||
rules={attachments}
|
rules={attachments}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
campaignId={campaignId}
|
||||||
@@ -161,9 +225,11 @@ function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions,
|
|||||||
buttonLabel={`entries: ${attachments.length}`}
|
buttonLabel={`entries: ${attachments.length}`}
|
||||||
basePaths={individualAttachmentBasePaths}
|
basePaths={individualAttachmentBasePaths}
|
||||||
zipConfig={zipConfig}
|
zipConfig={zipConfig}
|
||||||
onChange={(rules) => updateEntryAttachments(index, rules)}
|
filesModuleInstalled={filesModuleInstalled}
|
||||||
/>
|
previewContext={buildTemplatePreviewContext(draft, entry)}
|
||||||
);
|
onChange={(rules) => updateEntryAttachments(index, rules)} />);
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
|
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
|
||||||
},
|
},
|
||||||
@@ -183,14 +249,14 @@ function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions,
|
|||||||
fieldType={field.type}
|
fieldType={field.type}
|
||||||
value={fields[field.name]}
|
value={fields[field.name]}
|
||||||
disabled={locked || field.can_override === false}
|
disabled={locked || field.can_override === false}
|
||||||
placeholder={field.can_override === false ? "Uses global value" : undefined}
|
placeholder={field.can_override === false ? "i18n:govoplan-campaign.uses_global_value.98eae5e0" : undefined}
|
||||||
onChange={(value) => updateEntryField(index, field.name, value)}
|
onChange={(value) => updateEntryField(index, field.name, value)} />);
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
|
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
|
||||||
}))
|
}))];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstRecipientEmail(entry: Record<string, unknown>): string {
|
function firstRecipientEmail(entry: Record<string, unknown>): string {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,31 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns";
|
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns";
|
||||||
import Button from "../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import Card from "../../components/Card";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import FormField from "../../components/FormField";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import { DismissibleAlert, SegmentedControl, i18nMessage } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||||
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
|
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils/campaignView";
|
import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
import { cloneJson, getBool, getText } from "./utils/draftEditor";
|
import { cloneJson, getBool, getText } from "./utils/draftEditor";
|
||||||
import { humanizeFieldName } from "./utils/fieldDefinitions";
|
import { humanizeFieldName } from "./utils/fieldDefinitions";
|
||||||
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
|
import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft";
|
||||||
|
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, replacePlaceholderInText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
|
||||||
|
|
||||||
type BodyMode = "text" | "html";
|
type TemplateBodyMode = "text" | "html" | "both";
|
||||||
|
type BodyEditorMode = "text" | "html";
|
||||||
type EditorTarget = "subject" | "text" | "html";
|
type EditorTarget = "subject" | "text" | "html";
|
||||||
|
|
||||||
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const [bodyMode, setBodyMode] = useState<BodyMode>("text");
|
const [activeBodyEditor, setActiveBodyEditor] = useState<BodyEditorMode>("text");
|
||||||
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
|
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
const [previewIndex, setPreviewIndex] = useState(0);
|
const [previewIndex, setPreviewIndex] = useState(0);
|
||||||
@@ -45,12 +47,14 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
reload,
|
reload,
|
||||||
setError,
|
setError,
|
||||||
currentStep: "template",
|
currentStep: "template",
|
||||||
unsavedTitle: "Unsaved template changes",
|
unsavedTitle: "i18n:govoplan-campaign.unsaved_template_changes.4209cdec",
|
||||||
unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
|
unsavedMessage: "i18n:govoplan-campaign.the_template_has_unsaved_changes_save_them_befor.89f74fc1",
|
||||||
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "Loaded",
|
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a",
|
||||||
onLoaded: () => setPreviewIndex(0)
|
onLoaded: () => setPreviewIndex(0)
|
||||||
});
|
});
|
||||||
const template = asRecord(displayDraft.template);
|
const template = asRecord(displayDraft.template);
|
||||||
|
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
|
||||||
|
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
|
||||||
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
|
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
|
||||||
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
|
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
|
||||||
const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(displayDraft.global_values))]), [displayDraft.global_values, localFieldNames]);
|
const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(displayDraft.global_values))]), [displayDraft.global_values, localFieldNames]);
|
||||||
@@ -73,7 +77,11 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
const previewSelection = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? previewEntries[0];
|
const previewSelection = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? previewEntries[0];
|
||||||
const previewEntry = previewSelection.entry;
|
const previewEntry = previewSelection.entry;
|
||||||
const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false);
|
const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false);
|
||||||
const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
|
const templateText = [
|
||||||
|
getText(template, "subject"),
|
||||||
|
templateBodyMode !== "html" ? getText(template, "text") : "",
|
||||||
|
templateBodyMode !== "text" ? getText(template, "html") : ""].
|
||||||
|
join("\n");
|
||||||
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
|
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
|
||||||
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
|
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
|
||||||
const undefinedPlaceholders = useMemo(
|
const undefinedPlaceholders = useMemo(
|
||||||
@@ -90,8 +98,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
[attachmentPreviewRules, selectedPreviewEntryIndex]
|
[attachmentPreviewRules, selectedPreviewEntryIndex]
|
||||||
);
|
);
|
||||||
const previewAttachments = useMemo(
|
const previewAttachments = useMemo(
|
||||||
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError),
|
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError, displayDraft),
|
||||||
[attachmentPreviewError, attachmentPreviewLoading, previewAttachmentRules]
|
[attachmentPreviewError, attachmentPreviewLoading, displayDraft, previewAttachmentRules]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -99,6 +107,12 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
|
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
|
||||||
}, [previewIndex, previewEntries.length]);
|
}, [previewIndex, previewEntries.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (templateBodyMode === "text" && activeBodyEditor !== "text") setActiveBodyEditor("text");
|
||||||
|
if (templateBodyMode === "html" && activeBodyEditor !== "html") setActiveBodyEditor("html");
|
||||||
|
if (activeEditor !== "subject" && activeEditor !== visibleBodyEditor) setActiveEditor(visibleBodyEditor);
|
||||||
|
}, [activeBodyEditor, activeEditor, templateBodyMode, visibleBodyEditor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!previewOpen || !version?.id || !draft) return;
|
if (!previewOpen || !version?.id || !draft) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -107,18 +121,18 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
const handle = window.setTimeout(() => {
|
const handle = window.setTimeout(() => {
|
||||||
void previewCampaignAttachments(settings, campaignId, version.id, {
|
void previewCampaignAttachments(settings, campaignId, version.id, {
|
||||||
include_unmatched: false,
|
include_unmatched: false,
|
||||||
campaign_json: displayDraft
|
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
|
||||||
})
|
}).
|
||||||
.then((response) => {
|
then((response) => {
|
||||||
if (!cancelled) setAttachmentPreviewRules(response.rules);
|
if (!cancelled) setAttachmentPreviewRules(response.rules);
|
||||||
})
|
}).
|
||||||
.catch((reason: unknown) => {
|
catch((reason: unknown) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setAttachmentPreviewRules([]);
|
setAttachmentPreviewRules([]);
|
||||||
setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason));
|
setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason));
|
||||||
}
|
}
|
||||||
})
|
}).
|
||||||
.finally(() => {
|
finally(() => {
|
||||||
if (!cancelled) setAttachmentPreviewLoading(false);
|
if (!cancelled) setAttachmentPreviewLoading(false);
|
||||||
});
|
});
|
||||||
}, 120);
|
}, 120);
|
||||||
@@ -133,9 +147,18 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
patch(["template", target], value);
|
patch(["template", target], value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patchTemplateBodyMode(mode: TemplateBodyMode) {
|
||||||
|
if (locked) return;
|
||||||
|
patch(["template", "body_mode"], mode);
|
||||||
|
if (mode !== "both") {
|
||||||
|
setActiveBodyEditor(mode);
|
||||||
|
if (activeEditor !== "subject") setActiveEditor(mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
|
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
const target = bodyMode === "html" && activeEditor !== "subject" ? "html" : activeEditor;
|
const target = activeEditor === "subject" ? "subject" : visibleBodyEditor;
|
||||||
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
|
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
|
||||||
const token = `{{${namespace}:${name}}}`;
|
const token = `{{${namespace}:${name}}}`;
|
||||||
const currentText = getText(template, target);
|
const currentText = getText(template, target);
|
||||||
@@ -163,8 +186,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
type: "string",
|
type: "string",
|
||||||
required: false,
|
required: false,
|
||||||
can_override: true
|
can_override: true
|
||||||
}
|
}]
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
setUndefinedDialog(null);
|
setUndefinedDialog(null);
|
||||||
}
|
}
|
||||||
@@ -184,122 +207,162 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
setUndefinedDialog(null);
|
setUndefinedDialog(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replacePlaceholder(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) {
|
||||||
|
if (locked) return;
|
||||||
|
const replacement = `{{${namespace}:${name}}}`;
|
||||||
|
setDraft((current) => {
|
||||||
|
const next = cloneJson(current ?? {});
|
||||||
|
const nextTemplate = { ...asRecord(next.template) };
|
||||||
|
nextTemplate.subject = replacePlaceholderInText(getText(nextTemplate, "subject"), field.raw, replacement);
|
||||||
|
nextTemplate.text = replacePlaceholderInText(getText(nextTemplate, "text"), field.raw, replacement);
|
||||||
|
nextTemplate.html = replacePlaceholderInText(getText(nextTemplate, "html"), field.raw, replacement);
|
||||||
|
next.template = nextTemplate;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
markDirty();
|
||||||
|
setUndefinedDialog(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Template</PageTitle>
|
<PageTitle loading={loading}>i18n:govoplan-campaign.template.3ec1ae06</PageTitle>
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button disabled>Manage templates</Button>
|
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
<div className="dashboard-grid template-editor-grid">
|
<div className="dashboard-grid template-editor-grid">
|
||||||
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
|
<Card title="i18n:govoplan-campaign.editable_template.5e747a1e" actions={<Button onClick={() => setPreviewOpen(true)}>i18n:govoplan-campaign.preview.f1fbb2b4</Button>}>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<FormField label="Subject">
|
<FormField label="i18n:govoplan-campaign.subject.8d183dbd">
|
||||||
<input
|
<input
|
||||||
ref={subjectRef}
|
ref={subjectRef}
|
||||||
value={getText(template, "subject")}
|
value={getText(template, "subject")}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
onFocus={() => setActiveEditor("subject")}
|
onFocus={() => setActiveEditor("subject")}
|
||||||
onChange={(event) => patchTemplateText("subject", event.target.value)}
|
onChange={(event) => patchTemplateText("subject", event.target.value)} />
|
||||||
|
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-campaign.message_body_format.5fec42d2">
|
||||||
|
<SegmentedControl
|
||||||
|
className="template-body-mode"
|
||||||
|
size="content"
|
||||||
|
width="inline"
|
||||||
|
ariaLabel="i18n:govoplan-campaign.message_body_format.5fec42d2"
|
||||||
|
value={templateBodyMode}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={patchTemplateBodyMode}
|
||||||
|
options={[
|
||||||
|
{ id: "text", label: "i18n:govoplan-campaign.text_only.9ccbd022" },
|
||||||
|
{ id: "html", label: "i18n:govoplan-campaign.html_only.1c4fbcb1" },
|
||||||
|
{ id: "both", label: "i18n:govoplan-campaign.both.1f469838" }
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="template-body-mode" role="tablist" aria-label="Template body mode">
|
{templateBodyMode === "both" &&
|
||||||
<button type="button" className={bodyMode === "text" ? "active" : ""} onClick={() => { setBodyMode("text"); setActiveEditor("text"); }}>Plain text</button>
|
<SegmentedControl
|
||||||
<button type="button" className={bodyMode === "html" ? "active" : ""} onClick={() => { setBodyMode("html"); setActiveEditor("html"); }}>HTML</button>
|
className="template-body-mode template-editor-mode"
|
||||||
</div>
|
size="content"
|
||||||
{bodyMode === "text" && (
|
width="inline"
|
||||||
<FormField label="Plain text body">
|
ariaLabel="i18n:govoplan-campaign.body_editor.8615dd7e"
|
||||||
|
value={visibleBodyEditor}
|
||||||
|
onChange={(mode) => {setActiveBodyEditor(mode);setActiveEditor(mode);}}
|
||||||
|
options={[
|
||||||
|
{ id: "text", label: "i18n:govoplan-campaign.plain_text.9580fcbc" },
|
||||||
|
{ id: "html", label: "i18n:govoplan-campaign.html.9f738ce8" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{visibleBodyEditor === "text" &&
|
||||||
|
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textRef}
|
ref={textRef}
|
||||||
rows={16}
|
rows={16}
|
||||||
value={getText(template, "text")}
|
value={getText(template, "text")}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
onFocus={() => setActiveEditor("text")}
|
onFocus={() => {setActiveBodyEditor("text");setActiveEditor("text");}}
|
||||||
onChange={(event) => patchTemplateText("text", event.target.value)}
|
onChange={(event) => patchTemplateText("text", event.target.value)} />
|
||||||
/>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
}
|
||||||
{bodyMode === "html" && (
|
{visibleBodyEditor === "html" &&
|
||||||
<FormField label="HTML body">
|
<FormField label="i18n:govoplan-campaign.html_body.77b5ba37">
|
||||||
<textarea
|
<textarea
|
||||||
ref={htmlRef}
|
ref={htmlRef}
|
||||||
rows={16}
|
rows={16}
|
||||||
value={getText(template, "html")}
|
value={getText(template, "html")}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
onFocus={() => setActiveEditor("html")}
|
onFocus={() => {setActiveBodyEditor("html");setActiveEditor("html");}}
|
||||||
onChange={(event) => patchTemplateText("html", event.target.value)}
|
onChange={(event) => patchTemplateText("html", event.target.value)} />
|
||||||
/>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
}
|
||||||
<div className="button-row template-editor-actions">
|
<div className="button-row template-editor-actions">
|
||||||
<Button disabled>Load from library</Button>
|
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
|
||||||
<Button disabled>Save to library</Button>
|
<Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="template-side-stack">
|
<div className="template-side-stack">
|
||||||
<Card title="Fields">
|
<Card title="i18n:govoplan-campaign.fields.e8b68527">
|
||||||
{invalidNamespacePlaceholders.length > 0 && (
|
{invalidNamespacePlaceholders.length > 0 &&
|
||||||
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>Undefined placeholder namespace detected: {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
|
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282 {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
|
||||||
)}
|
}
|
||||||
{usedPlaceholders.length === 0 && <p className="muted">No template placeholders detected yet.</p>}
|
{usedPlaceholders.length === 0 && <p className="muted">i18n:govoplan-campaign.no_template_placeholders_detected_yet.56bba9d1</p>}
|
||||||
<p className="muted small-note">Click a field to insert it at the current cursor position as a namespaced placeholder.</p>
|
<p className="muted small-note">i18n:govoplan-campaign.click_a_field_to_insert_it_at_the_current_cursor.643aa7bc</p>
|
||||||
|
|
||||||
<h3 className="section-mini-heading">Global fields</h3>
|
<h3 className="section-mini-heading">i18n:govoplan-campaign.global_fields.07f84ea4</h3>
|
||||||
<TemplateFieldChipList
|
<TemplateFieldChipList
|
||||||
namespace="global"
|
namespace="global"
|
||||||
fields={globalFieldOptions}
|
fields={globalFieldOptions}
|
||||||
usedPlaceholders={usedPlaceholders}
|
usedPlaceholders={usedPlaceholders}
|
||||||
empty="No campaign fields or global values defined."
|
empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_defined.3888623d"
|
||||||
onInsert={insertPlaceholder}
|
onInsert={insertPlaceholder} />
|
||||||
/>
|
|
||||||
|
|
||||||
<h3 className="section-mini-heading">Local fields</h3>
|
|
||||||
<p className="muted small-note">Address fields use the effective merged or overridden headers. Use <code>to[2]</code> or <code>to[2].email</code> for a specific additional address.</p>
|
<h3 className="section-mini-heading">i18n:govoplan-campaign.local_fields.c81bb4de</h3>
|
||||||
|
<p className="muted small-note">i18n:govoplan-campaign.address_fields_use_the_effective_merged_or_overr.d6930da6 <code>i18n:govoplan-campaign.to_2.8c1db0c7</code> or <code>i18n:govoplan-campaign.to_2_email.5ad61c1a</code> i18n:govoplan-campaign.for_a_specific_additional_address.1199883f</p>
|
||||||
<TemplateFieldChipList
|
<TemplateFieldChipList
|
||||||
namespace="local"
|
namespace="local"
|
||||||
fields={localFieldOptions}
|
fields={localFieldOptions}
|
||||||
usedPlaceholders={usedPlaceholders}
|
usedPlaceholders={usedPlaceholders}
|
||||||
empty="No campaign fields defined."
|
empty="i18n:govoplan-campaign.no_campaign_fields_defined.8c2ea4b2"
|
||||||
onInsert={insertPlaceholder}
|
onInsert={insertPlaceholder} />
|
||||||
/>
|
|
||||||
|
|
||||||
<h3 className="section-mini-heading">Used in template, but undefined</h3>
|
|
||||||
|
<h3 className="section-mini-heading">i18n:govoplan-campaign.used_in_template_but_undefined.57b5f3da</h3>
|
||||||
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
|
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="button-row page-bottom-actions">
|
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
{previewOpen && (
|
{previewOpen &&
|
||||||
<MessagePreviewOverlay
|
<CampaignMessagePreviewOverlay
|
||||||
title="Template preview"
|
title="i18n:govoplan-campaign.template_preview.ea0aa8b2"
|
||||||
bodyMode={bodyMode}
|
bodyMode={visibleBodyEditor}
|
||||||
subject={previewSubject}
|
subject={previewSubject}
|
||||||
text={previewText}
|
text={templateBodyMode === "html" ? null : previewText}
|
||||||
html={previewHtml}
|
html={templateBodyMode === "text" ? null : previewHtml}
|
||||||
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "Global preview"}
|
metaItems={templatePreviewMetaItems(previewContext)}
|
||||||
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "No recipient preview is available." : "No inline recipients are available yet."}
|
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "i18n:govoplan-campaign.global_preview.f81f09b2"}
|
||||||
|
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "i18n:govoplan-campaign.no_recipient_preview_is_available.9e93a97d" : "i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb"}
|
||||||
attachments={previewAttachments}
|
attachments={previewAttachments}
|
||||||
navigation={{
|
navigation={{
|
||||||
index: Math.min(previewIndex, previewEntries.length - 1),
|
index: Math.min(previewIndex, previewEntries.length - 1),
|
||||||
@@ -309,63 +372,114 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)),
|
onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)),
|
||||||
onLast: () => setPreviewIndex(previewEntries.length - 1)
|
onLast: () => setPreviewIndex(previewEntries.length - 1)
|
||||||
}}
|
}}
|
||||||
onClose={() => setPreviewOpen(false)}
|
onClose={() => setPreviewOpen(false)} />
|
||||||
/>
|
|
||||||
)}
|
}
|
||||||
|
|
||||||
<UndefinedPlaceholderDecisionDialog
|
<UndefinedPlaceholderDecisionDialog
|
||||||
field={undefinedDialog}
|
field={undefinedDialog}
|
||||||
contextLabel="template"
|
contextLabel="template"
|
||||||
removeLabel="Remove from template"
|
removeLabel="i18n:govoplan-campaign.remove_from_template.5fb2827b"
|
||||||
onCancel={() => setUndefinedDialog(null)}
|
onCancel={() => setUndefinedDialog(null)}
|
||||||
onRemove={removePlaceholder}
|
onRemove={removePlaceholder}
|
||||||
|
onReplace={replacePlaceholder}
|
||||||
onAddField={addUndefinedField}
|
onAddField={addUndefinedField}
|
||||||
/>
|
localFields={localFieldOptions}
|
||||||
</div>
|
globalFields={globalFieldOptions} />
|
||||||
);
|
|
||||||
|
</div>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapResolvedAttachmentsToPreviewBoxes(
|
function mapResolvedAttachmentsToPreviewBoxes(
|
||||||
rules: CampaignAttachmentPreviewRule[],
|
rules: CampaignAttachmentPreviewRule[],
|
||||||
loading: boolean,
|
loading: boolean,
|
||||||
error: string
|
error: string,
|
||||||
): MessagePreviewAttachment[] {
|
draft: Record<string, unknown>)
|
||||||
|
: CampaignMessagePreviewAttachment[] {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }];
|
return [{
|
||||||
|
filename: i18nMessage("i18n:govoplan-campaign.resolving_attachment_patterns.87d7d21b"),
|
||||||
|
detail: i18nMessage("i18n:govoplan-campaign.managed_files_are_being_checked_for_this_recipie.489ecefd")
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return [{ filename: "Attachment preview unavailable", detail: error }];
|
return [{ filename: i18nMessage("i18n:govoplan-campaign.attachment_preview_unavailable.62211756"), detail: error }];
|
||||||
}
|
}
|
||||||
return rules.flatMap((rule) => {
|
return rules.flatMap((rule) => {
|
||||||
|
const zipProtection = zipProtectionForRule(rule, draft);
|
||||||
const detailParts = [
|
const detailParts = [
|
||||||
rule.source === "global" ? "Global" : "Recipient",
|
rule.source === "global" ? i18nMessage("i18n:govoplan-campaign.global.5f1184f7") : i18nMessage("i18n:govoplan-campaign.recipient.90343260"),
|
||||||
rule.label,
|
rule.label,
|
||||||
rule.required ? "required" : "optional",
|
rule.required ? i18nMessage("i18n:govoplan-campaign.required.eed6bfb4") : i18nMessage("i18n:govoplan-campaign.optional.0c6c4102"),
|
||||||
rule.pattern
|
rule.pattern].
|
||||||
].filter(Boolean);
|
filter(Boolean);
|
||||||
const detail = detailParts.join(" · ");
|
const detail = detailParts.join(" · ");
|
||||||
|
const fallbackArchiveLabel = i18nMessage("i18n:govoplan-campaign.recipient_attachments_zip.79d436c7");
|
||||||
if (rule.matches.length > 0) {
|
if (rule.matches.length > 0) {
|
||||||
return rule.matches.map((match) => ({
|
return rule.matches.map((match) => ({
|
||||||
filename: match.filename || match.display_path,
|
filename: match.filename || match.display_path,
|
||||||
label: rule.label,
|
label: rule.label,
|
||||||
detail: `${detail}${match.display_path ? ` · ${match.display_path}` : ""}`,
|
detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: match.display_path ? ` · ${match.display_path}` : "" }),
|
||||||
contentType: match.content_type,
|
contentType: match.content_type,
|
||||||
sizeBytes: match.size_bytes,
|
sizeBytes: match.size_bytes,
|
||||||
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
|
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
|
||||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null
|
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
|
||||||
|
protected: zipProtection.protected,
|
||||||
|
protectionNote: zipProtection.note
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
const pattern = rule.pattern || i18nMessage("i18n:govoplan-campaign.attachment_pattern.3540c952");
|
||||||
return [{
|
return [{
|
||||||
filename: `No file matched ${rule.pattern || "attachment pattern"}`,
|
filename: i18nMessage("i18n:govoplan-campaign.no_file_matched_value", { value0: pattern }),
|
||||||
label: rule.label,
|
label: rule.label,
|
||||||
detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`,
|
detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: rule.status !== "ok" ? ` · ${rule.status}` : "" }),
|
||||||
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
|
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
|
||||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null
|
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
|
||||||
|
protected: zipProtection.protected,
|
||||||
|
protectionNote: zipProtection.note
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function templatePreviewMetaItems(context: Record<string, string>) {
|
||||||
|
return [
|
||||||
|
{ label: "i18n:govoplan-campaign.from.3f66052a", value: context["local:from"] || null },
|
||||||
|
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: context["local:all_to"] || null },
|
||||||
|
{ label: "i18n:govoplan-campaign.reply_to.c1733667", value: context["local:all_reply_to"] || null },
|
||||||
|
{ label: "i18n:govoplan-campaign.cc.1fd6a880", value: context["local:all_cc"] || null },
|
||||||
|
{ label: "i18n:govoplan-campaign.bcc.8431acad", value: context["local:all_bcc"] || null }];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record<string, unknown>): {protected: boolean;note: string | null;} {
|
||||||
|
if (!rule.zip_included) return { protected: false, note: null };
|
||||||
|
const zipConfig = asRecord(asRecord(draft.attachments).zip);
|
||||||
|
const archives = asArray(zipConfig.archives).map(asRecord);
|
||||||
|
const archive = archives.find((item) => {
|
||||||
|
const id = getText(item, "id");
|
||||||
|
const name = getText(item, "name", getText(item, "filename_template"));
|
||||||
|
return rule.zip_archive_id && id === rule.zip_archive_id || rule.zip_filename && name === rule.zip_filename;
|
||||||
|
}) ?? archives.find((item) => getBool(item, "standard")) ?? archives[0];
|
||||||
|
if (!archive) return { protected: false, note: null };
|
||||||
|
const legacyMode = getText(archive, "password_mode");
|
||||||
|
const protectedArchive = getBool(archive, "password_enabled", ["direct", "field", "template"].includes(legacyMode));
|
||||||
|
if (!protectedArchive) return { protected: false, note: null };
|
||||||
|
const field = getText(archive, "password_field");
|
||||||
|
const scope = getText(archive, "password_scope") === "global" ? "global" : "local";
|
||||||
|
const method = getText(archive, "method", "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6";
|
||||||
|
const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : "";
|
||||||
|
return {
|
||||||
|
protected: true,
|
||||||
|
note: source ?
|
||||||
|
i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) :
|
||||||
|
i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeScope(scope: string): string {
|
||||||
|
return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d";
|
||||||
|
}
|
||||||
|
|
||||||
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||||
const name = valueToPreview(entry.name).trim();
|
const name = valueToPreview(entry.name).trim();
|
||||||
@@ -373,7 +487,12 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
|||||||
if (name && email) return `${name} <${email}>`;
|
if (name && email) return `${name} <${email}>`;
|
||||||
if (name) return name;
|
if (name) return name;
|
||||||
if (email) return email;
|
if (email) return email;
|
||||||
return `Recipient ${index + 1}`;
|
return i18nMessage("i18n:govoplan-campaign.recipient_value.d0233fb6", { value0: index + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateBodyMode(value: string): TemplateBodyMode {
|
||||||
|
if (value === "text" || value === "html" || value === "both") return value;
|
||||||
|
return "both";
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueSorted(values: string[]): string[] {
|
function uniqueSorted(values: string[]): string[] {
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import type { ApiSettings } from "../../../types";
|
import type { ApiSettings } from "../../../types";
|
||||||
import Button from "../../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
|
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
|
||||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
|
||||||
import ToggleSwitch from "../../../components/ToggleSwitch";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { getBool, getText } from "../utils/draftEditor";
|
import { getBool, getText } from "../utils/draftEditor";
|
||||||
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
|
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
|
||||||
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
|
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||||
import { insertAfter, moveArrayItem } from "../../../utils/arrayOrder";
|
|
||||||
import { asRecord } from "../utils/campaignView";
|
import { asRecord } from "../utils/campaignView";
|
||||||
|
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
|
||||||
|
|
||||||
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ type AttachmentRulesOverlayProps = {
|
|||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
basePaths?: AttachmentBasePath[];
|
basePaths?: AttachmentBasePath[];
|
||||||
zipConfig?: AttachmentZipCollection;
|
zipConfig?: AttachmentZipCollection;
|
||||||
|
filesModuleInstalled?: boolean;
|
||||||
|
previewContext?: Record<string, string>;
|
||||||
onChange: (rules: AttachmentRule[]) => void;
|
onChange: (rules: AttachmentRule[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,9 +37,10 @@ type AttachmentRulesTableProps = {
|
|||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
basePaths?: AttachmentBasePath[];
|
basePaths?: AttachmentBasePath[];
|
||||||
id?: string;
|
id?: string;
|
||||||
showAddButton?: boolean;
|
|
||||||
activeChooserRuleIndex?: number | null;
|
activeChooserRuleIndex?: number | null;
|
||||||
zipConfig?: AttachmentZipCollection;
|
zipConfig?: AttachmentZipCollection;
|
||||||
|
filesModuleInstalled?: boolean;
|
||||||
|
previewContext?: Record<string, string>;
|
||||||
onOpenFileChooser?: (ruleIndex: number) => void;
|
onOpenFileChooser?: (ruleIndex: number) => void;
|
||||||
onChange: (rules: AttachmentRule[]) => void;
|
onChange: (rules: AttachmentRule[]) => void;
|
||||||
};
|
};
|
||||||
@@ -52,9 +57,11 @@ export default function AttachmentRulesOverlay({
|
|||||||
campaignId,
|
campaignId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
buttonLabel,
|
buttonLabel,
|
||||||
emptyText = "No attachment files or matching rules configured yet.",
|
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
|
||||||
basePaths = [],
|
basePaths = [],
|
||||||
zipConfig = { enabled: false, archives: [] },
|
zipConfig = { enabled: false, archives: [] },
|
||||||
|
filesModuleInstalled = false,
|
||||||
|
previewContext,
|
||||||
onChange
|
onChange
|
||||||
}: AttachmentRulesOverlayProps) {
|
}: AttachmentRulesOverlayProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -78,14 +85,20 @@ export default function AttachmentRulesOverlay({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dialog = open ? createPortal(
|
const dialog = open ? createPortal(
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
|
<Dialog
|
||||||
<div className="modal-panel attachment-rules-modal">
|
open
|
||||||
<header className="modal-header">
|
title={title}
|
||||||
<h2 id="attachment-rules-title">{title}</h2>
|
className="attachment-rules-modal"
|
||||||
<button className="modal-close" aria-label="Cancel attachment changes" onClick={cancelOverlay}>×</button>
|
bodyClassName="attachment-rules-body"
|
||||||
</header>
|
onClose={cancelOverlay}
|
||||||
<div className="modal-body attachment-rules-body">
|
footer={
|
||||||
<AttachmentRulesDataGrid
|
<>
|
||||||
|
<Button onClick={cancelOverlay}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||||
|
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
|
||||||
|
<AttachmentRulesTable
|
||||||
rules={draftRules}
|
rules={draftRules}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
campaignId={campaignId}
|
||||||
@@ -93,27 +106,23 @@ export default function AttachmentRulesOverlay({
|
|||||||
emptyText={emptyText}
|
emptyText={emptyText}
|
||||||
basePaths={basePaths}
|
basePaths={basePaths}
|
||||||
zipConfig={zipConfig}
|
zipConfig={zipConfig}
|
||||||
|
filesModuleInstalled={filesModuleInstalled}
|
||||||
|
previewContext={previewContext}
|
||||||
activeChooserRuleIndex={null}
|
activeChooserRuleIndex={null}
|
||||||
onChange={setDraftRules}
|
onChange={setDraftRules} />
|
||||||
/>
|
|
||||||
</div>
|
</Dialog>,
|
||||||
<footer className="modal-footer">
|
|
||||||
<Button onClick={cancelOverlay}>Cancel</Button>
|
|
||||||
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
document.body
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={`${summary.direct} direct file(s), ${summary.rules} rule(s) / pattern(s)`}>
|
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={i18nMessage("i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9", { value0: summary.direct, value1: summary.rules })}>
|
||||||
{label}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
{dialog}
|
{dialog}
|
||||||
</>
|
</>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
|
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
|
||||||
@@ -124,29 +133,16 @@ function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AttachmentRulesTable({
|
export function AttachmentRulesTable({
|
||||||
showAddButton = true,
|
|
||||||
onChange,
|
onChange,
|
||||||
...tableProps
|
...tableProps
|
||||||
}: AttachmentRulesTableProps) {
|
}: AttachmentRulesTableProps) {
|
||||||
function addRule() {
|
|
||||||
onChange([
|
|
||||||
...tableProps.rules,
|
|
||||||
createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules), tableProps.basePaths?.[0]?.id ?? "")
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="attachment-rules-editor">
|
<div className="attachment-rules-editor">
|
||||||
<div className="attachment-rules-main">
|
<div className="attachment-rules-main">
|
||||||
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
|
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
|
||||||
{showAddButton && (
|
|
||||||
<div className="button-row compact-actions attachment-rules-footer-actions">
|
|
||||||
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled || (tableProps.basePaths?.length ?? 0) === 0}>Add file</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttachmentRulesDataGrid({
|
export function AttachmentRulesDataGrid({
|
||||||
@@ -154,15 +150,20 @@ export function AttachmentRulesDataGrid({
|
|||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
emptyText = "No attachment files or matching rules configured yet.",
|
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
|
||||||
basePaths = [],
|
basePaths = [],
|
||||||
id = "attachment-rules",
|
id = "attachment-rules",
|
||||||
activeChooserRuleIndex = null,
|
activeChooserRuleIndex = null,
|
||||||
zipConfig = { enabled: false, archives: [] },
|
zipConfig = { enabled: false, archives: [] },
|
||||||
|
filesModuleInstalled = false,
|
||||||
|
previewContext,
|
||||||
onOpenFileChooser,
|
onOpenFileChooser,
|
||||||
onChange
|
onChange
|
||||||
}: AttachmentRulesTableProps) {
|
}: AttachmentRulesTableProps) {
|
||||||
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
||||||
|
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||||
|
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
|
||||||
|
const managedFilesAvailable = Boolean(ManagedFileChooser);
|
||||||
|
|
||||||
function patchRule(index: number, patch: Partial<AttachmentRule>) {
|
function patchRule(index: number, patch: Partial<AttachmentRule>) {
|
||||||
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
||||||
@@ -186,6 +187,7 @@ export function AttachmentRulesDataGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openFileChooser(ruleIndex: number) {
|
function openFileChooser(ruleIndex: number) {
|
||||||
|
if (!managedFilesAvailable) return;
|
||||||
if (onOpenFileChooser) {
|
if (onOpenFileChooser) {
|
||||||
onOpenFileChooser(ruleIndex);
|
onOpenFileChooser(ruleIndex);
|
||||||
return;
|
return;
|
||||||
@@ -194,15 +196,15 @@ export function AttachmentRulesDataGrid({
|
|||||||
const currentPath = getText(rule, "base_dir");
|
const currentPath = getText(rule, "base_dir");
|
||||||
const currentBasePathId = getText(rule, "base_path_id");
|
const currentBasePathId = getText(rule, "base_path_id");
|
||||||
const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
|
const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
|
||||||
const basePath = basePaths.find((item) => item.id === currentBasePathId)
|
const basePath = basePaths.find((item) => item.id === currentBasePathId) ??
|
||||||
?? basePaths.find((item) => item.path === currentPath)
|
basePaths.find((item) => item.path === currentPath) ?? (
|
||||||
?? (!explicitlyReferenced ? basePaths[0] : undefined)
|
!explicitlyReferenced ? basePaths[0] : undefined) ??
|
||||||
?? null;
|
null;
|
||||||
if (!basePath) return;
|
if (!basePath) return;
|
||||||
setFileChooser({ ruleIndex, basePath });
|
setFileChooser({ ruleIndex, basePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAttachment(selection: ManagedAttachmentSelection) {
|
function selectAttachment(selection: FilesManagedAttachmentSelection) {
|
||||||
if (!fileChooser) return;
|
if (!fileChooser) return;
|
||||||
const currentRule = rules[fileChooser.ruleIndex] ?? {};
|
const currentRule = rules[fileChooser.ruleIndex] ?? {};
|
||||||
patchRule(fileChooser.ruleIndex, {
|
patchRule(fileChooser.ruleIndex, {
|
||||||
@@ -221,29 +223,32 @@ export function AttachmentRulesDataGrid({
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
id={id}
|
id={id}
|
||||||
rows={rules}
|
rows={rules}
|
||||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
||||||
getRowKey={(rule, index) => String(rule.id ?? index)}
|
getRowKey={(rule, index) => String(rule.id ?? index)}
|
||||||
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
|
emptyText={basePaths.length === 0 ? "i18n:govoplan-campaign.no_attachment_source_is_enabled_for_individual_a.818a2820" : emptyText}
|
||||||
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
|
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="i18n:govoplan-campaign.add_first_attachment.025fbf31" />}
|
||||||
className="attachment-rules-table-wrap attachment-rules-table"
|
className="attachment-rules-table-wrap attachment-rules-table"
|
||||||
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined} />
|
||||||
/>
|
|
||||||
{fileChooser && (
|
{fileChooser && ManagedFileChooser &&
|
||||||
<ManagedFileChooser
|
<ManagedFileChooser
|
||||||
open
|
open
|
||||||
settings={settings}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
storageScope={campaignId}
|
||||||
|
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
|
||||||
mode="attachment"
|
mode="attachment"
|
||||||
source={fileChooser.basePath?.source}
|
source={fileChooser.basePath?.source}
|
||||||
basePath={fileChooser.basePath?.path ?? "."}
|
basePath={fileChooser.basePath?.path ?? "."}
|
||||||
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
||||||
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
||||||
|
previewContext={previewContext}
|
||||||
|
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
|
||||||
onClose={() => setFileChooser(null)}
|
onClose={() => setFileChooser(null)}
|
||||||
onSelectAttachment={selectAttachment}
|
onSelectAttachment={selectAttachment} />
|
||||||
/>
|
|
||||||
)}
|
}
|
||||||
</>
|
</>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AttachmentRuleColumnContext = {
|
type AttachmentRuleColumnContext = {
|
||||||
@@ -251,6 +256,7 @@ type AttachmentRuleColumnContext = {
|
|||||||
rules: AttachmentRule[];
|
rules: AttachmentRule[];
|
||||||
basePaths: AttachmentBasePath[];
|
basePaths: AttachmentBasePath[];
|
||||||
zipConfig: AttachmentZipCollection;
|
zipConfig: AttachmentZipCollection;
|
||||||
|
filesModuleInstalled: boolean;
|
||||||
activeChooserRuleIndex: number | null;
|
activeChooserRuleIndex: number | null;
|
||||||
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
|
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
|
||||||
addRule: (afterIndex?: number) => void;
|
addRule: (afterIndex?: number) => void;
|
||||||
@@ -259,12 +265,12 @@ type AttachmentRuleColumnContext = {
|
|||||||
removeRule: (index: number) => void;
|
removeRule: (index: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
|
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
|
{ id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="i18n:govoplan-campaign.attachment_label.a340f70e" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
|
||||||
{
|
{
|
||||||
id: "base_path",
|
id: "base_path",
|
||||||
header: "Base path",
|
header: "i18n:govoplan-campaign.base_path.6a4867ca",
|
||||||
width: 250,
|
width: 250,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
@@ -274,9 +280,9 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeCh
|
|||||||
const currentBasePathValue = getText(rule, "base_dir");
|
const currentBasePathValue = getText(rule, "base_dir");
|
||||||
const currentBasePathId = getText(rule, "base_path_id");
|
const currentBasePathId = getText(rule, "base_path_id");
|
||||||
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
|
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
|
||||||
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId)
|
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ??
|
||||||
?? basePaths.find((basePath) => basePath.path === currentBasePathValue)
|
basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? (
|
||||||
?? (!explicitlyReferenced ? basePaths[0] : undefined);
|
!explicitlyReferenced ? basePaths[0] : undefined);
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
value={selectedBasePath?.id ?? ""}
|
value={selectedBasePath?.id ?? ""}
|
||||||
@@ -284,59 +290,94 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeCh
|
|||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
|
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
|
||||||
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
|
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
{!selectedBasePath && (
|
{!selectedBasePath &&
|
||||||
<option value="" disabled>{basePaths.length === 0 ? "No individual attachment source" : "Unavailable attachment source"}</option>
|
<option value="" disabled>{basePaths.length === 0 ? "i18n:govoplan-campaign.no_individual_attachment_source.6b54176a" : "i18n:govoplan-campaign.unavailable_attachment_source.7ff1096e"}</option>
|
||||||
)}
|
}
|
||||||
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
|
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
|
||||||
</select>
|
</select>);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
|
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "file_filter",
|
id: "file_filter",
|
||||||
header: "File / pattern",
|
header: "i18n:govoplan-campaign.file_pattern.86320b07",
|
||||||
width: "minmax(260px, 1fr)",
|
width: "minmax(260px, 1fr)",
|
||||||
resizable: true,
|
resizable: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
render: (rule, index) => (
|
render: (rule, index) =>
|
||||||
<div className="field-with-action split-field-action">
|
<div className="field-with-action split-field-action">
|
||||||
<input
|
<input
|
||||||
className="chooser-display-input"
|
className="chooser-display-input"
|
||||||
value={getText(rule, "file_filter")}
|
value={getText(rule, "file_filter")}
|
||||||
disabled={disabled || basePaths.length === 0}
|
disabled={disabled || basePaths.length === 0}
|
||||||
readOnly
|
readOnly={filesModuleInstalled}
|
||||||
tabIndex={-1}
|
tabIndex={filesModuleInstalled ? -1 : undefined}
|
||||||
placeholder="Choose a managed file or pattern"
|
placeholder={filesModuleInstalled ? "i18n:govoplan-campaign.choose_a_managed_file_or_pattern.96bb3bfb" : "file.pdf or **/*.pdf"}
|
||||||
onClick={() => !disabled && openFileChooser(index)}
|
onChange={(event) => {
|
||||||
|
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
|
||||||
|
}}
|
||||||
|
onClick={() => filesModuleInstalled && !disabled && openFileChooser(index)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (!disabled && (event.key === "Enter" || event.key === " ")) {
|
if (filesModuleInstalled && !disabled && (event.key === "Enter" || event.key === " ")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openFileChooser(index);
|
openFileChooser(index);
|
||||||
}
|
}
|
||||||
}}
|
}} />
|
||||||
/>
|
|
||||||
<Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>
|
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>i18n:govoplan-campaign.choose.78b7c9f6</Button>}
|
||||||
</div>
|
</div>,
|
||||||
),
|
|
||||||
value: (rule) => getText(rule, "file_filter")
|
value: (rule) => getText(rule, "file_filter")
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "message_filename_template",
|
||||||
|
header: "i18n:govoplan-campaign.message_filename_template.0b7ac934",
|
||||||
|
width: 230,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
render: (rule, index) =>
|
||||||
|
<input
|
||||||
|
value={getText(rule, "message_filename_template")}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="i18n:govoplan-campaign.keep_original_filename.9b805045"
|
||||||
|
onChange={(event) => patchRule(index, { message_filename_template: event.target.value })} />,
|
||||||
|
|
||||||
|
value: (rule) => getText(rule, "message_filename_template")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zip_entry_name_template",
|
||||||
|
header: "i18n:govoplan-campaign.zip_entry_name_template.83772a73",
|
||||||
|
width: 230,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
render: (rule, index) =>
|
||||||
|
<input
|
||||||
|
value={getText(rule, "zip_entry_name_template")}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4"
|
||||||
|
onChange={(event) => patchRule(index, { zip_entry_name_template: event.target.value })} />,
|
||||||
|
|
||||||
|
value: (rule) => getText(rule, "zip_entry_name_template")
|
||||||
|
},
|
||||||
...(zipConfig.enabled ? [{
|
...(zipConfig.enabled ? [{
|
||||||
id: "zip",
|
id: "zip",
|
||||||
header: "ZIP archive",
|
header: "i18n:govoplan-campaign.zip_archive.5a2430dd",
|
||||||
width: 240,
|
width: 240,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
columnType: "from-list",
|
columnType: "from-list",
|
||||||
list: {
|
list: {
|
||||||
options: [
|
options: [
|
||||||
{ value: "exclude", label: "Exclude from ZIP" },
|
{ value: "exclude", label: "i18n:govoplan-campaign.exclude_from_zip.a6422da1" },
|
||||||
{ value: "inherit", label: "Campaign standard" },
|
{ value: "inherit", label: "i18n:govoplan-campaign.campaign_standard.c88ce44c" },
|
||||||
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))
|
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))]
|
||||||
]
|
|
||||||
},
|
},
|
||||||
render: (rule: AttachmentRule, index: number) => {
|
render: (rule: AttachmentRule, index: number) => {
|
||||||
const selection = attachmentRuleZipSelection(rule);
|
const selection = attachmentRuleZipSelection(rule);
|
||||||
@@ -344,36 +385,36 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeCh
|
|||||||
<select
|
<select
|
||||||
value={selection}
|
value={selection}
|
||||||
disabled={disabled || zipConfig.archives.length === 0}
|
disabled={disabled || zipConfig.archives.length === 0}
|
||||||
aria-label={`ZIP archive for ${getText(rule, "label") || `attachment ${index + 1}`}`}
|
aria-label={i18nMessage("i18n:govoplan-campaign.zip_archive_for_value.3dfaf812", { value0: getText(rule, "label") || `attachment ${index + 1}` })}
|
||||||
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}
|
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}>
|
||||||
>
|
|
||||||
<option value="exclude">Exclude from ZIP</option>
|
<option value="exclude">i18n:govoplan-campaign.exclude_from_zip.a6422da1</option>
|
||||||
<option value="inherit">Campaign standard</option>
|
<option value="inherit">i18n:govoplan-campaign.campaign_standard.c88ce44c</option>
|
||||||
{zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)}
|
{zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)}
|
||||||
</select>
|
</select>);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
|
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
|
||||||
} satisfies DataGridColumn<AttachmentRule>] : []),
|
} satisfies DataGridColumn<AttachmentRule>] : []),
|
||||||
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
|
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (rule, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 180,
|
width: 180,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (_rule, index) => (
|
render: (_rule, index) =>
|
||||||
<DataGridRowActions
|
<DataGridRowActions
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onAddBelow={() => addRule(index)}
|
onAddBelow={() => addRule(index)}
|
||||||
onRemove={() => removeRule(index)}
|
onRemove={() => removeRule(index)}
|
||||||
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
|
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
|
||||||
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
|
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
|
||||||
addLabel="Add attachment below"
|
addLabel="i18n:govoplan-campaign.add_attachment_below.c7b66ffd"
|
||||||
removeLabel="Remove attachment"
|
removeLabel="i18n:govoplan-campaign.remove_attachment.0fcc6594"
|
||||||
moveUpLabel="Move attachment up"
|
moveUpLabel="i18n:govoplan-campaign.move_attachment_up.28614804"
|
||||||
moveDownLabel="Move attachment down"
|
moveDownLabel="i18n:govoplan-campaign.move_attachment_down.a4d6604f" />
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,38 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ApiSettings, CampaignListItem } from "../../../types";
|
import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types";
|
||||||
|
import { KeyRound } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
fetchResourceAccessExplanation,
|
||||||
getCampaignShares,
|
getCampaignShares,
|
||||||
getCampaignShareTargets,
|
getCampaignShareTargets,
|
||||||
revokeCampaignShare,
|
revokeCampaignShare,
|
||||||
updateCampaignOwner,
|
updateCampaignOwner,
|
||||||
upsertCampaignShare,
|
upsertCampaignShare,
|
||||||
type CampaignShare,
|
type CampaignShare,
|
||||||
type CampaignShareTargets
|
type CampaignShareTargets,
|
||||||
} from "../../../api/campaigns";
|
type ResourceAccessExplanationResponse } from
|
||||||
import Button from "../../../components/Button";
|
"../../../api/campaigns";
|
||||||
import Card from "../../../components/Card";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import ConfirmDialog from "../../../components/ConfirmDialog";
|
import { Card } from "@govoplan/core-webui";
|
||||||
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
||||||
import Dialog from "../../../components/Dialog";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import FormField from "../../../components/FormField";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import StatusBadge from "../../../components/StatusBadge";
|
import { StatusBadge, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
|
|
||||||
type TargetType = "user" | "group";
|
type TargetType = "user" | "group";
|
||||||
|
|
||||||
export default function CampaignAccessCard({
|
export default function CampaignAccessCard({
|
||||||
settings,
|
settings,
|
||||||
|
auth,
|
||||||
campaign,
|
campaign,
|
||||||
onChanged,
|
onChanged,
|
||||||
onError
|
onError
|
||||||
}: {
|
|
||||||
settings: ApiSettings;
|
|
||||||
campaign: CampaignListItem;
|
|
||||||
onChanged: () => Promise<void>;
|
|
||||||
onError: (message: string) => void;
|
|
||||||
}) {
|
}: {settings: ApiSettings;auth: AuthInfo;campaign: CampaignListItem;onChanged: () => Promise<void>;onError: (message: string) => void;}) {
|
||||||
const [available, setAvailable] = useState<boolean | null>(null);
|
const [available, setAvailable] = useState<boolean | null>(null);
|
||||||
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
|
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
|
||||||
const [shares, setShares] = useState<CampaignShare[]>([]);
|
const [shares, setShares] = useState<CampaignShare[]>([]);
|
||||||
@@ -41,21 +44,42 @@ export default function CampaignAccessCard({
|
|||||||
const [shareType, setShareType] = useState<TargetType>("user");
|
const [shareType, setShareType] = useState<TargetType>("user");
|
||||||
const [shareTargetId, setShareTargetId] = useState("");
|
const [shareTargetId, setShareTargetId] = useState("");
|
||||||
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
|
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
|
||||||
|
const [accessExplanationOpen, setAccessExplanationOpen] = useState(false);
|
||||||
|
const [accessExplanation, setAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
|
||||||
|
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const currentOwnerType: TargetType = campaign.owner_group_id ? "group" : "user";
|
||||||
|
const currentOwnerId = campaign.owner_group_id || campaign.owner_user_id || "";
|
||||||
|
const ownerDirty = ownerOpen && (ownerType !== currentOwnerType || ownerId !== currentOwnerId);
|
||||||
|
const shareDirty = shareOpen && (shareType !== "user" || Boolean(shareTargetId) || sharePermission !== "read");
|
||||||
|
const canExplainResourceAccess =
|
||||||
|
hasScope(auth, "admin:users:read") ||
|
||||||
|
hasScope(auth, "admin:roles:read") ||
|
||||||
|
hasScope(auth, "access:membership:read") ||
|
||||||
|
hasScope(auth, "access:role:read");
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: ownerDirty || shareDirty,
|
||||||
|
onSave: saveActiveDialog,
|
||||||
|
onDiscard: () => {
|
||||||
|
closeOwnerDialog();
|
||||||
|
closeShareDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [nextTargets, nextShares] = await Promise.all([
|
const [nextTargets, nextShares] = await Promise.all([
|
||||||
getCampaignShareTargets(settings, campaign.id),
|
getCampaignShareTargets(settings, campaign.id),
|
||||||
getCampaignShares(settings, campaign.id)
|
getCampaignShares(settings, campaign.id)]
|
||||||
]);
|
);
|
||||||
setTargets(nextTargets);
|
setTargets(nextTargets);
|
||||||
setShares(nextShares.filter((item) => !item.revoked_at));
|
setShares(nextShares.filter((item) => !item.revoked_at));
|
||||||
setAvailable(true);
|
setAvailable(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.startsWith("403 ")) setAvailable(false);
|
if (message.startsWith("403 ")) setAvailable(false);else
|
||||||
else onError(message);
|
onError(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,37 +93,58 @@ export default function CampaignAccessCard({
|
|||||||
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
|
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
|
||||||
const targetMap = useMemo(() => new Map([
|
const targetMap = useMemo(() => new Map([
|
||||||
...targets.users.map((item) => [`user:${item.id}`, item] as const),
|
...targets.users.map((item) => [`user:${item.id}`, item] as const),
|
||||||
...targets.groups.map((item) => [`group:${item.id}`, item] as const)
|
...targets.groups.map((item) => [`group:${item.id}`, item] as const)]
|
||||||
]), [targets]);
|
), [targets]);
|
||||||
|
|
||||||
const ownerLabel = campaign.owner_group_id
|
const ownerLabel = campaign.owner_group_id ?
|
||||||
? targetMap.get(`group:${campaign.owner_group_id}`)?.name || "Group owner"
|
targetMap.get(`group:${campaign.owner_group_id}`)?.name || "i18n:govoplan-campaign.group_owner.55630670" :
|
||||||
: targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "User owner";
|
targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "i18n:govoplan-campaign.user_owner.86e3faab";
|
||||||
|
|
||||||
async function saveOwner() {
|
function closeOwnerDialog() {
|
||||||
if (!ownerId) return;
|
setOwnerOpen(false);
|
||||||
|
setOwnerType(currentOwnerType);
|
||||||
|
setOwnerId(currentOwnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeShareDialog() {
|
||||||
|
setShareOpen(false);
|
||||||
|
setShareType("user");
|
||||||
|
setShareTargetId("");
|
||||||
|
setSharePermission("read");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveActiveDialog(): Promise<boolean> {
|
||||||
|
if (ownerDirty) return saveOwner();
|
||||||
|
if (shareDirty) return saveShare();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOwner(): Promise<boolean> {
|
||||||
|
if (!ownerId) return false;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await updateCampaignOwner(settings, campaign.id, ownerType === "user"
|
await updateCampaignOwner(settings, campaign.id, ownerType === "user" ?
|
||||||
? { owner_user_id: ownerId, owner_group_id: null }
|
{ owner_user_id: ownerId, owner_group_id: null } :
|
||||||
: { owner_user_id: null, owner_group_id: ownerId });
|
{ owner_user_id: null, owner_group_id: ownerId });
|
||||||
setOwnerOpen(false);
|
setOwnerOpen(false);
|
||||||
await onChanged();
|
await onChanged();
|
||||||
await load();
|
await load();
|
||||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
return true;
|
||||||
finally { setBusy(false); }
|
} catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
|
||||||
|
{setBusy(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveShare() {
|
async function saveShare(): Promise<boolean> {
|
||||||
if (!shareTargetId) return;
|
if (!shareTargetId) return false;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await upsertCampaignShare(settings, campaign.id, { target_type: shareType, target_id: shareTargetId, permission: sharePermission });
|
await upsertCampaignShare(settings, campaign.id, { target_type: shareType, target_id: shareTargetId, permission: sharePermission });
|
||||||
setShareOpen(false);
|
setShareOpen(false);
|
||||||
setShareTargetId("");
|
setShareTargetId("");
|
||||||
await load();
|
await load();
|
||||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
return true;
|
||||||
finally { setBusy(false); }
|
} catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
|
||||||
|
{setBusy(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revoke() {
|
async function revoke() {
|
||||||
@@ -109,14 +154,35 @@ export default function CampaignAccessCard({
|
|||||||
await revokeCampaignShare(settings, campaign.id, revokeTarget.id);
|
await revokeCampaignShare(settings, campaign.id, revokeTarget.id);
|
||||||
setRevokeTarget(null);
|
setRevokeTarget(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
} catch (err) {onError(err instanceof Error ? err.message : String(err));} finally
|
||||||
finally { setBusy(false); }
|
{setBusy(false);}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAccessExplanation() {
|
||||||
|
if (!auth.user?.id) return;
|
||||||
|
setAccessExplanationOpen(true);
|
||||||
|
setAccessExplanation(null);
|
||||||
|
setAccessExplanationLoading(true);
|
||||||
|
try {
|
||||||
|
setAccessExplanation(await fetchResourceAccessExplanation(settings, {
|
||||||
|
userId: auth.user.id,
|
||||||
|
resourceType: "campaign",
|
||||||
|
resourceId: campaign.id,
|
||||||
|
action: "campaigns:campaign:read",
|
||||||
|
tenantId: (auth.active_tenant ?? auth.tenant)?.id
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof Error ? err.message : String(err));
|
||||||
|
setAccessExplanationOpen(false);
|
||||||
|
} finally {
|
||||||
|
setAccessExplanationLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
|
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
|
||||||
{
|
{
|
||||||
id: "target",
|
id: "target",
|
||||||
header: "Shared with",
|
header: "i18n:govoplan-campaign.shared_with.6203f449",
|
||||||
width: "minmax(220px, 1fr)",
|
width: "minmax(220px, 1fr)",
|
||||||
minWidth: 190,
|
minWidth: 190,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
@@ -126,35 +192,105 @@ export default function CampaignAccessCard({
|
|||||||
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
|
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
|
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
|
||||||
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? ` · ${target.secondary}` : ""}</div></div>;
|
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.secondary }) : ""}</div></div>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ id: "permission", header: "Access", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "Can edit" : "Can view"} /> },
|
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
|
||||||
{ id: "actions", header: "Actions", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>Remove</Button> }
|
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>i18n:govoplan-campaign.remove.e963907d</Button> }],
|
||||||
], [targetMap]);
|
[targetMap]);
|
||||||
|
|
||||||
if (available === false) return null;
|
if (available === false) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card title="Ownership and sharing" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>Change owner</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>Share</Button></div>}>
|
<Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.change_owner.d3ce16a8</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button><Button onClick={() => void openAccessExplanation()} disabled={available !== true || !canExplainResourceAccess}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-campaign.explain_access.4d5fac37</Button></div>}>
|
||||||
<p><strong>Owner:</strong> {ownerLabel}</p>
|
<p><strong>i18n:govoplan-campaign.owner.719379ae</strong> {ownerLabel}</p>
|
||||||
<p className="muted small-note">Permissions define what a role may do; ownership and explicit shares determine which campaigns that permission applies to.</p>
|
<p className="muted small-note">i18n:govoplan-campaign.permissions_define_what_a_role_may_do_ownership_.9f8baaa2</p>
|
||||||
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "Loading campaign access…" : "This campaign has no explicit shares."} />
|
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "i18n:govoplan-campaign.loading_campaign_access.056299e3" : "i18n:govoplan-campaign.this_campaign_has_no_explicit_shares.978012d1"} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={ownerOpen} title="Change campaign owner" onClose={() => !busy && setOwnerOpen(false)} footer={<><Button onClick={() => setOwnerOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>Save owner</Button></>}>
|
<Dialog open={ownerOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.change_campaign_owner.63f80aef" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>i18n:govoplan-campaign.save_owner.b6763847</Button></>}>
|
||||||
<FormField label="Owner type"><select value={ownerType} onChange={(event) => { const next = event.target.value as TargetType; setOwnerType(next); setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
|
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
|
||||||
<FormField label="Owner"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
|
<FormField label="i18n:govoplan-campaign.owner.89ff3122"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
|
||||||
|
<p className="muted small-note">Changing the owner clears a selected reusable mail profile from the editable current version and requires profile reselection plus validation before live delivery.</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={shareOpen} title="Share campaign" onClose={() => !busy && setShareOpen(false)} footer={<><Button onClick={() => setShareOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>Save share</Button></>}>
|
<Dialog open={shareOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.share_campaign.b605982b" onClose={() => !busy && closeShareDialog()} footer={<><Button onClick={closeShareDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>i18n:govoplan-campaign.save_share.bcf6ed94</Button></>}>
|
||||||
<FormField label="Target type"><select value={shareType} onChange={(event) => { const next = event.target.value as TargetType; setShareType(next); setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
|
<FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
|
||||||
<FormField label="User or group"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">Select…</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
|
<FormField label="i18n:govoplan-campaign.user_or_group.53406ef0"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">i18n:govoplan-campaign.select.349ac8fb</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
|
||||||
<FormField label="Access"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">Can view</option><option value="write">Can edit and operate</option></select></FormField>
|
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(revokeTarget)} title="Remove campaign share" message="Remove this user's or group's explicit access to the campaign? Ownership and other group shares still apply." confirmLabel="Remove share" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
|
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||||
</>
|
<ResourceAccessExplanationContent loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} />
|
||||||
);
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceAccessExplanationContent({
|
||||||
|
loading,
|
||||||
|
explanation,
|
||||||
|
fallbackResourceLabel
|
||||||
|
}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) {
|
||||||
|
if (loading) return <p className="muted small-note">i18n:govoplan-campaign.loading_access_explanation.04a7c934</p>;
|
||||||
|
if (!explanation) return null;
|
||||||
|
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
|
||||||
|
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-grid compact responsive-form-grid">
|
||||||
|
<div><span className="form-label">i18n:govoplan-campaign.user.9f8a2389</span><p>{userLabel}</p></div>
|
||||||
|
<div><span className="form-label">i18n:govoplan-campaign.resource.d1c626a9</span><p>{resourceLabel}</p></div>
|
||||||
|
<div><span className="form-label">i18n:govoplan-campaign.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
|
||||||
|
<div><span className="form-label">i18n:govoplan-campaign.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
|
||||||
|
</div>
|
||||||
|
{explanation.provenance.length === 0 ?
|
||||||
|
<p className="muted small-note">i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e</p> :
|
||||||
|
<div className="admin-assignment-grid">
|
||||||
|
{explanation.provenance.map((item, index) =>
|
||||||
|
<div key={`${item.kind}:${item.id ?? index}`}>
|
||||||
|
<strong>{provenanceKindLabel(item.kind)}</strong>
|
||||||
|
<div className="muted small-note">
|
||||||
|
{item.source || "i18n:govoplan-campaign.no_source.6dcf9723"}
|
||||||
|
{item.id && <> · <code>{item.id}</code></>}
|
||||||
|
</div>
|
||||||
|
{item.label && <p>{item.label}</p>}
|
||||||
|
{Object.keys(item.details ?? {}).length > 0 && <p className="muted small-note">{formatProvenanceDetails(item.details ?? {})}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function provenanceKindLabel(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case "resource":
|
||||||
|
return "i18n:govoplan-campaign.resource.d1c626a9";
|
||||||
|
case "owner":
|
||||||
|
return "i18n:govoplan-campaign.owner.89ff3122";
|
||||||
|
case "share":
|
||||||
|
return "i18n:govoplan-campaign.share.09ca55ca";
|
||||||
|
case "policy":
|
||||||
|
return "i18n:govoplan-campaign.policy.0b779a05";
|
||||||
|
case "role":
|
||||||
|
return "i18n:govoplan-campaign.role.b5b4a5a2";
|
||||||
|
case "right":
|
||||||
|
return "i18n:govoplan-campaign.permission.2f81a22d";
|
||||||
|
default:
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProvenanceDetails(details: Record<string, unknown>): string {
|
||||||
|
return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProvenanceValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "i18n:govoplan-campaign.none.6eef6648";
|
||||||
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ApiSettings } from "../../../types";
|
import type { ApiSettings } from "../../../types";
|
||||||
import Button from "../../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import ConfirmDialog from "../../../components/ConfirmDialog";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../../components/DismissibleAlert";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
forkCampaignVersion,
|
forkCampaignVersion,
|
||||||
lockCampaignVersionPermanently,
|
lockCampaignVersionPermanently,
|
||||||
unlockCampaignVersionUserLock,
|
unlockCampaignVersionUserLock,
|
||||||
unlockCampaignVersionValidation,
|
unlockCampaignVersionValidation,
|
||||||
type CampaignVersionDetail,
|
type CampaignVersionDetail,
|
||||||
type CampaignVersionListItem,
|
type CampaignVersionListItem } from
|
||||||
} from "../../../api/campaigns";
|
"../../../api/campaigns";
|
||||||
import {
|
import {
|
||||||
canUnlockValidationVersion,
|
canUnlockValidationVersion,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
isFinalLockedVersion,
|
isFinalLockedVersion,
|
||||||
isHistoricalCampaignVersion,
|
isHistoricalCampaignVersion,
|
||||||
isPermanentUserLockedVersion,
|
isPermanentUserLockedVersion,
|
||||||
isTemporaryUserLockedVersion,
|
isTemporaryUserLockedVersion } from
|
||||||
} from "../utils/campaignView";
|
"../utils/campaignView";
|
||||||
|
|
||||||
type LockedVersionNoticeProps = {
|
type LockedVersionNoticeProps = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
@@ -48,7 +48,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
|||||||
validationLock,
|
validationLock,
|
||||||
temporaryUserLock,
|
temporaryUserLock,
|
||||||
permanentUserLock,
|
permanentUserLock,
|
||||||
finalLock,
|
finalLock
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runAction(action: Exclude<ConfirmAction, null>) {
|
async function runAction(action: Exclude<ConfirmAction, null>) {
|
||||||
@@ -59,13 +59,13 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
|||||||
try {
|
try {
|
||||||
if (action === "unlock-validation") {
|
if (action === "unlock-validation") {
|
||||||
await unlockCampaignVersionValidation(settings, campaignId, version.id);
|
await unlockCampaignVersionValidation(settings, campaignId, version.id);
|
||||||
setLocalMessage("Validation lock removed. Validation, build and generated queue state were invalidated.");
|
setLocalMessage("i18n:govoplan-campaign.validation_lock_removed_validation_build_and_gen.ea26975f");
|
||||||
} else if (action === "unlock-user") {
|
} else if (action === "unlock-user") {
|
||||||
await unlockCampaignVersionUserLock(settings, campaignId, version.id);
|
await unlockCampaignVersionUserLock(settings, campaignId, version.id);
|
||||||
setLocalMessage("Temporary user lock removed. The version is editable again.");
|
setLocalMessage("i18n:govoplan-campaign.temporary_user_lock_removed_the_version_is_edita.0ca4c587");
|
||||||
} else {
|
} else {
|
||||||
await lockCampaignVersionPermanently(settings, campaignId, version.id);
|
await lockCampaignVersionPermanently(settings, campaignId, version.id);
|
||||||
setLocalMessage("Version permanently locked. Future changes require an editable copy.");
|
setLocalMessage("i18n:govoplan-campaign.version_permanently_locked_future_changes_requir.fd718a84");
|
||||||
}
|
}
|
||||||
await reload();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -83,7 +83,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
|||||||
try {
|
try {
|
||||||
const result = await forkCampaignVersion(settings, campaignId, version.id, {
|
const result = await forkCampaignVersion(settings, campaignId, version.id, {
|
||||||
current_flow: "manual",
|
current_flow: "manual",
|
||||||
current_step: version.current_step ?? null,
|
current_step: version.current_step ?? null
|
||||||
});
|
});
|
||||||
setLocalMessage(`Created editable version #${result.version.version_number}.`);
|
setLocalMessage(`Created editable version #${result.version.version_number}.`);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -105,26 +105,26 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
|||||||
{localError && <span className="locked-version-error"> {localError}</span>}
|
{localError && <span className="locked-version-error"> {localError}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions locked-version-actions">
|
<div className="button-row compact-actions locked-version-actions">
|
||||||
{validationLock && (
|
{validationLock &&
|
||||||
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
|
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
|
||||||
{busy ? "Working…" : "Unlock validation"}
|
{busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock_validation.f01952b6"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
}
|
||||||
{temporaryUserLock && (
|
{temporaryUserLock &&
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
|
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
|
||||||
{busy ? "Working…" : "Unlock"}
|
{busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock.1526a17e"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
|
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
|
||||||
Lock permanently
|
i18n:govoplan-campaign.lock_permanently.cc0ce9e7
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
{canCreateEditableCopy && (
|
{canCreateEditableCopy &&
|
||||||
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
|
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
|
||||||
{busy ? "Creating copy…" : "Create editable copy"}
|
{busy ? "i18n:govoplan-campaign.creating_copy.4247f587" : "i18n:govoplan-campaign.create_editable_copy.92d6d91b"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -139,10 +139,10 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
|||||||
const action = confirmAction;
|
const action = confirmAction;
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
if (action) void runAction(action);
|
if (action) void runAction(action);
|
||||||
}}
|
}} />
|
||||||
/>
|
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LockFlags = {
|
type LockFlags = {
|
||||||
@@ -154,82 +154,82 @@ type LockFlags = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function lockPresentation(
|
function lockPresentation(
|
||||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||||
flags: LockFlags,
|
flags: LockFlags)
|
||||||
): { kind: string; title: string; description: string; info: string } {
|
: {kind: string;title: string;description: string;info: string;} {
|
||||||
if (flags.historicalVersion) {
|
if (flags.historicalVersion) {
|
||||||
return {
|
return {
|
||||||
kind: "historical",
|
kind: "historical",
|
||||||
title: "Historical version.",
|
title: "i18n:govoplan-campaign.historical_version.adac5dba",
|
||||||
description: "This version is review-only. Only the campaign's current working version may be edited in place.",
|
description: "i18n:govoplan-campaign.this_version_is_review_only_only_the_campaign_s_.9e68f6a0",
|
||||||
info: `Version #${version?.version_number ?? "—"}.`,
|
info: `Version #${version?.version_number ?? "—"}.`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (flags.temporaryUserLock) {
|
if (flags.temporaryUserLock) {
|
||||||
return {
|
return {
|
||||||
kind: "temporary-user",
|
kind: "temporary-user",
|
||||||
title: "Temporarily locked version.",
|
title: "i18n:govoplan-campaign.temporarily_locked_version.696f85e3",
|
||||||
description: "Campaign data is read-only, but an authorized user may unlock it or make the lock permanent.",
|
description: "i18n:govoplan-campaign.campaign_data_is_read_only_but_an_authorized_use.f2e513b1",
|
||||||
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : "",
|
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (flags.permanentUserLock) {
|
if (flags.permanentUserLock) {
|
||||||
return {
|
return {
|
||||||
kind: "permanent-user",
|
kind: "permanent-user",
|
||||||
title: "Permanently locked version.",
|
title: "i18n:govoplan-campaign.permanently_locked_version.248c2eeb",
|
||||||
description: "This user-requested snapshot cannot be unlocked. Create an editable copy for future changes.",
|
description: "i18n:govoplan-campaign.this_user_requested_snapshot_cannot_be_unlocked_.9e91210c",
|
||||||
info: version?.user_locked_at || version?.published_at
|
info: version?.user_locked_at || version?.published_at ?
|
||||||
? `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.`
|
`Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.` :
|
||||||
: "",
|
""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (flags.validationLock) {
|
if (flags.validationLock) {
|
||||||
return {
|
return {
|
||||||
kind: "validation",
|
kind: "validation",
|
||||||
title: "Temporarily locked after validation.",
|
title: "i18n:govoplan-campaign.temporarily_locked_after_validation.c558ce56",
|
||||||
description: "This protects the validated build input. Unlocking invalidates validation, build and generated queue state.",
|
description: "i18n:govoplan-campaign.this_protects_the_validated_build_input_unlockin.ed890772",
|
||||||
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : "",
|
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (flags.finalLock) {
|
if (flags.finalLock) {
|
||||||
return {
|
return {
|
||||||
kind: "delivery",
|
kind: "delivery",
|
||||||
title: "Permanently locked delivery snapshot.",
|
title: "i18n:govoplan-campaign.permanently_locked_delivery_snapshot.c3670daa",
|
||||||
description: "Delivery has started or the version is in a final state, so it cannot be unlocked in place.",
|
description: "i18n:govoplan-campaign.delivery_has_started_or_the_version_is_in_a_fina.571e86ed",
|
||||||
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
|
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
kind: "other",
|
kind: "other",
|
||||||
title: "Locked version.",
|
title: "i18n:govoplan-campaign.locked_version.e755b179",
|
||||||
description: "This version is read-only. Create an editable copy to continue working.",
|
description: "i18n:govoplan-campaign.this_version_is_read_only_create_an_editable_cop.9e0afd29",
|
||||||
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
|
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDialogTitle(action: ConfirmAction): string {
|
function confirmDialogTitle(action: ConfirmAction): string {
|
||||||
if (action === "unlock-validation") return "Unlock validation?";
|
if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.e3066247";
|
||||||
if (action === "unlock-user") return "Unlock temporary lock?";
|
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468";
|
||||||
if (action === "permanent") return "Lock permanently?";
|
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.24d5b74d";
|
||||||
return "Confirm action";
|
return "i18n:govoplan-campaign.confirm_action.50649c5c";
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDialogMessage(action: ConfirmAction): string {
|
function confirmDialogMessage(action: ConfirmAction): string {
|
||||||
if (action === "unlock-validation") {
|
if (action === "unlock-validation") {
|
||||||
return "This makes the version editable and clears validation, build results, review acknowledgement and generated jobs for this version.";
|
return "i18n:govoplan-campaign.this_makes_the_version_editable_and_clears_valid.89a6cd1b";
|
||||||
}
|
}
|
||||||
if (action === "unlock-user") {
|
if (action === "unlock-user") {
|
||||||
return "This removes the reversible user lock. Campaign data and existing workflow results are not otherwise changed.";
|
return "i18n:govoplan-campaign.this_removes_the_reversible_user_lock_campaign_d.fcf8cb1d";
|
||||||
}
|
}
|
||||||
if (action === "permanent") {
|
if (action === "permanent") {
|
||||||
return "This lock cannot be removed by any role. The version remains available for review and audit, but future changes require an editable copy.";
|
return "i18n:govoplan-campaign.this_lock_cannot_be_removed_by_any_role_the_vers.3054b076";
|
||||||
}
|
}
|
||||||
return "Continue?";
|
return "i18n:govoplan-campaign.continue.4e6302ed";
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDialogLabel(action: ConfirmAction): string {
|
function confirmDialogLabel(action: ConfirmAction): string {
|
||||||
if (action === "unlock-validation") return "Unlock validation";
|
if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.f01952b6";
|
||||||
if (action === "unlock-user") return "Unlock";
|
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock.1526a17e";
|
||||||
if (action === "permanent") return "Lock permanently";
|
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||||
return "Confirm";
|
return "i18n:govoplan-campaign.confirm.04a21221";
|
||||||
}
|
}
|
||||||
@@ -1,635 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
|
|
||||||
import type { ApiSettings } from "../../../types";
|
|
||||||
import {
|
|
||||||
listFileSpaces,
|
|
||||||
listFiles,
|
|
||||||
listFolders,
|
|
||||||
resolveFilePatterns,
|
|
||||||
shareFileWithCampaign,
|
|
||||||
type FileFolder,
|
|
||||||
type FileSpace,
|
|
||||||
type ManagedFile
|
|
||||||
} from "../../../api/files";
|
|
||||||
import Button from "../../../components/Button";
|
|
||||||
import ConfirmDialog from "../../../components/ConfirmDialog";
|
|
||||||
import Dialog from "../../../components/Dialog";
|
|
||||||
import DismissibleAlert from "../../../components/DismissibleAlert";
|
|
||||||
import { FolderTree } from "../../files/components/FileManagerComponents";
|
|
||||||
import { useFileTreeState } from "../../files/hooks/useFileTreeState";
|
|
||||||
import type { FolderNode, SortColumn, SortDirection } from "../../files/types";
|
|
||||||
import {
|
|
||||||
buildExplorerEntries,
|
|
||||||
buildFolderTree,
|
|
||||||
formatBytes,
|
|
||||||
formatDate,
|
|
||||||
normalizeFilePath,
|
|
||||||
normalizeFolder,
|
|
||||||
parentFolderPath,
|
|
||||||
sortExplorerEntries
|
|
||||||
} from "../../files/utils/fileManagerUtils";
|
|
||||||
import {
|
|
||||||
encodeManagedAttachmentSource,
|
|
||||||
parseManagedAttachmentSource,
|
|
||||||
type ManagedAttachmentSource
|
|
||||||
} from "../utils/attachments";
|
|
||||||
|
|
||||||
type ManagedFileChooserMode = "folder" | "attachment";
|
|
||||||
type AttachmentChoiceMode = "file" | "pattern";
|
|
||||||
|
|
||||||
type RememberedChooserState = {
|
|
||||||
spaceId?: string;
|
|
||||||
folder?: string;
|
|
||||||
pattern?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ManagedFolderSelection = {
|
|
||||||
space: FileSpace;
|
|
||||||
folderPath: string;
|
|
||||||
source: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ManagedAttachmentSelection = ManagedFolderSelection & {
|
|
||||||
fileFilter: string;
|
|
||||||
matchCount: number;
|
|
||||||
fileIds: string[];
|
|
||||||
selectionType: AttachmentChoiceMode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ManagedFileChooserProps = {
|
|
||||||
open: boolean;
|
|
||||||
settings: ApiSettings;
|
|
||||||
campaignId: string;
|
|
||||||
mode: ManagedFileChooserMode;
|
|
||||||
source?: string;
|
|
||||||
basePath?: string;
|
|
||||||
initialPattern?: string;
|
|
||||||
rememberKey?: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onSelectFolder?: (selection: ManagedFolderSelection) => void;
|
|
||||||
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ManagedFileChooser({
|
|
||||||
open,
|
|
||||||
settings,
|
|
||||||
campaignId,
|
|
||||||
mode,
|
|
||||||
source,
|
|
||||||
basePath = ".",
|
|
||||||
initialPattern = "",
|
|
||||||
rememberKey = mode,
|
|
||||||
onClose,
|
|
||||||
onSelectFolder,
|
|
||||||
onSelectAttachment
|
|
||||||
}: ManagedFileChooserProps) {
|
|
||||||
const parsedSource = useMemo(() => parseManagedAttachmentSource(source), [source]);
|
|
||||||
const normalizedBasePath = normalizeManagedBasePath(basePath);
|
|
||||||
const storageKey = useMemo(
|
|
||||||
() => `multimailer.managedFileChooser.${campaignId}.${rememberKey}`,
|
|
||||||
[campaignId, rememberKey]
|
|
||||||
);
|
|
||||||
const remembered = useMemo(() => readRememberedState(storageKey), [storageKey, open]);
|
|
||||||
const [spaces, setSpaces] = useState<FileSpace[]>([]);
|
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState("");
|
|
||||||
const [files, setFiles] = useState<ManagedFile[]>([]);
|
|
||||||
const [folders, setFolders] = useState<FileFolder[]>([]);
|
|
||||||
const [currentFolder, setCurrentFolder] = useState("");
|
|
||||||
const [pattern, setPattern] = useState("");
|
|
||||||
const [patternMatches, setPatternMatches] = useState<ManagedFile[] | null>(null);
|
|
||||||
const [pendingExactFile, setPendingExactFile] = useState<ManagedFile | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [resolving, setResolving] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null;
|
|
||||||
const sourceLocked = mode === "attachment" && Boolean(parsedSource);
|
|
||||||
const sourceMatchesSpace = selectedSpace
|
|
||||||
? !parsedSource || (selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId)
|
|
||||||
: false;
|
|
||||||
const effectiveRoot = mode === "attachment" ? normalizedBasePath : "";
|
|
||||||
const entries = useMemo(
|
|
||||||
() => buildExplorerEntries(files, folders, currentFolder, false),
|
|
||||||
[currentFolder, files, folders]
|
|
||||||
);
|
|
||||||
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
|
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
|
||||||
const sortedEntries = useMemo(
|
|
||||||
() => sortExplorerEntries(entries, sortColumn, sortDirection),
|
|
||||||
[entries, sortColumn, sortDirection]
|
|
||||||
);
|
|
||||||
const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot);
|
|
||||||
const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]);
|
|
||||||
const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]);
|
|
||||||
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
|
|
||||||
activeSpaceId: selectedSpaceId,
|
|
||||||
currentFolder,
|
|
||||||
onOpenFolder: (_spaceId, path) => openFolder(path)
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
let cancelled = false;
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
setPattern(remembered.pattern ?? initialPattern.trim());
|
|
||||||
setPatternMatches(null);
|
|
||||||
setPendingExactFile(null);
|
|
||||||
void listFileSpaces(settings)
|
|
||||||
.then((response) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setSpaces(response.spaces);
|
|
||||||
const sourceSpace = response.spaces.find((item) => sourceMatches(item, parsedSource));
|
|
||||||
const rememberedSpace = response.spaces.find((item) => item.id === remembered.spaceId);
|
|
||||||
const firstSpace = sourceSpace ?? rememberedSpace ?? response.spaces[0] ?? null;
|
|
||||||
setSelectedSpaceId(firstSpace?.id ?? "");
|
|
||||||
})
|
|
||||||
.catch((reason: unknown) => {
|
|
||||||
if (!cancelled) setError(errorMessage(reason));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [initialPattern, open, parsedSource?.ownerId, parsedSource?.ownerType, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !selectedSpace) return;
|
|
||||||
let cancelled = false;
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
setPatternMatches(null);
|
|
||||||
const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : "";
|
|
||||||
const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot;
|
|
||||||
setCurrentFolder(initialFolder || effectiveRoot);
|
|
||||||
void Promise.all([
|
|
||||||
listFiles(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id }),
|
|
||||||
listFolders(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id })
|
|
||||||
])
|
|
||||||
.then(([fileResponse, folderResponse]) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setFiles(fileResponse.files);
|
|
||||||
setFolders(folderResponse.folders);
|
|
||||||
})
|
|
||||||
.catch((reason: unknown) => {
|
|
||||||
if (!cancelled) setError(errorMessage(reason));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [effectiveRoot, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !selectedSpaceId) return;
|
|
||||||
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern });
|
|
||||||
}, [currentFolder, open, pattern, selectedSpaceId, storageKey]);
|
|
||||||
|
|
||||||
function toggleSort(column: SortColumn) {
|
|
||||||
if (sortColumn === column) {
|
|
||||||
setSortDirection((current) => current === "asc" ? "desc" : "asc");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSortColumn(column);
|
|
||||||
setSortDirection(column === "name" ? "asc" : "desc");
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFolder(path: string) {
|
|
||||||
const normalized = normalizeFolder(path);
|
|
||||||
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
|
|
||||||
setCurrentFolder(normalized);
|
|
||||||
setPatternMatches(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function previewPattern(): Promise<ManagedFile[]> {
|
|
||||||
if (!selectedSpace) return [];
|
|
||||||
const trimmed = pattern.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
setPatternMatches([]);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
setResolving(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const response = await resolveFilePatterns(settings, {
|
|
||||||
patterns: [trimmed],
|
|
||||||
owner_type: selectedSpace.owner_type,
|
|
||||||
owner_id: selectedSpace.owner_id,
|
|
||||||
path_prefix: effectiveRoot || undefined,
|
|
||||||
include_unmatched: false
|
|
||||||
});
|
|
||||||
const matches = response.patterns[0]?.matches ?? [];
|
|
||||||
setPatternMatches(matches);
|
|
||||||
return matches;
|
|
||||||
} catch (reason) {
|
|
||||||
setError(errorMessage(reason));
|
|
||||||
return [];
|
|
||||||
} finally {
|
|
||||||
setResolving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestExactFile(file: ManagedFile) {
|
|
||||||
const exactPattern = relativePath(file.display_path, effectiveRoot);
|
|
||||||
const currentPattern = pattern.trim();
|
|
||||||
if (currentPattern && currentPattern !== exactPattern) {
|
|
||||||
setPendingExactFile(file);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyExactFile(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyExactFile(file: ManagedFile) {
|
|
||||||
setPattern(relativePath(file.display_path, effectiveRoot));
|
|
||||||
setPatternMatches([file]);
|
|
||||||
setPendingExactFile(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shareMatches(matches: ManagedFile[]) {
|
|
||||||
const toShare = matches.filter((file) => !file.shares?.some((share) => (
|
|
||||||
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
|
|
||||||
)));
|
|
||||||
await Promise.all(toShare.map((file) => shareFileWithCampaign(settings, file.id, campaignId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmSelection() {
|
|
||||||
if (!selectedSpace || !sourceMatchesSpace) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
if (mode === "folder") {
|
|
||||||
onSelectFolder?.({
|
|
||||||
space: selectedSpace,
|
|
||||||
folderPath: currentFolder,
|
|
||||||
source: encodeManagedAttachmentSource(selectedSpace)
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = patternMatches ?? await previewPattern();
|
|
||||||
await shareMatches(matches);
|
|
||||||
const trimmedPattern = pattern.trim();
|
|
||||||
onSelectAttachment?.({
|
|
||||||
space: selectedSpace,
|
|
||||||
folderPath: effectiveRoot,
|
|
||||||
source: encodeManagedAttachmentSource(selectedSpace),
|
|
||||||
fileFilter: trimmedPattern,
|
|
||||||
matchCount: matches.length,
|
|
||||||
fileIds: matches.map((file) => file.id),
|
|
||||||
selectionType: isExactPattern(trimmedPattern) ? "file" : "pattern"
|
|
||||||
});
|
|
||||||
} catch (reason) {
|
|
||||||
setError(errorMessage(reason));
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open || typeof document === "undefined") return null;
|
|
||||||
|
|
||||||
const dialog = (
|
|
||||||
<>
|
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title={mode === "folder" ? "Choose managed attachment source" : "Choose managed file or pattern"}
|
|
||||||
onClose={onClose}
|
|
||||||
closeDisabled={submitting}
|
|
||||||
backdropClassName="managed-file-chooser-backdrop"
|
|
||||||
className="managed-file-chooser-dialog"
|
|
||||||
bodyClassName="managed-file-chooser-body"
|
|
||||||
footerClassName="managed-file-chooser-footer"
|
|
||||||
footer={(
|
|
||||||
<>
|
|
||||||
<Button onClick={onClose} disabled={submitting}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => void confirmSelection()}
|
|
||||||
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !pattern.trim()))}
|
|
||||||
>
|
|
||||||
{submitting ? "Linking…" : mode === "folder" ? "Use this folder" : "Use pattern"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
|
||||||
{mode === "attachment" && !parsedSource && (
|
|
||||||
<DismissibleAlert tone="warning" dismissible={false}>
|
|
||||||
This attachment base path is not connected to a managed file space. Choose its folder under <strong>Attachments → Attachment sources</strong> first.
|
|
||||||
</DismissibleAlert>
|
|
||||||
)}
|
|
||||||
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && (
|
|
||||||
<DismissibleAlert tone="danger" dismissible={false}>The configured managed file space is no longer available to this user.</DismissibleAlert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="managed-file-chooser-layout" aria-busy={loading}>
|
|
||||||
<aside className="managed-file-chooser-spaces file-tree-panel" aria-label="File spaces and folders">
|
|
||||||
<div className="file-tree-heading">Spaces</div>
|
|
||||||
<div className="file-tree-list">
|
|
||||||
{spaces.map((space) => {
|
|
||||||
const selected = space.id === selectedSpaceId;
|
|
||||||
const lockedOut = sourceLocked && !sourceMatches(space, parsedSource);
|
|
||||||
return (
|
|
||||||
<div className="file-tree-space" key={space.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`file-tree-node file-tree-root ${selected && currentFolder === effectiveRoot ? "is-active" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (!selected) setSelectedSpaceId(space.id);
|
|
||||||
else openFolder(effectiveRoot);
|
|
||||||
}}
|
|
||||||
disabled={loading || lockedOut}
|
|
||||||
>
|
|
||||||
{space.owner_type === "group" ? <FolderOpen size={15} aria-hidden="true" /> : <Home size={15} aria-hidden="true" />}
|
|
||||||
<span>{space.label}</span>
|
|
||||||
</button>
|
|
||||||
{selected && (
|
|
||||||
<FolderTree
|
|
||||||
nodes={treeNodes}
|
|
||||||
activeSpaceId={selectedSpaceId}
|
|
||||||
spaceId={space.id}
|
|
||||||
currentFolder={currentFolder}
|
|
||||||
expandedKeys={expandedTreeNodes}
|
|
||||||
onOpen={(_spaceId, path) => openFolder(path)}
|
|
||||||
onToggle={toggleTreeFolder}
|
|
||||||
dragDropEnabled={false}
|
|
||||||
contextMenuEnabled={false}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!loading && spaces.length === 0 && <p className="muted small-note">No accessible file spaces were found.</p>}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className={`managed-file-chooser-browser ${mode === "folder" ? "folder-mode" : "attachment-mode"}`}>
|
|
||||||
<div className="managed-file-chooser-toolbar">
|
|
||||||
<div className="managed-file-breadcrumb" aria-label="Current folder">
|
|
||||||
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
|
|
||||||
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "Files"}
|
|
||||||
</button>
|
|
||||||
{breadcrumbs.map((crumb) => (
|
|
||||||
<span key={crumb.path}>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<button type="button" onClick={() => openFolder(crumb.path)} disabled={loading}>{crumb.name}</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === "attachment" && (
|
|
||||||
<div className="managed-pattern-editor">
|
|
||||||
<label>
|
|
||||||
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
|
|
||||||
<div className="field-with-action split-field-action">
|
|
||||||
<input
|
|
||||||
value={pattern}
|
|
||||||
onChange={(event) => { setPattern(event.target.value); setPatternMatches(null); }}
|
|
||||||
onKeyDown={(event) => { if (event.key === "Enter") void previewPattern(); }}
|
|
||||||
placeholder="folder/file.pdf or **/*.pdf"
|
|
||||||
/>
|
|
||||||
<Button onClick={() => void previewPattern()} disabled={resolving || !pattern.trim()}>
|
|
||||||
<Search size={15} aria-hidden="true" /> {resolving ? "Checking…" : "Preview"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
{patternMatches !== null && (
|
|
||||||
<div className="managed-pattern-summary">
|
|
||||||
<span>{patternMatches.length} current file{patternMatches.length === 1 ? "" : "s"} match.</span>
|
|
||||||
<Button variant="ghost" onClick={() => setPatternMatches(null)}>Back to folder</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{patternMatches !== null && mode === "attachment" ? (
|
|
||||||
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
|
|
||||||
) : (
|
|
||||||
<div className="managed-file-entry-table">
|
|
||||||
<div className="managed-file-entry-head" role="row">
|
|
||||||
<span aria-hidden="true" />
|
|
||||||
<ChooserSortButton column="name" label="Name" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
|
|
||||||
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
|
|
||||||
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
|
|
||||||
</div>
|
|
||||||
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="managed-file-entry is-folder is-parent"
|
|
||||||
onClick={() => openFolder(parentWithinRoot(currentFolder, effectiveRoot))}
|
|
||||||
disabled={loading || currentFolder === effectiveRoot}
|
|
||||||
>
|
|
||||||
<FolderOpen size={18} aria-hidden="true" />
|
|
||||||
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
|
|
||||||
<span className="managed-file-entry-size">—</span>
|
|
||||||
<span className="managed-file-entry-modified">—</span>
|
|
||||||
</button>
|
|
||||||
{sortedEntries.map((entry) => {
|
|
||||||
if (entry.kind === "folder") {
|
|
||||||
return (
|
|
||||||
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => openFolder(entry.path)}>
|
|
||||||
<Folder size={18} aria-hidden="true" />
|
|
||||||
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
|
|
||||||
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
|
|
||||||
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
|
|
||||||
const selected = mode === "attachment" && pattern.trim() === exactPattern;
|
|
||||||
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={entry.file.id}
|
|
||||||
className={`managed-file-entry is-file ${selected ? "is-selected" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
|
|
||||||
onClick={() => mode === "attachment" ? requestExactFile(entry.file) : undefined}
|
|
||||||
disabled={mode === "folder"}
|
|
||||||
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
|
|
||||||
>
|
|
||||||
<File size={18} aria-hidden="true" />
|
|
||||||
<span className="managed-file-entry-name">
|
|
||||||
<strong>{entry.file.filename}</strong>
|
|
||||||
<small>{campaignShared ? <><Link2 size={12} aria-hidden="true" /> linked to campaign</> : "File"}</small>
|
|
||||||
</span>
|
|
||||||
<span className="managed-file-entry-size">{formatBytes(entry.file.size_bytes)}</span>
|
|
||||||
<span className="managed-file-entry-modified">{formatDate(entry.file.updated_at)}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!loading && sortedEntries.length === 0 && (
|
|
||||||
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === "folder" && (
|
|
||||||
<p className="muted small-note managed-file-chooser-note">Choose the folder that should act as this campaign attachment source.</p>
|
|
||||||
)}
|
|
||||||
{mode === "attachment" && (
|
|
||||||
<p className="muted small-note managed-file-chooser-note">Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={Boolean(pendingExactFile)}
|
|
||||||
title="Replace the current pattern?"
|
|
||||||
message={pendingExactFile ? `Replace “${pattern.trim()}” with the exact file “${relativePath(pendingExactFile.display_path, effectiveRoot)}”?` : "Replace the current pattern?"}
|
|
||||||
confirmLabel="Replace pattern"
|
|
||||||
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
|
|
||||||
onCancel={() => setPendingExactFile(null)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return createPortal(dialog, document.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChooserSortButton({
|
|
||||||
column,
|
|
||||||
label,
|
|
||||||
activeColumn,
|
|
||||||
direction,
|
|
||||||
onSort,
|
|
||||||
}: {
|
|
||||||
column: SortColumn;
|
|
||||||
label: string;
|
|
||||||
activeColumn: SortColumn;
|
|
||||||
direction: SortDirection;
|
|
||||||
onSort: (column: SortColumn) => void;
|
|
||||||
}) {
|
|
||||||
const active = activeColumn === column;
|
|
||||||
const Icon = active ? (direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={active ? "is-sorted" : ""}
|
|
||||||
aria-label={`${label}: ${active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "not sorted"}. Activate to sort.`}
|
|
||||||
onClick={() => onSort(column)}
|
|
||||||
>
|
|
||||||
<span>{label}</span><Icon size={14} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PatternResultList({ files, campaignId, onChoose }: { files: ManagedFile[]; campaignId: string; onChoose: (file: ManagedFile) => void }) {
|
|
||||||
return (
|
|
||||||
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results">
|
|
||||||
<div className="file-list-table-head" role="row">
|
|
||||||
<span>Name</span><span>Size</span><span>Modified</span>
|
|
||||||
</div>
|
|
||||||
<div className="file-list-table">
|
|
||||||
{files.length === 0 && <div className="file-list-empty">No current files match this pattern.</div>}
|
|
||||||
{files.map((file) => (
|
|
||||||
<button type="button" className="file-list-row file-row managed-pattern-result-row" role="row" key={file.id} onClick={() => onChoose(file)}>
|
|
||||||
<span className="file-list-name-cell">
|
|
||||||
<span className="file-list-name">
|
|
||||||
<File className="file-row-icon" size={20} aria-hidden="true" />
|
|
||||||
<span><strong>{file.display_path}</strong>{isSharedWithCampaign(file, campaignId) && <small>Linked to campaign</small>}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span>{formatBytes(file.size_bytes)}</span>
|
|
||||||
<span className="file-row-tail"><span>{formatDate(file.updated_at)}</span></span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceMatches(space: FileSpace, source: ManagedAttachmentSource | null): boolean {
|
|
||||||
return Boolean(source && space.owner_type === source.ownerType && space.owner_id === source.ownerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeManagedBasePath(value: string): string {
|
|
||||||
const normalized = normalizeFolder(value);
|
|
||||||
return normalized === "." ? "" : normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function relativePath(path: string, root: string): string {
|
|
||||||
const normalizedPath = normalizeFilePath(path);
|
|
||||||
const normalizedRoot = normalizeFolder(root);
|
|
||||||
if (!normalizedRoot) return normalizedPath;
|
|
||||||
const prefix = `${normalizedRoot}/`;
|
|
||||||
return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooserBreadcrumbs(folder: string, root: string): { name: string; path: string }[] {
|
|
||||||
const normalizedFolder = normalizeFolder(folder);
|
|
||||||
const normalizedRoot = normalizeFolder(root);
|
|
||||||
const relative = normalizedRoot && normalizedFolder.startsWith(`${normalizedRoot}/`)
|
|
||||||
? normalizedFolder.slice(normalizedRoot.length + 1)
|
|
||||||
: normalizedRoot === normalizedFolder ? "" : normalizedFolder;
|
|
||||||
const parts = relative.split("/").filter(Boolean);
|
|
||||||
return parts.map((name, index) => ({
|
|
||||||
name,
|
|
||||||
path: normalizeFolder([normalizedRoot, ...parts.slice(0, index + 1)].filter(Boolean).join("/"))
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentWithinRoot(folder: string, root: string): string {
|
|
||||||
const normalizedFolder = normalizeFolder(folder);
|
|
||||||
const normalizedRoot = normalizeFolder(root);
|
|
||||||
if (!normalizedFolder || normalizedFolder === normalizedRoot) return normalizedRoot;
|
|
||||||
const parent = parentFolderPath(normalizedFolder);
|
|
||||||
return isWithinRoot(parent, normalizedRoot) ? parent : normalizedRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWithinRoot(path: string, root: string): boolean {
|
|
||||||
const normalizedPath = normalizeFolder(path);
|
|
||||||
const normalizedRoot = normalizeFolder(root);
|
|
||||||
return !normalizedRoot || normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodesWithinRoot(nodes: FolderNode[], root: string): FolderNode[] {
|
|
||||||
const normalizedRoot = normalizeFolder(root);
|
|
||||||
if (!normalizedRoot) return nodes;
|
|
||||||
const node = findNode(nodes, normalizedRoot);
|
|
||||||
return node?.children ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNode(nodes: FolderNode[], path: string): FolderNode | null {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.path === path) return node;
|
|
||||||
const child = findNode(node.children, path);
|
|
||||||
if (child) return child;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExactPattern(value: string): boolean {
|
|
||||||
return Boolean(value) && !/[?*\[\]{}]/.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
|
|
||||||
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRememberedState(key: string): RememberedChooserState {
|
|
||||||
if (typeof window === "undefined") return {};
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(window.localStorage.getItem(key) || "{}") as RememberedChooserState;
|
|
||||||
return parsed && typeof parsed === "object" ? parsed : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeRememberedState(key: string, state: RememberedChooserState) {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(key, JSON.stringify(state));
|
|
||||||
} catch {
|
|
||||||
// Storage is optional; chooser behavior remains functional without it.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(reason: unknown): string {
|
|
||||||
return reason instanceof Error ? reason.message : String(reason);
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
||||||
import Button from "../../../components/Button";
|
import { Button, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
|
||||||
|
|
||||||
export type MessagePreviewAttachment = {
|
// Campaign review/template/mock previews need recipient navigation, review notes,
|
||||||
filename?: string | null;
|
// raw MIME inspection and attachment grouping. Generic mailbox reading uses
|
||||||
|
// core MessageDisplayPanel instead.
|
||||||
|
export type CampaignMessagePreviewAttachment = MessageDisplayAttachment & {
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
detail?: string | null;
|
|
||||||
contentType?: string | null;
|
|
||||||
sizeBytes?: number | null;
|
|
||||||
archiveGroup?: string | null;
|
|
||||||
archiveLabel?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessagePreviewMetaItem = {
|
export type CampaignMessagePreviewMetaItem = {
|
||||||
label: string;
|
label: string;
|
||||||
value: React.ReactNode;
|
value: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessagePreviewNavigation = {
|
export type CampaignMessagePreviewNavigation = {
|
||||||
index: number;
|
index: number;
|
||||||
total: number;
|
total: number;
|
||||||
onFirst: () => void;
|
onFirst: () => void;
|
||||||
@@ -25,25 +23,25 @@ export type MessagePreviewNavigation = {
|
|||||||
onLast: () => void;
|
onLast: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessagePreviewOverlayProps = {
|
export type CampaignMessagePreviewOverlayProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
subject?: string | null;
|
subject?: string | null;
|
||||||
bodyMode?: "text" | "html";
|
bodyMode?: "text" | "html";
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
html?: string | null;
|
html?: string | null;
|
||||||
recipientLabel?: React.ReactNode;
|
recipientLabel?: ReactNode;
|
||||||
recipientNote?: React.ReactNode;
|
recipientNote?: ReactNode;
|
||||||
metaItems?: MessagePreviewMetaItem[];
|
metaItems?: CampaignMessagePreviewMetaItem[];
|
||||||
attachments?: MessagePreviewAttachment[];
|
attachments?: CampaignMessagePreviewAttachment[];
|
||||||
raw?: string | null;
|
raw?: string | null;
|
||||||
rawLabel?: string;
|
rawLabel?: string;
|
||||||
navigation?: MessagePreviewNavigation;
|
navigation?: CampaignMessagePreviewNavigation;
|
||||||
closeLabel?: string;
|
closeLabel?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MessagePreviewOverlay({
|
export default function CampaignMessagePreviewOverlay({
|
||||||
title = "Message preview",
|
title = "i18n:govoplan-campaign.message_preview.58de1450",
|
||||||
subject,
|
subject,
|
||||||
bodyMode = "text",
|
bodyMode = "text",
|
||||||
text,
|
text,
|
||||||
@@ -53,14 +51,41 @@ export default function MessagePreviewOverlay({
|
|||||||
metaItems = [],
|
metaItems = [],
|
||||||
attachments = [],
|
attachments = [],
|
||||||
raw,
|
raw,
|
||||||
rawLabel = "Raw MIME",
|
rawLabel = "i18n:govoplan-campaign.raw_mime.82c612d4",
|
||||||
navigation,
|
navigation,
|
||||||
closeLabel = "Close",
|
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
|
||||||
onClose
|
onClose
|
||||||
}: MessagePreviewOverlayProps) {
|
}: CampaignMessagePreviewOverlayProps) {
|
||||||
const shownSubject = subject?.trim() || "No subject";
|
const shownSubject = subject?.trim() || "i18n:govoplan-campaign.no_subject.7b4e8035";
|
||||||
const shownText = text?.trim() || "No plain-text body to preview.";
|
const fields = metaItems.map((item) => ({ label: item.label, value: item.value }));
|
||||||
const shownHtml = html?.trim() || "<p>No HTML body to preview.</p>";
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (isEditableTarget(event.target)) return;
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!navigation) return;
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (navigation.index > 0) navigation.onPrevious();
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (navigation.index < navigation.total - 1) navigation.onNext();
|
||||||
|
} else if (event.key === "Home") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (navigation.index > 0) navigation.onFirst();
|
||||||
|
} else if (event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (navigation.index < navigation.total - 1) navigation.onLast();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [navigation, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
|
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
|
||||||
@@ -70,96 +95,55 @@ export default function MessagePreviewOverlay({
|
|||||||
<button className="modal-close" onClick={onClose}>×</button>
|
<button className="modal-close" onClick={onClose}>×</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{(recipientLabel || recipientNote || navigation) && (
|
{(recipientLabel || recipientNote || navigation) &&
|
||||||
<div className="template-preview-toolbar">
|
<div className="template-preview-toolbar">
|
||||||
<div>
|
<div>
|
||||||
{recipientLabel && <strong>{recipientLabel}</strong>}
|
{recipientLabel && <strong>{recipientLabel}</strong>}
|
||||||
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
|
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
|
||||||
</div>
|
</div>
|
||||||
{navigation && (
|
{navigation &&
|
||||||
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
|
<div className="button-row compact-actions template-preview-nav" aria-label="i18n:govoplan-campaign.preview_message_navigation.d28a8dc0">
|
||||||
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
|
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.first_message.ffc124fd" aria-label="i18n:govoplan-campaign.first_message.ffc124fd"><ArrowBigLeftDash aria-hidden="true" /></button>
|
||||||
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
|
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.previous_message.93261bd8" aria-label="i18n:govoplan-campaign.previous_message.93261bd8"><ArrowBigLeft aria-hidden="true" /></button>
|
||||||
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
|
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
|
||||||
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
|
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.next_message.e3960a5d" aria-label="i18n:govoplan-campaign.next_message.e3960a5d"><ArrowBigRight aria-hidden="true" /></button>
|
||||||
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
|
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
|
|
||||||
{metaItems.length > 0 && (
|
<MessageDisplayPanel
|
||||||
<div className="detail-grid message-preview-meta">
|
title={shownSubject}
|
||||||
{metaItems.map((item) => (
|
fields={fields}
|
||||||
<div key={item.label}><span className="muted small-note">{item.label}</span><strong>{item.value || "—"}</strong></div>
|
bodyText={text}
|
||||||
))}
|
bodyHtml={html}
|
||||||
</div>
|
preferredBodyMode={bodyMode}
|
||||||
)}
|
deriveTextFromHtml={false}
|
||||||
|
attachments={attachments}
|
||||||
|
emptyText="i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6" />
|
||||||
|
|
||||||
<div className="template-preview-box">
|
|
||||||
<h3>{shownSubject}</h3>
|
|
||||||
{bodyMode === "html" ? (
|
|
||||||
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={shownHtml} />
|
|
||||||
) : (
|
|
||||||
<pre>{shownText}</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MessagePreviewAttachmentBoxes attachments={attachments} />
|
{raw &&
|
||||||
|
|
||||||
{raw && (
|
|
||||||
<details className="message-preview-raw">
|
<details className="message-preview-raw">
|
||||||
<summary>{rawLabel}</summary>
|
<summary>{rawLabel}</summary>
|
||||||
<pre className="mock-message-raw">{raw}</pre>
|
<pre className="mock-message-raw">{raw}</pre>
|
||||||
</details>
|
</details>
|
||||||
)}
|
}
|
||||||
</div>
|
</div>
|
||||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
|
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessagePreviewAttachmentBoxes({ attachments }: { attachments: MessagePreviewAttachment[] }) {
|
function isEditableTarget(target: EventTarget | null): boolean {
|
||||||
const direct = attachments.filter((attachment) => !attachment.archiveGroup);
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
const archiveGroups = new Map<string, MessagePreviewAttachment[]>();
|
return target.isContentEditable || ["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName);
|
||||||
for (const attachment of attachments) {
|
|
||||||
if (!attachment.archiveGroup) continue;
|
|
||||||
const group = archiveGroups.get(attachment.archiveGroup) ?? [];
|
|
||||||
group.push(attachment);
|
|
||||||
archiveGroups.set(attachment.archiveGroup, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachmentChip(attachment: MessagePreviewAttachment, index: number) {
|
|
||||||
const filename = attachment.filename?.trim() || attachment.label?.trim() || "Unnamed attachment";
|
|
||||||
const details = [attachment.detail, attachment.contentType, attachment.sizeBytes ? `${attachment.sizeBytes} bytes` : ""].filter(Boolean).join(" · ");
|
|
||||||
return (
|
|
||||||
<div className="attachment-file-chip" key={`${filename}:${index}`}>
|
|
||||||
<strong>{filename}</strong>
|
|
||||||
{details && <span>{details}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="template-preview-attachments message-preview-attachments">
|
|
||||||
<h3>Attachments</h3>
|
|
||||||
{attachments.length === 0 ? (
|
|
||||||
<p className="muted small-note">No attachments are effective for this message.</p>
|
|
||||||
) : (
|
|
||||||
<div className="message-preview-attachment-layout">
|
|
||||||
{direct.length > 0 && <div className="attachment-chip-grid">{direct.map(attachmentChip)}</div>}
|
|
||||||
{[...archiveGroups.entries()].map(([groupName, items]) => (
|
|
||||||
<section className="attachment-zip-group" key={groupName}>
|
|
||||||
<header>
|
|
||||||
<strong>{items[0]?.archiveLabel || groupName}</strong>
|
|
||||||
<span>{items.length} file{items.length === 1 ? "" : "s"} inside ZIP</span>
|
|
||||||
</header>
|
|
||||||
<div className="attachment-chip-grid">{items.map(attachmentChip)}</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type MessagePreviewAttachment = CampaignMessagePreviewAttachment;
|
||||||
|
export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem;
|
||||||
|
export type MessagePreviewNavigation = CampaignMessagePreviewNavigation;
|
||||||
|
export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps;
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Button from "../../../components/Button";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import Dialog from "../../../components/Dialog";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../../components/DismissibleAlert";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
buildUndefinedPlaceholders,
|
buildUndefinedPlaceholders,
|
||||||
extractTemplatePlaceholders,
|
extractTemplatePlaceholders,
|
||||||
removePlaceholderFromText,
|
removePlaceholderFromText,
|
||||||
|
replacePlaceholderInText,
|
||||||
type TemplateNamespace,
|
type TemplateNamespace,
|
||||||
type UndefinedPlaceholder
|
type UndefinedPlaceholder } from
|
||||||
} from "../utils/templatePlaceholders";
|
"../utils/templatePlaceholders";
|
||||||
import {
|
import {
|
||||||
TemplateFieldChipList,
|
TemplateFieldChipList,
|
||||||
UndefinedPlaceholderDecisionDialog,
|
UndefinedPlaceholderDecisionDialog,
|
||||||
UndefinedPlaceholderList,
|
UndefinedPlaceholderList,
|
||||||
type TemplateFieldOption
|
type TemplateFieldOption } from
|
||||||
} from "./TemplatePlaceholderControls";
|
"./TemplatePlaceholderControls";
|
||||||
|
|
||||||
export default function TemplateExpressionEditorDialog({
|
export default function TemplateExpressionEditorDialog({
|
||||||
open,
|
open,
|
||||||
@@ -25,27 +26,27 @@ export default function TemplateExpressionEditorDialog({
|
|||||||
globalFields,
|
globalFields,
|
||||||
placeholder,
|
placeholder,
|
||||||
description,
|
description,
|
||||||
saveLabel = "Save",
|
saveLabel = "i18n:govoplan-campaign.save.efc007a3",
|
||||||
validate,
|
validate,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSave,
|
onSave,
|
||||||
onAddField,
|
onAddField,
|
||||||
canAddField = () => true
|
canAddField = () => true
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
title: string;
|
|
||||||
value: string;
|
|
||||||
localFields: TemplateFieldOption[];
|
|
||||||
globalFields: TemplateFieldOption[];
|
|
||||||
placeholder?: string;
|
|
||||||
description?: string;
|
|
||||||
saveLabel?: string;
|
|
||||||
validate?: (value: string) => string;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSave: (value: string) => void;
|
|
||||||
onAddField: (name: string) => void;
|
|
||||||
canAddField?: (name: string) => boolean;
|
|
||||||
}) {
|
}: {open: boolean;title: string;value: string;localFields: TemplateFieldOption[];globalFields: TemplateFieldOption[];placeholder?: string;description?: string;saveLabel?: string;validate?: (value: string) => string;onCancel: () => void;onSave: (value: string) => void;onAddField: (name: string) => void;canAddField?: (name: string) => boolean;}) {
|
||||||
const [draftValue, setDraftValue] = useState(value);
|
const [draftValue, setDraftValue] = useState(value);
|
||||||
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
|
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -100,6 +101,11 @@ export default function TemplateExpressionEditorDialog({
|
|||||||
setUndefinedDialog(null);
|
setUndefinedDialog(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceUndefined(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) {
|
||||||
|
setDraftValue((current) => replacePlaceholderInText(current, field.raw, `{{${namespace}:${name}}}`));
|
||||||
|
setUndefinedDialog(null);
|
||||||
|
}
|
||||||
|
|
||||||
if (!open || typeof document === "undefined") return null;
|
if (!open || typeof document === "undefined") return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -107,22 +113,22 @@ export default function TemplateExpressionEditorDialog({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={open && !undefinedDialog}
|
open={open && !undefinedDialog}
|
||||||
title={title}
|
title={title}
|
||||||
closeLabel="Cancel filename changes"
|
closeLabel="i18n:govoplan-campaign.cancel_filename_changes.b558598e"
|
||||||
className="template-expression-dialog"
|
className="template-expression-dialog"
|
||||||
headerClassName="template-expression-dialog-header"
|
headerClassName="template-expression-dialog-header"
|
||||||
bodyClassName="template-expression-dialog-body"
|
bodyClassName="template-expression-dialog-body"
|
||||||
footerClassName="template-expression-dialog-footer"
|
footerClassName="template-expression-dialog-footer"
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={onCancel}>Cancel</Button>
|
<Button onClick={onCancel}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||||
<Button variant="primary" onClick={requestSave} disabled={Boolean(validationMessage)}>{saveLabel}</Button>
|
<Button variant="primary" onClick={requestSave} disabled={Boolean(validationMessage)}>{saveLabel}</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}>
|
||||||
>
|
|
||||||
{description && <p className="muted template-expression-description">{description}</p>}
|
{description && <p className="muted template-expression-description">{description}</p>}
|
||||||
<label className="template-expression-value-field">
|
<label className="template-expression-value-field">
|
||||||
<span>Archive filename</span>
|
<span>i18n:govoplan-campaign.archive_filename.47becf3d</span>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={draftValue}
|
value={draftValue}
|
||||||
@@ -134,43 +140,46 @@ export default function TemplateExpressionEditorDialog({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
requestSave();
|
requestSave();
|
||||||
}
|
}
|
||||||
}}
|
}} />
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
{validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
|
{validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
|
||||||
|
|
||||||
<h3 className="section-mini-heading">Recipient fields</h3>
|
<h3 className="section-mini-heading">i18n:govoplan-campaign.recipient_fields.fdbcd95b</h3>
|
||||||
<p className="muted small-note">Select a field to insert it at the current cursor position. Address fields represent the first effective address; use <code>to[2]</code> or <code>to[2].email</code> for another single value.</p>
|
<p className="muted small-note">i18n:govoplan-campaign.select_a_field_to_insert_it_at_the_current_curso.1d1b51be <code>i18n:govoplan-campaign.to_2.8c1db0c7</code> or <code>i18n:govoplan-campaign.to_2_email.5ad61c1a</code> i18n:govoplan-campaign.for_another_single_value.ce57d76a</p>
|
||||||
<TemplateFieldChipList
|
<TemplateFieldChipList
|
||||||
namespace="local"
|
namespace="local"
|
||||||
fields={localFields}
|
fields={localFields}
|
||||||
usedPlaceholders={usedPlaceholders}
|
usedPlaceholders={usedPlaceholders}
|
||||||
empty="No recipient fields are available."
|
empty="i18n:govoplan-campaign.no_recipient_fields_are_available.5b7943ad"
|
||||||
onInsert={insertPlaceholder}
|
onInsert={insertPlaceholder} />
|
||||||
/>
|
|
||||||
|
|
||||||
<h3 className="section-mini-heading">Campaign fields</h3>
|
|
||||||
|
<h3 className="section-mini-heading">i18n:govoplan-campaign.campaign_fields.969e7d80</h3>
|
||||||
<TemplateFieldChipList
|
<TemplateFieldChipList
|
||||||
namespace="global"
|
namespace="global"
|
||||||
fields={globalFields}
|
fields={globalFields}
|
||||||
usedPlaceholders={usedPlaceholders}
|
usedPlaceholders={usedPlaceholders}
|
||||||
empty="No campaign fields or global values are available."
|
empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_are_availabl.b2e4089d"
|
||||||
onInsert={insertPlaceholder}
|
onInsert={insertPlaceholder} />
|
||||||
/>
|
|
||||||
|
|
||||||
<h3 className="section-mini-heading">Used, but undefined</h3>
|
|
||||||
|
<h3 className="section-mini-heading">i18n:govoplan-campaign.used_but_undefined.3b829043</h3>
|
||||||
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
|
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<UndefinedPlaceholderDecisionDialog
|
<UndefinedPlaceholderDecisionDialog
|
||||||
field={undefinedDialog}
|
field={undefinedDialog}
|
||||||
contextLabel="archive filename"
|
contextLabel="i18n:govoplan-campaign.archive_filename.de958cbd"
|
||||||
removeLabel="Remove from filename"
|
removeLabel="i18n:govoplan-campaign.remove_from_filename.560f7f42"
|
||||||
onCancel={() => setUndefinedDialog(null)}
|
onCancel={() => setUndefinedDialog(null)}
|
||||||
onRemove={removeUndefined}
|
onRemove={removeUndefined}
|
||||||
|
onReplace={replaceUndefined}
|
||||||
onAddField={addUndefined}
|
onAddField={addUndefined}
|
||||||
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
|
localFields={localFields}
|
||||||
/>
|
globalFields={globalFields}
|
||||||
|
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")} />
|
||||||
|
|
||||||
</>,
|
</>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Button from "../../../components/Button";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Dialog from "../../../components/Dialog";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import DismissibleAlert from "../../../components/DismissibleAlert";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
|
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
||||||
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
|
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
|
||||||
|
|
||||||
export type TemplateFieldOption = {
|
export type TemplateFieldOption = {
|
||||||
@@ -15,14 +16,14 @@ export function TemplateFieldChipList({
|
|||||||
empty,
|
empty,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onInsert
|
onInsert
|
||||||
}: {
|
|
||||||
namespace: TemplateNamespace;
|
|
||||||
fields: TemplateFieldOption[];
|
|
||||||
usedPlaceholders?: TemplatePlaceholder[];
|
|
||||||
empty: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
onInsert: (namespace: TemplateNamespace, name: string) => void;
|
|
||||||
}) {
|
}: {namespace: TemplateNamespace;fields: TemplateFieldOption[];usedPlaceholders?: TemplatePlaceholder[];empty: string;disabled?: boolean;onInsert: (namespace: TemplateNamespace, name: string) => void;}) {
|
||||||
if (fields.length === 0) return <p className="muted">{empty}</p>;
|
if (fields.length === 0) return <p className="muted">{empty}</p>;
|
||||||
return (
|
return (
|
||||||
<div className="field-chip-list">
|
<div className="field-chip-list">
|
||||||
@@ -34,85 +35,128 @@ export function TemplateFieldChipList({
|
|||||||
className={`field-chip field-chip-button ${used ? "used" : ""}`}
|
className={`field-chip field-chip-button ${used ? "used" : ""}`}
|
||||||
key={`${namespace}:${field.name}`}
|
key={`${namespace}:${field.name}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onInsert(namespace, field.name)}
|
onClick={() => onInsert(namespace, field.name)}>
|
||||||
>
|
|
||||||
<span className="field-chip-namespace">{namespace}</span><span title={field.label !== field.name ? field.label : undefined}>{field.name}</span>
|
<span className="field-chip-namespace">{namespace}</span><span title={field.label !== field.name ? field.label : undefined}>{field.name}</span>
|
||||||
</button>
|
</button>);
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UndefinedPlaceholderList({
|
export function UndefinedPlaceholderList({
|
||||||
items,
|
items,
|
||||||
empty = "No undefined placeholders detected.",
|
empty = "i18n:govoplan-campaign.no_undefined_placeholders_detected.1cbdf41b",
|
||||||
onSelect
|
onSelect
|
||||||
}: {
|
|
||||||
items: UndefinedPlaceholder[];
|
|
||||||
empty?: string;
|
|
||||||
onSelect: (item: UndefinedPlaceholder) => void;
|
|
||||||
}) {
|
}: {items: UndefinedPlaceholder[];empty?: string;onSelect: (item: UndefinedPlaceholder) => void;}) {
|
||||||
if (items.length === 0) return <p className="muted">{empty}</p>;
|
if (items.length === 0) return <p className="muted">{empty}</p>;
|
||||||
return (
|
return (
|
||||||
<div className="field-chip-list">
|
<div className="field-chip-list">
|
||||||
{items.map((item) => (
|
{items.map((item) =>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="field-chip field-chip-button undefined"
|
className="field-chip field-chip-button undefined"
|
||||||
key={`${item.raw}:${item.reason}`}
|
key={`${item.raw}:${item.reason}`}
|
||||||
onClick={() => onSelect(item)}
|
onClick={() => onSelect(item)}>
|
||||||
>
|
|
||||||
{item.display}
|
{item.display}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UndefinedPlaceholderDecisionDialog({
|
export function UndefinedPlaceholderDecisionDialog({
|
||||||
field,
|
field,
|
||||||
contextLabel = "template",
|
contextLabel = "template",
|
||||||
removeLabel = "Remove placeholder",
|
removeLabel = "i18n:govoplan-campaign.remove_placeholder.3f575c72",
|
||||||
|
localFields = [],
|
||||||
|
globalFields = [],
|
||||||
onCancel,
|
onCancel,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onReplace,
|
||||||
onAddField,
|
onAddField,
|
||||||
addDisabled = false
|
addDisabled = false
|
||||||
}: {
|
|
||||||
field: UndefinedPlaceholder | null;
|
|
||||||
contextLabel?: string;
|
|
||||||
removeLabel?: string;
|
|
||||||
onCancel: () => void;
|
|
||||||
onRemove: (field: UndefinedPlaceholder) => void;
|
|
||||||
onAddField: (field: UndefinedPlaceholder) => void;
|
|
||||||
addDisabled?: boolean;
|
|
||||||
}) {
|
|
||||||
|
|
||||||
|
|
||||||
|
}: {field: UndefinedPlaceholder | null;contextLabel?: string;removeLabel?: string;localFields?: TemplateFieldOption[];globalFields?: TemplateFieldOption[];onCancel: () => void;onRemove: (field: UndefinedPlaceholder) => void;onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;onAddField: (field: UndefinedPlaceholder) => void;addDisabled?: boolean;}) {
|
||||||
|
const replacementOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
...localFields.map((item) => ({ namespace: "local" as const, ...item })),
|
||||||
|
...globalFields.map((item) => ({ namespace: "global" as const, ...item }))],
|
||||||
|
|
||||||
|
[globalFields, localFields]
|
||||||
|
);
|
||||||
|
const [replacement, setReplacement] = useState("");
|
||||||
|
const firstReplacement = replacementOptions[0];
|
||||||
|
const effectiveReplacement = replacement || (firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReplacement(firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
|
||||||
|
}, [field?.raw, firstReplacement?.namespace, firstReplacement?.name]);
|
||||||
|
|
||||||
|
function replaceWithSelected() {
|
||||||
|
if (!field || !onReplace || !effectiveReplacement) return;
|
||||||
|
const separator = effectiveReplacement.indexOf(":");
|
||||||
|
if (separator < 0) return;
|
||||||
|
const namespace = effectiveReplacement.slice(0, separator) as TemplateNamespace;
|
||||||
|
const name = effectiveReplacement.slice(separator + 1);
|
||||||
|
onReplace(field, namespace, name);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={Boolean(field)}
|
open={Boolean(field)}
|
||||||
title="Undefined field reference"
|
title="i18n:govoplan-campaign.undefined_field_reference.4ff8e266"
|
||||||
closeLabel="Return to editing"
|
closeLabel="i18n:govoplan-campaign.return_to_editing.37d4a5f0"
|
||||||
className="template-action-dialog"
|
className="template-action-dialog"
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
footer={field ? (
|
footer={field ?
|
||||||
<>
|
<>
|
||||||
<Button onClick={onCancel}>Continue editing</Button>
|
<Button onClick={onCancel}>i18n:govoplan-campaign.continue_editing.b101465f</Button>
|
||||||
<Button onClick={() => onRemove(field)}>{removeLabel}</Button>
|
<Button onClick={() => onRemove(field)}>{removeLabel}</Button>
|
||||||
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
|
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>i18n:govoplan-campaign.replace.a7cf7b25</Button>}
|
||||||
</>
|
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>i18n:govoplan-campaign.add_field.039c6315</Button>
|
||||||
) : undefined}
|
</> :
|
||||||
>
|
undefined}>
|
||||||
{field && (
|
|
||||||
|
{field &&
|
||||||
<>
|
<>
|
||||||
<p>The {contextLabel} uses <code>{`{{${field.raw}}}`}</code>, but it cannot be matched to a known field.</p>
|
<p>i18n:govoplan-campaign.the.93ef0dd8 {contextLabel} uses <code>{i18nMessage("i18n:govoplan-campaign.value.91317e63", { value0: field.raw })}</code>i18n:govoplan-campaign.but_it_cannot_be_matched_to_a_known_field.e75c4dde</p>
|
||||||
{field.reason === "invalid-namespace" && (
|
{field.reason === "invalid-namespace" &&
|
||||||
<DismissibleAlert tone="warning">Use the namespace <code>global:</code> or <code>local:</code>.</DismissibleAlert>
|
<DismissibleAlert tone="warning">i18n:govoplan-campaign.use_the_namespace.a4c2669d <code>global:</code> or <code>local:</code>.</DismissibleAlert>
|
||||||
)}
|
}
|
||||||
{field.reason === "missing-field" && (
|
{field.reason === "missing-field" &&
|
||||||
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p>
|
<p className="muted">i18n:govoplan-campaign.you_can_add.66093419 <strong>{field.name}</strong> i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8</p>
|
||||||
|
}
|
||||||
|
{onReplace && replacementOptions.length > 0 &&
|
||||||
|
<label className="template-replacement-field">
|
||||||
|
<span>i18n:govoplan-campaign.replace_by_existing_field.ee3dc0eb</span>
|
||||||
|
<select value={effectiveReplacement} onChange={(event) => setReplacement(event.target.value)}>
|
||||||
|
{replacementOptions.map((option) =>
|
||||||
|
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
|
||||||
|
{option.namespace}:{option.label || option.name}
|
||||||
|
</option>
|
||||||
)}
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
</Dialog>
|
</Dialog>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user