Compare commits

..
21 Commits
Author SHA1 Message Date
zemion 696f8f6385 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 21:07:44 +02:00
zemion 6562484d32 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:58 +02:00
zemion 2db99eaf6a Release v0.1.16
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 19:51:59 +02:00
zemion ea0efe661f Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:18:11 +02:00
zemion 6bde11a286 Make package publication retries hash-safe 2026-08-04 14:32:18 +02:00
zemion 585493fe7a Harden module package publication 2026-08-04 14:02:39 +02:00
zemion 4133de86cd Enable generated campaign passwords 2026-08-04 11:07:55 +02:00
zemion 9137300780 Add bulk recipient activation controls 2026-08-04 10:51:44 +02:00
zemion f98fe06143 Resolve campaign worker jobs to tenants 2026-08-04 09:29:35 +02:00
zemion dc63e35550 Declare legacy operator route surface 2026-08-04 05:20:46 +02:00
zemion 2dac5570cd Add protected package release workflow 2026-08-04 04:14:02 +02:00
zemion 91890fdaf5 Add native permission-aware campaign search source 2026-08-04 03:03:26 +02:00
zemion 8f5231147d Complete governed campaign lifecycle actions 2026-08-04 01:04:39 +02:00
zemion df5a93d6a3 Close Campaign interface audit gaps 2026-08-04 01:04:39 +02:00
zemion 3f3545f080 feat(campaign): expose governed archive action 2026-08-03 20:39:06 +02:00
zemion d9195a2d2b Reconcile orphaned Campaign artifacts 2026-08-03 09:23:27 +02:00
zemion d635f3a5fc Link campaign interventions to configured help 2026-08-03 07:35:39 +02:00
zemion 1d6c745991 Clarify campaign review interventions 2026-08-03 07:22:27 +02:00
zemion 5df26be074 Bind Mail effects to stable delivery attempts 2026-08-03 05:00:30 +02:00
zemion 50ce8b0acb Fence generated artifact retention 2026-08-03 03:54:58 +02:00
zemion 9da03090a7 Fence Campaign delivery effects in recovery ledger 2026-08-03 03:48:03 +02:00
58 changed files with 5373 additions and 176 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+7 -2
View File
@@ -34,8 +34,13 @@ object-storage contract under opaque Campaign-owned keys. Templates returns a
bounded artifact or a Files-managed artifact for printable output. Database job bounded artifact or a Files-managed artifact for printable output. Database job
rows retain the expected hashes and provenance. Workers resolve and verify the rows retain the expected hashes and provenance. Workers resolve and verify the
frozen evidence before delivery. Build failure compensates objects written frozen evidence before delivery. Build failure compensates objects written
before database commit, and retention keeps database references when object before database commit. Retention uses a fenced forward-recovery operation and
deletion fails so cleanup can be retried. independently verifies both artifact absence and the committed locator update;
partial or unobservable cleanup remains visible in Ops.
An operator-only, dry-run-first reconciler inventories bounded tenant-prefix
pages and removes only old objects that remain unreferenced after an active
build-fence check. Applied runs are idempotent, fenced, audited, and preserve
database references on every storage failure.
## Dependencies ## Dependencies
+25
View File
@@ -31,6 +31,31 @@ unlabelled icon-only buttons in the message preview, and Campaign-local modal
implementations that bypass Core's `Dialog`. It complements rather than replaces implementations that bypass Core's `Dialog`. It complements rather than replaces
browser and assistive-technology testing. browser and assistive-technology testing.
The guard also verifies that Campaign retains narrow-viewport layouts, visible
keyboard focus for domain-specific controls, and an explicit reduced-motion
override. Shared dialog focus trapping and restoration are tested in Core;
Campaign tests verify that overlays continue to use that shared primitive.
## Release evidence
The feature implementation can be closed once the structural contract, shared
Core component tests, TypeScript graph, and representative responsive overlay
tests pass. The seven-step matrix above remains a release-candidate checklist:
it must be repeated for the exact browser, language packages, theme, density,
and assistive-technology combination being certified. Closing the implementation
ticket does not make a general WCAG-conformance claim for future releases.
Implementation closure evidence recorded on 2026-08-03 used headless Chromium
against the configured development system. It covered all 13 Campaign routes at
1440 CSS pixels, the list, overview, recipients, and review routes at 320 CSS
pixels, reduced-motion rendering, semantic accessibility-tree snapshots, HTTP
500/page-error capture, and 42 sampled Tab stops across desktop and narrow
Review & Send. The run found no unnamed interactive controls, hidden focus,
page-level horizontal overflow, titlebar overlap, or unhandled runtime errors.
Representative list, overview, review, and narrow-review screenshots were also
inspected. This is implementation evidence; release certification still uses
the complete matrix above with the selected screen reader and browser versions.
## Known boundary ## Known boundary
Translation keys may be visible in source because Core resolves them at runtime. Translation keys may be visible in source because Core resolves them at runtime.
+56 -7
View File
@@ -7,20 +7,69 @@ opaque object prefix. A repeated idempotency key can replay only a verified
successful build; it cannot start a second active build. successful build; it cannot start a second active build.
Generated EML and bounded print output are written to shared storage and checked Generated EML and bounded print output are written to shared storage and checked
for exact size and SHA-256 content. Campaign then commits its jobs and execution for exact size and SHA-256 content. Campaign renews the build fence after that
snapshot, compares the stored object and database manifests, and records check and before changing jobs, then commits its jobs and execution snapshot,
verified success. A managed Files output makes the operation forward-recoverable compares the stored object and database manifests, and records verified success.
because Campaign cannot undo a Files-owned artifact; object-only builds use A managed Files output makes the operation forward-recoverable because Campaign
explicit compensation. cannot undo a Files-owned artifact; object-only builds use explicit
compensation.
If the Campaign database transaction fails, Campaign deletes every object it If the Campaign database transaction fails, Campaign deletes every object it
recorded and verifies absence before recording recovered state. Failed deletion, recorded and verifies absence before recording recovered state. Failed deletion,
an unavailable storage check, process loss, or superseded-object cleanup failure an unavailable storage check, process loss, or superseded-object cleanup failure
leaves a recovery-required operation visible in Ops. Do not retry such an leaves a recovery-required operation visible in Ops. Do not retry such an
operation as a normal build. Verify its checkpoint chain and reserved prefix, operation as a normal build. Verify its checkpoint chain and reserved prefix,
then reconcile it through the owning-module procedure. The orphan inventory then reconcile it through the owning-module procedure.
reconciler tracked in Campaign issue 91 will automate that bounded inspection.
## Orphan inventory and cleanup
The operator-only endpoint
`POST /api/v1/campaigns/operations/artifacts/reconcile` requires
`system:settings:write`. It never scans outside
`campaign-artifacts/{tenant_id}/`, and one request reads at most `page_size`
objects. The default request is a dry run:
```json
{}
```
The response reports each eligible key, size, modification time, age, reason,
page totals, and `next_cursor`. Continue with that cursor to inspect the next
bounded page. Objects are not eligible when they are referenced by a committed
EML or print-output row, belong to an actively fenced build, have no trustworthy
modification time, have an invalid build-key shape, or are younger than the
grace period. The minimum and default grace period is 24 hours.
Apply an inspected page with a new idempotency key:
```json
{
"apply": true,
"idempotency_key": "incident-2026-08-03-page-1",
"grace_period_hours": 24,
"page_size": 250
}
```
An applied run rechecks committed references and active build leases before
each bounded deletion batch. A Core distributed lease prevents two nodes from
committing the same tenant cleanup concurrently. Every attempted deletion is
probed afterward. `recovery_required` means an object was verified to remain;
`outcome_unknown` means storage could not prove whether the delete took effect.
Use a new idempotency key to retry after the storage problem is corrected. A
successful repeated request with the same key returns `already_completed` and
does not delete again.
Cleanup never clears Campaign database references. Audit and recovery evidence
records counts and hashed manifests rather than object keys. Exact keys are
returned only by this privileged endpoint and must stay in restricted incident
records.
No recovery checkpoint contains message bodies, recipients, credentials, or No recovery checkpoint contains message bodies, recipients, credentials, or
resolved provider secrets. Object keys remain restricted diagnostics rather resolved provider secrets. Object keys remain restricted diagnostics rather
than Campaign business data. than Campaign business data.
Database rows, `campaign-artifacts/` objects, and the encryption/key service are
one coordinated backup and recovery boundary. After any partial restore, pause
delivery, run a dry inventory, reconcile Campaign recovery operations in Ops,
and verify referenced object hashes before workers resume.
+28 -4
View File
@@ -65,6 +65,24 @@ Before the first live send for a sender domain or mail-server profile:
## Outcome Handling ## Outcome Handling
Each real Campaign job delivery is represented by a Core recovery-ledger
operation before the worker claims the job or invokes Mail, Postbox, or print.
Synchronous batches use the same boundary after their batch-wide preflight.
Explicit test/resend actions and post-acceptance IMAP appends use separate
action-fenced operations. Operations store only opaque IDs, digests, channel
policy, and bounded status evidence. A verified acceptance becomes
`succeeded`, a definitive pre-effect or provider rejection becomes `rejected`,
and uncertain or stranded effects remain `outcome_unknown` or
`recovery_required` in Ops. Campaign jobs, message actions, and channel-attempt
records remain the business source of truth.
For SMTP and Sent-folder APPEND, Campaign also passes the stable job/action and
attempt identifier into Mail. Mail establishes its own provider-bound recovery
fence after profile authorization and policy checks but before network I/O.
This nested ownership is intentional: Campaign proves its business transition,
while Mail proves the transport effect. Neither layer replays a completed or
unknown provider attempt merely to repair the other layer's state.
- `smtp_accepted`: Do not retry. If IMAP append is enabled and pending, run or - `smtp_accepted`: Do not retry. If IMAP append is enabled and pending, run or
enqueue the append action. enqueue the append action.
- `failed_temporary`: Retry explicitly after checking the error and retry count. - `failed_temporary`: Retry explicitly after checking the error and retry count.
@@ -145,11 +163,17 @@ before attempting delivery.
business fields. business fields.
- A build failure deletes objects written before the database transaction can - A build failure deletes objects written before the database transaction can
commit. commit.
- Retention clears metadata only after object deletion succeeds; an unavailable - Retention starts a job-fenced forward-recovery operation before deletion,
backend leaves the reference in place for a later retry. commits metadata changes in the Campaign-owned boundary, and independently
probes the original locator before reporting success. An unavailable backend
leaves an outcome-unknown operation; a deletion/metadata mismatch becomes
recovery-required in Ops.
- A hard process loss between object creation and metadata commit can leave an - A hard process loss between object creation and metadata commit can leave an
orphan object. Reconcile only within the Campaign build prefix and verify that orphan object. Use the operator-only, dry-run-first Campaign artifact
no job references the object before deleting it. reconciler documented in `CAMPAIGN_BUILD_RECOVERY.md`; it scans one bounded
tenant-prefix page, enforces a minimum 24-hour grace period, protects active
build fences, and rechecks committed EML and print-output references before
deletion.
- Restore Campaign rows, object storage, and the encryption key to one - Restore Campaign rows, object storage, and the encryption key to one
coordinated recovery point before resuming workers. coordinated recovery point before resuming workers.
+57
View File
@@ -96,6 +96,18 @@ Important distinctions:
- **Archive** preserves evidence. Draft-only campaigns without built, locked, - **Archive** preserves evidence. Draft-only campaigns without built, locked,
or delivery evidence may be deleted where policy allows; evidence-bearing or delivery evidence may be deleted where policy allows; evidence-bearing
campaigns are archived instead. campaigns are archived instead.
- **Copy campaign** creates a new campaign and one fresh editable version from
the selected source version. It copies configuration, but never delivery
jobs, outcomes, shares, locks, or audit evidence.
- **Archive historical version** hides only a non-current version from the
default history. It does not change the version's workflow state or remove
configuration, reports, delivery results, or audit evidence.
Campaign lifecycle confirmations are bound to the state shown in the UI. If a
job, version, share, or campaign state changes before confirmation, the server
rejects the stale action and requires a reload. The lifecycle-policy response
states the applicable built-in rule and the reason for every unavailable
action.
## User tasks ## User tasks
@@ -112,6 +124,12 @@ Important distinctions:
5. Open **Mail settings** and select an available Mail profile. The campaign 5. Open **Mail settings** and select an available Mail profile. The campaign
stores only `server.mail_profile_id`; it never accepts SMTP/IMAP settings, stores only `server.mail_profile_id`; it never accepts SMTP/IMAP settings,
usernames, passwords, or credential references. usernames, passwords, or credential references.
If the selected profile permits a campaign-scoped Mail credential, that
credential remains Mail-owned even though it is created from this surface.
For that credential and for password-valued campaign fields, the shared
password generator keeps its candidate separate from the form until **Use
password** is explicitly confirmed. Copying a candidate does not save or
submit it.
6. Save the editable version, validate the relevant sections, and resolve every 6. Save the editable version, validate the relevant sections, and resolve every
blocking issue. Warnings remain explicit review decisions. blocking issue. Warnings remain explicit review decisions.
7. Build the exact messages and inspect recipient, addressing, template, 7. Build the exact messages and inspect recipient, addressing, template,
@@ -141,6 +159,14 @@ just the authoring form:
content, recipients, attachment inputs, owner context, or non-secret content, recipients, attachment inputs, owner context, or non-secret
transport identity changes, revalidate and rebuild. transport identity changes, revalidate and rebuild.
The Review & Send surface separates three kinds of attention. Critical
blockers must be corrected before delivery, individual review items require a
recorded message decision, and non-critical group items may be acknowledged
together after individual review is complete. Each warning or blocker names
the required action, the responsible role, and the workspace to open. The
review summary keeps reviewed and remaining counts visible; a completed review
acknowledges the group items and remains bound to the current build token.
This evidence is the Campaign input to separation-of-duties policy. Generic This evidence is the Campaign input to separation-of-duties policy. Generic
approve/reject chains, delegation, substitutions, escalation, and signatures approve/reject chains, delegation, substitutions, escalation, and signatures
belong to the optional Approvals capability. Campaign must not claim an belong to the optional Approvals capability. Campaign must not claim an
@@ -213,6 +239,8 @@ At a minimum:
Only the latter becomes explicitly retryable, and neither decision resends Only the latter becomes explicitly retryable, and neither decision resends
the already SMTP-accepted message. the already SMTP-accepted message.
8. Archive only after active and uncertain effects are resolved. 8. Archive only after active and uncertain effects are resolved.
9. Use **Delete draft** only for an untouched draft. If retained evidence or an
active share exists, revoke the share where appropriate or archive instead.
Pause stops new eligible work but cannot undo a provider effect already in Pause stops new eligible work but cannot undo a provider effect already in
progress. Cancel marks work that has not yet produced a protected SMTP outcome; progress. Cancel marks work that has not yet produced a protected SMTP outcome;
@@ -275,6 +303,8 @@ as an executable Mail configuration:
- public responses remove legacy transport fields and secrets; - public responses remove legacy transport fields and secrets;
- validation, build, queue, retry, and delivery fail closed; - validation, build, queue, retry, and delivery fail closed;
- computed previews that require a current Campaign configuration return an
actionable validation problem instead of a server error;
- an editable version changes to profile-only form only through an explicit - an editable version changes to profile-only form only through an explicit
Mail-settings save; and Mail-settings save; and
- a locked version is preserved and must be forked to an editable successor. - a locked version is preserved and must be forked to an editable successor.
@@ -389,6 +419,12 @@ After restore, keep outbound delivery paused until queue/attempt state and
provider evidence have been reconciled; never let restored accepted jobs send provider evidence have been reconciled; never let restored accepted jobs send
again merely because a queue message was lost. again merely because a queue message was lost.
For unreferenced generated objects after process loss, platform operators first
run the bounded Campaign artifact inventory in dry-run mode. Apply only an
inspected page with a unique incident idempotency key. The cleanup keeps exact
keys out of ordinary Campaign responses, does not clear database references,
and leaves storage failures in the Core recovery ledger for explicit retry.
### Incident handling ### Incident handling
1. Pause new delivery when duplicate or unknown effects are possible. 1. Pause new delivery when duplicate or unknown effects are possible.
@@ -424,6 +460,15 @@ Breaking payload or ownership changes require an interface-version bump and a
release-composition alignment gate. Optional absence must be tested physically, release-composition alignment gate. Optional absence must be tested physically,
not only hidden in navigation. not only hidden in navigation.
### Bulk recipient activation
Recipient data can activate all currently inactive rows or deactivate all
currently active rows. The action displays the exact affected count and
requires confirmation. It changes only the local Campaign draft until the
operator saves. Saving follows the ordinary versioned Campaign update path, so
the resulting recipient state is retained in version and protocol evidence and
any stale validation, build, or review evidence is invalidated.
### Reusable Distribution Lists ### Reusable Distribution Lists
When Distribution Lists is available, Recipient data offers a separate import When Distribution Lists is available, Recipient data offers a separate import
@@ -524,6 +569,18 @@ lock exists. Evidence-bearing campaigns are archived. Destructive module
retirement remains a separately confirmed installer operation with backup and retirement remains a separately confirmed installer operation with backup and
retirement evidence. retirement evidence.
The Campaign **Audit** page is currently an explained handoff, not a second
audit store: Campaign emits bounded platform audit records and authorized
readers inspect them in Administration > Tenant audit. A future object-scoped
projection may improve that navigation without duplicating Audit ownership.
Evidence-bundle export and offline verification remain owned by Audit #3.
The advanced **JSON** page displays and downloads the complete campaign
configuration available to the current campaign reader. It contains no inline
transport secrets, but recipient, message, and attachment fields may contain
personal data. The UI therefore identifies it as sensitive expert output;
campaign access and export purpose remain the governing controls.
## Reference-composition acceptance ## Reference-composition acceptance
Campaign is ready to serve as the demonstration module only when all of the Campaign is ready to serve as the demonstration module only when all of the
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.12", "version": "0.1.18",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -22,11 +22,11 @@
"read-excel-file": "9.2.0" "read-excel-file": "9.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.12", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1" "react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-campaign" name = "govoplan-campaign"
version = "0.1.12" version = "0.1.18"
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.14", "govoplan-core>=0.1.18",
"jsonschema>=4,<5", "jsonschema>=4,<5",
"pydantic>=2,<3", "pydantic>=2,<3",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
@@ -0,0 +1,734 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import json
from typing import Any, Callable
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignVersion,
)
from govoplan_core.core.object_storage import (
StorageBackend,
StorageBackendError,
StorageObjectInfo,
)
from govoplan_core.core.recovery import (
RecoveryMode,
RecoveryOperation,
RecoveryPlan,
RecoveryStatus,
TERMINAL_RECOVERY_STATUSES,
)
from govoplan_core.core.recovery_runtime import begin_durable_recovery_operation
from govoplan_core.core.runtime_coordination import (
DistributedLease,
RuntimeIdentity,
)
CAMPAIGN_ARTIFACT_NAMESPACE = "campaign-artifacts"
MINIMUM_GRACE_HOURS = 24
MAXIMUM_PAGE_SIZE = 1000
_CHECKPOINT_BATCH_SIZE = 25
SessionFactory = Callable[[], Session]
class CampaignArtifactReconciliationError(RuntimeError):
pass
@dataclass(slots=True)
class ArtifactCandidate:
key: str
size_bytes: int
modified_at: datetime
age_seconds: int
reason: str = "unreferenced_after_grace_period"
disposition: str = "candidate"
failure_type: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"key": self.key,
"size_bytes": self.size_bytes,
"modified_at": self.modified_at.isoformat(),
"age_seconds": self.age_seconds,
"reason": self.reason,
"disposition": self.disposition,
"failure_type": self.failure_type,
}
@dataclass(slots=True)
class ArtifactInventory:
tenant_prefix: str
cursor: str | None
next_cursor: str | None
scanned_count: int
scanned_bytes: int
referenced_count: int
active_build_count: int
young_count: int
unknown_age_count: int
invalid_shape_count: int
candidates: list[ArtifactCandidate]
manifest_sha256: str
@property
def candidate_bytes(self) -> int:
return sum(candidate.size_bytes for candidate in self.candidates)
def response(
self,
*,
apply: bool,
status: str,
recovery_operation_id: str | None = None,
) -> dict[str, Any]:
deleted = [
candidate
for candidate in self.candidates
if candidate.disposition == "deleted"
]
failures = [
candidate
for candidate in self.candidates
if candidate.disposition
in {"delete_failed", "delete_outcome_unknown"}
]
return {
"apply": apply,
"status": status,
"recovery_operation_id": recovery_operation_id,
"tenant_prefix": self.tenant_prefix,
"cursor": self.cursor,
"next_cursor": self.next_cursor,
"scanned_count": self.scanned_count,
"scanned_bytes": self.scanned_bytes,
"referenced_count": self.referenced_count,
"active_build_count": self.active_build_count,
"young_count": self.young_count,
"unknown_age_count": self.unknown_age_count,
"invalid_shape_count": self.invalid_shape_count,
"candidate_count": len(self.candidates),
"candidate_bytes": self.candidate_bytes,
"deleted_count": len(deleted),
"deleted_bytes": sum(candidate.size_bytes for candidate in deleted),
"failure_count": len(failures),
"manifest_sha256": self.manifest_sha256,
"candidates": [candidate.as_dict() for candidate in self.candidates],
}
def campaign_artifact_inventory(
session: Session,
*,
storage: StorageBackend,
tenant_id: str,
grace_period: timedelta,
cursor: str | None = None,
page_size: int = 250,
now: datetime | None = None,
) -> ArtifactInventory:
observed_at = _as_utc(now or datetime.now(timezone.utc))
if grace_period < timedelta(hours=MINIMUM_GRACE_HOURS):
raise ValueError(
f"Campaign artifact grace period must be at least {MINIMUM_GRACE_HOURS} hours"
)
bounded_page_size = max(1, min(int(page_size), MAXIMUM_PAGE_SIZE))
prefix = _tenant_artifact_prefix(tenant_id)
if cursor is not None and not cursor.startswith(prefix):
raise ValueError("Campaign artifact cursor is outside the tenant namespace")
page = storage.list_objects(
prefix=prefix,
after=cursor,
limit=bounded_page_size,
)
page_keys = {info.key for info in page.objects}
referenced_keys = _referenced_artifact_keys(
session,
tenant_id=tenant_id,
prefix=prefix,
artifact_keys=page_keys,
)
active_build_ids = _active_build_ids(
session,
tenant_id=tenant_id,
now=observed_at,
)
candidates: list[ArtifactCandidate] = []
referenced_count = 0
active_build_count = 0
young_count = 0
unknown_age_count = 0
invalid_shape_count = 0
scanned_bytes = 0
manifest_rows: list[dict[str, Any]] = []
for info in page.objects:
scanned_bytes += info.size_bytes
build_id = _build_id_for_key(info.key, prefix=prefix)
modified_at = _object_modified_at(info)
manifest_rows.append(
{
"key_sha256": _sha256(info.key),
"size_bytes": info.size_bytes,
"modified_at": modified_at.isoformat() if modified_at else None,
}
)
if build_id is None:
invalid_shape_count += 1
continue
if info.key in referenced_keys:
referenced_count += 1
continue
if build_id in active_build_ids:
active_build_count += 1
continue
if modified_at is None:
unknown_age_count += 1
continue
age = observed_at - modified_at
if age < grace_period:
young_count += 1
continue
candidates.append(
ArtifactCandidate(
key=info.key,
size_bytes=info.size_bytes,
modified_at=modified_at,
age_seconds=max(0, int(age.total_seconds())),
)
)
return ArtifactInventory(
tenant_prefix=prefix,
cursor=cursor,
next_cursor=page.next_cursor,
scanned_count=len(page.objects),
scanned_bytes=scanned_bytes,
referenced_count=referenced_count,
active_build_count=active_build_count,
young_count=young_count,
unknown_age_count=unknown_age_count,
invalid_shape_count=invalid_shape_count,
candidates=candidates,
manifest_sha256=_canonical_sha256(manifest_rows),
)
def reconcile_campaign_artifacts(
session_factory: SessionFactory,
*,
storage: StorageBackend,
identity: RuntimeIdentity,
tenant_id: str,
apply: bool = False,
idempotency_key: str | None = None,
grace_period_hours: int = MINIMUM_GRACE_HOURS,
cursor: str | None = None,
page_size: int = 250,
now: datetime | None = None,
) -> dict[str, Any]:
if apply and not (idempotency_key or "").strip():
raise ValueError("Applied Campaign artifact cleanup requires an idempotency key")
if grace_period_hours < MINIMUM_GRACE_HOURS:
raise ValueError(
f"Campaign artifact grace period must be at least {MINIMUM_GRACE_HOURS} hours"
)
observed_at = _as_utc(now or datetime.now(timezone.utc))
grace_period = timedelta(hours=grace_period_hours)
if not apply:
with session_factory() as session:
inventory = campaign_artifact_inventory(
session,
storage=storage,
tenant_id=tenant_id,
grace_period=grace_period,
cursor=cursor,
page_size=page_size,
now=observed_at,
)
return inventory.response(apply=False, status="dry_run")
prefix = _tenant_artifact_prefix(tenant_id)
request = {
"tenant_id": tenant_id,
"prefix": prefix,
"cursor_sha256": _sha256(cursor) if cursor else None,
"page_size": max(1, min(int(page_size), MAXIMUM_PAGE_SIZE)),
"grace_period_hours": grace_period_hours,
}
recovery_start = begin_durable_recovery_operation(
session_factory,
identity=identity,
module_id="campaigns",
operation_type="artifact-orphan-reconciliation",
idempotency_key=str(idempotency_key).strip(),
request=request,
recovery_plan=RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=(
"inventory is bounded to one tenant Campaign artifact prefix",
"objects younger than the conservative grace period are excluded",
),
forward_recovery_steps=(
"retry only objects still unreferenced by committed Campaign state",
),
verification_steps=(
"probe every attempted object after deletion",
"preserve database references without mutation",
),
),
precondition_evidence={
"tenant_prefix_sha256": _sha256(prefix),
"grace_period_hours": grace_period_hours,
"page_size": request["page_size"],
},
lease_resource_key=f"campaign:artifact-reconcile:{tenant_id}",
lease_ttl_seconds=15 * 60,
resource_type="campaign_artifact_namespace",
resource_id=tenant_id,
metadata={"tenant_id": tenant_id},
)
if recovery_start.replayed:
return _replayed_response(
prefix=prefix,
cursor=cursor,
operation_id=recovery_start.operation_id,
)
operation = recovery_start.operation
if operation is None: # pragma: no cover - guarded by replay branch
raise CampaignArtifactReconciliationError(
"Campaign artifact cleanup authority was not created"
)
try:
try:
with session_factory() as session:
inventory = campaign_artifact_inventory(
session,
storage=storage,
tenant_id=tenant_id,
grace_period=grace_period,
cursor=cursor,
page_size=page_size,
now=observed_at,
)
except Exception as exc:
operation.fail(
summary="Campaign artifact inventory failed before deletion",
evidence={
"effect_started": False,
"failure_type": type(exc).__name__,
},
)
raise
operation.checkpoint(
kind="artifact-inventory",
summary="The bounded Campaign artifact inventory was classified",
evidence={
"manifest_sha256": inventory.manifest_sha256,
"scanned_count": inventory.scanned_count,
"candidate_count": len(inventory.candidates),
"candidate_bytes": inventory.candidate_bytes,
"next_page": inventory.next_cursor is not None,
},
)
if inventory.candidates:
_apply_inventory(
session_factory,
storage=storage,
operation=operation,
inventory=inventory,
tenant_id=tenant_id,
now=observed_at,
)
failed = [
item
for item in inventory.candidates
if item.disposition == "delete_failed"
]
unknown = [
item
for item in inventory.candidates
if item.disposition == "delete_outcome_unknown"
]
evidence = _cleanup_evidence(inventory)
if unknown:
operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary="Campaign artifact deletion could not be verified",
evidence=evidence,
failure_summary=(
"One or more Campaign artifact deletion outcomes are unknown"
),
)
status = "outcome_unknown"
elif failed:
operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="Campaign artifact deletion requires a retry",
evidence=evidence,
failure_summary=(
"One or more unreferenced Campaign artifacts remain"
),
)
status = "recovery_required"
else:
operation.succeed(evidence=evidence)
status = "applied"
return inventory.response(
apply=True,
status=status,
recovery_operation_id=recovery_start.operation_id,
)
except Exception:
if not operation.closed:
try:
operation.release_unresolved()
except Exception:
pass
raise
def _apply_inventory(
session_factory: SessionFactory,
*,
storage: StorageBackend,
operation: Any,
inventory: ArtifactInventory,
tenant_id: str,
now: datetime,
) -> None:
for index in range(0, len(inventory.candidates), _CHECKPOINT_BATCH_SIZE):
batch = inventory.candidates[index : index + _CHECKPOINT_BATCH_SIZE]
with session_factory() as session:
referenced = _referenced_artifact_keys(
session,
tenant_id=tenant_id,
prefix=inventory.tenant_prefix,
artifact_keys={candidate.key for candidate in batch},
)
active_build_ids = _active_build_ids(
session,
tenant_id=tenant_id,
now=now,
)
operation.checkpoint(
kind="artifact-delete-batch-authorized",
summary="Deletion authority was renewed for a bounded object batch",
evidence={
"batch_index": index // _CHECKPOINT_BATCH_SIZE,
"batch_count": len(batch),
"batch_manifest_sha256": _canonical_sha256(
[_sha256(candidate.key) for candidate in batch]
),
},
)
for candidate in batch:
build_id = _build_id_for_key(
candidate.key,
prefix=inventory.tenant_prefix,
)
if candidate.key in referenced or build_id in active_build_ids:
candidate.disposition = "protected_before_delete"
candidate.reason = "reference_or_active_build_appeared"
continue
_delete_and_verify(storage, candidate)
def _delete_and_verify(
storage: StorageBackend,
candidate: ArtifactCandidate,
) -> None:
delete_failure: Exception | None = None
try:
storage.delete(candidate.key)
except (OSError, StorageBackendError) as exc:
delete_failure = exc
try:
remains = storage.exists(candidate.key)
except (OSError, StorageBackendError) as exc:
candidate.disposition = "delete_outcome_unknown"
candidate.failure_type = type(exc).__name__
return
if not remains:
candidate.disposition = "deleted"
return
candidate.disposition = "delete_failed"
candidate.failure_type = (
type(delete_failure).__name__ if delete_failure is not None else None
)
def _referenced_artifact_keys(
session: Session,
*,
tenant_id: str,
prefix: str,
artifact_keys: set[str] | None = None,
) -> set[str]:
if artifact_keys == set():
return set()
keys: set[str] = set()
version_ids = {
identity[1]
for key in artifact_keys or ()
if (identity := _artifact_identity(key, prefix=prefix)) is not None
}
job_query = session.query(
CampaignJob.eml_storage_key,
CampaignJob.resolved_print_output,
).filter(CampaignJob.tenant_id == tenant_id)
if artifact_keys is not None:
filters = [CampaignJob.eml_storage_key.in_(artifact_keys)]
if version_ids:
filters.append(CampaignJob.campaign_version_id.in_(version_ids))
job_query = job_query.filter(or_(*filters))
job_rows = job_query.yield_per(1000)
for eml_storage_key, print_output in job_rows:
_add_key(
keys,
eml_storage_key,
prefix=prefix,
allowed_keys=artifact_keys,
)
_add_key(
keys,
_print_output_storage_key(print_output),
prefix=prefix,
allowed_keys=artifact_keys,
)
version_query = session.query(CampaignVersion.build_summary).join(
Campaign,
Campaign.id == CampaignVersion.campaign_id,
).filter(Campaign.tenant_id == tenant_id)
if artifact_keys is not None:
if not version_ids:
return keys
version_query = version_query.filter(CampaignVersion.id.in_(version_ids))
version_rows = version_query.yield_per(500)
for (build_summary,) in version_rows:
print_output = (
build_summary.get("print_output")
if isinstance(build_summary, dict)
else None
)
_add_key(
keys,
_print_output_storage_key(print_output),
prefix=prefix,
allowed_keys=artifact_keys,
)
return keys
def _artifact_identity(
key: str,
*,
prefix: str,
) -> tuple[str, str, str] | None:
if not key.startswith(prefix):
return None
parts = key[len(prefix) :].split("/")
if len(parts) < 4 or any(not part for part in parts[:4]):
return None
return parts[0], parts[1], parts[2]
def _active_build_ids(
session: Session,
*,
tenant_id: str,
now: datetime,
) -> set[str]:
operations = (
session.query(RecoveryOperation)
.join(
CampaignVersion,
CampaignVersion.id == RecoveryOperation.resource_id,
)
.join(Campaign, Campaign.id == CampaignVersion.campaign_id)
.filter(
Campaign.tenant_id == tenant_id,
RecoveryOperation.module_id == "campaigns",
RecoveryOperation.operation_type == "build-artifacts",
RecoveryOperation.status.not_in(TERMINAL_RECOVERY_STATUSES),
)
.all()
)
resource_keys = {
operation.lease_resource_key
for operation in operations
if operation.lease_resource_key
}
if not resource_keys:
return set()
installation_ids = {operation.installation_id for operation in operations}
leases = (
session.query(DistributedLease)
.filter(
DistributedLease.installation_id.in_(installation_ids),
DistributedLease.resource_key.in_(resource_keys),
)
.all()
)
leases_by_key = {
(lease.installation_id, lease.resource_key): lease for lease in leases
}
active: set[str] = set()
for operation in operations:
lease = leases_by_key.get(
(operation.installation_id, operation.lease_resource_key or "")
)
if (
lease is not None
and lease.holder_node_id == operation.holder_node_id
and lease.holder_incarnation == operation.holder_incarnation
and lease.fencing_token == operation.fencing_token
and _as_utc(lease.expires_at) > now
):
active.add(operation.id)
return active
def _cleanup_evidence(inventory: ArtifactInventory) -> dict[str, Any]:
dispositions: dict[str, int] = {}
for candidate in inventory.candidates:
dispositions[candidate.disposition] = (
dispositions.get(candidate.disposition, 0) + 1
)
verified = not any(
key in dispositions for key in ("delete_failed", "delete_outcome_unknown")
)
return {
"verified": verified,
"checks": {
"candidate_objects": (
"absent-or-newly-protected" if verified else "incomplete"
),
"database_references": "unchanged",
"lease_fence": "renewed-before-each-batch",
},
"inventory_manifest_sha256": inventory.manifest_sha256,
"candidate_manifest_sha256": _canonical_sha256(
[_sha256(candidate.key) for candidate in inventory.candidates]
),
"candidate_count": len(inventory.candidates),
"candidate_bytes": inventory.candidate_bytes,
"dispositions": dispositions,
"database_references_mutated": False,
}
def _replayed_response(
*,
prefix: str,
cursor: str | None,
operation_id: str,
) -> dict[str, Any]:
return {
"apply": True,
"status": "already_completed",
"recovery_operation_id": operation_id,
"tenant_prefix": prefix,
"cursor": cursor,
"next_cursor": None,
"scanned_count": 0,
"scanned_bytes": 0,
"referenced_count": 0,
"active_build_count": 0,
"young_count": 0,
"unknown_age_count": 0,
"invalid_shape_count": 0,
"candidate_count": 0,
"candidate_bytes": 0,
"deleted_count": 0,
"deleted_bytes": 0,
"failure_count": 0,
"manifest_sha256": None,
"candidates": [],
}
def _tenant_artifact_prefix(tenant_id: str) -> str:
normalized = str(tenant_id or "").strip()
if not normalized or "/" in normalized or normalized in {".", ".."}:
raise ValueError("Campaign artifact inventory requires a valid tenant id")
return f"{CAMPAIGN_ARTIFACT_NAMESPACE}/{normalized}/"
def _build_id_for_key(key: str, *, prefix: str) -> str | None:
identity = _artifact_identity(key, prefix=prefix)
return identity[2] if identity is not None else None
def _print_output_storage_key(value: object) -> str | None:
if not isinstance(value, dict):
return None
artifact = value.get("artifact")
if not isinstance(artifact, dict):
return None
key = artifact.get("storage_key")
return str(key) if key else None
def _add_key(
keys: set[str],
value: object,
*,
prefix: str,
allowed_keys: set[str] | None = None,
) -> None:
if value is None:
return
key = str(value)
if key.startswith(prefix) and (allowed_keys is None or key in allowed_keys):
keys.add(key)
def _object_modified_at(info: StorageObjectInfo) -> datetime | None:
return _as_utc(info.modified_at) if info.modified_at is not None else None
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _sha256(value: object) -> str:
return hashlib.sha256(str(value).encode("utf-8")).hexdigest()
def _canonical_sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
).hexdigest()
__all__ = [
"CAMPAIGN_ARTIFACT_NAMESPACE",
"CampaignArtifactReconciliationError",
"campaign_artifact_inventory",
"reconcile_campaign_artifacts",
]
@@ -0,0 +1,234 @@
from __future__ import annotations
import hashlib
import hmac
import json
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignShare,
CampaignVersion,
)
from govoplan_core.auth import ApiPrincipal, has_scope
POLICY_ID = "campaign.lifecycle"
POLICY_VERSION = "1"
_ACTIVE_QUEUE_STATES = {"queued", "sending"}
_ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"}
_ACTIVE_POSTBOX_STATES = {"pending", "delivering", "outcome_unknown"}
_ACTIVE_PRINT_STATES = {"ready", "accepting"}
_ACTIVE_IMAP_STATES = {"pending", "appending", "outcome_unknown"}
@dataclass(frozen=True)
class LifecycleDecision:
allowed: bool
reason: str | None = None
def as_dict(self) -> dict[str, Any]:
return {"allowed": self.allowed, "reason": self.reason}
def _timestamp(value: datetime | None) -> str | None:
return value.isoformat() if value is not None else None
def _canonical_hash(value: object) -> str:
return hashlib.sha256(
json.dumps(
value,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
def _protected_version(version: CampaignVersion) -> bool:
return any(
value is not None
for value in (
version.locked_at,
version.user_lock_state,
version.published_at,
version.execution_snapshot_at,
)
)
def _active_delivery(job: CampaignJob) -> bool:
return any(
(
job.queue_status in _ACTIVE_QUEUE_STATES,
job.send_status in _ACTIVE_SEND_STATES,
job.postbox_status in _ACTIVE_POSTBOX_STATES,
job.print_status in _ACTIVE_PRINT_STATES,
job.imap_status in _ACTIVE_IMAP_STATES,
)
)
def campaign_lifecycle_policy(
session: Session,
*,
campaign: Campaign,
principal: ApiPrincipal,
version_id: str | None = None,
) -> dict[str, Any]:
versions = (
session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.asc())
.all()
)
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_id == campaign.id)
.order_by(CampaignJob.id.asc())
.all()
)
shares = (
session.query(CampaignShare)
.filter(
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
)
.order_by(CampaignShare.id.asc())
.all()
)
selected_version = next(
(version for version in versions if version.id == version_id),
None,
)
snapshot = {
"policy_id": POLICY_ID,
"policy_version": POLICY_VERSION,
"campaign": {
"id": campaign.id,
"status": campaign.status,
"current_version_id": campaign.current_version_id,
"updated_at": _timestamp(campaign.updated_at),
},
"versions": [
{
"id": version.id,
"version_number": version.version_number,
"edit_revision": version.edit_revision,
"workflow_state": version.workflow_state,
"locked_at": _timestamp(version.locked_at),
"user_lock_state": version.user_lock_state,
"published_at": _timestamp(version.published_at),
"execution_snapshot_at": _timestamp(version.execution_snapshot_at),
"archived_at": _timestamp(version.archived_at),
"updated_at": _timestamp(version.updated_at),
}
for version in versions
],
"jobs": [
{
"id": job.id,
"queue_status": job.queue_status,
"send_status": job.send_status,
"postbox_status": job.postbox_status,
"print_status": job.print_status,
"imap_status": job.imap_status,
"updated_at": _timestamp(job.updated_at),
}
for job in jobs
],
"active_share_ids": [share.id for share in shares],
"selected_version_id": version_id,
}
token = _canonical_hash(snapshot)
active_delivery = any(_active_delivery(job) for job in jobs)
protected_versions = any(_protected_version(version) for version in versions)
archive = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:archive"):
archive = LifecycleDecision(False, "Missing campaign archive permission.")
elif campaign.status in {"archived", "deleted"}:
archive = LifecycleDecision(False, "The campaign is already archived or deleted.")
elif active_delivery:
archive = LifecycleDecision(
False,
"Active or uncertain delivery must be resolved before archiving.",
)
delete = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:delete"):
delete = LifecycleDecision(False, "Missing campaign delete permission.")
elif campaign.status != "draft":
delete = LifecycleDecision(False, "Only untouched draft campaigns can be deleted.")
elif jobs:
delete = LifecycleDecision(
False,
"Campaigns with built or delivery jobs must be archived instead of deleted.",
)
elif protected_versions:
delete = LifecycleDecision(
False,
"Audit-relevant campaign versions must be archived instead of deleted.",
)
elif shares:
delete = LifecycleDecision(
False,
"Revoke active campaign shares before deleting the untouched draft.",
)
copy = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:copy"):
copy = LifecycleDecision(False, "Missing campaign copy permission.")
elif not has_scope(principal, "campaigns:recipient:read"):
copy = LifecycleDecision(False, "Recipient read permission is required to copy a campaign.")
elif version_id is not None and selected_version is None:
copy = LifecycleDecision(False, "The selected source version does not exist.")
archive_version = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:archive"):
archive_version = LifecycleDecision(False, "Missing campaign archive permission.")
elif version_id is None or selected_version is None:
archive_version = LifecycleDecision(False, "Select a historical campaign version.")
elif selected_version.id == campaign.current_version_id:
archive_version = LifecycleDecision(False, "The current campaign version cannot be archived.")
elif selected_version.archived_at is not None:
archive_version = LifecycleDecision(False, "The historical version is already archived.")
return {
"policy_id": POLICY_ID,
"policy_version": POLICY_VERSION,
"state_token": token,
"actions": {
"archive_campaign": archive.as_dict(),
"delete_campaign": delete.as_dict(),
"copy_campaign": copy.as_dict(),
"archive_version": archive_version.as_dict(),
},
"provenance": {
"source": "built_in",
"rules": (
"permission",
"campaign_state",
"retained_evidence",
"active_delivery",
"optimistic_concurrency",
),
"evidence_retention": "Versions, delivery outcomes, reports, and audit records are never deleted by archival.",
},
}
def assert_lifecycle_state_token(actual: str, expected: str) -> None:
if not hmac.compare_digest(actual, expected):
raise ValueError(
"Campaign state changed after this action was prepared. Reload and review the lifecycle decision again."
)
@@ -690,6 +690,10 @@ def policy_context_capability(context: object) -> CampaignPolicyContextService:
class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider): class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider):
def tenant_id_for_job(self, session: object, *, job_id: str) -> str | None:
job = session.get(CampaignJob, job_id) # type: ignore[attr-defined]
return job.tenant_id if job is not None else None
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]: 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 from govoplan_campaign.backend.sending.jobs import send_campaign_job
@@ -247,6 +247,16 @@ class CampaignVersion(Base, TimestampMixin):
execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
delivery_mode: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) delivery_mode: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
delivery_mode_selected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) delivery_mode_selected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
archived_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
campaign: Mapped[Campaign] = relationship(back_populates="versions") campaign: Mapped[Campaign] = relationship(back_populates="versions")
+51 -4
View File
@@ -159,6 +159,31 @@ CAMPAIGN_USER_DOCUMENTATION = (
verification="The Campaign overview identifies a new current version number and the earlier version remains in history.", verification="The Campaign overview identifies a new current version number and the earlier version remains in history.",
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.mail-profile-user-journey"), related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.mail-profile-user-journey"),
), ),
_workflow_topic(
topic_id="campaigns.workflow.copy-campaign",
title="Copy a campaign into a new draft",
summary="Reuse a selected campaign version as configuration for a new campaign without copying operational or audit evidence.",
body="Copy campaign is different from creating an editable successor. It creates a separately owned campaign with a generated identifier and one editable version. Delivery jobs, outcomes, explicit shares, locks, and audit evidence stay exclusively with the source campaign.",
order=32,
audience=("campaign_manager", "campaign_author"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:recipient:read"),
route="/campaigns/{campaign_id}",
screen="Campaign overview",
help_contexts=("campaign.overview",),
prerequisites=(
"You may read the selected campaign and its recipient configuration.",
"You may create campaign copies in the active tenant.",
),
steps=(
"Open the campaign overview and choose the current or a historical source version.",
"Choose Copy campaign or Copy as new campaign and review the evidence-isolation consequence.",
"Confirm while the lifecycle state token is current; reload if another actor changed the source state.",
"Open the newly created campaign, review its generated identifier and ownership, and validate all inherited configuration before use.",
),
outcome="A new editable campaign draft containing configuration from the selected version and no copied operational evidence.",
verification="The destination has a distinct campaign ID and owner, one editable version, and no source jobs, outcomes, shares, or locks.",
related_topic_ids=("campaigns.workflow.create-editable-successor", "campaigns.workflow.prepare-validate-and-build"),
),
_workflow_topic( _workflow_topic(
topic_id="campaigns.workflow.import-recipients", topic_id="campaigns.workflow.import-recipients",
title="Import recipients into a campaign", title="Import recipients into a campaign",
@@ -569,14 +594,13 @@ CAMPAIGN_USER_DOCUMENTATION = (
steps=( steps=(
"Open Report and resolve every active or outcome-unknown delivery state.", "Open Report and resolve every active or outcome-unknown delivery state.",
"Confirm that the campaign should leave active work while its evidence remains retained.", "Confirm that the campaign should leave active work while its evidence remains retained.",
"Invoke the authorized Archive action from a supporting client.", "Open the campaign overview, choose Archive campaign, and confirm the retained-evidence consequence.",
"Reopen or query the campaign and confirm its state is Archived.", "Reopen or query the campaign and confirm its state is Archived.",
), ),
outcome="An archived campaign whose versions, reports, and audit evidence remain preserved.", outcome="An archived campaign whose versions, reports, and audit evidence remain preserved.",
verification="The campaign state is Archived and its report remains available. Ask an authorized audit reader to verify the platform audit event.", verification="The campaign state is Archived and its report remains available. Ask an authorized audit reader to verify the platform audit event.",
related_topic_ids=("campaigns.workflow.view-delivery-report", "campaigns.workflow.delete-untouched-draft"), related_topic_ids=("campaigns.workflow.view-delivery-report", "campaigns.workflow.delete-untouched-draft"),
limitations=( limitations=(
"The current Campaign Web UI does not yet expose the archive action; use an authorized supporting client or API.",
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.", "The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
), ),
), ),
@@ -597,7 +621,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
), ),
steps=( steps=(
"Confirm that the draft is not needed and contains no evidence that should be retained.", "Confirm that the draft is not needed and contains no evidence that should be retained.",
"Invoke the authorized Delete action from a supporting client and confirm the destructive action.", "Choose Delete draft on the campaign overview and confirm the destructive action.",
"If deletion is refused because protected evidence exists, archive the campaign after resolving any active delivery state.", "If deletion is refused because protected evidence exists, archive the campaign after resolving any active delivery state.",
"Verify that the deleted draft no longer appears in active Campaigns and that the audit event exists.", "Verify that the deleted draft no longer appears in active Campaigns and that the audit event exists.",
), ),
@@ -605,10 +629,33 @@ CAMPAIGN_USER_DOCUMENTATION = (
verification="The draft is no longer returned as an active campaign. Ask an authorized audit reader to verify who deleted it and when through the platform audit surface.", verification="The draft is no longer returned as an active campaign. Ask an authorized audit reader to verify who deleted it and when through the platform audit surface.",
related_topic_ids=("campaigns.workflow.archive-campaign",), related_topic_ids=("campaigns.workflow.archive-campaign",),
limitations=( limitations=(
"The current Campaign Web UI does not yet expose the delete action; use an authorized supporting client or API.",
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.", "The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
), ),
), ),
_workflow_topic(
topic_id="campaigns.workflow.archive-historical-version",
title="Archive a historical campaign version",
summary="Hide a non-current version from default history without changing or deleting retained evidence.",
body="Historical version archival is presentation lifecycle only. The original workflow state, configuration, reports, delivery outcomes, and audit evidence remain readable to authorized users and are included when archived versions are shown.",
order=42,
audience=("campaign_owner", "campaign_records_manager"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:archive"),
route="/campaigns/{campaign_id}",
screen="Campaign versions",
help_contexts=("campaign.overview", "campaign.report", "campaign.audit"),
prerequisites=(
"The selected version is historical rather than the current working version.",
"You have write access and campaign archive permission.",
),
steps=(
"Open Versions and select Archive historical version for the intended row.",
"Review the retained-evidence consequence and confirm while the lifecycle token remains current.",
"Use Show archived to include the version in history again when reviewing reports or evidence.",
),
outcome="The historical version is hidden from default history but remains intact and attributable.",
verification="Show archived displays the same version number and original workflow state with its archival timestamp.",
related_topic_ids=("campaigns.workflow.archive-campaign", "campaigns.workflow.view-delivery-report"),
),
) )
+62 -22
View File
@@ -55,12 +55,14 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_EVIDENCE, CAPABILITY_POSTBOX_EVIDENCE,
) )
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking 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
from govoplan_campaign.backend.documentation import ( from govoplan_campaign.backend.documentation import (
CAMPAIGN_USER_DOCUMENTATION, CAMPAIGN_USER_DOCUMENTATION,
documentation_topics, documentation_topics,
) )
from govoplan_campaign.backend.search_source import create_campaign_search_source
register_campaign_change_tracking() register_campaign_change_tracking()
@@ -358,7 +360,7 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="campaigns", id="campaigns",
name="Campaigns", name="Campaigns",
version="0.1.12", version="0.1.18",
required_capabilities=( required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -378,6 +380,7 @@ manifest = ModuleManifest(
"postbox", "postbox",
"approvals", "approvals",
"reporting", "reporting",
"search",
), ),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
@@ -481,12 +484,24 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0", version_max_exclusive="0.2.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name="search.source",
version_min="1.0.0",
version_max_exclusive="2.0.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,), tenant_summary_providers=(_tenant_summary,),
tenant_summary_batch_providers=(_tenant_summary_batch,), tenant_summary_batch_providers=(_tenant_summary_batch,),
search_sources=(
SearchSourceProviderRegistration(
id="campaigns.campaigns",
factory=create_campaign_search_source,
),
),
nav_items=( nav_items=(
NavItem( NavItem(
path="/campaigns", path="/campaigns",
@@ -506,6 +521,14 @@ manifest = ModuleManifest(
required_any=CAMPAIGN_MODULE_REQUIRED_ANY, required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
order=20, order=20,
), ),
FrontendRoute(
path="/operator",
component="OperatorQueueRedirect",
required_all=("campaigns:campaign:read",),
required_any=OPERATOR_QUEUE_REQUIRED_ANY,
order=21,
surface_id="campaigns.route.operator-redirect",
),
FrontendRoute( FrontendRoute(
path="/campaigns/queue", path="/campaigns/queue",
component="OperatorQueuePage", component="OperatorQueuePage",
@@ -527,7 +550,6 @@ manifest = ModuleManifest(
order=22, order=22,
surface_id=REPORTS_SURFACE_ID, surface_id=REPORTS_SURFACE_ID,
), ),
FrontendRoute(path="/templates", component="TemplatesPage", order=90),
), ),
nav_items=( nav_items=(
NavItem( NavItem(
@@ -537,9 +559,6 @@ manifest = ModuleManifest(
required_any=CAMPAIGN_MODULE_REQUIRED_ANY, required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
order=20, order=20,
), ),
NavItem(
path="/templates", label="Templates", icon="layout-template", order=90
),
), ),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
@@ -596,6 +615,23 @@ manifest = ModuleManifest(
), ),
documentation=( documentation=(
*CAMPAIGN_USER_DOCUMENTATION, *CAMPAIGN_USER_DOCUMENTATION,
DocumentationTopic(
id="campaigns.search.campaigns",
title="Search authorized campaigns",
summary="Expose campaign identity and lifecycle metadata to permission-aware platform Search.",
body=(
"When Search is installed, Campaign contributes current campaign names, external identifiers, "
"descriptions, and lifecycle state. Search rechecks tenant ownership, group ownership, explicit "
"shares, revocation, deletion, and the Campaign read permission before returning a result. "
"Committed Campaign and share changes update the derived index through the durable platform event "
"path; rebuilding Search never changes Campaign evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("campaign_manager", "campaign_operator", "administrator"),
related_modules=("search",),
order=44,
),
DocumentationTopic( DocumentationTopic(
id="campaigns.postbox-delivery", id="campaigns.postbox-delivery",
title="Deliver Campaign messages to Postboxes", title="Deliver Campaign messages to Postboxes",
@@ -760,7 +796,7 @@ manifest = ModuleManifest(
id="campaigns.mail-profile-operations", id="campaigns.mail-profile-operations",
title="Operate profile-backed campaign delivery", title="Operate profile-backed campaign delivery",
summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.", summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.",
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Preserve the record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation.", body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Preserve the record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
layer="configured", layer="configured",
documentation_types=("admin",), documentation_types=("admin",),
audience=("campaign_sender", "campaign_operator", "mail_admin"), audience=("campaign_sender", "campaign_operator", "mail_admin"),
@@ -808,7 +844,7 @@ manifest = ModuleManifest(
id="campaigns.workflow.prepare-validate-and-build", id="campaigns.workflow.prepare-validate-and-build",
title="Prepare, validate, and build a campaign", title="Prepare, validate, and build a campaign",
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.", summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build.", body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.",
layer="configured", layer="configured",
documentation_types=("user",), documentation_types=("user",),
audience=("campaign_manager", "campaign_author"), audience=("campaign_manager", "campaign_author"),
@@ -836,7 +872,7 @@ manifest = ModuleManifest(
kind="repository", kind="repository",
), ),
), ),
related_modules=("addresses", "files", "mail"), related_modules=("addresses", "files", "mail", "templates"),
unlocks=( unlocks=(
"A reviewable build whose exact recipient-specific effects can be inspected before delivery.", "A reviewable build whose exact recipient-specific effects can be inspected before delivery.",
), ),
@@ -864,8 +900,8 @@ manifest = ModuleManifest(
], ],
"steps": [ "steps": [
"Set campaign-wide fields and purpose, then define the recipient fields and templates.", "Set campaign-wide fields and purpose, then define the recipient fields and templates.",
"Import or select recipients and inspect provenance, exclusions, and review-required rows.", "Import or select recipients, optionally confirm a counted activate-all or deactivate-all draft action, and inspect provenance, exclusions, and review-required rows.",
"Select managed attachment versions and an authorized Mail profile when those capabilities are used.", "Select managed attachment versions and an authorized Mail profile when those capabilities are used; generate a new password only when creating a campaign credential or password-valued field, then explicitly confirm the candidate.",
"Validate the relevant sections and resolve every blocker without hiding warnings.", "Validate the relevant sections and resolve every blocker without hiding warnings.",
"Build the selected version and inspect representative and exceptional rendered messages.", "Build the selected version and inspect representative and exceptional rendered messages.",
], ],
@@ -881,8 +917,8 @@ manifest = ModuleManifest(
DocumentationTopic( DocumentationTopic(
id="campaigns.workflow.complete-review", id="campaigns.workflow.complete-review",
title="Inspect built messages and complete review", title="Inspect built messages and complete review",
summary="Review the exact immutable candidate and record which built messages were inspected before delivery is enabled.", summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
body="Review completion records the inspected message keys for the selected build. The current baseline does not persist a separate approve/reject decision or review reason, so do not present completion as a richer decision record. Any material input or non-secret transport-identity change requires validation and a new build.", body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
layer="configured", layer="configured",
documentation_types=("user",), documentation_types=("user",),
audience=("campaign_reviewer",), audience=("campaign_reviewer",),
@@ -918,13 +954,14 @@ manifest = ModuleManifest(
"You may read the campaign and complete its review.", "You may read the campaign and complete its review.",
], ],
"steps": [ "steps": [
"Confirm the campaign, owner, selected version, recipient count, warnings, and exclusions.", "Open Review and send and inspect the Critical blockers, Individual review, and Group review summaries.",
"Inspect representative and exceptional messages, addressing, templates, and attachment evidence.", "Correct every critical blocker in the named campaign workspace, then validate and build again.",
"Confirm that the selected Mail profile is suitable and authorized for the current context.", "Open each remaining individual review message and record its decision.",
"Record review completion for the exact message keys inspected.", "When only non-critical group items remain, review their conditions and explicitly complete review.",
"Confirm that Reviewed equals the required review total and Remaining is zero before delivery.",
], ],
"outcome": "The reviewed build is eligible for a separately authorized queue or send action.", "outcome": "Review evidence for the exact current build, with no unresolved blocker or review decision.",
"verification": "Reload Review and confirm completion is tied to the same version and message build; changed inputs must invalidate or supersede it.", "verification": "Reload Review and send, confirm no critical blocker or remaining decision, and verify that the permitted delivery mode is unlocked for the same version and build.",
"related_topic_ids": [ "related_topic_ids": [
"campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.retry-and-reconcile", "campaigns.workflow.retry-and-reconcile",
@@ -1052,7 +1089,7 @@ manifest = ModuleManifest(
id="campaigns.reference.shared-build-artifacts", id="campaigns.reference.shared-build-artifacts",
title="Operate Campaign build artifacts across workers", title="Operate Campaign build artifacts across workers",
summary="Generated messages use shared object storage and are verified before delivery.", summary="Generated messages use shared object storage and are verified before delivery.",
body="Campaign stores generated EML under opaque shared object keys and records expected size, SHA-256 digest, and Message-ID in each job. A worker may run on another node and verifies that evidence before delivery. Before object or Files-owned output effects, a fenced Core recovery operation records the canonical build request, validated-version evidence, and reserved object prefix. Object-only failures prove compensation; managed Files output and uncertain cleanup remain explicit forward-recovery or recovery-required work in Ops. Retention retains metadata when deletion fails. Never copy or edit runtime object keys as business data.", body="Campaign stores generated EML under opaque shared object keys and records expected size, SHA-256 digest, and Message-ID in each job. A worker may run on another node and verifies that evidence before delivery. Before object or Files-owned output effects, a fenced Core recovery operation records the canonical source, validated-version evidence, and reserved object prefix, then renews its fence after object verification and before domain commit. Before a real Mail, Postbox, or print effect, a separate job-fenced operation records immutable message and recipient digests and later verifies the authoritative Campaign/channel attempt state. Definitive rejection is distinct from accepted, outcome-unknown, and recovery-required work in Ops. Object-only failures prove compensation; managed Files output and uncertain cleanup remain explicit forward-recovery work. Retention commits locator changes and independently verifies artifact absence. An operator-only reconciler inventories bounded tenant-prefix pages, protects active builds and a minimum 24-hour grace period, and deletes only objects that remain unreferenced. Never copy or edit runtime object keys as business data.",
layer="evidence", layer="evidence",
documentation_types=("admin",), documentation_types=("admin",),
audience=("campaign_operator", "platform_operator", "release_reviewer"), audience=("campaign_operator", "platform_operator", "release_reviewer"),
@@ -1085,9 +1122,9 @@ manifest = ModuleManifest(
"kind": "reference", "kind": "reference",
"route": "/campaigns/queue", "route": "/campaigns/queue",
"screen": "Campaign operator queue", "screen": "Campaign operator queue",
"verification": "Build on one replica, verify the recovery checkpoint chain and database/object manifests, deliver from another, then exercise process loss, stale fencing, storage failure, and retention cleanup.", "verification": "Build on one replica, verify the recovery checkpoint chain and database/object manifests, deliver from another, then exercise process loss, dry-run/apply orphan reconciliation, stale fencing, storage failure, and retention cleanup.",
"limitations": [ "limitations": [
"A hard process loss between object creation and database commit remains recovery-required under its reserved prefix until the inventory reconciler verifies or removes it.", "Artifact inventory and cleanup are currently an operator API/runbook action rather than an Ops WebUI control.",
"Database, object storage, and encryption keys require a coordinated deployment backup and restore procedure.", "Database, object storage, and encryption keys require a coordinated deployment backup and restore procedure.",
], ],
}, },
@@ -1177,7 +1214,10 @@ manifest = ModuleManifest(
"durable address directory", "durable address directory",
"file storage", "file storage",
), ),
recovery_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",), recovery_docs=(
"docs/CAMPAIGN_DELIVERY_RUNBOOK.md",
"docs/CAMPAIGN_BUILD_RECOVERY.md",
),
security_docs=("docs/ACCESS_EXPLANATION_COVERAGE.md",), security_docs=("docs/ACCESS_EXPLANATION_COVERAGE.md",),
operations_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",), operations_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",),
), ),
@@ -0,0 +1,85 @@
"""add non-destructive historical campaign version archival
Revision ID: e3c8f4a5b6d7
Revises: b7c8d9e0f1a2, d2b7af503c81
Create Date: 2026-08-03 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e3c8f4a5b6d7"
down_revision = ("b7c8d9e0f1a2", "d2b7af503c81")
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
columns = {
column["name"]
for column in inspector.get_columns("campaign_versions")
}
with op.batch_alter_table("campaign_versions") as batch_op:
if "archived_at" not in columns:
batch_op.add_column(
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True)
)
if "archived_by_user_id" not in columns:
batch_op.add_column(
sa.Column("archived_by_user_id", sa.String(length=36), nullable=True)
)
batch_op.create_foreign_key(
"fk_campaign_versions_archived_by_user_id_access_users",
"access_users",
["archived_by_user_id"],
["id"],
ondelete="SET NULL",
)
indexes = {
index["name"]
for index in sa.inspect(op.get_bind()).get_indexes("campaign_versions")
}
if "ix_campaign_versions_archived_at" not in indexes:
op.create_index(
"ix_campaign_versions_archived_at",
"campaign_versions",
["archived_at"],
unique=False,
)
if "ix_campaign_versions_archived_by_user_id" not in indexes:
op.create_index(
"ix_campaign_versions_archived_by_user_id",
"campaign_versions",
["archived_by_user_id"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
indexes = {
index["name"]
for index in inspector.get_indexes("campaign_versions")
}
for index_name in (
"ix_campaign_versions_archived_by_user_id",
"ix_campaign_versions_archived_at",
):
if index_name in indexes:
op.drop_index(index_name, table_name="campaign_versions")
columns = {
column["name"]
for column in sa.inspect(op.get_bind()).get_columns("campaign_versions")
}
with op.batch_alter_table("campaign_versions") as batch_op:
if "archived_by_user_id" in columns:
batch_op.drop_constraint(
"fk_campaign_versions_archived_by_user_id_access_users",
type_="foreignkey",
)
batch_op.drop_column("archived_by_user_id")
if "archived_at" in columns:
batch_op.drop_column("archived_at")
@@ -0,0 +1,85 @@
"""add non-destructive historical campaign version archival
Revision ID: e3c8f4a5b6d7
Revises: b7c8d9e0f1a2, d2b7af503c81
Create Date: 2026-08-03 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e3c8f4a5b6d7"
down_revision = ("b7c8d9e0f1a2", "d2b7af503c81")
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
columns = {
column["name"]
for column in inspector.get_columns("campaign_versions")
}
with op.batch_alter_table("campaign_versions") as batch_op:
if "archived_at" not in columns:
batch_op.add_column(
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True)
)
if "archived_by_user_id" not in columns:
batch_op.add_column(
sa.Column("archived_by_user_id", sa.String(length=36), nullable=True)
)
batch_op.create_foreign_key(
"fk_campaign_versions_archived_by_user_id_access_users",
"access_users",
["archived_by_user_id"],
["id"],
ondelete="SET NULL",
)
indexes = {
index["name"]
for index in sa.inspect(op.get_bind()).get_indexes("campaign_versions")
}
if "ix_campaign_versions_archived_at" not in indexes:
op.create_index(
"ix_campaign_versions_archived_at",
"campaign_versions",
["archived_at"],
unique=False,
)
if "ix_campaign_versions_archived_by_user_id" not in indexes:
op.create_index(
"ix_campaign_versions_archived_by_user_id",
"campaign_versions",
["archived_by_user_id"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
indexes = {
index["name"]
for index in inspector.get_indexes("campaign_versions")
}
for index_name in (
"ix_campaign_versions_archived_by_user_id",
"ix_campaign_versions_archived_at",
):
if index_name in indexes:
op.drop_index(index_name, table_name="campaign_versions")
columns = {
column["name"]
for column in sa.inspect(op.get_bind()).get_columns("campaign_versions")
}
with op.batch_alter_table("campaign_versions") as batch_op:
if "archived_by_user_id" in columns:
batch_op.drop_constraint(
"fk_campaign_versions_archived_by_user_id_access_users",
type_="foreignkey",
)
batch_op.drop_column("archived_by_user_id")
if "archived_at" in columns:
batch_op.drop_column("archived_at")
@@ -1736,6 +1736,17 @@ def build_campaign_version(
print_outputs_by_index=resolved_print_outputs_by_index, print_outputs_by_index=resolved_print_outputs_by_index,
), ),
) )
if recovery_operation is not None:
recovery_operation.checkpoint(
kind="build-storage-ready",
summary=(
"Generated Campaign objects were verified before domain commit"
),
evidence={
"storage_prefix": storage_prefix,
"storage": storage_manifest,
},
)
report_json = _campaign_build_report(result, files) report_json = _campaign_build_report(result, files)
report_json["built_by_user_id"] = user_id report_json["built_by_user_id"] = user_id
if resolved_print_outputs_by_index: if resolved_print_outputs_by_index:
+276 -7
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
from dataclasses import dataclass
import hashlib import hashlib
import json import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -9,12 +10,26 @@ from typing import Any, Callable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.recovery import (
RecoveryGuaranteeError,
RecoveryMode,
RecoveryPlan,
RecoveryStatus,
)
from govoplan_core.core.recovery_runtime import (
DurableRecoveryOperation,
RecoveryOperationBusy,
RecoveryOperationStateConflict,
begin_durable_recovery_operation,
)
from govoplan_core.core.object_storage import ( from govoplan_core.core.object_storage import (
StorageBackend, StorageBackend,
StorageBackendError, StorageBackendError,
StorageObjectMissing, StorageObjectMissing,
configured_storage_backend, configured_storage_backend,
) )
from govoplan_core.core.runtime_coordination import process_runtime_identity
from govoplan_core.db.session import get_database
from govoplan_core.settings import settings as core_settings from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
from govoplan_campaign.backend.runtime import get_settings from govoplan_campaign.backend.runtime import get_settings
@@ -36,6 +51,15 @@ FINAL_EML_SEND_STATUSES = {
} }
@dataclass(frozen=True, slots=True)
class _GeneratedArtifactRecovery:
operation: DurableRecoveryOperation
job_id: str
storage_key: str | None
local_path: str | None
storage: StorageBackend | None
def _cutoff(days: int | None, *, now: datetime) -> datetime | None: def _cutoff(days: int | None, *, now: datetime) -> datetime | None:
if days is None: if days is None:
return None return None
@@ -113,6 +137,203 @@ def _apply_raw_json_retention(
return result return result
def _artifact_locator_sha256(
*,
storage_key: str | None,
local_path: str | None,
) -> str:
return _json_sha256(
{
"storage_key": storage_key,
"local_path": local_path,
}
)
def _begin_generated_artifact_recovery(
*,
job: CampaignJob,
storage: StorageBackend | None,
) -> _GeneratedArtifactRecovery | None:
storage_key = str(job.eml_storage_key) if job.eml_storage_key else None
local_path = str(job.eml_local_path) if job.eml_local_path else None
locator_sha256 = _artifact_locator_sha256(
storage_key=storage_key,
local_path=local_path,
)
try:
started = begin_durable_recovery_operation(
get_database().SessionLocal,
identity=process_runtime_identity(),
module_id="campaigns",
operation_type="generated-artifact-retention",
idempotency_key=(
f"campaign-retention:{job.id}:{locator_sha256[:32]}"
),
request={
"tenant_id": job.tenant_id,
"campaign_id": job.campaign_id,
"version_id": job.campaign_version_id,
"job_id": job.id,
"message_sha256": job.eml_sha256,
"artifact_locator_sha256": locator_sha256,
},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=(
"the Campaign job is terminal and outside its retention window",
"no IMAP append or delivery outcome remains unresolved",
),
forward_recovery_steps=(
"verify whether each recorded artifact still exists",
"clear the database locator only after absence is established",
),
verification_steps=(
"reload the Campaign job through an independent session",
"probe every original object or local-development path",
),
),
precondition_evidence={
"job_id": job.id,
"queue_status": job.queue_status,
"send_status": job.send_status,
"imap_status": job.imap_status,
"message_sha256": job.eml_sha256,
"artifact_locator_sha256": locator_sha256,
},
lease_resource_key=f"campaign:retention:{job.tenant_id}:{job.id}",
lease_ttl_seconds=15 * 60,
resource_type="campaign_job",
resource_id=job.id,
metadata={
"resources": [
"postgresql",
"object-storage" if storage_key else "local-development-storage",
],
},
)
except (RecoveryOperationBusy, RecoveryOperationStateConflict):
return None
if started.replayed or started.operation is None:
return None
return _GeneratedArtifactRecovery(
operation=started.operation,
job_id=job.id,
storage_key=storage_key,
local_path=local_path,
storage=storage,
)
def _generated_artifact_recovery_evidence(
recovery: _GeneratedArtifactRecovery,
) -> tuple[str, dict[str, Any]]:
probes: dict[str, bool | None] = {}
if recovery.storage_key:
try:
if recovery.storage is None:
raise StorageBackendError("Artifact storage is unavailable")
probes["object_missing"] = not recovery.storage.exists(
recovery.storage_key
)
except (StorageBackendError, OSError):
probes["object_missing"] = None
if recovery.local_path:
try:
probes["local_path_missing"] = not Path(recovery.local_path).exists()
except OSError:
probes["local_path_missing"] = None
with get_database().SessionLocal() as evidence_session:
job = evidence_session.get(CampaignJob, recovery.job_id)
job_present = job is not None
metadata_cleared = bool(
job is None
or (
(
not recovery.storage_key
or job.eml_storage_key != recovery.storage_key
)
and (
not recovery.local_path
or job.eml_local_path != recovery.local_path
)
)
)
metadata_intact = bool(
job is not None
and job.eml_storage_key == recovery.storage_key
and job.eml_local_path == recovery.local_path
)
probe_values = tuple(probes.values())
probe_verified = bool(probe_values) and all(
value is not None for value in probe_values
)
artifacts_absent = probe_verified and all(value is True for value in probe_values)
artifacts_intact = probe_verified and all(value is False for value in probe_values)
evidence = {
"verified": probe_verified,
"checks": {
"job_state_reloaded": True,
"artifact_locations_probed": probe_verified,
},
"job_present": job_present,
"metadata_cleared": metadata_cleared,
"metadata_intact": metadata_intact,
"artifact_probes": probes,
}
if not probe_verified:
return "outcome_unknown", evidence
if artifacts_absent and metadata_cleared:
return "succeeded", evidence
if artifacts_intact and metadata_intact:
return "failed", evidence
return "recovery_required", evidence
def _finish_generated_artifact_recovery(
recovery: _GeneratedArtifactRecovery,
) -> None:
outcome, evidence = _generated_artifact_recovery_evidence(recovery)
if outcome == "succeeded":
recovery.operation.succeed(evidence=evidence)
elif outcome == "failed":
recovery.operation.reject(
summary="Generated Campaign artifacts were not deleted",
evidence=evidence,
)
elif outcome == "outcome_unknown":
recovery.operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary="Generated artifact deletion could not be verified",
evidence=evidence,
failure_summary="Artifact storage availability prevented verification",
)
else:
recovery.operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="Generated artifact retention is only partially complete",
evidence=evidence,
failure_summary="Artifact and Campaign metadata state require reconciliation",
)
def _finish_generated_artifact_recoveries(
recoveries: list[_GeneratedArtifactRecovery],
) -> None:
failures: list[Exception] = []
for recovery in recoveries:
try:
_finish_generated_artifact_recovery(recovery)
except Exception as exc: # preserve every operation's chance to close
failures.append(exc)
if failures:
raise RecoveryGuaranteeError(
f"{len(failures)} Campaign retention recovery operation(s) could not be finalized"
) from failures[0]
def _apply_eml_retention( def _apply_eml_retention(
session: Session, session: Session,
*, *,
@@ -120,6 +341,7 @@ def _apply_eml_retention(
now: datetime, now: datetime,
policy_for_campaign_id: Callable[[str | None], object], policy_for_campaign_id: Callable[[str | None], object],
storage: StorageBackend | None = None, storage: StorageBackend | None = None,
recovery_operations: list[_GeneratedArtifactRecovery] | None = None,
) -> dict[str, int]: ) -> dict[str, int]:
result = { result = {
"eligible": 0, "eligible": 0,
@@ -127,6 +349,7 @@ def _apply_eml_retention(
"files_deleted": 0, "files_deleted": 0,
"files_missing": 0, "files_missing": 0,
"delete_failed": 0, "delete_failed": 0,
"recovery_blocked": 0,
"skipped_not_final": 0, "skipped_not_final": 0,
} }
jobs = ( jobs = (
@@ -153,10 +376,22 @@ def _apply_eml_retention(
result["eligible"] += 1 result["eligible"] += 1
if dry_run: if dry_run:
continue continue
if job.eml_storage_key: active_storage = storage
active_storage = storage or configured_storage_backend( if job.eml_storage_key and active_storage is None:
active_storage = configured_storage_backend(
get_settings() or core_settings get_settings() or core_settings
) )
if recovery_operations is not None:
recovery = _begin_generated_artifact_recovery(
job=job,
storage=active_storage,
)
if recovery is None:
result["recovery_blocked"] += 1
continue
recovery_operations.append(recovery)
if job.eml_storage_key:
assert active_storage is not None
try: try:
if active_storage.exists(job.eml_storage_key): if active_storage.exists(job.eml_storage_key):
active_storage.delete(job.eml_storage_key) active_storage.delete(job.eml_storage_key)
@@ -219,8 +454,42 @@ def apply_campaign_retention(
now: datetime, now: datetime,
policy_for_campaign_id: Callable[[str | None], object], policy_for_campaign_id: Callable[[str | None], object],
) -> dict[str, dict[str, int]]: ) -> dict[str, dict[str, int]]:
return { recoveries: list[_GeneratedArtifactRecovery] = []
"raw_campaign_json": _apply_raw_json_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id), try:
"generated_eml": _apply_eml_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id), # Start external-effect fences before queries for database-only
"stored_report_detail": _apply_report_detail_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id), # redaction can autoflush unrelated changes in the caller session.
} generated_eml = _apply_eml_retention(
session,
dry_run=dry_run,
now=now,
policy_for_campaign_id=policy_for_campaign_id,
recovery_operations=None if dry_run else recoveries,
)
result = {
"raw_campaign_json": _apply_raw_json_retention(
session,
dry_run=dry_run,
now=now,
policy_for_campaign_id=policy_for_campaign_id,
),
"generated_eml": generated_eml,
"stored_report_detail": _apply_report_detail_retention(
session,
dry_run=dry_run,
now=now,
policy_for_campaign_id=policy_for_campaign_id,
),
}
if not dry_run:
# External artifact deletion and its locator update form one
# module-owned recovery boundary. The outer Policy audit commits
# separately after Campaign has verified this boundary.
session.commit()
except Exception:
session.rollback()
if recoveries:
_finish_generated_artifact_recoveries(recoveries)
raise
if recoveries:
_finish_generated_artifact_recoveries(recoveries)
return result
+2
View File
@@ -6,6 +6,7 @@ from govoplan_campaign.backend.routes.attachments import router as attachments_r
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
from govoplan_campaign.backend.routes.delivery import router as delivery_router from govoplan_campaign.backend.routes.delivery import router as delivery_router
from govoplan_campaign.backend.routes.jobs import router as jobs_router from govoplan_campaign.backend.routes.jobs import router as jobs_router
from govoplan_campaign.backend.routes.operations import router as operations_router
from govoplan_campaign.backend.routes.reports import router as reports_router from govoplan_campaign.backend.routes.reports import router as reports_router
from govoplan_campaign.backend.routes.sharing import router as sharing_router from govoplan_campaign.backend.routes.sharing import router as sharing_router
from govoplan_campaign.backend.routes.versions import router as versions_router from govoplan_campaign.backend.routes.versions import router as versions_router
@@ -13,6 +14,7 @@ from govoplan_campaign.backend.routes.versions import router as versions_router
router = APIRouter() router = APIRouter()
for workflow_router in ( for workflow_router in (
operations_router,
campaigns_router, campaigns_router,
versions_router, versions_router,
jobs_router, jobs_router,
@@ -23,6 +23,9 @@ from govoplan_campaign.backend.path_security import (
assert_server_safe_campaign_paths, assert_server_safe_campaign_paths,
) )
from govoplan_campaign.backend.campaign.loader import load_campaign_json from govoplan_campaign.backend.campaign.loader import load_campaign_json
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
)
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
from govoplan_campaign.backend.persistence.versions import ( from govoplan_campaign.backend.persistence.versions import (
is_version_final_locked, is_version_final_locked,
@@ -324,7 +327,7 @@ def preview_campaign_attachments(
include_unmatched=payload.include_unmatched, include_unmatched=payload.include_unmatched,
include_unlinked_candidates=payload.include_unlinked_candidates, include_unlinked_candidates=payload.include_unlinked_candidates,
) )
except CampaignPathSecurityError as exc: except (CampaignPathSecurityError, CampaignMailProfileBoundaryError) as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc ) from exc
+272 -42
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import copy
import dataclasses import dataclasses
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_campaign.backend.schemas import ( from govoplan_campaign.backend.schemas import (
@@ -12,6 +13,9 @@ from govoplan_campaign.backend.schemas import (
CampaignUpdateRequest, CampaignUpdateRequest,
CampaignCreateResponse, CampaignCreateResponse,
CampaignCreateMinimalRequest, CampaignCreateMinimalRequest,
CampaignCopyRequest,
CampaignLifecycleMutationRequest,
CampaignLifecyclePolicyResponse,
CampaignAddressLookupCandidate, CampaignAddressLookupCandidate,
CampaignAddressLookupResponse, CampaignAddressLookupResponse,
CampaignCalendarCatalogResponse, CampaignCalendarCatalogResponse,
@@ -56,13 +60,16 @@ from govoplan_campaign.backend.change_tracking import (
) )
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
CampaignJob,
CampaignVersion, CampaignVersion,
RecipientImportMappingProfile, RecipientImportMappingProfile,
) )
from govoplan_campaign.backend.campaign.postbox_targets import ( from govoplan_campaign.backend.campaign.postbox_targets import (
delivery_catalog_payload, delivery_catalog_payload,
) )
from govoplan_campaign.backend.campaign.lifecycle import (
assert_lifecycle_state_token,
campaign_lifecycle_policy,
)
from govoplan_campaign.backend.integrations import ( from govoplan_campaign.backend.integrations import (
calendar_integration, calendar_integration,
PostboxDeliveryUnavailable, PostboxDeliveryUnavailable,
@@ -120,6 +127,93 @@ CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source" CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
def _lifecycle_policy_for_mutation(
session: Session,
*,
campaign_id: str,
principal: ApiPrincipal,
expected_state_token: str,
action: str,
version_id: str | None = None,
) -> tuple[Campaign, dict[str, object]]:
_get_campaign_for_principal(session, campaign_id, principal, write=True)
campaign = (
session.query(Campaign)
.filter(
Campaign.id == campaign_id,
Campaign.tenant_id == principal.tenant_id,
)
.with_for_update()
.one()
)
policy = campaign_lifecycle_policy(
session,
campaign=campaign,
principal=principal,
version_id=version_id,
)
try:
assert_lifecycle_state_token(
str(policy["state_token"]),
expected_state_token,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
decision = policy["actions"][action] # type: ignore[index]
if not decision["allowed"]: # type: ignore[index]
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=decision["reason"], # type: ignore[index]
)
return campaign, policy
def _campaign_copy_external_id(
session: Session,
*,
tenant_id: str,
source_external_id: str,
requested: str | None,
) -> str:
if requested is not None:
candidate = requested.strip()
exists = (
session.query(Campaign.id)
.filter(
Campaign.tenant_id == tenant_id,
Campaign.external_id == candidate,
)
.first()
)
if exists is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Campaign ID already exists for this tenant",
)
return candidate
stem = f"{source_external_id[:240]}-copy"
for suffix in ("", *(f"-{number}" for number in range(2, 10_000))):
candidate = f"{stem[:255 - len(suffix)]}{suffix}"
exists = (
session.query(Campaign.id)
.filter(
Campaign.tenant_id == tenant_id,
Campaign.external_id == candidate,
)
.first()
)
if exists is None:
return candidate
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="No available campaign copy identifier could be generated.",
)
@router.post("", response_model=CampaignCreateResponse) @router.post("", response_model=CampaignCreateResponse)
def create_campaign( def create_campaign(
payload: CampaignCreateRequest, payload: CampaignCreateRequest,
@@ -1524,18 +1618,180 @@ def update_campaign_metadata_endpoint(
return CampaignResponse.model_validate(campaign) return CampaignResponse.model_validate(campaign)
@router.post("/{campaign_id}/archive", response_model=CampaignResponse) @router.get(
def archive_campaign( "/{campaign_id}/lifecycle-policy",
response_model=CampaignLifecyclePolicyResponse,
)
def get_campaign_lifecycle_policy(
campaign_id: str, campaign_id: str,
version_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal)
return campaign_lifecycle_policy(
session,
campaign=campaign,
principal=principal,
version_id=version_id,
)
@router.post("/{campaign_id}/copies", response_model=CampaignCreateResponse)
def copy_campaign(
campaign_id: str,
payload: CampaignCopyRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")),
):
source_campaign, _policy = _lifecycle_policy_for_mutation(
session,
campaign_id=campaign_id,
principal=principal,
expected_state_token=payload.expected_state_token,
action="copy_campaign",
version_id=payload.source_version_id,
)
_require_permission(principal, "campaigns:recipient:read")
source_version = (
session.query(CampaignVersion)
.filter(
CampaignVersion.id == payload.source_version_id,
CampaignVersion.campaign_id == source_campaign.id,
)
.one_or_none()
)
if source_version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign version not found",
)
external_id = _campaign_copy_external_id(
session,
tenant_id=principal.tenant_id,
source_external_id=source_campaign.external_id,
requested=payload.external_id,
)
name = (payload.name or f"{source_campaign.name} (copy)").strip()
raw_json = copy.deepcopy(source_version.raw_json)
campaign_metadata = raw_json.get("campaign")
if not isinstance(campaign_metadata, dict):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The selected source version has no valid campaign metadata.",
)
campaign_metadata["id"] = external_id
campaign_metadata["name"] = name
campaign_metadata["mode"] = "draft"
_require_mail_profile_use_if_needed(principal, raw_json)
try:
campaign, version = create_campaign_version_from_json(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
raw_json=raw_json,
source_filename=None,
source_base_path=source_version.source_base_path,
commit=False,
)
audit_from_principal(
session,
principal,
action="campaign.copied",
object_type="campaign",
object_id=campaign.id,
details={
"source_campaign_id": source_campaign.id,
"source_version_id": source_version.id,
"destination_version_id": version.id,
"copied_evidence": False,
},
commit=True,
)
except HTTPException:
session.rollback()
raise
except Exception:
session.rollback()
raise
session.refresh(campaign)
session.refresh(version)
return CampaignCreateResponse(
campaign=CampaignResponse.model_validate(campaign),
version=CampaignVersionResponse.model_validate(
version,
context=_campaign_response_context(principal),
),
)
@router.post(
"/{campaign_id}/versions/{version_id}/archive",
response_model=CampaignVersionResponse,
)
def archive_campaign_version(
campaign_id: str,
version_id: str,
payload: CampaignLifecycleMutationRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")),
): ):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) campaign, _policy = _lifecycle_policy_for_mutation(
if campaign.status in {"queued", "sending", "outcome_unknown"}: session,
raise HTTPException( campaign_id=campaign_id,
status_code=status.HTTP_409_CONFLICT, principal=principal,
detail="Active or uncertain delivery must be resolved before archiving.", expected_state_token=payload.expected_state_token,
action="archive_version",
version_id=version_id,
)
version = (
session.query(CampaignVersion)
.filter(
CampaignVersion.id == version_id,
CampaignVersion.campaign_id == campaign.id,
) )
.with_for_update()
.one()
)
version.archived_at = datetime.now(UTC)
version.archived_by_user_id = principal.user.id
session.add(version)
audit_from_principal(
session,
principal,
action="campaign.version_archived",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign.id,
"version_number": version.version_number,
"retained_evidence": True,
},
commit=True,
)
session.refresh(version)
return CampaignVersionResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
@router.post("/{campaign_id}/archive", response_model=CampaignResponse)
def archive_campaign(
campaign_id: str,
payload: CampaignLifecycleMutationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")),
):
campaign, _policy = _lifecycle_policy_for_mutation(
session,
campaign_id=campaign_id,
principal=principal,
expected_state_token=payload.expected_state_token,
action="archive_campaign",
)
campaign.status = "archived" campaign.status = "archived"
session.add(campaign) session.add(campaign)
audit_from_principal( audit_from_principal(
@@ -1554,43 +1810,17 @@ def archive_campaign(
@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_draft_campaign( def delete_draft_campaign(
campaign_id: str, campaign_id: str,
payload: CampaignLifecycleMutationRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:delete")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:delete")),
): ):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) campaign, _policy = _lifecycle_policy_for_mutation(
if campaign.status != "draft": session,
raise HTTPException( campaign_id=campaign_id,
status_code=status.HTTP_409_CONFLICT, principal=principal,
detail="Only untouched draft campaigns can be deleted.", expected_state_token=payload.expected_state_token,
) action="delete_campaign",
if (
session.query(CampaignJob.id)
.filter(CampaignJob.campaign_id == campaign.id)
.first()
is not None
):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Campaigns with built or delivery jobs must be archived instead of deleted.",
)
protected_version = (
session.query(CampaignVersion.id)
.filter(
CampaignVersion.campaign_id == campaign.id,
or_(
CampaignVersion.locked_at.is_not(None),
CampaignVersion.user_lock_state.is_not(None),
CampaignVersion.published_at.is_not(None),
CampaignVersion.execution_snapshot_at.is_not(None),
),
)
.first()
) )
if protected_version is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Audit-relevant campaign versions must be archived instead of deleted.",
)
campaign.status = "deleted" campaign.status = "deleted"
session.add(campaign) session.add(campaign)
audit_from_principal( audit_from_principal(
@@ -0,0 +1,102 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from govoplan_campaign.backend.artifact_reconciliation import (
CampaignArtifactReconciliationError,
reconcile_campaign_artifacts,
)
from govoplan_campaign.backend.persistence.campaigns import _object_storage
from govoplan_campaign.backend.schemas import (
CampaignArtifactReconcileRequest,
CampaignArtifactReconcileResponse,
)
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.object_storage import StorageBackendError
from govoplan_core.core.recovery import RecoveryGuaranteeError
from govoplan_core.core.recovery_runtime import (
RecoveryOperationBusy,
RecoveryOperationStateConflict,
)
from govoplan_core.db.session import get_database, get_session
from govoplan_core.server.runtime_agent import application_runtime_identity
router = APIRouter(prefix="/campaigns/operations", tags=["campaigns"])
@router.post(
"/artifacts/reconcile",
response_model=CampaignArtifactReconcileResponse,
)
def reconcile_artifacts(
payload: CampaignArtifactReconcileRequest,
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
) -> CampaignArtifactReconcileResponse:
"""Inventory or remove old, unreferenced Campaign-owned build objects."""
try:
result = reconcile_campaign_artifacts(
get_database().SessionLocal,
storage=_object_storage(),
identity=application_runtime_identity(request.app),
tenant_id=principal.tenant_id,
apply=payload.apply,
idempotency_key=payload.idempotency_key,
grace_period_hours=payload.grace_period_hours,
cursor=payload.cursor,
page_size=payload.page_size,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (
CampaignArtifactReconciliationError,
RecoveryGuaranteeError,
StorageBackendError,
SQLAlchemyError,
OSError,
) as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Campaign artifact reconciliation is temporarily unavailable "
f"({type(exc).__name__})."
),
) from exc
audit_from_principal(
session,
principal,
action=(
"campaign.artifact_orphans_reconciled"
if payload.apply
else "campaign.artifact_inventory_scanned"
),
object_type="campaign_artifact_namespace",
object_id=principal.tenant_id,
details={
"apply": payload.apply,
"status": result["status"],
"scanned_count": result["scanned_count"],
"candidate_count": result["candidate_count"],
"deleted_count": result["deleted_count"],
"failure_count": result["failure_count"],
"manifest_sha256": result["manifest_sha256"],
"recovery_operation_id": result["recovery_operation_id"],
},
commit=True,
)
return CampaignArtifactReconcileResponse.model_validate(result)
+76
View File
@@ -42,6 +42,18 @@ class CampaignUpdateRequest(BaseModel):
description: str | None = None description: str | None = None
class CampaignLifecycleMutationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_state_token: str = Field(min_length=64, max_length=64)
class CampaignCopyRequest(CampaignLifecycleMutationRequest):
source_version_id: str = Field(min_length=1, max_length=36)
external_id: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)
class CampaignCreateMinimalRequest(BaseModel): class CampaignCreateMinimalRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -141,6 +153,8 @@ class CampaignVersionResponse(BaseModel):
None None
) )
delivery_mode_selected_at: datetime | None = None delivery_mode_selected_at: datetime | None = None
archived_at: datetime | None = None
archived_by_user_id: str | None = None
@field_validator("editor_state", mode="before") @field_validator("editor_state", mode="before")
@classmethod @classmethod
@@ -200,6 +214,19 @@ class CampaignResponse(BaseModel):
updated_at: datetime updated_at: datetime
class CampaignLifecycleActionResponse(BaseModel):
allowed: bool
reason: str | None = None
class CampaignLifecyclePolicyResponse(BaseModel):
policy_id: str
policy_version: str
state_token: str
actions: dict[str, CampaignLifecycleActionResponse]
provenance: dict[str, Any]
class CampaignCreateResponse(BaseModel): class CampaignCreateResponse(BaseModel):
campaign: CampaignResponse campaign: CampaignResponse
version: CampaignVersionResponse version: CampaignVersionResponse
@@ -680,6 +707,55 @@ class BuildCampaignRequest(BaseModel):
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200) idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
class CampaignArtifactReconcileRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
apply: bool = False
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
grace_period_hours: int = Field(default=24, ge=24, le=24 * 90)
cursor: str | None = Field(default=None, min_length=1, max_length=1000)
page_size: int = Field(default=250, ge=1, le=1000)
@model_validator(mode="after")
def require_apply_idempotency_key(self) -> "CampaignArtifactReconcileRequest":
if self.apply and not self.idempotency_key:
raise ValueError("Applied artifact cleanup requires an idempotency key")
return self
class CampaignArtifactCandidateResponse(BaseModel):
key: str
size_bytes: int
modified_at: datetime
age_seconds: int
reason: str
disposition: str
failure_type: str | None = None
class CampaignArtifactReconcileResponse(BaseModel):
apply: bool
status: str
recovery_operation_id: str | None = None
tenant_prefix: str
cursor: str | None = None
next_cursor: str | None = None
scanned_count: int
scanned_bytes: int
referenced_count: int
active_build_count: int
young_count: int
unknown_age_count: int
invalid_shape_count: int
candidate_count: int
candidate_bytes: int
deleted_count: int
deleted_bytes: int
failure_count: int
manifest_sha256: str | None = None
candidates: list[CampaignArtifactCandidateResponse] = Field(default_factory=list)
class ApiKeyCreateRequest(BaseModel): class ApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -0,0 +1,251 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchIndexChange,
SearchResourceReference,
SearchResourceType,
)
from govoplan_campaign.backend.capabilities import CampaignAccessService
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
PROVIDER_ID = "campaigns.campaigns"
RESOURCE_TYPE = "campaign"
READ_SCOPE = "campaigns:campaign:read"
class CampaignSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="campaigns",
resource_type=RESOURCE_TYPE,
label="Campaigns",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
statement = select(Campaign).where(
Campaign.tenant_id == request.tenant_id,
Campaign.status != "deleted",
)
if request.cursor:
statement = statement.where(Campaign.id > request.cursor)
rows = list(
db.scalars(
statement.order_by(Campaign.id).limit(request.limit + 1)
)
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
shares = _shares_by_campaign(db, selected)
high_watermark = db.scalar(
select(func.max(Campaign.updated_at)).where(
Campaign.tenant_id == request.tenant_id,
Campaign.status != "deleted",
)
)
return SearchBackfillPage(
documents=tuple(
_document(row, shares=shares.get(row.id, ()))
for row in selected
),
next_cursor=selected[-1].id if has_more and selected else None,
complete=not has_more,
high_watermark=(
high_watermark.isoformat()
if high_watermark is not None
else None
),
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {item.reference.key: False for item in requests}
if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE):
return decisions
db = _session(session)
access = CampaignAccessService()
user_id = str(getattr(principal.user, "id", "") or principal.membership_id or "")
for request in requests:
reference = request.reference
if (
reference.tenant_id != principal.tenant_id
or reference.module_id != "campaigns"
or reference.resource_type != RESOURCE_TYPE
):
continue
decisions[reference.key] = access.can_read_campaign(
db,
tenant_id=principal.tenant_id,
campaign_id=reference.resource_id,
user_id=user_id,
group_ids=principal.group_ids,
tenant_admin=principal.has("tenant:*"),
)
return decisions
def index_changes_for_event(
self,
session: object,
*,
event: PlatformEvent,
delivery_key: str,
) -> Sequence[SearchIndexChange]:
if (
event.module_id != "campaigns"
or event.tenant is None
or event.resource is None
or event.resource.type != RESOURCE_TYPE
or event.resource.id is None
):
return ()
db = _session(session)
row = db.get(Campaign, event.resource.id)
deleted = row is None or row.tenant_id != event.tenant.id or row.status == "deleted"
cursor = event.event_id
document = None
if not deleted:
document = _document(
row,
shares=tuple(
db.scalars(
select(CampaignShare).where(
CampaignShare.campaign_id == row.id,
CampaignShare.revoked_at.is_(None),
)
)
),
change_cursor=cursor,
)
reference = SearchResourceReference(
tenant_id=event.tenant.id,
module_id="campaigns",
resource_type=RESOURCE_TYPE,
resource_id=event.resource.id,
)
return (
SearchIndexChange(
change_id=f"{delivery_key}:{PROVIDER_ID}",
provider_id=PROVIDER_ID,
kind="delete" if deleted else "upsert",
reference=reference,
source_revision=(
document.source_revision if document is not None else cursor
),
cursor=cursor,
document=document,
occurred_at=event.occurred_at,
),
)
def create_campaign_search_source(_context: ModuleContext) -> CampaignSearchSource:
return CampaignSearchSource()
def _document(
row: Campaign,
*,
shares: Sequence[CampaignShare],
change_cursor: str | None = None,
) -> SearchDocument:
tokens = [f"scope:{READ_SCOPE}"]
if row.owner_user_id:
tokens.append(f"membership:{row.owner_user_id}")
if row.owner_group_id:
tokens.append(f"group:{row.owner_group_id}")
for share in shares:
prefix = "membership" if share.target_type == "user" else share.target_type
if prefix in {"membership", "group"}:
tokens.append(f"{prefix}:{share.target_id}")
updated_at = row.updated_at or row.created_at
return SearchDocument(
tenant_id=row.tenant_id,
module_id="campaigns",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=row.id,
title=row.name,
url=f"/campaigns/{quote(row.id, safe='')}",
summary=row.description[:4000] if row.description else None,
body=" ".join(
value for value in (row.external_id, row.description) if value
)[:200_000],
keywords=(row.external_id[:200], row.status[:200]),
visibility="restricted",
acl_tokens=tuple(dict.fromkeys(tokens)),
metadata={
"external_id": row.external_id,
"status": row.status,
"current_version_id": row.current_version_id,
},
source_revision=f"{row.current_version_id or 'none'}:{updated_at.isoformat()}",
change_cursor=change_cursor,
source_updated_at=updated_at,
requires_authorization_recheck=True,
)
def _shares_by_campaign(
session: Session,
rows: Sequence[Campaign],
) -> dict[str, tuple[CampaignShare, ...]]:
ids = [row.id for row in rows]
grouped: dict[str, list[CampaignShare]] = {item: [] for item in ids}
if not ids:
return {}
for share in session.scalars(
select(CampaignShare).where(
CampaignShare.campaign_id.in_(ids),
CampaignShare.revoked_at.is_(None),
)
):
grouped.setdefault(share.campaign_id, []).append(share)
return {key: tuple(value) for key, value in grouped.items()}
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Campaign search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Campaign search requires a SQLAlchemy session.")
return value
__all__ = [
"CampaignSearchSource",
"PROVIDER_ID",
"RESOURCE_TYPE",
"create_campaign_search_source",
]
+658 -38
View File
@@ -15,6 +15,13 @@ from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.recovery import RecoveryMode, RecoveryPlan, RecoveryStatus
from govoplan_core.core.recovery_runtime import (
DurableRecoveryOperation,
RecoveryOperationBusy,
RecoveryOperationStateConflict,
begin_durable_recovery_operation,
)
from govoplan_core.core.notifications import ( from govoplan_core.core.notifications import (
NotificationDispatchRequest, NotificationDispatchRequest,
notification_dispatch_provider, notification_dispatch_provider,
@@ -23,7 +30,9 @@ from govoplan_core.core.object_storage import (
StorageBackendError, StorageBackendError,
configured_storage_backend, configured_storage_backend,
) )
from govoplan_core.core.runtime_coordination import process_runtime_identity
from govoplan_core.audit.logging import audit_event from govoplan_core.audit.logging import audit_event
from govoplan_core.db.session import get_database
from govoplan_core.security.redaction import redact_secret_values from govoplan_core.security.redaction import redact_secret_values
from govoplan_core.settings import settings as core_settings from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
@@ -1074,19 +1083,13 @@ def send_campaign_now(
skipped_after_queue = 0 skipped_after_queue = 0
for job in jobs: for job in jobs:
try: try:
claimed = _claimed_campaign_job_for_delivery(session, job) result = _deliver_job_with_recovery(
if isinstance(claimed, SendJobResult): session,
result = claimed job=job,
else: context=delivery_contexts[job.id],
claimed_job, claim_token = claimed use_rate_limit=use_rate_limit,
result = _send_claimed_campaign_job( enqueue_imap_task=enqueue_imap_task,
session, )
job=claimed_job,
claim_token=claim_token,
context=delivery_contexts[job.id],
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
result_dict = result.as_dict() result_dict = result.as_dict()
results.append(result_dict) results.append(result_dict)
if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}: if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}:
@@ -2096,6 +2099,134 @@ def _single_message_action_response(
} }
def _begin_single_action_delivery_recovery(
*,
action: CampaignMessageAction,
job: CampaignJob,
delivery_context: _SendJobDeliveryContext,
):
return begin_durable_recovery_operation(
get_database().SessionLocal,
identity=process_runtime_identity(),
module_id="campaigns",
operation_type="single-message-external-delivery",
idempotency_key=f"campaign-message-action:{action.id}",
request={
"tenant_id": action.tenant_id,
"campaign_id": action.campaign_id,
"version_id": action.campaign_version_id,
"job_id": action.job_id,
"action_id": action.id,
"action_kind": action.kind,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": action.recipient_manifest_sha256,
"smtp_transport_revision_sha256": hashlib.sha256(
str(delivery_context.snapshot.smtp_transport_revision).encode("utf-8")
).hexdigest(),
},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=(
"the immutable single-message action is durably recorded",
"the message and SMTP transport revision passed preflight",
),
forward_recovery_steps=(
"inspect the Campaign message action and SMTP provider evidence",
"reconcile acceptance, rejection, or an unknown outcome",
),
verification_steps=(
"reload the message action through an independent session",
"verify its terminal outcome and attempt evidence",
),
),
precondition_evidence={
"action_id": action.id,
"action_status": action.status,
"action_kind": action.kind,
"job_id": job.id,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": action.recipient_manifest_sha256,
},
lease_resource_key=f"campaign:message-action:{action.tenant_id}:{action.id}",
lease_ttl_seconds=15 * 60,
resource_type="campaign_message_action",
resource_id=action.id,
metadata={"resources": ["postgresql", "smtp"]},
)
def _single_action_recovery_evidence(
action_id: str,
) -> tuple[str, dict[str, Any]]:
with get_database().SessionLocal() as evidence_session:
action = evidence_session.get(CampaignMessageAction, action_id)
if action is None:
return "recovery_required", {
"verified": False,
"action_present": False,
}
latest = (
evidence_session.query(CampaignMessageActionAttempt)
.filter(CampaignMessageActionAttempt.action_id == action.id)
.order_by(CampaignMessageActionAttempt.attempt_number.desc())
.first()
)
evidence = {
"verified": True,
"checks": {
"action_state_reloaded": True,
"action_attempt_compared": True,
},
"action_present": True,
"action_id": action.id,
"action_kind": action.kind,
"action_status": action.status,
"attempt_status": latest.status if latest else None,
"accepted_count": action.accepted_count,
"refused_count": action.refused_count,
}
if action.status in {"accepted", "accepted_with_refusals"}:
return "succeeded", evidence
if action.status == "outcome_unknown":
return "outcome_unknown", evidence
if action.status in {
"failed_temporary",
"failed_permanent",
"initiation_failed",
}:
return "failed", evidence
return "recovery_required", evidence
def _finish_single_action_delivery_recovery(
operation: DurableRecoveryOperation,
*,
action_id: str,
) -> None:
outcome, evidence = _single_action_recovery_evidence(action_id)
if outcome == "succeeded":
operation.succeed(evidence=evidence)
elif outcome == "failed":
operation.reject(
summary="The single-message SMTP effect was definitively rejected",
evidence=evidence,
)
elif outcome == "outcome_unknown":
operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary="The single-message SMTP outcome requires reconciliation",
evidence=evidence,
failure_summary="The message may have been accepted by SMTP",
)
else:
operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="The single-message action did not reach a verified terminal state",
evidence=evidence,
failure_summary="The message action requires forward recovery",
)
def _send_single_message_direct( def _send_single_message_direct(
session: Session, session: Session,
*, *,
@@ -2127,6 +2258,53 @@ def _send_single_message_direct(
messages_per_minute=delivery_context.snapshot.delivery.rate_limit.messages_per_minute, messages_per_minute=delivery_context.snapshot.delivery.rate_limit.messages_per_minute,
enabled=use_rate_limit, enabled=use_rate_limit,
) )
try:
recovery_start = _begin_single_action_delivery_recovery(
action=action,
job=job,
delivery_context=delivery_context,
)
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
_finish_single_message_action(
session,
action=action,
status="initiation_failed",
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
raise QueueingError(str(exc)) from exc
if recovery_start.replayed or recovery_start.operation is None:
session.refresh(action)
return _single_message_action_response(action, duplicate=True)
operation = recovery_start.operation
try:
result = _perform_single_message_direct_effect(
session,
campaign=campaign,
version=version,
job=job,
action=action,
delivery_context=delivery_context,
enqueue_imap_task=enqueue_imap_task,
)
except Exception:
_finish_single_action_delivery_recovery(operation, action_id=action.id)
raise
_finish_single_action_delivery_recovery(operation, action_id=action.id)
return result
def _perform_single_message_direct_effect(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
job: CampaignJob,
action: CampaignMessageAction,
delivery_context: _SendJobDeliveryContext,
enqueue_imap_task: bool,
) -> dict[str, Any]:
attempt = _start_single_message_action_attempt(session, action) attempt = _start_single_message_action_attempt(session, action)
try: try:
result = mail_integration().send_campaign_email_bytes( result = mail_integration().send_campaign_email_bytes(
@@ -2143,6 +2321,11 @@ def _send_single_message_direct(
), ),
smtp_server_id=delivery_context.snapshot.smtp_server_id, smtp_server_id=delivery_context.snapshot.smtp_server_id,
smtp_credential_id=delivery_context.snapshot.smtp_credential_id, smtp_credential_id=delivery_context.snapshot.smtp_credential_id,
recovery_effect_id=(
f"campaign-single:{action.id}:smtp-attempt:{attempt.attempt_number}"
),
recovery_resource_type="campaign_message_action",
recovery_resource_id=action.id,
) )
except SmtpSendError as exc: except SmtpSendError as exc:
if exc.outcome_unknown: if exc.outcome_unknown:
@@ -2726,7 +2909,12 @@ def _recipients_from_job(job: CampaignJob) -> list[str]:
return list(dict.fromkeys(recipients)) return list(dict.fromkeys(recipients))
def _claim_job_for_sending(session: Session, job: CampaignJob) -> str | None: def _claim_job_for_sending(
session: Session,
job: CampaignJob,
*,
claim_token: str | None = None,
) -> str | None:
"""Atomically claim a queued job and return the claim token. """Atomically claim a queued job and return the claim token.
A duplicate task can observe CLAIMED/SENDING but cannot acquire a second A duplicate task can observe CLAIMED/SENDING but cannot acquire a second
@@ -2735,7 +2923,7 @@ def _claim_job_for_sending(session: Session, job: CampaignJob) -> str | None:
could race a slow worker. could race a slow worker.
""" """
claim_token = str(uuid4()) effective_claim_token = claim_token or str(uuid4())
changed = ( changed = (
session.query(CampaignJob) session.query(CampaignJob)
.filter( .filter(
@@ -2748,7 +2936,7 @@ def _claim_job_for_sending(session: Session, job: CampaignJob) -> str | None:
CampaignJob.queue_status: JobQueueStatus.SENDING.value, CampaignJob.queue_status: JobQueueStatus.SENDING.value,
CampaignJob.send_status: JobSendStatus.CLAIMED.value, CampaignJob.send_status: JobSendStatus.CLAIMED.value,
CampaignJob.claimed_at: _utcnow(), CampaignJob.claimed_at: _utcnow(),
CampaignJob.claim_token: claim_token, CampaignJob.claim_token: effective_claim_token,
CampaignJob.last_error: None, CampaignJob.last_error: None,
}, },
synchronize_session=False, synchronize_session=False,
@@ -2756,7 +2944,7 @@ def _claim_job_for_sending(session: Session, job: CampaignJob) -> str | None:
) )
session.commit() session.commit()
session.expire_all() session.expire_all()
return claim_token if changed == 1 else None return effective_claim_token if changed == 1 else None
def _record_attempt_start( def _record_attempt_start(
@@ -2946,6 +3134,184 @@ def _campaign_delivery_status_pair(
return None return None
def _canonical_delivery_sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
default=str,
).encode("utf-8")
).hexdigest()
def _begin_job_delivery_recovery(
*,
job: CampaignJob,
context: _SendJobDeliveryContext,
claim_token: str,
):
channel_policy = DeliveryChannelPolicy(job.delivery_channel_policy)
resources = ["postgresql"]
if channel_policy.uses_mail:
resources.append("smtp")
if channel_policy.uses_postbox:
resources.append("postbox")
if channel_policy.uses_print:
resources.append("print-provider")
claim_sha256 = hashlib.sha256(claim_token.encode("utf-8")).hexdigest()
recipient_manifest_sha256 = _canonical_delivery_sha256(
_recipients_from_job(job)
)
return begin_durable_recovery_operation(
get_database().SessionLocal,
identity=process_runtime_identity(),
module_id="campaigns",
operation_type="external-channel-delivery",
idempotency_key=f"campaign-delivery:{job.id}:{claim_sha256[:32]}",
request={
"tenant_id": job.tenant_id,
"campaign_id": job.campaign_id,
"version_id": job.campaign_version_id,
"job_id": job.id,
"channel_policy": channel_policy.value,
"message_sha256": job.eml_sha256,
"recipient_manifest_sha256": recipient_manifest_sha256,
"execution_snapshot_sha256": context.version.execution_snapshot_hash,
},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=(
"the immutable built job is explicitly queued",
"message and transport revisions passed preflight",
),
forward_recovery_steps=(
"inspect provider and module attempt evidence",
"reconcile accepted, rejected, or outcome-unknown channel state",
),
verification_steps=(
"reload the Campaign job through an independent session",
"compare final channel and attempt states",
),
),
precondition_evidence={
"job_id": job.id,
"queue_status": job.queue_status,
"send_status": job.send_status,
"build_status": job.build_status,
"validation_status": job.validation_status,
"message_sha256": job.eml_sha256,
"recipient_manifest_sha256": recipient_manifest_sha256,
"claim_sha256": claim_sha256,
},
lease_resource_key=f"campaign:delivery:{job.tenant_id}:{job.id}",
lease_ttl_seconds=30 * 60,
resource_type="campaign_job",
resource_id=job.id,
metadata={
"resources": resources,
"channel_policy": channel_policy.value,
},
)
def _job_delivery_recovery_evidence(job_id: str) -> tuple[str, dict[str, Any]]:
with get_database().SessionLocal() as evidence_session:
job = evidence_session.get(CampaignJob, job_id)
if job is None:
return "recovery_required", {
"verified": False,
"job_present": False,
}
latest_smtp = (
evidence_session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.desc())
.first()
)
latest_postbox = (
evidence_session.query(PostboxDeliveryAttempt)
.filter(PostboxDeliveryAttempt.job_id == job.id)
.order_by(PostboxDeliveryAttempt.attempt_number.desc())
.first()
)
latest_print = (
evidence_session.query(PrintOutputAttempt)
.filter(PrintOutputAttempt.job_id == job.id)
.order_by(PrintOutputAttempt.attempt_number.desc())
.first()
)
evidence = {
"verified": True,
"checks": {
"job_state_reloaded": True,
"channel_attempts_compared": True,
},
"job_present": True,
"job_id": job.id,
"queue_status": job.queue_status,
"send_status": job.send_status,
"postbox_status": job.postbox_status,
"print_status": job.print_status,
"attempt_count": job.attempt_count,
"postbox_attempt_count": job.postbox_attempt_count,
"print_attempt_count": job.print_attempt_count,
"latest_attempts": {
"smtp": latest_smtp.status if latest_smtp else None,
"postbox": latest_postbox.status if latest_postbox else None,
"print": latest_print.status if latest_print else None,
},
}
if (
job.send_status == JobSendStatus.OUTCOME_UNKNOWN.value
or job.postbox_status == JobPostboxStatus.OUTCOME_UNKNOWN.value
):
return "outcome_unknown", evidence
if (
job.send_status
in {JobSendStatus.CLAIMED.value, JobSendStatus.SENDING.value}
or job.postbox_status == JobPostboxStatus.DELIVERING.value
or job.print_status == JobPrintStatus.ACCEPTING.value
):
return "recovery_required", evidence
if job.send_status in {
*FULLY_ACCEPTED_STATUSES,
JobSendStatus.PARTIALLY_ACCEPTED.value,
}:
return "succeeded", evidence
return "failed", evidence
def _finish_job_delivery_recovery(
operation: DurableRecoveryOperation,
*,
job_id: str,
) -> None:
outcome, evidence = _job_delivery_recovery_evidence(job_id)
if outcome == "succeeded":
operation.succeed(evidence=evidence)
elif outcome == "failed":
operation.reject(
summary="Campaign delivery completed with a verified provider rejection",
evidence=evidence,
)
elif outcome == "outcome_unknown":
operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary="Campaign delivery provider outcome requires reconciliation",
evidence=evidence,
failure_summary="A Campaign delivery effect may have been accepted",
)
else:
operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="Campaign delivery did not reach a verified terminal state",
evidence=evidence,
failure_summary="Campaign delivery state requires forward recovery",
)
def send_campaign_job( def send_campaign_job(
session: Session, session: Session,
*, *,
@@ -2986,20 +3352,82 @@ def send_campaign_job(
message=f"Would deliver via {'; '.join(descriptions)}", message=f"Would deliver via {'; '.join(descriptions)}",
) )
claimed = _claimed_campaign_job_for_delivery(session, job) return _deliver_job_with_recovery(
if isinstance(claimed, SendJobResult):
return claimed
claimed_job, claim_token = claimed
return _send_claimed_campaign_job(
session, session,
job=claimed_job, job=job,
claim_token=claim_token,
context=context, context=context,
use_rate_limit=use_rate_limit, use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task, enqueue_imap_task=enqueue_imap_task,
) )
def _deliver_job_with_recovery(
session: Session,
*,
job: CampaignJob,
context: _SendJobDeliveryContext,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> SendJobResult:
claim_token = str(uuid4())
try:
recovery_start = _begin_job_delivery_recovery(
job=job,
context=context,
claim_token=claim_token,
)
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
return SendJobResult(
job_id=job.id,
status="recovery_blocked",
attempt_number=job.attempt_count,
message=str(exc),
)
if recovery_start.replayed or recovery_start.operation is None:
return SendJobResult(
job_id=job.id,
status="already_completed",
attempt_number=job.attempt_count,
message="The durable delivery operation already completed.",
)
recovery_operation = recovery_start.operation
claimed = _claimed_campaign_job_for_delivery(
session,
job,
claim_token=claim_token,
)
if isinstance(claimed, SendJobResult):
recovery_operation.reject(
summary="Campaign job could not be claimed before any provider effect",
evidence={
"verified": True,
"checks": {
"provider_effect_started": False,
"claim_rejected": True,
},
"provider_effect_started": False,
"job_id": job.id,
"claim_result": claimed.status,
},
)
return claimed
claimed_job, claim_token = claimed
try:
result = _send_claimed_campaign_job(
session,
job=claimed_job,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
except Exception:
_finish_job_delivery_recovery(recovery_operation, job_id=job.id)
raise
_finish_job_delivery_recovery(recovery_operation, job_id=job.id)
return result
def _preflight_send_campaign_job( def _preflight_send_campaign_job(
session: Session, session: Session,
job: CampaignJob, job: CampaignJob,
@@ -3100,15 +3528,21 @@ def _send_job_delivery_context(
def _claimed_campaign_job_for_delivery( def _claimed_campaign_job_for_delivery(
session: Session, session: Session,
job: CampaignJob, job: CampaignJob,
*,
claim_token: str | None = None,
) -> tuple[CampaignJob, str] | SendJobResult: ) -> tuple[CampaignJob, str] | SendJobResult:
claim_token = _claim_job_for_sending(session, job) effective_claim = _claim_job_for_sending(
if claim_token is None: session,
job,
claim_token=claim_token,
)
if effective_claim is None:
return _not_claimed_send_job_result(session, job) return _not_claimed_send_job_result(session, job)
job = session.get(CampaignJob, job.id) job = session.get(CampaignJob, job.id)
if job is None: if job is None:
raise SendJobError("Claimed campaign job disappeared before send.") raise SendJobError("Claimed campaign job disappeared before send.")
return job, claim_token return job, effective_claim
def _not_claimed_send_job_result(session: Session, job: CampaignJob) -> SendJobResult: def _not_claimed_send_job_result(session: Session, job: CampaignJob) -> SendJobResult:
@@ -3204,6 +3638,11 @@ def _send_claimed_mail_only_job(
or "", or "",
smtp_server_id=context.snapshot.smtp_server_id, smtp_server_id=context.snapshot.smtp_server_id,
smtp_credential_id=context.snapshot.smtp_credential_id, smtp_credential_id=context.snapshot.smtp_credential_id,
recovery_effect_id=(
f"campaign-job:{job.id}:smtp-attempt:{attempt.attempt_number}"
),
recovery_resource_type="campaign_job",
recovery_resource_id=job.id,
) )
if result.accepted_count <= 0: if result.accepted_count <= 0:
raise SmtpSendError( raise SmtpSendError(
@@ -3878,7 +4317,12 @@ def _imap_attempt_count(session: Session, job_id: str) -> int:
) )
def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None: def _claim_job_for_imap_append(
session: Session,
job: CampaignJob,
*,
claim_token: str | None = None,
) -> str | None:
"""Atomically grant one worker permission to invoke the IMAP provider.""" """Atomically grant one worker permission to invoke the IMAP provider."""
if not _mail_was_accepted( if not _mail_was_accepted(
@@ -3891,7 +4335,7 @@ def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None
), ),
): ):
return None return None
claim_token = str(uuid4()) effective_claim_token = claim_token or str(uuid4())
changed = ( changed = (
session.query(CampaignJob) session.query(CampaignJob)
.filter( .filter(
@@ -3905,7 +4349,7 @@ def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None
{ {
CampaignJob.imap_status: JobImapStatus.APPENDING.value, CampaignJob.imap_status: JobImapStatus.APPENDING.value,
CampaignJob.imap_claimed_at: _utcnow(), CampaignJob.imap_claimed_at: _utcnow(),
CampaignJob.imap_claim_token: claim_token, CampaignJob.imap_claim_token: effective_claim_token,
CampaignJob.last_error: None, CampaignJob.last_error: None,
}, },
synchronize_session=False, synchronize_session=False,
@@ -3913,7 +4357,7 @@ def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None
) )
session.commit() session.commit()
session.expire_all() session.expire_all()
return claim_token if changed == 1 else None return effective_claim_token if changed == 1 else None
def _record_imap_attempt_start( def _record_imap_attempt_start(
@@ -4269,10 +4713,17 @@ def _imap_append_dry_run(
def _claim_imap_append( def _claim_imap_append(
session: Session, job: CampaignJob session: Session,
job: CampaignJob,
*,
claim_token: str | None = None,
) -> _ClaimedImapAppend | AppendSentResult: ) -> _ClaimedImapAppend | AppendSentResult:
claim_token = _claim_job_for_imap_append(session, job) effective_claim = _claim_job_for_imap_append(
if claim_token is None: session,
job,
claim_token=claim_token,
)
if effective_claim is None:
current = session.get(CampaignJob, job.id) current = session.get(CampaignJob, job.id)
if current is None: if current is None:
raise SendJobError(f"Job disappeared while claiming IMAP append: {job.id}") raise SendJobError(f"Job disappeared while claiming IMAP append: {job.id}")
@@ -4290,8 +4741,8 @@ def _claim_imap_append(
raise SendJobError("Claimed campaign job disappeared before IMAP append") raise SendJobError("Claimed campaign job disappeared before IMAP append")
return _ClaimedImapAppend( return _ClaimedImapAppend(
job=claimed_job, job=claimed_job,
attempt=_record_imap_attempt_start(session, claimed_job, claim_token), attempt=_record_imap_attempt_start(session, claimed_job, effective_claim),
claim_token=claim_token, claim_token=effective_claim,
) )
@@ -4339,6 +4790,11 @@ def _invoke_imap_append(
smtp_credential_id=snapshot.smtp_credential_id, smtp_credential_id=snapshot.smtp_credential_id,
imap_server_id=snapshot.imap_server_id, imap_server_id=snapshot.imap_server_id,
imap_credential_id=snapshot.imap_credential_id, imap_credential_id=snapshot.imap_credential_id,
recovery_effect_id=(
f"campaign-job:{job.id}:imap-attempt:{claimed.attempt.attempt_number}"
),
recovery_resource_type="campaign_job",
recovery_resource_id=job.id,
) )
@@ -4392,6 +4848,127 @@ def _perform_imap_append(
) )
def _begin_imap_append_recovery(
*,
job: CampaignJob,
context: _ImapAppendContext,
claim_token: str,
):
claim_sha256 = hashlib.sha256(claim_token.encode("utf-8")).hexdigest()
return begin_durable_recovery_operation(
get_database().SessionLocal,
identity=process_runtime_identity(),
module_id="campaigns",
operation_type="imap-sent-append",
idempotency_key=f"campaign-imap:{job.id}:{claim_sha256[:32]}",
request={
"tenant_id": job.tenant_id,
"campaign_id": job.campaign_id,
"version_id": job.campaign_version_id,
"job_id": job.id,
"message_sha256": job.eml_sha256,
"folder_sha256": hashlib.sha256(
context.folder.encode("utf-8")
).hexdigest(),
"imap_transport_revision_sha256": hashlib.sha256(
context.snapshot.imap_transport_revision.encode("utf-8")
).hexdigest(),
},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=(
"SMTP acceptance is durable",
"the exact EML and IMAP transport revision passed preflight",
),
forward_recovery_steps=(
"inspect the target mailbox and append attempt",
"reconcile as appended or not appended before retry",
),
verification_steps=(
"reload the Campaign IMAP state through an independent session",
"compare the latest append attempt",
),
),
precondition_evidence={
"job_id": job.id,
"send_status": job.send_status,
"imap_status": job.imap_status,
"message_sha256": job.eml_sha256,
"claim_sha256": claim_sha256,
},
lease_resource_key=f"campaign:imap:{job.tenant_id}:{job.id}",
lease_ttl_seconds=15 * 60,
resource_type="campaign_job",
resource_id=job.id,
metadata={"resources": ["postgresql", "imap"]},
)
def _imap_recovery_evidence(job_id: str) -> tuple[str, dict[str, Any]]:
with get_database().SessionLocal() as evidence_session:
job = evidence_session.get(CampaignJob, job_id)
if job is None:
return "recovery_required", {
"verified": False,
"job_present": False,
}
latest = (
evidence_session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id == job.id)
.order_by(ImapAppendAttempt.attempt_number.desc())
.first()
)
evidence = {
"verified": True,
"checks": {
"job_state_reloaded": True,
"append_attempt_compared": True,
},
"job_present": True,
"job_id": job.id,
"send_status": job.send_status,
"imap_status": job.imap_status,
"attempt_count": _imap_attempt_count(evidence_session, job.id),
"latest_attempt_status": latest.status if latest else None,
}
if job.imap_status == JobImapStatus.APPENDED.value:
return "succeeded", evidence
if job.imap_status == JobImapStatus.FAILED.value:
return "failed", evidence
if job.imap_status == JobImapStatus.OUTCOME_UNKNOWN.value:
return "outcome_unknown", evidence
return "recovery_required", evidence
def _finish_imap_append_recovery(
operation: DurableRecoveryOperation,
*,
job_id: str,
) -> None:
outcome, evidence = _imap_recovery_evidence(job_id)
if outcome == "succeeded":
operation.succeed(evidence=evidence)
elif outcome == "failed":
operation.reject(
summary="The IMAP provider definitively rejected the append",
evidence=evidence,
)
elif outcome == "outcome_unknown":
operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary="The IMAP append outcome requires mailbox reconciliation",
evidence=evidence,
failure_summary="The exact message may already exist in the Sent folder",
)
else:
operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="The IMAP append did not reach a verified terminal state",
evidence=evidence,
failure_summary="The IMAP append requires forward recovery",
)
def append_sent_for_job( def append_sent_for_job(
session: Session, *, job_id: str, dry_run: bool = False session: Session, *, job_id: str, dry_run: bool = False
) -> AppendSentResult: ) -> AppendSentResult:
@@ -4408,10 +4985,53 @@ def append_sent_for_job(
return prepared return prepared
if dry_run: if dry_run:
return _imap_append_dry_run(session, job, prepared) return _imap_append_dry_run(session, job, prepared)
claimed = _claim_imap_append(session, job) claim_token = str(uuid4())
try:
recovery_start = _begin_imap_append_recovery(
job=job,
context=prepared,
claim_token=claim_token,
)
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
return AppendSentResult(
job_id=job.id,
status="recovery_blocked",
attempt_number=_imap_attempt_count(session, job.id),
message=str(exc),
)
if recovery_start.replayed or recovery_start.operation is None:
return AppendSentResult(
job_id=job.id,
status="already_completed",
attempt_number=_imap_attempt_count(session, job.id),
)
recovery_operation = recovery_start.operation
claimed = _claim_imap_append(
session,
job,
claim_token=claim_token,
)
if isinstance(claimed, AppendSentResult): if isinstance(claimed, AppendSentResult):
recovery_operation.reject(
summary="The IMAP append claim was rejected before any provider effect",
evidence={
"verified": True,
"checks": {
"provider_effect_started": False,
"claim_rejected": True,
},
"provider_effect_started": False,
"claim_result": claimed.status,
},
)
return claimed return claimed
return _perform_imap_append(session, claimed, prepared) try:
result = _perform_imap_append(session, claimed, prepared)
except Exception:
_finish_imap_append_recovery(recovery_operation, job_id=job.id)
raise
_finish_imap_append_recovery(recovery_operation, job_id=job.id)
return result
def enqueue_pending_imap_appends( def enqueue_pending_imap_appends(
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import pytest
from govoplan_core.core import runtime_coordination
from govoplan_core.core.runtime_coordination import (
RuntimeIdentity,
bind_process_runtime_identity,
)
@pytest.fixture(scope="session", autouse=True)
def _bind_test_runtime_identity():
previous = runtime_coordination._process_runtime_identity
bind_process_runtime_identity(
RuntimeIdentity(
installation_id="campaign-tests",
node_id="campaign-test-process",
incarnation="campaign-test-incarnation",
role="test",
software_version="test",
composition_hash="a" * 64,
)
)
try:
yield
finally:
bind_process_runtime_identity(previous)
+337
View File
@@ -0,0 +1,337 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_campaign.backend.artifact_reconciliation import (
campaign_artifact_inventory,
reconcile_campaign_artifacts,
)
from govoplan_core.core.object_storage import (
StorageBackendError,
StorageObjectInfo,
StorageObjectMissing,
StorageObjectPage,
)
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
from govoplan_core.core.recovery_runtime import RecoveryOperationBusy
from govoplan_core.core.runtime_coordination import (
DistributedLease,
RuntimeIdentity,
acquire_lease,
)
NOW = datetime(2026, 8, 3, 12, 0, tzinfo=timezone.utc)
PREFIX = "campaign-artifacts/tenant-1/campaign-1/version-1/"
class _MemoryStorage:
name = "memory"
def __init__(self) -> None:
self.objects: dict[str, bytes] = {}
self.modified_at: dict[str, datetime | None] = {}
self.delete_failures: set[str] = set()
self.exists_failures: set[str] = set()
def add(
self,
key: str,
*,
payload: bytes = b"artifact",
modified_at: datetime | None = NOW - timedelta(days=2),
) -> None:
self.objects[key] = payload
self.modified_at[key] = modified_at
def put_bytes(self, key: str, data: bytes, **_kwargs) -> None:
self.add(key, payload=data, modified_at=NOW)
def get_bytes(self, key: str) -> bytes:
try:
return self.objects[key]
except KeyError as exc:
raise StorageObjectMissing("missing") from exc
def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024):
del chunk_size
yield self.get_bytes(key)
def delete(self, key: str) -> None:
if key in self.delete_failures:
raise StorageBackendError("delete unavailable")
self.objects.pop(key, None)
self.modified_at.pop(key, None)
def exists(self, key: str) -> bool:
if key in self.exists_failures:
raise StorageBackendError("probe unavailable")
return key in self.objects
def stat(self, key: str) -> StorageObjectInfo:
if key not in self.objects:
raise StorageObjectMissing("missing")
return StorageObjectInfo(
key=key,
size_bytes=len(self.objects[key]),
modified_at=self.modified_at[key],
)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
keys = [
key
for key in sorted(self.objects)
if key.startswith(prefix) and (after is None or key > after)
]
selected = keys[:limit]
return StorageObjectPage(
objects=tuple(self.stat(key) for key in selected),
next_cursor=(selected[-1] if len(keys) > len(selected) else None),
)
@pytest.fixture
def recovery_session_factory():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
DistributedLease.__table__.create(engine)
RecoveryOperation.__table__.create(engine)
RecoveryCheckpoint.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
def _identity(*, node_id: str = "node-1", incarnation: str = "run-1"):
return RuntimeIdentity(
installation_id="campaign-artifact-tests",
node_id=node_id,
incarnation=incarnation,
role="worker",
software_version="test",
composition_hash="a" * 64,
)
def _without_domain_references():
return (
patch(
"govoplan_campaign.backend.artifact_reconciliation._referenced_artifact_keys",
return_value=set(),
),
patch(
"govoplan_campaign.backend.artifact_reconciliation._active_build_ids",
return_value=set(),
),
)
def test_inventory_classifies_reference_grace_active_build_and_unknown_age() -> None:
storage = _MemoryStorage()
orphan = f"{PREFIX}build-orphan/message.eml"
referenced = f"{PREFIX}build-referenced/message.eml"
active = f"{PREFIX}build-active/message.eml"
young = f"{PREFIX}build-young/message.eml"
unknown_age = f"{PREFIX}build-unknown/message.eml"
malformed = "campaign-artifacts/tenant-1/not-a-build-object"
storage.add(orphan)
storage.add(referenced)
storage.add(active)
storage.add(young, modified_at=NOW - timedelta(hours=1))
storage.add(unknown_age, modified_at=None)
storage.add(malformed)
with (
patch(
"govoplan_campaign.backend.artifact_reconciliation._referenced_artifact_keys",
return_value={referenced},
),
patch(
"govoplan_campaign.backend.artifact_reconciliation._active_build_ids",
return_value={"build-active"},
),
):
inventory = campaign_artifact_inventory(
object(), # type: ignore[arg-type]
storage=storage,
tenant_id="tenant-1",
grace_period=timedelta(hours=24),
page_size=20,
now=NOW,
)
assert [candidate.key for candidate in inventory.candidates] == [orphan]
assert inventory.referenced_count == 1
assert inventory.active_build_count == 1
assert inventory.young_count == 1
assert inventory.unknown_age_count == 1
assert inventory.invalid_shape_count == 1
def test_process_loss_orphan_is_deleted_once_and_same_request_replays(
recovery_session_factory,
) -> None:
storage = _MemoryStorage()
orphan = f"{PREFIX}lost-build/message.eml"
storage.add(orphan)
reference_patch, active_patch = _without_domain_references()
with reference_patch, active_patch:
dry_run = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
now=NOW,
)
applied = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="cleanup-lost-build",
now=NOW,
)
replayed = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="cleanup-lost-build",
now=NOW,
)
repeated = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="cleanup-empty-page",
now=NOW,
)
assert dry_run["candidate_count"] == 1
assert dry_run["deleted_count"] == 0
assert applied["status"] == "applied"
assert applied["deleted_count"] == 1
assert orphan not in storage.objects
assert replayed["status"] == "already_completed"
assert repeated["candidate_count"] == 0
def test_competing_node_cannot_acquire_cleanup_authority(
recovery_session_factory,
) -> None:
with recovery_session_factory() as session:
claim = acquire_lease(
session,
installation_id="campaign-artifact-tests",
resource_key="campaign:artifact-reconcile:tenant-1",
holder_node_id="node-other",
holder_incarnation="run-other",
ttl_seconds=900,
now=NOW,
)
assert claim is not None
session.commit()
storage = _MemoryStorage()
reference_patch, active_patch = _without_domain_references()
with reference_patch, active_patch, pytest.raises(RecoveryOperationBusy):
reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="competing-cleanup",
now=NOW,
)
def test_partial_storage_outage_remains_visible_and_retryable(
recovery_session_factory,
) -> None:
storage = _MemoryStorage()
removed = f"{PREFIX}build-a/message.eml"
retained = f"{PREFIX}build-b/message.eml"
storage.add(removed)
storage.add(retained)
storage.delete_failures.add(retained)
reference_patch, active_patch = _without_domain_references()
with reference_patch, active_patch:
partial = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="partial-cleanup",
now=NOW,
)
storage.delete_failures.clear()
retry = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="partial-cleanup-retry",
now=NOW,
)
assert partial["status"] == "recovery_required"
assert partial["deleted_count"] == 1
assert partial["failure_count"] == 1
assert removed not in storage.objects
assert retry["status"] == "applied"
assert retry["deleted_count"] == 1
assert storage.objects == {}
with recovery_session_factory() as session:
states = session.execute(
select(RecoveryOperation.status).order_by(RecoveryOperation.created_at)
).scalars().all()
assert "recovery_required" in states
assert states[-1] == "succeeded"
def test_unverifiable_delete_is_outcome_unknown(
recovery_session_factory,
) -> None:
storage = _MemoryStorage()
orphan = f"{PREFIX}build-unknown/message.eml"
storage.add(orphan)
storage.exists_failures.add(orphan)
reference_patch, active_patch = _without_domain_references()
with reference_patch, active_patch:
result = reconcile_campaign_artifacts(
recovery_session_factory,
storage=storage,
identity=_identity(),
tenant_id="tenant-1",
apply=True,
idempotency_key="unknown-cleanup",
now=NOW,
)
assert result["status"] == "outcome_unknown"
assert result["failure_count"] == 1
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from unittest.mock import Mock
import pytest
from govoplan_core.core.recovery import RecoveryStatus
from govoplan_campaign.backend.sending import jobs
@pytest.mark.parametrize(
("outcome", "expected_method", "expected_status"),
[
("succeeded", "succeed", None),
("failed", "reject", None),
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
],
)
def test_delivery_recovery_maps_verified_job_state(
monkeypatch,
outcome: str,
expected_method: str,
expected_status: RecoveryStatus | None,
) -> None:
evidence = {
"verified": outcome != "recovery_required",
"checks": {"job_state_reloaded": True},
"send_status": outcome,
}
monkeypatch.setattr(
jobs,
"_job_delivery_recovery_evidence",
lambda _job_id: (outcome, evidence),
)
operation = Mock()
jobs._finish_job_delivery_recovery(operation, job_id="job-1")
method = getattr(operation, expected_method)
method.assert_called_once()
if expected_status is not None:
assert method.call_args.kwargs["status"] == expected_status
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
getattr(operation, other).assert_not_called()
@pytest.mark.parametrize(
("outcome", "expected_method", "expected_status"),
[
("succeeded", "succeed", None),
("failed", "reject", None),
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
],
)
def test_imap_recovery_maps_verified_append_state(
monkeypatch,
outcome: str,
expected_method: str,
expected_status: RecoveryStatus | None,
) -> None:
evidence = {
"verified": outcome != "recovery_required",
"checks": {"job_state_reloaded": True},
"imap_status": outcome,
}
monkeypatch.setattr(
jobs,
"_imap_recovery_evidence",
lambda _job_id: (outcome, evidence),
)
operation = Mock()
jobs._finish_imap_append_recovery(operation, job_id="job-1")
method = getattr(operation, expected_method)
method.assert_called_once()
if expected_status is not None:
assert method.call_args.kwargs["status"] == expected_status
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
getattr(operation, other).assert_not_called()
@pytest.mark.parametrize(
("outcome", "expected_method", "expected_status"),
[
("succeeded", "succeed", None),
("failed", "reject", None),
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
],
)
def test_single_action_recovery_maps_verified_action_state(
monkeypatch,
outcome: str,
expected_method: str,
expected_status: RecoveryStatus | None,
) -> None:
evidence = {
"verified": outcome != "recovery_required",
"checks": {"action_state_reloaded": True},
"action_status": outcome,
}
monkeypatch.setattr(
jobs,
"_single_action_recovery_evidence",
lambda _action_id: (outcome, evidence),
)
operation = Mock()
jobs._finish_single_action_delivery_recovery(operation, action_id="action-1")
method = getattr(operation, expected_method)
method.assert_called_once()
if expected_status is not None:
assert method.call_args.kwargs["status"] == expected_status
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
getattr(operation, other).assert_not_called()
+295
View File
@@ -0,0 +1,295 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_policy
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.routes.campaigns import (
archive_campaign_version,
copy_campaign,
delete_draft_campaign,
)
from govoplan_campaign.backend.schemas import (
CampaignCopyRequest,
CampaignLifecycleMutationRequest,
)
from govoplan_core.db.base import Base
class _Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="user-1")
def __init__(self, *scopes: str) -> None:
self.scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self.scopes or "tenant:*" in self.scopes
class CampaignLifecycleTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
access_users = Base.metadata.tables.get("access_users")
if access_users is None:
access_users = Table(
"access_users",
Base.metadata,
Column("id", String(36), primary_key=True),
)
access_groups = Base.metadata.tables.get("access_groups")
if access_groups is None:
access_groups = Table(
"access_groups",
Base.metadata,
Column("id", String(36), primary_key=True),
)
Base.metadata.create_all(
self.engine,
tables=[
access_users,
access_groups,
Campaign.__table__,
CampaignVersion.__table__,
CampaignShare.__table__,
CampaignJob.__table__,
],
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.SessionLocal() as session:
session.execute(access_users.insert().values(id="user-1"))
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
created_by_user_id="user-1",
owner_user_id="user-1",
external_id="campaign-1",
name="Campaign",
status="draft",
current_version_id="version-2",
)
historical = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
workflow_state="completed",
raw_json={"version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}},
)
current = CampaignVersion(
id="version-2",
campaign_id=campaign.id,
version_number=2,
workflow_state="editing",
raw_json={"version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}},
)
session.add_all((campaign, historical, current))
session.commit()
self.principal = _Principal(
"campaigns:campaign:read",
"campaigns:campaign:copy",
"campaigns:campaign:archive",
"campaigns:campaign:delete",
"campaigns:recipient:read",
)
self.addCleanup(self.engine.dispose)
def _policy(self, session: Session, version_id: str | None = None):
campaign = session.get(Campaign, "campaign-1")
assert campaign is not None
return campaign_lifecycle_policy(
session,
campaign=campaign,
principal=self.principal,
version_id=version_id,
)
def test_policy_explains_retained_evidence_and_changes_token(self) -> None:
with self.SessionLocal() as session:
initial = self._policy(session, "version-1")
self.assertTrue(initial["actions"]["delete_campaign"]["allowed"])
self.assertTrue(initial["actions"]["archive_version"]["allowed"])
current = session.get(CampaignVersion, "version-2")
assert current is not None
current.published_at = datetime.now(UTC)
session.commit()
changed = self._policy(session, "version-1")
self.assertNotEqual(initial["state_token"], changed["state_token"])
self.assertFalse(changed["actions"]["delete_campaign"]["allowed"])
self.assertIn("Audit-relevant", changed["actions"]["delete_campaign"]["reason"])
def test_active_delivery_blocks_campaign_archival(self) -> None:
with self.SessionLocal() as session:
session.add(
CampaignJob(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-2",
entry_index=0,
queue_status="sending",
send_status="sending",
)
)
session.commit()
policy = self._policy(session)
self.assertFalse(policy["actions"]["archive_campaign"]["allowed"])
self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"])
def test_stale_delete_token_is_rejected(self) -> None:
with self.SessionLocal() as session:
policy = self._policy(session)
campaign = session.get(Campaign, "campaign-1")
assert campaign is not None
campaign.name = "Changed elsewhere"
session.commit()
with self.assertRaises(HTTPException) as raised:
delete_draft_campaign(
"campaign-1",
CampaignLifecycleMutationRequest(
expected_state_token=policy["state_token"],
),
session=session,
principal=self.principal,
)
self.assertEqual(raised.exception.status_code, 409)
self.assertIn("state changed", str(raised.exception.detail))
def test_historical_archival_preserves_version_state_and_content(self) -> None:
with self.SessionLocal() as session:
policy = self._policy(session, "version-1")
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
active_session.commit()
with patch(
"govoplan_campaign.backend.routes.campaigns.audit_from_principal",
side_effect=commit_audit,
):
response = archive_campaign_version(
"campaign-1",
"version-1",
CampaignLifecycleMutationRequest(
expected_state_token=policy["state_token"],
),
session=session,
principal=self.principal,
)
self.assertIsNotNone(response.archived_at)
self.assertEqual(response.archived_by_user_id, "user-1")
version = session.get(CampaignVersion, "version-1")
assert version is not None
self.assertEqual(version.workflow_state, "completed")
self.assertEqual(version.raw_json["campaign"]["name"], "Campaign")
def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None:
with self.SessionLocal() as session:
session.add_all(
(
CampaignShare(
id="share-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
target_type="user",
target_id="user-1",
permission="read",
),
CampaignJob(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-2",
entry_index=0,
queue_status="cancelled",
send_status="cancelled",
),
)
)
session.commit()
policy = self._policy(session, "version-2")
captured: dict[str, object] = {}
def create_copy(active_session: Session, **kwargs):
raw_json = kwargs["raw_json"]
captured["raw_json"] = raw_json
destination = Campaign(
id="campaign-copy",
tenant_id="tenant-1",
created_by_user_id="user-1",
owner_user_id="user-1",
external_id=raw_json["campaign"]["id"],
name=raw_json["campaign"]["name"],
status="draft",
current_version_id="version-copy",
)
version = CampaignVersion(
id="version-copy",
campaign_id=destination.id,
version_number=1,
raw_json=raw_json,
)
active_session.add_all((destination, version))
active_session.flush()
return destination, version
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
active_session.commit()
with (
patch(
"govoplan_campaign.backend.routes.campaigns.create_campaign_version_from_json",
side_effect=create_copy,
),
patch(
"govoplan_campaign.backend.routes.campaigns.audit_from_principal",
side_effect=commit_audit,
),
):
response = copy_campaign(
"campaign-1",
CampaignCopyRequest(
source_version_id="version-2",
expected_state_token=policy["state_token"],
),
session=session,
principal=self.principal,
)
self.assertEqual(response.campaign.external_id, "campaign-1-copy")
self.assertEqual(response.campaign.owner_user_id, "user-1")
self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft")
self.assertEqual(
session.query(CampaignJob)
.filter(CampaignJob.campaign_id == "campaign-copy")
.count(),
0,
)
self.assertEqual(
session.query(CampaignShare)
.filter(CampaignShare.campaign_id == "campaign-copy")
.count(),
0,
)
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -54,6 +54,30 @@ def test_campaign_runtime_documentation_provider_is_registered() -> None:
assert documentation_topics in get_manifest().documentation_providers assert documentation_topics in get_manifest().documentation_providers
def test_complete_review_workflow_documents_each_attention_class() -> None:
from govoplan_campaign.backend.manifest import get_manifest
topic = next(
item
for item in get_manifest().documentation
if item.id == "campaigns.workflow.complete-review"
)
rendered = "\n".join(
(
topic.summary,
topic.body,
*topic.metadata["steps"],
topic.metadata["verification"],
)
)
assert "Critical blockers" in rendered
assert "Individual review" in rendered
assert "Group review" in rendered
assert "Remaining is zero" in rendered
assert topic.metadata["help_contexts"] == ["campaign.review-send"]
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None: def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
assert _topics({"docs:documentation:read"}) == () assert _topics({"docs:documentation:read"}) == ()
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == () assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
+5
View File
@@ -159,6 +159,11 @@ def test_post_provider_persistence_failure_freezes_imap_retry() -> None:
patch("govoplan_campaign.backend.sending.jobs._load_eml_bytes_for_job", return_value=b"message"), patch("govoplan_campaign.backend.sending.jobs._load_eml_bytes_for_job", return_value=b"message"),
patch("govoplan_campaign.backend.sending.jobs._claim_job_for_imap_append", return_value="claim-1"), patch("govoplan_campaign.backend.sending.jobs._claim_job_for_imap_append", return_value="claim-1"),
patch("govoplan_campaign.backend.sending.jobs._record_imap_attempt_start", return_value=attempt), patch("govoplan_campaign.backend.sending.jobs._record_imap_attempt_start", return_value=attempt),
patch(
"govoplan_campaign.backend.sending.jobs._begin_imap_append_recovery",
return_value=SimpleNamespace(replayed=False, operation=MagicMock()),
),
patch("govoplan_campaign.backend.sending.jobs._finish_imap_append_recovery"),
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()), patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
patch( patch(
"govoplan_campaign.backend.sending.jobs._record_imap_append_success", "govoplan_campaign.backend.sending.jobs._record_imap_append_success",
+37
View File
@@ -7,6 +7,7 @@ import pytest
from fastapi import HTTPException from fastapi import HTTPException
from govoplan_campaign.backend import route_support from govoplan_campaign.backend import route_support
from govoplan_campaign.backend.routes import attachments as attachment_routes
from govoplan_campaign.backend.routes import versions as router from govoplan_campaign.backend.routes import versions as router
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
from govoplan_campaign.backend.campaign.mail_profile_boundary import ( from govoplan_campaign.backend.campaign.mail_profile_boundary import (
@@ -94,6 +95,42 @@ def test_loader_rejects_inline_transport_before_optional_mail_summary() -> None:
) )
def test_attachment_preview_reports_legacy_mail_boundary_as_validation_error() -> None:
campaign = SimpleNamespace(id="campaign-1")
version = SimpleNamespace(
id="version-1",
campaign_id=campaign.id,
raw_json=_campaign_json({"smtp": {"host": "legacy.example.test"}}),
)
principal = SimpleNamespace(
tenant_id="tenant-1",
user=SimpleNamespace(id="user-1"),
)
with (
patch.object(attachment_routes, "_get_campaign_for_principal"),
patch.object(attachment_routes, "_require_permission"),
patch.object(attachment_routes, "_get_campaign_for_tenant", return_value=campaign),
patch.object(attachment_routes, "_get_version_for_tenant", return_value=version),
patch.object(attachment_routes, "_require_mail_profile_use_if_needed"),
patch.object(
attachment_routes,
"_attachment_preview_for_version",
side_effect=CampaignMailProfileBoundaryError("Select an authorized Mail profile."),
),
pytest.raises(HTTPException) as captured,
):
attachment_routes.preview_campaign_attachments(
campaign.id,
version.id,
session=object(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
assert captured.value.status_code == 422
assert captured.value.detail == "Select an authorized Mail profile."
def test_loader_uses_only_non_secret_mail_profile_capabilities() -> None: def test_loader_uses_only_non_secret_mail_profile_capabilities() -> None:
raw = _campaign_json({"mail_profile_id": "profile-1"}) raw = _campaign_json({"mail_profile_id": "profile-1"})
+9
View File
@@ -62,3 +62,12 @@ def test_aggregate_reports_are_an_integrated_campaign_view() -> None:
] ]
assert len(report_surfaces) == 1 assert len(report_surfaces) == 1
assert report_surfaces[0].description == "/campaigns/reports" assert report_surfaces[0].description == "/campaigns/reports"
def test_reusable_template_library_is_not_owned_by_campaign() -> None:
manifest = get_manifest()
assert manifest.frontend is not None
assert "/templates" not in {item.path for item in manifest.nav_items}
assert "/templates" not in {item.path for item in manifest.frontend.nav_items}
assert "/templates" not in {route.path for route in manifest.frontend.routes}
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from contextlib import AbstractContextManager
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from govoplan_core.core.recovery import RecoveryStatus
from govoplan_campaign.backend import retention
class _EvidenceSession(AbstractContextManager):
def __init__(self, job: object | None) -> None:
self.job = job
def __enter__(self):
return self
def __exit__(self, *_args) -> None:
return None
def get(self, _model, _object_id):
return self.job
def _recovery(*, storage, storage_key: str | None, local_path: str | None):
return retention._GeneratedArtifactRecovery(
operation=Mock(),
job_id="job-1",
storage_key=storage_key,
local_path=local_path,
storage=storage,
)
@pytest.mark.parametrize(
("outcome", "expected_method", "expected_status"),
[
("succeeded", "succeed", None),
("failed", "reject", None),
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
],
)
def test_retention_recovery_maps_verified_artifact_state(
monkeypatch,
outcome: str,
expected_method: str,
expected_status: RecoveryStatus | None,
) -> None:
recovery = _recovery(
storage=Mock(),
storage_key="campaigns/message.eml",
local_path=None,
)
monkeypatch.setattr(
retention,
"_generated_artifact_recovery_evidence",
lambda _recovery: (
outcome,
{
"verified": outcome != "outcome_unknown",
"checks": {"artifact_locations_probed": True},
},
),
)
retention._finish_generated_artifact_recovery(recovery)
method = getattr(recovery.operation, expected_method)
method.assert_called_once()
if expected_status is not None:
assert method.call_args.kwargs["status"] == expected_status
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
getattr(recovery.operation, other).assert_not_called()
@pytest.mark.parametrize(
("object_exists", "metadata_key", "expected"),
[
(False, None, "succeeded"),
(True, "campaigns/message.eml", "failed"),
(False, "campaigns/message.eml", "recovery_required"),
(True, None, "recovery_required"),
],
)
def test_retention_recovery_compares_storage_and_database_independently(
monkeypatch,
object_exists: bool,
metadata_key: str | None,
expected: str,
) -> None:
storage = Mock()
storage.exists.return_value = object_exists
job = SimpleNamespace(
eml_storage_key=metadata_key,
eml_local_path=None,
)
monkeypatch.setattr(
retention,
"get_database",
lambda: SimpleNamespace(
SessionLocal=lambda: _EvidenceSession(job),
),
)
outcome, evidence = retention._generated_artifact_recovery_evidence(
_recovery(
storage=storage,
storage_key="campaigns/message.eml",
local_path=None,
)
)
assert outcome == expected
assert evidence["verified"] is True
def test_campaign_retention_commits_before_recovery_verification(monkeypatch) -> None:
events: list[str] = []
session = Mock()
session.commit.side_effect = lambda: events.append("commit")
recovery = Mock()
monkeypatch.setattr(retention, "_apply_raw_json_retention", lambda *_args, **_kwargs: {})
monkeypatch.setattr(retention, "_apply_report_detail_retention", lambda *_args, **_kwargs: {})
def apply_eml(*_args, **kwargs):
kwargs["recovery_operations"].append(recovery)
return {"metadata_cleared": 1}
monkeypatch.setattr(retention, "_apply_eml_retention", apply_eml)
monkeypatch.setattr(
retention,
"_finish_generated_artifact_recoveries",
lambda _recoveries: events.append("verify"),
)
result = retention.apply_campaign_retention(
session,
dry_run=False,
now=datetime.now(timezone.utc),
policy_for_campaign_id=lambda _campaign_id: object(),
)
assert result["generated_eml"] == {"metadata_cleared": 1}
assert events == ["commit", "verify"]
session.rollback.assert_not_called()
+7 -1
View File
@@ -7,6 +7,7 @@ from govoplan_campaign.backend.routes.attachments import router as attachments_r
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
from govoplan_campaign.backend.routes.delivery import router as delivery_router from govoplan_campaign.backend.routes.delivery import router as delivery_router
from govoplan_campaign.backend.routes.jobs import router as jobs_router from govoplan_campaign.backend.routes.jobs import router as jobs_router
from govoplan_campaign.backend.routes.operations import router as operations_router
from govoplan_campaign.backend.routes.reports import router as reports_router from govoplan_campaign.backend.routes.reports import router as reports_router
from govoplan_campaign.backend.routes.sharing import router as sharing_router from govoplan_campaign.backend.routes.sharing import router as sharing_router
from govoplan_campaign.backend.routes.versions import router as versions_router from govoplan_campaign.backend.routes.versions import router as versions_router
@@ -22,6 +23,7 @@ def _operation_keys(candidate_router) -> list[tuple[str, str]]:
def test_campaign_router_composes_every_workflow_operation_once() -> None: def test_campaign_router_composes_every_workflow_operation_once() -> None:
workflow_routers = ( workflow_routers = (
operations_router,
campaigns_router, campaigns_router,
versions_router, versions_router,
jobs_router, jobs_router,
@@ -38,12 +40,16 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router) actual = _operation_keys(router)
assert actual == expected assert actual == expected
assert len(actual) == 71 assert len(actual) == 72
assert not [operation for operation, count in Counter(actual).items() if count > 1] assert not [operation for operation, count in Counter(actual).items() if count > 1]
def test_key_routes_are_owned_by_their_focused_router() -> None: def test_key_routes_are_owned_by_their_focused_router() -> None:
expectations = ( expectations = (
(
operations_router,
("POST", "/campaigns/operations/artifacts/reconcile"),
),
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")), (campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")), (versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")), (jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
from govoplan_campaign.backend.search_source import (
CampaignSearchSource,
PROVIDER_ID,
RESOURCE_TYPE,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillRequest,
SearchResourceReference,
)
from govoplan_core.db.base import Base
class CampaignSearchSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite://")
Base.metadata.create_all(
self.engine,
tables=(
Account.__table__,
User.__table__,
Group.__table__,
Campaign.__table__,
CampaignShare.__table__,
),
)
self.session = Session(self.engine)
self.session.add_all(
(
Account(
id="account-1",
email="one@example.test",
normalized_email="one@example.test",
),
User(
id="user-1",
tenant_id="tenant-1",
account_id="account-1",
email="one@example.test",
),
Campaign(
id="campaign-1",
tenant_id="tenant-1",
owner_user_id="user-1",
external_id="monthly-letters",
name="Monthly letters",
),
Campaign(
id="campaign-other",
tenant_id="tenant-2",
external_id="other",
name="Other tenant",
),
)
)
self.session.commit()
self.source = CampaignSearchSource()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_backfill_and_live_acl_recheck_do_not_cross_tenants(self) -> None:
page = self.source.backfill(
self.session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
rebuild_id="rebuild-1",
),
)
self.assertEqual(("campaign-1",), tuple(doc.resource_id for doc in page.documents))
reference = SearchResourceReference(
tenant_id="tenant-1",
module_id="campaigns",
resource_type=RESOURCE_TYPE,
resource_id="campaign-1",
)
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
self.assertTrue(
self.source.authorize(
self.session,
_principal({"campaigns:campaign:read"}),
requests=(request,),
)[reference.key]
)
self.assertFalse(
self.source.authorize(
self.session,
_principal(set()),
requests=(request,),
)[reference.key]
)
def _principal(scopes: set[str]) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="user-1"),
)
if __name__ == "__main__":
unittest.main()
+17 -1
View File
@@ -60,6 +60,19 @@ class CampaignQueueSelectionTests(unittest.TestCase):
self.assertIsNotNone(capability) self.assertIsNotNone(capability)
configure.assert_called_once_with(registry=registry, settings=settings) configure.assert_called_once_with(registry=registry, settings=settings)
def test_delivery_task_capability_resolves_job_tenant_before_effect(self):
capability = delivery_tasks_capability(
SimpleNamespace(registry=object(), settings=object())
)
session = SimpleNamespace(
get=lambda _model, _job_id: SimpleNamespace(tenant_id="tenant-1")
)
self.assertEqual(
"tenant-1",
capability.tenant_id_for_job(session, job_id="job-1"),
)
def test_selects_queueable_jobs_without_reclassifying_retry_states(self): def test_selects_queueable_jobs_without_reclassifying_retry_states(self):
skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value) skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value)
skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value) skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value)
@@ -181,7 +194,10 @@ class CampaignQueueSelectionTests(unittest.TestCase):
) )
with ( with (
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()), patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
patch("govoplan_campaign.backend.sending.jobs._record_attempt_start", return_value=object()), patch(
"govoplan_campaign.backend.sending.jobs._record_attempt_start",
return_value=SimpleNamespace(attempt_number=1),
),
patch( patch(
"govoplan_campaign.backend.sending.jobs._record_smtp_send_success", "govoplan_campaign.backend.sending.jobs._record_smtp_send_success",
side_effect=OSError("storage unavailable"), side_effect=OSError("storage unavailable"),
+14
View File
@@ -170,6 +170,13 @@ class CampaignSingleMessageActionTests(unittest.TestCase):
"govoplan_campaign.backend.sending.jobs.mail_integration", "govoplan_campaign.backend.sending.jobs.mail_integration",
return_value=mail, return_value=mail,
), ),
patch(
"govoplan_campaign.backend.sending.jobs._begin_single_action_delivery_recovery",
return_value=SimpleNamespace(replayed=False, operation=Mock()),
),
patch(
"govoplan_campaign.backend.sending.jobs._finish_single_action_delivery_recovery"
),
patch( patch(
"govoplan_campaign.backend.sending.jobs.audit_event" "govoplan_campaign.backend.sending.jobs.audit_event"
), ),
@@ -269,6 +276,13 @@ class CampaignSingleMessageActionTests(unittest.TestCase):
"govoplan_campaign.backend.sending.jobs.mail_integration", "govoplan_campaign.backend.sending.jobs.mail_integration",
return_value=mail, return_value=mail,
), ),
patch(
"govoplan_campaign.backend.sending.jobs._begin_single_action_delivery_recovery",
return_value=SimpleNamespace(replayed=False, operation=Mock()),
),
patch(
"govoplan_campaign.backend.sending.jobs._finish_single_action_delivery_recovery"
),
patch( patch(
"govoplan_campaign.backend.sending.jobs.files_integration" "govoplan_campaign.backend.sending.jobs.files_integration"
) as files, ) as files,
+5 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.12", "version": "0.1.18",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -17,7 +17,7 @@
"read-excel-file": "9.2.0" "read-excel-file": "9.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.14", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
@@ -30,10 +30,12 @@
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs", "test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js", "test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs", "test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs",
"test:review-workflow": "node --experimental-strip-types --test tests/review-workflow-guidance.test.ts && node tests/review-workflow-guidance-ui-structure.test.mjs",
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs", "test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs", "test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs", "test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs" "test:accessibility-contract": "node tests/accessibility-contract.test.mjs",
"test:campaign-lifecycle": "node tests/campaign-lifecycle-ui-structure.test.mjs"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
+73
View File
@@ -85,6 +85,21 @@ export type CampaignVersionListItem = {
execution_snapshot_at?: string | null; execution_snapshot_at?: string | null;
delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | null; delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | null;
delivery_mode_selected_at?: string | null; delivery_mode_selected_at?: string | null;
archived_at?: string | null;
archived_by_user_id?: string | null;
};
export type CampaignLifecycleAction = {
allowed: boolean;
reason?: string | null;
};
export type CampaignLifecyclePolicy = {
policy_id: string;
policy_version: string;
state_token: string;
actions: Record<string, CampaignLifecycleAction>;
provenance: Record<string, unknown>;
}; };
export type CampaignVersionDetail = CampaignVersionListItem & { export type CampaignVersionDetail = CampaignVersionListItem & {
@@ -1002,6 +1017,64 @@ payload: CampaignUpdatePayload)
}); });
} }
export async function archiveCampaign(
settings: ApiSettings,
campaignId: string,
expectedStateToken: string)
: Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/archive`, {
method: "POST",
body: JSON.stringify({ expected_state_token: expectedStateToken })
});
}
export async function getCampaignLifecyclePolicy(
settings: ApiSettings,
campaignId: string,
versionId?: string | null)
: Promise<CampaignLifecyclePolicy> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
return apiFetch<CampaignLifecyclePolicy>(settings, `/api/v1/campaigns/${campaignId}/lifecycle-policy${suffix}`);
}
export async function deleteCampaign(
settings: ApiSettings,
campaignId: string,
expectedStateToken: string)
: Promise<void> {
await apiFetch<void>(settings, `/api/v1/campaigns/${campaignId}`, {
method: "DELETE",
body: JSON.stringify({ expected_state_token: expectedStateToken })
});
}
export async function copyCampaign(
settings: ApiSettings,
campaignId: string,
sourceVersionId: string,
expectedStateToken: string)
: Promise<CampaignCreateResponse> {
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/copies`, {
method: "POST",
body: JSON.stringify({
source_version_id: sourceVersionId,
expected_state_token: expectedStateToken
})
});
}
export async function archiveCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
expectedStateToken: string)
: Promise<CampaignVersionListItem> {
return apiFetch<CampaignVersionListItem>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/archive`, {
method: "POST",
body: JSON.stringify({ expected_state_token: expectedStateToken })
});
}
export async function createNewCampaign( export async function createNewCampaign(
settings: ApiSettings, settings: ApiSettings,
overrides: CampaignCreateMinimalPayload = {}) overrides: CampaignCreateMinimalPayload = {})
@@ -5,6 +5,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { ActionBlockerHint, DocumentationHelpLink } 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;}) {
@@ -27,7 +28,27 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968"> <LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d"> <Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
<p className="muted">i18n:govoplan-campaign.campaign_specific_audit_api_integration_will_be_.e53c8280</p> <ActionBlockerHint
tone="info"
reason={{
summary: "Campaign-specific audit projection is not available on this page.",
details: "Campaign actions already emit bounded platform audit evidence. Authorized readers can inspect it in the Audit administration surface.",
requiredAction: "Open tenant audit and filter by the campaign identifier.",
actor: "Audit reader or system operator",
target: "Administration > Tenant audit"
}}
documentation={{
topicId: "campaigns.reference.composition-assurance",
documentationType: "admin"
}}
/>
<DocumentationHelpLink
reference={{
topicId: "campaigns.reference.composition-assurance",
documentationType: "user"
}}
label="Open Campaign assurance documentation"
/>
</Card> </Card>
</LoadingFrame> </LoadingFrame>
</div>); </div>);
@@ -29,6 +29,11 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<DismissibleAlert tone="warning" dismissible={false}>
This expert view contains the complete authorized campaign configuration,
including recipient and message fields that may contain personal data.
Download and share it only for an authorized purpose.
</DismissibleAlert>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50"> <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>}
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ExternalLink, LockKeyhole, LockOpen } from "lucide-react"; import { Archive, Copy, ExternalLink, LockKeyhole, LockOpen, Trash2 } from "lucide-react";
import { Link } from "react-router"; import { Link } from "react-router";
import type { ApiSettings } from "../../types"; import type { ApiSettings, AuthInfo } from "../../types";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
@@ -10,13 +10,20 @@ import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup, hasScope, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { import {
archiveCampaign,
archiveCampaignVersion,
copyCampaign,
deleteCampaign,
getCampaignLifecyclePolicy,
lockCampaignVersionPermanently, lockCampaignVersionPermanently,
lockCampaignVersionTemporarily, lockCampaignVersionTemporarily,
unlockCampaignVersionUserLock, unlockCampaignVersionUserLock,
updateCampaignMetadata, updateCampaignMetadata,
type CampaignLifecyclePolicy,
type CampaignVersionDetail, type CampaignVersionDetail,
type CampaignVersionListItem } from type CampaignVersionListItem } from
"../../api/campaigns"; "../../api/campaigns";
@@ -38,19 +45,32 @@ import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddre
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;
type LifecycleAction = "archive_campaign" | "delete_campaign" | "copy_campaign" | "archive_version";
type PendingLifecycleAction = {
action: LifecycleAction;
policy: CampaignLifecyclePolicy;
version?: CampaignVersionListItem;
} | null;
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
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 [showArchivedVersions, setShowArchivedVersions] = useState(false);
const archivedVersionCount = useMemo(() => data.versions.filter((version) => Boolean(version.archived_at)).length, [data.versions]);
const versions = useMemo(() => data.versions.filter((version) => showArchivedVersions || !version.archived_at).sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions, showArchivedVersions]);
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" }); const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
const [identityDirty, setIdentityDirty] = useState(false); const [identityDirty, setIdentityDirty] = useState(false);
const [savingIdentity, setSavingIdentity] = useState(false); const [savingIdentity, setSavingIdentity] = useState(false);
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null); const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
const [lockBusy, setLockBusy] = useState(false); const [lockBusy, setLockBusy] = useState(false);
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
const [lifecycleBusy, setLifecycleBusy] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]); const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive");
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy") && hasScope(auth, "campaigns:recipient:read");
useUnsavedDraftGuard({ useUnsavedDraftGuard({
dirty: identityDirty, dirty: identityDirty,
@@ -145,6 +165,59 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
await reload({ force: true }); await reload({ force: true });
} }
async function prepareLifecycleAction(action: LifecycleAction, version?: CampaignVersionListItem) {
if (!campaign || lifecycleBusy || identityDirty) return;
setLifecycleBusy(true);
setError("");
setMessage("");
try {
const policy = await getCampaignLifecyclePolicy(settings, campaign.id, version?.id);
const decision = policy.actions[action];
if (!decision?.allowed) {
setError(decision?.reason || "This lifecycle action is not available for the current campaign state.");
return;
}
setPendingLifecycleAction({ action, policy, version });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLifecycleBusy(false);
}
}
async function applyLifecycleAction() {
if (!campaign || !pendingLifecycleAction || lifecycleBusy) return;
const pending = pendingLifecycleAction;
setLifecycleBusy(true);
setError("");
setMessage("");
try {
if (pending.action === "archive_campaign") {
await archiveCampaign(settings, campaign.id, pending.policy.state_token);
setMessage("i18n:govoplan-campaign.campaign_archived.3f0ca2b7");
} else if (pending.action === "delete_campaign") {
await deleteCampaign(settings, campaign.id, pending.policy.state_token);
setPendingLifecycleAction(null);
navigate("/campaigns");
return;
} else if (pending.action === "copy_campaign" && pending.version) {
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token);
setPendingLifecycleAction(null);
navigate(`/campaigns/${created.campaign.id}`);
return;
} else if (pending.action === "archive_version" && pending.version) {
await archiveCampaignVersion(settings, campaign.id, pending.version.id, pending.policy.state_token);
setMessage(`Version #${pending.version.version_number} archived.`);
}
setPendingLifecycleAction(null);
await reload({ force: true });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLifecycleBusy(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">
@@ -153,6 +226,29 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</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">
{canCopy && data.currentVersion && <Button
onClick={() => void prepareLifecycleAction("copy_campaign", data.currentVersion ?? undefined)}
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
disabledReason={identityDirty ? "Save or discard overview changes before copying." : undefined}>
<Copy size={16} aria-hidden="true" />
Copy campaign
</Button>}
{canDelete && <Button
variant="danger"
onClick={() => void prepareLifecycleAction("delete_campaign")}
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
disabledReason={identityDirty ? "Save or discard overview changes before deleting." : undefined}>
<Trash2 size={16} aria-hidden="true" />
Delete draft
</Button>}
{canArchive && <Button
variant="danger"
onClick={() => void prepareLifecycleAction("archive_campaign")}
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
disabledReason={identityDirty ? "i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0" : undefined}>
<Archive size={16} aria-hidden="true" />
i18n:govoplan-campaign.archive_campaign.26dcfb8a
</Button>}
<Button onClick={() => void discardOverview()} disabled={loading || savingIdentity || lockBusy}>Discard</Button> <Button onClick={() => void discardOverview()} disabled={loading || savingIdentity || lockBusy}>Discard</Button>
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</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>
@@ -191,14 +287,19 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
</div> </div>
</Card> </Card>
<Card title="Versions" collapsible actions={<Link <Card title="Versions" collapsible actions={<div className="button-row compact-actions">
to={`send?version=${campaign?.current_version_id}`} {archivedVersionCount > 0 && <ToggleSwitch
className={`btn btn-primary`} label={`Show archived (${archivedVersionCount})`}
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})} checked={showArchivedVersions}
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}> onChange={setShowArchivedVersions} />}
<Link
i18n:govoplan-campaign.open.cf9b7706 to={`send?version=${campaign?.current_version_id}`}
</Link>}> 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>}>
<div className="metric-grid inside campaign-versions-metrics"> <div className="metric-grid inside campaign-versions-metrics">
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" /> <MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" /> <MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
@@ -215,17 +316,34 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
<DataGrid <DataGrid
id={`campaign-${campaignId}-versions`} id={`campaign-${campaignId}-versions`}
rows={versions} rows={versions}
columns={versionColumns(setPendingLockAction, navigate, campaign?.current_version_id)} columns={versionColumns(
setPendingLockAction,
navigate,
campaign?.current_version_id,
canCopy,
hasScope(auth, "campaigns:campaign:archive"),
(action, version) => void prepareLifecycleAction(action, version)
)}
getRowKey={(version) => version.id} getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }} initialSort={{ columnId: "version", direction: "desc" }}
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e" 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.archived_at ? "archived-version-row" : version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
</div> </div>
</Card> </Card>
</LoadingFrame> </LoadingFrame>
<ConfirmDialog
open={Boolean(pendingLifecycleAction)}
title={lifecycleDialogTitle(pendingLifecycleAction)}
message={lifecycleDialogMessage(pendingLifecycleAction)}
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
tone="danger"
busy={lifecycleBusy}
onCancel={() => setPendingLifecycleAction(null)}
onConfirm={() => void applyLifecycleAction()} />
<ConfirmDialog <ConfirmDialog
open={Boolean(pendingLockAction)} open={Boolean(pendingLockAction)}
title={lockDialogTitle(pendingLockAction)} title={lockDialogTitle(pendingLockAction)}
@@ -297,7 +415,14 @@ function textValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback; return typeof value === "string" ? value : fallback;
} }
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, navigate: (to: string) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] { function versionColumns(
setPendingLockAction: (action: PendingLockAction) => void,
navigate: (to: string) => void,
currentVersionId: string | null | undefined,
canCopy: boolean,
canArchive: boolean,
onLifecycleAction: (action: LifecycleAction, version: CampaignVersionListItem) => void
): DataGridColumn<CampaignVersionListItem>[] {
return [ return [
{ 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: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" }, { id: "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" },
@@ -317,6 +442,8 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at; const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
return <TableActionGroup actions={[ return <TableActionGroup actions={[
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number }), icon: <ExternalLink aria-hidden="true" />, variant: isCurrent ? "primary" : "secondary", onClick: () => navigate(`send?version=${version.id}`) }, { id: "open", label: i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number }), icon: <ExternalLink aria-hidden="true" />, variant: isCurrent ? "primary" : "secondary", onClick: () => navigate(`send?version=${version.id}`) },
{ id: "copy-campaign", label: "Copy as new campaign", icon: <Copy aria-hidden="true" />, applicable: canCopy, onClick: () => onLifecycleAction("copy_campaign", version) },
{ id: "archive-version", label: "Archive historical version", icon: <Archive aria-hidden="true" />, variant: "danger", applicable: canArchive && !isCurrent && !version.archived_at, onClick: () => onLifecycleAction("archive_version", version) },
{ id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) }, { id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) },
{ id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) }, { id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) },
{ id: "temporary-lock", label: i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number }), icon: <LockKeyhole aria-hidden="true" />, applicable: canTemporarilyLock, onClick: () => setPendingLockAction({ version, action: "temporary" }) } { id: "temporary-lock", label: i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number }), icon: <LockKeyhole aria-hidden="true" />, applicable: canTemporarilyLock, onClick: () => setPendingLockAction({ version, action: "temporary" }) }
@@ -328,6 +455,7 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
} }
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string { function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
if (version.archived_at) return "Archived from default history";
if (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82"; if (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82";
if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9"; if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959"; if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
@@ -376,3 +504,27 @@ function lockDialogLabel(pending: PendingLockAction): string {
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7"; if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
return "i18n:govoplan-campaign.confirm.04a21221"; return "i18n:govoplan-campaign.confirm.04a21221";
} }
function lifecycleDialogTitle(pending: PendingLifecycleAction): string {
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign.26dcfb8a";
if (pending?.action === "delete_campaign") return "Delete untouched draft";
if (pending?.action === "copy_campaign") return "Copy campaign";
if (pending?.action === "archive_version") return "Archive historical version";
return "Confirm lifecycle action";
}
function lifecycleDialogMessage(pending: PendingLifecycleAction): string {
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1";
if (pending?.action === "delete_campaign") return "This removes the untouched draft from active work. Drafts with build, delivery, sharing, lock, publication, or snapshot evidence cannot be deleted.";
if (pending?.action === "copy_campaign") return `Create a fresh campaign draft from version #${pending.version?.version_number ?? "?"}? Delivery jobs, outcomes, shares, locks, and audit evidence are not copied.`;
if (pending?.action === "archive_version") return `Hide historical version #${pending.version?.version_number ?? "?"} from the default history? Its configuration, reports, delivery results, and audit evidence remain available.`;
return "Review the lifecycle consequence before continuing.";
}
function lifecycleDialogLabel(pending: PendingLifecycleAction): string {
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign.26dcfb8a";
if (pending?.action === "delete_campaign") return "Delete draft";
if (pending?.action === "copy_campaign") return "Create copy";
if (pending?.action === "archive_version") return "Archive version";
return "Confirm";
}
@@ -89,7 +89,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
<SectionSidebar active={active} onSelect={select} /> <SectionSidebar active={active} onSelect={select} />
<section className="workspace-content"> <section className="workspace-content">
<Routes> <Routes>
<Route index element={<CampaignOverviewPage settings={settings} campaignId={campaignId || ""} />} /> <Route index element={<CampaignOverviewPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="data" element={<Navigate to="../recipients" replace />} /> <Route path="data" element={<Navigate to="../recipients" replace />} />
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
@@ -465,7 +465,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} /> <input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
</FormField> </FormField>
<FormField label="Password"> <FormField label="Password">
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} autoComplete="new-password" /> <PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} generator autoComplete="new-password" />
</FormField> </FormField>
</div> </div>
<div className="form-grid two"> <div className="form-grid two">
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { CircleCheck, CircleX } from "lucide-react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
getCampaignPostboxCatalog, getCampaignPostboxCatalog,
@@ -13,6 +14,7 @@ import {
"../../api/campaigns"; "../../api/campaigns";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { usePlatformModuleInstalled } from "@govoplan/core-webui"; import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold"; import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
@@ -39,7 +41,7 @@ import {
createAddressSourceImportProvenance createAddressSourceImportProvenance
} from "./utils/addressSourceImport"; } from "./utils/addressSourceImport";
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui"; import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
import { insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui"; import { i18nMessage, insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog"; import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
import DistributionListImportDialog from "./recipients/DistributionListImportDialog"; import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
import { import {
@@ -69,6 +71,12 @@ import {
} from "./recipients/RecipientAddressEditor"; } from "./recipients/RecipientAddressEditor";
import { RecipientImportDialog } from "./recipients/RecipientImportDialog"; import { RecipientImportDialog } from "./recipients/RecipientImportDialog";
import { recipientProfileColumns } from "./recipients/recipientProfileColumns"; import { recipientProfileColumns } from "./recipients/recipientProfileColumns";
type RecipientBulkActivation = {
active: boolean;
count: number;
};
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const filesModuleInstalled = usePlatformModuleInstalled("files"); const filesModuleInstalled = usePlatformModuleInstalled("files");
@@ -91,6 +99,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
const [recipientProfilesQuery, setRecipientProfilesQuery] = useState<DataGridQueryState>({ sort: null, filters: {} }); const [recipientProfilesQuery, setRecipientProfilesQuery] = useState<DataGridQueryState>({ sort: null, filters: {} });
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null); const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null); const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
const [bulkActivation, setBulkActivation] = useState<RecipientBulkActivation | null>(null);
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({ const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
available: false, available: false,
postboxes: [], postboxes: [],
@@ -346,6 +355,19 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
}); });
} }
function requestBulkActivation(active: boolean) {
if (locked) return;
const count = inlineEntries.filter((entry) => (entry.active !== false) !== active).length;
if (count === 0) return;
setBulkActivation({ active, count });
}
function confirmBulkActivation() {
if (!bulkActivation || locked) return;
replaceInlineEntries(inlineEntries.map((entry) => ({ ...entry, active: bulkActivation.active })));
setBulkActivation(null);
}
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) { function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
if (locked || !draft) return; if (locked || !draft) return;
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance })); setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance }));
@@ -492,6 +514,22 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
</Button> </Button>
} }
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button> <Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
<Button
disabled={locked || inlineEntries.every((entry) => entry.active !== false)}
onClick={() => requestBulkActivation(true)}>
<CircleCheck size={16} aria-hidden="true" />
{i18nMessage("i18n:govoplan-campaign.activate_all_value0.7e911e3e", {
value0: inlineEntries.filter((entry) => entry.active === false).length
})}
</Button>
<Button
disabled={locked || inlineEntries.every((entry) => entry.active === false)}
onClick={() => requestBulkActivation(false)}>
<CircleX size={16} aria-hidden="true" />
{i18nMessage("i18n:govoplan-campaign.deactivate_all_value0.87e5d46f", {
value0: inlineEntries.filter((entry) => entry.active !== false).length
})}
</Button>
</div> </div>
}> }>
{inlineEntries.length === 0 && Boolean(source.type) && {inlineEntries.length === 0 && Boolean(source.type) &&
@@ -626,6 +664,23 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
onClose={() => setRecipientAddressEditorIndex(null)} /> onClose={() => setRecipientAddressEditorIndex(null)} />
} }
<ConfirmDialog
open={Boolean(bulkActivation)}
title={bulkActivation?.active
? "i18n:govoplan-campaign.activate_all_recipients.6be687f0"
: "i18n:govoplan-campaign.deactivate_all_recipients.5939b005"}
message={bulkActivation ? i18nMessage(
bulkActivation.active
? "i18n:govoplan-campaign.activate_value0_currently_inactive_recipients_the_change_r.53787f50"
: "i18n:govoplan-campaign.deactivate_value0_currently_active_recipients_the_change_r.3bee469f",
{ value0: bulkActivation.count }
) : ""}
confirmLabel={bulkActivation?.active
? "i18n:govoplan-campaign.activate_recipients.bc3a7878"
: "i18n:govoplan-campaign.deactivate_recipients.88d80611"}
tone={bulkActivation?.active ? "default" : "danger"}
onConfirm={confirmBulkActivation}
onCancel={() => setBulkActivation(null)} />
{postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] && {postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&
<PostboxTargetsDialog <PostboxTargetsDialog
open open
+23 -10
View File
@@ -72,6 +72,12 @@ import DeliverabilityPreflight, {
} from "./review/DeliverabilityPreflight"; } from "./review/DeliverabilityPreflight";
import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay"; import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay";
import BuiltMessagePreview from "./review/BuiltMessagePreview"; import BuiltMessagePreview from "./review/BuiltMessagePreview";
import {
BuiltMessageReviewProgress,
BuiltMessageWorkflowGuidance,
ValidationWorkflowGuidance
} from "./review/ReviewWorkflowGuidance";
import { calculateBuildReviewProgress } from "./review/reviewProgress";
import { import {
WorkflowFact, WorkflowFact,
WorkflowNavigation, WorkflowNavigation,
@@ -466,7 +472,6 @@ export default function ReviewSendPage({
Number(reviewMetadata.reviewed_required_count ?? 0) + newlyReviewedRequiredKeys.size Number(reviewMetadata.reviewed_required_count ?? 0) + newlyReviewedRequiredKeys.size
); );
const reviewRequiredCount = explicitReviewCount + bulkAcceptableCount; const reviewRequiredCount = explicitReviewCount + bulkAcceptableCount;
const reviewedRequiredCount = reviewedExplicitCount;
const automaticInspectionComplete = reviewJobs.total_unfiltered > 0 && const automaticInspectionComplete = reviewJobs.total_unfiltered > 0 &&
blockingReviewCount === 0 && blockingReviewCount === 0 &&
reviewRequiredCount === 0; reviewRequiredCount === 0;
@@ -505,6 +510,13 @@ export default function ReviewSendPage({
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted; const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity; const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity;
const buildReviewProgress = calculateBuildReviewProgress({
blocking: Math.max(blockingReviewCount, buildBlocked),
individualRequired: explicitReviewCount,
individualReviewed: reviewedExplicitCount,
groupRequired: bulkAcceptableCount,
reviewComplete: messageReviewComplete || downstreamDeliveryActivity
});
const buildReviewState: FlowState = !readyForDelivery ? const buildReviewState: FlowState = !readyForDelivery ?
"locked" : "locked" :
@@ -512,7 +524,7 @@ export default function ReviewSendPage({
"running" : "running" :
hasBuild && buildBlocked > 0 ? hasBuild && buildBlocked > 0 ?
"danger" : "danger" :
hasBuild && (buildNeedsReview > 0 || buildWarnings > 0 || !inspectionSatisfied) ? hasBuild && !inspectionSatisfied && (buildNeedsReview > 0 || buildWarnings > 0 || buildReviewProgress.remaining > 0) ?
"warning" : "warning" :
hasBuild && inspectionSatisfied ? hasBuild && inspectionSatisfied ?
"complete" : "complete" :
@@ -1392,13 +1404,14 @@ export default function ReviewSendPage({
<WorkflowFact label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} /> <WorkflowFact label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} /> <WorkflowFact label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} />
</div> </div>
{validationStale && <p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.the_stored_validation_result_is_no_longer_an_act.45d2dd14</p>} <ValidationWorkflowGuidance
errors={validationErrors}
warnings={validationWarnings}
stale={validationStale}
/>
{validationPresent && validationErrors === 0 && {validationPresent && validationErrors === 0 &&
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d</p> <p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d</p>
} }
{validationErrors > 0 &&
<p className="review-flow-inline-note is-danger">i18n:govoplan-campaign.resolve_the_blocking_entries_then_validate_again.e282ae0f</p>
}
<AttachmentLinkingPreview <AttachmentLinkingPreview
preview={attachmentPreview} preview={attachmentPreview}
loading={attachmentPreviewLoading} loading={attachmentPreviewLoading}
@@ -1447,10 +1460,13 @@ export default function ReviewSendPage({
<WorkflowFact label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} /> <WorkflowFact label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} /> <WorkflowFact label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} /> <WorkflowFact label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? reviewedRequiredCount : "—"} /> <WorkflowFact label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} /> <WorkflowFact label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} />
<WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} /> <WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
</div> </div>
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p> <p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
{getText(printOutput, "render_id") && ( {getText(printOutput, "render_id") && (
<div className="review-flow-data-section"> <div className="review-flow-data-section">
@@ -1501,9 +1517,6 @@ export default function ReviewSendPage({
{automaticInspectionComplete && {automaticInspectionComplete &&
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791</p> <p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791</p>
} }
{blockingReviewCount > 0 &&
<p className="review-flow-inline-note is-danger">{blockingReviewCount} i18n:govoplan-campaign.blocked_or_failed_message_s_must_be_resolved_bef.5c6b5140</p>
}
{hasBuild && {hasBuild &&
<div className="review-flow-data-section"> <div className="review-flow-data-section">
<div className="page-heading split"> <div className="page-heading split">
@@ -18,6 +18,7 @@ export default function FieldValueInput({ fieldType = "string", value, disabled
inputClassName={className} inputClassName={className}
value={valueToInputText(value, normalizedType)} value={valueToInputText(value, normalizedType)}
disabled={disabled} disabled={disabled}
generator
placeholder={placeholder} placeholder={placeholder}
autoComplete="new-password" autoComplete="new-password"
onValueChange={(nextValue) => onChange(inputValueToFieldValue(normalizedType, nextValue))} onValueChange={(nextValue) => onChange(inputValueToFieldValue(normalizedType, nextValue))}
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Link2, X } from "lucide-react"; import { Link2, X } from "lucide-react";
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui"; import { Button, Dialog, DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import type { import type {
CampaignAttachmentPreviewFile, CampaignAttachmentPreviewFile,
@@ -108,7 +108,11 @@ export default function AttachmentLinkingPreview({
value={loading ? "..." : preview?.shared_file_count ?? "—"} value={loading ? "..." : preview?.shared_file_count ?? "—"}
/> />
</div> </div>
{error && <p className="review-flow-inline-note is-danger">{error}</p>} {error && (
<DismissibleAlert tone="danger" compact dismissible={false}>
{error}
</DismissibleAlert>
)}
{!error && unlinkedCount > 0 && ( {!error && unlinkedCount > 0 && (
<p className="review-flow-inline-note is-stale"> <p className="review-flow-inline-note is-stale">
i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998 i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998
@@ -0,0 +1,169 @@
import {
ActionBlockerHint,
GuidedReviewList,
i18nMessage,
type ActionBlockerReason
} from "@govoplan/core-webui";
import type { BuildReviewProgress } from "./reviewProgress";
type InterventionHintProps = {
tone: "warning" | "danger";
summary: string;
requiredAction: string;
destination: string;
documentationTopicId: string;
};
function InterventionHint({ tone, summary, requiredAction, destination, documentationTopicId }: InterventionHintProps) {
const requiredActionLabel = i18nMessage("i18n:govoplan-campaign.required_action.f2429497");
const actorLabel = i18nMessage("i18n:govoplan-campaign.who_can_resolve_this.e60939b2");
const destinationLabel = i18nMessage("i18n:govoplan-campaign.where_to_go.bb1c6969");
const actor = i18nMessage("i18n:govoplan-campaign.campaign_editor_or_reviewer.7869b106");
const reason: ActionBlockerReason = {
summary,
requiredAction,
actor,
target: destination
};
return (
<ActionBlockerHint
tone={tone}
reason={reason}
documentation={{ topicId: documentationTopicId }}
labels={{
requiredAction: requiredActionLabel,
actor: actorLabel,
target: destinationLabel
}}
/>
);
}
export function ValidationWorkflowGuidance({
errors,
warnings,
stale
}: {
errors: number;
warnings: number;
stale: boolean;
}) {
return (
<div className="review-workflow-guidance">
{errors > 0 && (
<InterventionHint
tone="danger"
summary={i18nMessage("i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a", { value0: errors })}
requiredAction={i18nMessage("i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99")}
destination={i18nMessage("i18n:govoplan-campaign.campaign_identity_sender_recipients_template_or_files.69bc29f0")}
documentationTopicId="campaigns.workflow.prepare-validate-and-build"
/>
)}
{stale && (
<InterventionHint
tone="warning"
summary={i18nMessage("i18n:govoplan-campaign.the_validation_evidence_is_stale.408e8930")}
requiredAction={i18nMessage("i18n:govoplan-campaign.run_validation_again_before_relying_on_this_result.97116fc9")}
destination={i18nMessage("i18n:govoplan-campaign.review_send_validate.c1b403c7")}
documentationTopicId="campaigns.workflow.prepare-validate-and-build"
/>
)}
{warnings > 0 && (
<InterventionHint
tone="warning"
summary={i18nMessage("i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1", { value0: warnings })}
requiredAction={i18nMessage("i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4")}
destination={i18nMessage("i18n:govoplan-campaign.review_send_validate.c1b403c7")}
documentationTopicId="campaigns.workflow.prepare-validate-and-build"
/>
)}
</div>
);
}
export function BuiltMessageReviewProgress({ progress }: { progress: BuildReviewProgress }) {
const blockerDetail = progress.blocking > 0
? i18nMessage("i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b")
: i18nMessage("i18n:govoplan-campaign.no_critical_built_message_blockers_remain.97b3c64f");
const individualDetail = progress.individualRemaining > 0
? i18nMessage("i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9")
: i18nMessage("i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc");
const groupDetail = progress.groupRemaining > 0
? i18nMessage("i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f")
: progress.groupRequired > 0
? i18nMessage("i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6")
: i18nMessage("i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc");
return (
<GuidedReviewList
className="review-workflow-progress"
items={[
{
label: i18nMessage("i18n:govoplan-campaign.critical_blockers.8a37e088"),
value: progress.blocking,
detail: blockerDetail,
tone: progress.blocking > 0 ? "danger" : "success"
},
{
label: i18nMessage("i18n:govoplan-campaign.individual_review.402783bf"),
value: `${progress.individualReviewed} / ${progress.individualRequired}`,
detail: individualDetail,
tone: progress.individualRemaining > 0 ? "warning" : "success"
},
{
label: i18nMessage("i18n:govoplan-campaign.group_review.a809a9d9"),
value: `${progress.groupReviewed} / ${progress.groupRequired}`,
detail: groupDetail,
tone: progress.groupRemaining > 0 ? "warning" : "success"
}
]}
/>
);
}
export function BuiltMessageWorkflowGuidance({
progress,
buildWarnings
}: {
progress: BuildReviewProgress;
buildWarnings: number;
}) {
const showUnacknowledgedWarning = progress.remaining === 0 && buildWarnings > 0;
return (
<div className="review-workflow-guidance">
{progress.blocking > 0 && (
<InterventionHint
tone="danger"
summary={i18nMessage("i18n:govoplan-campaign.value0_built_message_s_are_blocked_or_failed.8061ea60", { value0: progress.blocking })}
requiredAction={i18nMessage("i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373")}
destination={i18nMessage("i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62")}
documentationTopicId="campaigns.workflow.complete-review"
/>
)}
{progress.remaining > 0 && (
<InterventionHint
tone="warning"
summary={i18nMessage("i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c", {
value0: progress.remaining,
value1: progress.individualRemaining,
value2: progress.groupRemaining
})}
requiredAction={i18nMessage("i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029")}
destination={i18nMessage("i18n:govoplan-campaign.review_send_built_messages.6b030946")}
documentationTopicId="campaigns.workflow.complete-review"
/>
)}
{showUnacknowledgedWarning && (
<InterventionHint
tone="warning"
summary={i18nMessage("i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2", { value0: buildWarnings })}
requiredAction={i18nMessage("i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4")}
destination={i18nMessage("i18n:govoplan-campaign.review_send_built_messages.6b030946")}
documentationTopicId="campaigns.workflow.complete-review"
/>
)}
</div>
);
}
@@ -0,0 +1,49 @@
export type BuildReviewProgressInput = {
blocking: number;
individualRequired: number;
individualReviewed: number;
groupRequired: number;
reviewComplete: boolean;
};
export type BuildReviewProgress = {
blocking: number;
individualRequired: number;
individualReviewed: number;
individualRemaining: number;
groupRequired: number;
groupReviewed: number;
groupRemaining: number;
required: number;
reviewed: number;
remaining: number;
};
function count(value: number): number {
return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
}
export function calculateBuildReviewProgress(input: BuildReviewProgressInput): BuildReviewProgress {
const blocking = count(input.blocking);
const individualRequired = count(input.individualRequired);
const groupRequired = count(input.groupRequired);
const individualReviewed = input.reviewComplete
? individualRequired
: Math.min(individualRequired, count(input.individualReviewed));
const groupReviewed = input.reviewComplete ? groupRequired : 0;
const individualRemaining = individualRequired - individualReviewed;
const groupRemaining = groupRequired - groupReviewed;
return {
blocking,
individualRequired,
individualReviewed,
individualRemaining,
groupRequired,
groupReviewed,
groupRemaining,
required: individualRequired + groupRequired,
reviewed: individualReviewed + groupReviewed,
remaining: individualRemaining + groupRemaining
};
}
+82
View File
@@ -2,8 +2,45 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Activate all ({value0})",
"i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Deactivate all ({value0})",
"i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Activate all recipients",
"i18n:govoplan-campaign.deactivate_all_recipients.5939b005": "Deactivate all recipients",
"i18n:govoplan-campaign.activate_value0_currently_inactive_recipients_the_change_r.53787f50": "Activate {value0} currently inactive recipients? The change remains a draft until you save.",
"i18n:govoplan-campaign.deactivate_value0_currently_active_recipients_the_change_r.3bee469f": "Deactivate {value0} currently active recipients? The change remains a draft until you save.",
"i18n:govoplan-campaign.activate_recipients.bc3a7878": "Activate recipients",
"i18n:govoplan-campaign.deactivate_recipients.88d80611": "Deactivate recipients",
"i18n:govoplan-campaign.guided_workflows": "Guided workflows", "i18n:govoplan-campaign.guided_workflows": "Guided workflows",
"i18n:govoplan-campaign.no_guided_workflows": "No guided workflows are available.", "i18n:govoplan-campaign.no_guided_workflows": "No guided workflows are available.",
"i18n:govoplan-campaign.required_action.f2429497": "Required action",
"i18n:govoplan-campaign.who_can_resolve_this.e60939b2": "Who can resolve this",
"i18n:govoplan-campaign.where_to_go.bb1c6969": "Where to go",
"i18n:govoplan-campaign.critical_blockers.8a37e088": "Critical blockers",
"i18n:govoplan-campaign.remaining.cc632b5e": "Remaining",
"i18n:govoplan-campaign.individual_review.402783bf": "Individual review",
"i18n:govoplan-campaign.group_review.a809a9d9": "Group review",
"i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blocking validation issue(s) prevent building.",
"i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Correct the affected campaign data, then validate again.",
"i18n:govoplan-campaign.campaign_identity_sender_recipients_template_or_files.69bc29f0": "Campaign identity, Sender & Recipients, Template, or Files",
"i18n:govoplan-campaign.campaign_editor_or_reviewer.7869b106": "Campaign editor or reviewer",
"i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1": "{value0} validation warning(s) need review but do not block building.",
"i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4": "Inspect the warning details; correct the data or accept the documented condition.",
"i18n:govoplan-campaign.review_send_validate.c1b403c7": "Review & Send - Validate",
"i18n:govoplan-campaign.the_validation_evidence_is_stale.408e8930": "The validation evidence is stale.",
"i18n:govoplan-campaign.run_validation_again_before_relying_on_this_result.97116fc9": "Run validation again before relying on this result.",
"i18n:govoplan-campaign.value0_built_message_s_are_blocked_or_failed.8061ea60": "{value0} built message(s) are blocked or failed.",
"i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Correct the affected recipient, template, mail, or attachment data and rebuild.",
"i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Sender & Recipients, Template, Mail settings, or Files",
"i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} review decision(s) remain ({value1} individual, {value2} group).",
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Open every critical message and record a decision; then explicitly accept any non-critical group.",
"i18n:govoplan-campaign.review_send_built_messages.6b030946": "Review & Send - Built messages",
"i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} built message warning(s) need acknowledgement.",
"i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Must be corrected before delivery.",
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Requires an individual decision.",
"i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "May be accepted together after critical review is complete.",
"i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "Already acknowledged in the completed review.",
"i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "All required review decisions are complete.",
"i18n:govoplan-campaign.no_critical_built_message_blockers_remain.97b3c64f": "No critical built-message blockers remain.",
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign", "i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns", "i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Appended", "i18n:govoplan-campaign.appended.979f5a82": "Appended",
@@ -222,9 +259,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.archive_filename.de958cbd": "archive filename", "i18n:govoplan-campaign.archive_filename.de958cbd": "archive filename",
"i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639": "Archive filenames must be present and unique.", "i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639": "Archive filenames must be present and unique.",
"i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427": "Archive filenames must not be empty", "i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427": "Archive filenames must not be empty",
"i18n:govoplan-campaign.archive_campaign.26dcfb8a": "Archive campaign",
"i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1": "This removes the campaign from active work while preserving versions, delivery results, and audit evidence. Active or outcome-unknown delivery must be resolved first.",
"i18n:govoplan-campaign.archive_name.6310f9e1": "Archive name", "i18n:govoplan-campaign.archive_name.6310f9e1": "Archive name",
"i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1": "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.", "i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1": "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.",
"i18n:govoplan-campaign.archived.eddc813f": "Archived", "i18n:govoplan-campaign.archived.eddc813f": "Archived",
"i18n:govoplan-campaign.campaign_archived.3f0ca2b7": "Campaign archived.",
"i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.", "i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.",
"i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV", "i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV",
"i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report", "i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report",
@@ -1013,6 +1053,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.save.efc007a3": "Save", "i18n:govoplan-campaign.save.efc007a3": "Save",
"i18n:govoplan-campaign.saved.c0ae8f6e": "Saved", "i18n:govoplan-campaign.saved.c0ae8f6e": "Saved",
"i18n:govoplan-campaign.saving.56a2285c": "Saving…", "i18n:govoplan-campaign.saving.56a2285c": "Saving…",
"i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0": "Save or discard overview changes before archiving.",
"i18n:govoplan-campaign.scope_field_value": "{value0} field \"{value1}\"", "i18n:govoplan-campaign.scope_field_value": "{value0} field \"{value1}\"",
"i18n:govoplan-campaign.scope.4651a34e": "Scope", "i18n:govoplan-campaign.scope.4651a34e": "Scope",
"i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5": "Search recipient, subject or entry ID", "i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5": "Search recipient, subject or entry ID",
@@ -1302,8 +1343,45 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto" "i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
}, },
"de": { "de": {
"i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Alle aktivieren ({value0})",
"i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Alle deaktivieren ({value0})",
"i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Alle Empfänger aktivieren",
"i18n:govoplan-campaign.deactivate_all_recipients.5939b005": "Alle Empfänger deaktivieren",
"i18n:govoplan-campaign.activate_value0_currently_inactive_recipients_the_change_r.53787f50": "{value0} derzeit inaktive Empfänger aktivieren? Die Änderung bleibt ein Entwurf, bis Sie speichern.",
"i18n:govoplan-campaign.deactivate_value0_currently_active_recipients_the_change_r.3bee469f": "{value0} derzeit aktive Empfänger deaktivieren? Die Änderung bleibt ein Entwurf, bis Sie speichern.",
"i18n:govoplan-campaign.activate_recipients.bc3a7878": "Empfänger aktivieren",
"i18n:govoplan-campaign.deactivate_recipients.88d80611": "Empfänger deaktivieren",
"i18n:govoplan-campaign.guided_workflows": "Geführte Abläufe", "i18n:govoplan-campaign.guided_workflows": "Geführte Abläufe",
"i18n:govoplan-campaign.no_guided_workflows": "Es sind keine geführten Abläufe verfügbar.", "i18n:govoplan-campaign.no_guided_workflows": "Es sind keine geführten Abläufe verfügbar.",
"i18n:govoplan-campaign.required_action.f2429497": "Erforderliche Aktion",
"i18n:govoplan-campaign.who_can_resolve_this.e60939b2": "Wer kann dies beheben",
"i18n:govoplan-campaign.where_to_go.bb1c6969": "Ziel",
"i18n:govoplan-campaign.critical_blockers.8a37e088": "Kritische Blockaden",
"i18n:govoplan-campaign.remaining.cc632b5e": "Verbleibend",
"i18n:govoplan-campaign.individual_review.402783bf": "Einzelprüfung",
"i18n:govoplan-campaign.group_review.a809a9d9": "Gruppenprüfung",
"i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blockierende Validierungsprobleme verhindern die Erstellung.",
"i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Korrigieren Sie die betroffenen Kampagnendaten und validieren Sie erneut.",
"i18n:govoplan-campaign.campaign_identity_sender_recipients_template_or_files.69bc29f0": "Kampagnenidentität, Absender & Empfänger, Vorlage oder Dateien",
"i18n:govoplan-campaign.campaign_editor_or_reviewer.7869b106": "Kampagnenbearbeitung oder -prüfung",
"i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1": "{value0} Validierungswarnungen müssen geprüft werden, verhindern die Erstellung aber nicht.",
"i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4": "Prüfen Sie die Warnungsdetails; korrigieren Sie die Daten oder akzeptieren Sie die dokumentierte Bedingung.",
"i18n:govoplan-campaign.review_send_validate.c1b403c7": "Prüfen & Senden - Validieren",
"i18n:govoplan-campaign.the_validation_evidence_is_stale.408e8930": "Der Validierungsnachweis ist veraltet.",
"i18n:govoplan-campaign.run_validation_again_before_relying_on_this_result.97116fc9": "Validieren Sie erneut, bevor Sie sich auf dieses Ergebnis verlassen.",
"i18n:govoplan-campaign.value0_built_message_s_are_blocked_or_failed.8061ea60": "{value0} erstellte Nachrichten sind blockiert oder fehlgeschlagen.",
"i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Korrigieren Sie die betroffenen Empfänger-, Vorlagen-, Mail- oder Anhangsdaten und erstellen Sie erneut.",
"i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Absender & Empfänger, Vorlage, Mail-Einstellungen oder Dateien",
"i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} Prüfentscheidungen verbleiben ({value1} einzeln, {value2} als Gruppe).",
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Öffnen Sie jede kritische Nachricht und dokumentieren Sie eine Entscheidung; akzeptieren Sie anschließend ausdrücklich die nichtkritische Gruppe.",
"i18n:govoplan-campaign.review_send_built_messages.6b030946": "Prüfen & Senden - Erstellte Nachrichten",
"i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} Warnungen zu erstellten Nachrichten müssen bestätigt werden.",
"i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Muss vor dem Versand korrigiert werden.",
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Erfordert eine Einzelentscheidung.",
"i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "Kann nach Abschluss der kritischen Prüfung gemeinsam akzeptiert werden.",
"i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "In der abgeschlossenen Prüfung bereits bestätigt.",
"i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "Alle erforderlichen Prüfentscheidungen sind abgeschlossen.",
"i18n:govoplan-campaign.no_critical_built_message_blockers_remain.97b3c64f": "Es verbleiben keine kritischen Blockaden für erstellte Nachrichten.",
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign", "i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns", "i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Abgelegt", "i18n:govoplan-campaign.appended.979f5a82": "Abgelegt",
@@ -1522,9 +1600,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.archive_filename.de958cbd": "archive filename", "i18n:govoplan-campaign.archive_filename.de958cbd": "archive filename",
"i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639": "Archive filenames must be present and unique.", "i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639": "Archive filenames must be present and unique.",
"i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427": "Archive filenames must not be empty", "i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427": "Archive filenames must not be empty",
"i18n:govoplan-campaign.archive_campaign.26dcfb8a": "Kampagne archivieren",
"i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1": "Dadurch wird die Kampagne aus der aktiven Arbeit entfernt. Versionen, Versandergebnisse und Pruefnachweise bleiben erhalten. Aktive oder ungeklaerte Versandvorgaenge muessen zuerst abgeschlossen werden.",
"i18n:govoplan-campaign.archive_name.6310f9e1": "Archive name", "i18n:govoplan-campaign.archive_name.6310f9e1": "Archive name",
"i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1": "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.", "i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1": "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.",
"i18n:govoplan-campaign.archived.eddc813f": "Archived", "i18n:govoplan-campaign.archived.eddc813f": "Archived",
"i18n:govoplan-campaign.campaign_archived.3f0ca2b7": "Kampagne archiviert.",
"i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.", "i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.",
"i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV", "i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV",
"i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report", "i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report",
@@ -2313,6 +2394,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.save.efc007a3": "Speichern", "i18n:govoplan-campaign.save.efc007a3": "Speichern",
"i18n:govoplan-campaign.saved.c0ae8f6e": "Gespeichert", "i18n:govoplan-campaign.saved.c0ae8f6e": "Gespeichert",
"i18n:govoplan-campaign.saving.56a2285c": "Saving…", "i18n:govoplan-campaign.saving.56a2285c": "Saving…",
"i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0": "Speichern oder verwerfen Sie die Aenderungen an der Uebersicht vor dem Archivieren.",
"i18n:govoplan-campaign.scope_field_value": "{value0}-Feld \"{value1}\"", "i18n:govoplan-campaign.scope_field_value": "{value0}-Feld \"{value1}\"",
"i18n:govoplan-campaign.scope.4651a34e": "Geltungsbereich", "i18n:govoplan-campaign.scope.4651a34e": "Geltungsbereich",
"i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5": "Search recipient, subject or entry ID", "i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5": "Search recipient, subject or entry ID",
+2 -5
View File
@@ -17,7 +17,6 @@ import "./styles/campaign-workspace.css";
const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage")); const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage"));
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace")); const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
const campaignRead = ["campaigns:campaign:read"]; const campaignRead = ["campaigns:campaign:read"];
const reportRead = ["campaigns:report:read"]; const reportRead = ["campaigns:report:read"];
@@ -91,16 +90,14 @@ export const campaignModule: PlatformWebModule = {
} }
], ],
navItems: [ navItems: [
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignModuleRead, order: 20 }, { to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignModuleRead, order: 20 }],
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }],
routes: [ routes: [
{ path: "/campaigns", anyOf: campaignModuleRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModuleLandingRoute, { settings, auth }) }, { path: "/campaigns", anyOf: campaignModuleRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModuleLandingRoute, { settings, auth }) },
{ path: "/operator", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: () => createElement(Navigate, { to: "/campaigns/queue", replace: true }) }, { path: "/operator", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: () => createElement(Navigate, { to: "/campaigns/queue", replace: true }) },
{ path: "/campaigns/queue", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "queue", settings, auth }) }, { path: "/campaigns/queue", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "queue", settings, auth }) },
{ path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) }, { path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) },
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }, { path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
uiCapabilities: { uiCapabilities: {
"dashboard.widgets": campaignDashboardWidgets, "dashboard.widgets": campaignDashboardWidgets,
"wizard.directories": campaignWizardDirectories "wizard.directories": campaignWizardDirectories
+30
View File
@@ -1285,6 +1285,11 @@
font-weight: 700; font-weight: 700;
} }
.version-history-table .data-grid-body-cell.archived-version-row {
color: var(--text-muted);
background: var(--surface-muted);
}
.mock-message-detail { .mock-message-detail {
margin-top: 16px; margin-top: 16px;
border-top: 1px solid var(--line-subtle); border-top: 1px solid var(--line-subtle);
@@ -1627,6 +1632,16 @@
min-height: 150px; min-height: 150px;
} }
.review-workflow-guidance {
display: grid;
gap: 10px;
margin: 0 0 16px;
}
.review-workflow-progress {
margin-bottom: 16px;
}
.attachment-linking-preview { .attachment-linking-preview {
margin-top: 14px; margin-top: 14px;
} }
@@ -2751,3 +2766,18 @@
align-items: start; align-items: start;
} }
} }
@media (prefers-reduced-motion: reduce) {
.related-link-card,
.recipient-import-step-icon {
transition: none;
}
.related-link-card:hover {
transform: none;
}
.review-flow-stage[data-state="running"] .review-flow-stage-node {
animation: none;
}
}
@@ -32,6 +32,47 @@ const preview = fs.readFileSync(
path.join(sourceRoot, "features/campaigns/components/MessagePreviewOverlay.tsx"), path.join(sourceRoot, "features/campaigns/components/MessagePreviewOverlay.tsx"),
"utf8", "utf8",
); );
const styles = fs.readFileSync(
path.join(sourceRoot, "styles/campaign-workspace.css"),
"utf8",
);
const jsonView = fs.readFileSync(
path.join(sourceRoot, "features/campaigns/CampaignJsonView.tsx"),
"utf8",
);
const auditView = fs.readFileSync(
path.join(sourceRoot, "features/campaigns/CampaignAuditPage.tsx"),
"utf8",
);
const attachmentPreview = fs.readFileSync(
path.join(sourceRoot, "features/campaigns/review/AttachmentLinkingPreview.tsx"),
"utf8",
);
assert.ok(
jsonView.includes('DismissibleAlert tone="warning" dismissible={false}'),
"The full Campaign JSON projection must retain an explicit privacy warning",
);
assert.ok(
auditView.includes("ActionBlockerHint"),
"Unavailable Campaign audit projection must retain an actionable shared blocker",
);
assert.ok(
attachmentPreview.includes('<DismissibleAlert tone="danger" compact dismissible={false}>'),
"Attachment-preview validation failures must use the compact shared alert",
);
assert.ok(
styles.includes("@media (prefers-reduced-motion: reduce)"),
"Campaign must retain an explicit reduced-motion presentation contract",
);
assert.ok(
styles.includes("@media (max-width:"),
"Campaign must retain responsive narrow-viewport layouts",
);
assert.ok(
styles.includes(":focus-visible"),
"Campaign-specific interactive controls must retain visible keyboard focus",
);
for (const handler of ["onFirst", "onPrevious", "onNext", "onLast"]) { for (const handler of ["onFirst", "onPrevious", "onNext", "onLast"]) {
const buttonLine = preview const buttonLine = preview
.split("\n") .split("\n")
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8");
const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8");
const recipients = readFileSync("src/features/campaigns/RecipientDataPage.tsx", "utf8");
const fieldValueInput = readFileSync("src/features/campaigns/components/FieldValueInput.tsx", "utf8");
const mailSettings = readFileSync("src/features/campaigns/MailSettingsPage.tsx", "utf8");
const api = readFileSync("src/api/campaigns.ts", "utf8");
assert.match(workspace, /<CampaignOverviewPage settings=\{settings\} auth=\{auth\}/);
assert.match(overview, /hasScope\(auth, "campaigns:campaign:archive"\)/);
assert.match(overview, /getCampaignLifecyclePolicy/);
assert.match(overview, /pendingLifecycleAction/);
assert.match(overview, /archive_campaign_confirmation/);
assert.match(overview, /await archiveCampaign\(settings, campaign\.id, pending\.policy\.state_token\)/);
assert.match(overview, /await deleteCampaign\(settings, campaign\.id, pending\.policy\.state_token\)/);
assert.match(overview, /await copyCampaign\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/lifecycle-policy/);
assert.match(recipients, /requestBulkActivation\(true\)/);
assert.match(recipients, /requestBulkActivation\(false\)/);
assert.match(recipients, /const count = inlineEntries\.filter/);
assert.match(recipients, /<ConfirmDialog[\s\S]*bulkActivation/);
assert.match(recipients, /replaceInlineEntries\(inlineEntries\.map/);
assert.match(fieldValueInput, /<PasswordField[\s\S]*generator[\s\S]*autoComplete="new-password"/);
assert.match(mailSettings, /disabled=\{credentialSaving\} generator autoComplete="new-password"/);
console.log("Campaign lifecycle UI structural contract passed.");
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const guidance = readFileSync(resolve(here, "../src/features/campaigns/review/ReviewWorkflowGuidance.tsx"), "utf8");
const page = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8");
assert.match(guidance, /<ActionBlockerHint/);
assert.match(guidance, /<GuidedReviewList/);
assert.match(guidance, /tone="danger"/);
assert.match(guidance, /tone="warning"/);
assert.match(guidance, /requiredAction: requiredActionLabel/);
assert.match(guidance, /target: destinationLabel/);
assert.match(guidance, /documentation=\{\{ topicId: documentationTopicId \}\}/);
assert.match(guidance, /campaigns\.workflow\.prepare-validate-and-build/);
assert.match(guidance, /campaigns\.workflow\.complete-review/);
assert.match(page, /calculateBuildReviewProgress/);
assert.match(page, /<WorkflowFact label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/);
assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { calculateBuildReviewProgress } from "../src/features/campaigns/review/reviewProgress.ts";
test("separates blocking, individual, and group review work", () => {
assert.deepEqual(calculateBuildReviewProgress({
blocking: 2,
individualRequired: 3,
individualReviewed: 1,
groupRequired: 4,
reviewComplete: false
}), {
blocking: 2,
individualRequired: 3,
individualReviewed: 1,
individualRemaining: 2,
groupRequired: 4,
groupReviewed: 0,
groupRemaining: 4,
required: 7,
reviewed: 1,
remaining: 6
});
});
test("a recorded review completion acknowledges every review decision", () => {
const progress = calculateBuildReviewProgress({
blocking: 0,
individualRequired: 3,
individualReviewed: 3,
groupRequired: 4,
reviewComplete: true
});
assert.equal(progress.reviewed, 7);
assert.equal(progress.remaining, 0);
assert.equal(progress.individualRemaining, 0);
assert.equal(progress.groupRemaining, 0);
});
test("normalizes malformed counters without overstating progress", () => {
const progress = calculateBuildReviewProgress({
blocking: -2,
individualRequired: 2.9,
individualReviewed: 99,
groupRequired: Number.NaN,
reviewComplete: false
});
assert.equal(progress.blocking, 0);
assert.equal(progress.individualRequired, 2);
assert.equal(progress.individualReviewed, 2);
assert.equal(progress.required, 2);
assert.equal(progress.remaining, 0);
});