11 Commits
37 changed files with 1609 additions and 1008 deletions
+8
View File
@@ -33,6 +33,14 @@ Files and mail are optional module integrations declared in the campaign manifes
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows. - `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
- `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable. - `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable.
Public campaign, version, job, and report responses expose business data and
delivery evidence, but never process-local paths, storage-backend keys, or
worker claim tokens. Operational troubleshooting uses the dedicated job
diagnostics endpoint and requires the tenant-level
`campaigns:diagnostic:read` permission. The campaign sender role receives this
permission; tenant-wide administrator scopes continue to grant it through the
standard policy evaluator.
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed. Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.
Campaign also provides narrow kernel capabilities so other modules and core Campaign also provides narrow kernel capabilities so other modules and core
+1 -1
View File
@@ -22,7 +22,7 @@
"read-excel-file": "9.2.0" "read-excel-file": "9.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import fnmatch import fnmatch
import re
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
@@ -18,6 +17,7 @@ from govoplan_campaign.backend.path_security import (
assert_logical_relative_path, assert_logical_relative_path,
is_managed_source, is_managed_source,
) )
from govoplan_campaign.backend.template_rendering import render_template
class AttachmentScope(StrEnum): class AttachmentScope(StrEnum):
@@ -145,43 +145,8 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
return (campaign_path.parent / path).resolve() return (campaign_path.parent / path).resolve()
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
def _render_template(template: str, values: dict[str, Any]) -> str:
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0)
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str: def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
rendered = _render_template(config.base_dir, values).strip() rendered = render_template(config.base_dir, values, keep_missing=True).strip()
return rendered or "." return rendered or "."
@@ -265,7 +230,7 @@ def _attachment_zip_archive(
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None: def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
if archive is None: if archive is None:
return None return None
rendered = _render_template(archive.name or "attachments.zip", values).strip() or "attachments.zip" rendered = render_template(archive.name or "attachments.zip", values, keep_missing=True).strip() or "attachments.zip"
return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip" return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip"
@@ -444,7 +409,7 @@ def _resolve_one_config(
match_index: AttachmentMatchIndex | None = None, match_index: AttachmentMatchIndex | None = None,
) -> ResolvedAttachment: ) -> ResolvedAttachment:
rendered_base_dir = _rendered_base_dir(config, values) rendered_base_dir = _rendered_base_dir(config, values)
rendered_file_filter = _render_template(config.file_filter, values) rendered_file_filter = render_template(config.file_filter, values, keep_missing=True)
directory, selected_base_path = _resolve_attachment_directory( directory, selected_base_path = _resolve_attachment_directory(
campaign_file=campaign_file, campaign_file=campaign_file,
campaign_config=campaign_config, campaign_config=campaign_config,
@@ -299,6 +299,237 @@ def _mock_sent_folder(config: Any) -> str:
return "Sent" return "Sent"
def _mock_campaign_version(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None,
) -> tuple[Campaign, CampaignVersion]:
campaign = (
session.query(Campaign)
.filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id)
.one_or_none()
)
if not campaign:
raise MockCampaignSendError("Campaign not found or not accessible")
wanted_version_id = version_id or campaign.current_version_id
if not wanted_version_id:
raise MockCampaignSendError("Campaign has no current version")
version = session.get(CampaignVersion, wanted_version_id)
if not version or version.campaign_id != campaign.id:
raise MockCampaignSendError(
"Campaign version not found or not part of campaign"
)
return campaign, version
def _mock_mailbox_for_run(*, send: bool, clear_mailbox: bool) -> Any | None:
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
if clear_mailbox and mailbox is not None:
mailbox.clear_records()
return mailbox
def _build_mock_campaign_run(
session: Session,
*,
tenant_id: str,
campaign: Campaign,
version: CampaignVersion,
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
check_files: bool,
) -> tuple[Any, Any, _MockSendBatch]:
files = files_integration()
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
assert_server_safe_campaign_paths(
raw_json,
managed_files_available=files.available,
)
with files.prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
raw_json=raw_json,
include_bytes=True,
prefix="govoplan-mock-send-",
) as prepared:
prepared_raw = load_campaign_json(prepared.path)
config = load_campaign_config_from_json(
session,
tenant_id=tenant_id,
raw_json=prepared_raw,
campaign_id=campaign.id,
)
validation_report = validate_campaign_config(
config,
campaign_file=prepared.path,
check_files=check_files,
)
build_result = build_campaign_messages(
config,
campaign_file=prepared.path,
write_eml=False,
)
files.annotate_built_messages_with_managed_files(
build_result.built_messages,
prepared.managed_files_by_local_path,
)
send_batch = _mock_send_batch(
config=config,
built_messages=build_result.built_messages,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
return validation_report, build_result, send_batch
def _mock_validation_payload(validation_report: Any) -> dict[str, Any]:
payload = validation_report.model_dump(mode="json")
payload.update(
{
"ok": validation_report.ok,
"error_count": validation_report.error_count,
"warning_count": validation_report.warning_count,
}
)
return payload
def _mock_build_payload(build_result: Any) -> dict[str, Any]:
report = build_result.report
payload = report.model_dump(mode="json")
payload.update(
{
"built_count": report.built_count,
"queueable_count": report.queueable_count,
"needs_review_count": report.needs_review_count,
"blocked_count": report.blocked_count,
"warning_count": report.warning_count,
"ready_count": report.ready_count,
"messages": [_message_payload(message) for message in report.messages],
}
)
return payload
def _mock_send_steps(
*,
validation_report: Any,
validation_payload: dict[str, Any],
build_report: Any,
send_batch: _MockSendBatch,
send: bool,
append_sent: bool,
) -> list[dict[str, Any]]:
return [
{
"key": "validate",
"label": "Validate campaign JSON",
"status": "ok" if validation_report.ok else "needs_review",
"summary": validation_payload,
},
{
"key": "build",
"label": "Build messages",
"status": "ok" if build_report.queueable_count else "needs_review",
"summary": {
"built": build_report.built_count,
"queueable": build_report.queueable_count,
"needs_review": build_report.needs_review_count,
"blocked": build_report.blocked_count,
},
},
{
"key": "send",
"label": "Mock SMTP delivery",
"status": (
"skipped"
if not send
else "ok"
if send_batch.failed_count == 0
else "needs_review"
),
"summary": {
"attempted": send_batch.attempted_count,
"sent": send_batch.sent_count,
"failed": send_batch.failed_count,
"skipped": send_batch.skipped_count,
},
},
{
"key": "imap",
"label": "Mock IMAP Sent append",
"status": (
"skipped"
if not send or not append_sent
else "ok"
if send_batch.imap_failed_count == 0
else "needs_review"
),
"summary": {
"appended": send_batch.imap_appended_count,
"failed": send_batch.imap_failed_count,
},
},
]
def _mock_campaign_send_response(
*,
campaign: Campaign,
version: CampaignVersion,
mailbox: Any | None,
validation_report: Any,
validation_payload: dict[str, Any],
build_result: Any,
build_payload: dict[str, Any],
send_batch: _MockSendBatch,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> dict[str, Any]:
return {
"campaign_id": campaign.id,
"version_id": version.id,
"version_number": version.version_number,
"send_requested": send,
"include_warnings": include_warnings,
"include_needs_review": include_needs_review,
"append_sent": append_sent,
"steps": _mock_send_steps(
validation_report=validation_report,
validation_payload=validation_payload,
build_report=build_result.report,
send_batch=send_batch,
send=send,
append_sent=append_sent,
),
"validation": validation_payload,
"build": build_payload,
"send": {
"attempted_count": send_batch.attempted_count,
"sent_count": send_batch.sent_count,
"failed_count": send_batch.failed_count,
"skipped_count": send_batch.skipped_count,
"imap_appended_count": send_batch.imap_appended_count,
"imap_failed_count": send_batch.imap_failed_count,
"results": send_batch.results,
},
"mailbox": {
"messages": mailbox.list_records(limit=200) if mailbox is not None else []
},
}
def run_mock_campaign_send( def run_mock_campaign_send(
session: Session, session: Session,
*, *,
@@ -320,87 +551,38 @@ def run_mock_campaign_send(
mailbox only when send=True. mailbox only when send=True.
""" """
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none() campaign, version = _mock_campaign_version(
if not campaign:
raise MockCampaignSendError("Campaign not found or not accessible")
wanted_version_id = version_id or campaign.current_version_id
if not wanted_version_id:
raise MockCampaignSendError("Campaign has no current version")
version = session.get(CampaignVersion, wanted_version_id)
if not version or version.campaign_id != campaign.id:
raise MockCampaignSendError("Campaign version not found or not part of campaign")
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
if clear_mailbox and mailbox is not None:
mailbox.clear_records()
files = files_integration()
assert_server_safe_campaign_paths(
version.raw_json if isinstance(version.raw_json, dict) else {},
managed_files_available=files.available,
)
with files.prepared_campaign_snapshot(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign.id, campaign_id=campaign_id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {}, version_id=version_id,
include_bytes=True, )
prefix="govoplan-mock-send-", mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox)
) as prepared: validation_report, build_result, send_batch = _build_mock_campaign_run(
prepared_raw = load_campaign_json(prepared.path) session,
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id) tenant_id=tenant_id,
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files) campaign=campaign,
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False) version=version,
files.annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
send_batch = _mock_send_batch(
config=config,
built_messages=build_result.built_messages,
mailbox=mailbox, mailbox=mailbox,
send=send, send=send,
include_warnings=include_warnings, include_warnings=include_warnings,
include_needs_review=include_needs_review, include_needs_review=include_needs_review,
append_sent=append_sent, append_sent=append_sent,
check_files=check_files,
)
validation_payload = _mock_validation_payload(validation_report)
build_payload = _mock_build_payload(build_result)
return _mock_campaign_send_response(
campaign=campaign,
version=version,
mailbox=mailbox,
validation_report=validation_report,
validation_payload=validation_payload,
build_result=build_result,
build_payload=build_payload,
send_batch=send_batch,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
) )
validation_json = validation_report.model_dump(mode="json")
validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count})
build_report = build_result.report
build_json = build_report.model_dump(mode="json")
build_json.update({
"built_count": build_report.built_count,
"queueable_count": build_report.queueable_count,
"needs_review_count": build_report.needs_review_count,
"blocked_count": build_report.blocked_count,
"warning_count": build_report.warning_count,
"ready_count": build_report.ready_count,
"messages": [_message_payload(message) for message in build_report.messages],
})
return {
"campaign_id": campaign.id,
"version_id": version.id,
"version_number": version.version_number,
"send_requested": send,
"include_warnings": include_warnings,
"include_needs_review": include_needs_review,
"append_sent": append_sent,
"steps": [
{"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json},
{"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}},
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if send_batch.failed_count == 0 else "needs_review"), "summary": {"attempted": send_batch.attempted_count, "sent": send_batch.sent_count, "failed": send_batch.failed_count, "skipped": send_batch.skipped_count}},
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if send_batch.imap_failed_count == 0 else "needs_review"), "summary": {"appended": send_batch.imap_appended_count, "failed": send_batch.imap_failed_count}},
],
"validation": validation_json,
"build": build_json,
"send": {
"attempted_count": send_batch.attempted_count,
"sent_count": send_batch.sent_count,
"failed_count": send_batch.failed_count,
"skipped_count": send_batch.skipped_count,
"imap_appended_count": send_batch.imap_appended_count,
"imap_failed_count": send_batch.imap_failed_count,
"results": send_batch.results,
},
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
}
@@ -60,6 +60,7 @@ PERMISSIONS = (
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"), _permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"), _permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"), _permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"),
_permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"),
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"), _permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"), _permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
_permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"), _permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"),
@@ -111,6 +112,7 @@ ROLE_TEMPLATES = (
"campaigns:campaign:send", "campaigns:campaign:send",
"campaigns:campaign:retry", "campaigns:campaign:retry",
"campaigns:campaign:reconcile", "campaigns:campaign:reconcile",
"campaigns:diagnostic:read",
"campaigns:recipient:read", "campaigns:recipient:read",
"campaigns:report:read", "campaigns:report:read",
"campaigns:report:send", "campaigns:report:send",
@@ -36,6 +36,10 @@ from govoplan_campaign.backend.campaign.models import (
) )
from govoplan_campaign.backend.campaign.template_values import build_template_values from govoplan_campaign.backend.campaign.template_values import build_template_values
from govoplan_campaign.backend.services.zip_service import create_zip_archive from govoplan_campaign.backend.services.zip_service import create_zip_archive
from govoplan_campaign.backend.template_rendering import (
find_unresolved_placeholders as _find_unresolved_placeholders,
render_template as _render_template,
)
from .models import ( from .models import (
CampaignBuildReport, CampaignBuildReport,
@@ -47,28 +51,6 @@ from .models import (
MessageValidationStatus, MessageValidationStatus,
) )
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
@dataclass(slots=True) @dataclass(slots=True)
class BuiltMessage: class BuiltMessage:
draft: MessageDraft draft: MessageDraft
@@ -123,29 +105,6 @@ def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str =
return path.read_text(encoding=encoding) return path.read_text(encoding=encoding)
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0) if keep_missing else ""
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _find_unresolved_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return {
_normalize_template_key(match.group(1))
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
for match in pattern.finditer(text)
}
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None: def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
if recipient is None: if recipient is None:
return None return None
@@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
import copy
import hashlib import hashlib
import json import json
import os
from datetime import UTC, datetime
from email import policy from email import policy
from email.parser import BytesParser from email.parser import BytesParser
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import copy
from datetime import UTC, datetime
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import func from sqlalchemy import func
@@ -44,8 +45,9 @@ class CampaignPersistenceError(RuntimeError):
def _ensure_dirs() -> None: def _ensure_dirs() -> None:
CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) for directory in (CAMPAIGN_SNAPSHOT_DIR, BUILD_OUTPUT_DIR):
BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, mode=0o700, exist_ok=True)
directory.chmod(0o700)
@@ -74,7 +76,18 @@ def load_campaign_config_from_json(
def _write_campaign_snapshot(version: CampaignVersion) -> Path: def _write_campaign_snapshot(version: CampaignVersion) -> Path:
_ensure_dirs() _ensure_dirs()
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json" path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8") flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags, 0o600)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
descriptor = -1
json.dump(version.raw_json, stream, ensure_ascii=False, indent=2)
finally:
if descriptor >= 0:
os.close(descriptor)
return path return path
@@ -20,6 +20,7 @@ from govoplan_campaign.backend.db.models import (
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import public_campaign_payload, public_source_filename
class CampaignReportError(RuntimeError): class CampaignReportError(RuntimeError):
@@ -62,10 +63,10 @@ def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None:
"id": version.id, "id": version.id,
"version_number": version.version_number, "version_number": version.version_number,
"schema_version": version.schema_version, "schema_version": version.schema_version,
"source_filename": version.source_filename, "source_filename": public_source_filename(version.source_filename),
"created_at": version.created_at.isoformat() if version.created_at else None, "created_at": version.created_at.isoformat() if version.created_at else None,
"validation_summary": version.validation_summary, "validation_summary": public_campaign_payload(version.validation_summary),
"build_summary": version.build_summary, "build_summary": public_campaign_payload(version.build_summary),
"execution_snapshot_hash": version.execution_snapshot_hash, "execution_snapshot_hash": version.execution_snapshot_hash,
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None, "execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
} }
@@ -323,8 +324,6 @@ def _job_evidence_row(
"campaign_id": job.campaign_id, "campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id, "campaign_version_id": job.campaign_version_id,
"message_id_header": job.message_id_header, "message_id_header": job.message_id_header,
"eml_storage_key": job.eml_storage_key,
"eml_local_path": job.eml_local_path,
"from": _address_summary(recipients.get("from")), "from": _address_summary(recipients.get("from")),
"to": _address_summary(recipients.get("to")), "to": _address_summary(recipients.get("to")),
"cc": _address_summary(recipients.get("cc")), "cc": _address_summary(recipients.get("cc")),
@@ -603,8 +602,6 @@ def generate_jobs_csv(
"last_error", "last_error",
"eml_size_bytes", "eml_size_bytes",
"eml_sha256", "eml_sha256",
"eml_storage_key",
"eml_local_path",
"issues_count", "issues_count",
"attachment_config_count", "attachment_config_count",
"matched_file_count", "matched_file_count",
@@ -0,0 +1,134 @@
from __future__ import annotations
import copy
from pathlib import Path, PureWindowsPath
from typing import Any
# These fields locate process-local or storage-backend resources, or authorize
# a worker claim. They are useful for tightly controlled diagnostics but are
# not part of the campaign business-data contract.
CAMPAIGN_INTERNAL_RESPONSE_KEYS = frozenset(
{
"campaign_file",
"claim_token",
"eml_local_path",
"eml_path",
"eml_storage_key",
"local_path",
"source_base_path",
"storage_bucket",
"storage_key",
}
)
def public_campaign_payload(value: Any) -> Any:
"""Return a detached payload without infrastructure-only locators."""
if isinstance(value, dict):
return {
key: public_campaign_payload(item)
for key, item in value.items()
if key not in CAMPAIGN_INTERNAL_RESPONSE_KEYS
}
if isinstance(value, list):
return [public_campaign_payload(item) for item in value]
if isinstance(value, tuple):
return tuple(public_campaign_payload(item) for item in value)
return copy.deepcopy(value)
def public_campaign_configuration(value: Any) -> Any:
"""Return campaign JSON without infrastructure locators or mail secrets.
Password-named business fields are intentionally retained. Only the
schema-defined SMTP/IMAP credential paths under ``server`` are secrets.
"""
payload = public_campaign_payload(value)
if not isinstance(payload, dict):
return payload
server = payload.get("server")
if isinstance(server, dict):
for protocol in ("smtp", "imap"):
config = server.get(protocol)
if isinstance(config, dict):
config.pop("password", None)
credentials = server.get("credentials")
if isinstance(credentials, dict):
for protocol in ("smtp", "imap"):
config = credentials.get(protocol)
if isinstance(config, dict):
config.pop("password", None)
_sanitize_configuration_paths(payload)
return payload
def public_source_filename(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return text
windows_path = PureWindowsPath(text)
return windows_path.name if windows_path.is_absolute() or "\\" in text else Path(text).name
def _sanitize_configuration_paths(payload: dict[str, Any]) -> None:
template = payload.get("template")
source = template.get("source") if isinstance(template, dict) else None
if isinstance(source, dict):
for key in ("subject_path", "text_path", "html_path"):
if key in source:
source[key] = _public_configuration_path(source[key])
entries = payload.get("entries")
entry_source = entries.get("source") if isinstance(entries, dict) else None
if isinstance(entry_source, dict) and "path" in entry_source:
entry_source["path"] = _public_configuration_path(entry_source["path"])
attachments = payload.get("attachments")
if isinstance(attachments, dict):
if "base_path" in attachments:
attachments["base_path"] = _public_configuration_path(attachments["base_path"])
base_paths = attachments.get("base_paths")
if isinstance(base_paths, list):
for item in base_paths:
if not isinstance(item, dict):
continue
for key in ("path", "source"):
if key in item:
item[key] = _public_configuration_path(item[key])
_sanitize_attachment_rules(attachments.get("global"))
if isinstance(entries, dict):
inline_entries = entries.get("inline")
if isinstance(inline_entries, list):
for entry in inline_entries:
if isinstance(entry, dict):
_sanitize_attachment_rules(entry.get("attachments"))
defaults = entries.get("defaults")
if isinstance(defaults, dict):
_sanitize_attachment_rules(defaults.get("attachments"))
def _sanitize_attachment_rules(value: Any) -> None:
if not isinstance(value, list):
return
for rule in value:
if isinstance(rule, dict) and "base_dir" in rule:
rule["base_dir"] = _public_configuration_path(rule["base_dir"])
def _public_configuration_path(value: Any) -> Any:
if not isinstance(value, str) or not value.strip():
return value
text = value.strip()
windows_path = PureWindowsPath(text)
path = Path(text)
if windows_path.is_absolute():
return windows_path.name
if path.is_absolute() or text.startswith("~"):
return path.name
return value
+107 -34
View File
@@ -38,6 +38,7 @@ from govoplan_campaign.backend.schemas import (
CampaignJobsResponse, CampaignJobsResponse,
CampaignJobsDeltaResponse, CampaignJobsDeltaResponse,
CampaignJobDetailResponse, CampaignJobDetailResponse,
CampaignJobDiagnosticsResponse,
CampaignRetryJobsRequest, CampaignRetryJobsRequest,
CampaignSendJobRequest, CampaignSendJobRequest,
CampaignSendUnattemptedRequest, CampaignSendUnattemptedRequest,
@@ -98,6 +99,7 @@ from govoplan_campaign.backend.db.models import (
) )
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.response_security import public_campaign_payload
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from govoplan_campaign.backend.persistence.campaigns import ( from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError, CampaignPersistenceError,
@@ -1812,7 +1814,7 @@ def validate_version(
}, },
commit=True, commit=True,
) )
return result return public_campaign_payload(result)
except HTTPException: except HTTPException:
raise raise
except CampaignPersistenceError as exc: except CampaignPersistenceError as exc:
@@ -1847,7 +1849,7 @@ def build_version(
details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")}, details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")},
commit=True, commit=True,
) )
return result return public_campaign_payload(result)
except CampaignPersistenceError as exc: except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
@@ -1904,14 +1906,81 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
return { return {
**_job_summary_payload(job), **_job_summary_payload(job),
"message_id_header": job.message_id_header, "message_id_header": job.message_id_header,
"eml_local_path": job.eml_local_path,
"eml_storage_key": job.eml_storage_key,
"issues": job.issues_snapshot or [], "issues": job.issues_snapshot or [],
"attachments": job.resolved_attachments or [], "attachments": public_campaign_payload(job.resolved_attachments or []),
"resolved_recipients": job.resolved_recipients or {}, "resolved_recipients": job.resolved_recipients or {},
} }
def _job_attempts_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
*,
include_diagnostics: bool = False,
) -> dict[str, list[dict[str, object]]]:
smtp_payloads: list[dict[str, object]] = []
for attempt in send_attempts:
payload: dict[str, object] = {
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"smtp_status_code": attempt.smtp_status_code,
"smtp_response": attempt.smtp_response,
"error_type": attempt.error_type,
"error_message": attempt.error_message,
"started_at": attempt.started_at,
"finished_at": attempt.finished_at,
}
if include_diagnostics:
payload["claim_token"] = attempt.claim_token
smtp_payloads.append(payload)
imap_payloads: list[dict[str, object]] = []
for attempt in imap_attempts:
payload = {
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"folder": attempt.folder,
"error_message": attempt.error_message,
"created_at": attempt.created_at,
"updated_at": attempt.updated_at,
}
if include_diagnostics:
payload["claim_token"] = attempt.claim_token
imap_payloads.append(payload)
return {"smtp": smtp_payloads, "imap": imap_payloads}
def _job_diagnostics_payload(
job: CampaignJob,
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
) -> CampaignJobDiagnosticsResponse:
return CampaignJobDiagnosticsResponse(
job_id=job.id,
campaign_id=job.campaign_id,
campaign_version_id=job.campaign_version_id,
storage={
"eml_local_path": job.eml_local_path,
"eml_storage_key": job.eml_storage_key,
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
},
worker_claim={
"claim_token": job.claim_token,
"claimed_at": job.claimed_at,
"smtp_started_at": job.smtp_started_at,
"outcome_unknown_at": job.outcome_unknown_at,
},
attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
include_diagnostics=True,
),
)
def _review_metadata( def _review_metadata(
session: Session, session: Session,
version: CampaignVersion | None, version: CampaignVersion | None,
@@ -2462,38 +2531,42 @@ def get_job_detail(
) )
return CampaignJobDetailResponse( return CampaignJobDetailResponse(
job=_job_detail_payload(job), job=_job_detail_payload(job),
attempts={ attempts=_job_attempts_payload(send_attempts, imap_attempts),
"smtp": [
{
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"claim_token": attempt.claim_token,
"smtp_status_code": attempt.smtp_status_code,
"smtp_response": attempt.smtp_response,
"error_type": attempt.error_type,
"error_message": attempt.error_message,
"started_at": attempt.started_at,
"finished_at": attempt.finished_at,
}
for attempt in send_attempts
],
"imap": [
{
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"folder": attempt.folder,
"error_message": attempt.error_message,
"created_at": attempt.created_at,
"updated_at": attempt.updated_at,
}
for attempt in imap_attempts
],
},
) )
@router.get(
"/{campaign_id}/jobs/{job_id}/diagnostics",
response_model=CampaignJobDiagnosticsResponse,
)
def get_job_diagnostics(
campaign_id: str,
job_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:diagnostic:read")),
):
"""Return infrastructure details only to campaign operators/admins."""
_get_campaign_for_principal(session, campaign_id, principal)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
job = session.get(CampaignJob, job_id)
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
send_attempts = (
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.asc())
.all()
)
imap_attempts = (
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id == job.id)
.order_by(ImapAppendAttempt.attempt_number.asc())
.all()
)
return _job_diagnostics_payload(job, send_attempts, imap_attempts)
@router.get("/{campaign_id}/summary") @router.get("/{campaign_id}/summary")
def campaign_summary( def campaign_summary(
campaign_id: str, campaign_id: str,
+31 -2
View File
@@ -3,11 +3,17 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.mail.config import ImapConfig, SmtpConfig from govoplan_core.mail.config import ImapConfig, SmtpConfig
from govoplan_campaign.backend.response_security import (
public_campaign_configuration,
public_campaign_payload,
public_source_filename,
)
class CampaignCreateRequest(BaseModel): class CampaignCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -80,7 +86,6 @@ class CampaignVersionResponse(BaseModel):
version_number: int version_number: int
schema_version: str schema_version: str
source_filename: str | None = None source_filename: str | None = None
source_base_path: str | None = None
workflow_state: str = "editing" workflow_state: str = "editing"
current_flow: str = "manual" current_flow: str = "manual"
current_step: str | None = None current_step: str | None = None
@@ -100,10 +105,25 @@ class CampaignVersionResponse(BaseModel):
execution_snapshot_hash: str | None = None execution_snapshot_hash: str | None = None
execution_snapshot_at: datetime | None = None execution_snapshot_at: datetime | None = None
@field_validator("source_filename", mode="before")
@classmethod
def remove_source_directory(cls, value: Any) -> str | None:
return public_source_filename(value)
@field_validator("validation_summary", "build_summary", mode="before")
@classmethod
def remove_internal_summary_fields(cls, value: Any) -> Any:
return public_campaign_payload(value)
class CampaignVersionDetailResponse(CampaignVersionResponse): class CampaignVersionDetailResponse(CampaignVersionResponse):
raw_json: dict[str, Any] raw_json: dict[str, Any]
@field_validator("raw_json", mode="before")
@classmethod
def remove_internal_configuration_fields(cls, value: Any) -> Any:
return public_campaign_configuration(value)
class CampaignPartialValidationResponse(BaseModel): class CampaignPartialValidationResponse(BaseModel):
ok: bool ok: bool
@@ -351,6 +371,15 @@ class CampaignJobDetailResponse(BaseModel):
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class CampaignJobDiagnosticsResponse(BaseModel):
job_id: str
campaign_id: str
campaign_version_id: str
storage: dict[str, Any] = Field(default_factory=dict)
worker_claim: dict[str, Any] = Field(default_factory=dict)
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class CampaignRetryJobsRequest(BaseModel): class CampaignRetryJobsRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
+142 -46
View File
@@ -161,6 +161,19 @@ QUEUEABLE_VALIDATION_STATUSES = {
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value} SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value} AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value} EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
INITIAL_QUEUE_SKIPPED_SEND_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
JobSendStatus.OUTCOME_UNKNOWN.value,
JobSendStatus.FAILED_TEMPORARY.value,
JobSendStatus.FAILED_PERMANENT.value,
JobSendStatus.CANCELLED.value,
}
INITIAL_QUEUE_SKIPPED_QUEUE_STATUSES = {
JobQueueStatus.CANCELLED.value,
JobQueueStatus.SENDING.value,
JobQueueStatus.PAUSED.value,
}
CAMPAIGN_STATUS_NOTIFICATION_EVENTS = { CAMPAIGN_STATUS_NOTIFICATION_EVENTS = {
CampaignStatus.QUEUED.value: ("campaign.queued", "Campaign queued", 1), CampaignStatus.QUEUED.value: ("campaign.queued", "Campaign queued", 1),
CampaignStatus.SENDING.value: ("campaign.sending", "Campaign sending started", 1), CampaignStatus.SENDING.value: ("campaign.sending", "Campaign sending started", 1),
@@ -324,73 +337,73 @@ def _celery_enqueue_append_sent_job(job_id: str) -> None:
celery.send_task("govoplan.campaigns.append_sent", args=[job_id], queue="append_sent") celery.send_task("govoplan.campaigns.append_sent", args=[job_id], queue="append_sent")
def queue_campaign_jobs( def _ensure_campaign_execution_snapshot(
session: Session, session: Session,
*, version: CampaignVersion,
tenant_id: str, ) -> None:
campaign_id: str,
version_id: str | None = None,
enqueue_celery: bool = True,
include_warnings: bool = True,
dry_run: bool = False,
) -> QueueCampaignResult:
"""Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks."""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
version = _get_current_version(session, campaign, version_id=version_id)
_ensure_version_validated_and_locked(version)
try: try:
ensure_execution_snapshot(session, version) ensure_execution_snapshot(session, version)
except ExecutionSnapshotError as exc: except ExecutionSnapshotError as exc:
raise QueueingError(str(exc)) from exc raise QueueingError(str(exc)) from exc
def _queue_validation_statuses(*, include_warnings: bool) -> set[str]:
allowed_validation = {JobValidationStatus.READY.value} allowed_validation = {JobValidationStatus.READY.value}
if include_warnings: if include_warnings:
allowed_validation.add(JobValidationStatus.WARNING.value) allowed_validation.add(JobValidationStatus.WARNING.value)
return allowed_validation
def _campaign_jobs_for_queue(
session: Session,
*,
tenant_id: str,
version_id: str,
) -> list[CampaignJob]:
jobs = ( jobs = (
session.query(CampaignJob) session.query(CampaignJob)
.filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_version_id == version.id) .filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_version_id == version_id,
)
.order_by(CampaignJob.entry_index.asc()) .order_by(CampaignJob.entry_index.asc())
.all() .all()
) )
if not jobs: if not jobs:
raise QueueingError("Campaign version has no jobs. Build messages before queueing.") raise QueueingError(
"Campaign version has no jobs. Build messages before queueing."
)
return jobs
queued: list[CampaignJob] = []
reviewed_needs_review_keys = _reviewed_needs_review_keys(version) def _initial_queue_disposition(
skipped_count = 0 job: CampaignJob,
blocked_count = 0 *,
for job in jobs: allowed_validation: set[str],
if job.send_status in SMTP_ACCEPTED_STATUSES | { reviewed_needs_review_keys: set[str],
JobSendStatus.CLAIMED.value, ) -> str:
JobSendStatus.SENDING.value, if job.send_status in INITIAL_QUEUE_SKIPPED_SEND_STATUSES:
JobSendStatus.OUTCOME_UNKNOWN.value, # Initial queueing never doubles as retry/reconciliation. Those states
JobSendStatus.FAILED_TEMPORARY.value, # require the dedicated explicit actions below.
JobSendStatus.FAILED_PERMANENT.value, return "skipped"
JobSendStatus.CANCELLED.value, if job.queue_status in INITIAL_QUEUE_SKIPPED_QUEUE_STATUSES:
}: return "skipped"
# Initial queueing never doubles as retry/reconciliation. Those
# states require the dedicated explicit actions below.
skipped_count += 1
continue
if job.queue_status in {JobQueueStatus.CANCELLED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value}:
skipped_count += 1
continue
validation_allowed = job.validation_status in allowed_validation or ( validation_allowed = job.validation_status in allowed_validation or (
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
and _job_review_key(job) in reviewed_needs_review_keys and _job_review_key(job) in reviewed_needs_review_keys
) )
if job.build_status != JobBuildStatus.BUILT.value or not validation_allowed: if job.build_status != JobBuildStatus.BUILT.value or not validation_allowed:
blocked_count += 1 return "blocked"
continue
if not job.eml_local_path and not job.eml_storage_key: if not job.eml_local_path and not job.eml_storage_key:
job.last_error = "Job has no generated EML path/storage key. Rebuild with write_eml enabled before queueing." job.last_error = (
blocked_count += 1 "Job has no generated EML path/storage key. Rebuild with write_eml "
continue "enabled before queueing."
)
return "blocked"
return "queued"
queued.append(job)
if not dry_run: def _mark_campaign_job_queued(session: Session, job: CampaignJob) -> None:
job.queue_status = JobQueueStatus.QUEUED.value job.queue_status = JobQueueStatus.QUEUED.value
job.send_status = JobSendStatus.QUEUED.value job.send_status = JobSendStatus.QUEUED.value
job.queued_at = _utcnow() job.queued_at = _utcnow()
@@ -401,7 +414,43 @@ def queue_campaign_jobs(
job.last_error = None job.last_error = None
session.add(job) session.add(job)
def _select_campaign_jobs_for_queue(
session: Session,
*,
jobs: list[CampaignJob],
allowed_validation: set[str],
reviewed_needs_review_keys: set[str],
dry_run: bool,
) -> tuple[list[CampaignJob], int, int]:
queued: list[CampaignJob] = []
skipped_count = 0
blocked_count = 0
for job in jobs:
disposition = _initial_queue_disposition(
job,
allowed_validation=allowed_validation,
reviewed_needs_review_keys=reviewed_needs_review_keys,
)
if disposition == "skipped":
skipped_count += 1
continue
if disposition == "blocked":
blocked_count += 1
continue
queued.append(job)
if not dry_run: if not dry_run:
_mark_campaign_job_queued(session, job)
return queued, skipped_count, blocked_count
def _persist_campaign_queue(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
queued: list[CampaignJob],
) -> None:
if queued: if queued:
previous_status = campaign.status previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value campaign.status = CampaignStatus.QUEUED.value
@@ -420,11 +469,58 @@ def queue_campaign_jobs(
session.add(campaign) session.add(campaign)
session.commit() session.commit()
enqueued_count = 0
if _should_enqueue_celery(enqueue_celery) and not dry_run: def _enqueue_campaign_jobs(queued: list[CampaignJob], *, enabled: bool) -> int:
if not enabled:
return 0
for job in queued: for job in queued:
_celery_enqueue_send_job(job.id) _celery_enqueue_send_job(job.id)
enqueued_count += 1 return len(queued)
def queue_campaign_jobs(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
enqueue_celery: bool = True,
include_warnings: bool = True,
dry_run: bool = False,
) -> QueueCampaignResult:
"""Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks."""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
version = _get_current_version(session, campaign, version_id=version_id)
_ensure_version_validated_and_locked(version)
_ensure_campaign_execution_snapshot(session, version)
allowed_validation = _queue_validation_statuses(
include_warnings=include_warnings
)
jobs = _campaign_jobs_for_queue(
session,
tenant_id=tenant_id,
version_id=version.id,
)
reviewed_needs_review_keys = _reviewed_needs_review_keys(version)
queued, skipped_count, blocked_count = _select_campaign_jobs_for_queue(
session,
jobs=jobs,
allowed_validation=allowed_validation,
reviewed_needs_review_keys=reviewed_needs_review_keys,
dry_run=dry_run,
)
if not dry_run:
_persist_campaign_queue(
session,
campaign=campaign,
version=version,
queued=queued,
)
enqueued_count = _enqueue_campaign_jobs(
queued,
enabled=_should_enqueue_celery(enqueue_celery) and not dry_run,
)
return QueueCampaignResult( return QueueCampaignResult(
campaign_id=campaign.id, campaign_id=campaign.id,
@@ -0,0 +1,55 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
def render_template(
template: str,
values: Mapping[str, Any],
*,
keep_missing: bool = True,
) -> str:
def replace(match: re.Match[str]) -> str:
key = normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0) if keep_missing else ""
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def find_unresolved_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return {
normalize_template_key(match.group(1))
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
for match in pattern.finditer(text)
}
+15
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
import unittest
from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id
@@ -89,6 +90,8 @@ def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
assert row["latest_smtp_response"] == "2.0.0 queued" assert row["latest_smtp_response"] == "2.0.0 queued"
assert row["latest_imap_status"] == "appended" assert row["latest_imap_status"] == "appended"
assert row["latest_imap_folder"] == "Sent" assert row["latest_imap_folder"] == "Sent"
assert "eml_storage_key" not in row
assert "eml_local_path" not in row
def test_latest_by_job_id_keeps_highest_attempt_number() -> None: def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
@@ -102,3 +105,15 @@ def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
assert latest["job-1"].attempt_number == 3 assert latest["job-1"].attempt_number == 3
assert latest["job-2"].attempt_number == 2 assert latest["job-2"].attempt_number == 2
class CampaignReportProjectionTests(unittest.TestCase):
def test_evidence_projection(self) -> None:
test_job_evidence_row_contains_transport_and_message_evidence()
def test_latest_attempt_projection(self) -> None:
test_latest_by_job_id_keeps_highest_attempt_number()
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from govoplan_campaign.backend.dev.mock_campaign import (
_MockSendBatch,
_mock_send_steps,
)
class MockCampaignStepTests(unittest.TestCase):
def test_preview_marks_delivery_steps_as_skipped(self):
validation = SimpleNamespace(ok=True)
build = SimpleNamespace(
built_count=2,
queueable_count=2,
needs_review_count=0,
blocked_count=0,
)
steps = _mock_send_steps(
validation_report=validation,
validation_payload={"ok": True},
build_report=build,
send_batch=_MockSendBatch(results=[]),
send=False,
append_sent=True,
)
self.assertEqual(
[step["status"] for step in steps],
["ok", "ok", "skipped", "skipped"],
)
def test_delivery_failures_require_review(self):
validation = SimpleNamespace(ok=False)
build = SimpleNamespace(
built_count=1,
queueable_count=0,
needs_review_count=1,
blocked_count=0,
)
batch = _MockSendBatch(
results=[],
failed_count=1,
imap_failed_count=1,
)
steps = _mock_send_steps(
validation_report=validation,
validation_payload={"ok": False},
build_report=build,
send_batch=batch,
send=True,
append_sent=True,
)
self.assertEqual(
[step["status"] for step in steps],
["needs_review", "needs_review", "needs_review", "needs_review"],
)
if __name__ == "__main__":
unittest.main()
+274
View File
@@ -0,0 +1,274 @@
from __future__ import annotations
import stat
import unittest
from datetime import UTC, datetime
from types import SimpleNamespace
from govoplan_campaign.backend.response_security import public_campaign_payload
from govoplan_campaign.backend.router import (
_job_attempts_payload,
_job_detail_payload,
_job_diagnostics_payload,
)
from govoplan_campaign.backend.schemas import CampaignVersionDetailResponse, CampaignVersionResponse
def _now() -> datetime:
return datetime(2026, 7, 21, 12, 0, tzinfo=UTC)
def _job() -> SimpleNamespace:
return SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
entry_index=1,
entry_id="recipient-1",
recipient_email="person@example.test",
subject="Subject",
message_id_header="<message@example.test>",
build_status="built",
validation_status="ready",
queue_status="claimed",
send_status="sending",
imap_status="pending",
eml_size_bytes=123,
eml_sha256="sha256",
eml_local_path="/runtime/campaign/job-1.eml",
eml_storage_key="campaign/job-1.eml",
claim_token="job-claim-secret",
attempt_count=1,
last_error=None,
queued_at=_now(),
claimed_at=_now(),
smtp_started_at=_now(),
outcome_unknown_at=None,
sent_at=None,
created_at=_now(),
updated_at=_now(),
issues_snapshot=[],
resolved_attachments=[
{
"filename": "public.pdf",
"local_path": "/tmp/materialized/public.pdf",
"storage_key": "private/blob-key",
}
],
resolved_recipients={"to": [{"email": "person@example.test"}]},
)
def _smtp_attempt() -> SimpleNamespace:
return SimpleNamespace(
id="smtp-attempt-1",
attempt_number=1,
status="started",
claim_token="attempt-claim-secret",
smtp_status_code=None,
smtp_response=None,
error_type=None,
error_message=None,
started_at=_now(),
finished_at=None,
)
def _imap_attempt() -> SimpleNamespace:
return SimpleNamespace(
id="imap-attempt-1",
attempt_number=1,
status="claimed",
claim_token="imap-claim-secret",
folder="Sent",
error_message=None,
created_at=_now(),
updated_at=_now(),
)
def test_public_payload_recursively_removes_infrastructure_locators() -> None:
source = {
"campaign_file": "/tmp/campaign.json",
"messages": [
{
"subject": "Public",
"eml_path": "/tmp/message.eml",
"managed": {"storage_bucket": "private", "storage_key": "object"},
}
],
}
result = public_campaign_payload(source)
assert result == {"messages": [{"subject": "Public", "managed": {}}]}
assert source["messages"][0]["eml_path"] == "/tmp/message.eml"
def test_version_response_omits_source_base_path_and_sanitizes_summaries() -> None:
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
version_number=1,
schema_version="1",
source_filename="/srv/govoplan/imports/campaign.json",
source_base_path="/private/import/path",
workflow_state="editing",
current_flow="manual",
current_step=None,
is_complete=False,
editor_state={},
autosaved_at=None,
published_at=None,
locked_at=None,
locked_by_user_id=None,
user_lock_state=None,
user_locked_at=None,
user_locked_by_user_id=None,
created_at=_now(),
updated_at=_now(),
validation_summary={"campaign_file": "/tmp/campaign.json", "ok": True},
build_summary={"messages": [{"eml_path": "/tmp/message.eml", "subject": "Public"}]},
execution_snapshot_hash=None,
execution_snapshot_at=None,
)
payload = CampaignVersionResponse.model_validate(version).model_dump()
assert "source_base_path" not in payload
assert payload["source_filename"] == "campaign.json"
assert payload["validation_summary"] == {"ok": True}
assert payload["build_summary"] == {"messages": [{"subject": "Public"}]}
detail = CampaignVersionDetailResponse.model_validate(
SimpleNamespace(
**version.__dict__,
raw_json={
"campaign": {"title": "Public"},
"files": [{"storage_key": "private/object", "filename": "public.pdf"}],
"server": {
"smtp": {"host": "smtp.example.invalid", "password": "smtp-secret"},
"imap": {"host": "imap.example.invalid", "password": "imap-secret"},
"credentials": {
"smtp": {"username": "sender", "password": "legacy-smtp-secret"},
"imap": {"username": "archive", "password": "legacy-imap-secret"},
},
},
"archives": [{"password": "business-zip-password"}],
"template": {
"source": {
"subject_path": "/srv/govoplan/templates/subject.txt",
"html_path": "templates/body.html",
}
},
"entries": {"source": {"type": "csv", "path": "C:\\imports\\recipients.csv"}},
"attachments": {
"base_path": "/srv/govoplan/attachments",
"base_paths": [
{"name": "Shared", "path": "/mnt/shared/campaign", "source": "/mnt/shared"}
],
"global": [{"base_dir": "/srv/govoplan/attachments/global", "file_filter": "*.pdf"}],
},
},
)
).model_dump()
assert detail["raw_json"] == {
"campaign": {"title": "Public"},
"files": [{"filename": "public.pdf"}],
"server": {
"smtp": {"host": "smtp.example.invalid"},
"imap": {"host": "imap.example.invalid"},
"credentials": {
"smtp": {"username": "sender"},
"imap": {"username": "archive"},
},
},
"archives": [{"password": "business-zip-password"}],
"template": {
"source": {
"subject_path": "subject.txt",
"html_path": "templates/body.html",
}
},
"entries": {"source": {"type": "csv", "path": "recipients.csv"}},
"attachments": {
"base_path": "attachments",
"base_paths": [{"name": "Shared", "path": "campaign", "source": "shared"}],
"global": [{"base_dir": "global", "file_filter": "*.pdf"}],
},
}
def test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics() -> None:
job_payload = _job_detail_payload(_job()) # type: ignore[arg-type]
attempts = _job_attempts_payload([_smtp_attempt()], [_imap_attempt()]) # type: ignore[list-item]
assert "eml_local_path" not in job_payload
assert "eml_storage_key" not in job_payload
assert job_payload["attachments"] == [{"filename": "public.pdf"}]
assert "claim_token" not in attempts["smtp"][0]
assert "claim_token" not in attempts["imap"][0]
def test_operator_diagnostics_include_claim_and_storage_details() -> None:
diagnostics = _job_diagnostics_payload( # type: ignore[arg-type, list-item]
_job(),
[_smtp_attempt()],
[_imap_attempt()],
).model_dump()
assert diagnostics["storage"]["eml_local_path"] == "/runtime/campaign/job-1.eml"
assert diagnostics["storage"]["eml_storage_key"] == "campaign/job-1.eml"
assert diagnostics["worker_claim"]["claim_token"] == "job-claim-secret"
assert diagnostics["attempts"]["smtp"][0]["claim_token"] == "attempt-claim-secret"
assert diagnostics["attempts"]["imap"][0]["claim_token"] == "imap-claim-secret"
def test_diagnostics_permission_is_operator_only_by_default() -> None:
from govoplan_campaign.backend.manifest import PERMISSIONS, ROLE_TEMPLATES
permission_scopes = {permission.scope for permission in PERMISSIONS}
role_permissions = {role.slug: set(role.permissions) for role in ROLE_TEMPLATES}
assert "campaigns:diagnostic:read" in permission_scopes
assert "campaigns:diagnostic:read" in role_permissions["campaign_sender"]
assert "campaigns:diagnostic:read" not in role_permissions["campaign_manager"]
assert "campaigns:diagnostic:read" not in role_permissions["campaign_reviewer"]
def test_campaign_snapshot_with_inline_credentials_is_owner_only(tmp_path, monkeypatch) -> None:
from govoplan_campaign.backend.persistence import campaigns as persistence
snapshots = tmp_path / "snapshots"
output = tmp_path / "generated"
monkeypatch.setattr(persistence, "CAMPAIGN_SNAPSHOT_DIR", snapshots)
monkeypatch.setattr(persistence, "BUILD_OUTPUT_DIR", output)
path = persistence._write_campaign_snapshot( # type: ignore[arg-type]
SimpleNamespace(id="version-secret", raw_json={"server": {"smtp": {"password": "secret"}}})
)
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert stat.S_IMODE(snapshots.stat().st_mode) == 0o700
assert stat.S_IMODE(output.stat().st_mode) == 0o700
class ResponseSecurityTests(unittest.TestCase):
def test_public_payload(self) -> None:
test_public_payload_recursively_removes_infrastructure_locators()
def test_version_response(self) -> None:
test_version_response_omits_source_base_path_and_sanitizes_summaries()
def test_ordinary_job_response(self) -> None:
test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics()
def test_operator_diagnostics(self) -> None:
test_operator_diagnostics_include_claim_and_storage_details()
def test_operator_permission(self) -> None:
test_diagnostics_permission_is_operator_only_by_default()
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from govoplan_campaign.backend.db.models import (
JobBuildStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
)
from govoplan_campaign.backend.sending.jobs import (
_queue_validation_statuses,
_select_campaign_jobs_for_queue,
)
class FakeSession:
def __init__(self) -> None:
self.added: list[object] = []
def add(self, value: object) -> None:
self.added.append(value)
def _job(entry_id: str, **overrides):
values = {
"entry_id": entry_id,
"entry_index": int(entry_id),
"send_status": JobSendStatus.NOT_QUEUED.value,
"queue_status": JobQueueStatus.DRAFT.value,
"validation_status": JobValidationStatus.READY.value,
"build_status": JobBuildStatus.BUILT.value,
"eml_local_path": f"{entry_id}.eml",
"eml_storage_key": None,
"last_error": "old error",
"queued_at": None,
"claimed_at": "claimed",
"claim_token": "token",
"smtp_started_at": "started",
"outcome_unknown_at": "unknown",
}
values.update(overrides)
return SimpleNamespace(**values)
class CampaignQueueSelectionTests(unittest.TestCase):
def test_selects_queueable_jobs_without_reclassifying_retry_states(self):
skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value)
skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value)
blocked_validation = _job(
"3",
validation_status=JobValidationStatus.WARNING.value,
)
blocked_missing_eml = _job(
"4",
eml_local_path=None,
eml_storage_key=None,
)
ready = _job("5")
reviewed = _job(
"6",
validation_status=JobValidationStatus.NEEDS_REVIEW.value,
)
session = FakeSession()
queued, skipped_count, blocked_count = _select_campaign_jobs_for_queue(
session,
jobs=[
skipped_send,
skipped_queue,
blocked_validation,
blocked_missing_eml,
ready,
reviewed,
],
allowed_validation=_queue_validation_statuses(include_warnings=False),
reviewed_needs_review_keys={"6"},
dry_run=False,
)
self.assertEqual(queued, [ready, reviewed])
self.assertEqual(skipped_count, 2)
self.assertEqual(blocked_count, 2)
self.assertIn("generated EML", blocked_missing_eml.last_error)
self.assertEqual(session.added, [ready, reviewed])
for job in queued:
self.assertEqual(job.queue_status, JobQueueStatus.QUEUED.value)
self.assertEqual(job.send_status, JobSendStatus.QUEUED.value)
self.assertIsNotNone(job.queued_at)
self.assertIsNone(job.claimed_at)
self.assertIsNone(job.claim_token)
self.assertIsNone(job.smtp_started_at)
self.assertIsNone(job.outcome_unknown_at)
self.assertIsNone(job.last_error)
def test_dry_run_does_not_mutate_queueable_job(self):
warning = _job(
"1",
validation_status=JobValidationStatus.WARNING.value,
)
session = FakeSession()
queued, skipped_count, blocked_count = _select_campaign_jobs_for_queue(
session,
jobs=[warning],
allowed_validation=_queue_validation_statuses(include_warnings=True),
reviewed_needs_review_keys=set(),
dry_run=True,
)
self.assertEqual(queued, [warning])
self.assertEqual((skipped_count, blocked_count), (0, 0))
self.assertEqual(warning.queue_status, JobQueueStatus.DRAFT.value)
self.assertEqual(warning.send_status, JobSendStatus.NOT_QUEUED.value)
self.assertEqual(session.added, [])
if __name__ == "__main__":
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.attachments.resolver import resolve_entry_attachments
from govoplan_campaign.backend.template_rendering import (
find_unresolved_placeholders,
normalize_template_key,
render_template,
)
class CampaignTemplateRenderingTests(unittest.TestCase):
def test_template_key_aliases_have_shared_normalization(self) -> None:
cases = {
" name ": "name",
"fields.name": "name",
"local.name": "local::name",
"local:name": "local::name",
"local::name": "local::name",
"global.name": "global::name",
"global:name": "global::name",
"global::name": "global::name",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(normalize_template_key(raw), expected)
def test_rendering_preserves_both_syntaxes_none_and_missing_values(self) -> None:
values = {
"name": "Ada",
"local::reference": 42,
"global::empty": None,
}
template = "${fields.name}|{{ local.reference }}|${global:empty}|${missing}|{{ global.absent }}"
self.assertEqual(
render_template(template, values),
"Ada|42||${missing}|{{ global.absent }}",
)
self.assertEqual(render_template(template, values, keep_missing=False), "Ada|42|||")
def test_rendering_unescapes_literal_dollar_placeholders(self) -> None:
self.assertEqual(
render_template(r"\${literal\}|${known}", {"known": "resolved"}, keep_missing=False),
"${literal}|resolved",
)
def test_unresolved_placeholders_are_normalized_and_deduplicated(self) -> None:
unresolved = find_unresolved_placeholders(
r"${fields.missing} {{ local:name }} ${global.other} {{local::name}} \${escaped\}"
)
self.assertEqual(unresolved, {"missing", "local::name", "global::other"})
self.assertEqual(find_unresolved_placeholders(None), set())
def test_attachment_resolution_keeps_missing_placeholders(self) -> None:
config = CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {"id": "template-parity", "name": "Template parity", "mode": "test"},
"template": {"subject": "Subject", "text": "Body"},
"attachments": {
"base_path": ".",
"global": [
{
"base_dir": ".",
"file_filter": "${missing}.pdf",
"required": False,
"missing_behavior": "continue",
}
],
},
"entries": {
"inline": [
{
"id": "recipient-1",
"to": [{"email": "recipient@example.test", "type": "to"}],
}
]
},
}
)
with tempfile.TemporaryDirectory() as temp_dir:
resolution = resolve_entry_attachments(
config=config,
campaign_file=Path(temp_dir) / "campaign.json",
entry=config.entries.inline[0], # type: ignore[index]
entry_index=1,
)
self.assertEqual(resolution.attachments[0].file_filter, "${missing}.pdf")
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -17,7 +17,7 @@
"read-excel-file": "9.2.0" "read-excel-file": "9.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
+6 -35
View File
@@ -1,5 +1,11 @@
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types"; import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
import { apiDownload, apiFetch } from "./client"; import { apiDownload, apiFetch } from "./client";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export type {
AccessDecisionProvenanceItem,
ResourceAccessExplanationUser as AccessExplanationUser,
ResourceAccessExplanationResponse
} from "@govoplan/core-webui";
export type CampaignListResponse = export type CampaignListResponse =
CampaignListItem[] | CampaignListItem[] |
@@ -22,27 +28,6 @@ export type CampaignShare = {
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;}; export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];}; export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
export type AccessExplanationUser = {
id: string;
account_id?: string | null;
email?: string | null;
display_name?: string | null;
};
export type AccessDecisionProvenanceItem = {
kind: string;
id?: string | null;
label?: string | null;
tenant_id?: string | null;
source?: string | null;
details?: Record<string, unknown>;
};
export type ResourceAccessExplanationResponse = {
user: AccessExplanationUser;
resource_type: string;
resource_id: string;
action: string;
provenance: AccessDecisionProvenanceItem[];
};
export type CampaignUpdatePayload = { export type CampaignUpdatePayload = {
external_id?: string | null; external_id?: string | null;
@@ -995,20 +980,6 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`); return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
} }
export function fetchResourceAccessExplanation(
settings: ApiSettings,
options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
: Promise<ResourceAccessExplanationResponse> {
const params = new URLSearchParams({
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action
});
if (options.tenantId) params.set("tenant_id", options.tenantId);
return apiFetch<ResourceAccessExplanationResponse>(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
}
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> { export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`); const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
return response.shares; return response.shares;
-4
View File
@@ -1,4 +0,0 @@
import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui";
export { DataGridEmptyAction, DataGridRowActions };
export default DataGrid;
@@ -12,7 +12,7 @@ import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -13,7 +13,7 @@ import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
import { getBool, getText, updateNested } from "./utils/draftEditor"; import { getBool, getText, updateNested } from "./utils/draftEditor";
import FieldValueInput from "./components/FieldValueInput"; import FieldValueInput from "./components/FieldValueInput";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions"; import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui"; import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
@@ -8,8 +8,8 @@ import { Button } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns"; import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
import type { CampaignListItem } from "../../types"; import type { CampaignListItem } from "../../types";
@@ -126,15 +126,13 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
width: 70, width: 70,
sticky: "end", sticky: "end",
align: "right", align: "right",
render: (campaign) => render: (campaign) => <TableActionGroup actions={[{
<Link id: "open",
to={`/campaigns/${campaign.id}`} label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id }),
className="btn btn-primary admin-icon-button" icon: <ExternalLink aria-hidden="true" />,
aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })} variant: "primary",
title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}> onClick: () => navigate(`/campaigns/${campaign.id}`)
}]} />
<ExternalLink aria-hidden="true" />
</Link>
}]; }];
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ExternalLink, LockKeyhole } from "lucide-react"; import { ExternalLink, LockKeyhole, LockOpen } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
@@ -10,8 +10,8 @@ import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { import {
lockCampaignVersionPermanently, lockCampaignVersionPermanently,
lockCampaignVersionTemporarily, lockCampaignVersionTemporarily,
@@ -40,6 +40,7 @@ type LockAction = "temporary" | "unlock" | "permanent";
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null; type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const navigate = useGuardedNavigate();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const campaign = data.campaign; const campaign = data.campaign;
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]); const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
@@ -204,17 +205,17 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
<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.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} /> <MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
</div> </div>
<div className="summary-grid overview-summary-grid"> <div className="metric-grid inside">
<SummaryTile label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} /> <MetricCard label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} /> <MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} /> <MetricCard label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} /> <MetricCard label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
</div> </div>
<div className="admin-table-surface version-history-table-surface"> <div className="admin-table-surface version-history-table-surface">
<DataGrid <DataGrid
id={`campaign-${campaignId}-versions`} id={`campaign-${campaignId}-versions`}
rows={versions} rows={versions}
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)} columns={versionColumns(setPendingLockAction, navigate, campaign?.current_version_id)}
getRowKey={(version) => version.id} getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }} initialSort={{ columnId: "version", direction: "desc" }}
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e" emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
@@ -296,7 +297,7 @@ function textValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback; return typeof value === "string" ? value : fallback;
} }
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] { function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, navigate: (to: string) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
return [ return [
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 }, { id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" }, { id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
@@ -307,36 +308,18 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
{ {
id: "actions", id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 260, width: 150,
sticky: "end", sticky: "end",
render: (version) => { render: (version) => {
const isCurrent = version.id === currentVersionId; const isCurrent = version.id === currentVersionId;
return ( const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version);
<div className="button-row compact-actions"> const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
<Link return <TableActionGroup actions={[
to={`send?version=${version.id}`} { id: "open", label: i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number }), icon: <ExternalLink aria-hidden="true" />, variant: isCurrent ? "primary" : "secondary", onClick: () => navigate(`send?version=${version.id}`) },
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`} { id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) },
aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })} { id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) },
title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}> { id: "temporary-lock", label: i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number }), icon: <LockKeyhole aria-hidden="true" />, applicable: canTemporarilyLock, onClick: () => setPendingLockAction({ version, action: "temporary" }) }
]} />;
<ExternalLink aria-hidden="true" />
</Link>
{isCurrent && (isTemporaryUserLockedVersion(version) ?
<>
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>i18n:govoplan-campaign.unlock.1526a17e</Button>
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>i18n:govoplan-campaign.lock_permanently.cc0ce9e7</Button>
</> :
!isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ?
<Button
className="admin-icon-button"
onClick={() => setPendingLockAction({ version, action: "temporary" })}
aria-label={i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number })}
title="i18n:govoplan-campaign.temporarily_lock_version.82b31149">
<LockKeyhole aria-hidden="true" />
</Button> :
null)}
</div>);
} }
}]; }];
@@ -392,12 +375,3 @@ function lockDialogLabel(pending: PendingLockAction): string {
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7"; if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
return "i18n:govoplan-campaign.confirm.04a21221"; return "i18n:govoplan-campaign.confirm.04a21221";
} }
function SummaryTile({ label, value }: {label: string;value: string | number;}) {
return (
<div className="summary-tile">
<span>{label}</span>
<strong>{value}</strong>
</div>);
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, RotateCcw, Search, X } from "lucide-react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
downloadCampaignJobsCsv, downloadCampaignJobsCsv,
@@ -15,13 +16,13 @@ import {
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid"; import { DataGrid, type DataGridColumn, type DataGridListOption } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { LoadingFrame, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui"; import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, humanize } from "./utils/campaignView"; import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas"; import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
@@ -358,18 +359,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{ {
id: "actions", id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 250, width: 190,
sticky: "end", sticky: "end",
render: (row) => { render: (row) => {
const id = String(row.id ?? ""); const id = String(row.id ?? "");
const status = String(row.send_status ?? ""); const status = String(row.send_status ?? "");
return ( return <TableActionGroup actions={[
<div className="button-row compact-actions"> { id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, disabled: !id || busyAction === "detail", onClick: () => void openJob(id) },
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button> { id: "retry", label: busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now", icon: <RotateCcw aria-hidden="true" />, applicable: retryableFailedStatus(status), disabled: !id || Boolean(busyAction), onClick: () => void retryFailedSynchronously([row]) },
{retryableFailedStatus(status) && <Button onClick={() => void retryFailedSynchronously([row])} disabled={!id || Boolean(busyAction)}>{busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now"}</Button>} { id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "smtp_accepted" }) },
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>} { id: "not-sent", label: "i18n:govoplan-campaign.not_sent.587c501e", icon: <X aria-hidden="true" />, variant: "danger", applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "not_sent" }) }
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>} ]} />;
</div>);
} }
}], }],
@@ -473,8 +473,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<span>i18n:govoplan-campaign.recipients.78cbf8eb</span> <span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} /> <textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
</label> </label>
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label> <ToggleSwitch label="i18n:govoplan-campaign.attach_job_csv.adb76197" checked={attachCsv} onChange={setAttachCsv} />
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label> <ToggleSwitch label="i18n:govoplan-campaign.attach_json_report.d70883b5" checked={attachJson} onChange={setAttachJson} />
</Dialog> </Dialog>
<Dialog <Dialog
@@ -526,39 +526,21 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
} }
const columns: DataGridColumn<Record<string, unknown>>[] = [
{ id: "attempt", header: "#", width: 72, sortable: true, value: (row, index) => Number(row.attempt_number ?? index + 1), render: (row, index) => String(row.attempt_number ?? index + 1) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (row) => String(row.status ?? "unknown"), render: (row) => <StatusBadge status={String(row.status ?? "unknown")} /> },
kind === "imap" ?
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? "")}>{String(row.smtp_response ?? row.error_message ?? "—")}</span> }
];
return ( return (
<section className="attempt-history-section"> <section className="attempt-history-section">
<h3>{title}</h3> <h3>{title}</h3>
<div className="attempt-history-wrap"> <DataGrid id={`campaign-${kind}-attempt-history`} rows={rows} columns={columns} getRowKey={(row, index) => String(row.id ?? `${kind}-${index}`)} />
<table className="attempt-history-table">
<thead>
<tr>
<th>#</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
{kind === "imap" && <th>i18n:govoplan-campaign.folder.30baa249</th>}
{kind === "smtp" && <th>i18n:govoplan-campaign.code.adac6937</th>}
<th>i18n:govoplan-campaign.started.faa9e7e7</th>
<th>i18n:govoplan-campaign.finished.355bcc57</th>
<th>i18n:govoplan-campaign.result.5faa59d4</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) =>
<tr key={String(row.id ?? `${kind}-${index}`)}>
<td>{String(row.attempt_number ?? index + 1)}</td>
<td><StatusBadge status={String(row.status ?? "unknown")} /></td>
{kind === "imap" && <td>{String(row.folder ?? "—")}</td>}
{kind === "smtp" && <td>{String(row.smtp_status_code ?? "—")}</td>}
<td>{formatDateTime(String(row.started_at ?? row.created_at ?? ""))}</td>
<td>{formatDateTime(String(row.finished_at ?? row.updated_at ?? ""))}</td>
<td title={String(row.smtp_response ?? row.error_message ?? "")}>
{String(row.smtp_response ?? row.error_message ?? "—")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>); </section>);
} }
@@ -13,7 +13,7 @@ import CampaignAccessCard from "./components/CampaignAccessCard";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
import { hasScope } from "@govoplan/core-webui"; import { hasScope } from "@govoplan/core-webui";
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement"; import { RetentionPolicyEditor } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
+111 -210
View File
@@ -21,7 +21,8 @@ import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { SegmentedControl } from "@govoplan/core-webui"; import { SegmentedControl } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import { TableActionGroup } from "@govoplan/core-webui";
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -582,24 +583,10 @@ function AddressHeaderControl({ columns, values, emptyText, disabled, onEdit, on
<span>{translateText(emptyText)}</span> <span>{translateText(emptyText)}</span>
} }
</div> </div>
<Button <TableActionGroup actions={[
type="button" { id: "edit", label: "Edit addresses", icon: <Pencil aria-hidden="true" />, disabled, onClick: onEdit },
className="admin-icon-button recipient-address-inline-button" { id: "copy", label: "Copy addresses", icon: <Copy aria-hidden="true" />, applicable: Boolean(copiedText), onClick: onCopy }
disabled={disabled} ]} />
aria-label="Edit addresses"
title="Edit addresses"
onClick={onEdit}>
<Pencil aria-hidden="true" />
</Button>
<Button
type="button"
className="admin-icon-button recipient-address-inline-button"
disabled={!copiedText}
aria-label="Copy addresses"
title="Copy addresses"
onClick={onCopy}>
<Copy aria-hidden="true" />
</Button>
</div> </div>
</div>); </div>);
} }
@@ -783,18 +770,17 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
{column.allowMultiple && draftAddresses.length === 0 && {column.allowMultiple && draftAddresses.length === 0 &&
<div className="recipient-address-empty-row"> <div className="recipient-address-empty-row">
<p className="muted recipient-address-empty">{column.emptyText}</p> <p className="muted recipient-address-empty">{column.emptyText}</p>
<div className="data-grid-row-actions data-grid-empty-row-actions"> <TableActionGroup
<Button className="data-grid-empty-row-actions"
type="button" actions={[{
variant="primary" id: "add",
className="data-grid-row-action is-add" label: translatedAddLabel,
aria-label={translatedAddLabel} icon: <Plus size={16} aria-hidden="true" />,
title={translatedAddLabel} variant: "primary",
disabled={!canAdd} disabled: !canAdd,
onClick={() => addAddress()}> onClick: () => addAddress()
<Plus size={16} aria-hidden="true" /> }]}
</Button> />
</div>
</div> </div>
} }
{draftAddresses.map((address, addressIndex) => {draftAddresses.map((address, addressIndex) =>
@@ -813,48 +799,15 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
aria-label={`${column.label} email ${addressIndex + 1}`} aria-label={`${column.label} email ${addressIndex + 1}`}
onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} /> onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} />
{column.allowMultiple && {column.allowMultiple &&
<div className="data-grid-row-actions recipient-address-line-actions"> <TableActionGroup
<Button className="recipient-address-line-actions"
type="button" actions={[
variant="primary" { id: "add", label: translatedAddLabel, icon: <Plus size={16} aria-hidden="true" />, variant: "primary", disabled: !canAdd, onClick: () => addAddress(addressIndex) },
className="data-grid-row-action is-add" { id: "move-up", label: translatedMoveUpLabel, icon: <ArrowUp size={16} aria-hidden="true" />, disabled: locked || addressIndex === 0, onClick: () => moveAddress(addressIndex, addressIndex - 1) },
aria-label={translatedAddLabel} { id: "move-down", label: translatedMoveDownLabel, icon: <ArrowDown size={16} aria-hidden="true" />, disabled: locked || addressIndex >= draftAddresses.length - 1, onClick: () => moveAddress(addressIndex, addressIndex + 1) },
title={translatedAddLabel} { id: "remove", label: translatedRemoveLabel, icon: <Trash2 size={16} aria-hidden="true" />, variant: "danger", disabled: locked, onClick: () => removeAddress(addressIndex) }
disabled={!canAdd} ]}
onClick={() => addAddress(addressIndex)}> />
<Plus size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveUpLabel}
title={translatedMoveUpLabel}
disabled={locked || addressIndex === 0}
onClick={() => moveAddress(addressIndex, addressIndex - 1)}>
<ArrowUp size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveDownLabel}
title={translatedMoveDownLabel}
disabled={locked || addressIndex >= draftAddresses.length - 1}
onClick={() => moveAddress(addressIndex, addressIndex + 1)}>
<ArrowDown size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="danger"
className="data-grid-row-action is-remove"
aria-label={translatedRemoveLabel}
title={translatedRemoveLabel}
disabled={locked}
onClick={() => removeAddress(addressIndex)}>
<Trash2 size={16} aria-hidden="true" />
</Button>
</div>
} }
</div> </div>
)} )}
@@ -1080,34 +1033,25 @@ function AddressSourceImportDialog({ settings, campaignId, sources, initialSourc
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div> <div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div> <div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
</dl> </dl>
<div className="admin-table-surface recipient-import-preview-surface"> <AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Email</th>
<th>Fields</th>
</tr>
</thead>
<tbody>
{snapshot.recipients.slice(0, 20).map((recipient, index) =>
<tr key={`${recipient.contact_id}-${recipient.email}`}>
<td>{index + 1}</td>
<td>{recipient.display_name}</td>
<td>{recipient.email}</td>
<td>{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}</td>
</tr>
)}
</tbody>
</table>
</div>
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>} {snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
</> </>
} }
</Dialog>); </Dialog>);
} }
type AddressSourceSnapshotRecipient = CampaignRecipientAddressSourceSnapshot["recipients"][number];
function AddressSourceRecipientPreviewGrid({ recipients }: {recipients: AddressSourceSnapshotRecipient[];}) {
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
{ id: "row", header: "#", width: 64, value: (_recipient, index) => index + 1, render: (_recipient, index) => index + 1 },
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.display_name },
{ id: "email", header: "Email", width: "minmax(220px, 1.2fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.email },
{ id: "fields", header: "Fields", width: 100, sortable: true, value: (recipient) => Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length }
];
return <DataGrid id="campaign-address-source-import-preview" rows={recipients} columns={columns} getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`} />;
}
type RecipientImportDialogProps = { type RecipientImportDialogProps = {
settings: ApiSettings; settings: ApiSettings;
campaignId: string; campaignId: string;
@@ -1451,6 +1395,30 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
replaceColumnMapping(nextMapping); replaceColumnMapping(nextMapping);
} }
const mappingRows = (table?.headers ?? []).map((header, columnIndex) => ({
id: `${columnIndex}-${header}`,
header,
columnIndex,
mapping: mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const }
}));
type MappingRow = (typeof mappingRows)[number];
const mappingColumns: DataGridColumn<MappingRow>[] = [
{ id: "column", header: "i18n:govoplan-campaign.column.65ba00e9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.header, render: (row) => <strong>{row.header}</strong> },
{ id: "sample", header: "i18n:govoplan-campaign.sample.58fabfa7", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (row) => table ? sampleColumnValue(table.dataRows, row.columnIndex) : "", render: (row) => <span className="mono-small">{table ? sampleColumnValue(table.dataRows, row.columnIndex) : ""}</span> },
{ id: "content", header: "i18n:govoplan-campaign.content.4f9be057", width: 190, value: (row) => row.mapping.kind, render: (row) => <select value={row.mapping.kind} onChange={(event) => changeColumnKind(row.columnIndex, event.target.value as RecipientColumnKind)}>{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select> },
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(200px, .9fr)", minWidth: 180, resizable: true, value: (row) => row.mapping.fieldName ?? row.mapping.newFieldName ?? "", render: (row) => <>{row.mapping.kind === "field" && <select value={row.mapping.fieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, fieldName: event.target.value, newFieldName: undefined })}><option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}</select>}{row.mapping.kind === "new_field" && <input value={row.mapping.newFieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, newFieldName: event.target.value, fieldName: undefined })} />}</> }
];
type PreviewRow = RecipientImportPreview["rows"][number];
const previewColumns: DataGridColumn<PreviewRow>[] = [
{ id: "row", header: "#", width: 68, sortable: true, value: (row) => row.rowNumber },
{ id: "to", header: "i18n:govoplan-campaign.to.ae79ea1e", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => formatAddressList(row.addresses.to) },
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.name },
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 100, sortable: true, value: (row) => Object.keys(row.fields).length },
{ id: "patterns", header: "i18n:govoplan-campaign.patterns.4d34f7a2", width: 110, sortable: true, value: (row) => row.patterns.length },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552", render: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552" }
];
const stepContent = activeStep === "upload" ? const stepContent = activeStep === "upload" ?
<> <>
<div className="campaign-header-grid recipient-import-upload-grid"> <div className="campaign-header-grid recipient-import-upload-grid">
@@ -1569,51 +1537,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
</select> </select>
</FormField> </FormField>
</div> </div>
<div className="admin-table-surface recipient-import-preview-surface"> <DataGrid id="campaign-recipient-import-mapping" rows={mappingRows} columns={mappingColumns} getRowKey={(row) => row.id} />
<table className="recipient-import-preview-table recipient-import-mapping-table">
<thead>
<tr>
<th>i18n:govoplan-campaign.column.65ba00e9</th>
<th>i18n:govoplan-campaign.sample.58fabfa7</th>
<th>i18n:govoplan-campaign.content.4f9be057</th>
<th>i18n:govoplan-campaign.field.c326a466</th>
</tr>
</thead>
<tbody>
{table?.headers.map((header, columnIndex) => {
const mapping = mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const };
return (
<tr key={`${columnIndex}-${header}`}>
<td><strong>{header}</strong></td>
<td className="mono-small">{sampleColumnValue(table.dataRows, columnIndex)}</td>
<td>
<select value={mapping.kind} onChange={(event) => changeColumnKind(columnIndex, event.target.value as RecipientColumnKind)}>
{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</td>
<td>
{mapping.kind === "field" &&
<select
value={mapping.fieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, fieldName: event.target.value, newFieldName: undefined })}>
<option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>
{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
}
{mapping.kind === "new_field" &&
<input
value={mapping.newFieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, newFieldName: event.target.value, fieldName: undefined })} />
}
</td>
</tr>);
})}
</tbody>
</table>
</div>
</> : </> :
activeStep === "preview" ? activeStep === "preview" ?
<> <>
@@ -1628,32 +1552,14 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
{preview.fieldNamesToCreate.length > 0 && {preview.fieldNamesToCreate.length > 0 &&
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p> <p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
} }
<div className="admin-table-surface recipient-import-preview-surface"> <DataGrid
<table className="recipient-import-preview-table"> id="campaign-recipient-import-preview"
<thead> className="recipient-import-preview-grid"
<tr> rows={preview.rows.slice(0, 20)}
<th>#</th> columns={previewColumns}
<th>i18n:govoplan-campaign.to.ae79ea1e</th> getRowKey={(row) => String(row.rowNumber)}
<th>i18n:govoplan-campaign.name.709a2322</th> rowClassName={(row) => row.issues.length ? "is-invalid" : undefined}
<th>i18n:govoplan-campaign.fields.e8b68527</th> />
<th>i18n:govoplan-campaign.patterns.4d34f7a2</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
</tr>
</thead>
<tbody>
{preview.rows.slice(0, 20).map((row) =>
<tr key={row.rowNumber} className={row.issues.length ? "is-invalid" : undefined}>
<td>{row.rowNumber}</td>
<td>{formatAddressList(row.addresses.to)}</td>
<td>{row.name}</td>
<td>{Object.keys(row.fields).length}</td>
<td>{row.patterns.length}</td>
<td>{row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552"}</td>
</tr>
)}
</tbody>
</table>
</div>
</> </>
} }
</> : </> :
@@ -1743,6 +1649,14 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>; return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>;
} }
type ResolvedFile = ImportFileLinkResolution["files"][number];
const fileColumns: DataGridColumn<ResolvedFile>[] = [
{ id: "file", header: "i18n:govoplan-campaign.file.2c3cafa4", width: "minmax(200px, .8fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (file) => file.filename, render: (file) => <strong>{file.filename}</strong> },
{ id: "path", header: "i18n:govoplan-campaign.path.519e3913", width: "minmax(260px, 1.3fr)", minWidth: 220, resizable: true, sortable: true, filterable: true, value: (file) => file.display_path },
{ id: "size", header: "i18n:govoplan-campaign.size.b7152342", width: 120, sortable: true, value: (file) => file.size_bytes, render: (file) => formatImportBytes(file.size_bytes) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (file) => linkableIds.has(file.id) ? "needs-linking" : "linked", render: (file) => linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600" }
];
return ( return (
<div className="recipient-import-file-step"> <div className="recipient-import-file-step">
<div className="recipient-import-file-actions"> <div className="recipient-import-file-actions">
@@ -1781,30 +1695,13 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
</div> </div>
} }
<div className="admin-table-surface recipient-import-preview-surface"> <DataGrid
<table className="recipient-import-preview-table recipient-import-file-link-table"> id="campaign-recipient-import-file-links"
<thead> rows={resolution.files}
<tr> columns={fileColumns}
<th>i18n:govoplan-campaign.file.2c3cafa4</th> getRowKey={(file) => file.id}
<th>i18n:govoplan-campaign.path.519e3913</th> emptyText="i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b"
<th>i18n:govoplan-campaign.size.b7152342</th> />
<th>i18n:govoplan-campaign.status.bae7d5be</th>
</tr>
</thead>
<tbody>
{resolution.files.length === 0 ?
<tr><td colSpan={4}>i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b</td></tr> :
resolution.files.map((file) =>
<tr key={file.id}>
<td><strong>{file.filename}</strong></td>
<td>{file.display_path}</td>
<td>{formatImportBytes(file.size_bytes)}</td>
<td>{linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600"}</td>
</tr>
)}
</tbody>
</table>
</div>
</> </>
} }
</div>); </div>);
@@ -1820,27 +1717,31 @@ type RecipientImportRawTableProps = {
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) { function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
const visibleRows = tableRows.slice(0, 15); const visibleRows = tableRows.slice(0, 15);
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length)); const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
const rows = visibleRows.map((cells, rowIndex) => ({ rowIndex, cells }));
type RawRow = (typeof rows)[number];
const columns: DataGridColumn<RawRow>[] = [
{ id: "row", header: "#", width: 64, sortable: true, value: (row) => row.rowIndex + 1 },
...Array.from({ length: safeColumnCount }, (_value, columnIndex): DataGridColumn<RawRow> => ({
id: `column-${columnIndex + 1}`,
header: String(columnIndex + 1),
width: "minmax(140px, 1fr)",
minWidth: 120,
resizable: true,
filterable: true,
value: (row) => row.cells[columnIndex] ?? ""
}))
];
return ( return (
<div className="admin-table-surface recipient-import-preview-surface"> <DataGrid
<table className="recipient-import-preview-table recipient-import-raw-table"> id="campaign-recipient-import-raw"
<thead> className="recipient-import-raw-grid"
<tr> rows={rows}
<th>#</th> columns={columns}
{Array.from({ length: safeColumnCount }, (_value, index) => <th key={index}>{index + 1}</th>)} getRowKey={(row) => String(row.rowIndex)}
</tr> rowClassName={(row) => row.rowIndex < headerRowCount ? "is-header-row" : undefined}
</thead> emptyText="i18n:govoplan-campaign.no_rows_parsed.a7ccc3de"
<tbody> initialFit="content"
{visibleRows.length === 0 ? />);
<tr><td colSpan={safeColumnCount + 1}>i18n:govoplan-campaign.no_rows_parsed.a7ccc3de</td></tr> :
visibleRows.map((row, rowIndex) =>
<tr key={rowIndex} className={rowIndex < headerRowCount ? "is-header-row" : undefined}>
<td>{rowIndex + 1}</td>
{Array.from({ length: safeColumnCount }, (_value, columnIndex) => <td key={columnIndex}>{row[columnIndex] ?? ""}</td>)}
</tr>
)}
</tbody>
</table>
</div>);
} }
@@ -7,6 +7,7 @@ import {
Link2, Link2,
LockKeyhole, LockKeyhole,
PackageCheck, PackageCheck,
Search,
Send, Send,
ShieldCheck, ShieldCheck,
X, X,
@@ -36,8 +37,8 @@ import {
"../../api/campaigns"; "../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui"; import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "../../components/table/DataGrid"; import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
@@ -1940,7 +1941,7 @@ reviewedKeys: Set<string>)
}, },
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) }, { id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" }, { id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => openMessage(row)}>i18n:govoplan-campaign.review.e29a79fe</Button> }]; { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];
} }
@@ -2132,7 +2133,7 @@ function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): Da
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") }, { id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") }, { id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") }, { id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openDetail(String(row.id ?? ""))}>i18n:govoplan-campaign.details.dc3decbb</Button> }]; { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, onClick: () => void openDetail(String(row.id ?? "")) }]} /> }];
} }
@@ -2146,7 +2147,7 @@ function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGri
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, { id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")}${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` }, { id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")}${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) }, { id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openMessage(String(row.id ?? ""))}>i18n:govoplan-campaign.review.e29a79fe</Button> }]; { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => void openMessage(String(row.id ?? "")) }]} /> }];
} }
@@ -4,7 +4,7 @@ import type { ApiSettings } from "../../../types";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui"; import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid"; import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
import { getBool, getText } from "../utils/draftEditor"; import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments"; import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types"; import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types";
import { KeyRound } from "lucide-react"; import { KeyRound, Trash2 } from "lucide-react";
import { import {
fetchResourceAccessExplanation, fetchResourceAccessExplanation,
getCampaignShares, getCampaignShares,
@@ -12,13 +12,13 @@ import {
type CampaignShareTargets, type CampaignShareTargets,
type ResourceAccessExplanationResponse } from type ResourceAccessExplanationResponse } from
"../../../api/campaigns"; "../../../api/campaigns";
import { Button } from "@govoplan/core-webui"; import { Button, ResourceAccessExplanation } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { StatusBadge, TableActionGroup, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
type TargetType = "user" | "group"; type TargetType = "user" | "group";
export default function CampaignAccessCard({ export default function CampaignAccessCard({
@@ -196,7 +196,7 @@ export default function CampaignAccessCard({
} }
}, },
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> }, { id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>i18n:govoplan-campaign.remove.e963907d</Button> }], { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, resizable: false, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "remove", label: "i18n:govoplan-campaign.remove.e963907d", icon: <Trash2 aria-hidden="true" />, variant: "danger", onClick: () => setRevokeTarget(row) }]} /> }],
[targetMap]); [targetMap]);
if (available === false) return null; if (available === false) return null;
@@ -221,76 +221,11 @@ 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> <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>
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}> <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>}>
<ResourceAccessExplanationContent loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} /> <ResourceAccessExplanation loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} />
</Dialog> </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()} /> <ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
</>); </>);
} }
function ResourceAccessExplanationContent({
loading,
explanation,
fallbackResourceLabel
}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) {
if (loading) return <p className="muted small-note">i18n:govoplan-campaign.loading_access_explanation.04a7c934</p>;
if (!explanation) return null;
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
return (
<>
<div className="form-grid compact responsive-form-grid">
<div><span className="form-label">i18n:govoplan-campaign.user.9f8a2389</span><p>{userLabel}</p></div>
<div><span className="form-label">i18n:govoplan-campaign.resource.d1c626a9</span><p>{resourceLabel}</p></div>
<div><span className="form-label">i18n:govoplan-campaign.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
<div><span className="form-label">i18n:govoplan-campaign.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
</div>
{explanation.provenance.length === 0 ?
<p className="muted small-note">i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e</p> :
<div className="admin-assignment-grid">
{explanation.provenance.map((item, index) =>
<div key={`${item.kind}:${item.id ?? index}`}>
<strong>{provenanceKindLabel(item.kind)}</strong>
<div className="muted small-note">
{item.source || "i18n:govoplan-campaign.no_source.6dcf9723"}
{item.id && <> · <code>{item.id}</code></>}
</div>
{item.label && <p>{item.label}</p>}
{Object.keys(item.details ?? {}).length > 0 && <p className="muted small-note">{formatProvenanceDetails(item.details ?? {})}</p>}
</div>
)}
</div>
}
</>);
}
function provenanceKindLabel(kind: string): string {
switch (kind) {
case "resource":
return "i18n:govoplan-campaign.resource.d1c626a9";
case "owner":
return "i18n:govoplan-campaign.owner.89ff3122";
case "share":
return "i18n:govoplan-campaign.share.09ca55ca";
case "policy":
return "i18n:govoplan-campaign.policy.0b779a05";
case "role":
return "i18n:govoplan-campaign.role.b5b4a5a2";
case "right":
return "i18n:govoplan-campaign.permission.2f81a22d";
default:
return kind;
}
}
function formatProvenanceDetails(details: Record<string, unknown>): string {
return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
}
function formatProvenanceValue(value: unknown): string {
if (value === null || value === undefined) return "i18n:govoplan-campaign.none.6eef6648";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
@@ -1,15 +1,15 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink } from "lucide-react"; import { ExternalLink, RotateCcw, Send } from "lucide-react";
import type { ApiSettings, CampaignListItem } from "../../types"; import type { ApiSettings, CampaignListItem } from "../../types";
import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns"; import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui"; import { StatusBadge, TableActionGroup, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView"; import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
type OperatorRow = { type OperatorRow = {
@@ -165,16 +165,13 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
{ {
id: "actions", id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 270, width: 150,
sticky: "end", sticky: "end",
render: (row) => render: (row) => <TableActionGroup actions={[
<div className="button-row compact-actions"> { id: "open", label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }), icon: <ExternalLink aria-hidden="true" />, onClick: () => navigate(`/campaigns/${row.campaign.id}/report`) },
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })} title={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })}> { id: "retry", label: "i18n:govoplan-campaign.retry.9f5cd8a2", icon: <RotateCcw aria-hidden="true" />, applicable: row.failed > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "retry") },
<ExternalLink /> { id: "queue-unsent", label: "i18n:govoplan-campaign.queue_unsent.b0e98610", icon: <Send aria-hidden="true" />, applicable: row.notAttempted > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "unattempted") }
</Button> ]} />
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>i18n:govoplan-campaign.retry.9f5cd8a2</Button>
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>i18n:govoplan-campaign.queue_unsent.b0e98610</Button>
</div>
}], }],
[busy, navigate]); [busy, navigate]);
@@ -1,2 +0,0 @@
export { RetentionPolicyEditor, RetentionPolicyScopeManager } from "@govoplan/core-webui";
export type { RetentionPolicyTargetOption } from "@govoplan/core-webui";
+10 -9
View File
@@ -4,10 +4,10 @@ import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui"; import { FieldLabel } from "@govoplan/core-webui";
import { StatusBadge, i18nMessage } from "@govoplan/core-webui"; import { StatusBadge, TableActionGroup, i18nMessage } from "@govoplan/core-webui";
import { usePlatformLanguage } from "@govoplan/core-webui"; import { usePlatformLanguage } from "@govoplan/core-webui";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav"; import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
type TemplateRecord = { type TemplateRecord = {
id: string; id: string;
@@ -97,10 +97,12 @@ function templateLibraryColumns(openTemplate: (templateId: string) => void): Dat
width: 70, width: 70,
sticky: "end", sticky: "end",
align: "right", align: "right",
render: (template) => render: (template) => <TableActionGroup actions={[{
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })} title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })}> id: "open",
<ExternalLink /> label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name }),
</Button> icon: <ExternalLink aria-hidden="true" />,
onClick: () => openTemplate(template.id)
}]} />
}]; }];
@@ -115,8 +117,7 @@ function templateFieldColumns(): DataGridColumn<{id: string;field: string;source
return [ return [
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field }, { id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
{ id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source }, { id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source },
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required }, { id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required }];
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 130, sticky: "end", render: () => <Button disabled>i18n:govoplan-campaign.configure.792c81a4</Button> }];
} }
-4
View File
@@ -1,4 +0,0 @@
import { ModuleSubnav } from "@govoplan/core-webui";
export type { ModuleSubnavGroup, ModuleSubnavItem } from "@govoplan/core-webui";
export default ModuleSubnav;
+1 -1
View File
@@ -1,5 +1,5 @@
import type { CampaignWorkspaceSection } from "../types"; import type { CampaignWorkspaceSection } from "../types";
import ModuleSubnav, { type ModuleSubnavGroup } from "./ModuleSubnav"; import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
{ {
+14 -252
View File
@@ -1,7 +1,4 @@
/* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */ /* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */
.alert.success { background: var(--success-bg); color: var(--success-text); margin-bottom: 12px; }
.alert.info { background: var(--info-bg); color: var(--info-text); margin-bottom: 12px; }
.small-note { font-size: 12px; }
.wizard-action-grid { .wizard-action-grid {
display: grid; display: grid;
@@ -80,7 +77,6 @@
.standalone-wizard-body { min-height: auto; } .standalone-wizard-body { min-height: auto; }
/* Editable campaign data surfaces. */ /* Editable campaign data surfaces. */
.responsive-form-grid { align-items: start; }
.json-edit-block { .json-edit-block {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -92,7 +88,6 @@
line-height: 1.45; line-height: 1.45;
color: var(--text); color: var(--text);
} }
.danger-text { color: var(--danger-text); }
.section-mini-heading { .section-mini-heading {
margin: 18px 0 8px; margin: 18px 0 8px;
font-size: 12px; font-size: 12px;
@@ -112,24 +107,8 @@
white-space: pre-wrap; white-space: pre-wrap;
font-size: 12px; font-size: 12px;
} }
.placeholder-stack {
display: grid;
gap: 8px;
margin-top: 12px;
}
.placeholder-stack span {
display: block;
border: 1px dashed var(--line-dark);
border-radius: 6px;
background: var(--panel-soft);
padding: 10px 12px;
color: var(--muted);
font-weight: 700;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.template-editor-grid { grid-template-columns: 1fr; } .template-editor-grid { grid-template-columns: 1fr; }
.form-grid.compact.responsive-form-grid { grid-template-columns: 1fr; }
} }
/* Campaign data layout refinement. */ /* Campaign data layout refinement. */
@@ -183,16 +162,6 @@
.direct-attachment-table td:nth-child(3) { width: 180px; } .direct-attachment-table td:nth-child(3) { width: 180px; }
.direct-attachment-table th:last-child, .direct-attachment-table th:last-child,
.direct-attachment-table td:last-child { width: 96px; } .direct-attachment-table td:last-child { width: 96px; }
.table-action-cell {
text-align: right;
white-space: nowrap;
}
.empty-table-cell {
color: var(--muted);
font-size: 13px;
padding: 18px 14px !important;
text-align: center;
}
.field-editor-table th:nth-child(1), .field-editor-table th:nth-child(1),
.field-editor-table td:nth-child(1) { min-width: 80px; } .field-editor-table td:nth-child(1) { min-width: 80px; }
.field-editor-table th:nth-child(2), .field-editor-table th:nth-child(2),
@@ -257,9 +226,6 @@
font-size: 12px; font-size: 12px;
line-height: 1.45; line-height: 1.45;
} }
.compact-detail-list {
gap: 8px;
}
.global-values-preview { .global-values-preview {
margin-top: 14px; margin-top: 14px;
} }
@@ -322,28 +288,9 @@
.campaigns-page .card-body { .campaigns-page .card-body {
padding: 0; padding: 0;
} }
.campaign-table-wrap {
margin: 0;
}
.campaign-table thead,
.campaign-table thead th {
top: 64px;
}
.campaign-table th:first-child,
.campaign-table td:first-child {
padding-left: 24px;
}
.campaign-table th:last-child,
.campaign-table td:last-child {
padding-right: 24px;
}
.module-workspace .section-sidebar { .module-workspace .section-sidebar {
padding-top: 18px; padding-top: 18px;
} }
.settings-dashboard-grid {
align-items: start;
}
.queue-pressure-section { .queue-pressure-section {
margin-bottom: 18px; margin-bottom: 18px;
} }
@@ -374,13 +321,6 @@
font-weight: 700; font-weight: 700;
color: var(--text-strong); color: var(--text-strong);
} }
.module-big-number {
display: block;
color: var(--text-strong);
font-size: 24px;
line-height: 1.2;
margin-bottom: 8px;
}
.stacked-actions { .stacked-actions {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
@@ -439,10 +379,6 @@
line-height: 1.35; line-height: 1.35;
} }
.small-text {
font-size: 0.78rem;
}
.compact-chip-row { .compact-chip-row {
gap: 0.28rem; gap: 0.28rem;
flex-wrap: nowrap; flex-wrap: nowrap;
@@ -550,13 +486,8 @@
width: 100%; width: 100%;
} }
.overview-config-actions a { text-decoration: none; } .overview-config-actions a { text-decoration: none; }
.overview-summary-grid {
grid-template-columns: repeat(4, minmax(120px, 1fr));
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.overview-config-grid, .overview-config-grid { grid-template-columns: 1fr; }
.overview-summary-grid { grid-template-columns: 1fr; }
.overview-config-card { grid-template-rows: auto auto auto auto; } .overview-config-card { grid-template-rows: auto auto auto auto; }
} }
@@ -580,7 +511,7 @@
box-shadow: var(--shadow-campaign-small); box-shadow: var(--shadow-campaign-small);
} }
.field-chip-button.used { .field-chip-button.used {
background: var(--green-soft); background: var(--success-soft);
border-color: var(--success-border-soft); border-color: var(--success-border-soft);
color: var(--success-text-strong); color: var(--success-text-strong);
} }
@@ -745,14 +676,6 @@
color: var(--muted); color: var(--muted);
font-style: italic; font-style: italic;
} }
.recipient-address-inline-button {
width: 28px;
height: 28px;
}
.recipient-address-inline-button svg {
width: 16px;
height: 16px;
}
.recipient-add-row td { .recipient-add-row td {
background: var(--panel-soft); background: var(--panel-soft);
} }
@@ -771,15 +694,6 @@
} }
/* Recipient editor compaction and reusable attachment rule overlay. */ /* Recipient editor compaction and reusable attachment rule overlay. */
.recipient-editor-table th:nth-child(1),
.recipient-editor-table td:nth-child(1) { width: 42px; }
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 500px; }
.recipient-editor-table th:nth-child(3),
.recipient-editor-table td:nth-child(3) { min-width: 192px; }
.recipient-editor-table th:last-child,
.recipient-editor-table td:last-child { width: 123px; }
.recipient-editor-table td { vertical-align: top; }
.recipient-cell { white-space: normal !important; } .recipient-cell { white-space: normal !important; }
.recipient-address-stack { .recipient-address-stack {
display: grid; display: grid;
@@ -805,9 +719,6 @@
line-height: 22px; line-height: 22px;
text-transform: uppercase; text-transform: uppercase;
} }
.recipient-field-input {
min-width: 140px;
}
.attachment-summary-button { .attachment-summary-button {
white-space: nowrap; white-space: nowrap;
} }
@@ -861,8 +772,6 @@
.attachment-sources-table td:last-child { width: 123px; } .attachment-sources-table td:last-child { width: 123px; }
@media (max-width: 900px) { @media (max-width: 900px) {
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 300px; }
.recipient-address-stack { min-width: 260px; } .recipient-address-stack { min-width: 260px; }
} }
.attachment-rules-table th:nth-child(1), .attachment-rules-table th:nth-child(1),
@@ -894,7 +803,7 @@
.attachment-rules-main { .attachment-rules-main {
min-width: 0; min-width: 0;
} }
.attachment-rules-table tr.is-choosing-file td { .attachment-rules-table .data-grid-body-cell.is-choosing-file {
background: var(--panel-soft); background: var(--panel-soft);
} }
.attachment-file-browser-panel { .attachment-file-browser-panel {
@@ -940,7 +849,7 @@
.field-with-action input.chooser-display-input { .field-with-action input.chooser-display-input {
background: var(--panel-soft); background: var(--panel-soft);
border-color: var(--line-dark); border-color: var(--line-dark);
color: var(--ink); color: var(--text-strong);
cursor: pointer; cursor: pointer;
caret-color: transparent; caret-color: transparent;
user-select: none; user-select: none;
@@ -1130,7 +1039,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
max-width: 260px; max-width: 260px;
color: var(--ink); color: var(--text-strong);
text-decoration: none; text-decoration: none;
} }
.recipient-data-identity:hover .recipient-data-address { .recipient-data-identity:hover .recipient-data-address {
@@ -1279,7 +1188,7 @@
overflow: auto; overflow: auto;
} }
.version-history-table .current-version-row td { .version-history-table .data-grid-body-cell.current-version-row {
font-weight: 700; font-weight: 700;
} }
@@ -1297,7 +1206,7 @@
border: 1px solid var(--line-subtle); border: 1px solid var(--line-subtle);
border-radius: 12px; border-radius: 12px;
background: var(--panel-glass); background: var(--panel-glass);
color: var(--text-primary); color: var(--text);
padding: 12px; padding: 12px;
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -1379,7 +1288,7 @@
border-radius: 12px; border-radius: 12px;
background: var(--panel-glass); background: var(--panel-glass);
padding: 0.55rem 0.7rem; padding: 0.55rem 0.7rem;
color: var(--text-primary); color: var(--text);
} }
.attachment-file-chip strong { .attachment-file-chip strong {
@@ -1401,7 +1310,7 @@
.message-preview-raw summary { .message-preview-raw summary {
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text);
} }
@media (max-width: 720px), (max-height: 700px) { @media (max-width: 720px), (max-height: 700px) {
@@ -1446,25 +1355,6 @@
color: var(--warning); color: var(--warning);
} }
/* Managed attachment chooser refinements. */
.split-field-action {
gap: 0;
}
.split-field-action > input,
.split-field-action > select,
.split-field-action > textarea {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-field-action > button,
.split-field-action > .button,
.split-field-action > .btn {
align-self: stretch;
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: none;
}
/* Review & Send workflow page. */ /* Review & Send workflow page. */
.review-send-page { .review-send-page {
--review-flow-partial: var(--review-flow-purple, #9b86c7); --review-flow-partial: var(--review-flow-purple, #9b86c7);
@@ -2415,29 +2305,6 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.admin-secret {
display: block;
padding: 14px;
margin: 12px 0;
overflow-wrap: anywhere;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted);
font-size: 14px;
user-select: all;
}
.admin-json-preview {
max-height: 420px;
overflow: auto;
padding: 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted);
white-space: pre-wrap;
word-break: break-word;
}
.dropdown-error { .dropdown-error {
max-width: 300px; max-width: 300px;
margin: 6px 8px 8px; margin: 6px 8px 8px;
@@ -2654,54 +2521,20 @@
min-height: 78px; min-height: 78px;
padding: 16px; padding: 16px;
} }
.recipient-import-preview-surface { .recipient-import-preview-grid .data-grid-body-cell.is-invalid {
overflow: auto;
}
.recipient-import-preview-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
}
.recipient-import-preview-table th,
.recipient-import-preview-table td {
border-bottom: var(--border-line);
padding: 9px 10px;
text-align: left;
vertical-align: top;
}
.recipient-import-preview-table th {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.recipient-import-preview-table td {
color: var(--text);
font-size: 13px;
word-break: break-word;
}
.recipient-import-preview-table tr.is-invalid td {
color: var(--danger-text); color: var(--danger-text);
} }
.recipient-import-raw-table td {
.recipient-import-raw-grid .data-grid-body-cell {
white-space: pre; white-space: pre;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px; font-size: 12px;
} }
.recipient-import-raw-table tr.is-header-row td {
.recipient-import-raw-grid .data-grid-body-cell.is-header-row {
background: color-mix(in srgb, var(--accent) 8%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent);
font-weight: 700; font-weight: 700;
} }
.recipient-import-mapping-table select,
.recipient-import-mapping-table input {
width: 100%;
min-width: 160px;
}
.recipient-import-mapping-table td:nth-child(2) {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-import-file-step { .recipient-import-file-step {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -2767,77 +2600,6 @@
margin: 0; margin: 0;
width: 100%; width: 100%;
} }
.admin-audit-grid .data-grid-scroll-region {
max-height: min(68vh, 720px);
}
.admin-audit-grid .data-grid-header-cell {
top: 0;
}
.admin-details-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
}
.admin-details-grid > div {
min-width: 0;
padding: 10px 0;
border-bottom: var(--border-line);
}
.admin-details-grid dt {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.admin-details-grid dd {
margin: 4px 0 0;
overflow-wrap: anywhere;
}
.admin-managed-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 7px;
border-radius: 999px;
background: var(--info-muted-bg);
color: var(--info-text-muted);
font-size: 11px;
font-weight: 800;
}
.admin-governance-mode {
display: grid;
gap: 8px;
}
.admin-tenant-assignment-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px;
align-items: center;
gap: 10px;
}
.admin-settings-form {
display: grid;
gap: 18px;
}
.admin-settings-form .card {
margin: 0;
}
@media (max-width: 850px) {
.admin-details-grid,
.admin-tenant-assignment-row {
grid-template-columns: 1fr;
}
}
.admin-managed-notice {
margin: 0 0 16px;
padding: 12px 14px;
border: 1px solid var(--info-muted-border);
border-radius: var(--radius-sm);
background: var(--info-bg);
color: var(--info-text-deep);
line-height: 1.45;
}
.campaign-access-dialog > .dialog-header, .campaign-access-dialog > .dialog-header,
.campaign-access-dialog > .dialog-footer { .campaign-access-dialog > .dialog-footer {
background: var(--surface); background: var(--surface);