Compare commits

...
14 Commits
79 changed files with 6965 additions and 763 deletions
+5 -2
View File
@@ -1,8 +1,11 @@
# Campaign Accessibility Review
The Campaign WebUI uses Core's semantic `Button`, `Dialog`, `DataGrid`, form,
alert, and segmented-control components. Core owns focus trapping, focus return,
Escape handling, labels, disabled state, and keyboard behavior for those shared
alert, and segmented-control components. Its page frames use Core `PageLayout`
and its full-canvas navigation/content shells use `WorkspaceLayout`, so pane
scrolling, sticky headings, action collapse, narrow-layout behavior, and help
scope remain platform-owned. Core also owns focus trapping, focus return,
Escape handling, labels, disabled state, and keyboard behavior for the shared
primitives.
## Repeatable review matrix
+58 -11
View File
@@ -10,19 +10,60 @@ and they must identify that inheritance explicitly.
- Campaign version
- Campaign delivery job / built message
- Computed Campaign report, identified by Campaign, version, and report kind
- Recipient row, identified by `<version UUID>:<job UUID>`
- Frozen recipient source snapshot, identified by its Campaign-version UUID
- Campaign attachment binding and version-bound frozen attachment resolution
- Persisted validation issue, version-bound review decision, and attachment-policy override
- SMTP, IMAP append, Postbox, and printable-output attempts
- Message action, message-action attempt, and job reconciliation decision
- Campaign share and Core-owned Campaign ownership-transfer record
- Independently user-owned recipient import mapping profile
- Saved recipient import execution, identified by `<version UUID>:<import UUID>`
- Persisted validation, build, execution-snapshot, and review evidence, identified
by `<version UUID>:<artifact kind>`
## Planned Slices
All persisted child IDs are random UUIDs. Embedded build/review children use a
version UUID plus a random job UUID, so callers cannot enumerate a recipient
index or infer an address. A version mismatch is reported as a stale reference.
Missing and cross-tenant children use the same non-disclosing not-found
provenance. Explanations never include recipient addresses, source rows,
filenames, object locators, transport responses, worker claims, target
snapshots, diagnostic text, or reconciliation notes.
1. Recipient rows and imported recipient-source snapshots
2. Attachment bindings and frozen attachment resolutions
3. Validation issues, review decisions, and attachment-policy overrides
4. Delivery attempts, IMAP append attempts, Postbox attempts, and
reconciliation decisions
5. Campaign shares and ownership-transfer records
6. Import mapping profiles and import executions
7. Reusable Campaign templates and template revisions when the template
library becomes persistent
8. Export packages and protocol/report artifacts
## Permission matrix
| Evidence | Parent boundary | Further restriction |
| --- | --- | --- |
| Recipient row or source snapshot | Campaign read/owner/share | `campaigns:recipient:read` |
| Attachment binding/resolution, validation, review, override | Campaign read/owner/share | Campaign review and `campaigns:diagnostic:read` |
| Delivery status | Campaign read/owner/share | `campaigns:report:read` |
| Transport or worker diagnostics | Campaign read/owner/share | `campaigns:diagnostic:read` |
| Exported delivery evidence | Campaign read/owner/share | `campaigns:report:export` |
| Reconciliation decision | Campaign read/owner/share | Campaign reconcile and diagnostic read |
| Share or ownership transfer | Campaign governance | Campaign share, transfer-participant, group-acceptance, or recovery authority; content access remains a separate decision |
| Import mapping profile | Independent user owner | `campaigns:recipient:import`; no Campaign share is inherited |
| Import execution | Campaign read/owner/share | Recipient read and import authority |
| Persisted protocol artifact | Campaign read/owner/share | Recipient, review, report, diagnostic, or export authority appropriate to the artifact |
Postbox, Mail/IMAP, and printable attempts keep bounded Campaign-owned evidence
after provider acceptance. Their explanation therefore remains available when
an optional provider module is later disabled. A missing attempt reports only
the optional owner and `unavailable_or_hidden`; it does not distinguish absence
from hidden data.
## Optional and unsupported owner boundaries
Reusable templates and template revisions are independently governed by the
optional Templates module; Campaign never treats a Campaign share as a template
grant. Durable export packages are independently governed by the optional
Reporting module. Asking the Campaign provider to explain either class therefore
fails closed with `independently_governed_by_optional_module` and
`unavailable_or_hidden`. The response does not reveal whether the optional
module is absent, the object does not exist, or the caller cannot see it.
Campaign reports generated on demand remain non-persisted, version-bound
resources. Their explanation names the report kind and its Campaign/version
parent, and keeps report read, export, and diagnostic permissions distinct.
Each child explanation must include:
@@ -38,3 +79,9 @@ Delivery attempts, review decisions, reports, and exports can contain more
sensitive evidence than the Campaign summary. Their read and diagnostic/export
permissions therefore remain independently enforceable even when the parent
Campaign is readable.
Import explanations include only the stable import identity, source type,
opaque source identity, source revision, and whether additional provenance was
recorded. They never return imported rows, filenames, column mappings, or source
metadata. Mapping-profile explanations expose only non-reversible header
fingerprints and shape information; headers and mappings remain hidden.
@@ -0,0 +1,184 @@
from __future__ import annotations
import hashlib
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from govoplan_campaign.backend.campaign.models import (
AttachmentReuseAction,
AttachmentReuseAllowance,
AttachmentReusePolicy,
)
from govoplan_campaign.backend.messages.models import MessageDraft, MessageIssue
@dataclass(frozen=True, slots=True)
class _AttachmentUse:
message: MessageDraft
message_key: str
recipient_key: tuple[str, ...]
source_identity: str
file_name: str
@dataclass(slots=True)
class AttachmentReuseEvaluation:
report: dict[str, object]
issues_by_entry_index: dict[int, list[MessageIssue]]
def evaluate_attachment_reuse(
messages: list[MessageDraft],
*,
policy: AttachmentReusePolicy,
) -> AttachmentReuseEvaluation:
"""Evaluate repeated resolved-file use without exposing source paths.
A use is one resolved file occurrence in one attachment rule. The same
source file can therefore be detected both across built messages and when
two rules add it to one message. Allowed findings remain in the build
protocol; policy violations additionally become recipient-level issues.
"""
uses_by_source: dict[str, list[_AttachmentUse]] = defaultdict(list)
for message in messages:
if not message.active:
continue
message_key = str(message.entry_id or message.entry_index)
recipient_key = _recipient_key(message, fallback=message_key)
for attachment in message.attachments:
for match in attachment.matches:
source_identity = _source_identity(match)
uses_by_source[source_identity].append(
_AttachmentUse(
message=message,
message_key=message_key,
recipient_key=recipient_key,
source_identity=source_identity,
file_name=Path(match).name,
)
)
findings: list[dict[str, object]] = []
issues_by_entry_index: dict[int, list[MessageIssue]] = defaultdict(list)
affected_entry_indexes: set[int] = set()
allowed_count = 0
violation_count = 0
for source_identity, uses in sorted(uses_by_source.items()):
if len(uses) < 2:
continue
fingerprint = hashlib.sha256(source_identity.encode("utf-8")).hexdigest()
message_keys = {item.message_key for item in uses}
recipient_keys = {item.recipient_key for item in uses}
allowed, explanation = _is_allowed(
policy,
message_count=len(message_keys),
recipient_count=len(recipient_keys),
)
disposition = "allowed" if allowed else policy.action.value
finding = {
"file_fingerprint": fingerprint,
"file_name": uses[0].file_name,
"use_count": len(uses),
"message_count": len(message_keys),
"recipient_count": len(recipient_keys),
"disposition": disposition,
"explanation": explanation,
}
findings.append(finding)
if allowed:
allowed_count += 1
continue
violation_count += 1
behavior = _issue_behavior(policy.action)
severity = (
"error" if policy.action == AttachmentReuseAction.BLOCK else "warning"
)
for use in _unique_message_uses(uses):
affected_entry_indexes.add(use.message.entry_index)
issues_by_entry_index[use.message.entry_index].append(
MessageIssue(
severity=severity,
code="duplicate_attachment_reuse",
message=(
f"Attachment {use.file_name!r} is reused {len(uses)} times "
f"across {len(message_keys)} built message(s); the configured "
f"policy requires {disposition}."
),
behavior=behavior,
source="attachments:reuse_policy",
details={
**finding,
"policy": policy.model_dump(mode="json"),
},
)
)
return AttachmentReuseEvaluation(
report={
"contract_version": "1",
"policy": policy.model_dump(mode="json"),
"duplicate_file_count": len(findings),
"allowed_file_count": allowed_count,
"violation_file_count": violation_count,
"affected_message_count": len(affected_entry_indexes),
"findings": findings,
},
issues_by_entry_index=dict(issues_by_entry_index),
)
def _source_identity(value: str) -> str:
return str(Path(value).resolve(strict=False))
def _recipient_key(message: MessageDraft, *, fallback: str) -> tuple[str, ...]:
addresses = message.to or message.bcc or message.cc
normalized = sorted(
{
item.email.strip().casefold()
for item in addresses
if item.email and item.email.strip()
}
)
return tuple(normalized) if normalized else (f"entry:{fallback}",)
def _is_allowed(
policy: AttachmentReusePolicy,
*,
message_count: int,
recipient_count: int,
) -> tuple[bool, str]:
if policy.action == AttachmentReuseAction.ALLOW:
return True, "The campaign policy explicitly allows attachment reuse."
if (
policy.allow_within == AttachmentReuseAllowance.SAME_MESSAGE
and message_count == 1
):
return True, "Reuse is confined to one built message as allowed by policy."
if (
policy.allow_within == AttachmentReuseAllowance.SAME_RECIPIENT
and recipient_count == 1
):
return True, "Reuse is confined to one recipient as allowed by policy."
return False, (
"Reuse crosses the configured allowance and is handled by the "
f"{policy.action.value} policy."
)
def _issue_behavior(action: AttachmentReuseAction) -> str:
if action == AttachmentReuseAction.REVIEW:
return "ask"
return action.value
def _unique_message_uses(uses: list[_AttachmentUse]) -> list[_AttachmentUse]:
unique: dict[int, _AttachmentUse] = {}
for use in uses:
unique.setdefault(use.message.entry_index, use)
return list(unique.values())
@@ -0,0 +1,44 @@
from __future__ import annotations
import copy
from collections.abc import Mapping
DEFAULT_COPY_OPTIONS: dict[str, bool] = {
"include_recipients": True,
"include_files": True,
"include_shares": False,
"include_policies": True,
"include_mail_profile": True,
}
def campaign_copy_configuration(
source: Mapping[str, object],
options: Mapping[str, object],
) -> dict[str, object]:
"""Return an editable configuration copy without operational evidence."""
selected = {**DEFAULT_COPY_OPTIONS, **dict(options)}
raw_json = copy.deepcopy(dict(source))
if not selected["include_recipients"]:
raw_json["recipients"] = {}
raw_json["entries"] = {"inline": [], "imports": []}
if not selected["include_files"]:
raw_json["attachments"] = {}
entries = raw_json.get("entries")
if isinstance(entries, dict):
inline = entries.get("inline")
if isinstance(inline, list):
for entry in inline:
if isinstance(entry, dict):
entry["attachments"] = []
entry["combine_attachments"] = True
if not selected["include_policies"]:
raw_json["validation_policy"] = {}
if not selected["include_mail_profile"]:
raw_json["server"] = {}
return raw_json
__all__ = ["DEFAULT_COPY_OPTIONS", "campaign_copy_configuration"]
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignSchedule,
CampaignShare,
CampaignVersion,
)
@@ -19,7 +20,7 @@ from govoplan_core.auth import ApiPrincipal, has_scope
POLICY_ID = "campaign.lifecycle"
POLICY_VERSION = "1"
POLICY_VERSION = "2"
_ACTIVE_QUEUE_STATES = {"queued", "sending"}
_ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"}
@@ -104,6 +105,12 @@ def campaign_lifecycle_policy(
.order_by(CampaignShare.id.asc())
.all()
)
schedules = (
session.query(CampaignSchedule)
.filter(CampaignSchedule.campaign_id == campaign.id)
.order_by(CampaignSchedule.id.asc())
.all()
)
selected_version = next(
(version for version in versions if version.id == version_id),
None,
@@ -116,6 +123,10 @@ def campaign_lifecycle_policy(
"id": campaign.id,
"status": campaign.status,
"current_version_id": campaign.current_version_id,
"settings_sha256": _canonical_hash(campaign.settings or {}),
"mail_profile_policy_sha256": _canonical_hash(
campaign.mail_profile_policy or {}
),
"updated_at": _timestamp(campaign.updated_at),
},
"versions": [
@@ -129,6 +140,7 @@ def campaign_lifecycle_policy(
"published_at": _timestamp(version.published_at),
"execution_snapshot_at": _timestamp(version.execution_snapshot_at),
"archived_at": _timestamp(version.archived_at),
"configuration_sha256": _canonical_hash(version.raw_json or {}),
"updated_at": _timestamp(version.updated_at),
}
for version in versions
@@ -145,13 +157,34 @@ def campaign_lifecycle_policy(
}
for job in jobs
],
"active_share_ids": [share.id for share in shares],
"active_shares": [
{
"id": share.id,
"target_type": share.target_type,
"target_id": share.target_id,
"permission": share.permission,
"updated_at": _timestamp(share.updated_at),
}
for share in shares
],
"schedules": [
{
"id": schedule.id,
"active": schedule.active,
"resource_revision": schedule.resource_revision,
"next_fire_at": _timestamp(schedule.next_fire_at),
"occurrence_count": schedule.occurrence_count,
"updated_at": _timestamp(schedule.updated_at),
}
for schedule in schedules
],
"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)
active_schedules = any(schedule.active for schedule in schedules)
archive = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:archive"):
@@ -163,6 +196,11 @@ def campaign_lifecycle_policy(
False,
"Active or uncertain delivery must be resolved before archiving.",
)
elif active_schedules:
archive = LifecycleDecision(
False,
"Pause active Campaign schedules before archiving.",
)
delete = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:delete"):
@@ -184,12 +222,15 @@ def campaign_lifecycle_policy(
False,
"Revoke active campaign shares before deleting the untouched draft.",
)
elif schedules:
delete = LifecycleDecision(
False,
"Campaigns with schedule evidence must be archived instead of deleted.",
)
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.")
@@ -220,9 +261,10 @@ def campaign_lifecycle_policy(
"campaign_state",
"retained_evidence",
"active_delivery",
"scheduled_automation",
"optimistic_concurrency",
),
"evidence_retention": "Versions, delivery outcomes, reports, and audit records are never deleted by archival.",
"evidence_retention": "Versions, schedule occurrences, delivery outcomes, reports, and audit records are never deleted by archival.",
},
}
@@ -468,6 +468,48 @@ class AttachmentBasePathConfig(StrictModel):
source: str | None = None
class ResidualFileMode(StrEnum):
NONE = "none"
REPORT = "report"
ATTACH = "attach"
class AttachmentReuseAction(StrEnum):
ALLOW = "allow"
WARN = "warn"
REVIEW = "review"
BLOCK = "block"
class AttachmentReuseAllowance(StrEnum):
NONE = "none"
SAME_RECIPIENT = "same_recipient"
SAME_MESSAGE = "same_message"
class AttachmentReusePolicy(StrictModel):
action: AttachmentReuseAction = AttachmentReuseAction.ALLOW
allow_within: AttachmentReuseAllowance = AttachmentReuseAllowance.NONE
class ResidualFileDispositionConfig(StrictModel):
mode: ResidualFileMode = ResidualFileMode.NONE
recipient: RecipientConfig | None = None
subject: str = "Unassigned files in campaign {{local:campaign_name}}"
text: str = (
"The campaign build found {{local:residual_file_count}} file(s) that "
"were not assigned to a recipient.\n\n{{local:residual_file_list}}"
)
@model_validator(mode="after")
def require_recipient_for_routing(self) -> "ResidualFileDispositionConfig":
if self.mode != ResidualFileMode.NONE and self.recipient is None:
raise ValueError(
"Residual-file report or attachment routing requires a recipient."
)
return self
class AttachmentConfig(StrictModel):
id: str | None = None
label: str | None = None
@@ -509,6 +551,12 @@ class AttachmentsConfig(StrictModel):
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
missing_behavior: Behavior = Behavior.WARN
ambiguous_behavior: Behavior = Behavior.ASK
reuse_policy: AttachmentReusePolicy = Field(
default_factory=AttachmentReusePolicy
)
residual_files: ResidualFileDispositionConfig = Field(
default_factory=ResidualFileDispositionConfig
)
@model_validator(mode="after")
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
@@ -0,0 +1,363 @@
from __future__ import annotations
import calendar
import copy
import hashlib
import json
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignSchedule,
CampaignScheduleOccurrence,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.persistence.campaigns import (
create_campaign_version_from_json,
)
from govoplan_core.audit.logging import audit_event
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
def canonical_configuration_hash(value: Mapping[str, object]) -> str:
return hashlib.sha256(
json.dumps(
value,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
def campaign_schedule_source_snapshot(
*,
configuration: Mapping[str, object],
campaign_settings: Mapping[str, object],
mail_profile_policy: Mapping[str, object],
shares: list[Mapping[str, object]],
) -> dict[str, object]:
"""Seal every selected source domain so worker execution cannot drift."""
return {
"schema": SCHEDULE_SOURCE_SCHEMA,
"configuration": copy.deepcopy(dict(configuration)),
"campaign_settings": copy.deepcopy(dict(campaign_settings)),
"mail_profile_policy": copy.deepcopy(dict(mail_profile_policy)),
"shares": [copy.deepcopy(dict(item)) for item in shares],
}
def next_schedule_fire(
scheduled_for: datetime,
*,
recurrence_kind: str,
interval_count: int,
timezone_name: str,
) -> datetime | None:
if recurrence_kind == "once":
return None
if recurrence_kind not in RECURRENCE_KINDS:
raise ValueError(f"Unsupported campaign recurrence: {recurrence_kind}")
if interval_count < 1:
raise ValueError("Campaign recurrence interval must be positive")
try:
zone = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as exc:
raise ValueError(f"Unknown campaign schedule timezone: {timezone_name}") from exc
local = _as_utc(scheduled_for).astimezone(zone)
if recurrence_kind == "daily":
upcoming = local + timedelta(days=interval_count)
elif recurrence_kind == "weekly":
upcoming = local + timedelta(weeks=interval_count)
else:
month_index = local.year * 12 + local.month - 1 + interval_count
year, month_offset = divmod(month_index, 12)
month = month_offset + 1
day = min(local.day, calendar.monthrange(year, month)[1])
upcoming = local.replace(year=year, month=month, day=day)
return upcoming.astimezone(UTC)
def dispatch_due_campaign_schedules(
session: Session,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> dict[str, object]:
observed_at = _as_utc(now or datetime.now(UTC))
query = session.query(CampaignSchedule).filter(
CampaignSchedule.active.is_(True),
CampaignSchedule.next_fire_at.is_not(None),
CampaignSchedule.next_fire_at <= observed_at,
)
if tenant_id is not None:
query = query.filter(CampaignSchedule.tenant_id == tenant_id)
schedules = (
query.order_by(CampaignSchedule.next_fire_at.asc(), CampaignSchedule.id.asc())
.with_for_update(skip_locked=True)
.limit(max(1, min(limit, 250)))
.all()
)
result: dict[str, object] = {
"selected": len(schedules),
"prepared": 0,
"failed": 0,
"completed": 0,
"coalesced": 0,
"campaign_ids": [],
"operator_actions": [],
}
for schedule in schedules:
scheduled_for = _as_utc(schedule.next_fire_at or observed_at)
try:
with session.begin_nested():
campaign, version, skipped = _prepare_occurrence(
session,
schedule=schedule,
scheduled_for=scheduled_for,
observed_at=observed_at,
)
result["prepared"] = int(result["prepared"]) + 1
result["coalesced"] = int(result["coalesced"]) + skipped
result["campaign_ids"].append(campaign.id) # type: ignore[union-attr]
if not schedule.active:
result["completed"] = int(result["completed"]) + 1
except Exception as exc: # noqa: BLE001 - persist bounded operator evidence
session.add(
CampaignScheduleOccurrence(
tenant_id=schedule.tenant_id,
schedule_id=schedule.id,
scheduled_for=scheduled_for,
status="failed",
error=str(exc)[:4000],
)
)
schedule.active = False
schedule.last_error = str(exc)[:4000]
schedule.resource_revision += 1
session.add(schedule)
result["failed"] = int(result["failed"]) + 1
result["operator_actions"].append( # type: ignore[union-attr]
{
"schedule_id": schedule.id,
"campaign_id": schedule.campaign_id,
"reason": "draft_preparation_failed",
}
)
session.flush()
return result
def _prepare_occurrence(
session: Session,
*,
schedule: CampaignSchedule,
scheduled_for: datetime,
observed_at: datetime,
) -> tuple[Campaign, CampaignVersion, int]:
existing = (
session.query(CampaignScheduleOccurrence)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule.id,
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
)
.one_or_none()
)
if existing is not None:
raise RuntimeError("Campaign schedule occurrence was already recorded")
source_campaign = session.get(Campaign, schedule.campaign_id)
if source_campaign is None or source_campaign.tenant_id != schedule.tenant_id:
raise RuntimeError("Campaign schedule source is no longer available")
source_version = session.get(CampaignVersion, schedule.source_version_id)
if source_version is None or source_version.campaign_id != source_campaign.id:
raise RuntimeError("Campaign schedule source version is no longer available")
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
snapshot = _schedule_snapshot(schedule.source_snapshot)
sequence = schedule.occurrence_count + 1
external_id = _scheduled_external_id(
source_campaign.external_id,
schedule.id,
sequence,
)
local_date = scheduled_for.astimezone(ZoneInfo(schedule.timezone)).date().isoformat()
generated_name = f"{schedule.name} - {local_date}"
raw_json = campaign_copy_configuration(
snapshot["configuration"],
schedule.copy_options,
)
metadata = raw_json.get("campaign")
if not isinstance(metadata, dict):
raise RuntimeError("Campaign schedule snapshot has no campaign metadata")
metadata["id"] = external_id
metadata["name"] = generated_name
metadata["mode"] = "draft"
generated_campaign, generated_version = create_campaign_version_from_json(
session,
tenant_id=schedule.tenant_id,
user_id=schedule.created_by_user_id,
raw_json=raw_json,
source_filename=None,
source_base_path=schedule.source_base_path,
commit=False,
)
if bool(schedule.copy_options.get("include_policies", True)):
generated_campaign.settings = copy.deepcopy(snapshot["campaign_settings"])
if bool(schedule.copy_options.get("include_mail_profile", True)):
generated_campaign.mail_profile_policy = copy.deepcopy(
snapshot["mail_profile_policy"]
)
if bool(schedule.copy_options.get("include_shares", False)):
_copy_snapshot_shares(
session,
schedule=schedule,
generated_campaign=generated_campaign,
shares=snapshot["shares"],
)
occurrence = CampaignScheduleOccurrence(
tenant_id=schedule.tenant_id,
schedule_id=schedule.id,
scheduled_for=scheduled_for,
status="prepared",
generated_campaign_id=generated_campaign.id,
generated_version_id=generated_version.id,
)
session.add(occurrence)
schedule.occurrence_count = sequence
schedule.last_fired_at = scheduled_for
schedule.last_campaign_id = generated_campaign.id
schedule.last_error = None
next_fire = next_schedule_fire(
scheduled_for,
recurrence_kind=schedule.recurrence_kind,
interval_count=schedule.interval_count,
timezone_name=schedule.timezone,
)
coalesced = 0
while next_fire is not None and next_fire <= observed_at:
next_fire = next_schedule_fire(
next_fire,
recurrence_kind=schedule.recurrence_kind,
interval_count=schedule.interval_count,
timezone_name=schedule.timezone,
)
coalesced += 1
if (
next_fire is None
or sequence >= schedule.max_occurrences
or (schedule.ends_at is not None and next_fire > _as_utc(schedule.ends_at))
):
schedule.active = False
schedule.next_fire_at = None
else:
schedule.next_fire_at = next_fire
schedule.resource_revision += 1
session.add(schedule)
audit_event(
session,
tenant_id=schedule.tenant_id,
user_id=schedule.created_by_user_id,
action="campaign.schedule.draft_prepared",
object_type="campaign_schedule",
object_id=schedule.id,
details={
"source_campaign_id": source_campaign.id,
"source_version_id": source_version.id,
"scheduled_for": scheduled_for.isoformat(),
"generated_campaign_id": generated_campaign.id,
"generated_version_id": generated_version.id,
"occurrence": sequence,
"coalesced_missed_intervals": coalesced,
"delivery_started": False,
},
commit=False,
)
return generated_campaign, generated_version, coalesced
def _copy_snapshot_shares(
session: Session,
*,
schedule: CampaignSchedule,
generated_campaign: Campaign,
shares: object,
) -> None:
if not isinstance(shares, list):
raise RuntimeError("Campaign schedule share snapshot is invalid")
for source in shares:
if not isinstance(source, Mapping):
raise RuntimeError("Campaign schedule share snapshot is invalid")
target_type = str(source.get("target_type") or "")
target_id = str(source.get("target_id") or "")
permission = str(source.get("permission") or "read")
if not target_type or not target_id:
raise RuntimeError("Campaign schedule share snapshot is incomplete")
session.add(
CampaignShare(
tenant_id=schedule.tenant_id,
campaign_id=generated_campaign.id,
target_type=target_type,
target_id=target_id,
permission=permission,
created_by_user_id=schedule.created_by_user_id,
)
)
def _schedule_snapshot(value: object) -> dict[str, object]:
if not isinstance(value, Mapping) or value.get("schema") != SCHEDULE_SOURCE_SCHEMA:
raise RuntimeError("Campaign schedule source snapshot schema is invalid")
configuration = value.get("configuration")
settings = value.get("campaign_settings")
mail_policy = value.get("mail_profile_policy")
shares = value.get("shares")
if (
not isinstance(configuration, Mapping)
or not isinstance(settings, Mapping)
or not isinstance(mail_policy, Mapping)
or not isinstance(shares, list)
):
raise RuntimeError("Campaign schedule source snapshot is incomplete")
return {
"configuration": dict(configuration),
"campaign_settings": dict(settings),
"mail_profile_policy": dict(mail_policy),
"shares": shares,
}
def _scheduled_external_id(source: str, schedule_id: str, sequence: int) -> str:
suffix = f"-scheduled-{schedule_id[:8]}-{sequence}"
return f"{source[:255 - len(suffix)]}{suffix}"
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
__all__ = [
"RECURRENCE_KINDS",
"SCHEDULE_SOURCE_SCHEMA",
"campaign_schedule_source_snapshot",
"canonical_configuration_hash",
"dispatch_due_campaign_schedules",
"next_schedule_fire",
]
File diff suppressed because it is too large Load Diff
@@ -168,6 +168,91 @@ class CampaignShare(Base, TimestampMixin):
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class CampaignSchedule(Base, TimestampMixin):
__tablename__ = "campaign_schedules"
__table_args__ = (
Index("ix_campaign_schedules_due", "tenant_id", "active", "next_fire_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(
ForeignKey("campaigns.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
source_version_id: Mapped[str] = mapped_column(
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
recurrence_kind: Mapped[str] = mapped_column(
String(20),
default="once",
nullable=False,
index=True,
)
interval_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
next_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
max_occurrences: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
occurrence_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
copy_options: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
source_base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_campaign_id: Mapped[str | None] = mapped_column(
ForeignKey("campaigns.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
class CampaignScheduleOccurrence(Base, TimestampMixin):
__tablename__ = "campaign_schedule_occurrences"
__table_args__ = (
UniqueConstraint(
"schedule_id",
"scheduled_for",
name="uq_campaign_schedule_occurrence",
),
Index("ix_campaign_schedule_occurrences_schedule", "schedule_id", "scheduled_for"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
schedule_id: Mapped[str] = mapped_column(
ForeignKey("campaign_schedules.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
status: Mapped[str] = mapped_column(String(30), default="preparing", nullable=False, index=True)
generated_campaign_id: Mapped[str | None] = mapped_column(
ForeignKey("campaigns.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
generated_version_id: Mapped[str | None] = mapped_column(
ForeignKey("campaign_versions.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
class RecipientImportMappingProfile(Base, TimestampMixin):
__tablename__ = "campaign_recipient_import_mapping_profiles"
__table_args__ = (
+163 -8
View File
@@ -46,6 +46,7 @@ _ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
_DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
_TEMPLATE_CATALOG_INTEGRATION = "templates.catalog"
_TEMPLATE_CONTENT_LIBRARY_INTEGRATION = "templates.content_library"
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
@@ -73,6 +74,7 @@ def _workflow_topic(
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
related_modules: tuple[str, ...] = (),
limitations: tuple[str, ...] = (),
translations: dict[str, dict[str, str]] | None = None,
) -> DocumentationTopic:
metadata: dict[str, object] = {
"kind": "workflow",
@@ -106,6 +108,7 @@ def _workflow_topic(
links=links,
related_modules=related_modules,
unlocks=(outcome,),
translations=translations or {},
source_module_id="campaigns",
metadata=metadata,
)
@@ -116,7 +119,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
topic_id="campaigns.workflow.create-campaign",
title="Create a campaign",
summary="Start a governed campaign as an editable draft and complete its purpose and ownership before adding delivery data.",
body="A new campaign starts with one editable working version. Creating it does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized.",
body="A new campaign starts with one editable working version. Campaign editors report saved, unsaved, and saving state in the page action bar; Discard remains immediately before Save, and leaving a dirty draft invokes the shared save-or-discard guard. Destructive campaign lifecycle actions are visually separated from ordinary actions. Creating a campaign does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized.",
order=30,
audience=("campaign_manager", "campaign_author"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
@@ -128,7 +131,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
"Open Campaigns and select New campaign.",
"Use the creation wizard to enter a clear name, identifier, and purpose.",
"Open the new campaign and confirm its owner before adding recipient or delivery data.",
"Continue through the preparation sections and save the editable working version.",
"Continue through the preparation sections; use the stable Discard and Save actions while the bar reports the draft state.",
),
outcome="An owned campaign draft with an editable working version.",
verification="The Campaign overview shows the new campaign as a draft and identifies its current working version.",
@@ -163,26 +166,109 @@ CAMPAIGN_USER_DOCUMENTATION = (
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.",
body="Copy campaign is different from creating an editable successor. It creates a separately owned campaign with a chosen or generated identifier and one editable version. Recipients, attachment rules, active shares, campaign policies, and the Mail profile reference are explicit independent choices. Delivery jobs, outcomes, locks, reports, and audit evidence always stay exclusively with the source campaign.",
order=32,
audience=("campaign_manager", "campaign_author"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:recipient:read"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy"),
route="/campaigns/{campaign_id}",
screen="Campaign overview",
help_contexts=("campaign.overview",),
prerequisites=(
"You may read the selected campaign and its recipient configuration.",
"You may read the selected campaign. Copying recipient data additionally requires recipient-read authority.",
"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.",
"Choose Copy campaign or Copy as new campaign, enter the new identity, and select recipients, files, shares, policies, and Mail profile independently.",
"If a choice is unavailable, obtain the corresponding recipient-read or campaign-share authority or leave that content excluded.",
"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.",
outcome="A new editable campaign draft containing only the explicitly selected configuration and no copied operational evidence.",
verification="The destination has a distinct campaign ID and owner, one editable version, the selected configuration domains, and no source jobs, outcomes, locks, reports, or audit evidence.",
related_topic_ids=("campaigns.workflow.create-editable-successor", "campaigns.workflow.prepare-validate-and-build"),
translations={
"de": {
"title": "Kampagne als neuen Entwurf kopieren",
"summary": "Eine ausgewählte Kampagnenversion als Konfiguration wiederverwenden, ohne Betriebs- oder Auditnachweise zu kopieren.",
"body": "Kampagne kopieren erzeugt eine eigenständige Kampagne mit eigener Kennung und einer bearbeitbaren Version. Empfänger, Dateiregeln, aktive Freigaben, Kampagnenrichtlinien und die Mailprofil-Referenz werden unabhängig ausgewählt. Sendeaufträge, Ergebnisse, Sperren, Berichte und Auditnachweise verbleiben immer ausschließlich bei der Quellkampagne.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.reuse-content-library",
title="Reuse Campaign content through Templates",
summary="Insert scoped, versioned fragments or complete message parts and save new content as an unpublished Templates draft.",
body="The reusable library is owned by Templates. Loading a fragment inserts it at the selected Campaign field and cursor; applying a complete part replaces the current subject and body only after explicit confirmation. Required fields are compared with the current Campaign fields and shown before content is applied. Saving from Campaign creates a personal or tenant Templates draft, retains placeholder requirements as its data contract, and never publishes it automatically. Neither operation changes an existing Template revision or a historical Campaign version.",
order=33,
audience=("campaign_manager", "campaign_author"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update"),
required_modules=("campaigns", "templates"),
required_capabilities=(
_TEMPLATE_CATALOG_INTEGRATION,
_TEMPLATE_CONTENT_LIBRARY_INTEGRATION,
),
route="/campaigns/{campaign_id}/template",
screen="Campaign template",
help_contexts=("campaign.template", "campaign.template.content-library"),
prerequisites=(
"The Campaign version is editable.",
"Templates is active and you may read its library; saving additionally requires Template write authority.",
),
steps=(
"Open Template and choose Load from library to search content visible in the active Templates scope.",
"Review any missing or incompatible required fields, then insert a fragment into its declared subject, text, or HTML target, or explicitly confirm replacement by a complete Campaign part.",
"Review placeholders and save the Campaign draft normally.",
"To retain new content, choose Save to library, select fragment or complete part plus personal or tenant visibility, and create the unpublished draft.",
"Open Templates to review, revise, and publish shared content.",
),
outcome="Reusable content remains centrally versioned while each Campaign records its own deliberate draft changes.",
verification="The Campaign draft shows the inserted content, and saved library content appears in Templates as an unpublished revision with Campaign provenance.",
related_topic_ids=("campaigns.workflow.prepare-validate-and-build",),
related_modules=("templates",),
translations={
"de": {
"title": "Kampagneninhalte über Templates wiederverwenden",
"summary": "Bereichsbezogene, versionierte Bausteine oder vollständige Nachrichtenteile einfügen und neue Inhalte als unveröffentlichten Templates-Entwurf speichern.",
"body": "Die wiederverwendbare Bibliothek gehört Templates. Ein Baustein wird in das ausgewählte Kampagnenfeld an der Cursorposition eingefügt; ein vollständiger Teil ersetzt Betreff und Nachrichtentext erst nach ausdrücklicher Bestätigung. Pflichtfelder werden vor dem Anwenden mit den aktuellen Kampagnenfeldern verglichen und angezeigt. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an, bewahrt Platzhalteranforderungen als Datenvertrag und veröffentlicht ihn niemals automatisch. Bestehende Template-Revisionen und historische Kampagnenversionen bleiben unverändert.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.schedule-drafts",
title="Schedule bounded recurring campaign drafts",
summary="Prepare fresh campaign drafts at a future time without bypassing validation, review, approval, or delivery controls.",
body="A Campaign schedule stores an integrity-sealed snapshot of the selected version, campaign policy, Mail profile policy, and optional shares, plus a bounded one-time, daily, weekly, or monthly recurrence. Each due occurrence creates a separately owned draft and an occurrence record. Missed intervals are coalesced instead of producing a catch-up storm. A schedule never validates, approves, queues, retries, or sends a campaign, and a preparation failure pauses it for operator review. Pause and resume reject stale browser state.",
order=34,
audience=("campaign_manager", "campaign_author", "operator"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"),
route="/campaigns/{campaign_id}",
screen="Campaign overview",
help_contexts=("campaign.overview", "campaigns.action.schedule-drafts"),
prerequisites=(
"Choose the exact campaign version whose configuration should seed future drafts.",
"Recipient data and active shares require their corresponding read or share authority.",
"A worker and scheduler process must be running for automatic due-time preparation.",
),
steps=(
"Open the campaign overview and choose Schedule.",
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
"Select which configuration domains may be copied and create the schedule.",
"Review each generated draft independently before validating, building, approving, and sending it.",
"Pause the schedule when the approved plan changes; a failed occurrence is paused automatically and remains visible as evidence.",
),
outcome="A bounded sequence of independent campaign drafts with durable schedule and occurrence evidence.",
verification="The Schedules section shows the next occurrence and generated count; each prepared occurrence links to a distinct draft with no delivery jobs or outcomes.",
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"),
translations={
"de": {
"title": "Begrenzte wiederkehrende Kampagnenentwürfe planen",
"summary": "Künftige Kampagnenentwürfe vorbereiten, ohne Validierung, Prüfung, Freigabe oder Versandkontrollen zu umgehen.",
"body": "Ein Kampagnenzeitplan speichert einen integritätsgesicherten Stand der ausgewählten Version, Kampagnenrichtlinie, Mail-Profilrichtlinie und optionalen Freigaben sowie eine begrenzte einmalige, tägliche, wöchentliche oder monatliche Wiederholung. Jede fällige Ausführung erzeugt einen eigenständigen Entwurf und einen Ausführungsnachweis. Verpasste Intervalle werden zusammengefasst, statt unkontrolliert nachgeholt zu werden. Der Zeitplan validiert, genehmigt, startet, wiederholt oder versendet niemals eine Kampagne; ein Fehler pausiert ihn zur betrieblichen Prüfung. Pausieren und Fortsetzen weisen veraltete Browserstände zurück.",
"outcome": "Eine begrenzte Folge eigenständiger Kampagnenentwürfe mit dauerhaftem Zeitplan- und Ausführungsnachweis.",
"verification": "Der Abschnitt Zeitpläne zeigt die nächste Ausführung und die Zahl erzeugter Entwürfe; jede Ausführung verweist auf einen eigenen Entwurf ohne Versandaufträge oder Ergebnisse.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.import-recipients",
@@ -366,6 +452,75 @@ CAMPAIGN_USER_DOCUMENTATION = (
),
related_modules=("files",),
),
_workflow_topic(
topic_id="campaigns.workflow.control-attachment-reuse",
title="Control repeated campaign attachment use",
summary="Choose whether one resolved file may be reused, produces a warning, requires a reasoned review decision, or blocks delivery.",
body="Attachment reuse is a campaign-owned policy. The action can allow and record every repeated file, warn, require explicit review, or block affected messages. An optional exception permits reuse confined to one recipient or one built message. Every repeated-file finding is represented in the build protocol by a path-safe fingerprint, display filename, use count, message count, disposition, and explanation. Review decisions require a reason, bind to the exact message and build fingerprint, and remain available in campaign protocol and audit evidence. Changing the policy requires a new validation and build; it never rewrites historical evidence.",
order=35,
audience=("campaign_manager", "campaign_author", "campaign_reviewer", "administrator"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build", "campaigns:campaign:review"),
route="/campaigns/{campaign_id}/files",
screen="Attachments",
help_contexts=("campaign.attachments", "campaign.attachments.reuse-policy"),
prerequisites=(
"Decide which repeated use is acceptable for the campaign's purpose and recipients.",
"The current campaign version is editable when changing the policy.",
),
steps=(
"Open Attachments and choose Allow, Warn, Require explicit review, or Block delivery.",
"Optionally allow reuse only within the same recipient or the same built message.",
"Save, validate, and build the campaign, then inspect the repeated-file summary and affected messages.",
"For Review findings, open every affected message and record an explicit reason; for Block findings, correct the rules or policy and rebuild.",
),
outcome="A campaign build whose repeated attachment use is governed and reviewable under an explicit policy.",
verification="Review and send shows the configured action and boundary, repeated-file counts and dispositions; affected messages show warning, review, or blocked state as configured.",
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
translations={
"de": {
"title": "Wiederholte Verwendung von Kampagnenanhängen steuern",
"summary": "Festlegen, ob dieselbe aufgelöste Datei wiederverwendet werden darf, eine Warnung erzeugt, eine begründete Prüfentscheidung erfordert oder den Versand sperrt.",
"body": "Die Wiederverwendung von Anhängen wird durch eine kampagneneigene Richtlinie gesteuert. Die Aktion kann jede Wiederverwendung zulassen und protokollieren, warnen, eine ausdrückliche Prüfung verlangen oder betroffene Nachrichten sperren. Optional darf die Wiederverwendung innerhalb desselben Empfängers oder derselben erzeugten Nachricht ausgenommen werden. Jeder Befund erscheint mit pfadsicherem Fingerabdruck, Anzeigename, Verwendungs- und Nachrichtenanzahl, Ergebnis und Begründung im Build-Protokoll. Prüfentscheidungen benötigen eine Begründung, sind an Nachricht und Build-Fingerabdruck gebunden und bleiben in Protokoll und Auditnachweis erhalten. Eine Richtlinienänderung erfordert eine neue Validierung und einen neuen Build und verändert keine historischen Nachweise.",
"outcome": "Ein Kampagnen-Build, dessen wiederholte Anhangsverwendung durch eine ausdrückliche Richtlinie gesteuert und prüfbar ist.",
"verification": "Prüfen und senden zeigt Aktion, Ausnahmegrenze, Anzahlen und Ergebnisse; betroffene Nachrichten tragen entsprechend Warn-, Prüf- oder Sperrstatus.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.route-unassigned-files",
title="Review and route unassigned campaign files",
summary="Turn files left in a watched source into an explicit report or reviewed attachment message instead of silently overlooking them.",
body="Campaign compares watched attachment sources with the exact files assigned to built recipient messages. The configurable validation behavior can block, require review, or explicitly ignore the remaining set. An optional residual-file disposition instead turns it into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The normalized action, observed file and source counts, routing mode, and configured recipient are visible in build review and retained in the campaign protocol; the audit event records the same policy and counts without copying the recipient address. A routed row always needs review and follows the normal build, approval, delivery, reporting, and audit lifecycle. Saving or building never sends it directly.",
order=35,
audience=("campaign_manager", "campaign_author", "campaign_reviewer"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build"),
route="/campaigns/{campaign_id}/files",
screen="Attachments",
help_contexts=("campaign.attachments", "campaign.attachments.residual-files"),
prerequisites=(
"Enable Unsent on every attachment source that must be checked.",
"Configure the ordinary warning or blocking policy even if no routed message is wanted.",
"For routing, provide a reviewed recipient, subject, and report body.",
),
steps=(
"Open Attachments and set the unassigned-file action to warning only, report, or report with attachments.",
"Save and build the campaign; Campaign compares resolved recipient files with every file in the watched sources.",
"Open the residual-file row in Review and send, inspect its exact list and any attached files, and record the review decision.",
"Queue or send it through the same controlled Campaign lifecycle, or correct the attachment rules and rebuild instead.",
),
outcome="Every watched file is either assigned, deliberately reported, or represented by a visible policy finding.",
verification="The build contains no hidden residual set: it shows either the configured warning/blocking issue or one needs-review row with residual-file provenance and the configured recipient.",
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
translations={
"de": {
"title": "Nicht zugeordnete Kampagnendateien prüfen und weiterleiten",
"summary": "Übrig gebliebene Dateien aus überwachten Quellen ausdrücklich melden oder als geprüfte Nachricht vorbereiten, statt sie unbemerkt zu übergehen.",
"body": "Campaign vergleicht überwachte Anhangsquellen mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Das konfigurierbare Validierungsverhalten kann die Restmenge sperren, zur Prüfung vorlegen oder ausdrücklich ignorieren. Eine optionale Restdatei-Behandlung erzeugt stattdessen eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die normalisierte Aktion, Datei- und Quellenanzahl, Routingart und der konfigurierte Empfänger sind in der Build-Prüfung sichtbar und bleiben im Kampagnenprotokoll erhalten; das Audit-Ereignis speichert Richtlinie und Anzahlen ohne die Empfängeradresse zu kopieren. Die Zeile muss immer geprüft werden und durchläuft den normalen Erzeugungs-, Freigabe-, Versand-, Berichts- und Auditablauf. Speichern oder Erzeugen versendet sie niemals unmittelbar.",
"outcome": "Jede überwachte Datei ist zugeordnet, bewusst gemeldet oder durch einen sichtbaren Richtlinienbefund erfasst.",
"verification": "Der Build enthält keine verborgene Restmenge: Er zeigt entweder den konfigurierten Warn- oder Sperrbefund oder eine zu prüfende Zeile mit Restdatei-Provenienz und dem konfigurierten Empfänger.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.send-calendar-invitations",
title="Send individualized calendar invitations",
@@ -38,9 +38,12 @@ from govoplan_core.core.postbox import (
)
from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER,
TemplateCatalogProvider,
TemplateCompatibility,
TemplateContentDraftRequest,
TemplateContentLibraryProvider,
TemplateRef,
TemplateRenderRequest,
TemplateRenderResult,
@@ -56,6 +59,7 @@ POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG
TEMPLATE_CONTENT_LIBRARY_CAPABILITY = CAPABILITY_TEMPLATE_CONTENT_LIBRARY
TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER
CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS
@@ -527,6 +531,7 @@ class TemplatesCampaignIntegration:
self,
catalog_delegate: object | None = None,
renderer_delegate: object | None = None,
content_library_delegate: object | None = None,
) -> None:
self._catalog = (
catalog_delegate
@@ -538,11 +543,24 @@ class TemplatesCampaignIntegration:
if isinstance(renderer_delegate, TemplateRendererProvider)
else None
)
self._content_library = (
content_library_delegate
if isinstance(content_library_delegate, TemplateContentLibraryProvider)
else None
)
@property
def available(self) -> bool:
return self._catalog is not None and self._renderer is not None
@property
def content_available(self) -> bool:
return self._catalog is not None
@property
def content_writable(self) -> bool:
return self._content_library is not None
def list_templates(
self,
session: object,
@@ -563,6 +581,43 @@ class TemplatesCampaignIntegration:
)
)
def list_content_templates(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> tuple[TemplateRef, ...]:
if self._catalog is None:
return ()
return tuple(
self._catalog.list_templates(
session,
principal,
query=query,
usage="campaign.content",
limit=limit,
)
)
def create_content_draft(
self,
session: object,
principal: object,
*,
request: TemplateContentDraftRequest,
) -> TemplateRef:
if self._content_library is None:
raise TemplateOutputUnavailable(
"Saving reusable Campaign content requires the Templates content-library capability."
)
return self._content_library.create_content_draft(
session,
principal,
request=request,
)
def check_compatibility(
self,
session: object,
@@ -799,6 +854,7 @@ def templates_integration() -> TemplatesCampaignIntegration:
return TemplatesCampaignIntegration(
capability(TEMPLATE_CATALOG_CAPABILITY),
capability(TEMPLATE_RENDERER_CAPABILITY),
capability(TEMPLATE_CONTENT_LIBRARY_CAPABILITY),
)
+117 -2
View File
@@ -13,6 +13,7 @@ from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_RETENTION,
CAPABILITY_CAMPAIGNS_SCHEDULES,
)
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS
from govoplan_core.core.module_guards import (
@@ -34,6 +35,7 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
@@ -43,6 +45,7 @@ from govoplan_core.core.distribution_lists import (
)
from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER,
)
from govoplan_core.core.operations import OperationalCheckProviderRegistration
@@ -108,6 +111,12 @@ PERMISSIONS = (
"Create campaigns or working versions from existing campaigns.",
"Campaigns",
),
_permission(
"campaigns:campaign:schedule",
"Schedule campaign drafts",
"Prepare fresh campaign drafts at a governed time or bounded recurrence.",
"Campaigns",
),
_permission(
"campaigns:campaign:archive",
"Archive campaigns",
@@ -274,6 +283,7 @@ ROLE_TEMPLATES = (
"campaigns:campaign:create",
"campaigns:campaign:update",
"campaigns:campaign:copy",
"campaigns:campaign:schedule",
"campaigns:campaign:validate",
"campaigns:campaign:build",
"campaigns:ownership:accept_group",
@@ -385,6 +395,7 @@ manifest = ModuleManifest(
provides_interfaces=(
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.schedules", version="0.1.0"),
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
@@ -454,6 +465,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
version_min="0.1.18",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_CALENDAR_INVITATIONS,
version_min="0.2.0",
@@ -560,6 +577,23 @@ manifest = ModuleManifest(
order=20,
),
),
product_areas=(
ProductAreaContribution(
id="communication",
module_id="campaigns",
label="i18n:govoplan-core.product_area.communication",
icon="mail",
description="i18n:govoplan-core.product_area.communication_description",
surface_ids=(
"campaigns.nav.campaigns",
"campaigns.route.campaigns",
"campaigns.route.operator-redirect",
OPERATOR_QUEUE_SURFACE_ID,
REPORTS_SURFACE_ID,
),
order=40,
),
),
view_surfaces=(
ViewSurface(
id="campaigns.widget.activity",
@@ -577,6 +611,8 @@ manifest = ModuleManifest(
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
campaign_models.Campaign,
campaign_models.CampaignSchedule,
campaign_models.CampaignScheduleOccurrence,
campaign_models.CampaignShare,
campaign_models.RecipientImportMappingProfile,
campaign_models.CampaignVersion,
@@ -597,6 +633,8 @@ manifest = ModuleManifest(
uninstall_guard_providers=(
persistent_table_uninstall_guard(
campaign_models.Campaign,
campaign_models.CampaignSchedule,
campaign_models.CampaignScheduleOccurrence,
campaign_models.CampaignShare,
campaign_models.RecipientImportMappingProfile,
campaign_models.CampaignVersion,
@@ -615,6 +653,79 @@ manifest = ModuleManifest(
),
documentation=(
*CAMPAIGN_USER_DOCUMENTATION,
DocumentationTopic(
id="campaigns.access.child-evidence",
title="Explain access to Campaign child evidence",
summary="Trace governance, import, recipient, attachment, protocol, delivery, and reconciliation access without disclosing the protected payload.",
body=(
"Campaign child explanations first identify the parent Campaign and immutable version, then state whether owner, "
"group, share, or tenant administration provides the inherited boundary. Recipient evidence additionally requires "
"recipient-read authority; review and attachment overrides require review and diagnostic authority; delivery status, "
"diagnostics, exports, and reconciliation remain separately permissioned. Version-bound children use a version UUID "
"and random job UUID. Missing, cross-tenant, and stale references return non-disclosing provenance. Explanations expose "
"bounded state and hashes only, never recipient addresses, source rows, filenames, storage locators, transport responses, "
"worker claims, provider targets, or operator notes. Persisted Mail, Postbox, and printable attempt evidence remains "
"explainable after an optional provider is disabled; an absent child reports only unavailable-or-hidden. Campaign share "
"and ownership-transfer records describe governance visibility separately from the content grant. Saved imports expose "
"only their stable source identity and revision, while independently user-owned mapping profiles never inherit a Campaign "
"share. Persisted validation, build, snapshot, and review artifacts use typed version references. Reusable templates and "
"durable export packages stay with their optional owning modules and fail closed when Campaign is asked to explain them. "
"The shared explanation dialog evaluates the signed-in user by default. Policy may permit a tenant-bounded selected-user "
"administrator diagnostic; Access returns only permitted subject metadata and records every cross-user explanation in audit evidence."
),
layer="available",
documentation_types=("admin",),
audience=("administrator", "security_reviewer", "campaign_operator"),
order=43,
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
any_scopes=(
"campaigns:diagnostic:read",
"campaigns:report:read",
"campaigns:recipient:read",
"admin:users:read",
),
),
),
links=(
DocumentationLink(
label="Campaign access-explanation coverage",
href="govoplan-campaign/docs/ACCESS_EXPLANATION_COVERAGE.md",
kind="repository",
),
),
related_modules=("access", "audit", "mail", "policy", "postbox", "templates"),
translations={
"de": {
"title": "Zugriff auf untergeordnete Campaign-Nachweise erklaeren",
"summary": "Zugriff auf Governance-, Import-, Empfaenger-, Anlagen-, Protokoll-, Zustell- und Abgleichnachweise ohne Offenlegung der geschuetzten Inhalte nachvollziehen.",
"body": (
"Zugriffserklaerungen fuer untergeordnete Campaign-Nachweise nennen zuerst die uebergeordnete Campaign und "
"die unveraenderliche Version. Danach zeigen sie, ob Eigentum, Gruppe, Freigabe oder Mandantenadministration "
"die geerbte Grenze begruendet. Empfaengernachweise erfordern zusaetzlich Leserecht fuer Empfaenger; Pruef- und "
"Anlagenausnahmen erfordern Pruef- und Diagnoserecht. Zustellstatus, Diagnostik, Export und Abgleich bleiben getrennt "
"berechtigt. Versionsgebundene Nachweise verwenden Versions-UUID und zufaellige Auftrags-UUID. Fehlende, mandantenfremde "
"oder veraltete Verweise liefern keine geschuetzten Daten. Adressen, Quellzeilen, Dateinamen, Speicherorte, Transportantworten, "
"Worker-Claims, Anbieterziele und Bediennotizen werden nie offengelegt. Dauerhafte Mail-, Postbox- und Drucknachweise bleiben "
"auch nach Deaktivierung eines optionalen Anbieters erklaerbar; ein fehlender Nachweis meldet nur nicht verfuegbar oder verborgen."
" Campaign-Freigaben und Eigentumsuebertragungen trennen Governance-Sichtbarkeit vom Inhaltszugriff. Gespeicherte Importe "
"nennen nur stabile Quellidentitaet und Revision; benutzereigene Zuordnungsprofile erben keine Campaign-Freigabe. "
"Validierungs-, Build-, Snapshot- und Pruefnachweise verwenden typisierte Versionsverweise. Wiederverwendbare Vorlagen "
"und dauerhafte Exportpakete bleiben bei ihren optionalen Eigentuemer-Modulen und werden sonst geschlossen behandelt."
),
}
},
metadata={
"kind": "reference",
"help_contexts": [
"campaign.access",
"campaign.import",
"campaign.report",
"campaign.operator-queue",
],
},
),
DocumentationTopic(
id="campaigns.search.campaigns",
title="Search authorized campaigns",
@@ -844,7 +955,7 @@ manifest = ModuleManifest(
id="campaigns.workflow.prepare-validate-and-build",
title="Prepare, validate, and build a campaign",
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. 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.",
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. Summary metrics expose a named drill-down only when an authorized source collection, filtered review table, attachment preview, or report helps the user inspect and act on the count. Privacy-suppressed aggregate reports remain non-interactive because an unsuppressed subgroup would violate their disclosure boundary. 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",
documentation_types=("user",),
audience=("campaign_manager", "campaign_author"),
@@ -918,7 +1029,7 @@ manifest = ModuleManifest(
id="campaigns.workflow.complete-review",
title="Inspect built messages and complete review",
summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Use the explicit actions on actionable recipient, attachment, validation, and review metrics to reveal the corresponding source page, evidence preview, or filtered built-message table. Informational and privacy-suppressed measures do not become hidden click targets. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
layer="configured",
documentation_types=("user",),
audience=("campaign_reviewer",),
@@ -1149,6 +1260,10 @@ manifest = ModuleManifest(
"govoplan_campaign.backend.capabilities",
fromlist=["delivery_tasks_capability"],
).delivery_tasks_capability(context),
CAPABILITY_CAMPAIGNS_SCHEDULES: lambda context: __import__(
"govoplan_campaign.backend.capabilities",
fromlist=["schedules_capability"],
).schedules_capability(context),
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT: lambda context: __import__(
"govoplan_campaign.backend.capabilities",
fromlist=["mail_policy_context_capability"],
+260 -12
View File
@@ -19,16 +19,19 @@ from govoplan_campaign.backend.attachments.resolver import (
effective_send_without_attachments_behavior,
resolve_entry_attachments,
)
from govoplan_campaign.backend.attachments.reuse import evaluate_attachment_reuse
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
from govoplan_campaign.backend.campaign.models import (
Behavior,
AttachmentConfig,
BuildStatus,
CampaignConfig,
EntryConfig,
MissingAddressBehavior,
RecipientConfig,
ResidualFileMode,
SendStatus,
TemplateBodyMode,
ZipArchiveConfig,
@@ -92,6 +95,13 @@ class _MimeBuildResult:
attachment_count: int
@dataclass(slots=True)
class _ResidualFileGroup:
source_name: str
directory: Path
files: list[Path]
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
campaign_path = Path(campaign_file).resolve()
path = Path(raw_path).expanduser()
@@ -890,17 +900,13 @@ def build_entry_message(
def _unsent_attachment_issues(
def _residual_attachment_files(
*,
config: CampaignConfig,
campaign_file: str | Path,
built_messages: list[BuiltMessage],
attachment_match_index: AttachmentMatchIndex | None = None,
) -> list[MessageIssue]:
behavior = config.validation_policy.unsent_attachment_files.value
if behavior == Behavior.CONTINUE.value:
return []
) -> list[_ResidualFileGroup]:
matched_files = {
Path(match).resolve()
for built in built_messages
@@ -908,7 +914,7 @@ def _unsent_attachment_issues(
for match in attachment.matches
}
issues: list[MessageIssue] = []
groups: list[_ResidualFileGroup] = []
for base_path in config.attachments.base_paths:
if not base_path.unsent_warning:
continue
@@ -922,20 +928,215 @@ def _unsent_attachment_issues(
unsent = [path for path in all_files if path not in matched_files]
if not unsent:
continue
groups.append(
_ResidualFileGroup(
source_name=base_path.name,
directory=directory,
files=unsent,
)
)
return groups
def _unsent_attachment_issues(
*,
config: CampaignConfig,
residual_groups: list[_ResidualFileGroup],
) -> list[MessageIssue]:
behavior = config.validation_policy.unsent_attachment_files.value
if (
behavior == Behavior.CONTINUE.value
or config.attachments.residual_files.mode != ResidualFileMode.NONE
):
return []
issues: list[MessageIssue] = []
for group in residual_groups:
unsent = group.files
directory = group.directory
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
if len(unsent) > 10:
shown += f", … (+{len(unsent) - 10} more)"
issues.append(
_issue_from_behavior(
code="unsent_attachment_files",
message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}",
message=f"{len(unsent)} file(s) in attachment source {group.source_name!r} are not used by any message: {shown}",
behavior=behavior,
source=f"attachments:{base_path.name}",
source=f"attachments:{group.source_name}",
)
)
return issues
def _build_residual_file_message(
*,
config: CampaignConfig,
campaign_file: Path,
residual_groups: list[_ResidualFileGroup],
entry_index: int,
output_dir: Path | None,
write_eml: bool,
work_dir: Path,
attachment_match_index: AttachmentMatchIndex,
) -> BuiltMessage | None:
disposition = config.attachments.residual_files
if disposition.mode == ResidualFileMode.NONE or disposition.recipient is None:
return None
files = sorted({path.resolve() for group in residual_groups for path in group.files})
if not files:
return None
file_lines = [
f"{group.source_name}: {path.relative_to(group.directory)}"
for group in residual_groups
for path in group.files
]
entry = EntryConfig(
id="__residual_files__",
to=[disposition.recipient],
merge_to=False,
combine_attachments=True,
fields={
"campaign_name": config.campaign.name,
"residual_file_count": len(files),
"residual_file_list": "\n".join(file_lines),
},
)
residual_config = config.model_copy(deep=True)
residual_config.attachments.global_ = []
residual_config.attachments.zip.enabled = False
if disposition.mode == ResidualFileMode.ATTACH:
residual_config.attachments.global_ = [
AttachmentConfig(
id=f"residual-file-{index}",
label=path.name,
base_dir=str(path.parent),
file_filter=path.name,
required=True,
allow_multiple=False,
)
for index, path in enumerate(files, start=1)
]
context = _entry_message_context(
config=residual_config,
campaign_file=campaign_file,
entry=entry,
entry_index=entry_index,
attachment_match_index=attachment_match_index,
)
context.validation_status = _validate_required_sender(
context.senders,
context.issues,
context.validation_status,
)
context.validation_status = _validate_required_recipients(
residual_config,
context.recipients,
context.issues,
context.validation_status,
)
values = build_template_values(residual_config, entry)
rendered = _RenderedMessageTemplate(
subject=_render_template(disposition.subject, values, keep_missing=True),
text_body=_render_template(disposition.text, values, keep_missing=True),
html_body=None,
body_mode=TemplateBodyMode.TEXT.value,
values=values,
)
context.validation_status = _validate_rendered_template(
residual_config,
rendered,
context.issues,
context.validation_status,
)
context.issues.append(
MessageIssue(
severity="warning",
code="residual_attachment_disposition",
message=(
f"{len(files)} unassigned file(s) are routed as a reviewed "
f"{disposition.mode.value} message."
),
behavior="ask",
source="attachments:residual_files",
details={
"mode": disposition.mode.value,
"file_count": len(files),
"source_count": len(residual_groups),
},
)
)
if context.validation_status not in {
MessageValidationStatus.BLOCKED,
MessageValidationStatus.EXCLUDED,
}:
context.validation_status = MessageValidationStatus.NEEDS_REVIEW
mime_result = _build_mime_message(
config=residual_config,
entry=entry,
entry_index=entry_index,
output_dir=output_dir,
work_dir=work_dir,
context=context,
rendered=rendered,
)
eml_path: str | None = None
eml_size: int | None = None
if write_eml and output_dir is not None and mime_result.message is not None:
eml_path, eml_size = _write_eml(
mime_result.message,
output_dir,
entry,
entry_index,
)
return BuiltMessage(
draft=_message_draft(
config=residual_config,
entry=entry,
entry_index=entry_index,
context=context,
build_status=mime_result.build_status,
validation_status=mime_result.validation_status,
subject=rendered.subject,
attachment_count=mime_result.attachment_count,
eml_path=eml_path,
eml_size=eml_size,
),
mime=mime_result.message,
)
def _residual_file_disposition_evidence(
*,
config: CampaignConfig,
residual_groups: list[_ResidualFileGroup],
) -> dict[str, object]:
disposition = config.attachments.residual_files
behavior = config.validation_policy.unsent_attachment_files.value
if disposition.mode == ResidualFileMode.REPORT:
action = "route_report"
elif disposition.mode == ResidualFileMode.ATTACH:
action = "route_with_files"
elif behavior == Behavior.BLOCK.value:
action = "block"
elif behavior in {Behavior.CONTINUE.value, Behavior.DROP.value}:
action = "ignore"
else:
action = "review"
recipient = (
disposition.recipient.model_dump(mode="json", by_alias=True)
if disposition.recipient is not None
else None
)
return {
"contract_version": "1",
"action": action,
"routing_mode": disposition.mode.value,
"validation_behavior": behavior,
"watched_source_count": len(residual_groups),
"residual_file_count": sum(len(group.files) for group in residual_groups),
"recipient": recipient,
}
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
if not issues:
return
@@ -949,6 +1150,28 @@ def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: lis
status = _apply_behavior(status, issue.behavior)
built.draft.validation_status = status
def _apply_attachment_reuse_policy(
config: CampaignConfig,
built_messages: list[BuiltMessage],
) -> dict[str, object]:
evaluation = evaluate_attachment_reuse(
[built.draft for built in built_messages],
policy=config.attachments.reuse_policy,
)
for built in built_messages:
issues = evaluation.issues_by_entry_index.get(built.draft.entry_index, [])
if not issues:
continue
built.draft.issues.extend(issues)
for issue in issues:
if issue.behavior:
built.draft.validation_status = _apply_behavior(
built.draft.validation_status,
issue.behavior,
)
return evaluation.report
def build_campaign_messages(
config: CampaignConfig,
*,
@@ -978,15 +1201,35 @@ def build_campaign_messages(
for index, entry in enumerate(entries, start=1)
if entry.active
]
residual_groups = _residual_attachment_files(
config=config,
campaign_file=campaign_path,
built_messages=built_messages,
attachment_match_index=attachment_match_index,
)
_apply_campaign_level_issues(
built_messages,
_unsent_attachment_issues(
config=config,
campaign_file=campaign_path,
built_messages=built_messages,
attachment_match_index=attachment_match_index,
residual_groups=residual_groups,
),
)
residual_message = _build_residual_file_message(
config=config,
campaign_file=campaign_path,
residual_groups=residual_groups,
entry_index=len(entries) + 1,
output_dir=output_path,
write_eml=write_eml,
work_dir=work_dir,
attachment_match_index=attachment_match_index,
)
if residual_message is not None:
built_messages.append(residual_message)
attachment_reuse = _apply_attachment_reuse_policy(
config,
built_messages,
)
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
report = CampaignBuildReport(
@@ -1000,5 +1243,10 @@ def build_campaign_messages(
duration_ms=(time.perf_counter() - started) * 1000,
rules_resolved=rules_resolved,
),
attachment_reuse=attachment_reuse,
residual_file_disposition=_residual_file_disposition_evidence(
config=config,
residual_groups=residual_groups,
),
)
return CampaignBuildResult(report=report, built_messages=built_messages)
@@ -117,6 +117,8 @@ class CampaignBuildReport(BaseModel):
inactive_entries_count: int = 0
messages: list[MessageDraft] = Field(default_factory=list)
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
attachment_reuse: dict[str, object] = Field(default_factory=dict)
residual_file_disposition: dict[str, object] = Field(default_factory=dict)
@property
def built_count(self) -> int:
@@ -0,0 +1,27 @@
"""add campaign schedule optimistic-concurrency revisions
Revision ID: a5b6c7d8e9f0
Revises: f4a5b6c7d8e9
Create Date: 2026-08-07 12:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
_migration = import_module(
"govoplan_campaign.backend.migrations.versions."
"a5b6c7d8e9f0_v0120_campaign_schedule_revisions"
)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
def upgrade() -> None:
_migration.upgrade()
def downgrade() -> None:
_migration.downgrade()
@@ -0,0 +1,27 @@
"""add durable campaign schedules and occurrence evidence
Revision ID: f4a5b6c7d8e9
Revises: e3c8f4a5b6d7
Create Date: 2026-08-07 10:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
_migration = import_module(
"govoplan_campaign.backend.migrations.versions."
"f4a5b6c7d8e9_v0119_campaign_schedules"
)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
def upgrade() -> None:
_migration.upgrade()
def downgrade() -> None:
_migration.downgrade()
@@ -0,0 +1,40 @@
"""add campaign schedule optimistic-concurrency revisions
Revision ID: a5b6c7d8e9f0
Revises: f4a5b6c7d8e9
Create Date: 2026-08-07 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a5b6c7d8e9f0"
down_revision = "f4a5b6c7d8e9"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_schedules"):
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
if "resource_revision" not in columns:
op.add_column(
"campaign_schedules",
sa.Column(
"resource_revision",
sa.Integer(),
nullable=False,
server_default="1",
),
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_schedules"):
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
if "resource_revision" in columns:
op.drop_column("campaign_schedules", "resource_revision")
@@ -0,0 +1,106 @@
"""add durable campaign schedules and occurrence evidence
Revision ID: f4a5b6c7d8e9
Revises: e3c8f4a5b6d7
Create Date: 2026-08-07 10:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f4a5b6c7d8e9"
down_revision = "e3c8f4a5b6d7"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("campaign_schedules"):
op.create_table(
"campaign_schedules",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("campaign_id", sa.String(length=36), nullable=False),
sa.Column("source_version_id", sa.String(length=36), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("recurrence_kind", sa.String(length=20), nullable=False),
sa.Column("interval_count", sa.Integer(), nullable=False),
sa.Column("timezone", sa.String(length=100), nullable=False),
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("next_fire_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("max_occurrences", sa.Integer(), nullable=False),
sa.Column("occurrence_count", sa.Integer(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("resource_revision", sa.Integer(), nullable=False),
sa.Column("copy_options", sa.JSON(), nullable=False),
sa.Column("source_snapshot", sa.JSON(), nullable=False),
sa.Column("source_snapshot_hash", sa.String(length=64), nullable=False),
sa.Column("source_base_path", sa.String(length=1000), nullable=True),
sa.Column("last_fired_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_campaign_id", sa.String(length=36), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["source_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["last_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_campaign_schedules_tenant_id", ["tenant_id"]),
("ix_campaign_schedules_campaign_id", ["campaign_id"]),
("ix_campaign_schedules_source_version_id", ["source_version_id"]),
("ix_campaign_schedules_created_by_user_id", ["created_by_user_id"]),
("ix_campaign_schedules_recurrence_kind", ["recurrence_kind"]),
("ix_campaign_schedules_next_fire_at", ["next_fire_at"]),
("ix_campaign_schedules_active", ["active"]),
("ix_campaign_schedules_source_snapshot_hash", ["source_snapshot_hash"]),
("ix_campaign_schedules_last_campaign_id", ["last_campaign_id"]),
("ix_campaign_schedules_due", ["tenant_id", "active", "next_fire_at"]),
):
op.create_index(name, "campaign_schedules", columns, unique=False)
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("campaign_schedule_occurrences"):
op.create_table(
"campaign_schedule_occurrences",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("schedule_id", sa.String(length=36), nullable=False),
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("generated_campaign_id", sa.String(length=36), nullable=True),
sa.Column("generated_version_id", sa.String(length=36), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["schedule_id"], ["campaign_schedules.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["generated_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["generated_version_id"], ["campaign_versions.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("schedule_id", "scheduled_for", name="uq_campaign_schedule_occurrence"),
)
for name, columns in (
("ix_campaign_schedule_occurrences_tenant_id", ["tenant_id"]),
("ix_campaign_schedule_occurrences_schedule_id", ["schedule_id"]),
("ix_campaign_schedule_occurrences_status", ["status"]),
("ix_campaign_schedule_occurrences_generated_campaign_id", ["generated_campaign_id"]),
("ix_campaign_schedule_occurrences_generated_version_id", ["generated_version_id"]),
("ix_campaign_schedule_occurrences_schedule", ["schedule_id", "scheduled_for"]),
):
op.create_index(name, "campaign_schedule_occurrences", columns, unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_schedule_occurrences"):
op.drop_table("campaign_schedule_occurrences")
inspector = sa.inspect(op.get_bind())
if inspector.has_table("campaign_schedules"):
op.drop_table("campaign_schedules")
+2
View File
@@ -8,6 +8,7 @@ from govoplan_campaign.backend.routes.delivery import router as delivery_router
from govoplan_campaign.backend.routes.jobs import router as jobs_router
from govoplan_campaign.backend.routes.operations import router as operations_router
from govoplan_campaign.backend.routes.reports import router as reports_router
from govoplan_campaign.backend.routes.schedules import router as schedules_router
from govoplan_campaign.backend.routes.sharing import router as sharing_router
from govoplan_campaign.backend.routes.versions import router as versions_router
@@ -19,6 +20,7 @@ for workflow_router in (
versions_router,
jobs_router,
reports_router,
schedules_router,
sharing_router,
delivery_router,
attachments_router,
@@ -2,6 +2,7 @@ from __future__ import annotations
import copy
import dataclasses
import re
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
@@ -14,6 +15,7 @@ from govoplan_campaign.backend.schemas import (
CampaignCreateResponse,
CampaignCreateMinimalRequest,
CampaignCopyRequest,
CampaignContentLibrarySaveRequest,
CampaignLifecycleMutationRequest,
CampaignLifecyclePolicyResponse,
CampaignAddressLookupCandidate,
@@ -42,6 +44,11 @@ from govoplan_campaign.backend.schemas import (
)
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.templates import (
TemplateContentDraftRequest,
TemplateFieldRequirement,
TemplateRef,
)
from govoplan_core.core.change_sequence import (
decode_sequence_watermark,
encode_sequence_watermark,
@@ -60,6 +67,7 @@ from govoplan_campaign.backend.change_tracking import (
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignShare,
CampaignVersion,
RecipientImportMappingProfile,
)
@@ -70,12 +78,14 @@ from govoplan_campaign.backend.campaign.lifecycle import (
assert_lifecycle_state_token,
campaign_lifecycle_policy,
)
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
from govoplan_campaign.backend.integrations import (
calendar_integration,
PostboxDeliveryUnavailable,
postbox_integration,
templates_integration,
)
from govoplan_campaign.backend.template_rendering import find_unresolved_placeholders
from govoplan_core.db.session import get_session
from govoplan_core.core.distribution_lists import (
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
@@ -214,6 +224,13 @@ def _campaign_copy_external_id(
)
def _campaign_copy_configuration(
source: dict[str, object],
payload: CampaignCopyRequest,
) -> dict[str, object]:
return campaign_copy_configuration(source, payload.model_dump())
@router.post("", response_model=CampaignCreateResponse)
def create_campaign(
payload: CampaignCreateRequest,
@@ -924,6 +941,229 @@ def campaign_print_templates(
}
def _campaign_content_library_item(template: TemplateRef) -> dict[str, object]:
revision = template.revision
revision_metadata = dict(revision.metadata) if revision else {}
raw_targets = revision_metadata.get("campaign_targets")
targets = (
[str(value) for value in raw_targets if str(value) in {"subject", "text", "html"}]
if isinstance(raw_targets, (list, tuple))
else []
)
if not targets and revision is not None:
if template.template_type == "email":
targets = ["subject", "text", "html"]
else:
if revision.content_text:
targets.append("text")
if revision.content_html:
targets.append("html")
kind = str(revision_metadata.get("campaign_kind") or "").strip()
if kind not in {"fragment", "campaign_part"}:
kind = "fragment" if template.template_type == "content_fragment" else "campaign_part"
return {
"id": template.id,
"name": template.name,
"description": template.description,
"template_type": template.template_type,
"kind": kind,
"status": template.status,
"scope_type": template.scope_type,
"scope_id": template.scope_id,
"read_only": template.read_only,
"current_revision": template.current_revision,
"revision": revision.revision if revision else template.current_revision,
"revision_id": revision.id if revision else template.current_revision_id,
"locale": revision.locale if revision else None,
"published": bool(template.published_revision_id),
"targets": list(dict.fromkeys(targets)),
"subject": revision_metadata.get("campaign_subject"),
"text": revision.content_text if revision else None,
"html": revision.content_html if revision else None,
"body_mode": revision_metadata.get("campaign_body_mode") or "both",
"required_fields": [
dataclasses.asdict(field) for field in revision.required_fields
] if revision else [],
}
def _campaign_content_required_fields(
payload: CampaignContentLibrarySaveRequest,
) -> tuple[TemplateFieldRequirement, ...]:
if payload.kind == "fragment":
values = (
payload.subject if payload.target == "subject"
else payload.html if payload.target == "html"
else payload.text,
)
else:
values = (
payload.subject,
payload.text if payload.body_mode != "html" else None,
payload.html if payload.body_mode != "text" else None,
)
paths = {
path
for value in values
for key in find_unresolved_placeholders(value)
if (path := _campaign_content_requirement_path(key)) is not None
}
return tuple(
TemplateFieldRequirement(path=path, label=path)
for path in sorted(paths)
)
def _campaign_content_requirement_path(key: str) -> str | None:
path = key.replace("local::", "local.", 1).replace("global::", "global.", 1)
path = re.sub(r"\[([1-9][0-9]*)\]", r".\1", path)
return path if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", path) else None
@router.get("/{campaign_id}/content-library")
def campaign_content_library(
campaign_id: str,
query: str = Query(default="", min_length=0, max_length=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
integration = templates_integration()
if not integration.content_available:
return {
"available": False,
"writable": False,
"reason": "The Templates content library is not active.",
"items": [],
}
try:
templates = integration.list_content_templates(
session,
principal,
query=query,
limit=250,
)
except PermissionError as exc:
return {
"available": True,
"writable": False,
"reason": str(exc),
"items": [],
}
return {
"available": True,
"writable": integration.content_writable,
"items": [_campaign_content_library_item(template) for template in templates],
}
@router.post("/{campaign_id}/content-library", status_code=status.HTTP_201_CREATED)
def save_campaign_content_library_item(
campaign_id: str,
payload: CampaignContentLibrarySaveRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
campaign = _get_campaign_for_principal(
session,
campaign_id,
principal,
write=True,
)
integration = templates_integration()
if not integration.content_writable:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="The Templates content-library capability is not active.",
)
target = payload.target if payload.kind == "fragment" else None
content_text = (
payload.subject
if target == "subject"
else payload.text
if target == "text"
else None
)
content_html = payload.html if target == "html" else None
if payload.kind == "campaign_part":
content_text = payload.text
content_html = payload.html
metadata: dict[str, object] = {
"campaign_kind": payload.kind,
"campaign_targets": (
[target]
if target
else [
field
for field, value in (
("subject", payload.subject),
("text", payload.text),
("html", payload.html),
)
if value and value.strip()
]
),
"campaign_body_mode": payload.body_mode,
"source_module": "campaigns",
"source_campaign_id": campaign.id,
}
if payload.kind == "campaign_part" and payload.subject:
metadata["campaign_subject"] = payload.subject
try:
template = integration.create_content_draft(
session,
principal,
request=TemplateContentDraftRequest(
name=payload.name,
description=payload.description,
template_type=(
"content_fragment" if payload.kind == "fragment" else "email"
),
usages=("campaign.content",),
content_text=content_text,
content_html=content_html,
locale=payload.locale,
required_fields=_campaign_content_required_fields(payload),
scope_type=("user" if payload.visibility == "personal" else "tenant"),
scope_id=(
principal.account_id if payload.visibility == "personal" else None
),
metadata=metadata,
),
)
audit_from_principal(
session,
principal,
action="campaign.content_library_saved",
object_type="campaign",
object_id=campaign.id,
details={
"template_id": template.id,
"template_revision_id": (
template.revision.id if template.revision else None
),
"kind": payload.kind,
"target": payload.target,
"visibility": payload.visibility,
},
commit=False,
)
session.commit()
except PermissionError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
) from exc
except ValueError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return {"template": _campaign_content_library_item(template)}
@router.post(
"/{campaign_id}/recipient-address-sources/snapshot",
response_model=CampaignRecipientAddressSourceSnapshotResponse,
@@ -1652,7 +1892,10 @@ def copy_campaign(
action="copy_campaign",
version_id=payload.source_version_id,
)
_require_permission(principal, "campaigns:recipient:read")
if payload.include_recipients:
_require_permission(principal, "campaigns:recipient:read")
if payload.include_shares:
_require_permission(principal, "campaigns:campaign:share")
source_version = (
session.query(CampaignVersion)
.filter(
@@ -1674,7 +1917,7 @@ def copy_campaign(
requested=payload.external_id,
)
name = (payload.name or f"{source_campaign.name} (copy)").strip()
raw_json = copy.deepcopy(source_version.raw_json)
raw_json = _campaign_copy_configuration(source_version.raw_json, payload)
campaign_metadata = raw_json.get("campaign")
if not isinstance(campaign_metadata, dict):
raise HTTPException(
@@ -1696,6 +1939,36 @@ def copy_campaign(
source_base_path=source_version.source_base_path,
commit=False,
)
if payload.include_policies:
campaign.settings = copy.deepcopy(source_campaign.settings or {})
if payload.include_mail_profile:
campaign.mail_profile_policy = copy.deepcopy(
source_campaign.mail_profile_policy or {}
)
copied_share_count = 0
if payload.include_shares:
source_shares = (
session.query(CampaignShare)
.filter(
CampaignShare.tenant_id == principal.tenant_id,
CampaignShare.campaign_id == source_campaign.id,
CampaignShare.revoked_at.is_(None),
)
.order_by(CampaignShare.id.asc())
.all()
)
for source_share in source_shares:
session.add(
CampaignShare(
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
target_type=source_share.target_type,
target_id=source_share.target_id,
permission=source_share.permission,
created_by_user_id=principal.user.id,
)
)
copied_share_count = len(source_shares)
audit_from_principal(
session,
principal,
@@ -1707,6 +1980,14 @@ def copy_campaign(
"source_version_id": source_version.id,
"destination_version_id": version.id,
"copied_evidence": False,
"copy_options": {
"recipients": payload.include_recipients,
"files": payload.include_files,
"shares": payload.include_shares,
"policies": payload.include_policies,
"mail_profile": payload.include_mail_profile,
},
"copied_share_count": copied_share_count,
},
commit=True,
)
@@ -0,0 +1,290 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.scheduling import (
campaign_schedule_source_snapshot,
canonical_configuration_hash,
)
from govoplan_campaign.backend.db.models import (
CampaignSchedule,
CampaignScheduleOccurrence,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.route_support import (
_get_campaign_for_principal,
_require_permission,
)
from govoplan_campaign.backend.schemas import (
CampaignScheduleCreateRequest,
CampaignScheduleListResponse,
CampaignScheduleOccurrenceResponse,
CampaignScheduleResponse,
CampaignScheduleStateRequest,
)
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.db.session import get_session
router = APIRouter(prefix="/campaigns", tags=["campaign-schedules"])
@router.get(
"/{campaign_id}/schedules",
response_model=CampaignScheduleListResponse,
)
def list_campaign_schedules(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
schedules = (
session.query(CampaignSchedule)
.filter(
CampaignSchedule.tenant_id == principal.tenant_id,
CampaignSchedule.campaign_id == campaign_id,
)
.order_by(CampaignSchedule.created_at.desc(), CampaignSchedule.id.asc())
.all()
)
occurrences = _occurrences_by_schedule(session, schedules)
return CampaignScheduleListResponse(
items=[
_schedule_response(item, occurrences.get(item.id, []))
for item in schedules
]
)
@router.post(
"/{campaign_id}/schedules",
response_model=CampaignScheduleResponse,
status_code=status.HTTP_201_CREATED,
)
def create_campaign_schedule(
campaign_id: str,
payload: CampaignScheduleCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:copy")
if payload.include_recipients:
_require_permission(principal, "campaigns:recipient:read")
if payload.include_shares:
_require_permission(principal, "campaigns:campaign:share")
source_version = (
session.query(CampaignVersion)
.filter(
CampaignVersion.id == payload.source_version_id,
CampaignVersion.campaign_id == campaign.id,
)
.one_or_none()
)
if source_version is None:
raise HTTPException(status_code=404, detail="Campaign version not found")
starts_at = payload.starts_at.astimezone(UTC)
if starts_at < datetime.now(UTC) - timedelta(minutes=5):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Campaign schedules cannot start in the past.",
)
try:
ZoneInfo(payload.timezone)
except ZoneInfoNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Unknown campaign schedule timezone.",
) from exc
source_shares = (
session.query(CampaignShare)
.filter(
CampaignShare.tenant_id == principal.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
)
.order_by(CampaignShare.id.asc())
.all()
if payload.include_shares
else []
)
snapshot = campaign_schedule_source_snapshot(
configuration=source_version.raw_json,
campaign_settings=campaign.settings or {},
mail_profile_policy=campaign.mail_profile_policy or {},
shares=[
{
"target_type": item.target_type,
"target_id": item.target_id,
"permission": item.permission,
}
for item in source_shares
],
)
schedule = CampaignSchedule(
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
source_version_id=source_version.id,
created_by_user_id=principal.user.id,
name=payload.name.strip(),
recurrence_kind=payload.recurrence_kind,
interval_count=payload.interval_count,
timezone=payload.timezone,
starts_at=starts_at,
next_fire_at=starts_at,
ends_at=payload.ends_at.astimezone(UTC) if payload.ends_at else None,
max_occurrences=payload.max_occurrences,
copy_options={
"include_recipients": payload.include_recipients,
"include_files": payload.include_files,
"include_shares": payload.include_shares,
"include_policies": payload.include_policies,
"include_mail_profile": payload.include_mail_profile,
},
source_snapshot=snapshot,
source_snapshot_hash=canonical_configuration_hash(snapshot),
source_base_path=source_version.source_base_path,
)
session.add(schedule)
session.flush()
audit_from_principal(
session,
principal,
action="campaign.schedule.created",
object_type="campaign_schedule",
object_id=schedule.id,
details={
"campaign_id": campaign.id,
"source_version_id": source_version.id,
"recurrence_kind": schedule.recurrence_kind,
"interval_count": schedule.interval_count,
"starts_at": schedule.starts_at.isoformat(),
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
"max_occurrences": schedule.max_occurrences,
"delivery_started": False,
},
commit=True,
)
session.refresh(schedule)
return _schedule_response(schedule, [])
@router.patch(
"/{campaign_id}/schedules/{schedule_id}",
response_model=CampaignScheduleResponse,
)
def set_campaign_schedule_state(
campaign_id: str,
schedule_id: str,
payload: CampaignScheduleStateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
):
_get_campaign_for_principal(session, campaign_id, principal)
schedule = _schedule_for_campaign(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
schedule_id=schedule_id,
for_update=True,
)
if schedule.resource_revision != payload.base_revision:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Campaign schedule changed. Reload it before changing its state.",
)
if payload.active and schedule.next_fire_at is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A completed campaign schedule cannot be resumed.",
)
schedule.active = payload.active
schedule.last_error = None if payload.active else schedule.last_error
schedule.resource_revision += 1
session.add(schedule)
audit_from_principal(
session,
principal,
action=(
"campaign.schedule.resumed"
if payload.active
else "campaign.schedule.paused"
),
object_type="campaign_schedule",
object_id=schedule.id,
details={"campaign_id": campaign_id},
commit=True,
)
session.refresh(schedule)
occurrences = _occurrences_by_schedule(session, [schedule]).get(schedule.id, [])
return _schedule_response(schedule, occurrences)
def _schedule_for_campaign(
session: Session,
*,
tenant_id: str,
campaign_id: str,
schedule_id: str,
for_update: bool = False,
) -> CampaignSchedule:
query = session.query(CampaignSchedule)
if for_update:
query = query.with_for_update()
schedule = (
query
.filter(
CampaignSchedule.id == schedule_id,
CampaignSchedule.tenant_id == tenant_id,
CampaignSchedule.campaign_id == campaign_id,
)
.one_or_none()
)
if schedule is None:
raise HTTPException(status_code=404, detail="Campaign schedule not found")
return schedule
def _occurrences_by_schedule(
session: Session,
schedules: list[CampaignSchedule],
) -> dict[str, list[CampaignScheduleOccurrence]]:
ids = [item.id for item in schedules]
if not ids:
return {}
rows = (
session.query(CampaignScheduleOccurrence)
.filter(CampaignScheduleOccurrence.schedule_id.in_(ids))
.order_by(
CampaignScheduleOccurrence.scheduled_for.desc(),
CampaignScheduleOccurrence.id.asc(),
)
.all()
)
grouped: dict[str, list[CampaignScheduleOccurrence]] = {}
for row in rows:
grouped.setdefault(row.schedule_id, []).append(row)
return grouped
def _schedule_response(
schedule: CampaignSchedule,
occurrences: list[CampaignScheduleOccurrence],
) -> CampaignScheduleResponse:
response = CampaignScheduleResponse.model_validate(schedule)
return response.model_copy(
update={
"occurrences": [
CampaignScheduleOccurrenceResponse.model_validate(item)
for item in occurrences
]
}
)
@@ -594,6 +594,7 @@ def set_version_review_state(
"inspection_complete": payload.inspection_complete,
"reviewed_message_count": len(payload.reviewed_message_keys),
"issue_decision_count": len(payload.issue_decisions),
"issue_decisions": _review_decision_audit_evidence(version),
},
commit=True,
)
@@ -876,6 +877,12 @@ def build_version(
"write_eml": write_eml,
"built_count": result.get("built_count"),
"recovery_operation_id": recovery_start.operation_id,
"residual_file_disposition": _residual_file_audit_evidence(
result.get("residual_file_disposition")
),
"attachment_reuse": _attachment_reuse_audit_evidence(
result.get("attachment_reuse")
),
},
commit=True,
)
@@ -906,3 +913,70 @@ def build_version(
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
def _residual_file_audit_evidence(value: object) -> dict[str, object]:
if not isinstance(value, dict):
return {}
return {
key: value.get(key)
for key in (
"contract_version",
"action",
"routing_mode",
"validation_behavior",
"watched_source_count",
"residual_file_count",
)
}
def _attachment_reuse_audit_evidence(value: object) -> dict[str, object]:
if not isinstance(value, dict):
return {}
policy = value.get("policy")
return {
"contract_version": value.get("contract_version"),
"policy": dict(policy) if isinstance(policy, dict) else {},
"duplicate_file_count": value.get("duplicate_file_count"),
"allowed_file_count": value.get("allowed_file_count"),
"violation_file_count": value.get("violation_file_count"),
"affected_message_count": value.get("affected_message_count"),
}
def _review_decision_audit_evidence(
version: CampaignVersion,
) -> dict[str, object]:
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
review_state = editor_state.get("review_send")
if not isinstance(review_state, dict):
return {}
raw_decisions = review_state.get("issue_decisions")
decisions = [item for item in raw_decisions or [] if isinstance(item, dict)]
evidence = [
{
"decision": item.get("decision"),
"issue_codes": sorted(
str(code) for code in item.get("issue_codes") or [] if code
),
"issue_fingerprint": item.get("issue_fingerprint"),
"message_sha256": item.get("message_sha256"),
"reason_recorded": bool(str(item.get("reason") or "").strip()),
}
for item in decisions
]
return {
"count": len(evidence),
"with_reason_count": sum(
1 for item in evidence if item["reason_recorded"]
),
"issue_codes": sorted(
{
code
for item in evidence
for code in item["issue_codes"]
}
),
"evidence_sha256": _canonical_sha256(evidence),
}
@@ -371,6 +371,13 @@
"warn"
],
"default": "ask"
},
"reuse_policy": {
"$ref": "#/$defs/attachment_reuse_policy",
"description": "Controls whether resolving the same source file more than once is allowed, warned, reviewed, or blocked, with an optional same-recipient or same-message allowance."
},
"residual_files": {
"$ref": "#/$defs/residual_file_disposition"
}
},
"additionalProperties": false,
@@ -1455,6 +1462,53 @@
},
"additionalProperties": false
},
"attachment_reuse_policy": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["allow", "warn", "review", "block"],
"default": "allow",
"description": "Action when one resolved source file is used more than once outside the configured allowance. Review creates an explicit, reasoned review decision."
},
"allow_within": {
"type": "string",
"enum": ["none", "same_recipient", "same_message"],
"default": "none",
"description": "Optional exception that permits reuse confined to one recipient or one built message."
}
},
"additionalProperties": false,
"default": {"action": "allow", "allow_within": "none"}
},
"residual_file_disposition": {
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["none", "report", "attach"],
"default": "none",
"description": "Keep normal warning policy, prepare a reviewed report message, or prepare a reviewed message with the residual files attached."
},
"recipient": {
"oneOf": [
{ "$ref": "#/$defs/recipient" },
{ "type": "null" }
],
"default": null
},
"subject": {
"type": "string",
"default": "Unassigned files in campaign {{local:campaign_name}}"
},
"text": {
"type": "string",
"default": "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}"
}
},
"additionalProperties": false,
"default": { "mode": "none", "recipient": null }
},
"zip_config": {
"type": "object",
"properties": {
@@ -61,6 +61,16 @@
}
],
"fields": {
"/attachments/reuse_policy/action": {
"label": "Duplicate-file action",
"control": "select",
"description": "Allow and record, warn, require a reasoned review decision, or block repeated use of the same resolved file."
},
"/attachments/reuse_policy/allow_within": {
"label": "Allowed reuse boundary",
"control": "select",
"description": "Optionally exempt reuse confined to one recipient or one built message."
},
"/attachments/global[]/message_filename_template": {
"label": "Direct attachment filename",
"control": "text",
+120
View File
@@ -52,6 +52,126 @@ 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)
include_recipients: bool = True
include_files: bool = True
include_shares: bool = False
include_policies: bool = True
include_mail_profile: bool = True
class CampaignScheduleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_version_id: str = Field(min_length=1, max_length=36)
name: str = Field(min_length=1, max_length=255)
recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once"
interval_count: int = Field(default=1, ge=1, le=365)
timezone: str = Field(default="UTC", min_length=1, max_length=100)
starts_at: datetime
ends_at: datetime | None = None
max_occurrences: int = Field(default=1, ge=1, le=1000)
include_recipients: bool = True
include_files: bool = True
include_shares: bool = False
include_policies: bool = True
include_mail_profile: bool = True
@model_validator(mode="after")
def validate_schedule(self) -> "CampaignScheduleCreateRequest":
if self.starts_at.tzinfo is None:
raise ValueError("Campaign schedule start must include a timezone.")
if self.ends_at is not None:
if self.ends_at.tzinfo is None:
raise ValueError("Campaign schedule end must include a timezone.")
if self.ends_at <= self.starts_at:
raise ValueError("Campaign schedule end must be after its start.")
if self.recurrence_kind == "once":
self.max_occurrences = 1
self.interval_count = 1
elif self.max_occurrences < 2:
raise ValueError("A recurring campaign schedule needs at least two occurrences.")
return self
class CampaignScheduleStateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
active: bool
base_revision: int = Field(ge=1)
class CampaignScheduleOccurrenceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
schedule_id: str
scheduled_for: datetime
status: str
generated_campaign_id: str | None = None
generated_version_id: str | None = None
error: str | None = None
created_at: datetime
class CampaignScheduleResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
campaign_id: str
source_version_id: str
name: str
recurrence_kind: str
interval_count: int
timezone: str
starts_at: datetime
next_fire_at: datetime | None = None
ends_at: datetime | None = None
max_occurrences: int
occurrence_count: int
active: bool
resource_revision: int
last_fired_at: datetime | None = None
last_campaign_id: str | None = None
last_error: str | None = None
created_at: datetime
updated_at: datetime
occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list)
class CampaignScheduleListResponse(BaseModel):
items: list[CampaignScheduleResponse]
class CampaignContentLibrarySaveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4000)
kind: Literal["fragment", "campaign_part"]
target: Literal["subject", "text", "html"] | None = None
subject: str | None = Field(default=None, max_length=1000)
text: str | None = Field(default=None, max_length=1_000_000)
html: str | None = Field(default=None, max_length=2_000_000)
body_mode: Literal["text", "html", "both"] = "both"
locale: str = Field(default="de", min_length=2, max_length=35)
visibility: Literal["personal", "tenant"] = "personal"
@model_validator(mode="after")
def validate_content(self) -> "CampaignContentLibrarySaveRequest":
if self.kind == "fragment" and self.target is None:
raise ValueError("A content fragment requires a target field.")
values = {
"subject": self.subject,
"text": self.text,
"html": self.html,
}
if self.kind == "fragment":
selected = values[self.target or "text"]
if not selected or not selected.strip():
raise ValueError("The selected fragment field is empty.")
elif not any(value and value.strip() for value in (self.text, self.html)):
raise ValueError("A campaign part requires text or HTML body content.")
return self
class CampaignCreateMinimalRequest(BaseModel):
+544 -4
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import json
import unittest
from unittest.mock import patch
@@ -10,17 +12,30 @@ from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.capabilities import (
CampaignAccessService,
CampaignOwnershipService,
campaign_import_execution_resource_id,
campaign_protocol_artifact_resource_id,
campaign_report_resource_id,
campaign_version_child_resource_id,
)
from govoplan_campaign.backend.db.models import (
AttachmentBlob,
AttachmentInstance,
Campaign,
CampaignIssue,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignShare,
CampaignVersion,
ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
RecipientImportMappingProfile,
SendAttempt,
)
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.ownership import OwnershipSubjectRef, OwnershipTransferError
from govoplan_core.core.ownership import OwnershipSubjectRef, OwnershipTransfer, OwnershipTransferError
from govoplan_core.db.base import Base
@@ -181,6 +196,513 @@ class CampaignAccessProviderTests(unittest.TestCase):
self.assertEqual(report.details["report_kind"], "delivery")
self.assertFalse(report.details["persisted"])
def test_recipient_attachment_and_review_children_are_bounded_and_versioned(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(
id="campaign-sensitive",
tenant_id=TENANT_ID,
owner_user_id=OTHER_USER_ID,
external_id="sensitive",
name="Sensitive child evidence",
)
version = CampaignVersion(
id="version-sensitive",
campaign_id=campaign.id,
version_number=4,
raw_json={},
execution_snapshot={"secret_source_rows": ["do-not-disclose"]},
execution_snapshot_hash="a" * 64,
)
job = CampaignJob(
id="job-sensitive",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=7,
recipient_email="hidden-recipient@example.test",
execution_input_sha256="b" * 64,
resolved_attachments=[{"filename": "hidden-file.pdf", "storage_key": "secret/key"}],
issues_snapshot=[{
"code": "attachment_override_required",
"source": "attachments.policy",
"behavior": "ask",
"message": "hidden validation detail",
}],
)
version.editor_state = {
"review_send": {
"issue_decisions": [{
"job_id": job.id,
"review_key": "opaque-review-key",
"decision": "accept",
"reason": "hidden operator reason",
"decided_at": "2026-08-19T10:00:00+00:00",
"issue_fingerprint": "c" * 64,
}]
}
}
issue = CampaignIssue(
id="issue-sensitive",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
job_id=job.id,
severity="warning",
code="attachment_override_required",
message="hidden validation detail",
source="hidden/source/path",
behavior="ask",
)
blob = AttachmentBlob(
id="blob-sensitive",
tenant_id=TENANT_ID,
sha256="d" * 64,
size_bytes=123,
storage_bucket="secret-bucket",
storage_key="secret/object",
)
attachment = AttachmentInstance(
id="attachment-sensitive",
tenant_id=TENANT_ID,
owner_user_id=OTHER_USER_ID,
campaign_id=campaign.id,
blob_id=blob.id,
filename="hidden-file.pdf",
)
session.add_all([
campaign,
version,
job,
issue,
blob,
attachment,
CampaignShare(
id="share-sensitive",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
target_type="group",
target_id=GROUP_ID,
permission="read",
),
])
session.commit()
service = CampaignAccessService()
principal = _principal(
scopes={
"campaigns:campaign:read",
"campaigns:recipient:read",
"campaigns:campaign:review",
"campaigns:diagnostic:read",
},
group_ids={GROUP_ID},
)
version_child_id = campaign_version_child_resource_id(
version_id=version.id,
child_id=job.id,
)
cases = (
("campaign_recipient_source_snapshot", version.id, "campaigns.recipient_source_snapshot"),
("campaign_recipient", version_child_id, "campaigns.recipient"),
("campaign_attachment_binding", attachment.id, "campaigns.attachment_binding"),
("campaign_attachment_resolution", version_child_id, "campaigns.attachment_resolution"),
("campaign_validation_issue", issue.id, "campaigns.validation_issue"),
("campaign_review_decision", version_child_id, "campaigns.review_decision"),
("campaign_attachment_override", version_child_id, "campaigns.attachment_override"),
)
for resource_type, resource_id, source in cases:
items = service.explain_resource_provenance(
session,
principal,
resource_type=resource_type,
resource_id=resource_id,
action="campaigns:diagnostic:read",
)
child = next(item for item in items if item.source == source)
self.assertEqual(campaign.id, child.details["campaign_id"])
self.assertEqual(
{"resource_type": "campaign", "resource_id": campaign.id},
child.details["authorization_inherited_from"],
)
self.assertEqual("inherited_and_further_restricted", child.details["authorization_mode"])
self.assertTrue(any(item.id == "share-sensitive" for item in items))
serialized = json.dumps(child.details, sort_keys=True)
for hidden in (
"hidden-recipient@example.test",
"hidden-file.pdf",
"secret/object",
"secret/key",
"hidden validation detail",
"hidden operator reason",
"hidden/source/path",
"do-not-disclose",
):
self.assertNotIn(hidden, serialized)
denied_items = service.explain_resource_provenance(
session,
_principal(scopes=set()),
resource_type="campaign_recipient",
resource_id=version_child_id,
action="campaigns:recipient:read",
)
denied_child = next(item for item in denied_items if item.source == "campaigns.recipient")
self.assertIn("campaigns:recipient:read", denied_child.details["permission_actions"])
self.assertFalse(any(item.kind in {"owner", "share", "policy"} for item in denied_items))
missing = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_recipient",
resource_id=f"{version.id}:missing-job",
action="campaigns:recipient:read",
)
self.assertEqual("campaigns.not_found", missing[0].source)
self.assertNotIn("hidden-recipient", json.dumps(missing[0].details))
stale = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_review_decision",
resource_id=f"stale-version:{job.id}",
action="campaigns:diagnostic:read",
)
self.assertEqual("stale_version_reference", stale[0].details["reason"])
hidden = service.explain_resource_provenance(
session,
_principal(tenant_id="tenant-2"),
resource_type="campaign_validation_issue",
resource_id=issue.id,
action="campaigns:diagnostic:read",
)
self.assertEqual("campaigns.not_found", hidden[0].source)
self.assertNotIn(issue.code, json.dumps(hidden[0].details))
def test_delivery_evidence_explanations_separate_permissions_and_hide_transport_data(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(
id="campaign-delivery-evidence",
tenant_id=TENANT_ID,
owner_user_id=USER_ID,
external_id="delivery-evidence",
name="Delivery evidence",
)
version = CampaignVersion(
id="version-delivery-evidence",
campaign_id=campaign.id,
version_number=1,
raw_json={},
)
job = CampaignJob(
id="job-delivery-evidence",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=1,
recipient_email="hidden-delivery-recipient@example.test",
send_status="sent",
postbox_status="accepted",
imap_status="appended",
last_error="hidden reconciliation note",
)
send_attempt = SendAttempt(
id="send-attempt-evidence",
job_id=job.id,
attempt_number=1,
status="reconciled_not_sent",
claim_token="hidden-claim-token",
smtp_response="hidden-smtp-response",
)
imap_attempt = ImapAppendAttempt(
id="imap-attempt-evidence",
job_id=job.id,
attempt_number=1,
status="reconciled_imap_appended",
folder="Hidden/Sent",
error_message="hidden imap note",
)
postbox_attempt = PostboxDeliveryAttempt(
id="postbox-attempt-evidence",
tenant_id=TENANT_ID,
job_id=job.id,
target_key="opaque-target",
target_index=0,
attempt_number=1,
idempotency_key="opaque-postbox-key",
status="accepted",
target_snapshot={"address": "hidden postbox address"},
evidence={
"operator_reconciliation": {
"decision": "accepted",
"note": "hidden postbox note",
}
},
)
print_attempt = PrintOutputAttempt(
id="print-attempt-evidence",
tenant_id=TENANT_ID,
job_id=job.id,
attempt_number=1,
idempotency_key="opaque-print-key",
status="accepted",
render_id="hidden-render-id",
evidence={"storage_key": "hidden print locator"},
)
message_action = CampaignMessageAction(
id="message-action-evidence",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
job_id=job.id,
kind="retry",
idempotency_key="opaque-action-key",
canonical_request_hash="e" * 64,
reason="hidden action reason",
message_sha256="f" * 64,
recipient_manifest_sha256="1" * 64,
recipient_count=1,
prior_send_status="failed",
prior_attempt_count=1,
status="completed",
)
action_attempt = CampaignMessageActionAttempt(
id="message-action-attempt-evidence",
action_id=message_action.id,
attempt_number=1,
status="completed",
started_at=datetime.now(UTC),
diagnostic_summary="hidden diagnostic summary",
)
session.add_all([
campaign,
version,
job,
send_attempt,
imap_attempt,
postbox_attempt,
print_attempt,
message_action,
action_attempt,
])
session.commit()
service = CampaignAccessService()
principal = _principal(scopes={
"campaigns:campaign:read",
"campaigns:report:read",
"campaigns:report:export",
"campaigns:diagnostic:read",
"campaigns:campaign:reconcile",
})
cases = (
("campaign_send_attempt", send_attempt.id, "campaigns.send_attempt"),
("campaign_imap_append_attempt", imap_attempt.id, "campaigns.imap_append_attempt"),
("campaign_postbox_attempt", postbox_attempt.id, "campaigns.postbox_attempt"),
("campaign_print_attempt", print_attempt.id, "campaigns.print_attempt"),
("campaign_message_action", message_action.id, "campaigns.message_action"),
("campaign_message_action_attempt", action_attempt.id, "campaigns.message_action_attempt"),
)
for resource_type, resource_id, source in cases:
items = service.explain_resource_provenance(
session,
principal,
resource_type=resource_type,
resource_id=resource_id,
action="campaigns:diagnostic:read",
)
child = next(item for item in items if item.source == source)
self.assertEqual(campaign.id, child.details["campaign_id"])
self.assertEqual(version.id, child.details["campaign_version_id"])
self.assertEqual(job.id, child.details["job_id"])
self.assertEqual(
{
"read": ["campaigns:campaign:read"],
"report": ["campaigns:report:read"],
"diagnostic": ["campaigns:diagnostic:read"],
"export": ["campaigns:report:export"],
},
child.details["permission_classes"],
)
serialized = json.dumps(child.details, sort_keys=True)
for hidden_value in (
"hidden-delivery-recipient@example.test",
"hidden-claim-token",
"hidden-smtp-response",
"Hidden/Sent",
"hidden imap note",
"hidden postbox address",
"hidden postbox note",
"hidden-render-id",
"hidden print locator",
"hidden action reason",
"hidden diagnostic summary",
"hidden reconciliation note",
):
self.assertNotIn(hidden_value, serialized)
reconciliation = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_reconciliation_decision",
resource_id=job.id,
action="campaigns:campaign:reconcile",
)
decision = next(item for item in reconciliation if item.source == "campaigns.reconciliation_decision")
self.assertEqual(["smtp", "imap", "postbox"], decision.details["channels"])
self.assertTrue(decision.details["evidence_note_recorded"])
self.assertFalse(decision.details["evidence_note_disclosed"])
unavailable = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_postbox_attempt",
resource_id="missing-postbox-attempt",
action="campaigns:diagnostic:read",
)
self.assertEqual("postbox", unavailable[0].details["optional_module"])
self.assertEqual("unavailable_or_hidden", unavailable[0].details["evidence_availability"])
self.assertNotIn("hidden postbox address", json.dumps(unavailable[0].details))
def test_governance_import_and_protocol_explanation_matrix(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
now = datetime.now(UTC)
campaign = Campaign(
id="campaign-governed-children", tenant_id=TENANT_ID,
owner_user_id=USER_ID, external_id="governed-children", name="Governed children",
)
share = CampaignShare(
id="share-governed-child", tenant_id=TENANT_ID, campaign_id=campaign.id,
target_type="user", target_id=OTHER_USER_ID, permission="read",
)
version = CampaignVersion(
id="version-governed-children", campaign_id=campaign.id, version_number=2,
raw_json={"entries": {"inline": [], "imports": [{
"id": "import-stable-id", "imported_at": "2026-08-20T08:00:00+00:00",
"mode": "replace", "source_type": "addresses", "source_id": "address-source-42",
"source_revision": "revision-7",
"source_provenance": {"secret_source_row": "do-not-disclose-row"},
"filename": "hidden-import.csv",
"mapping": [{"header": "Secret column", "field_name": "secret"}],
}]}},
validation_summary={"secret_issue": "do-not-disclose-validation"},
build_summary={"secret_recipient": "do-not-disclose-build"},
execution_snapshot={"rows": ["do-not-disclose-snapshot"]},
execution_snapshot_hash="a" * 64, execution_snapshot_at=now,
editor_state={"review_send": {"issue_decisions": [
{"reason": "do-not-disclose-review-reason"}
]}},
)
profile = RecipientImportMappingProfile(
id="mapping-profile-governed-child", tenant_id=TENANT_ID, owner_user_id=USER_ID,
name="Hidden profile name", column_count=2,
headers=["Secret A", "Secret B"], normalized_headers=["secret a", "secret b"],
ordered_header_fingerprint="b" * 64, unordered_header_fingerprint="c" * 64,
delimiter=";", header_rows=1, quoted=True, value_separators=",;|",
mappings=[{"header": "Secret A", "field": "hidden"}],
)
transfer = OwnershipTransfer(
id="transfer-governed-child", tenant_id=TENANT_ID,
resource_module="campaigns", resource_type="campaign", resource_id=campaign.id,
kind="transfer", status="pending_target",
current_owner_type="user", current_owner_id=USER_ID,
target_owner_type="user", target_owner_id=OTHER_USER_ID,
initiated_by_type="user", initiated_by_id=USER_ID,
reason="do-not-disclose-transfer-reason", required_approvals=1,
approvals=[], decisions=[], idempotency_key="transfer-idempotency",
canonical_request_hash="d" * 64, expires_at=now + timedelta(days=1),
revision=3, metadata_={"secret": "do-not-disclose-transfer-metadata"},
)
session.add_all([campaign, share, version, profile, transfer])
session.commit()
service = CampaignAccessService()
principal = _principal(scopes={
"campaigns:campaign:read", "campaigns:campaign:share",
"campaigns:recipient:read", "campaigns:recipient:import",
"campaigns:campaign:review", "campaigns:diagnostic:read",
"campaigns:report:read",
})
import_id = campaign_import_execution_resource_id(
version_id=version.id, import_id="import-stable-id",
)
protocol_ids = [
campaign_protocol_artifact_resource_id(version_id=version.id, artifact_kind=kind)
for kind in ("validation", "build", "execution_snapshot", "review")
]
cases = [
("campaign_share", share.id, "campaigns.share_record"),
("campaign_ownership_transfer", transfer.id, "campaigns.ownership_transfer"),
("campaign_import_execution", import_id, "campaigns.import_execution"),
*(("campaign_protocol_artifact", item, "campaigns.protocol_artifact") for item in protocol_ids),
]
hidden_values = (
OTHER_USER_ID, "do-not-disclose-row", "hidden-import.csv", "Secret column",
"do-not-disclose-validation", "do-not-disclose-build", "do-not-disclose-snapshot",
"do-not-disclose-review-reason", "do-not-disclose-transfer-reason",
"do-not-disclose-transfer-metadata",
)
for resource_type, resource_id, source in cases:
items = service.explain_resource_provenance(
session, principal, resource_type=resource_type, resource_id=resource_id,
action="campaigns:diagnostic:read",
)
child = next(item for item in items if item.source == source)
self.assertEqual(campaign.id, child.details["campaign_id"])
serialized = json.dumps(child.details, sort_keys=True)
for hidden_value in hidden_values:
self.assertNotIn(hidden_value, serialized)
import_item = next(item for item in service.explain_resource_provenance(
session, principal, resource_type="campaign_import_execution",
resource_id=import_id, action="campaigns:recipient:import",
) if item.source == "campaigns.import_execution")
self.assertEqual("address-source-42", import_item.details["source_id"])
self.assertEqual("revision-7", import_item.details["source_revision"])
self.assertFalse(import_item.details["source_rows_disclosed"])
profile_items = service.explain_resource_provenance(
session, principal, resource_type="campaign_import_mapping_profile",
resource_id=profile.id, action="campaigns:recipient:import",
)
profile_item = next(item for item in profile_items if item.source == "campaigns.import_mapping_profile")
self.assertEqual("independently_owned", profile_item.details["authorization_mode"])
self.assertTrue(any(item.kind == "owner" for item in profile_items))
self.assertNotIn("Secret A", json.dumps(profile_item.details))
self.assertNotIn("Hidden profile name", json.dumps(profile_item.to_dict()))
for resource_type, optional_module in (
("campaign_template", "templates"),
("campaign_template_revision", "templates"),
("campaign_export_package", "reporting"),
):
unavailable = service.explain_resource_provenance(
session, principal, resource_type=resource_type,
resource_id="opaque-optional-resource", action="campaigns:diagnostic:read",
)[0]
self.assertEqual("campaigns.not_found", unavailable.source)
self.assertEqual(optional_module, unavailable.details["optional_module"])
self.assertEqual("independently_governed_by_optional_module", unavailable.details["reason"])
self.assertEqual("unavailable_or_hidden", unavailable.details["evidence_availability"])
unsupported = service.explain_resource_provenance(
session, principal, resource_type="campaign_protocol_artifact",
resource_id=f"{version.id}:unsupported", action="campaigns:diagnostic:read",
)
self.assertEqual("unsupported_artifact_kind", unsupported[0].details["reason"])
cross_tenant = service.explain_resource_provenance(
session, _principal(tenant_id="tenant-2"),
resource_type="campaign_import_mapping_profile", resource_id=profile.id,
action="campaigns:recipient:import",
)
self.assertEqual("campaigns.not_found", cross_tenant[0].source)
def test_campaign_ownership_provider_requires_group_acceptance_authority(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
@@ -398,6 +920,17 @@ def _session():
CampaignShare.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
CampaignIssue.__table__,
AttachmentBlob.__table__,
AttachmentInstance.__table__,
SendAttempt.__table__,
CampaignMessageAction.__table__,
CampaignMessageActionAttempt.__table__,
ImapAppendAttempt.__table__,
PostboxDeliveryAttempt.__table__,
PrintOutputAttempt.__table__,
RecipientImportMappingProfile.__table__,
OwnershipTransfer.__table__,
ChangeSequenceEntry.__table__,
],
)
@@ -421,11 +954,18 @@ def _seed_access_subjects(session) -> None:
session.commit()
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
def _principal(
*,
scopes: set[str] | None = None,
group_ids: set[str] | None = None,
tenant_id: str = TENANT_ID,
) -> PrincipalRef:
return PrincipalRef(
account_id="account-1",
membership_id=USER_ID,
tenant_id=TENANT_ID,
scopes=frozenset(scopes or {"campaigns:campaign:read"}),
tenant_id=tenant_id,
scopes=frozenset(
{"campaigns:campaign:read"} if scopes is None else scopes
),
group_ids=frozenset(group_ids or set()),
)
+2 -1
View File
@@ -240,6 +240,7 @@ def test_process_loss_orphan_is_deleted_once_and_same_request_replays(
def test_competing_node_cannot_acquire_cleanup_authority(
recovery_session_factory,
) -> None:
lease_observed_at = datetime.now(timezone.utc)
with recovery_session_factory() as session:
claim = acquire_lease(
session,
@@ -248,7 +249,7 @@ def test_competing_node_cannot_acquire_cleanup_authority(
holder_node_id="node-other",
holder_incarnation="run-other",
ttl_seconds=900,
now=NOW,
now=lease_observed_at,
)
assert claim is not None
session.commit()
+317
View File
@@ -7,6 +7,7 @@ import zipfile
from pathlib import Path
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.loader import validate_against_schema
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.messages.builder import build_campaign_messages
@@ -56,6 +57,64 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
"delivery": {"imap_append_sent": {"enabled": False}},
})
def _attachment_reuse_config(
self,
*,
action: str,
allow_within: str = "none",
recipient_emails: tuple[str, ...] = (
"first@example.org",
"second@example.org",
),
duplicate_rules: bool = False,
) -> CampaignConfig:
rules = [{
"id": "shared-file",
"base_dir": "documents",
"file_filter": "shared.pdf",
"required": True,
}]
if duplicate_rules:
rules.append({**rules[0], "id": "shared-file-again"})
return CampaignConfig.model_validate({
"version": "1.0",
"campaign": {
"id": f"reuse-{action}-{allow_within}",
"name": "Attachment reuse",
"mode": "test",
},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {
"from": {"email": "sender@example.org", "type": "to"},
"allow_individual_to": True,
},
"template": {"subject": "Subject", "text": "Body"},
"attachments": {
"global": rules,
"reuse_policy": {
"action": action,
"allow_within": allow_within,
},
},
"entries": {
"inline": [
{
"id": f"recipient-{index}",
"to": [{"email": email, "type": "to"}],
}
for index, email in enumerate(recipient_emails, start=1)
]
},
"validation_policy": {
"missing_email": "block",
"template_error": "block",
},
"delivery": {"imap_append_sent": {"enabled": False}},
})
def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -275,6 +334,264 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
self.assertEqual(archive.namelist(), ["matched.xlsx"])
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
def test_attachment_reuse_action_controls_message_validation(self) -> None:
expected = {
"allow": ("ready", None, 0, 1),
"warn": ("warning", "warn", 1, 0),
"review": ("needs_review", "ask", 1, 0),
"block": ("blocked", "block", 1, 0),
}
for action, (status, behavior, violation_count, allowed_count) in expected.items():
with self.subTest(action=action), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
documents = root / "documents"
documents.mkdir()
(documents / "shared.pdf").write_bytes(b"shared")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = self._attachment_reuse_config(action=action)
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(
[status, status],
[message.validation_status.value for message in result.report.messages],
)
report = result.report.attachment_reuse
self.assertEqual(1, report["duplicate_file_count"])
self.assertEqual(violation_count, report["violation_file_count"])
self.assertEqual(allowed_count, report["allowed_file_count"])
finding = report["findings"][0]
self.assertEqual("shared.pdf", finding["file_name"])
self.assertNotIn(str(root), str(report))
issues = [
issue
for message in result.report.messages
for issue in message.issues
if issue.code == "duplicate_attachment_reuse"
]
if behavior is None:
self.assertEqual([], issues)
else:
self.assertEqual([behavior, behavior], [issue.behavior for issue in issues])
self.assertEqual(
{"action": action, "allow_within": "none"},
issues[0].details["policy"],
)
def test_attachment_reuse_can_be_allowed_within_recipient_or_message(self) -> None:
cases = (
("same_recipient", ("same@example.org", "same@example.org"), False, 2),
("same_message", ("same@example.org",), True, 2),
)
for allow_within, recipients, duplicate_rules, expected_use_count in cases:
with self.subTest(allow_within=allow_within), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
documents = root / "documents"
documents.mkdir()
(documents / "shared.pdf").write_bytes(b"shared")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
result = build_campaign_messages(
self._attachment_reuse_config(
action="block",
allow_within=allow_within,
recipient_emails=recipients,
duplicate_rules=duplicate_rules,
),
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(
["ready"] * len(recipients),
[message.validation_status.value for message in result.report.messages],
)
report = result.report.attachment_reuse
self.assertEqual(1, report["allowed_file_count"])
self.assertEqual(0, report["violation_file_count"])
self.assertEqual(expected_use_count, report["findings"][0]["use_count"])
def test_attachment_reuse_policy_is_part_of_the_json_schema(self) -> None:
config = self._attachment_reuse_config(
action="review",
allow_within="same_recipient",
)
payload = config.model_dump(
mode="json",
by_alias=True,
exclude_none=True,
exclude_defaults=True,
)
payload["server"].pop("profile_capabilities", None)
validate_against_schema(payload)
def test_residual_files_become_a_separate_reviewed_report_or_attachment_message(self) -> None:
for mode, expected_attachment_count in (("report", 0), ("attach", 1)):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
watched = root / "watched"
watched.mkdir()
(watched / "assigned.txt").write_text("assigned", encoding="utf-8")
(watched / "residual.txt").write_text("residual", encoding="utf-8")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": f"residual-{mode}", "name": "Monthly import", "mode": "test"},
"fields": [],
"global_values": {},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {
"from": {"email": "sender@example.org", "type": "to"},
"allow_individual_to": True,
},
"template": {"subject": "Normal message", "text": "Normal body"},
"attachments": {
"base_paths": [{
"id": "watched",
"name": "Watched folder",
"path": "watched",
"unsent_warning": True,
}],
"global": [{
"id": "assigned",
"base_path_id": "watched",
"base_dir": "watched",
"file_filter": "assigned.txt",
"required": True,
}],
"residual_files": {
"mode": mode,
"recipient": {"email": "operator@example.org", "name": "Operator"},
"subject": "Residual files for {{local:campaign_name}}",
"text": "{{local:residual_file_count}} file(s):\n{{local:residual_file_list}}",
},
},
"entries": {"inline": [{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "type": "to"}],
}]},
"validation_policy": {
"missing_email": "block",
"template_error": "block",
"unsent_attachment_files": "block",
},
"delivery": {"imap_append_sent": {"enabled": False}},
})
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=True,
)
self.assertEqual(len(result.report.messages), 2)
self.assertEqual(
{
"contract_version": "1",
"action": "route_report" if mode == "report" else "route_with_files",
"routing_mode": mode,
"validation_behavior": "block",
"watched_source_count": 1,
"residual_file_count": 1,
"recipient": {
"email": "operator@example.org",
"name": "Operator",
"type": "to",
},
},
result.report.residual_file_disposition,
)
normal, residual = result.report.messages
self.assertEqual(normal.validation_status.value, "ready")
self.assertEqual(residual.entry_id, "__residual_files__")
self.assertEqual(residual.validation_status.value, "needs_review")
self.assertEqual(residual.to[0].email, "operator@example.org")
self.assertEqual(residual.subject, "Residual files for Monthly import")
self.assertEqual(residual.attachment_count, expected_attachment_count)
self.assertIn(
"residual_attachment_disposition",
{issue.code for issue in residual.issues},
)
self.assertNotIn(
"unsent_attachment_files",
{issue.code for message in result.report.messages for issue in message.issues},
)
mime = result.built_messages[1].mime
self.assertIsNotNone(mime)
self.assertIn("residual.txt", mime.get_body(preferencelist=("plain",)).get_content())
filenames = [part.get_filename() for part in mime.iter_attachments()]
self.assertEqual(filenames, ["residual.txt"] if mode == "attach" else [])
def test_residual_file_policy_evidence_normalizes_block_and_ignore(self) -> None:
for behavior, action in (("block", "block"), ("continue", "ignore")):
with self.subTest(behavior=behavior), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
watched = root / "watched"
watched.mkdir()
(watched / "residual.txt").write_text("residual", encoding="utf-8")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": f"residual-{behavior}", "name": "Residual", "mode": "test"},
"fields": [],
"global_values": {},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {
"from": {"email": "sender@example.org", "type": "to"},
"allow_individual_to": True,
},
"template": {"subject": "Normal", "text": "Body"},
"attachments": {
"base_paths": [{
"id": "watched",
"name": "Watched folder",
"path": "watched",
"unsent_warning": True,
}],
},
"entries": {"inline": [{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "type": "to"}],
}]},
"validation_policy": {"unsent_attachment_files": behavior},
"delivery": {"imap_append_sent": {"enabled": False}},
})
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(action, result.report.residual_file_disposition["action"])
self.assertEqual(1, result.report.residual_file_disposition["residual_file_count"])
issue_codes = {
issue.code
for message in result.report.messages
for issue in message.issues
}
self.assertEqual(behavior == "block", "unsent_attachment_files" in issue_codes)
if __name__ == "__main__":
unittest.main()
+68 -1
View File
@@ -15,7 +15,12 @@ from govoplan_campaign.backend.persistence.campaigns import (
_verify_build_storage_manifest,
_verify_storage_keys_absent,
)
from govoplan_campaign.backend.routes.versions import _campaign_build_recovery_plan
from govoplan_campaign.backend.routes.versions import (
_attachment_reuse_audit_evidence,
_campaign_build_recovery_plan,
_residual_file_audit_evidence,
_review_decision_audit_evidence,
)
def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None:
@@ -34,6 +39,68 @@ def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() ->
)
def test_residual_file_audit_evidence_omits_recipient_identity() -> None:
assert _residual_file_audit_evidence({
"contract_version": "1",
"action": "route_report",
"routing_mode": "report",
"validation_behavior": "warn",
"watched_source_count": 2,
"residual_file_count": 3,
"recipient": {"email": "operator@example.org"},
}) == {
"contract_version": "1",
"action": "route_report",
"routing_mode": "report",
"validation_behavior": "warn",
"watched_source_count": 2,
"residual_file_count": 3,
}
def test_attachment_reuse_audit_evidence_keeps_policy_but_not_file_names() -> None:
assert _attachment_reuse_audit_evidence({
"contract_version": "1",
"policy": {"action": "review", "allow_within": "same_recipient"},
"duplicate_file_count": 4,
"allowed_file_count": 1,
"violation_file_count": 3,
"affected_message_count": 5,
"findings": [{"file_name": "personal.pdf"}],
}) == {
"contract_version": "1",
"policy": {"action": "review", "allow_within": "same_recipient"},
"duplicate_file_count": 4,
"allowed_file_count": 1,
"violation_file_count": 3,
"affected_message_count": 5,
}
def test_review_decision_audit_evidence_is_aggregate_and_integrity_sealed() -> None:
version = type("Version", (), {
"editor_state": {
"review_send": {
"issue_decisions": [{
"decision": "accept",
"reason": "Approved exception",
"issue_codes": ["duplicate_attachment_reuse"],
"issue_fingerprint": "f" * 64,
"message_sha256": "m" * 64,
}]
}
}
})()
evidence = _review_decision_audit_evidence(version)
assert evidence["count"] == 1
assert evidence["with_reason_count"] == 1
assert evidence["issue_codes"] == ["duplicate_attachment_reuse"]
assert len(str(evidence["evidence_sha256"])) == 64
assert "Approved exception" not in str(evidence)
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
storage = LocalFilesystemStorageBackend(tmp_path)
payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from datetime import UTC, datetime
from govoplan_campaign.backend.integrations import TemplatesCampaignIntegration
from govoplan_campaign.backend.routes.campaigns import (
_campaign_content_library_item,
_campaign_content_required_fields,
)
from govoplan_campaign.backend.schemas import CampaignContentLibrarySaveRequest
from govoplan_core.core.templates import (
TemplateContentDraftRequest,
TemplateRef,
TemplateRevisionRef,
)
class _Catalog:
def __init__(self, template: TemplateRef) -> None:
self.template = template
self.calls: list[dict[str, object]] = []
def list_templates(self, session, principal, **kwargs):
del session, principal
self.calls.append(kwargs)
return (self.template,)
def get_template(self, session, principal, **kwargs):
del session, principal, kwargs
return self.template
def check_compatibility(self, session, principal, **kwargs):
del session, principal, kwargs
raise AssertionError("Compatibility is not needed for raw reusable content.")
class _ContentLibrary:
def __init__(self, template: TemplateRef) -> None:
self.template = template
self.request: TemplateContentDraftRequest | None = None
def create_content_draft(self, session, principal, *, request):
del session, principal
self.request = request
return self.template
def _template_ref() -> TemplateRef:
revision = TemplateRevisionRef(
id="revision-1",
template_id="template-1",
revision=3,
definition_hash="a" * 64,
template_type="content_fragment",
usages=("campaign.content",),
locale="de",
required_fields=(),
output_profiles=(),
content_text="Mit freundlichen Grussen",
metadata={
"campaign_kind": "fragment",
"campaign_targets": ["subject"],
},
published_at=datetime.now(tz=UTC),
)
return TemplateRef(
id="template-1",
tenant_id="tenant-1",
name="Closing",
slug="closing",
template_type="content_fragment",
status="published",
current_revision=3,
current_revision_id=revision.id,
published_revision_id=revision.id,
revision=revision,
)
def test_campaign_content_integration_filters_usage_and_delegates_draft_creation() -> None:
template = _template_ref()
catalog = _Catalog(template)
writer = _ContentLibrary(template)
integration = TemplatesCampaignIntegration(catalog, None, writer)
listed = integration.list_content_templates(object(), object(), query="close")
request = TemplateContentDraftRequest(
name="Closing",
template_type="content_fragment",
usages=("campaign.content",),
content_text="Regards",
)
created = integration.create_content_draft(
object(),
object(),
request=request,
)
assert listed == (template,)
assert catalog.calls == [
{"query": "close", "usage": "campaign.content", "limit": 100}
]
assert writer.request is request
assert created is template
def test_campaign_content_payload_keeps_revision_and_declared_target() -> None:
payload = _campaign_content_library_item(_template_ref())
assert payload["kind"] == "fragment"
assert payload["targets"] == ["subject"]
assert payload["text"] == "Mit freundlichen Grussen"
assert payload["published"] is True
assert payload["revision"] == 3
assert payload["required_fields"] == []
def test_campaign_content_required_fields_are_normalized_and_deduplicated() -> None:
payload = CampaignContentLibrarySaveRequest(
name="Greeting",
kind="campaign_part",
subject="Hello {{ local:display_name }}",
text="Reference ${global.case_reference}; hello {{local::display_name}}",
html="<p>{{fields.salutation}} {{local:to[2].email}} {{unsupported namespace}}</p>",
)
requirements = _campaign_content_required_fields(payload)
assert [item.path for item in requirements] == [
"global.case_reference",
"local.display_name",
"local.to.2.email",
"salutation",
]
def test_content_save_request_rejects_empty_selected_fragment() -> None:
try:
CampaignContentLibrarySaveRequest(
name="Empty",
kind="fragment",
target="html",
text="Only text",
)
except ValueError as exc:
assert "selected fragment field is empty" in str(exc)
else:
raise AssertionError("Expected empty target validation to fail")
+128 -1
View File
@@ -13,10 +13,12 @@ from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_poli
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignSchedule,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.routes.campaigns import (
_campaign_copy_configuration,
archive_campaign_version,
copy_campaign,
delete_draft_campaign,
@@ -26,6 +28,7 @@ from govoplan_campaign.backend.schemas import (
CampaignLifecycleMutationRequest,
)
from govoplan_core.db.base import Base
from govoplan_core.core.change_sequence import ChangeSequenceEntry
class _Principal:
@@ -65,6 +68,8 @@ class CampaignLifecycleTests(unittest.TestCase):
CampaignVersion.__table__,
CampaignShare.__table__,
CampaignJob.__table__,
CampaignSchedule.__table__,
ChangeSequenceEntry.__table__,
],
)
self.SessionLocal = sessionmaker(
@@ -73,7 +78,14 @@ class CampaignLifecycleTests(unittest.TestCase):
expire_on_commit=False,
)
with self.SessionLocal() as session:
session.execute(access_users.insert().values(id="user-1"))
user_values = {"id": "user-1"}
if "tenant_id" in access_users.c:
user_values.update(
tenant_id="tenant-1",
account_id="account-1",
email="user-1@example.test",
)
session.execute(access_users.insert().values(**user_values))
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
@@ -153,6 +165,50 @@ class CampaignLifecycleTests(unittest.TestCase):
self.assertFalse(policy["actions"]["archive_campaign"]["allowed"])
self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"])
def test_schedule_evidence_blocks_destructive_lifecycle_actions(self) -> None:
with self.SessionLocal() as session:
session.add(
CampaignSchedule(
id="schedule-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
source_version_id="version-2",
created_by_user_id="user-1",
name="Recurring draft",
recurrence_kind="daily",
interval_count=1,
timezone="UTC",
starts_at=datetime(2026, 8, 8, tzinfo=UTC),
next_fire_at=datetime(2026, 8, 8, tzinfo=UTC),
max_occurrences=2,
copy_options={},
source_snapshot={"schema": "test"},
source_snapshot_hash="a" * 64,
)
)
session.commit()
active_policy = self._policy(session)
self.assertFalse(active_policy["actions"]["archive_campaign"]["allowed"])
self.assertIn(
"Pause active",
active_policy["actions"]["archive_campaign"]["reason"],
)
self.assertFalse(active_policy["actions"]["delete_campaign"]["allowed"])
self.assertIn(
"schedule evidence",
active_policy["actions"]["delete_campaign"]["reason"],
)
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.active = False
schedule.resource_revision += 1
session.commit()
paused_policy = self._policy(session)
self.assertTrue(paused_policy["actions"]["archive_campaign"]["allowed"])
self.assertFalse(paused_policy["actions"]["delete_campaign"]["allowed"])
def test_stale_delete_token_is_rejected(self) -> None:
with self.SessionLocal() as session:
policy = self._policy(session)
@@ -203,6 +259,10 @@ class CampaignLifecycleTests(unittest.TestCase):
def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None:
with self.SessionLocal() as session:
source_campaign = session.get(Campaign, "campaign-1")
assert source_campaign is not None
source_campaign.settings = {"retention": "source-policy"}
source_campaign.mail_profile_policy = {"profile_id": "mail-profile-1"}
session.add_all(
(
CampaignShare(
@@ -269,6 +329,8 @@ class CampaignLifecycleTests(unittest.TestCase):
CampaignCopyRequest(
source_version_id="version-2",
expected_state_token=policy["state_token"],
include_policies=False,
include_mail_profile=True,
),
session=session,
principal=self.principal,
@@ -277,6 +339,13 @@ class CampaignLifecycleTests(unittest.TestCase):
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")
copied_campaign = session.get(Campaign, "campaign-copy")
assert copied_campaign is not None
self.assertEqual({}, copied_campaign.settings)
self.assertEqual(
{"profile_id": "mail-profile-1"},
copied_campaign.mail_profile_policy,
)
self.assertEqual(
session.query(CampaignJob)
.filter(CampaignJob.campaign_id == "campaign-copy")
@@ -290,6 +359,64 @@ class CampaignLifecycleTests(unittest.TestCase):
0,
)
def test_copy_choices_reset_only_selected_configuration_domains(self) -> None:
source = {
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign"},
"fields": [{"name": "case_id"}],
"global_values": {"sender": "Office"},
"recipients": {"to": [{"email": "team@example.test"}]},
"entries": {
"inline": [
{
"id": "one",
"to": [{"email": "person@example.test"}],
"attachments": [{"base_dir": ".", "file_filter": "one.pdf"}],
}
]
},
"template": {"subject": "Hello", "text": "Body"},
"attachments": {
"global": [{"base_dir": ".", "file_filter": "global.pdf"}]
},
"validation_policy": {"missing_required_attachment": "warn"},
"server": {"mail_profile_id": "profile-1"},
"delivery": {"rate_limit": {"messages_per_minute": 20}},
}
copied = _campaign_copy_configuration(
source,
CampaignCopyRequest(
source_version_id="version-2",
expected_state_token="a" * 64,
include_recipients=True,
include_files=False,
include_policies=False,
include_mail_profile=False,
),
)
self.assertEqual(source["attachments"]["global"][0]["file_filter"], "global.pdf")
self.assertEqual({}, copied["attachments"])
self.assertEqual([], copied["entries"]["inline"][0]["attachments"])
self.assertEqual("person@example.test", copied["entries"]["inline"][0]["to"][0]["email"])
self.assertEqual({}, copied["validation_policy"])
self.assertEqual({}, copied["server"])
self.assertEqual(20, copied["delivery"]["rate_limit"]["messages_per_minute"])
def test_copy_without_recipient_permission_is_allowed_when_recipient_data_is_excluded(self) -> None:
principal = _Principal("campaigns:campaign:read", "campaigns:campaign:copy")
with self.SessionLocal() as session:
campaign = session.get(Campaign, "campaign-1")
assert campaign is not None
policy = campaign_lifecycle_policy(
session,
campaign=campaign,
principal=principal,
version_id="version-2",
)
self.assertTrue(policy["actions"]["copy_campaign"]["allowed"])
if __name__ == "__main__":
unittest.main()
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm.attributes import flag_modified
from govoplan_campaign.backend.campaign.scheduling import (
campaign_schedule_source_snapshot,
canonical_configuration_hash,
dispatch_due_campaign_schedules,
next_schedule_fire,
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignSchedule,
CampaignScheduleOccurrence,
CampaignShare,
CampaignVersion,
)
from govoplan_core.db.base import Base
from govoplan_core.core.change_sequence import ChangeSequenceEntry
def _access_table(name: str) -> Table:
existing = Base.metadata.tables.get(name)
if existing is not None:
return existing
return Table(
name,
Base.metadata,
Column("id", String(36), primary_key=True),
)
def _create_generated_campaign(session: Session, **kwargs):
raw = kwargs["raw_json"]
metadata = raw["campaign"]
campaign = Campaign(
tenant_id=kwargs["tenant_id"],
created_by_user_id=kwargs["user_id"],
owner_user_id=kwargs["user_id"],
external_id=metadata["id"],
name=metadata["name"],
status="draft",
)
session.add(campaign)
session.flush()
version = CampaignVersion(
campaign_id=campaign.id,
version_number=1,
raw_json=raw,
)
session.add(version)
session.flush()
campaign.current_version_id = version.id
return campaign, version
class TestCampaignScheduling:
def setup_method(self):
self.engine = create_engine("sqlite+pysqlite:///:memory:")
users = _access_table("access_users")
groups = _access_table("access_groups")
Base.metadata.create_all(
self.engine,
tables=[
users,
groups,
Campaign.__table__,
CampaignVersion.__table__,
CampaignShare.__table__,
CampaignSchedule.__table__,
CampaignScheduleOccurrence.__table__,
ChangeSequenceEntry.__table__,
],
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
configuration = {
"version": "1.0",
"campaign": {"id": "source", "name": "Monthly notice"},
}
snapshot = campaign_schedule_source_snapshot(
configuration=configuration,
campaign_settings={"retention": "sealed"},
mail_profile_policy={"profile_id": "profile-1"},
shares=[],
)
with self.SessionLocal() as session:
user_values = {"id": "user-1"}
if "tenant_id" in users.c:
user_values.update(
tenant_id="tenant-1",
account_id="account-1",
email="user-1@example.test",
)
session.execute(users.insert().values(**user_values))
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
created_by_user_id="user-1",
owner_user_id="user-1",
external_id="source",
name="Monthly notice",
status="sent",
current_version_id="version-1",
)
version = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
workflow_state="completed",
raw_json=configuration,
)
schedule = CampaignSchedule(
id="schedule-1",
tenant_id="tenant-1",
campaign_id=campaign.id,
source_version_id=version.id,
created_by_user_id="user-1",
name="Monthly notice",
recurrence_kind="daily",
interval_count=1,
timezone="Europe/Berlin",
starts_at=datetime(2026, 8, 7, 8, tzinfo=UTC),
next_fire_at=datetime(2026, 8, 7, 8, tzinfo=UTC),
max_occurrences=2,
copy_options={
"include_recipients": True,
"include_files": True,
"include_shares": False,
"include_policies": True,
"include_mail_profile": True,
},
source_snapshot=snapshot,
source_snapshot_hash=canonical_configuration_hash(snapshot),
)
session.add_all((campaign, version, schedule))
session.commit()
def teardown_method(self):
self.engine.dispose()
def test_due_occurrences_prepare_distinct_drafts_and_complete_bound(self):
with self.SessionLocal() as session, patch(
"govoplan_campaign.backend.campaign.scheduling.create_campaign_version_from_json",
side_effect=lambda *args, **kwargs: _create_generated_campaign(session, **kwargs),
), patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
first = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
session.commit()
assert first["prepared"] == 1
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
assert schedule.active is True
assert schedule.occurrence_count == 1
assert schedule.next_fire_at is not None
second = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 8, 8, tzinfo=UTC),
)
session.commit()
assert second["prepared"] == 1
assert schedule.active is False
assert schedule.next_fire_at is None
occurrences = session.query(CampaignScheduleOccurrence).all()
assert len(occurrences) == 2
assert len({item.generated_campaign_id for item in occurrences}) == 2
assert session.get(Campaign, "campaign-1").status == "sent"
def test_snapshot_integrity_failure_pauses_schedule_for_operator(self):
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.source_snapshot["configuration"]["campaign"]["name"] = "Tampered"
flag_modified(schedule, "source_snapshot")
session.commit()
with patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
result = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
session.commit()
assert result["failed"] == 1
assert schedule.active is False
assert "integrity" in (schedule.last_error or "")
occurrence = session.query(CampaignScheduleOccurrence).one()
assert occurrence.status == "failed"
def test_monthly_recurrence_clamps_end_of_month(self):
result = next_schedule_fire(
datetime(2026, 1, 31, 9, tzinfo=UTC),
recurrence_kind="monthly",
interval_count=1,
timezone_name="UTC",
)
assert result == datetime(2026, 2, 28, 9, tzinfo=UTC)
def test_occurrence_uses_sealed_policy_state_and_advances_revision(self):
with self.SessionLocal() as session:
source = session.get(Campaign, "campaign-1")
assert source is not None
source.settings = {"retention": "changed-after-scheduling"}
source.mail_profile_policy = {"profile_id": "profile-2"}
session.commit()
with patch(
"govoplan_campaign.backend.campaign.scheduling.create_campaign_version_from_json",
side_effect=lambda *args, **kwargs: _create_generated_campaign(
session,
**kwargs,
),
), patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
result = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
session.commit()
assert result["prepared"] == 1
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
generated = session.get(Campaign, schedule.last_campaign_id)
assert generated is not None
assert generated.settings == {"retention": "sealed"}
assert generated.mail_profile_policy == {"profile_id": "profile-1"}
assert schedule.resource_revision == 2
+52 -1
View File
@@ -78,6 +78,53 @@ def test_complete_review_workflow_documents_each_attention_class() -> None:
assert topic.metadata["help_contexts"] == ["campaign.review-send"]
def test_attachment_reuse_workflow_documents_policy_and_evidence() -> None:
topic = next(
item
for item in CAMPAIGN_USER_DOCUMENTATION
if item.id == "campaigns.workflow.control-attachment-reuse"
)
rendered = "\n".join(
(
topic.summary,
topic.body,
*topic.metadata["steps"],
topic.metadata["verification"],
)
)
assert "allow" in rendered.lower()
assert "warn" in rendered.lower()
assert "review" in rendered.lower()
assert "block" in rendered.lower()
assert "fingerprint" in rendered.lower()
assert "reason" in rendered.lower()
assert topic.metadata["help_contexts"] == [
"campaign.attachments",
"campaign.attachments.reuse-policy",
]
def test_access_evidence_reference_documents_independent_owner_boundaries() -> None:
from govoplan_campaign.backend.manifest import get_manifest
topic = next(
item for item in get_manifest().documentation
if item.id == "campaigns.access.child-evidence"
)
rendered = f"{topic.summary}\n{topic.body}".lower()
for expected in (
"governance visibility",
"mapping profiles",
"validation",
"reusable templates",
"export packages",
"fail closed",
):
assert expected in rendered
assert "campaign.import" in topic.metadata["help_contexts"]
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
assert _topics({"docs:documentation:read"}) == ()
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
@@ -416,7 +463,11 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
"campaign.settings",
"campaign.fields",
"campaign.template",
"campaign.attachments",
"campaign.template.content-library",
"campaigns.action.schedule-drafts",
"campaign.attachments",
"campaign.attachments.reuse-policy",
"campaign.attachments.residual-files",
"campaign.recipients",
"campaign.recipient-data",
"campaign.server-settings",
+3 -1
View File
@@ -19,7 +19,9 @@ def test_operator_queue_is_an_integrated_campaign_view() -> None:
assert "/operator" not in {item.path for item in manifest.frontend.nav_items}
routes = {route.path: route for route in manifest.frontend.routes}
assert "/operator" not in routes
legacy_redirect = routes["/operator"]
assert legacy_redirect.component == "OperatorQueueRedirect"
assert legacy_redirect.surface_id == "campaigns.route.operator-redirect"
queue = routes["/campaigns/queue"]
assert queue.component == "OperatorQueuePage"
assert queue.required_all == ("campaigns:campaign:read",)
+37
View File
@@ -92,6 +92,43 @@ def test_attachment_block_cannot_be_overridden_by_review_decision() -> None:
assert decisions == []
def test_attachment_reuse_review_requires_reason_and_captures_policy() -> None:
job = _job(
issues=[{
"code": "duplicate_attachment_reuse",
"behavior": "ask",
"source": "attachments:reuse_policy",
"details": {
"file_fingerprint": "f" * 64,
"policy": {"action": "review", "allow_within": "none"},
},
}]
)
with pytest.raises(CampaignPersistenceError, match="require an explicit reason"):
_normalize_review_issue_decisions(
[job],
[],
user_id="reviewer-1",
build_token="build-1",
)
decisions = _normalize_review_issue_decisions(
[job],
[{
"job_id": job.id,
"decision": "accept",
"reason": "The shared statutory notice is intentionally identical.",
}],
user_id="reviewer-1",
build_token="build-1",
)
assert decisions[0]["issue_codes"] == ["duplicate_attachment_reuse"]
assert decisions[0]["reason"] == "The shared statutory notice is intentionally identical."
assert len(decisions[0]["issue_fingerprint"]) == 64
def _job(*, issues: list[dict[str, object]]) -> SimpleNamespace:
return SimpleNamespace(
id="job-1",
+4 -1
View File
@@ -9,6 +9,7 @@ from govoplan_campaign.backend.routes.delivery import router as delivery_router
from govoplan_campaign.backend.routes.jobs import router as jobs_router
from govoplan_campaign.backend.routes.operations import router as operations_router
from govoplan_campaign.backend.routes.reports import router as reports_router
from govoplan_campaign.backend.routes.schedules import router as schedules_router
from govoplan_campaign.backend.routes.sharing import router as sharing_router
from govoplan_campaign.backend.routes.versions import router as versions_router
@@ -28,6 +29,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
versions_router,
jobs_router,
reports_router,
schedules_router,
sharing_router,
delivery_router,
attachments_router,
@@ -40,7 +42,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router)
assert actual == expected
assert len(actual) == 72
assert len(actual) == 80
assert not [operation for operation, count in Counter(actual).items() if count > 1]
@@ -54,6 +56,7 @@ def test_key_routes_are_owned_by_their_focused_router() -> None:
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
(schedules_router, ("GET", "/campaigns/{campaign_id}/schedules")),
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
(
+2
View File
@@ -20,6 +20,7 @@ from govoplan_core.core.search import (
SearchBackfillRequest,
SearchResourceReference,
)
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
@@ -34,6 +35,7 @@ class CampaignSearchSourceTests(unittest.TestCase):
Group.__table__,
Campaign.__table__,
CampaignShare.__table__,
ChangeSequenceEntry.__table__,
),
)
self.session = Session(self.engine)
+1 -1
View File
@@ -25,7 +25,7 @@
},
"scripts": {
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js && node tests/content-library-ui-structure.test.mjs",
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
+175 -4
View File
@@ -5,11 +5,15 @@ import {
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export {
fetchResourceAccessExplanation,
fetchResourceAccessExplanationSubjects
} from "@govoplan/core-webui";
export type {
AccessDecisionProvenanceItem,
ResourceAccessExplanationUser as AccessExplanationUser,
ResourceAccessExplanationResponse
ResourceAccessExplanationResponse,
ResourceAccessExplanationSubjectsResponse
} from "@govoplan/core-webui";
export type CampaignListResponse =
@@ -102,6 +106,66 @@ export type CampaignLifecyclePolicy = {
provenance: Record<string, unknown>;
};
export type CampaignCopyOptions = {
name?: string;
external_id?: string;
include_recipients: boolean;
include_files: boolean;
include_shares: boolean;
include_policies: boolean;
include_mail_profile: boolean;
};
export type CampaignScheduleOccurrence = {
id: string;
schedule_id: string;
scheduled_for: string;
status: "prepared" | "failed" | string;
generated_campaign_id?: string | null;
generated_version_id?: string | null;
error?: string | null;
created_at: string;
};
export type CampaignSchedule = {
id: string;
campaign_id: string;
source_version_id: string;
name: string;
recurrence_kind: "once" | "daily" | "weekly" | "monthly";
interval_count: number;
timezone: string;
starts_at: string;
next_fire_at?: string | null;
ends_at?: string | null;
max_occurrences: number;
occurrence_count: number;
active: boolean;
resource_revision: number;
last_fired_at?: string | null;
last_campaign_id?: string | null;
last_error?: string | null;
created_at: string;
updated_at: string;
occurrences: CampaignScheduleOccurrence[];
};
export type CampaignScheduleCreate = {
source_version_id: string;
name: string;
recurrence_kind: CampaignSchedule["recurrence_kind"];
interval_count: number;
timezone: string;
starts_at: string;
ends_at?: string | null;
max_occurrences: number;
include_recipients: boolean;
include_files: boolean;
include_shares: boolean;
include_policies: boolean;
include_mail_profile: boolean;
};
export type CampaignVersionDetail = CampaignVersionListItem & {
raw_json: Record<string, unknown>;
campaign_json?: Record<string, unknown>;
@@ -431,6 +495,57 @@ export type CampaignPrintTemplatesResponse = {
templates: CampaignPrintTemplate[];
};
export type CampaignContentLibraryTarget = "subject" | "text" | "html";
export type CampaignContentLibraryItem = {
id: string;
name: string;
description?: string | null;
template_type: string;
kind: "fragment" | "campaign_part";
status: string;
scope_type: "tenant" | "group" | "user";
scope_id?: string | null;
read_only: boolean;
current_revision: number;
revision: number;
revision_id: string;
locale?: string | null;
published: boolean;
targets: CampaignContentLibraryTarget[];
subject?: string | null;
text?: string | null;
html?: string | null;
body_mode: "text" | "html" | "both";
required_fields: Array<{
path: string;
value_type: "string" | "integer" | "number" | "boolean" | "date" | "datetime" | "object" | "array";
label?: string | null;
required: boolean;
description?: string | null;
}>;
};
export type CampaignContentLibraryResponse = {
available: boolean;
writable: boolean;
reason?: string | null;
items: CampaignContentLibraryItem[];
};
export type CampaignContentLibrarySaveInput = {
name: string;
description?: string | null;
kind: "fragment" | "campaign_part";
target?: CampaignContentLibraryTarget | null;
subject?: string | null;
text?: string | null;
html?: string | null;
body_mode: "text" | "html" | "both";
locale: string;
visibility: "personal" | "tenant";
};
export type CampaignRecipientSnapshotItem = {
contact_id: string;
display_name: string;
@@ -991,6 +1106,26 @@ query = "")
return apiFetch<CampaignPrintTemplatesResponse>(settings, `/api/v1/campaigns/${campaignId}/print-templates${suffix}`);
}
export async function listCampaignContentLibrary(
settings: ApiSettings,
campaignId: string,
query = "")
: Promise<CampaignContentLibraryResponse> {
const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : "";
return apiFetch<CampaignContentLibraryResponse>(settings, `/api/v1/campaigns/${campaignId}/content-library${suffix}`);
}
export async function saveCampaignContentLibraryItem(
settings: ApiSettings,
campaignId: string,
payload: CampaignContentLibrarySaveInput)
: Promise<{template: CampaignContentLibraryItem;}> {
return apiFetch<{template: CampaignContentLibraryItem;}>(settings, `/api/v1/campaigns/${campaignId}/content-library`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function snapshotCampaignRecipientAddressSource(
settings: ApiSettings,
campaignId: string,
@@ -1052,17 +1187,53 @@ export async function copyCampaign(
settings: ApiSettings,
campaignId: string,
sourceVersionId: string,
expectedStateToken: string)
expectedStateToken: string,
options: CampaignCopyOptions)
: Promise<CampaignCreateResponse> {
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/copies`, {
method: "POST",
body: JSON.stringify({
source_version_id: sourceVersionId,
expected_state_token: expectedStateToken
expected_state_token: expectedStateToken,
...options,
name: options.name?.trim() || undefined,
external_id: options.external_id?.trim() || undefined
})
});
}
export async function listCampaignSchedules(
settings: ApiSettings,
campaignId: string)
: Promise<CampaignSchedule[]> {
const response = await apiFetch<{items: CampaignSchedule[]}>(settings, `/api/v1/campaigns/${campaignId}/schedules`);
return response.items;
}
export async function createCampaignSchedule(
settings: ApiSettings,
campaignId: string,
payload: CampaignScheduleCreate)
: Promise<CampaignSchedule> {
return apiFetch<CampaignSchedule>(settings, `/api/v1/campaigns/${campaignId}/schedules`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function setCampaignScheduleState(
settings: ApiSettings,
campaignId: string,
scheduleId: string,
active: boolean,
baseRevision: number)
: Promise<CampaignSchedule> {
return apiFetch<CampaignSchedule>(settings, `/api/v1/campaigns/${campaignId}/schedules/${scheduleId}`, {
method: "PATCH",
body: JSON.stringify({ active, base_revision: baseRevision })
});
}
export async function archiveCampaignVersion(
settings: ApiSettings,
campaignId: string,
@@ -1,12 +1,14 @@
import { MetricGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui";
@@ -56,6 +58,12 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
const reusePolicy = asRecord(attachments.reuse_policy);
const reuseAction = ["allow", "warn", "review", "block"].includes(String(reusePolicy.action)) ? String(reusePolicy.action) : "allow";
const reuseAllowance = ["same_recipient", "same_message"].includes(String(reusePolicy.allow_within)) ? String(reusePolicy.allow_within) : "none";
const residualFiles = asRecord(attachments.residual_files);
const residualMode = ["report", "attach"].includes(String(residualFiles.mode)) ? String(residualFiles.mode) : "none";
const residualRecipient = asRecord(residualFiles.recipient);
const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]);
const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]);
const zipArchiveNameValidation = useMemo(
@@ -96,6 +104,28 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
markDirty();
}
function patchResidualFiles(next: Record<string, unknown>) {
if (locked) return;
patch(["attachments", "residual_files"], {
mode: "none",
recipient: null,
subject: "Unassigned files in campaign {{local:campaign_name}}",
text: "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}",
...residualFiles,
...next
});
}
function patchReusePolicy(next: Record<string, unknown>) {
if (locked) return;
patch(["attachments", "reuse_policy"], {
action: "allow",
allow_within: "none",
...reusePolicy,
...next
});
}
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
}
@@ -229,35 +259,53 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
patchZipCollection({ ...zipConfig, archives: moveArrayItem(zipConfig.archives, index, targetIndex) });
}
function revealAttachmentSection(id: string) {
const section = document.getElementById(id);
if (!section) return;
section.querySelector<HTMLButtonElement>('.card-collapse-toggle[aria-expanded="false"]')?.click();
window.requestAnimationFrame(() => {
section.scrollIntoView({ behavior: "smooth", block: "start" });
section.focus({ preventScroll: true });
});
}
function openRecipients() {
const query = version?.id ? `?version=${encodeURIComponent(version.id)}` : "";
navigate(`/campaigns/${campaignId}/recipients${query}`);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.attachments.6771ade6</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<PageLayout
archetype="editor"
mode="workspace"
title="i18n:govoplan-campaign.attachments.6771ade6"
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
headerLoading={loading}
error={error}
actions={<PageActionBar
variant="editor"
state={loading ? "saving" : dirty ? "dirty" : "clean"}
contextActions={filesModuleInstalled ? <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button> : undefined}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current editor blocker before saving." : undefined }}
/>}
notices={(localError || locked) ? <>
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
</> : undefined}
>
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<div className="metric-grid attachment-statistics-grid">
<MetricCard label="i18n:govoplan-campaign.base_paths.edf35a97" value={basePaths.length} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.global_attachments.438263a1" value={`${globalSummary.direct} / ${globalSummary.rules}`} tone="info" detail="direct / rules" />
<MetricCard label="i18n:govoplan-campaign.per_recipient_patterns.53da30da" value={individualRulesCount} tone="neutral" />
<MetricGrid>
<MetricCard label="i18n:govoplan-campaign.base_paths.edf35a97" value={basePaths.length} tone="neutral" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Attachment sources" }), onActivate: () => revealAttachmentSection("campaign-attachment-sources") }} />
<MetricCard label="i18n:govoplan-campaign.global_attachments.438263a1" value={`${globalSummary.direct} / ${globalSummary.rules}`} tone="info" detail="direct / rules" drilldown={{ label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealAttachmentSection("campaign-global-attachments") }} />
<MetricCard label="i18n:govoplan-campaign.per_recipient_patterns.53da30da" value={individualRulesCount} tone="neutral" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Recipients" }), onActivate: openRecipients }} />
<MetricCard label="i18n:govoplan-campaign.upload_support.1c54931c" value={managedFilesAvailable ? "Files" : "Manual"} tone={managedFilesAvailable ? "good" : filesModuleInstalled ? "warning" : "neutral"} />
</div>
</MetricGrid>
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
<Card id="campaign-attachment-sources" tabIndex={-1} title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
<div className="admin-table-surface attachment-sources-table-surface">
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
@@ -271,6 +319,71 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
</div>
</Card>
<Card title="Attachment reuse policy" collapsible>
<div className="campaign-residual-file-form">
<FormField label="Duplicate-file action" help="Applied when the same resolved source file appears more than once outside the configured allowance.">
<select value={reuseAction} disabled={locked} onChange={(event) => patchReusePolicy({ action: event.target.value })}>
<option value="allow">Allow and record</option>
<option value="warn">Allow with warning</option>
<option value="review">Require explicit review</option>
<option value="block">Block delivery</option>
</select>
</FormField>
<FormField label="Allowed reuse boundary" help="A boundary is an exception to the selected action. No exception applies the action to every repeated use.">
<select value={reuseAllowance} disabled={locked} onChange={(event) => patchReusePolicy({ allow_within: event.target.value })}>
<option value="none">No exception</option>
<option value="same_recipient">Within the same recipient</option>
<option value="same_message">Within the same built message</option>
</select>
</FormField>
</div>
<DismissibleAlert tone={reuseAction === "block" ? "warning" : "info"} dismissible={false} compact>
Every repeated-file finding is retained in the build protocol. Review requires a reason bound to the exact build; Block prevents affected messages from being queued.
</DismissibleAlert>
</Card>
<Card title="Unassigned file disposition" collapsible>
<div className="campaign-residual-file-form">
<FormField label="Action" help="Only sources with Unsent enabled are inspected. The existing warning policy remains active when no disposition is selected.">
<select value={residualMode} disabled={locked} onChange={(event) => {
const mode = event.target.value;
patchResidualFiles({
mode,
recipient: mode === "none" ? null : {
email: String(residualRecipient.email ?? ""),
name: String(residualRecipient.name ?? "") || null
}
});
}}>
<option value="none">Apply warning policy only</option>
<option value="report">Prepare report message</option>
<option value="attach">Prepare report with files attached</option>
</select>
</FormField>
{residualMode !== "none" && <>
<FormField label="Recipient email">
<input type="email" value={String(residualRecipient.email ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ recipient: { ...residualRecipient, email: event.target.value } })} />
</FormField>
<FormField label="Recipient name">
<input value={String(residualRecipient.name ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ recipient: { ...residualRecipient, name: event.target.value || null } })} />
</FormField>
<div className="campaign-residual-file-wide">
<FormField label="Subject">
<input value={String(residualFiles.subject ?? "Unassigned files in campaign {{local:campaign_name}}") } disabled={locked} onChange={(event) => patchResidualFiles({ subject: event.target.value })} />
</FormField>
</div>
<div className="campaign-residual-file-wide">
<FormField label="Report text" help="Available values: {{local:campaign_name}}, {{local:residual_file_count}}, and {{local:residual_file_list}}.">
<textarea rows={5} value={String(residualFiles.text ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ text: event.target.value })} />
</FormField>
</div>
</>}
</div>
{residualMode !== "none" && <DismissibleAlert tone="info" dismissible={false} compact>
A residual-file message becomes a separate campaign row that always needs review. It uses the normal build, approval, queue, delivery, report, and audit lifecycle; saving this setting never sends it.
</DismissibleAlert>}
</Card>
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
<div className="attachment-zip-master-toggle">
<ToggleSwitch
@@ -310,7 +423,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
}
</Card>
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
<Card id="campaign-global-attachments" tabIndex={-1} title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
<AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`}
rules={globalRules}
@@ -394,7 +507,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
onConfirm={confirmIndividualDisable}
onCancel={() => setIndividualDisable(null)} />
</div>);
</PageLayout>);
}
@@ -1,11 +1,8 @@
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine";
import { LoadingFrame } from "@govoplan/core-webui";
import { ActionBlockerHint, DocumentationHelpLink } from "@govoplan/core-webui";
import { ActionBlockerHint, DocumentationHelpLink, PageActionBar, PageLayout } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
@@ -13,20 +10,20 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
const version = data.currentVersion;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.audit_log.3cfc5f1c</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => void reload({ force: true })} disabled={loading}>Discard</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
<PageLayout
archetype="detail"
mode="workspace"
title="i18n:govoplan-campaign.audit_log.3cfc5f1c"
description={<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />}
loading={loading}
loadingLabel="i18n:govoplan-campaign.loading_audit_data.af52b968"
error={error}
actions={<PageActionBar
variant="detail"
refreshable
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
/>}
>
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
<ActionBlockerHint
tone="info"
@@ -50,7 +47,6 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
label="Open Campaign assurance documentation"
/>
</Card>
</LoadingFrame>
</div>);
</PageLayout>);
}
@@ -1,7 +1,7 @@
import { useMemo, useRef } from "react";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
@@ -150,22 +150,25 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.fields.e8b68527</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
<Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<PageLayout
archetype="editor"
mode="workspace"
title="i18n:govoplan-campaign.fields.e8b68527"
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
headerLoading={loading}
error={error}
actions={<PageActionBar
variant="editor"
state={loading ? "saving" : dirty ? "dirty" : "clean"}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: saveFields, disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current field-definition blocker before saving." : undefined }}
/>}
notices={(localError || fieldNameWarning || locked) ? <>
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
</> : undefined}
>
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
@@ -184,7 +187,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
</Card>
</>
</LoadingFrame>
</div>);
</PageLayout>);
}
@@ -1,10 +1,8 @@
import type { ApiSettings } from "../../types";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { DismissibleAlert, PageActionBar, PageLayout } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine";
import { LoadingFrame } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, getCampaignJson } from "./utils/campaignView";
import { downloadJson, safeFileStem } from "./utils/draftEditor";
@@ -17,28 +15,31 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.json.031a4e76</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => void reload({ force: true })} disabled={loading}>Discard</Button>
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<DismissibleAlert tone="warning" dismissible={false}>
This expert view contains the complete authorized campaign configuration,
including recipient and message fields that may contain personal data.
Download and share it only for an authorized purpose.
</DismissibleAlert>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50">
<PageLayout
archetype="detail"
mode="workspace"
title="i18n:govoplan-campaign.json.031a4e76"
description={<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />}
loading={loading}
loadingLabel="i18n:govoplan-campaign.loading_json.812c7a50"
error={error}
actions={<PageActionBar
variant="detail"
refreshable
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
primaryActions={<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>}
/>}
notices={
<DismissibleAlert tone="warning" dismissible={false}>
This expert view contains the complete authorized campaign configuration,
including recipient and message fields that may contain personal data.
Download and share it only for an authorized purpose.
</DismissibleAlert>
}
>
<Card>
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
</Card>
</LoadingFrame>
</div>);
</PageLayout>);
}
@@ -6,10 +6,8 @@ import type { ApiSettings } from "../../types";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { PageScrollViewport } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { PageActionBar, PageLayout, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
@@ -144,22 +142,23 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
return (
<PageScrollViewport>
<div className="content-pad workspace-data-page campaigns-page">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.all_campaigns.2bd1ee3a</PageTitle>
<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => void load(null)} disabled={loading}>Discard</Button>
<Button variant="primary" onClick={create} disabled={creating}>
<PageLayout
archetype="collection"
mode="workspace"
className="campaigns-page"
title="i18n:govoplan-campaign.all_campaigns.2bd1ee3a"
description={<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>}
headerLoading={loading}
error={error}
actions={<PageActionBar
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(null), loading }}
createAction={<Button variant="primary" onClick={create} disabled={creating}>
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
</Button>
</div>
</div>
</Button>}
/>}
>
<Card title="i18n:govoplan-campaign.campaigns.01a23a28">
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaigns.90a466bb">
@@ -186,8 +185,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
}
</LoadingFrame>
</Card>
</div>
</PageScrollViewport>);
</PageLayout>);
}
@@ -1,6 +1,7 @@
import { lazy } from "react";
import {
ModuleSubnav,
WorkspaceLayout,
hasAnyScope,
useGuardedNavigate,
type ApiSettings,
@@ -64,15 +65,19 @@ export default function CampaignModulePage({
}
return (
<div className="workspace campaign-module-workspace">
<ModuleSubnav active={active} groups={groups} onSelect={select} />
<section className="workspace-content">
<WorkspaceLayout
className="campaign-module-workspace"
primary={<ModuleSubnav active={active} groups={groups} onSelect={select} />}
primaryLabel="Campaign module sections"
contentLabel="Campaign module content"
interfaceId="campaign.module-workspace"
helpModuleId="campaign"
>
{active === "queue"
? <OperatorQueuePage settings={settings} auth={auth} />
: active === "reports"
? <AggregateReportsPage settings={settings} />
: <CampaignListPage settings={settings} />}
</section>
</div>
</WorkspaceLayout>
);
}
@@ -1,14 +1,16 @@
import { MetricGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react";
import { Archive, Copy, ExternalLink, LockKeyhole, LockOpen, Trash2 } from "lucide-react";
import { Archive, CalendarClock, Copy, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react";
import { Link } from "react-router";
import type { ApiSettings, AuthInfo } from "../../types";
import { Button } from "@govoplan/core-webui";
import { FormGrid, Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { PageActionBar, PageLayout } 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";
@@ -17,13 +19,19 @@ import {
archiveCampaign,
archiveCampaignVersion,
copyCampaign,
createCampaignSchedule,
deleteCampaign,
getCampaignLifecyclePolicy,
lockCampaignVersionPermanently,
lockCampaignVersionTemporarily,
unlockCampaignVersionUserLock,
listCampaignSchedules,
setCampaignScheduleState,
updateCampaignMetadata,
type CampaignSchedule,
type CampaignScheduleCreate,
type CampaignLifecyclePolicy,
type CampaignCopyOptions,
type CampaignVersionDetail,
type CampaignVersionListItem } from
"../../api/campaigns";
@@ -51,6 +59,34 @@ type PendingLifecycleAction = {
policy: CampaignLifecyclePolicy;
version?: CampaignVersionListItem;
} | null;
const defaultCopyOptions: CampaignCopyOptions = {
name: "",
external_id: "",
include_recipients: true,
include_files: true,
include_shares: false,
include_policies: true,
include_mail_profile: true
};
function defaultScheduleDraft(): CampaignScheduleCreate {
const start = new Date(Date.now() + 60 * 60 * 1000);
start.setSeconds(0, 0);
return {
source_version_id: "",
name: "",
recurrence_kind: "once",
interval_count: 1,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
starts_at: localDateTimeValue(start),
max_occurrences: 1,
include_recipients: true,
include_files: true,
include_shares: false,
include_policies: true,
include_mail_profile: true
};
}
export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
const navigate = useGuardedNavigate();
@@ -65,12 +101,31 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
const [lockBusy, setLockBusy] = useState(false);
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
const [copyOptions, setCopyOptions] = useState<CampaignCopyOptions>(defaultCopyOptions);
const [lifecycleBusy, setLifecycleBusy] = useState(false);
const [message, setMessage] = useState("");
const [schedules, setSchedules] = useState<CampaignSchedule[]>([]);
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
const [scheduleDraft, setScheduleDraft] = useState<CampaignScheduleCreate>(defaultScheduleDraft);
const [scheduleBusy, setScheduleBusy] = useState(false);
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");
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
function openSection(section: string, fragment = "") {
const params = new URLSearchParams();
if (data.currentVersion?.id) params.set("version", data.currentVersion.id);
const query = params.toString();
navigate(`/campaigns/${campaignId}/${section}${query ? `?${query}` : ""}${fragment}`);
}
function openQueue() {
const params = new URLSearchParams({ campaign: campaignId });
if (data.currentVersion?.id) params.set("version", data.currentVersion.id);
navigate(`/campaigns/queue?${params.toString()}`);
}
useUnsavedDraftGuard({
dirty: identityDirty,
@@ -97,6 +152,20 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
});
}, [campaign, identityDirty]);
useEffect(() => {
if (!campaign?.id) {
setSchedules([]);
return;
}
let active = true;
void listCampaignSchedules(settings, campaign.id).then((items) => {
if (active) setSchedules(items);
}).catch((err) => {
if (active) setError(err instanceof Error ? err.message : String(err));
});
return () => { active = false; };
}, [campaign?.id, settings, setError]);
function patchIdentity(key: keyof typeof identity, value: string) {
setIdentity((current) => ({ ...current, [key]: value }));
setIdentityDirty(true);
@@ -177,6 +246,13 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
setError(decision?.reason || "This lifecycle action is not available for the current campaign state.");
return;
}
if (action === "copy_campaign") {
setCopyOptions({
...defaultCopyOptions,
name: `${campaign.name} (copy)`,
include_recipients: hasScope(auth, "campaigns:recipient:read")
});
}
setPendingLifecycleAction({ action, policy, version });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -201,7 +277,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
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);
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token, copyOptions);
setPendingLifecycleAction(null);
navigate(`/campaigns/${created.campaign.id}`);
return;
@@ -218,14 +294,70 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
}
}
function openScheduleDialog() {
if (!campaign || !data.currentVersion) return;
setScheduleDraft({
...defaultScheduleDraft(),
source_version_id: data.currentVersion.id,
name: campaign.name,
include_recipients: hasScope(auth, "campaigns:recipient:read")
});
setScheduleDialogOpen(true);
}
async function submitSchedule() {
if (!campaign || scheduleBusy) return;
setScheduleBusy(true);
setError("");
try {
const created = await createCampaignSchedule(settings, campaign.id, {
...scheduleDraft,
starts_at: new Date(scheduleDraft.starts_at).toISOString(),
max_occurrences: scheduleDraft.recurrence_kind === "once" ? 1 : scheduleDraft.max_occurrences
});
setSchedules((current) => [created, ...current]);
setScheduleDialogOpen(false);
setMessage("Campaign schedule created. Each occurrence prepares a fresh draft for review; it does not send automatically.");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setScheduleBusy(false);
}
}
async function toggleSchedule(schedule: CampaignSchedule) {
if (!campaign || scheduleBusy) return;
setScheduleBusy(true);
setError("");
try {
const updated = await setCampaignScheduleState(
settings,
campaign.id,
schedule.id,
!schedule.active,
schedule.resource_revision
);
setSchedules((current) => current.map((item) => item.id === updated.id ? updated : item));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setScheduleBusy(false);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{campaign?.name || "i18n:govoplan-campaign.overview.0efc2e6b"}</PageTitle>
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
</div>
<div className="button-row compact-actions">
<PageLayout
archetype="editor"
mode="workspace"
title={campaign?.name || "i18n:govoplan-campaign.overview.0efc2e6b"}
description={<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>}
headerLoading={loading}
error={error}
success={message}
actions={<PageActionBar
variant="editor"
state={savingIdentity ? "saving" : identityDirty ? "dirty" : "clean"}
primaryActions={<>
{canCopy && data.currentVersion && <Button
onClick={() => void prepareLifecycleAction("copy_campaign", data.currentVersion ?? undefined)}
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
@@ -233,6 +365,15 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
<Copy size={16} aria-hidden="true" />
Copy campaign
</Button>}
{canSchedule && <Button
onClick={openScheduleDialog}
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
disabledReason={identityDirty ? "Save or discard overview changes before scheduling." : undefined}>
<CalendarClock size={16} aria-hidden="true" />
Schedule
</Button>}
</>}
destructiveActions={<>
{canDelete && <Button
variant="danger"
onClick={() => void prepareLifecycleAction("delete_campaign")}
@@ -249,27 +390,25 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
<Archive size={16} aria-hidden="true" />
i18n:govoplan-campaign.archive_campaign.26dcfb8a
</Button>}
<Button onClick={() => void discardOverview()} disabled={loading || savingIdentity || lockBusy}>Discard</Button>
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
</>}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardOverview(), disabled: loading || lockBusy }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => void saveIdentity(), disabled: !campaign && identityDirty, disabledReason: !campaign && identityDirty ? "The campaign identity is not available." : undefined }}
/>}
>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
<div className="metric-grid campaign-overview-metrics">
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" />
<MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
</div>
<MetricGrid>
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" drilldown={{ label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => openSection("review", "#workflow-send") }} />
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" drilldown={{ label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => openSection("review", "#workflow-build-review") }} />
<MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" drilldown={{ label: "i18n:govoplan-campaign.open_report.44f83158", onActivate: () => openSection("report") }} />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" drilldown={{ label: "i18n:govoplan-campaign.open_report.44f83158", onActivate: () => openSection("report") }} />
</MetricGrid>
<Card
title="i18n:govoplan-campaign.campaign_identity.a00ca574"
collapsible
actions={<Link className="btn btn-secondary" to="wizard">i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Link>}>
<div className="form-grid campaign-identity-grid">
<FormGrid columns={1} collapseAt="standard" className="campaign-identity-grid">
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
</FormField>
@@ -284,9 +423,40 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
</FormField>
</div>
</FormGrid>
</Card>
{(canSchedule || schedules.length > 0) && <Card
title="Schedules"
collapsible
actions={canSchedule ? <Button onClick={openScheduleDialog}>
<CalendarClock size={16} aria-hidden="true" />
Add schedule
</Button> : undefined}>
<p className="muted small-note">Due occurrences prepare independent campaign drafts. Validation, review, approval, and delivery are never started by the schedule.</p>
{schedules.length === 0 ? <p className="muted">No schedules configured.</p> : <div className="campaign-schedule-list">
{schedules.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
<div className="campaign-schedule-main">
<strong>{schedule.name}</strong>
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} prepared</span>
{schedule.next_fire_at && <span>Next: {formatDateTime(schedule.next_fire_at)}</span>}
{schedule.last_error && <span className="danger-text">Paused: {schedule.last_error}</span>}
</div>
<div className="button-row compact-actions">
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>Open latest draft</Link>}
{schedule.next_fire_at && <Button
iconOnly
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
title={schedule.active ? "Pause schedule" : "Resume schedule"}
disabled={scheduleBusy || !canSchedule}
onClick={() => void toggleSchedule(schedule)}>
{schedule.active ? <Pause size={16} aria-hidden="true" /> : <Play size={16} aria-hidden="true" />}
</Button>}
</div>
</div>)}
</div>}
</Card>}
<Card title="Versions" collapsible actions={<div className="button-row compact-actions">
{archivedVersionCount > 0 && <ToggleSwitch
label={`Show archived (${archivedVersionCount})`}
@@ -300,18 +470,18 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
i18n:govoplan-campaign.open.cf9b7706
</Link>
</div>}>
<div className="metric-grid inside campaign-versions-metrics">
<MetricGrid spacing="inset">
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
</div>
<div className="metric-grid inside">
<MetricCard label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<MetricCard label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<MetricCard label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
</div>
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Fields" }), onActivate: () => openSection("fields") }} />
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Recipients" }), onActivate: () => openSection("recipients") }} />
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} drilldown={{ label: "i18n:govoplan-campaign.open_template_editor.1739b545", onActivate: () => openSection("template") }} />
</MetricGrid>
<MetricGrid spacing="inset">
<MetricCard label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} drilldown={{ label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => openSection("review", "#workflow-validate-review") }} />
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} drilldown={{ label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => openSection("review", "#workflow-validate-review") }} />
<MetricCard label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} drilldown={{ label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => openSection("review", "#workflow-build-review") }} />
<MetricCard label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: campaign?.name ?? campaignId }), onActivate: openQueue }} />
</MetricGrid>
<div className="admin-table-surface version-history-table-surface">
<DataGrid
id={`campaign-${campaignId}-versions`}
@@ -335,7 +505,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
</LoadingFrame>
<ConfirmDialog
open={Boolean(pendingLifecycleAction)}
open={Boolean(pendingLifecycleAction && pendingLifecycleAction.action !== "copy_campaign")}
title={lifecycleDialogTitle(pendingLifecycleAction)}
message={lifecycleDialogMessage(pendingLifecycleAction)}
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
@@ -344,6 +514,119 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
onCancel={() => setPendingLifecycleAction(null)}
onConfirm={() => void applyLifecycleAction()} />
<Dialog
open={pendingLifecycleAction?.action === "copy_campaign"}
title="Copy campaign"
className="campaign-copy-dialog"
helpContextId="campaigns.action.copy-campaign"
closeDisabled={lifecycleBusy}
onClose={() => setPendingLifecycleAction(null)}
footer={<>
<Button onClick={() => setPendingLifecycleAction(null)} disabled={lifecycleBusy}>Cancel</Button>
<Button variant="primary" onClick={() => void applyLifecycleAction()} disabled={lifecycleBusy || !copyOptions.name?.trim()}>
{lifecycleBusy ? "Creating copy..." : "Create copy"}
</Button>
</>}>
<div className="campaign-copy-form">
<p className="muted small-note">
Create a new draft from version #{pendingLifecycleAction?.version?.version_number ?? "?"}. Delivery jobs, outcomes, locks, reports, and audit evidence are never copied.
</p>
<div className="campaign-copy-identity">
<FormField label="Campaign name" help="The new campaign receives an independent identity and version history.">
<input value={copyOptions.name ?? ""} onChange={(event) => setCopyOptions((current) => ({ ...current, name: event.target.value }))} />
</FormField>
<FormField label="Campaign ID" help="Leave blank to generate a unique ID from the source campaign.">
<input value={copyOptions.external_id ?? ""} onChange={(event) => setCopyOptions((current) => ({ ...current, external_id: event.target.value }))} placeholder="Generated automatically" />
</FormField>
</div>
<div className="campaign-copy-options">
<CopyOption
label="Recipients"
detail="Global address headers, recipient rows, imported audience provenance, and per-recipient values."
checked={copyOptions.include_recipients}
disabled={!hasScope(auth, "campaigns:recipient:read")}
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_recipients: checked }))} />
<CopyOption
label="Files"
detail="Campaign and recipient attachment rules. Managed files remain owned by Files and are referenced, not duplicated."
checked={copyOptions.include_files}
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_files: checked }))} />
<CopyOption
label="Shares"
detail="Current active user and group shares. Ownership always starts with the account creating the copy."
checked={copyOptions.include_shares}
disabled={!hasScope(auth, "campaigns:campaign:share")}
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_shares: checked }))} />
<CopyOption
label="Policies"
detail="Validation behavior and campaign settings."
checked={copyOptions.include_policies}
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_policies: checked }))} />
<CopyOption
label="Mail profile"
detail="References to the reusable Mail profile, its campaign policy, server selections, and credential selections. Secrets are never copied into Campaign."
checked={copyOptions.include_mail_profile}
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_mail_profile: checked }))} />
</div>
</div>
</Dialog>
<Dialog
open={scheduleDialogOpen}
title="Schedule campaign drafts"
className="campaign-schedule-dialog"
helpContextId="campaigns.action.schedule-drafts"
closeDisabled={scheduleBusy}
onClose={() => setScheduleDialogOpen(false)}
footer={<>
<Button onClick={() => setScheduleDialogOpen(false)} disabled={scheduleBusy}>Cancel</Button>
<Button variant="primary" onClick={() => void submitSchedule()} disabled={scheduleBusy || !scheduleDraft.name.trim() || !scheduleDraft.starts_at}>
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
</Button>
</>}>
<DismissibleAlert tone="info" resetKey="campaign-schedule-safety">
A schedule creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically.
</DismissibleAlert>
<div className="campaign-schedule-form">
<FormField label="Draft name">
<input value={scheduleDraft.name} onChange={(event) => setScheduleDraft((current) => ({ ...current, name: event.target.value }))} />
</FormField>
<FormField label="First occurrence">
<input type="datetime-local" value={scheduleDraft.starts_at} onChange={(event) => setScheduleDraft((current) => ({ ...current, starts_at: event.target.value }))} />
</FormField>
<FormField label="Recurrence">
<select value={scheduleDraft.recurrence_kind} onChange={(event) => {
const recurrence_kind = event.target.value as CampaignSchedule["recurrence_kind"];
setScheduleDraft((current) => ({ ...current, recurrence_kind, max_occurrences: recurrence_kind === "once" ? 1 : Math.max(2, current.max_occurrences) }));
}}>
<option value="once">Once</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</FormField>
{scheduleDraft.recurrence_kind !== "once" && <FormField label="Every">
<div className="campaign-schedule-interval">
<input type="number" min={1} max={365} value={scheduleDraft.interval_count} onChange={(event) => setScheduleDraft((current) => ({ ...current, interval_count: Math.max(1, Number(event.target.value) || 1) }))} />
<span>{scheduleDraft.recurrence_kind.replace("ly", "")} interval(s)</span>
</div>
</FormField>}
{scheduleDraft.recurrence_kind !== "once" && <FormField label="Maximum occurrences" help="Recurring schedules are bounded. Create a new schedule if the approved plan changes.">
<input type="number" min={2} max={1000} value={scheduleDraft.max_occurrences} onChange={(event) => setScheduleDraft((current) => ({ ...current, max_occurrences: Math.max(2, Number(event.target.value) || 2) }))} />
</FormField>}
<FormField label="Timezone">
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
</FormField>
</div>
<div className="campaign-copy-options">
<CopyOption label="Recipients" detail="Recipient rows and values used to prepare each draft." checked={scheduleDraft.include_recipients} disabled={!hasScope(auth, "campaigns:recipient:read")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_recipients: checked }))} />
<CopyOption label="Files" detail="Attachment rules and Files references; generated evidence is never copied." checked={scheduleDraft.include_files} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_files: checked }))} />
<CopyOption label="Shares" detail="Current active shares are sealed when the schedule is created." checked={scheduleDraft.include_shares} disabled={!hasScope(auth, "campaigns:campaign:share")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_shares: checked }))} />
<CopyOption label="Policies" detail="Campaign and validation policy configuration." checked={scheduleDraft.include_policies} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_policies: checked }))} />
<CopyOption label="Mail profile" detail="Reusable Mail profile references; credentials remain Mail-owned." checked={scheduleDraft.include_mail_profile} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_mail_profile: checked }))} />
</div>
</Dialog>
<ConfirmDialog
open={Boolean(pendingLockAction)}
title={lockDialogTitle(pendingLockAction)}
@@ -354,10 +637,46 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
onCancel={() => setPendingLockAction(null)}
onConfirm={() => void applyLockAction()} />
</div>);
</PageLayout>);
}
function localDateTimeValue(value: Date): string {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function scheduleCadence(schedule: CampaignSchedule): string {
if (schedule.recurrence_kind === "once") return "One time";
const unit = { daily: "day", weekly: "week", monthly: "month" }[schedule.recurrence_kind];
return schedule.interval_count === 1 ? schedule.recurrence_kind : `Every ${schedule.interval_count} ${unit}s`;
}
function CopyOption({
label,
detail,
checked,
disabled = false,
onChange
}: {
label: string;
detail: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return <div className={`campaign-copy-option ${disabled ? "is-disabled" : ""}`}>
<div><strong>{label}</strong><small>{detail}</small></div>
<ToggleSwitch
label={`Copy ${label.toLowerCase()}`}
inactiveLabel="Exclude"
activeLabel="Include"
checked={checked}
disabled={disabled}
onChange={onChange} />
</div>;
}
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
type CampaignVersionMetrics = {
@@ -1,3 +1,4 @@
import { DescriptionList } from "@govoplan/core-webui";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, RotateCcw, Search, X } from "lucide-react";
import type { ApiSettings } from "../../types";
@@ -14,14 +15,14 @@ import {
type CampaignJobsResponse,
type CampaignRetentionReport } from
"../../api/campaigns";
import { Card } from "@govoplan/core-webui";
import { ContentGrid, Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine";
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
@@ -445,24 +446,28 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
[busyAction, retryFailedSynchronously]);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.report.ee45c303</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
</div>
<div className="button-row compact-actions">
<PageLayout
archetype="detail"
mode="workspace"
title="i18n:govoplan-campaign.report.ee45c303"
description={<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />}
headerLoading={loading}
error={error || actionError}
success={actionMessage}
actions={<PageActionBar
variant="detail"
refreshable
reloadAction={{ onReload: () => void reloadAll(), loading: loading || jobsLoading }}
primaryActions={<>
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Discard</Button>
</div>
</div>
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
{actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
</>}
/>}
>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_report_data.0908ade5">
<div className="dashboard-grid">
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
<dl className="detail-list">
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
{deliveryOutcomeShortcuts.map(({ label, value, shortcutId }) => {
const active = activeJobGridShortcut === shortcutId;
@@ -477,10 +482,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
</div>
);
})}
</dl>
</DescriptionList>
</Card>
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
<dl className="detail-list">
<DescriptionList variant="inline">
{imapOutcomeShortcuts.map(({ label, value, shortcutId }) => {
const active = activeJobGridShortcut === shortcutId;
return (
@@ -498,10 +503,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.execution_snapshot.5a67f098</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}</dd></div>
</dl>
</DescriptionList>
</Card>
<Card title="Postbox receipt evidence">
<dl className="detail-list">
<DescriptionList variant="inline">
<div>
<dt>Evidence state</dt>
<dd>
@@ -522,7 +527,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>Read</dt><dd>{String(postboxReceipts.read_delivery_count ?? "—")}</dd></div>
<div><dt>Acknowledged</dt><dd>{String(postboxReceipts.acknowledged_delivery_count ?? "—")}</dd></div>
<div><dt>Withdrawn / expired copies</dt><dd>{String(Number(postboxReceipts.withdrawn_message_count ?? 0) + Number(postboxReceipts.expired_message_count ?? 0))}</dd></div>
</dl>
</DescriptionList>
</Card>
<RetentionPrivacyCard retention={retention} />
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
@@ -535,7 +540,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
</div>
</Card>
</div>
</ContentGrid>
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
<p className="muted small-note">
@@ -606,7 +611,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{detail &&
<div className="stacked-sections">
<dl className="detail-list">
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
@@ -618,7 +623,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
<div><dt>Message SHA-256</dt><dd><code>{String(detail.job.eml_sha256 ?? "—")}</code></dd></div>
</dl>
</DescriptionList>
<PostboxTargetEvidenceSection
targets={
Array.isArray(detail.job.resolved_postbox_targets)
@@ -647,7 +652,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
onConfirm={() => void reconcileOutcome()}
onCancel={() => setReconcile(null)} />
</div>);
</PageLayout>);
}
@@ -670,7 +675,7 @@ function RetentionPrivacyCard({ retention }: { retention?: CampaignRetentionRepo
<Card title="i18n:govoplan-campaign.retention_and_privacy.24418676">
<p>{retention.privacy_impact.summary}</p>
{retention.policy_reason && <p className="muted">{retention.policy_reason}</p>}
<dl className="detail-list">
<DescriptionList variant="inline">
<div>
<dt>i18n:govoplan-campaign.policy_state.7e955a0d</dt>
<dd>
@@ -712,7 +717,7 @@ function RetentionPrivacyCard({ retention }: { retention?: CampaignRetentionRepo
</dd>
</div>
)}
</dl>
</DescriptionList>
</Card>
);
}
@@ -2,6 +2,7 @@ import { lazy, useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router";
import {
ConcurrencyConflictProvider,
WorkspaceLayout,
useGuardedNavigate
} from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
@@ -85,9 +86,13 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
}
return (
<div className="workspace">
<SectionSidebar active={active} onSelect={select} />
<section className="workspace-content">
<WorkspaceLayout
primary={<SectionSidebar active={active} onSelect={select} />}
primaryLabel="Campaign sections"
contentLabel="Campaign workspace"
interfaceId="campaign.workspace"
helpModuleId="campaign"
>
<Routes>
<Route index element={<CampaignOverviewPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="data" element={<Navigate to="../recipients" replace />} />
@@ -121,8 +126,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
<Route path="Review" element={<Navigate to="../" replace />} />
<Route path="*" element={<Navigate to="../" replace />} />
</Routes>
</section>
</div>
</WorkspaceLayout>
);
}
@@ -6,11 +6,11 @@ import {
type CampaignCalendarCatalog,
type CampaignPostboxCatalog
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { FormGrid, ContentGrid, Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { PolicyRow } from "@govoplan/core-webui";
import { PolicyTable } from "@govoplan/core-webui";
@@ -178,21 +178,24 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{pageTitle}</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<PageLayout
archetype="editor"
mode="workspace"
title={pageTitle}
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
headerLoading={loading}
error={error}
actions={<PageActionBar
variant="editor"
state={loading ? "saving" : dirty ? "dirty" : "clean"}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
/>}
notices={(localError || locked) ? <>
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
</> : undefined}
>
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
{isPolicyView ?
@@ -209,7 +212,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
}
<div className="dashboard-grid below-grid">
<ContentGrid columns={2} collapseAt="workspace" className="below-grid">
<Card title="i18n:govoplan-campaign.validation_policy.57dcc756" collapsible>
<PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
<PolicyRow
@@ -319,20 +322,20 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
</PolicyTable>
</Card>
</div>
</ContentGrid>
</> :
<>
{data.campaign && <CampaignAccessCard settings={settings} auth={auth} campaign={data.campaign} onChanged={reload} onError={setError} />}
<div className="dashboard-grid below-grid">
<ContentGrid columns={2} collapseAt="workspace" className="below-grid">
<Card title="i18n:govoplan-campaign.delivery_defaults.33cc3b97" collapsible>
<div className="form-grid compact responsive-form-grid">
<FormGrid columns={2} collapseAt="wide" className="">
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div>
</FormGrid>
{postboxModuleInstalled &&
<div className="campaign-postbox-delivery-settings">
<FormField label="Delivery channels">
@@ -373,7 +376,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
</Card>
<Card title="Calendar invitations" collapsible>
<div className="form-grid compact responsive-form-grid">
<FormGrid columns={2} collapseAt="wide" className="">
<ToggleSwitch
label="Send individualized invitations"
checked={getBool(calendarInvitation, "enabled")}
@@ -452,7 +455,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
onChange={(event) => patch(["delivery", "calendar_invitation", "description_template"], event.target.value)}
/>
</FormField>
</div>
</FormGrid>
{!mailModuleInstalled &&
<DismissibleAlert tone="warning" dismissible={false}>
Mail must be active to deliver iCalendar invitations.
@@ -481,7 +484,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
</div>
<p className="muted small-note">i18n:govoplan-campaign.these_opt_ins_are_stored_in_the_draft_editor_met.dc6ffbfd</p>
</Card>
</div>
</ContentGrid>
</>
}
</LoadingFrame>
@@ -500,7 +503,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
onClose={() => setPostboxTargetsOpen(false)}
/>
}
</div>);
</PageLayout>);
}
@@ -1,5 +1,6 @@
import { MetricGrid } from "@govoplan/core-webui";
import { useEffect, useState } from "react";
import {
import { FormGrid,
Button,
Card,
Dialog,
@@ -8,7 +9,8 @@ import {
LoadingFrame,
MailServerFolderLookupResultView,
MetricCard,
PageTitle,
PageActionBar,
PageLayout,
PasswordField,
ToggleSwitch,
usePlatformModuleInstalled,
@@ -326,21 +328,32 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
{!isPolicyView && <Button variant="primary" onClick={() => void saveDraft("manual")} disabled={!canSave}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
<Button onClick={() => void discardDraft()} disabled={loading}>i18n:govoplan-campaign.discard.36fff63c</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<PageLayout
archetype={isPolicyView ? "detail" : "editor"}
mode="workspace"
title={isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
headerLoading={loading}
error={error}
actions={isPolicyView ? (
<PageActionBar
variant="detail"
refreshable
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
/>
) : (
<PageActionBar
variant="editor"
state={loading ? "saving" : dirty ? "dirty" : "clean"}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => void saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current mail-settings blocker before saving." : undefined }}
/>
)}
notices={(localError || locked) ? <>
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
</> : undefined}
>
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
@@ -373,7 +386,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
<Button variant="primary" onClick={openCredentialDialog} disabled={locked || profilesLoading || !selectedProfile || !selectedSmtpServer && !selectedImapServer}>Add campaign credential</Button>
</div>}>
<p className="muted small-note">The campaign stores stable references to the envelope, selected servers, and credentials.</p>
<div className="form-grid compact responsive-form-grid">
<FormGrid columns={2} collapseAt="wide" className="">
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
<option value="">i18n:govoplan-campaign.select_a_mail_profile.76480af0</option>
@@ -408,14 +421,14 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
)}
</select>
</FormField>
</div>
</FormGrid>
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
{selectedProfile && <div className="metric-grid inside">
{selectedProfile && <MetricGrid spacing="inset">
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
<MetricCard label="i18n:govoplan-campaign.scope.4651a34e" value={profileScopeLabel(selectedProfile)} />
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value={selectedSmtpServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedSmtpServer ? "good" : "neutral"} />
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedImapServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedImapServer ? "good" : "neutral"} />
</div>}
</MetricGrid>}
<div className="button-row compact-actions">
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedSmtpServer || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
<Button onClick={() => void runProfileTest("imap")} disabled={!selectedProfileHasImap || locked || Boolean(mailActionState)}>{mailActionState === "imap" ? "i18n:govoplan-campaign.testing_imap.13d255cf" : "i18n:govoplan-campaign.test_profile_imap.e1cec0e0"}</Button>
@@ -425,12 +438,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
</Card>}
<Dialog
<Dialog variant="administration" size="wide"
open={credentialDialogOpen}
title="Add campaign credential"
onClose={() => setCredentialDialogOpen(false)}
closeDisabled={credentialSaving}
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
footerClassName="button-row compact-actions"
footer={
<>
@@ -457,7 +469,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
<h3>Campaign login</h3>
<p>This credential belongs to this campaign and can use only the selected mail servers.</p>
</header>
<div className="form-grid two">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Name">
<input value={credentialDraft.name} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} autoFocus />
</FormField>
@@ -467,17 +479,17 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
<FormField label="Password">
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} generator autoComplete="new-password" />
</FormField>
</div>
<div className="form-grid two">
</FormGrid>
<FormGrid columns={2} collapseAt="standard" className="">
{selectedSmtpServer && <ToggleSwitch checked={credentialDraft.useSmtp} disabled={credentialSaving} onChange={(useSmtp) => setCredentialDraft({ ...credentialDraft, useSmtp })} label={`Use for SMTP: ${selectedSmtpServer.name}`} />}
{selectedImapServer && <ToggleSwitch checked={credentialDraft.useImap} disabled={credentialSaving} onChange={(useImap) => setCredentialDraft({ ...credentialDraft, useImap })} label={`Use for IMAP: ${selectedImapServer.name}`} />}
</div>
</FormGrid>
</section>
</div>
</Dialog>
{!isPolicyView && <Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
<FormGrid columns={2} collapseAt="wide" className="mail-server-form-grid">
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
<ToggleSwitch
label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8"
@@ -497,11 +509,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
</div>
</FormField>
<MailServerFolderLookupResultView result={folderResult} disabled={locked || !imapAppendEnabled} onUseDetected={useDetectedSentFolder} floatingFailures />
</div>
</FormGrid>
</Card>}
</>
</LoadingFrame>
</div>
</PageLayout>
);
}
+166 -104
View File
@@ -1,3 +1,4 @@
import { DescriptionList } from "@govoplan/core-webui";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
@@ -37,12 +38,12 @@ import {
type CampaignSummary } from
"../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, Dialog, FormField, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { FormGrid, Button, Dialog, FormField, MetricCard, MetricGrid, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { DataGrid, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { i18nMessage } from "@govoplan/core-webui";
@@ -79,7 +80,6 @@ import {
} from "./review/ReviewWorkflowGuidance";
import { calculateBuildReviewProgress } from "./review/reviewProgress";
import {
WorkflowFact,
WorkflowNavigation,
WorkflowStage,
stageConnectorState,
@@ -158,6 +158,11 @@ export default function ReviewSendPage({
);
const validation = asRecord(version?.validation_summary);
const build = asRecord(version?.build_summary);
const attachmentReuse = asRecord(build.attachment_reuse);
const attachmentReusePolicy = asRecord(attachmentReuse.policy);
const attachmentReuseFindings = asArray(attachmentReuse.findings).map(asRecord);
const residualFileDisposition = asRecord(build.residual_file_disposition);
const residualFileRecipient = asRecord(residualFileDisposition.recipient);
const printOutput = asRecord(build.print_output);
const printArtifact = asRecord(printOutput.artifact);
const summary = liveSummary ?? data.summary;
@@ -1287,7 +1292,27 @@ export default function ReviewSendPage({
}
function scrollToStage(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
revealReviewElement(id);
}
function revealReviewElement(id: string) {
const target = document.getElementById(id);
if (!target) return;
const stage = target.classList.contains("review-flow-stage")
? target
: target.closest<HTMLElement>(".review-flow-stage");
stage?.querySelector<HTMLButtonElement>('.card-collapse-toggle[aria-expanded="false"]')?.click();
window.requestAnimationFrame(() => {
target.scrollIntoView({ behavior: "smooth", block: "start" });
target.focus({ preventScroll: true });
});
}
function showBuiltDetails(filters: Record<string, string> = {}, includeAll = false) {
setShowAllReviewJobs(includeAll);
setReviewQuery({ sort: null, filters });
setReviewPage(1);
window.requestAnimationFrame(() => revealReviewElement("campaign-built-message-details"));
}
const matchedAttachments = numberFrom(attachmentSummary, ["total_matched_files"]);
@@ -1364,46 +1389,46 @@ export default function ReviewSendPage({
reviewedExplicitCount === explicitReviewCount;
return (
<div className="content-pad workspace-data-page review-send-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading || Boolean(busy)}>i18n:govoplan-campaign.review_send.1627617d</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => void refreshQueueStatus(false)} disabled={!version || queueStatusLoading || Boolean(busy)}>
<PageLayout
archetype="workspace"
mode="workspace"
className="review-send-page"
title="i18n:govoplan-campaign.review_send.1627617d"
description={<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />}
headerLoading={loading || Boolean(busy)}
error={error}
actions={<PageActionBar
variant="workspace"
refreshable
reloadAction={{ onReload: () => void reload({ force: true }), loading: loading || Boolean(busy) }}
primaryActions={<Button onClick={() => void refreshQueueStatus(false)} disabled={!version || queueStatusLoading || Boolean(busy)}>
{queueStatusLoading ? "i18n:govoplan-campaign.refreshing_status.d8965739" : "i18n:govoplan-campaign.refresh_status.ade15a52"}
</Button>
<Button onClick={() => void reload({ force: true })} disabled={loading || Boolean(busy)}>Discard</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="info" resetKey={message} floating>{message}</DismissibleAlert>}
{version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion) &&
<LockedVersionNotice
settings={settings}
campaignId={campaignId}
version={version}
currentVersionId={data.campaign?.current_version_id}
reload={reload}
message="i18n:govoplan-campaign.this_workflow_is_read_only_for_the_selected_vers.6626b342" />
}
</Button>}
/>}
notices={(message || (version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion))) ? <>
{message && <DismissibleAlert tone="info" resetKey={message} floating>{message}</DismissibleAlert>}
{version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion) &&
<LockedVersionNotice
settings={settings}
campaignId={campaignId}
version={version}
currentVersionId={data.campaign?.current_version_id}
reload={reload}
message="i18n:govoplan-campaign.this_workflow_is_read_only_for_the_selected_vers.6626b342" />}
</> : undefined}
>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_workflow_state.5319aae1">
<WorkflowNavigation stages={stages} onSelect={scrollToStage} />
<div className="review-flow-timeline" aria-label="i18n:govoplan-campaign.campaign_review_and_sending_workflow.e784ea7b">
<WorkflowStage stage={stages[0]} nextState={stages[1].state} nextConnectorState={stageConnectorState(stages[1])}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.status.bae7d5be" value={validationPresent ? validationOk ? "i18n:govoplan-campaign.passed.271d60f4" : "i18n:govoplan-campaign.needs_attention.a126722e" : "i18n:govoplan-campaign.not_run.9e019cd5"} />
<WorkflowFact label="i18n:govoplan-campaign.blocking.d785c0d4" value={validationPresent ? validationErrors : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} />
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.status.bae7d5be" value={validationPresent ? validationOk ? "i18n:govoplan-campaign.passed.271d60f4" : "i18n:govoplan-campaign.needs_attention.a126722e" : "i18n:govoplan-campaign.not_run.9e019cd5"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocking.d785c0d4" value={validationPresent ? validationErrors : "—"} drilldown={validationErrors > 0 && visibleValidationIssues.length > 0 ? { label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => revealReviewElement("campaign-validation-details") } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} drilldown={validationWarnings > 0 && visibleValidationIssues.length > 0 ? { label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => revealReviewElement("campaign-validation-details") } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} drilldown={hasBuild && Number(cards?.needs_attention ?? 0) > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails() } : undefined} />
</MetricGrid>
<ValidationWorkflowGuidance
errors={validationErrors}
warnings={validationWarnings}
@@ -1412,19 +1437,21 @@ export default function ReviewSendPage({
{validationPresent && validationErrors === 0 &&
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d</p>
}
<AttachmentLinkingPreview
preview={attachmentPreview}
loading={attachmentPreviewLoading}
error={attachmentPreviewError}
linking={attachmentLinking}
disabled={!version || readOnlyVersion || readyForDelivery || Boolean(busy)}
onRefresh={() => void reloadAttachmentPreview(false)}
onLink={() => void linkMatchedAttachmentFiles()} />
<div id="campaign-attachment-preview" tabIndex={-1}>
<AttachmentLinkingPreview
preview={attachmentPreview}
loading={attachmentPreviewLoading}
error={attachmentPreviewError}
linking={attachmentLinking}
disabled={!version || readOnlyVersion || readyForDelivery || Boolean(busy)}
onRefresh={() => void reloadAttachmentPreview(false)}
onLink={() => void linkMatchedAttachmentFiles()} />
</div>
{visibleValidationIssues.length > 0 &&
<div className="review-flow-data-section">
<div id="campaign-validation-details" className="review-flow-data-section" tabIndex={-1}>
<h3>i18n:govoplan-campaign.validation_details.aa503267</h3>
<dl className="detail-list">
<DescriptionList variant="inline">
{visibleValidationIssues.map((issue, index) =>
<div key={`${String(issue.code ?? "issue")}:${index}`}>
<dt>{humanize(String(issue.severity ?? "issue"))}</dt>
@@ -1435,7 +1462,7 @@ export default function ReviewSendPage({
</dd>
</div>
)}
</dl>
</DescriptionList>
{validationIssues.length > visibleValidationIssues.length && <p className="muted small-note">i18n:govoplan-campaign.showing.163d8174 {visibleValidationIssues.length} of {validationIssues.length} i18n:govoplan-campaign.validation_issue_s.c6a8b911</p>}
</div>
}
@@ -1456,18 +1483,53 @@ export default function ReviewSendPage({
</WorkflowStage>
<WorkflowStage stage={stages[1]} nextState={stages[2].state} nextConnectorState={stageConnectorState(stages[2])}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} />
<WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} drilldown={hasBuild && builtCount > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} drilldown={buildBlocked > 0 ? { label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => showBuiltDetails({ validation: 'list:["blocked"]' }) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} drilldown={buildNeedsReview > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails({ validation: 'list:["warning","needs_review"]' }) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} drilldown={buildReviewProgress.reviewed > 0 ? { label: "i18n:govoplan-campaign.reviewed.31ef8593", onActivate: () => showBuiltDetails({ reviewed: 'list:["yes"]' }, true) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} drilldown={buildReviewProgress.remaining > 0 ? { label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => showBuiltDetails({ reviewed: 'list:["no"]' }) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} drilldown={reviewJobs.total > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails() } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} drilldown={missingAttachments + ambiguousAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
</MetricGrid>
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
{hasBuild && Object.keys(residualFileDisposition).length > 0 && (
<div className="review-flow-data-section" data-residual-file-policy>
<h3>i18n:govoplan-campaign.unassigned_file_policy</h3>
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-campaign.action.97c89a4d</dt><dd>{humanize(String(residualFileDisposition.action ?? "review"))}</dd></div>
<div><dt>i18n:govoplan-campaign.validation_policy.57dcc756</dt><dd>{humanize(String(residualFileDisposition.validation_behavior ?? "warn"))}</dd></div>
<div><dt>i18n:govoplan-campaign.unassigned_files_detected</dt><dd>{String(residualFileDisposition.residual_file_count ?? 0)}</dd></div>
<div><dt>i18n:govoplan-campaign.watched_sources</dt><dd>{String(residualFileDisposition.watched_source_count ?? 0)}</dd></div>
{residualFileRecipient.email ? <div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(residualFileRecipient.email)}</dd></div> : null}
</DescriptionList>
</div>
)}
{hasBuild && Object.keys(attachmentReuse).length > 0 && (
<div className="review-flow-data-section" data-attachment-reuse-policy>
<h3>Attachment reuse policy</h3>
<DescriptionList variant="inline">
<div><dt>Action</dt><dd>{humanize(String(attachmentReusePolicy.action ?? "allow"))}</dd></div>
<div><dt>Allowed boundary</dt><dd>{humanize(String(attachmentReusePolicy.allow_within ?? "none"))}</dd></div>
<div><dt>Repeated files</dt><dd>{String(attachmentReuse.duplicate_file_count ?? 0)}</dd></div>
<div><dt>Allowed</dt><dd>{String(attachmentReuse.allowed_file_count ?? 0)}</dd></div>
<div><dt>Policy findings</dt><dd>{String(attachmentReuse.violation_file_count ?? 0)}</dd></div>
<div><dt>Affected messages</dt><dd>{String(attachmentReuse.affected_message_count ?? 0)}</dd></div>
</DescriptionList>
{attachmentReuseFindings.length > 0 && (
<ul className="small-note">
{attachmentReuseFindings.slice(0, 10).map((finding) => (
<li key={String(finding.file_fingerprint)}>
{String(finding.file_name ?? "Attachment")} · {String(finding.use_count ?? 0)} uses · {humanize(String(finding.disposition ?? "allowed"))}
</li>
))}
</ul>
)}
{attachmentReuseFindings.length > 10 && <p className="muted small-note">Showing 10 of {attachmentReuseFindings.length} repeated files.</p>}
</div>
)}
{getText(printOutput, "render_id") && (
<div className="review-flow-data-section">
<div className="page-heading split">
@@ -1489,11 +1551,11 @@ export default function ReviewSendPage({
</Button>
)}
</div>
<dl className="detail-list">
<DescriptionList variant="inline">
<div><dt>Template hash</dt><dd><code>{getText(printOutput, "template_hash") || "—"}</code></dd></div>
<div><dt>Input hash</dt><dd><code>{getText(printOutput, "input_hash") || "—"}</code></dd></div>
<div><dt>Output hash</dt><dd><code>{getText(printOutput, "output_sha256") || "—"}</code></dd></div>
</dl>
</DescriptionList>
</div>
)}
<div className="button-row compact-actions review-flow-stage-actions">
@@ -1518,7 +1580,7 @@ export default function ReviewSendPage({
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791</p>
}
{hasBuild &&
<div className="review-flow-data-section">
<div id="campaign-built-message-details" className="review-flow-data-section" tabIndex={-1}>
<div className="page-heading split">
<div className="button-row compact-actions">
<Button onClick={() => {setShowAllReviewJobs((value) => !value);setReviewPage(1);setJobsLoadedKey("");}} disabled={busy === "inspect"}>
@@ -1559,12 +1621,12 @@ export default function ReviewSendPage({
</WorkflowStage>
<WorkflowStage stage={stages[2]} nextState={stages[3].state} nextConnectorState={stageConnectorState(stages[3])}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.captured_smtp.78f2e2ca" value={mockResult ? mockSent : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.mock_failures.9475ce2a" value={mockResult ? mockFailed : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.skipped.5a000ad7" value={mockResult ? mockSkipped : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.captured_messages.a833d293" value={!mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : mockResult ? mockMailboxMessages.length : "—"} />
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.captured_smtp.78f2e2ca" value={mockResult ? mockSent : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.mock_failures.9475ce2a" value={mockResult ? mockFailed : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.skipped.5a000ad7" value={mockResult ? mockSkipped : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.captured_messages.a833d293" value={!mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : mockResult ? mockMailboxMessages.length : "—"} />
</MetricGrid>
<div className="button-row compact-actions review-flow-stage-actions">
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
{busy === "mock" ? "i18n:govoplan-campaign.running_mock_delivery.06c6a3bf" : mockResult ? "i18n:govoplan-campaign.run_mock_delivery_again.77fa416e" : "i18n:govoplan-campaign.run_mock_delivery.b74ff33d"}
@@ -1616,19 +1678,19 @@ export default function ReviewSendPage({
</WorkflowStage>
<WorkflowStage stage={stages[3]} nextState={stages[4].state} nextConnectorState={stageConnectorState(stages[4])}>
<div className="review-flow-execution-summary">
<div><span>i18n:govoplan-campaign.recipients.78cbf8eb</span><strong>{jobsTotal || "—"}</strong></div>
<div><span>i18n:govoplan-campaign.messages_to_send.f7763bf9</span><strong>{builtCount || jobsTotal || "—"}</strong></div>
<div><span>i18n:govoplan-campaign.matched_attachments.ead1eeb1</span><strong>{matchedAttachments || "—"}</strong></div>
<div><span>i18n:govoplan-campaign.missing_ambiguous.fffdd3f5</span><strong>{missingAttachments} / {ambiguousAttachments}</strong></div>
<div><span>i18n:govoplan-campaign.rate_limit.d08e55f5</span><strong>{messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"}</strong></div>
<div><span>i18n:govoplan-campaign.minimum_duration.91a71a6d</span><strong>{estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"}</strong></div>
<div><span>i18n:govoplan-campaign.imap_append.8c0d9e96</span><strong>{Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"}</strong></div>
<div><span>i18n:govoplan-campaign.synchronous_limit.f88c0bcd</span><strong>{deliveryOptionsLoading ? "…" : synchronousSendLimit}</strong></div>
<div><span>i18n:govoplan-campaign.limit_source.bd933adb</span><strong>{deliveryPolicySourceLabel(String(synchronousSendPolicy.source ?? ""))}</strong></div>
<div><span>i18n:govoplan-campaign.worker_queue.c911e32c</span><strong>{workerQueueAvailable ? "i18n:govoplan-campaign.available.7c62a142" : "i18n:govoplan-campaign.not_configured.811931bb"}</strong></div>
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.recipients.78cbf8eb" value={jobsTotal || "—"} drilldown={hasBuild && jobsTotal > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.messages_to_send.f7763bf9" value={builtCount || jobsTotal || "—"} drilldown={hasBuild && builtCount > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.matched_attachments.ead1eeb1" value={matchedAttachments || "—"} drilldown={matchedAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.missing_ambiguous.fffdd3f5" value={`${missingAttachments} / ${ambiguousAttachments}`} drilldown={missingAttachments + ambiguousAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.rate_limit.d08e55f5" value={messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.minimum_duration.91a71a6d" value={estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_append.8c0d9e96" value={Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.synchronous_limit.f88c0bcd" value={deliveryOptionsLoading ? "…" : synchronousSendLimit} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.limit_source.bd933adb" value={deliveryPolicySourceLabel(String(synchronousSendPolicy.source ?? ""))} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.worker_queue.c911e32c" value={workerQueueAvailable ? "i18n:govoplan-campaign.available.7c62a142" : "i18n:govoplan-campaign.not_configured.811931bb"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.version.2da600bf" value={version ? `v${version.version_number}` : "—"} />
</MetricGrid>
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
<div className="review-send-controls">
<span>Approval gate</span>
@@ -1642,12 +1704,12 @@ export default function ReviewSendPage({
{approvalGateConfigured &&
<Button disabled={Boolean(busy)} onClick={() => navigate("/approvals")}>Open approval</Button>}
</div>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
<WorkflowFact label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
<WorkflowFact label="i18n:govoplan-campaign.not_queued.b7f41e1e" value={notQueuedCount} />
<WorkflowFact label="i18n:govoplan-campaign.send_attempts.2cdb19e8" value={sendAttemptCount} />
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.not_queued.b7f41e1e" value={notQueuedCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.send_attempts.2cdb19e8" value={sendAttemptCount} />
</MetricGrid>
{queueStatusNote &&
<p className={`review-flow-inline-note ${queueStatusTone}`.trim()}>{queueStatusNote}</p>
}
@@ -1718,18 +1780,18 @@ export default function ReviewSendPage({
</WorkflowStage>
<WorkflowStage stage={stages[4]}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
<WorkflowFact label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
<WorkflowFact label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
<WorkflowFact label="i18n:govoplan-campaign.paused.c7dfb6f1" value={pausedQueueCount} />
<WorkflowFact label="i18n:govoplan-campaign.delivery_mode.109ed9d1" value={deliveryModeLabel(persistedDeliveryMode)} />
<WorkflowFact label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
<WorkflowFact label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
<WorkflowFact label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
<WorkflowFact label="i18n:govoplan-campaign.imap_failed.50dbca55" value={imapFailedForDisplay} />
<WorkflowFact label="i18n:govoplan-campaign.imap_skipped.5a97b542" value={imapSkipped} />
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.paused.c7dfb6f1" value={pausedQueueCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.delivery_mode.109ed9d1" value={deliveryModeLabel(persistedDeliveryMode)} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_failed.50dbca55" value={imapFailedForDisplay} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_skipped.5a97b542" value={imapSkipped} />
</MetricGrid>
<div className="review-flow-result-line">
<StatusBadge status={deliveryDisplayStatus} />
<span>{deliveryStarted ? "i18n:govoplan-campaign.delivery_activity_is_available_in_the_report_and.cb163d1d" : "i18n:govoplan-campaign.no_real_delivery_has_started_for_this_campaign_v.3b9235ff"}</span>
@@ -1816,7 +1878,7 @@ export default function ReviewSendPage({
{recentFailures.length > 0 &&
<div className="review-flow-data-section">
<h3>i18n:govoplan-campaign.recent_delivery_failures.282285fb</h3>
<dl className="detail-list">
<DescriptionList variant="inline">
{recentFailures.map((failure, index) =>
<div key={String(failure.job_id ?? index)}>
<dt>{String(failure.recipient_email ?? failure.entry_id ?? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: String(failure.entry_index ?? index + 1) }))}</dt>
@@ -1827,7 +1889,7 @@ export default function ReviewSendPage({
</dd>
</div>
)}
</dl>
</DescriptionList>
</div>
}
<div className="button-row compact-actions">
@@ -1900,7 +1962,7 @@ export default function ReviewSendPage({
{busy === "approval" ? "Requesting..." : "Request approval"}
</Button>
</>}>
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<p className="muted small-note form-grid-wide">The request covers the exact built message, attachment, recipient, policy, and transport snapshot. Rebuilding invalidates and removes this gate.</p>
<FormField label="Approver type">
<select value={approvalSelectorKind} disabled={busy === "approval"} onChange={(event) => setApprovalSelectorKind(event.target.value as typeof approvalSelectorKind)}>
@@ -1934,7 +1996,7 @@ export default function ReviewSendPage({
})} />)}
</div>
</div>
</div>
</FormGrid>
</Dialog>
<ConfirmDialog
@@ -2030,7 +2092,7 @@ export default function ReviewSendPage({
}
>
{singleSendConfirmRow && (
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<SegmentedControl
value={singleMessageActionKind}
width="fill"
@@ -2090,7 +2152,7 @@ export default function ReviewSendPage({
/>
</label>
)}
</div>
</FormGrid>
)}
</Dialog>
@@ -2113,6 +2175,6 @@ export default function ReviewSendPage({
onClose={() => setSelectedMockMessage(null)} />
}
</div>);
</PageLayout>);
}
+410 -27
View File
@@ -1,16 +1,22 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import {
listCampaignContentLibrary,
listCampaignPrintTemplates,
previewCampaignAttachments,
saveCampaignContentLibraryItem,
type CampaignAttachmentPreviewRule,
type CampaignContentLibraryItem,
type CampaignContentLibraryResponse,
type CampaignContentLibraryTarget,
type CampaignPrintTemplate
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { FormGrid, ContentGrid, Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog, Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
@@ -25,10 +31,19 @@ import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, replacePlaceholderInText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
import { campaignContentFieldType, checkContentLibraryCompatibility, type ContentLibraryCompatibility } from "./utils/contentLibraryCompatibility";
type TemplateBodyMode = "text" | "html" | "both";
type BodyEditorMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html";
type ContentLibrarySaveKind = "fragment" | "campaign_part";
type PendingContentApply = {
item: CampaignContentLibraryItem;
target?: CampaignContentLibraryTarget;
value?: string;
overwrites: boolean;
compatibility: ContentLibraryCompatibility;
};
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
@@ -44,6 +59,20 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
const [printTemplates, setPrintTemplates] = useState<CampaignPrintTemplate[]>([]);
const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true);
const [printTemplatesError, setPrintTemplatesError] = useState("");
const [contentLibraryOpen, setContentLibraryOpen] = useState(false);
const [contentLibraryQuery, setContentLibraryQuery] = useState("");
const [contentLibrary, setContentLibrary] = useState<CampaignContentLibraryResponse | null>(null);
const [contentLibraryLoading, setContentLibraryLoading] = useState(false);
const [contentLibraryError, setContentLibraryError] = useState("");
const [contentSaveOpen, setContentSaveOpen] = useState(false);
const [contentSaveBusy, setContentSaveBusy] = useState(false);
const [contentSaveName, setContentSaveName] = useState("");
const [contentSaveDescription, setContentSaveDescription] = useState("");
const [contentSaveKind, setContentSaveKind] = useState<ContentLibrarySaveKind>("fragment");
const [contentSaveTarget, setContentSaveTarget] = useState<CampaignContentLibraryTarget>("text");
const [contentSaveVisibility, setContentSaveVisibility] = useState<"personal" | "tenant">("personal");
const [contentLibraryNotice, setContentLibraryNotice] = useState("");
const [pendingContentApply, setPendingContentApply] = useState<PendingContentApply | null>(null);
const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<WysiwygEditorHandle | null>(null);
@@ -68,6 +97,8 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
const printConfig = asRecord(delivery.print);
const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null;
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
const contentSaveSelectedValue = getText(template, contentSaveTarget);
const contentSaveHasBody = Boolean(getText(template, "text").trim() || getText(template, "html").trim());
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
@@ -76,6 +107,25 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
const localAvailableNames = useMemo(() => new Set([...localFieldNames, ...builtInAddressNames]), [builtInAddressNames, localFieldNames]);
const globalAvailableNames = useMemo(() => new Set(globalFieldNames), [globalFieldNames]);
const allAvailableNames = useMemo(() => new Set([...localAvailableNames, ...globalAvailableNames]), [globalAvailableNames, localAvailableNames]);
const contentAvailableFields = useMemo(() => {
const available = new Map<string, string>();
for (const field of fields) {
const name = String(field.name || field.id || "").trim();
if (!name) continue;
const valueType = campaignFieldDefinitionType(String(field.type || "string"));
available.set(name, valueType);
available.set(`local:${name}`, valueType);
available.set(`global:${name}`, valueType);
}
for (const [name, value] of Object.entries(asRecord(displayDraft.global_values))) {
const valueType = available.get(name) ?? campaignContentFieldType(value);
available.set(name, valueType);
available.set(`local:${name}`, valueType);
available.set(`global:${name}`, valueType);
}
for (const name of builtInAddressNames) available.set(`local:${name}`, "string");
return available;
}, [builtInAddressNames, displayDraft.global_values, fields]);
const localFieldOptions = useMemo(() => {
const options = [...recipientAddressTemplateFieldOptions(), ...localFieldNames.map((name) => ({ name, label: name }))];
return options.filter((option, index) => options.findIndex((candidate) => candidate.name === option.name) === index);
@@ -179,6 +229,32 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
return () => { cancelled = true; };
}, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!contentLibraryOpen) return;
let cancelled = false;
setContentLibraryLoading(true);
setContentLibraryError("");
const handle = window.setTimeout(() => {
void listCampaignContentLibrary(settings, campaignId, contentLibraryQuery)
.then((response) => {
if (!cancelled) setContentLibrary(response);
})
.catch((reason: unknown) => {
if (!cancelled) {
setContentLibrary(null);
setContentLibraryError(reason instanceof Error ? reason.message : String(reason));
}
})
.finally(() => {
if (!cancelled) setContentLibraryLoading(false);
});
}, 180);
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}, [campaignId, contentLibraryOpen, contentLibraryQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value);
@@ -280,30 +356,149 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
setUndefinedDialog(null);
}
function openContentSaveDialog() {
setContentSaveName("");
setContentSaveDescription("");
setContentSaveKind("fragment");
setContentSaveTarget(activeEditor === "subject" ? "subject" : visibleBodyEditor);
setContentSaveVisibility("personal");
setContentLibraryError("");
setContentSaveOpen(true);
}
function insertContentFragment(target: CampaignContentLibraryTarget, value: string) {
if (locked || !value) return;
if (target === "html") {
const inserted = htmlRef.current?.insertText(value) ?? false;
if (!inserted) patchTemplateText("html", `${getText(template, "html")}${value}`);
setActiveBodyEditor("html");
setActiveEditor("html");
setContentLibraryOpen(false);
return;
}
const element = target === "subject" ? subjectRef.current : textRef.current;
const currentText = getText(template, target);
const start = element?.selectionStart ?? currentText.length;
const end = element?.selectionEnd ?? currentText.length;
patchTemplateText(target, `${currentText.slice(0, start)}${value}${currentText.slice(end)}`);
window.requestAnimationFrame(() => {
element?.focus();
const cursor = start + value.length;
element?.setSelectionRange(cursor, cursor);
});
if (target === "text") setActiveBodyEditor("text");
setActiveEditor(target);
setContentLibraryOpen(false);
}
function requestContentFragment(item: CampaignContentLibraryItem, target: CampaignContentLibraryTarget, value: string) {
const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields);
if (compatibility.compatible) {
insertContentFragment(target, value);
return;
}
setPendingContentApply({ item, target, value, overwrites: false, compatibility });
}
function applyCampaignPart(item: CampaignContentLibraryItem) {
if (locked) return;
setDraft((current) => {
const next = cloneJson(current ?? {});
next.template = {
...asRecord(next.template),
subject: item.subject ?? "",
text: item.text ?? "",
html: item.html ?? "",
body_mode: item.body_mode
};
return next;
});
markDirty();
setActiveBodyEditor(item.body_mode === "html" ? "html" : "text");
setActiveEditor(item.body_mode === "html" ? "html" : "text");
setContentLibraryOpen(false);
}
function requestCampaignPart(item: CampaignContentLibraryItem) {
const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields);
const overwrites = ["subject", "text", "html"].some((field) => {
const current = getText(template, field);
const replacement = field === "subject" ? item.subject : field === "text" ? item.text : item.html;
return Boolean(current.trim()) && current !== (replacement ?? "");
});
if (!overwrites && compatibility.compatible) {
applyCampaignPart(item);
return;
}
setPendingContentApply({ item, overwrites, compatibility });
}
function confirmContentApply() {
const pending = pendingContentApply;
if (!pending) return;
setPendingContentApply(null);
if (pending.target && pending.value !== undefined) {
insertContentFragment(pending.target, pending.value);
} else {
applyCampaignPart(pending.item);
}
}
async function saveReusableContent() {
if (contentSaveBusy || !contentSaveName.trim()) return;
setContentSaveBusy(true);
setContentLibraryError("");
try {
const result = await saveCampaignContentLibraryItem(settings, campaignId, {
name: contentSaveName.trim(),
description: contentSaveDescription.trim() || null,
kind: contentSaveKind,
target: contentSaveKind === "fragment" ? contentSaveTarget : null,
subject: getText(template, "subject"),
text: getText(template, "text"),
html: getText(template, "html"),
body_mode: templateBodyMode,
locale: "de",
visibility: contentSaveVisibility
});
setContentSaveOpen(false);
setContentLibraryNotice(`${result.template.name} was saved as an unpublished Templates draft.`);
setContentLibrary(null);
} catch (reason) {
setContentLibraryError(reason instanceof Error ? reason.message : String(reason));
} finally {
setContentSaveBusy(false);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.template.3ec1ae06</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<PageLayout
archetype="editor"
mode="workspace"
title="i18n:govoplan-campaign.template.3ec1ae06"
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
headerLoading={loading}
error={error}
success={contentLibraryNotice}
actions={<PageActionBar
variant="editor"
state={loading ? "saving" : dirty ? "dirty" : "clean"}
contextActions={<Button onClick={() => window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071</Button>}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
/>}
notices={(localError || locked) ? <>
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
</> : undefined}
>
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<div className="dashboard-grid template-editor-grid">
<ContentGrid columns={2} collapseAt="workspace" className="template-editor-grid">
<Card title="i18n:govoplan-campaign.editable_template.5e747a1e" actions={<Button onClick={() => setPreviewOpen(true)}>i18n:govoplan-campaign.preview.f1fbb2b4</Button>}>
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<FormField label="i18n:govoplan-campaign.subject.8d183dbd">
<input
ref={subjectRef}
@@ -371,16 +566,16 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
</div>
}
<div className="button-row template-editor-actions">
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
<Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button>
<Button onClick={() => setContentLibraryOpen(true)} disabled={locked}>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
<Button onClick={openContentSaveDialog} disabled={locked}>i18n:govoplan-campaign.save_to_library.396649bf</Button>
</div>
</div>
</FormGrid>
</Card>
<div className="template-side-stack">
<Card title="Printable output">
<LoadingFrame loading={printTemplatesLoading} label="Loading printable templates">
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
{printTemplatesError && <DismissibleAlert tone="danger" compact resetKey={printTemplatesError}>{printTemplatesError}</DismissibleAlert>}
{!printTemplatesError && !printTemplatesAvailable && (
<DismissibleAlert tone="info" compact dismissible={false}>
@@ -433,7 +628,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
/>
</>
)}
</div>
</FormGrid>
</LoadingFrame>
</Card>
<Card title="i18n:govoplan-campaign.fields.e8b68527">
@@ -466,7 +661,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Card>
</div>
</div>
</ContentGrid>
</>
</LoadingFrame>
@@ -494,6 +689,173 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
}
<Dialog
open={contentLibraryOpen}
title="Reusable content"
className="campaign-content-library-dialog"
helpContextId="campaign.template.content-library"
onClose={() => setContentLibraryOpen(false)}
footer={<>
<Button onClick={() => window.location.assign("/templates")}>Manage Templates</Button>
<Button variant="primary" onClick={() => setContentLibraryOpen(false)}>Close</Button>
</>}
>
<div className="campaign-content-library">
<FormField label="Search library" help="Searches content published or visible to your Templates scope.">
<input
value={contentLibraryQuery}
onChange={(event) => setContentLibraryQuery(event.target.value)}
placeholder="Name or description"
autoFocus
/>
</FormField>
{contentLibraryError && <DismissibleAlert tone="danger" compact resetKey={contentLibraryError}>{contentLibraryError}</DismissibleAlert>}
<LoadingFrame loading={contentLibraryLoading} label="Loading reusable content">
<div className="campaign-content-library-list">
{!contentLibraryError && contentLibrary && !contentLibrary.available && (
<DismissibleAlert tone="info" dismissible={false}>{contentLibrary.reason || "Enable Templates to use reusable content."}</DismissibleAlert>
)}
{!contentLibraryError && contentLibrary?.available && contentLibrary.items.length === 0 && (
<p className="muted">No reusable Campaign content matches this search.</p>
)}
{contentLibrary?.items.map((item) => {
const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields);
return <div className="campaign-content-library-item" key={`${item.id}:${item.revision_id}`}>
<div className="campaign-content-library-item-copy">
<div className="campaign-content-library-item-title">
<strong>{item.name}</strong>
<span>{item.published ? `Published r${item.revision}` : `Draft r${item.revision}`}</span>
</div>
{item.description && <p>{item.description}</p>}
<small>{item.kind === "fragment" ? "Content fragment" : "Complete campaign part"} · {item.scope_type} · {item.locale || "unspecified locale"}</small>
{!compatibility.compatible && (
<div className="campaign-content-library-compatibility" role="status">
<strong>Field compatibility needs attention.</strong>
{compatibility.missing.length > 0 && <span>Missing: {compatibility.missing.map((field) => field.label).join(", ")}.</span>}
{compatibility.incompatible.length > 0 && <span>Wrong type: {compatibility.incompatible.map((field) => `${field.label} (${field.actual} instead of ${field.expected})`).join(", ")}.</span>}
</div>
)}
</div>
<div className="button-row campaign-content-library-item-actions">
{item.kind === "campaign_part" ? (
<Button
variant="primary"
disabled={locked}
onClick={() => requestCampaignPart(item)}
title="Replaces the current subject and body fields in this draft"
>Apply part</Button>
) : item.targets.map((target) => {
const value = target === "html" ? item.html : item.text;
return (
<Button
key={target}
disabled={locked || !value}
onClick={() => value && requestContentFragment(item, target, value)}
>Insert in {target}</Button>
);
})}
</div>
</div>;
})}
</div>
</LoadingFrame>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(pendingContentApply)}
title={pendingContentApply?.overwrites ? "Replace current Campaign content?" : "Apply content with field mismatches?"}
message={contentApplyConfirmationMessage(pendingContentApply)}
confirmLabel={pendingContentApply?.overwrites ? "Replace content" : "Apply content"}
cancelLabel="Cancel"
helpContextId="campaign.template.content-library"
onConfirm={confirmContentApply}
onCancel={() => setPendingContentApply(null)}
/>
<Dialog
open={contentSaveOpen}
title="Save reusable content"
size="large"
helpContextId="campaign.template.content-library"
closeDisabled={contentSaveBusy}
onClose={() => setContentSaveOpen(false)}
footer={<>
<Button onClick={() => setContentSaveOpen(false)} disabled={contentSaveBusy}>Cancel</Button>
<Button
variant="primary"
onClick={() => void saveReusableContent()}
disabled={
contentSaveBusy ||
!contentSaveName.trim() ||
(contentSaveKind === "fragment" ? !contentSaveSelectedValue.trim() : !contentSaveHasBody)
}
>{contentSaveBusy ? "Saving..." : "Save draft"}</Button>
</>}
>
<div className="campaign-content-save-form">
<DismissibleAlert tone="info" compact dismissible={false}>
Saving creates an unpublished Templates draft. Publication and later revisions remain governed in Templates.
</DismissibleAlert>
{contentLibraryError && <DismissibleAlert tone="danger" compact resetKey={contentLibraryError}>{contentLibraryError}</DismissibleAlert>}
<div className="campaign-content-save-identity">
<FormField label="Name">
<input value={contentSaveName} onChange={(event) => setContentSaveName(event.target.value)} autoFocus />
</FormField>
<FormField label="Visibility" help="Personal drafts are visible to you; tenant drafts are available to authorized template users.">
<SegmentedControl
ariaLabel="Template visibility"
value={contentSaveVisibility}
onChange={setContentSaveVisibility}
size="content"
width="inline"
options={[
{ id: "personal", label: "Personal" },
{ id: "tenant", label: "Tenant" }
]}
/>
</FormField>
</div>
<FormField label="Description">
<textarea rows={3} value={contentSaveDescription} onChange={(event) => setContentSaveDescription(event.target.value)} />
</FormField>
<FormField label="Content kind">
<SegmentedControl
ariaLabel="Reusable content kind"
value={contentSaveKind}
onChange={setContentSaveKind}
size="content"
width="inline"
options={[
{ id: "fragment", label: "Fragment" },
{ id: "campaign_part", label: "Complete part" }
]}
/>
</FormField>
{contentSaveKind === "fragment" && (
<FormField label="Source field" help="The selected current field becomes the reusable fragment.">
<SegmentedControl
ariaLabel="Fragment source field"
value={contentSaveTarget}
onChange={setContentSaveTarget}
size="content"
width="inline"
options={[
{ id: "subject", label: "Subject" },
{ id: "text", label: "Text" },
{ id: "html", label: "HTML" }
]}
/>
</FormField>
)}
<p className="muted small-note">
{contentSaveKind === "fragment"
? `${contentSaveSelectedValue.length.toLocaleString()} characters from ${contentSaveTarget}.`
: "Subject, text, HTML, and body mode are stored as one reusable Campaign part."}
</p>
</div>
</Dialog>
<UndefinedPlaceholderDecisionDialog
field={undefinedDialog}
contextLabel="template"
@@ -505,7 +867,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
localFields={localFieldOptions}
globalFields={globalFieldOptions} />
</div>);
</PageLayout>);
}
@@ -614,6 +976,27 @@ function normalizeTemplateBodyMode(value: string): TemplateBodyMode {
return "both";
}
function campaignFieldDefinitionType(value: string): string {
if (value === "double") return "number";
if (value === "integer" || value === "date") return value;
return "string";
}
function contentApplyConfirmationMessage(pending: PendingContentApply | null): string {
if (!pending) return "";
const messages = pending.overwrites
? [`Applying ${pending.item.name} replaces the current subject and body in this editable Campaign draft.`]
: [`Applying ${pending.item.name} inserts content into this editable Campaign draft.`];
if (pending.compatibility.missing.length > 0) {
messages.push(`Missing required fields: ${pending.compatibility.missing.map((field) => field.label).join(", ")}.`);
}
if (pending.compatibility.incompatible.length > 0) {
messages.push(`Fields with incompatible types: ${pending.compatibility.incompatible.map((field) => `${field.label} (${field.actual} instead of ${field.expected})`).join(", ")}.`);
}
messages.push("Review the resulting placeholders before saving the Campaign draft.");
return messages.join(" ");
}
function uniqueSorted(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
}
@@ -9,12 +9,14 @@ import {
} from "lucide-react";
import {
fetchResourceAccessExplanation,
fetchResourceAccessExplanationSubjects,
getCampaignShares,
campaignShareTargetProvider,
revokeCampaignShare,
upsertCampaignShare,
type CampaignShare,
type ResourceAccessExplanationResponse } from
type ResourceAccessExplanationResponse,
type ResourceAccessExplanationSubjectsResponse } from
"../../../api/campaigns";
import {
Button,
@@ -72,6 +74,8 @@ export default function CampaignAccessCard({
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
const [accessExplanationOpen, setAccessExplanationOpen] = useState(false);
const [accessExplanation, setAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
const [accessExplanationSubjects, setAccessExplanationSubjects] = useState<ResourceAccessExplanationSubjectsResponse | null>(null);
const [accessExplanationSubjectId, setAccessExplanationSubjectId] = useState("");
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
const [ownershipTransfers, setOwnershipTransfers] = useState<OwnershipTransfer[]>([]);
const [busy, setBusy] = useState(false);
@@ -346,13 +350,24 @@ export default function CampaignAccessCard({
}
async function openAccessExplanation() {
if (!auth.user?.id) return;
const currentUserId = auth.principal?.membership_id || auth.user?.id;
if (!currentUserId) return;
setAccessExplanationOpen(true);
setAccessExplanation(null);
setAccessExplanationSubjects(null);
setAccessExplanationLoading(true);
try {
const subjects = await fetchResourceAccessExplanationSubjects(settings, {
tenantId: (auth.active_tenant ?? auth.tenant)?.id
});
const initialSubjectId = subjects.users.some((user) => user.id === currentUserId)
? currentUserId
: subjects.users[0]?.id;
if (!initialSubjectId) throw new Error("No permitted access-explanation subject is available.");
setAccessExplanationSubjects(subjects);
setAccessExplanationSubjectId(initialSubjectId);
setAccessExplanation(await fetchResourceAccessExplanation(settings, {
userId: auth.user.id,
userId: initialSubjectId,
resourceType: "campaign",
resourceId: campaign.id,
action: "campaigns:campaign:read",
@@ -366,6 +381,26 @@ export default function CampaignAccessCard({
}
}
async function selectAccessExplanationSubject(userId: string) {
if (userId === accessExplanationSubjectId) return;
setAccessExplanationLoading(true);
try {
const explanation = await fetchResourceAccessExplanation(settings, {
userId,
resourceType: "campaign",
resourceId: campaign.id,
action: "campaigns:campaign:read",
tenantId: (auth.active_tenant ?? auth.tenant)?.id
});
setAccessExplanation(explanation);
setAccessExplanationSubjectId(userId);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setAccessExplanationLoading(false);
}
}
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
{
id: "target",
@@ -517,8 +552,15 @@ export default function CampaignAccessCard({
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
</Dialog>
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<ResourceAccessExplanation loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} />
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); setAccessExplanationSubjects(null); setAccessExplanationSubjectId(""); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); setAccessExplanationSubjects(null); setAccessExplanationSubjectId(""); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<ResourceAccessExplanation
loading={accessExplanationLoading}
explanation={accessExplanation}
subjects={accessExplanationSubjects}
selectedUserId={accessExplanationSubjectId}
onSelectedUserIdChange={(userId) => void selectAccessExplanationSubject(userId)}
fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id}
/>
</Dialog>
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Button, DismissibleAlert, LoadingFrame, PageTitle } from "@govoplan/core-webui";
import { DismissibleAlert, PageActionBar, PageLayout } from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import LockedVersionNotice from "./LockedVersionNotice";
@@ -24,6 +24,7 @@ type CampaignDraftPageScaffoldProps = {
children: ReactNode;
};
// semantic-editor-guard: delegated to each caller's useCampaignDraftEditor lifecycle.
export default function CampaignDraftPageScaffold({
settings,
campaignId,
@@ -43,25 +44,27 @@ export default function CampaignDraftPageScaffold({
children
}: CampaignDraftPageScaffoldProps) {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{title}</PageTitle>
<VersionLine version={version} versions={versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={onReload} disabled={loading}>Discard</Button>
<Button variant="primary" onClick={onSave} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={currentVersionId} reload={onReload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>{children}</>
</LoadingFrame>
</div>);
<PageLayout
archetype="editor"
mode="workspace"
title={title}
description={<VersionLine version={version} versions={versions} status={saveState} />}
loading={loading || !draft}
loadingLabel="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50"
error={error || ""}
actions={<PageActionBar
variant="editor"
state={loading ? "saving" : dirty ? "dirty" : "clean"}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: onReload }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: onSave, disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
/>}
notices={(localError || locked) ?
<>
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={currentVersionId} reload={onReload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
</> : undefined}
>
{children}
</PageLayout>);
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, type ReactNode } from "react";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { Button, Dialog, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
import { ActionToolbar, Button, Dialog, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
// Campaign review/template/mock previews need recipient navigation, review notes,
// raw MIME inspection and attachment grouping. Generic mailbox reading uses
@@ -106,7 +106,7 @@ export default function CampaignMessagePreviewOverlay({
>
<div ref={contentRef} className="message-preview-content">
{(recipientLabel || recipientNote || navigation) &&
<div className="template-preview-toolbar">
<ActionToolbar className="template-preview-toolbar">
<div>
{recipientLabel && <strong>{recipientLabel}</strong>}
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
@@ -120,7 +120,7 @@ export default function CampaignMessagePreviewOverlay({
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
</div>
}
</div>
</ActionToolbar>
}
<MessageDisplayPanel
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Button, Dialog, DismissibleAlert, FormField, TableActionGroup, ToggleSwitch } from "@govoplan/core-webui";
import { FormGrid, ActionToolbar, Button, Dialog, DismissibleAlert, FormField, TableActionGroup, ToggleSwitch } from "@govoplan/core-webui";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import type {
CampaignPostboxCatalog,
@@ -91,7 +91,7 @@ export default function PostboxTargetsDialog({
<Dialog
open={open}
title={title}
className="campaign-postbox-target-modal"
size="wide"
bodyClassName="campaign-postbox-target-body"
onClose={onClose}
footer={
@@ -101,7 +101,7 @@ export default function PostboxTargetsDialog({
</>
}
>
<div className="campaign-postbox-target-toolbar">
<ActionToolbar className="campaign-postbox-target-toolbar">
{showMerge &&
<ToggleSwitch
label="Postbox target defaults"
@@ -121,7 +121,7 @@ export default function PostboxTargetsDialog({
<Plus aria-hidden="true" />
Add target
</Button>
</div>
</ActionToolbar>
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
{draftTargets.length === 0 &&
@@ -181,7 +181,7 @@ function PostboxTargetRow({
{ id: "remove", label: "Remove target", icon: <Trash2 aria-hidden="true" />, disabled: locked, variant: "danger", onClick: onRemove }
]} />
</div>
<div className="form-grid compact responsive-form-grid campaign-postbox-target-grid">
<FormGrid columns={2} collapseAt="wide" className="campaign-postbox-target-grid">
<FormField label="Target type">
<select
value={target.mode}
@@ -219,7 +219,7 @@ function PostboxTargetRow({
onChange={onChange}
/>
}
</div>
</FormGrid>
</section>
);
}
@@ -1,3 +1,4 @@
import { DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react";
import {
Button,
@@ -264,7 +265,7 @@ export default function AddressSourceImportDialog({
{snapshot && (
<>
<dl className="detail-list recipient-import-summary">
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
<div>
<dt>Source</dt>
<dd>{snapshot.source_label}</dd>
@@ -295,7 +296,7 @@ export default function AddressSourceImportDialog({
<dt>Revision</dt>
<dd className="mono-small">{snapshot.source_revision}</dd>
</div>
</dl>
</DescriptionList>
{snapshot.excluded_count > 0 && (
<DismissibleAlert tone="warning" compact dismissible={false}>
{snapshot.excluded_count} contact point{snapshot.excluded_count === 1 ? " was" : "s were"} excluded by effective communication-governance facts.
@@ -1,3 +1,4 @@
import { DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react";
import {
Button,
@@ -294,7 +295,7 @@ export default function DistributionListImportDialog({
)}
{preview && (
<>
<dl className="detail-list recipient-import-summary">
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
<div><dt>List</dt><dd>{preview.source.name}</dd></div>
<div><dt>Revision</dt><dd>{preview.source.revision}</dd></div>
<div><dt>Included</dt><dd>{preview.recipients.length}</dd></div>
@@ -302,7 +303,7 @@ export default function DistributionListImportDialog({
<div><dt>Providers</dt><dd>{preview.provider_evidence.length}</dd></div>
<div><dt>Route decisions</dt><dd>{unresolvedRoutes.length ? `${unresolvedRoutes.length} required` : "Complete"}</dd></div>
<div><dt>State</dt><dd>{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}</dd></div>
</dl>
</DescriptionList>
{preview.stale && (
<DismissibleAlert tone="warning" compact dismissible={false}>
At least one provider result is stale. Review its diagnostics before freezing this expansion.
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from "react";
import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
import {
ActionToolbar,
Button,
Dialog,
DismissibleAlert,
@@ -309,7 +310,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
return (
<section className="recipient-address-category" tabIndex={locked ? undefined : 0} onPaste={handlePaste}>
<div className="recipient-address-category-header">
<ActionToolbar surface="section-header" className="recipient-address-category-header">
<h3>{column.label}</h3>
<div className="recipient-address-category-controls">
{onMergeChange ?
@@ -322,7 +323,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
onChange={onMergeChange} /> :
null}
</div>
</div>
</ActionToolbar>
<div className="recipient-address-lines">
{column.allowMultiple && draftAddresses.length === 0 &&
@@ -1,3 +1,4 @@
import { DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../../types";
import {
@@ -506,12 +507,12 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
</div>
{table &&
<>
<dl className="detail-list recipient-import-summary">
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
<div><dt>{sourceType === "xlsx" ? "i18n:govoplan-campaign.sheet.53bc47a7" : "i18n:govoplan-campaign.separator.b4b289a7"}</dt><dd>{sourceType === "xlsx" ? selectedSheet?.name ?? "i18n:govoplan-campaign.workbook.b4418e62" : formatDelimiter(table.delimiter)}</dd></div>
<div><dt>i18n:govoplan-campaign.header_rows.9814b9e3</dt><dd>{table.headerRows.length}</dd></div>
<div><dt>i18n:govoplan-campaign.data_rows.3734724c</dt><dd>{table.dataRows.length}</dd></div>
<div><dt>i18n:govoplan-campaign.columns.cf723c59</dt><dd>{table.headers.length}</dd></div>
</dl>
</DescriptionList>
<RecipientImportRawTable tableRows={table.rows} headerRowCount={table.headerRows.length} columnCount={table.headers.length} />
</>
}
@@ -543,12 +544,12 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
<>
{preview &&
<>
<dl className="detail-list recipient-import-summary">
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
<div><dt>i18n:govoplan-campaign.rows.52d0b352</dt><dd>{preview.validCount} i18n:govoplan-campaign.valid.210cece7 {preview.invalidCount} invalid</dd></div>
<div><dt>i18n:govoplan-campaign.fields.e8b68527</dt><dd>{preview.fieldNamesToCreate.length} new</dd></div>
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{preview.patternCount} from {patternRows} i18n:govoplan-campaign.row_s.61464061</dd></div>
<div><dt>i18n:govoplan-campaign.source.6da13add</dt><dd>{preview.patternCount ? defaultAttachmentBasePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b" : "i18n:govoplan-campaign.no_patterns.5e1e46fa"}</dd></div>
</dl>
</DescriptionList>
{preview.fieldNamesToCreate.length > 0 &&
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
}
@@ -676,12 +677,12 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
{resolution &&
<>
<dl className="detail-list recipient-import-summary">
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{resolution.patterns.length}</dd></div>
<div><dt>i18n:govoplan-campaign.matched_files.f79c63bb</dt><dd>{resolution.files.length}</dd></div>
<div><dt>i18n:govoplan-campaign.already_linked.7d6c3dd5</dt><dd>{resolution.linkedFiles.length}</dd></div>
<div><dt>i18n:govoplan-campaign.need_linking.a7617722</dt><dd>{resolution.linkableFiles.length}</dd></div>
</dl>
</DescriptionList>
{resolution.unmatchedPatterns.length > 0 &&
<div className="recipient-import-unmatched-patterns">
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Link2, X } from "lucide-react";
import { Button, Dialog, DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import { Button, Dialog, DismissibleAlert, MetricCard, MetricGrid, i18nMessage } from "@govoplan/core-webui";
import type {
CampaignAttachmentPreviewFile,
@@ -10,7 +10,6 @@ import {
attachmentPreviewLinkableFiles,
attachmentPreviewMatchedFiles
} from "../utils/attachmentPreview";
import { WorkflowFact } from "./WorkflowNavigation";
export default function AttachmentLinkingPreview({
preview,
@@ -70,8 +69,8 @@ export default function AttachmentLinkingPreview({
</Button>
</div>
</div>
<div className="review-flow-fact-grid">
<WorkflowFact
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle"
label="i18n:govoplan-campaign.matched.1bf3ec5b"
value={
!loading && matchedFiles.length > 0 ? (
@@ -95,19 +94,19 @@ export default function AttachmentLinkingPreview({
)
}
/>
<WorkflowFact
<MetricCard density="compact" surface="subtle"
label="i18n:govoplan-campaign.linked.a089f600"
value={loading ? "..." : linkedCount || "—"}
/>
<WorkflowFact
<MetricCard density="compact" surface="subtle"
label="i18n:govoplan-campaign.need_link.fa4ab530"
value={loading ? "..." : unlinkedCount || "—"}
/>
<WorkflowFact
<MetricCard density="compact" surface="subtle"
label="i18n:govoplan-campaign.shared_source.7d4a1bf2"
value={loading ? "..." : preview?.shared_file_count ?? "—"}
/>
</div>
</MetricGrid>
{error && (
<DismissibleAlert tone="danger" compact dismissible={false}>
{error}
@@ -1,3 +1,4 @@
import { DescriptionList } from "@govoplan/core-webui";
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui";
import { asArray, asRecord, formatDateTime, humanize } from "../utils/campaignView";
@@ -28,7 +29,7 @@ export default function DeliveryJobDetailOverlay({
</Button>
}
>
<dl className="detail-list">
<DescriptionList variant="inline">
<div>
<dt>i18n:govoplan-campaign.recipient.90343260</dt>
<dd>
@@ -54,7 +55,7 @@ export default function DeliveryJobDetailOverlay({
<dd>{String(job.last_error)}</dd>
</div>
) : null}
</dl>
</DescriptionList>
{issues.length > 0 && (
<AttemptList
title="i18n:govoplan-campaign.message_issues.9092a7db"
@@ -90,7 +91,7 @@ function AttemptList({
{rows.length === 0 ? (
<p className="muted small-note">{emptyText}</p>
) : (
<dl className="detail-list">
<DescriptionList variant="inline">
{rows.map((row, index) => (
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
<dt>
@@ -129,7 +130,7 @@ function AttemptList({
</dd>
</div>
))}
</dl>
</DescriptionList>
)}
</section>
);
@@ -1,6 +1,7 @@
import { useState, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react";
import { type CSSProperties, type ReactNode } from "react";
import { LockKeyhole, type LucideIcon } from "lucide-react";
import {
Card,
InlineHelp,
StageRail,
i18nMessage,
@@ -107,7 +108,6 @@ export function WorkflowStage({
}) {
const Icon = stage.icon;
const locked = stage.state === "locked";
const [collapsed, setCollapsed] = useState(false);
const title = i18nMessage(stage.title);
const description = i18nMessage(stage.description);
const stateText = i18nMessage(stage.stateLabel);
@@ -127,11 +127,16 @@ export function WorkflowStage({
</div>
{nextState && <div className="review-flow-stage-line" />}
</div>
<article
className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`}
<Card
as="article"
collapsible
persistCollapse={false}
className={`review-flow-stage-card${locked ? " is-locked" : ""}`}
headerClassName="review-flow-stage-header"
bodyClassName="review-flow-stage-content"
actionsClassName="review-flow-stage-header-actions"
aria-disabled={locked || undefined}
>
<header className="card-header review-flow-stage-header">
title={
<h2>
<span>{title}</span>
{locked && (
@@ -153,54 +158,22 @@ export function WorkflowStage({
)}
<InlineHelp>{description}</InlineHelp>
</h2>
<div className="review-flow-stage-header-actions">
<button
type="button"
className="card-collapse-toggle"
aria-expanded={!collapsed}
aria-label={
collapsed
? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: title })
: i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: title })
}
title={
collapsed
? "i18n:govoplan-campaign.show_content.0528d8d2"
: "i18n:govoplan-campaign.show_header_only.24afefca"
}
onClick={() => setCollapsed((value) => !value)}
>
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
</button>
</div>
</header>
{!collapsed && (
<>
<div className="card-body review-flow-stage-content">{children}</div>
{locked && (
}
afterBody={locked ? (
<div className="review-flow-lock-message">
<span className="review-flow-lock-icon">
<LockKeyhole size={20} aria-hidden="true" />
</span>
<span>{lockReason}</span>
</div>
)}
</>
)}
</article>
) : undefined}
>
{children}
</Card>
</section>
);
}
export function WorkflowFact({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="review-flow-fact">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function stateLabel(state: FlowState): string {
switch (state) {
case "complete":
@@ -0,0 +1,75 @@
export type ContentFieldRequirement = {
path: string;
value_type: string;
label?: string | null;
required: boolean;
};
export type ContentFieldMismatch = {
path: string;
label: string;
expected: string;
actual?: string;
};
export type ContentLibraryCompatibility = {
compatible: boolean;
missing: ContentFieldMismatch[];
incompatible: ContentFieldMismatch[];
};
export function checkContentLibraryCompatibility(
requirements: readonly ContentFieldRequirement[],
availableFields: ReadonlyMap<string, string>
): ContentLibraryCompatibility {
const missing: ContentFieldMismatch[] = [];
const incompatible: ContentFieldMismatch[] = [];
for (const requirement of requirements) {
if (!requirement.required || !requirement.path.trim()) continue;
const path = normalizeContentFieldPath(requirement.path);
const actual = availableFields.get(path) ?? recipientAddressRequirementType(path, availableFields);
const mismatch = {
path,
label: requirement.label?.trim() || path,
expected: requirement.value_type || "string"
};
if (!actual) {
missing.push(mismatch);
} else if (!fieldTypesAreCompatible(mismatch.expected, actual)) {
incompatible.push({ ...mismatch, actual });
}
}
return {
compatible: missing.length === 0 && incompatible.length === 0,
missing,
incompatible
};
}
export function normalizeContentFieldPath(path: string): string {
return path.trim()
.replace(/^fields\./, "")
.replace(/^local\./, "local:")
.replace(/^global\./, "global:")
.replace(/^local::/, "local:")
.replace(/^global::/, "global:");
}
export function campaignContentFieldType(value: unknown): string {
if (Array.isArray(value)) return "array";
if (value && typeof value === "object") return "object";
if (typeof value === "boolean") return "boolean";
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
return "string";
}
function fieldTypesAreCompatible(expected: string, actual: string): boolean {
if (actual === "unknown" || expected === actual) return true;
return expected === "number" && (actual === "integer" || actual === "number");
}
function recipientAddressRequirementType(path: string, availableFields: ReadonlyMap<string, string>): string | undefined {
const match = /^local:(all_)?(from|to|reply_to|cc|bcc)(?:\.|$)/.exec(path);
if (!match) return undefined;
return availableFields.get(`local:${match[1] ?? ""}${match[2]}`);
}
@@ -28,7 +28,17 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
allow_individual: false,
send_without_attachments: true,
send_without_attachments_behavior: "continue",
reuse_policy: {
action: "allow",
allow_within: "none"
},
global: [],
residual_files: {
mode: "none",
recipient: null,
subject: "Unassigned files in campaign {{local:campaign_name}}",
text: "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}"
},
missing_behavior: "warn",
ambiguous_behavior: "ask",
...sourceAttachments
@@ -1,6 +1,7 @@
import { Link } from "react-router";
import {
PageTitle,
PageActionBar,
PageLayout,
WizardDirectory,
usePlatformUiCapabilities,
wizardEntriesForContext,
@@ -38,18 +39,19 @@ export default function WizardDirectoryPage({
});
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<PageTitle>i18n:govoplan-campaign.guided_workflows</PageTitle>
<Link className="btn btn-secondary" to="../">
<PageLayout
archetype="collection"
mode="workspace"
title="i18n:govoplan-campaign.guided_workflows"
actions={<PageActionBar variant="collection" contextActions={<Link className="btn btn-secondary" to="../">
i18n:govoplan-campaign.back_to_overview.ec986cba
</Link>
</div>
</Link>} />}
>
<WizardDirectory
entries={entries}
auth={auth}
emptyText="i18n:govoplan-campaign.no_guided_workflows"
/>
</div>
</PageLayout>
);
}
@@ -1,5 +1,6 @@
import { MetricGrid } from "@govoplan/core-webui";
import { useEffect, useState } from "react";
import { Button } from "@govoplan/core-webui";
import { FormGrid, Button } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
@@ -22,7 +23,7 @@ export type WizardStepProps = {
export function BasicsStep({ draft, patch }: WizardStepProps) {
const campaign = asRecord(draft.campaign);
return (
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<FormField label="i18n:govoplan-campaign.campaign_name.aa5d0e72" help="i18n:govoplan-campaign.a_human_readable_name_shown_in_lists_and_reports.afc23e7e">
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
</FormField>
@@ -39,7 +40,7 @@ export function BasicsStep({ draft, patch }: WizardStepProps) {
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
</FormField>
</div>);
</FormGrid>);
}
@@ -54,7 +55,7 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
const delivery = asRecord(draft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
return (
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
<EmailAddressInput
value={from}
@@ -108,7 +109,7 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
</FormField>
<p className="muted small-note">i18n:govoplan-campaign.select_the_delivery_profile_on_the_mail_settings.a89cbb5f</p>
<ToggleSwitch label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>);
</FormGrid>);
}
@@ -144,7 +145,7 @@ export function TemplateStep({ draft, patch }: WizardStepProps) {
<h2>i18n:govoplan-campaign.template.3ec1ae06</h2>
<p>i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b</p>
</div>
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<FormField label="i18n:govoplan-campaign.subject.8d183dbd"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
<div className="form-field">
@@ -157,7 +158,7 @@ export function TemplateStep({ draft, patch }: WizardStepProps) {
onChange={(value) => patch(["template", "html"], value)}
/>
</div>
</div>
</FormGrid>
</div>);
}
@@ -170,12 +171,12 @@ export function AttachmentsStep({ draft, patch }: WizardStepProps) {
<h2>i18n:govoplan-campaign.attachments.6771ade6</h2>
<p>i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2</p>
</div>
<div className="form-grid compact responsive-form-grid">
<FormGrid columns={2} collapseAt="wide" className="">
<FormField label="i18n:govoplan-campaign.campaign_attachment_base_path.84827619"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
<FormField label="i18n:govoplan-campaign.missing_behavior.de3d1ac7"><select value={getText(attachments, "missing_behavior", "warn")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="i18n:govoplan-campaign.ambiguous_behavior.e86e724b"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<ToggleSwitch label="i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
</div>
</FormGrid>
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
</div>);
@@ -189,11 +190,11 @@ export function ReviewStep({ version, onValidate }: {version: unknown;onValidate
<h2>i18n:govoplan-campaign.review_setup.d0059acb</h2>
<p>i18n:govoplan-campaign.validate_the_campaign_definition_before_building.d36ee1dc</p>
</div>
<div className="metric-grid inside">
<MetricGrid spacing="inset">
<MetricCard label="i18n:govoplan-campaign.errors.805e86a8" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.built.a6ad3f82" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
</div>
</MetricGrid>
<Button variant="primary" onClick={onValidate}>i18n:govoplan-campaign.validate_campaign.6934c1c2</Button>
</div>);
@@ -208,10 +209,10 @@ export function SendStep({ draft, patch }: WizardStepProps) {
<h2>i18n:govoplan-campaign.send_preparation.6e078f43</h2>
<p>i18n:govoplan-campaign.configure_rate_limits_and_prepare_the_final_send.af3d4e48</p>
</div>
<div className="form-grid compact responsive-form-grid">
<FormGrid columns={2} collapseAt="wide" className="">
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
</div>
</FormGrid>
<p className="muted">i18n:govoplan-campaign.test_send_and_queue_actions_remain_in_the_send_w.60dee716</p>
</div>);
@@ -1,3 +1,4 @@
import { MetricGrid } from "@govoplan/core-webui";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Ban, ExternalLink, Eye, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react";
import { useSearchParams } from "react-router";
@@ -25,11 +26,9 @@ import {
type DataGridListOption,
type DataGridQueryState
} from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { DismissibleAlert, PageActionBar, PageLayout } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { PageScrollViewport } from "@govoplan/core-webui";
import {
StatusBadge,
TableActionGroup,
@@ -757,26 +756,29 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
], [navigate, permissions.canOpenReport, selectedRow, selectedVersionId]);
return (
<PageScrollViewport>
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
<PageLayout
archetype="collection"
mode="workspace"
title="i18n:govoplan-campaign.operator_queue.72492fb5"
description={
<>
<p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</p>
<p className="muted small-note">i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => void refreshAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{jobsError && <DismissibleAlert tone="danger" resetKey={jobsError} floating>{jobsError}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
</>
}
error={error}
success={message}
actions={<PageActionBar
variant="collection"
refreshable
reloadAction={{ onReload: () => void refreshAll(), loading: loading || jobsLoading }}
/>}
notices={jobsError ? <DismissibleAlert tone="danger" resetKey={jobsError} floating>{jobsError}</DismissibleAlert> : undefined}
>
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
<div className="metric-grid queue-pressure-grid">
<MetricGrid columns="auto" spacing="none" className="queue-pressure-metrics">
<MetricCard label="i18n:govoplan-campaign.queued.6a599877" value={totals.queued} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.claimed.83c87884" value={totals.claimed} tone="info" />
<MetricCard label="i18n:govoplan-campaign.sending.ceafde86" value={totals.sending} tone="info" />
@@ -786,7 +788,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
<MetricCard label="i18n:govoplan-campaign.unattempted.e7411dd6" value={totals.notAttempted} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.paused.c7dfb6f1" value={totals.paused} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.imap_failed.50dbca55" value={totals.imapFailed} tone="warning" />
</div>
</MetricGrid>
</section>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
@@ -847,8 +849,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
if (cancelTarget) void runAction(cancelTarget, "cancel");
}}
onCancel={() => setCancelTarget(null)} />
</div>
</PageScrollViewport>);
</PageLayout>);
}
@@ -1,15 +1,14 @@
import { DescriptionList } from "@govoplan/core-webui";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Eye, RefreshCw } from "lucide-react";
import { Eye } from "lucide-react";
import { useSearchParams } from "react-router";
import {
Button,
import { ContentGrid,
Card,
DataGrid,
DismissibleAlert,
LoadingFrame,
MetricCard,
PageScrollViewport,
PageTitle,
PageActionBar,
PageLayout,
StatusBadge,
TableActionGroup,
formatDateTime,
@@ -167,24 +166,22 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
const outcomes = report?.outcomes;
return (
<PageScrollViewport className="aggregate-reports-page">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={listLoading || reportLoading}>i18n:govoplan-campaign.reports.88bc3fe3</PageTitle>
<p className="muted">i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d</p>
</div>
<div className="button-row compact-actions">
<Button
onClick={() => void Promise.all([loadCampaigns(), selectedFromUrl ? loadReport(selectedFromUrl) : Promise.resolve()])}
disabled={listLoading || reportLoading}
>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-campaign.refresh.56e3badc
</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<PageLayout
archetype="overview"
mode="workspace"
className="aggregate-reports-page"
title="i18n:govoplan-campaign.reports.88bc3fe3"
description="i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d"
error={error}
actions={<PageActionBar
variant="overview"
refreshable
reloadAction={{
onReload: () => void Promise.all([loadCampaigns(), selectedFromUrl ? loadReport(selectedFromUrl) : Promise.resolve()]),
loading: listLoading || reportLoading
}}
/>}
>
<Card title="i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403">
<LoadingFrame loading={listLoading} label="i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8">
@@ -203,17 +200,17 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
{report && outcomes && (
<>
<Card title={report.campaign.name}>
<dl className="detail-list">
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={report.campaign.status} /></dd></div>
<div><dt>i18n:govoplan-campaign.completion.2ff25568</dt><dd><StatusBadge status={report.completion_state} label={aggregateCompletionLabel(report.completion_state)} /></dd></div>
<div><dt>i18n:govoplan-campaign.version.2da600bf</dt><dd>{report.version_number ?? "—"}</dd></div>
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(report.generated_at)}</dd></div>
</dl>
</DescriptionList>
</Card>
{/* Aggregate outcome cards are deliberately not filter shortcuts: each subgroup
would require a separately suppressed aggregate response from the server. */}
<div className="dashboard-grid">
<ContentGrid columns={2} collapseAt="workspace" className="">
<MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" />
<MetricCard label="Postbox accepted" value={countValue(outcomes.postbox_accepted)} tone="good" />
<MetricCard label="Print accepted" value={countValue(outcomes.print_accepted)} tone="good" />
@@ -225,22 +222,22 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
<MetricCard label="i18n:govoplan-campaign.not_attempted.e1be3c69" value={countValue(outcomes.not_attempted)} />
<MetricCard label="i18n:govoplan-campaign.cancelled.a1bf92ef" value={countValue(outcomes.cancelled)} />
<MetricCard label="i18n:govoplan-campaign.excluded.9804952b" value={countValue(outcomes.excluded)} />
</div>
</ContentGrid>
<Card title="i18n:govoplan-campaign.population_and_exclusions.cdff62ad">
<dl className="detail-list">
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-campaign.report_denominator.d089074a</dt><dd>{countValue(report.population.denominator)}</dd></div>
<div><dt>i18n:govoplan-campaign.inactive_source_entries.88fa0cf8</dt><dd>{countValue(report.population.inactive_source_entries)}</dd></div>
<div><dt>i18n:govoplan-campaign.excluded_or_blocked_jobs.e2190d47</dt><dd>{countValue(report.population.excluded_or_blocked_jobs)}</dd></div>
</dl>
</DescriptionList>
<p className="muted">i18n:govoplan-campaign.all_persisted_recipient_delivery_jobs_for_the_se.208e90cc</p>
</Card>
<Card title="i18n:govoplan-campaign.delivery_activity_range.0d1b62ec">
<dl className="detail-list">
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-campaign.first_activity.3fe36ef4</dt><dd>{report.time_range.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : formatDateTime(report.time_range.first_activity_at ?? undefined)}</dd></div>
<div><dt>i18n:govoplan-campaign.last_activity.1c73b806</dt><dd>{report.time_range.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : formatDateTime(report.time_range.last_activity_at ?? undefined)}</dd></div>
</dl>
</DescriptionList>
</Card>
<Card title="i18n:govoplan-campaign.privacy_boundary.d37e12bc">
@@ -252,8 +249,7 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
</>
)}
</LoadingFrame>
</div>
</PageScrollViewport>
</PageLayout>
);
}
+38 -38
View File
@@ -1,9 +1,9 @@
import { DescriptionList } from "@govoplan/core-webui";
import { useMemo, useState } from "react";
import { ExternalLink } from "lucide-react";
import { Button } from "@govoplan/core-webui";
import { FormGrid, ContentGrid, Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageScrollViewport } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { PageActionBar, PageLayout, WorkspaceLayout } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { StatusBadge, TableActionGroup, i18nMessage } from "@govoplan/core-webui";
import { usePlatformLanguage } from "@govoplan/core-webui";
@@ -137,20 +137,24 @@ export default function TemplatesPage() {
if (selectedTemplate) {
return (
<div className="workspace module-workspace">
<ModuleSubnav active={active} groups={templateSubnav(() => setSelectedId(null))} onSelect={setActive} />
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>{selectedTemplate.name}</PageTitle>
<p>{selectedTemplate.description}</p>
</div>
<div className="button-row compact-actions">
<WorkspaceLayout
className="module-workspace"
primary={<ModuleSubnav active={active} groups={templateSubnav(() => setSelectedId(null))} onSelect={setActive} />}
primaryLabel="Template sections"
contentLabel="Template workspace"
interfaceId="campaign.template-workspace"
helpModuleId="campaign"
>
<PageLayout
archetype="detail"
mode="workspace"
title={selectedTemplate.name}
description={selectedTemplate.description}
actions={<PageActionBar variant="detail" primaryActions={<>
<Button disabled>i18n:govoplan-campaign.duplicate.972d5737</Button>
<Button variant="primary" disabled>i18n:govoplan-campaign.use_in_campaign.779163bc</Button>
</div>
</div>
</>} />}
>
{active === "overview" && <TemplateOverview template={selectedTemplate} />}
{active === "content" && <TemplateContent template={selectedTemplate} />}
@@ -158,25 +162,22 @@ export default function TemplatesPage() {
{active === "preview" && <TemplatePreview template={selectedTemplate} />}
{active === "usage" && <TemplateUsage template={selectedTemplate} />}
{active === "versions" && <TemplateVersions template={selectedTemplate} />}
</div>
</section>
</div>);
</PageLayout>
</WorkspaceLayout>);
}
return (
<PageScrollViewport>
<div className="content-pad workspace-data-page module-entry-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>i18n:govoplan-campaign.templates.f25b700e</PageTitle>
<p>i18n:govoplan-campaign.reusable_message_templates_open_a_template_to_ed.792e2afb</p>
</div>
<div className="button-row compact-actions">
<PageLayout
archetype="collection"
className="module-entry-page"
title="i18n:govoplan-campaign.templates.f25b700e"
description="i18n:govoplan-campaign.reusable_message_templates_open_a_template_to_ed.792e2afb"
actions={<PageActionBar variant="collection" contextActions={<>
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
<Button variant="primary" disabled>i18n:govoplan-campaign.export.f3e4fadb</Button>
</div>
</div>
</>} />}
>
<Card
title={
@@ -196,21 +197,20 @@ export default function TemplatesPage() {
className="compact-table-wrap module-table-wrap module-entry-table" />
</Card>
</div>
</PageScrollViewport>);
</PageLayout>);
}
function TemplateOverview({ template }: {template: TemplateRecord;}) {
return (
<div className="dashboard-grid settings-dashboard-grid">
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="i18n:govoplan-campaign.template_status.8bec97cf">
<dl className="detail-list compact-detail-list">
<DescriptionList variant="inline" density="compact">
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={template.status} /></dd></div>
<div><dt>i18n:govoplan-campaign.type.3deb7456</dt><dd>{templateTypeLabel(template.type)}</dd></div>
<div><dt>i18n:govoplan-campaign.updated.f2f8570d</dt><dd>{template.updatedAt}</dd></div>
<div><dt>i18n:govoplan-campaign.versions.a239107e</dt><dd>{template.versions}</dd></div>
</dl>
</DescriptionList>
</Card>
<Card title="i18n:govoplan-campaign.compatibility_notes.7d648a6d">
<p className="muted">i18n:govoplan-campaign.campaigns_can_reuse_this_template_but_each_campa.bdb25009</p>
@@ -220,7 +220,7 @@ function TemplateOverview({ template }: {template: TemplateRecord;}) {
<span>i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3</span>
</div>
</Card>
</div>);
</ContentGrid>);
}
@@ -230,10 +230,10 @@ function TemplateContent({ template }: {template: TemplateRecord;}) {
const body = translateText(template.body);
return (
<Card title="i18n:govoplan-campaign.template_content.48630575" actions={<Button disabled>i18n:govoplan-campaign.save_changes.179359b3</Button>}>
<div className="form-grid compact responsive-form-grid">
<FormGrid columns={2} collapseAt="wide" className="">
<label className="form-field"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_subject_stored_in_this_reusable_templa.549ae246">i18n:govoplan-campaign.subject.8d183dbd</FieldLabel><input value={subject} readOnly /></label>
<label className="form-field full-span"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_body_stored_in_this_reusable_template_.98fbf178">i18n:govoplan-campaign.body.718a7e8a</FieldLabel><textarea rows={10} value={body} readOnly /></label>
</div>
</FormGrid>
</Card>);
}
@@ -284,10 +284,10 @@ function TemplateUsage({ template }: {template: TemplateRecord;}) {
return (
<Card title="i18n:govoplan-campaign.usage.0bb18642">
<p className="muted">i18n:govoplan-campaign.this_view_will_list_campaigns_using_the_template.116f7ee0</p>
<dl className="detail-list compact-detail-list">
<DescriptionList variant="inline" density="compact">
<div><dt>i18n:govoplan-campaign.currently_used_by.a065cfbe</dt><dd>{template.usedBy}</dd></div>
<div><dt>i18n:govoplan-campaign.safe_to_edit.57b64df5</dt><dd>i18n:govoplan-campaign.requires_versioning_once_templates_are_shared.e020b221</dd></div>
</dl>
</DescriptionList>
</Card>);
}
+6
View File
@@ -2,6 +2,9 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-campaign.unassigned_file_policy": "Unassigned file policy",
"i18n:govoplan-campaign.unassigned_files_detected": "Unassigned files detected",
"i18n:govoplan-campaign.watched_sources": "Watched sources",
"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",
@@ -1343,6 +1346,9 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
},
"de": {
"i18n:govoplan-campaign.unassigned_file_policy": "Richtlinie für nicht zugeordnete Dateien",
"i18n:govoplan-campaign.unassigned_files_detected": "Erkannte nicht zugeordnete Dateien",
"i18n:govoplan-campaign.watched_sources": "Überwachte Quellen",
"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",
+104 -115
View File
@@ -8,7 +8,7 @@
.wizard-action-card {
min-height: 176px;
border: var(--border-line);
border-radius: 6px;
border-radius: var(--radius-compact);
background: var(--panel-soft);
padding: 16px;
display: flex;
@@ -47,7 +47,7 @@
color: var(--text);
background: var(--panel-soft);
border: var(--border-line);
border-radius: 6px;
border-radius: var(--radius-compact);
padding: 14px;
font: inherit;
line-height: 1.5;
@@ -62,7 +62,7 @@
align-items: center;
min-height: 28px;
border: var(--border-line);
border-radius: 999px;
border-radius: var(--radius-pill);
background: var(--panel-soft);
padding: 4px 10px;
color: var(--text-strong);
@@ -70,7 +70,7 @@
font-weight: 700;
}
@media (max-width: 1000px) {
@media (max-width: 1100px) {
.wizard-action-grid { grid-template-columns: 1fr; }
}
.locked-wizard-card { grid-template-columns: 1fr; }
@@ -133,12 +133,6 @@
font-size: 14px;
letter-spacing: .01em;
}
.subsection-heading.split {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toggle-grid {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
@@ -149,7 +143,7 @@
}
.toggle-span-full {
border: var(--border-line);
border-radius: 8px;
border-radius: var(--radius-md);
background: var(--panel-soft);
padding: 10px 12px;
}
@@ -210,7 +204,7 @@
min-height: 88px;
padding: 14px;
border: var(--border-line);
border-radius: 12px;
border-radius: var(--radius-lg);
background: var(--panel-soft);
color: inherit;
text-decoration: none;
@@ -239,7 +233,7 @@
gap: 10px;
padding: 14px;
border: var(--border-line);
border-radius: 12px;
border-radius: var(--radius-lg);
background: var(--panel-soft);
}
.template-preview-box h3 {
@@ -260,7 +254,7 @@
.override-toggle-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
}
@media (max-width: 780px) {
@media (max-width: 760px) {
.related-link-grid,
.override-toggle-grid { grid-template-columns: 1fr; }
}
@@ -299,20 +293,16 @@
color: var(--text-strong);
font-size: 16px;
}
.queue-pressure-grid {
grid-template-columns: repeat(5, minmax(112px, 1fr));
margin: 0;
}
.queue-pressure-grid .metric-card {
.queue-pressure-metrics {
min-width: 0;
}
.queue-pressure-grid .metric-label {
.queue-pressure-metrics .metric-card {
min-width: 0;
}
.queue-pressure-metrics .metric-label {
min-height: 2.4em;
}
@media (max-width: 1150px) {
.queue-pressure-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
}
.module-table th,
.module-table td {
white-space: nowrap;
@@ -404,7 +394,7 @@
line-height: 1.55;
}
@media (max-width: 980px) {
@media (max-width: 1100px) {
.module-entry-table {
min-width: 880px;
}
@@ -421,7 +411,7 @@
.overview-config-card {
min-height: 218px;
border: var(--border-line);
border-radius: 6px;
border-radius: var(--radius-compact);
background: linear-gradient(var(--control-gradient-start), var(--panel-soft));
box-shadow: var(--shadow-campaign-soft);
padding: 16px;
@@ -546,7 +536,7 @@
width: 100%;
min-height: 360px;
border: var(--border-line);
border-radius: 8px;
border-radius: var(--radius-md);
background: var(--surface);
}
.template-action-dialog {
@@ -583,7 +573,7 @@
white-space: nowrap;
}
@media (max-width: 720px) {
@media (max-width: 760px) {
.template-preview-toolbar {
display: grid;
}
@@ -680,7 +670,7 @@
background: var(--panel-soft);
}
.recipient-add-row .email-address-input {
max-width: 720px;
max-width: 760px;
}
@media (max-width: 1100px) {
@@ -689,7 +679,7 @@
.attachment-base-grid { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
@media (max-width: 900px) {
.campaign-identity-grid { grid-template-columns: 1fr; }
}
@@ -711,7 +701,7 @@
justify-content: center;
min-height: 24px;
border: var(--border-line);
border-radius: 999px;
border-radius: var(--radius-pill);
background: var(--panel-soft);
color: var(--muted);
font-size: 11px;
@@ -756,7 +746,7 @@
}
.attachment-rules-empty {
border: 1px dashed var(--line-dark);
border-radius: 8px;
border-radius: var(--radius-md);
background: var(--panel-soft);
padding: 14px;
}
@@ -810,7 +800,7 @@
display: grid;
gap: 12px;
border: var(--border-line);
border-radius: 12px;
border-radius: var(--radius-lg);
background: var(--panel-soft);
padding: 14px;
min-width: 0;
@@ -966,10 +956,6 @@
margin-top: 16px;
}
.campaign-postbox-target-modal {
width: min(1040px, calc(100vw - 32px));
}
.campaign-postbox-target-body {
max-height: min(76vh, 820px);
overflow: auto;
@@ -1050,19 +1036,6 @@
padding: 10px;
}
.recipient-address-category-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.recipient-address-category-header h3 {
margin: 0;
font-size: 15px;
color: var(--text-strong);
}
.recipient-address-category-controls {
display: flex;
align-items: center;
@@ -1141,7 +1114,7 @@
justify-content: center;
min-height: 24px;
border: var(--border-line);
border-radius: 999px;
border-radius: var(--radius-pill);
background: var(--panel-soft);
color: var(--muted);
font-size: 11px;
@@ -1165,7 +1138,7 @@
.recipient-data-table td:nth-child(3) { min-width: 192px; }
.recipient-index-cell { white-space: nowrap; }
@media (max-width: 720px) {
@media (max-width: 760px) {
.recipient-address-empty-row,
.recipient-address-edit-line {
grid-template-columns: 1fr;
@@ -1228,7 +1201,7 @@
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
border-radius: var(--radius-pill);
color: var(--text);
text-decoration: none;
line-height: 1;
@@ -1302,7 +1275,7 @@
max-height: 260px;
overflow: auto;
border: 1px solid var(--line-subtle);
border-radius: 12px;
border-radius: var(--radius-lg);
background: var(--panel-glass);
color: var(--text);
padding: 12px;
@@ -1425,7 +1398,7 @@
min-width: min(220px, 100%);
max-width: 100%;
border: 1px solid var(--line-subtle);
border-radius: 12px;
border-radius: var(--radius-lg);
background: var(--panel-glass);
padding: 0.55rem 0.7rem;
color: var(--text);
@@ -1456,7 +1429,7 @@
color: var(--text);
}
@media (max-width: 720px), (max-height: 700px) {
@media (max-width: 760px), (max-height: 700px) {
.message-preview-backdrop {
padding: 8px;
}
@@ -1500,7 +1473,7 @@
/* Review & Send workflow page. */
.review-send-page {
--review-flow-partial: var(--review-flow-purple, #9b86c7);
--review-flow-partial: var(--data-category-purple);
}
.review-flow-navigation {
@@ -1549,7 +1522,7 @@
place-items: center;
flex: 0 0 auto;
border: 1px solid var(--review-stage-color);
border-radius: 50%;
border-radius: var(--radius-round);
color: color-mix(in srgb, var(--review-stage-color) 82%, var(--text));
background: color-mix(in srgb, var(--review-stage-color) 9%, var(--surface));
box-shadow: 0 0 0 4px var(--bg), 0 4px 12px var(--campaign-stage-shadow-color);
@@ -1564,7 +1537,7 @@
flex: 1 1 auto;
min-height: 54px;
margin: 4px 0 -14px;
border-radius: 999px;
border-radius: var(--radius-pill);
background: linear-gradient(
to bottom,
var(--review-stage-line-color, var(--review-stage-color)),
@@ -1795,7 +1768,7 @@
width: min(430px, calc(100% - 44px));
height: 110px;
border: 1px solid var(--campaign-panel-border);
border-radius: 10px;
border-radius: var(--radius-lg);
content: "";
transform: translate(-50%, -50%);
background: var(--campaign-panel-bg);
@@ -1824,48 +1797,11 @@
display: grid;
place-items: center;
border: var(--border-line-dark);
border-radius: 50%;
border-radius: var(--radius-round);
color: var(--muted);
background: var(--surface);
}
.review-flow-fact-grid,
.review-flow-execution-summary {
display: grid;
grid-template-columns: repeat(4, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.review-flow-fact,
.review-flow-execution-summary > div {
display: grid;
gap: 5px;
min-width: 0;
padding: 12px 14px;
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
}
.review-flow-fact span,
.review-flow-execution-summary span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
}
.review-flow-fact strong,
.review-flow-execution-summary strong {
min-width: 0;
overflow: hidden;
color: var(--text-strong);
font-size: 16px;
text-overflow: ellipsis;
}
.review-flow-inline-note {
display: flex;
align-items: center;
@@ -1937,7 +1873,7 @@
padding: 12px 14px;
border: var(--border-line);
border-left: 3px solid var(--blue);
border-radius: 6px;
border-radius: var(--radius-compact);
background: var(--panel-soft);
}
@@ -2017,13 +1953,6 @@
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--review-stage-color) 18%, transparent), 0 4px 12px var(--campaign-stage-shadow-color); }
}
@media (max-width: 980px) {
.review-flow-fact-grid,
.review-flow-execution-summary {
grid-template-columns: repeat(2, minmax(130px, 1fr));
}
}
@media (max-width: 680px) {
.review-flow-stage {
grid-template-columns: 36px minmax(0, 1fr);
@@ -2044,11 +1973,6 @@
.review-flow-lock-message {
inset: 100px 0 0;
}
.review-flow-fact-grid,
.review-flow-execution-summary {
grid-template-columns: 1fr;
}
.deliverability-preflight-header {
align-items: flex-start;
flex-direction: column;
@@ -2109,7 +2033,7 @@
justify-content: space-between;
gap: 10px;
border: 1px solid transparent;
border-radius: 5px;
border-radius: var(--radius-xs);
background: transparent;
padding: 6px 8px;
color: var(--text);
@@ -2219,7 +2143,7 @@
display: grid;
gap: 0.65rem;
border: 2px solid var(--line-strong, var(--line-subtle));
border-radius: 14px;
border-radius: var(--radius-xl);
padding: 0.75rem;
background: var(--panel-glass);
}
@@ -2298,7 +2222,7 @@
overflow: auto;
padding: 12px;
border: var(--border-line);
border-radius: 8px;
border-radius: var(--radius-md);
background: var(--surface-muted);
white-space: pre-wrap;
word-break: break-word;
@@ -2360,7 +2284,7 @@
.attempt-history-wrap {
overflow: auto;
border: var(--border-line);
border-radius: 8px;
border-radius: var(--radius-md);
}
.attempt-history-table {
@@ -2456,7 +2380,7 @@
display: grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--line));
border-radius: 50%;
border-radius: var(--radius-round);
color: color-mix(in srgb, var(--accent) 82%, var(--text));
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
font-weight: 700;
@@ -2781,3 +2705,68 @@
animation: none;
}
}
.campaign-copy-dialog { width: min(760px, calc(100vw - 32px)); }
.campaign-copy-form { display: grid; gap: 16px; }
.campaign-copy-form > p { margin: 0; }
.campaign-copy-identity { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, .65fr); gap: 12px; }
.campaign-copy-identity input { width: 100%; }
.campaign-copy-options { border: var(--border-line); }
.campaign-copy-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 18px; min-height: 66px; padding: 10px 12px; border-bottom: var(--border-line); }
.campaign-copy-option:last-child { border-bottom: 0; }
.campaign-copy-option strong, .campaign-copy-option small { display: block; }
.campaign-copy-option small { margin-top: 3px; color: var(--muted); line-height: 1.35; }
.campaign-copy-option.is-disabled { opacity: .62; }
@media (max-width: 760px) {
.campaign-copy-identity { grid-template-columns: 1fr; }
.campaign-copy-option { grid-template-columns: 1fr; }
}
.campaign-content-library-dialog { width: min(880px, calc(100vw - 32px)); }
.campaign-content-library, .campaign-content-save-form { display: grid; gap: 14px; }
.campaign-content-library input, .campaign-content-save-form input, .campaign-content-save-form textarea { width: 100%; }
.campaign-content-library-list { display: grid; max-height: min(56vh, 560px); overflow: auto; border: var(--border-line); }
.campaign-content-library-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 12px; border-bottom: var(--border-line); }
.campaign-content-library-item:last-child { border-bottom: 0; }
.campaign-content-library-item:hover { background: var(--hover-bg); }
.campaign-content-library-item-copy { min-width: 0; }
.campaign-content-library-item-copy p { margin: 4px 0; }
.campaign-content-library-item-copy small { color: var(--muted); }
.campaign-content-library-item-title { display: flex; align-items: baseline; gap: 8px; }
.campaign-content-library-item-title span { color: var(--muted); font-size: var(--font-size-sm); }
.campaign-content-library-item-actions { justify-content: flex-end; }
.campaign-content-save-identity { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; }
.campaign-content-library-compatibility { display: grid; gap: 3px; margin-top: 8px; color: var(--warning-text); font-size: .875rem; }
.campaign-content-save-form > p { margin: 0; }
@media (max-width: 760px) {
.campaign-content-library-item, .campaign-content-save-identity { grid-template-columns: 1fr; }
.campaign-content-library-item-actions { justify-content: flex-start; }
}
.campaign-schedule-dialog { width: min(760px, calc(100vw - 32px)); }
.campaign-schedule-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-block: 12px; }
.campaign-schedule-interval { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 8px; }
.campaign-schedule-list { display: grid; gap: 4px; }
.campaign-schedule-row { display: flex; align-items: center; justify-content: space-between; min-height: 52px; gap: 12px; padding: 8px; border-radius: var(--radius-sm); }
.campaign-schedule-row:hover { background: var(--hover-bg); }
.campaign-schedule-main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; min-width: 0; }
.campaign-schedule-main strong { flex-basis: 100%; }
.campaign-schedule-main span { color: var(--muted); font-size: var(--font-size-sm); }
.campaign-schedule-main .danger-text { color: var(--danger); }
@media (max-width: 760px) {
.campaign-schedule-form { grid-template-columns: minmax(0, 1fr); }
.campaign-schedule-row { align-items: flex-start; flex-direction: column; }
}
.campaign-residual-file-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
.campaign-residual-file-wide { grid-column: 1 / -1; }
.campaign-residual-file-form input,
.campaign-residual-file-form select,
.campaign-residual-file-form textarea { width: 100%; }
@media (max-width: 760px) {
.campaign-residual-file-form { grid-template-columns: minmax(0, 1fr); }
.campaign-residual-file-wide { grid-column: auto; }
}
@@ -17,6 +17,26 @@ const sources = sourceFiles(sourceRoot).map((file) => ({
value: fs.readFileSync(file, "utf8"),
}));
for (const { file, value } of sources) {
assert.ok(
!value.includes('className="content-pad workspace-data-page'),
`${path.relative(sourceRoot, file)} must use Core PageLayout instead of a raw page frame`,
);
assert.ok(
!/<div\s+className="workspace(?:\s|\")/.test(value),
`${path.relative(sourceRoot, file)} must use Core WorkspaceLayout instead of a raw workspace`,
);
}
for (const relativePath of [
"features/campaigns/CampaignWorkspace.tsx",
"features/campaigns/CampaignModulePage.tsx",
"features/templates/TemplatesPage.tsx",
]) {
const value = fs.readFileSync(path.join(sourceRoot, relativePath), "utf8");
assert.ok(value.includes("<WorkspaceLayout"), `${relativePath} must retain the shared workspace shell`);
}
const nonSemanticClicks = sources.flatMap(({ file, value }) =>
[...value.matchAll(/<(div|span|li|tr)\b[^>]*\bonClick\s*=/g)].map(
(match) => `${path.relative(sourceRoot, file)}:${match.index}`,
@@ -11,6 +11,8 @@ const reviewPresentation = readFileSync(
"utf8"
);
const operatorPage = readFileSync("src/features/operator/OperatorQueuePage.tsx", "utf8");
const overviewPage = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8");
const attachmentsPage = readFileSync("src/features/campaigns/AttachmentsDataPage.tsx", "utf8");
const deliveryMode = readFileSync("src/features/campaigns/utils/deliveryMode.ts", "utf8");
const translationCatalog = readFileSync("src/i18n/generatedTranslations.ts", "utf8");
@@ -49,6 +51,13 @@ const outcomeCardsStart = page.indexOf("Aggregate outcome cards are deliberately
const outcomeCardsEnd = page.indexOf('<Card title="i18n:govoplan-campaign.population_and_exclusions', outcomeCardsStart);
assert(outcomeCardsStart >= 0 && outcomeCardsEnd > outcomeCardsStart, "the aggregate privacy boundary is documented beside the outcome cards");
assert(!page.slice(outcomeCardsStart, outcomeCardsEnd).includes("onClick"), "aggregate outcome cards cannot create unsuppressed filtered subgroups");
assert(!page.slice(outcomeCardsStart, outcomeCardsEnd).includes("drilldown="), "privacy-suppressed aggregates remain informational metrics");
assert(overviewPage.includes("drilldown={{"), "campaign overview metrics expose explicit destinations");
assert(overviewPage.includes("openSection(\"recipients\")"), "the recipient count opens its owning collection");
assert(attachmentsPage.includes('revealAttachmentSection("campaign-global-attachments")'), "attachment counts reveal their underlying editor section");
assert(attachmentsPage.includes("openRecipients"), "per-recipient attachment counts open recipient details");
assert(reviewPage.includes("showBuiltDetails({ validation:"), "review counts apply a relevant table filter");
assert(reviewPage.includes('revealReviewElement("campaign-attachment-preview")'), "attachment-result counts reveal the evidence preview");
assert(!page.includes("{report.population.denominator_definition}"), "the known denominator contract uses a localized UI explanation");
assert(!page.includes("{report.privacy.rule}"), "the known privacy contract uses a localized UI explanation");
assert(page.includes("all_persisted_recipient_delivery_jobs_for_the_se.208e90cc"), "the denominator explanation is bilingual");
@@ -15,7 +15,13 @@ assert.match(overview, /pendingLifecycleAction/);
assert.match(overview, /archive_campaign_confirmation/);
assert.match(overview, /await archiveCampaign\(settings, campaign\.id, pending\.policy\.state_token\)/);
assert.match(overview, /await deleteCampaign\(settings, campaign\.id, pending\.policy\.state_token\)/);
assert.match(overview, /await copyCampaign\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
assert.match(overview, /await copyCampaign\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token, copyOptions\)/);
assert.match(overview, /include_recipients/);
assert.match(overview, /include_files/);
assert.match(overview, /include_shares/);
assert.match(overview, /include_policies/);
assert.match(overview, /include_mail_profile/);
assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/);
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("../src/features/campaigns/TemplateDataPage.tsx", import.meta.url), "utf8");
assert.match(source, /checkContentLibraryCompatibility\(item\.required_fields, contentAvailableFields\)/);
assert.match(source, /Field compatibility needs attention\./);
assert.match(source, /requestCampaignPart\(item\)/);
assert.match(source, /<ConfirmDialog[\s\S]*Replace current Campaign content\?/);
assert.doesNotMatch(source, /onClick=\{\(\) => applyCampaignPart\(item\)\}/);
console.log("Campaign content-library compatibility and overwrite-confirmation contract passed.");
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const guidance = readFileSync(resolve(here, "../src/features/campaigns/review/ReviewWorkflowGuidance.tsx"), "utf8");
const page = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8");
const attachmentsPage = readFileSync(resolve(here, "../src/features/campaigns/AttachmentsDataPage.tsx"), "utf8");
assert.match(guidance, /<ActionBlockerHint/);
assert.match(guidance, /<GuidedReviewList/);
@@ -17,6 +18,21 @@ assert.match(guidance, /documentation=\{\{ topicId: documentationTopicId \}\}/);
assert.match(guidance, /campaigns\.workflow\.prepare-validate-and-build/);
assert.match(guidance, /campaigns\.workflow\.complete-review/);
assert.match(page, /calculateBuildReviewProgress/);
assert.match(page, /<WorkflowFact label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
assert.match(page, /<MetricCard[^>]+label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
assert.match(page, /showBuiltDetails\(\{ reviewed: 'list:\["no"\]' \}\)/);
assert.match(page, /data-residual-file-policy/);
assert.match(page, /build\.residual_file_disposition/);
assert.match(page, /residual_file_count/);
assert.match(page, /watched_source_count/);
assert.match(page, /data-attachment-reuse-policy/);
assert.match(page, /build\.attachment_reuse/);
assert.match(page, /attachmentReusePolicy\.allow_within/);
assert.match(attachmentsPage, /patch\(\["attachments", "reuse_policy"\]/);
for (const action of ["allow", "warn", "review", "block"]) {
assert.match(attachmentsPage, new RegExp(`<option value="${action}">`));
}
for (const allowance of ["none", "same_recipient", "same_message"]) {
assert.match(attachmentsPage, new RegExp(`<option value="${allowance}">`));
}
assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/);
assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);
@@ -1,4 +1,5 @@
import { campaignJsonForAttachmentPreview } from "../src/features/campaigns/utils/templatePreviewDraft";
import { checkContentLibraryCompatibility } from "../src/features/campaigns/utils/contentLibraryCompatibility";
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
@@ -52,3 +53,18 @@ assert(to.length === 1 && to[0].email === "alice@example.org", "empty recipient
assert(defaults.email === undefined, "empty defaults email is stripped");
assert(Array.isArray(defaults.to) && (defaults.to as unknown[]).length === 0, "empty defaults recipients are removed");
assert((asRecord(draft.entries).inline as Record<string, unknown>[])[0].email === "", "original draft is not mutated");
const compatibility = checkContentLibraryCompatibility([
{ path: "local::display_name", value_type: "string", label: "Display name", required: true },
{ path: "global.case_reference", value_type: "string", label: "Case reference", required: true },
{ path: "count", value_type: "number", label: "Count", required: true },
{ path: "local.to.2.email", value_type: "string", label: "Second recipient", required: true },
{ path: "optional", value_type: "string", required: false }
], new Map([
["local:display_name", "string"],
["local:to", "string"],
["count", "boolean"]
]));
assert(!compatibility.compatible, "missing or wrong-typed fields are incompatible");
assert(compatibility.missing.length === 1 && compatibility.missing[0].path === "global:case_reference", "namespaced missing fields are normalized");
assert(compatibility.incompatible.length === 1 && compatibility.incompatible[0].actual === "boolean", "type mismatches are reported");
+2 -1
View File
@@ -18,6 +18,7 @@
},
"include": [
"tests/template-preview-draft.test.ts",
"src/features/campaigns/utils/templatePreviewDraft.ts"
"src/features/campaigns/utils/templatePreviewDraft.ts",
"src/features/campaigns/utils/contentLibraryCompatibility.ts"
]
}