Compare commits

...
11 Commits
Author SHA1 Message Date
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
15 changed files with 814 additions and 16 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
+15
View File
@@ -124,6 +124,12 @@ action.
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,
@@ -454,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
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.12", "version": "0.1.17",
"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.17",
"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.17"
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.17",
"jsonschema>=4,<5", "jsonschema>=4,<5",
"pydantic>=2,<3", "pydantic>=2,<3",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
@@ -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
+45 -5
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.17",
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",
@@ -592,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",
@@ -756,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"),
@@ -804,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. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.", 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"),
@@ -860,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.",
], ],
@@ -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",
]
+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()
+13
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)
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.12", "version": "0.1.17",
"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.17",
"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",
@@ -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
@@ -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))}
+16
View File
@@ -2,6 +2,14 @@ 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.required_action.f2429497": "Required action",
@@ -1335,6 +1343,14 @@ 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.required_action.f2429497": "Erforderliche Aktion",
@@ -3,6 +3,9 @@ import { readFileSync } from "node:fs";
const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8"); const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8");
const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.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"); const api = readFileSync("src/api/campaigns.ts", "utf8");
assert.match(workspace, /<CampaignOverviewPage settings=\{settings\} auth=\{auth\}/); assert.match(workspace, /<CampaignOverviewPage settings=\{settings\} auth=\{auth\}/);
@@ -17,5 +20,12 @@ assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pe
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/); assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/); assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/lifecycle-policy/); 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."); console.log("Campaign lifecycle UI structural contract passed.");