From 8f5231147db7d98cf80a0714608c2890c84542d8 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 4 Aug 2026 01:04:39 +0200 Subject: [PATCH] Complete governed campaign lifecycle actions --- docs/CAMPAIGN_HANDBOOK.md | 28 ++ .../backend/campaign/lifecycle.py | 234 +++++++++++++ src/govoplan_campaign/backend/db/models.py | 10 + .../backend/documentation.py | 52 ++- .../e3c8f4a5b6d7_campaign_version_archival.py | 85 +++++ .../e3c8f4a5b6d7_campaign_version_archival.py | 85 +++++ .../backend/routes/campaigns.py | 314 +++++++++++++++--- src/govoplan_campaign/backend/schemas.py | 27 ++ tests/test_campaign_lifecycle.py | 295 ++++++++++++++++ webui/src/api/campaigns.ts | 68 +++- .../campaigns/CampaignOverviewPage.tsx | 175 ++++++++-- webui/src/styles/campaign-workspace.css | 20 ++ .../campaign-lifecycle-ui-structure.test.mjs | 10 +- 13 files changed, 1324 insertions(+), 79 deletions(-) create mode 100644 src/govoplan_campaign/backend/campaign/lifecycle.py create mode 100644 src/govoplan_campaign/backend/migrations/dev_versions/e3c8f4a5b6d7_campaign_version_archival.py create mode 100644 src/govoplan_campaign/backend/migrations/versions/e3c8f4a5b6d7_campaign_version_archival.py create mode 100644 tests/test_campaign_lifecycle.py diff --git a/docs/CAMPAIGN_HANDBOOK.md b/docs/CAMPAIGN_HANDBOOK.md index 78e7a3f..fa72500 100644 --- a/docs/CAMPAIGN_HANDBOOK.md +++ b/docs/CAMPAIGN_HANDBOOK.md @@ -96,6 +96,18 @@ Important distinctions: - **Archive** preserves evidence. Draft-only campaigns without built, locked, or delivery evidence may be deleted where policy allows; evidence-bearing 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 @@ -221,6 +233,8 @@ At a minimum: Only the latter becomes explicitly retryable, and neither decision resends the already SMTP-accepted message. 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 progress. Cancel marks work that has not yet produced a protected SMTP outcome; @@ -283,6 +297,8 @@ as an executable Mail configuration: - public responses remove legacy transport fields and secrets; - 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 Mail-settings save; and - a locked version is preserved and must be forked to an editable successor. @@ -538,6 +554,18 @@ lock exists. Evidence-bearing campaigns are archived. Destructive module retirement remains a separately confirmed installer operation with backup and 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 Campaign is ready to serve as the demonstration module only when all of the diff --git a/src/govoplan_campaign/backend/campaign/lifecycle.py b/src/govoplan_campaign/backend/campaign/lifecycle.py new file mode 100644 index 0000000..4864ce5 --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/lifecycle.py @@ -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." + ) diff --git a/src/govoplan_campaign/backend/db/models.py b/src/govoplan_campaign/backend/db/models.py index 4c05801..4922be4 100644 --- a/src/govoplan_campaign/backend/db/models.py +++ b/src/govoplan_campaign/backend/db/models.py @@ -247,6 +247,16 @@ class CampaignVersion(Base, TimestampMixin): 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_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") diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index f56376f..9db4fbd 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -159,6 +159,31 @@ CAMPAIGN_USER_DOCUMENTATION = ( 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"), ), + _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( topic_id="campaigns.workflow.import-recipients", title="Import recipients into a campaign", @@ -596,7 +621,7 @@ CAMPAIGN_USER_DOCUMENTATION = ( ), steps=( "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.", "Verify that the deleted draft no longer appears in active Campaigns and that the audit event exists.", ), @@ -604,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.", related_topic_ids=("campaigns.workflow.archive-campaign",), 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.", ), ), + _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"), + ), ) diff --git a/src/govoplan_campaign/backend/migrations/dev_versions/e3c8f4a5b6d7_campaign_version_archival.py b/src/govoplan_campaign/backend/migrations/dev_versions/e3c8f4a5b6d7_campaign_version_archival.py new file mode 100644 index 0000000..f9237a1 --- /dev/null +++ b/src/govoplan_campaign/backend/migrations/dev_versions/e3c8f4a5b6d7_campaign_version_archival.py @@ -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") diff --git a/src/govoplan_campaign/backend/migrations/versions/e3c8f4a5b6d7_campaign_version_archival.py b/src/govoplan_campaign/backend/migrations/versions/e3c8f4a5b6d7_campaign_version_archival.py new file mode 100644 index 0000000..f9237a1 --- /dev/null +++ b/src/govoplan_campaign/backend/migrations/versions/e3c8f4a5b6d7_campaign_version_archival.py @@ -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") diff --git a/src/govoplan_campaign/backend/routes/campaigns.py b/src/govoplan_campaign/backend/routes/campaigns.py index df3bc48..ca2553b 100644 --- a/src/govoplan_campaign/backend/routes/campaigns.py +++ b/src/govoplan_campaign/backend/routes/campaigns.py @@ -1,9 +1,10 @@ from __future__ import annotations +import copy import dataclasses +from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query, Response, status -from sqlalchemy import or_ from sqlalchemy.orm import Session from govoplan_campaign.backend.schemas import ( @@ -12,6 +13,9 @@ from govoplan_campaign.backend.schemas import ( CampaignUpdateRequest, CampaignCreateResponse, CampaignCreateMinimalRequest, + CampaignCopyRequest, + CampaignLifecycleMutationRequest, + CampaignLifecyclePolicyResponse, CampaignAddressLookupCandidate, CampaignAddressLookupResponse, CampaignCalendarCatalogResponse, @@ -56,13 +60,16 @@ from govoplan_campaign.backend.change_tracking import ( ) from govoplan_campaign.backend.db.models import ( Campaign, - CampaignJob, CampaignVersion, RecipientImportMappingProfile, ) from govoplan_campaign.backend.campaign.postbox_targets import ( delivery_catalog_payload, ) +from govoplan_campaign.backend.campaign.lifecycle import ( + assert_lifecycle_state_token, + campaign_lifecycle_policy, +) from govoplan_campaign.backend.integrations import ( calendar_integration, PostboxDeliveryUnavailable, @@ -120,6 +127,93 @@ CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" 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) def create_campaign( payload: CampaignCreateRequest, @@ -1524,18 +1618,180 @@ def update_campaign_metadata_endpoint( return CampaignResponse.model_validate(campaign) -@router.post("/{campaign_id}/archive", response_model=CampaignResponse) -def archive_campaign( +@router.get( + "/{campaign_id}/lifecycle-policy", + response_model=CampaignLifecyclePolicyResponse, +) +def get_campaign_lifecycle_policy( 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), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")), ): - campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) - if campaign.status in {"queued", "sending", "outcome_unknown"}: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Active or uncertain delivery must be resolved before archiving.", + campaign, _policy = _lifecycle_policy_for_mutation( + session, + campaign_id=campaign_id, + principal=principal, + 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" session.add(campaign) audit_from_principal( @@ -1554,43 +1810,17 @@ def archive_campaign( @router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_draft_campaign( campaign_id: str, + payload: CampaignLifecycleMutationRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:delete")), ): - campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) - if campaign.status != "draft": - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Only untouched draft campaigns can be deleted.", - ) - 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() + campaign, _policy = _lifecycle_policy_for_mutation( + session, + campaign_id=campaign_id, + principal=principal, + expected_state_token=payload.expected_state_token, + action="delete_campaign", ) - 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" session.add(campaign) audit_from_principal( diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index ac683b3..bee2efb 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -42,6 +42,18 @@ class CampaignUpdateRequest(BaseModel): 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): model_config = ConfigDict(extra="forbid") @@ -141,6 +153,8 @@ class CampaignVersionResponse(BaseModel): 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") @classmethod @@ -200,6 +214,19 @@ class CampaignResponse(BaseModel): 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): campaign: CampaignResponse version: CampaignVersionResponse diff --git a/tests/test_campaign_lifecycle.py b/tests/test_campaign_lifecycle.py new file mode 100644 index 0000000..eeab854 --- /dev/null +++ b/tests/test_campaign_lifecycle.py @@ -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() diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index f5e4743..5a46774 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -85,6 +85,21 @@ export type CampaignVersionListItem = { execution_snapshot_at?: string | null; delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | 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; + provenance: Record; }; export type CampaignVersionDetail = CampaignVersionListItem & { @@ -1004,10 +1019,59 @@ payload: CampaignUpdatePayload) export async function archiveCampaign( settings: ApiSettings, -campaignId: string) +campaignId: string, +expectedStateToken: string) : Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/archive`, { - method: "POST" + method: "POST", + body: JSON.stringify({ expected_state_token: expectedStateToken }) + }); +} + +export async function getCampaignLifecyclePolicy( +settings: ApiSettings, +campaignId: string, +versionId?: string | null) +: Promise { + const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/lifecycle-policy${suffix}`); +} + +export async function deleteCampaign( +settings: ApiSettings, +campaignId: string, +expectedStateToken: string) +: Promise { + await apiFetch(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 { + return apiFetch(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 { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/archive`, { + method: "POST", + body: JSON.stringify({ expected_state_token: expectedStateToken }) }); } diff --git a/webui/src/features/campaigns/CampaignOverviewPage.tsx b/webui/src/features/campaigns/CampaignOverviewPage.tsx index 1d00bfd..76f6dd6 100644 --- a/webui/src/features/campaigns/CampaignOverviewPage.tsx +++ b/webui/src/features/campaigns/CampaignOverviewPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Archive, ExternalLink, LockKeyhole, LockOpen } from "lucide-react"; +import { Archive, Copy, ExternalLink, LockKeyhole, LockOpen, Trash2 } from "lucide-react"; import { Link } from "react-router"; import type { ApiSettings, AuthInfo } from "../../types"; import { Button } from "@govoplan/core-webui"; @@ -10,14 +10,20 @@ import { LoadingFrame } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui"; +import { 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 { archiveCampaign, + archiveCampaignVersion, + copyCampaign, + deleteCampaign, + getCampaignLifecyclePolicy, lockCampaignVersionPermanently, lockCampaignVersionTemporarily, unlockCampaignVersionUserLock, updateCampaignMetadata, + type CampaignLifecyclePolicy, type CampaignVersionDetail, type CampaignVersionListItem } from "../../api/campaigns"; @@ -39,22 +45,32 @@ import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddre const campaignModeOptions = ["draft", "test", "send"]; type LockAction = "temporary" | "unlock" | "permanent"; 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, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) { const navigate = useGuardedNavigate(); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const campaign = data.campaign; - const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]); + 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 [identityDirty, setIdentityDirty] = useState(false); const [savingIdentity, setSavingIdentity] = useState(false); const [pendingLockAction, setPendingLockAction] = useState(null); const [lockBusy, setLockBusy] = useState(false); - const [archiveDialogOpen, setArchiveDialogOpen] = useState(false); - const [archiving, setArchiving] = useState(false); + const [pendingLifecycleAction, setPendingLifecycleAction] = useState(null); + const [lifecycleBusy, setLifecycleBusy] = useState(false); const [message, setMessage] = useState(""); 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({ dirty: identityDirty, @@ -149,20 +165,56 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se await reload({ force: true }); } - async function applyArchive() { - if (!campaign || archiving) return; - setArchiving(true); + async function prepareLifecycleAction(action: LifecycleAction, version?: CampaignVersionListItem) { + if (!campaign || lifecycleBusy || identityDirty) return; + setLifecycleBusy(true); setError(""); setMessage(""); try { - await archiveCampaign(settings, campaign.id); - setArchiveDialogOpen(false); - setMessage("i18n:govoplan-campaign.campaign_archived.3f0ca2b7"); + 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 { - setArchiving(false); + setLifecycleBusy(false); } } @@ -174,10 +226,25 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se

i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113

+ {canCopy && data.currentVersion && } + {canDelete && } {canArchive &&
- - - i18n:govoplan-campaign.open.cf9b7706 - }> + + {archivedVersionCount > 0 && } + + i18n:govoplan-campaign.open.cf9b7706 + + }>
@@ -244,26 +316,33 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se void prepareLifecycleAction(action, version) + )} getRowKey={(version) => version.id} initialSort={{ columnId: "version", direction: "desc" }} emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e" className="version-history-table" - rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined} /> + rowClassName={(version) => version.archived_at ? "archived-version-row" : version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
setArchiveDialogOpen(false)} - onConfirm={() => void applyArchive()} /> + busy={lifecycleBusy} + onCancel={() => setPendingLifecycleAction(null)} + onConfirm={() => void applyLifecycleAction()} /> void, navigate: (to: string) => void, currentVersionId?: string | null): DataGridColumn[] { +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[] { 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: "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) => , value: (version) => version.workflow_state ?? "editing" }, @@ -356,6 +442,8 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at; return