diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index d8f1fd3..948db1f 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -114,7 +114,7 @@ manifest = ModuleManifest( route_factory=_campaigns_router, role_templates=ROLE_TEMPLATES, nav_items=( - NavItem(path="/campaigns", label="Campaigns", icon="mail", required_any=("campaigns:campaign:read",), order=20), + NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20), NavItem( path="/operator", label="Operator Queue", @@ -134,7 +134,7 @@ manifest = ModuleManifest( module_id="campaigns", package_name="@govoplan/campaign-webui", nav_items=( - NavItem(path="/campaigns", label="Campaigns", icon="mail", required_any=("campaigns:campaign:read",), order=20), + NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20), NavItem( path="/operator", label="Operator Queue", diff --git a/src/govoplan_campaign/backend/reports/campaigns.py b/src/govoplan_campaign/backend/reports/campaigns.py index 86299a6..af7c0bc 100644 --- a/src/govoplan_campaign/backend/reports/campaigns.py +++ b/src/govoplan_campaign/backend/reports/campaigns.py @@ -9,6 +9,8 @@ from typing import Any from sqlalchemy.orm import Session +from govoplan_core.settings import settings as core_settings + from govoplan_campaign.backend.db.models import ( Campaign, CampaignIssue, @@ -89,6 +91,7 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob] "imap_config_fingerprint": None, "estimated_remaining_send_seconds": None, "estimated_remaining_send_human": None, + "background_workers_enabled": bool(core_settings.celery_enabled), } if not version or not isinstance(version.execution_snapshot, dict): default["load_error"] = "No execution snapshot exists; rebuild the current locked version before delivery." @@ -131,6 +134,7 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob] "imap_config_fingerprint": snapshot.imap_config_fingerprint, "estimated_remaining_send_seconds": estimated_seconds, "estimated_remaining_send_human": _human_duration(estimated_seconds), + "background_workers_enabled": bool(core_settings.celery_enabled), } diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index 151e653..245464a 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -54,7 +54,7 @@ from govoplan_files.backend.storage.files import current_version_and_blob from govoplan_files.backend.storage.campaign_attachments import managed_match_payloads, prepared_campaign_snapshot from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments -from govoplan_campaign.backend.time import utc_now +from govoplan_core.security.time import utc_now from govoplan_campaign.backend.persistence.versions import ( LockedCampaignVersionError, create_minimal_campaign, diff --git a/src/govoplan_campaign/backend/sending/jobs.py b/src/govoplan_campaign/backend/sending/jobs.py index 29f5a88..2112054 100644 --- a/src/govoplan_campaign/backend/sending/jobs.py +++ b/src/govoplan_campaign/backend/sending/jobs.py @@ -12,6 +12,7 @@ from uuid import uuid4 from sqlalchemy.orm import Session +from govoplan_core.settings import settings as core_settings from govoplan_campaign.backend.db.models import ( Campaign, CampaignJob, @@ -197,6 +198,14 @@ def _get_current_version(session: Session, campaign: Campaign, version_id: str | return version +def _celery_enabled() -> bool: + return bool(core_settings.celery_enabled) + + +def _should_enqueue_celery(enqueue_celery: bool) -> bool: + return bool(enqueue_celery and _celery_enabled()) + + def _celery_enqueue_send_job(job_id: str) -> None: from govoplan_core.celery_app import celery @@ -292,7 +301,7 @@ def queue_campaign_jobs( session.commit() enqueued_count = 0 - if enqueue_celery and not dry_run: + if _should_enqueue_celery(enqueue_celery) and not dry_run: for job in queued: _celery_enqueue_send_job(job.id) enqueued_count += 1 @@ -402,6 +411,8 @@ def send_campaign_now( def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int: + if not _celery_enabled(): + return 0 campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) jobs = ( session.query(CampaignJob) @@ -459,7 +470,7 @@ def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str, session.commit() enqueued_count = 0 - if enqueue_celery: + if _should_enqueue_celery(enqueue_celery): for job in jobs: _celery_enqueue_send_job(job.id) enqueued_count += 1 @@ -577,7 +588,7 @@ def queue_failed_jobs_for_retry( session.commit() enqueued = 0 - if enqueue_celery and not dry_run: + if _should_enqueue_celery(enqueue_celery) and not dry_run: for job in selected: _celery_enqueue_send_job(job.id) enqueued += 1 @@ -645,7 +656,7 @@ def queue_unattempted_jobs( session.add(version) session.commit() enqueued = 0 - if enqueue_celery and not dry_run: + if _should_enqueue_celery(enqueue_celery) and not dry_run: for job in selected: _celery_enqueue_send_job(job.id) enqueued += 1 @@ -1092,7 +1103,7 @@ def send_campaign_job( session.add(job) _update_campaign_after_job(session, job.campaign_id, job.campaign_version_id) session.commit() - if enqueue_imap_task and job.imap_status == JobImapStatus.PENDING.value: + if enqueue_imap_task and _celery_enabled() and job.imap_status == JobImapStatus.PENDING.value: _celery_enqueue_append_sent_job(job.id) return SendJobResult( job_id=job.id, @@ -1265,13 +1276,14 @@ def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_i .order_by(CampaignJob.entry_index.asc()) .all() ) - if not dry_run and enqueue_celery: + should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run + if should_enqueue: for job in jobs: _celery_enqueue_append_sent_job(job.id) return { "campaign_id": campaign.id, "pending_count": len(jobs), - "enqueued_count": 0 if dry_run or not enqueue_celery else len(jobs), + "enqueued_count": len(jobs) if should_enqueue else 0, "dry_run": dry_run, } diff --git a/src/govoplan_campaign/backend/time.py b/src/govoplan_campaign/backend/time.py deleted file mode 100644 index 163654b..0000000 --- a/src/govoplan_campaign/backend/time.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone - - -def utc_now() -> datetime: - return datetime.now(timezone.utc) - - -def ensure_aware_utc(value: datetime | None) -> datetime | None: - """Return a timezone-aware UTC datetime. - - SQLite and some DB drivers may return naive datetimes even when SQLAlchemy - columns are declared as DateTime(timezone=True). Treat naive values as UTC - so comparisons with aware `datetime.now(timezone.utc)` do not crash. - """ - if value is None: - return None - if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc) diff --git a/webui/src/features/campaigns/CampaignListPage.tsx b/webui/src/features/campaigns/CampaignListPage.tsx index 00afc99..0c086d5 100644 --- a/webui/src/features/campaigns/CampaignListPage.tsx +++ b/webui/src/features/campaigns/CampaignListPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate } from "@govoplan/core-webui"; import { Link, useNavigate } from "react-router-dom"; import type { ApiSettings } from "../../types"; import Card from "../../components/Card"; @@ -167,25 +168,9 @@ function shortId(value: string): string { } function formatDateTime(value?: string): string { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "2-digit", - hour: "2-digit", - minute: "2-digit" - }); + return formatPlatformDateTime(value); } function formatLoadedAt(value: Date): string { - return value.toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }); + return formatDateTimeFromDate(value, { second: "2-digit" }); } diff --git a/webui/src/features/campaigns/MailSettingsPage.tsx b/webui/src/features/campaigns/MailSettingsPage.tsx index e8f2101..4956fbd 100644 --- a/webui/src/features/campaigns/MailSettingsPage.tsx +++ b/webui/src/features/campaigns/MailSettingsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { MailServerSettingsPanel, formatDateTime as formatPlatformDateTime, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; @@ -8,7 +9,6 @@ import LoadingFrame from "../../components/LoadingFrame"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay"; -import ToggleSwitch from "../../components/ToggleSwitch"; import DismissibleAlert from "../../components/DismissibleAlert"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import { MailProfilePolicyEditor } from "../mail/MailProfileManagement"; @@ -26,8 +26,6 @@ import { testMailProfileSmtp, testSmtpSettings, updateMockMailboxFailures, - type MailConnectionTestResponse, - type MailImapFolderListResponse, type MailProfilePolicy, type MailSecurity, type MailServerProfile, @@ -42,9 +40,9 @@ const securityOptions = ["plain", "tls", "starttls"]; export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); - const [smtpTestResult, setSmtpTestResult] = useState(null); - const [imapTestResult, setImapTestResult] = useState(null); - const [folderResult, setFolderResult] = useState(null); + const [smtpTestResult, setSmtpTestResult] = useState(null); + const [imapTestResult, setImapTestResult] = useState(null); + const [folderResult, setFolderResult] = useState(null); const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | "mock" | null>(null); const [mockMessages, setMockMessages] = useState([]); const [selectedMockMessage, setSelectedMockMessage] = useState(null); @@ -164,6 +162,25 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A } } + function patchSmtpSettings(patchValue: Partial) { + if (patchValue.host !== undefined) patch(["server", "smtp", "host"], String(patchValue.host ?? "")); + if (patchValue.port !== undefined) patch(["server", "smtp", "port"], Number(patchValue.port || 0)); + if (patchValue.username !== undefined) patch(["server", "smtp", "username"], String(patchValue.username ?? "")); + if (patchValue.password !== undefined) patch(["server", "smtp", "password"], String(patchValue.password ?? "")); + if (patchValue.security !== undefined) patch(["server", "smtp", "security"], String(patchValue.security || "starttls")); + if (patchValue.timeout_seconds !== undefined) patch(["server", "smtp", "timeout_seconds"], Number(patchValue.timeout_seconds || 0)); + } + + function patchImapSettings(patchValue: Partial) { + if (patchValue.host !== undefined) patch(["server", "imap", "host"], String(patchValue.host ?? "")); + if (patchValue.port !== undefined) patch(["server", "imap", "port"], Number(patchValue.port || 0)); + if (patchValue.username !== undefined) patch(["server", "imap", "username"], String(patchValue.username ?? "")); + if (patchValue.password !== undefined) patch(["server", "imap", "password"], String(patchValue.password ?? "")); + if (patchValue.security !== undefined) patch(["server", "imap", "security"], String(patchValue.security || "tls")); + if (patchValue.sent_folder !== undefined) patch(["server", "imap", "sent_folder"], String(patchValue.sent_folder ?? "")); + if (patchValue.timeout_seconds !== undefined) patch(["server", "imap", "timeout_seconds"], Number(patchValue.timeout_seconds || 0)); + } + function profileScopeLabel(profile: MailServerProfile): string { if (profile.scope_type === "system") return "system"; if (profile.scope_type === "tenant") return "tenant"; @@ -451,54 +468,57 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A -
-
-
-

SMTP login

- -
-
- patch(["server", "smtp", "host"], event.target.value)} /> - patch(["server", "smtp", "port"], Number(event.target.value || 0))} /> - patch(["server", "smtp", "username"], event.target.value)} /> - patch(["server", "smtp", "password"], event.target.value)} /> - - patch(["server", "smtp", "timeout_seconds"], Number(event.target.value || 0))} /> -
-
- -
- -
- -
-
-

IMAP sent-folder append

-
-
-
- -
- patch(["server", "imap", "host"], event.target.value)} /> - patch(["server", "imap", "port"], Number(event.target.value || 0))} /> - patch(["server", "imap", "username"], event.target.value)} /> - patch(["server", "imap", "password"], event.target.value)} /> - - patch(["server", "imap", "sent_folder"], event.target.value)} /> -
- patch(["delivery", "imap_append_sent", "enabled"], checked)} /> -
- patch(["delivery", "imap_append_sent", "folder"], event.target.value)} /> -
-
- - -
-

Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.

- - -
-
+ patch(["delivery", "imap_append_sent", "enabled"], checked), + onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder) + }} + smtpTestLabel={usingMailProfile ? "Test profile SMTP" : "Test SMTP login"} + imapTestLabel={usingMailProfile ? "Test profile IMAP" : "Test IMAP login"} + busyAction={mailActionState} + onTestSmtp={runSmtpTest} + onTestImap={runImapTest} + onLookupFolders={runFolderLookup} + smtpTestResult={smtpTestResult} + imapTestResult={imapTestResult} + folderLookupResult={folderResult} + onUseDetectedFolder={useDetectedSentFolder} + useDetectedFolderDisabled={imapDisabled} + floatingResults + />
@@ -538,7 +558,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A bodyMode="text" text={selectedMockMessage.body_preview || ""} recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"} - recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined} + recipientNote={selectedMockMessage.created_at ? formatPlatformDateTime(selectedMockMessage.created_at) : undefined} metaItems={mockMessageMetaItems(selectedMockMessage)} attachments={mockMessageAttachments(selectedMockMessage)} raw={selectedMockMessage.raw_eml} @@ -572,10 +592,7 @@ function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAtta } function formatMockDate(value: string): string { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); + return formatPlatformDateTime(value); } @@ -589,39 +606,3 @@ function mockMailboxColumns(openMockMessage: (id: string) => Promise): Dat { id: "actions", header: "Actions", width: 110, sticky: "end", render: (message) => } ]; } - -function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) { - if (!result) return null; - const authenticated = result.details?.authenticated; - return ( - - {result.message} - {result.ok && typeof authenticated === "boolean" && ( - Authentication: {authenticated ? "credentials accepted" : "not used"}. - )} - - ); -} - -function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) { - if (!result) return null; - if (!result.ok) { - return {result.message}; - } - - return ( - -

{result.message}

-

Detected Sent folder: {result.detected_sent_folder || "—"}

- {result.detected_sent_folder && } - {result.folders.length > 0 && ( -
- {result.folders.slice(0, 12).map((folder) => ( - {folder.name} - ))} - {result.folders.length > 12 && +{result.folders.length - 12} more} -
- )} -
- ); -} \ No newline at end of file diff --git a/webui/src/features/campaigns/ReviewSendPage.tsx b/webui/src/features/campaigns/ReviewSendPage.tsx index a2ed088..6cb1333 100644 --- a/webui/src/features/campaigns/ReviewSendPage.tsx +++ b/webui/src/features/campaigns/ReviewSendPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"; import { useNavigate } from "react-router-dom"; import { BarChart3, @@ -16,11 +16,13 @@ import { buildVersion, getCampaignJobs, getCampaignJobDetail, + getCampaignSummary, mockSendCampaign, sendCampaignNow, updateCampaignReviewState, validateVersion, type CampaignJobsResponse, + type CampaignSummary, type CampaignVersionDetail, } from "../../api/campaigns"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; @@ -40,6 +42,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asArray, asRecord, + formatDateTime, getCampaignJson, getDeliverySection, humanize, @@ -101,6 +104,8 @@ const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "ex export default function ReviewSendPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const navigate = useNavigate(); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); + const [liveSummary, setLiveSummary] = useState(null); + const [queueStatusLoading, setQueueStatusLoading] = useState(false); const version = data.currentVersion; const campaignJson = useMemo(() => getCampaignJson(version), [version]); const inlineEntries = useMemo( @@ -109,8 +114,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api ); const validation = asRecord(version?.validation_summary); const build = asRecord(version?.build_summary); - const cards = data.summary?.cards; - const attachmentSummary = asRecord(data.summary?.attachments); + const summary = liveSummary ?? data.summary; + const cards = summary?.cards; + const attachmentSummary = asRecord(summary?.attachments); const delivery = getDeliverySection(version); const rateLimit = asRecord(delivery.rate_limit); const imapAppend = asRecord(delivery.imap_append_sent); @@ -148,6 +154,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api setSelectedMockMessage(null); setSendResult(null); setSendConfirmOpen(false); + setLiveSummary(null); }, [version?.id]); useEffect(() => { @@ -176,24 +183,91 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api void loadBuiltMessages(true, reviewPage); }, [version?.id, hasBuild, reviewPage, showAllReviewJobs, jobsLoadedKey]); + const statusCounts = asRecord(summary?.status_counts); + const sendStatusCounts = asRecord(statusCounts.send); + const attempts = asRecord(summary?.attempts); + const summaryDelivery = asRecord(summary?.delivery); + const queuedSendCount = numberFrom(sendStatusCounts, ["queued"]); + const claimedSendCount = numberFrom(sendStatusCounts, ["claimed"]); + const sendingSendCount = numberFrom(sendStatusCounts, ["sending"]); + const activeSendCount = claimedSendCount + sendingSendCount; + const queuedOrActiveCount = cards?.queued_or_active ?? queuedSendCount + activeSendCount; + const notQueuedCount = cards?.not_attempted ?? numberFrom(sendStatusCounts, ["not_queued"]); + const cancelledCount = cards?.cancelled ?? numberFrom(sendStatusCounts, ["cancelled"]); + const outcomeUnknownCount = cards?.outcome_unknown ?? numberFrom(sendStatusCounts, ["outcome_unknown"]); + const sendAttemptCount = numberFrom(attempts, ["send_attempts"]); + const backgroundWorkersEnabled = summaryDelivery.background_workers_enabled === true || summaryDelivery.celery_enabled === true; + const backgroundWorkersDisabled = summaryDelivery.background_workers_enabled === false || summaryDelivery.celery_enabled === false; + const recentFailures = asArray(summary?.recent_failures).map(asRecord).slice(0, 5); const jobsTotal = cards?.jobs_total ?? inlineEntries.filter((entry) => entry.active !== false).length; const sentCount = cards?.sent ?? 0; const failedCount = cards?.failed ?? 0; const imapAppended = cards?.imap_appended ?? 0; const imapFailed = cards?.imap_failed ?? 0; + const deliveryHasTerminalOutcome = sentCount + failedCount + outcomeUnknownCount + cancelledCount > 0; const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase(); - const deliveryQueued = currentWorkflowState === "queued"; - const deliveryStarted = ["sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed"].includes(currentWorkflowState); + const deliverySending = activeSendCount > 0 || (currentWorkflowState === "sending" && queuedOrActiveCount > 0); + const deliveryQueued = queuedOrActiveCount > 0 || (currentWorkflowState === "queued" && !deliveryHasTerminalOutcome); + const deliveryStarted = deliverySending || deliveryHasTerminalOutcome || ["sent", "completed", "partially_completed", "outcome_unknown", "failed"].includes(currentWorkflowState); + const queuedWithoutWorker = backgroundWorkersDisabled && queuedSendCount > 0 && activeSendCount === 0; + const directQueuedSendAllowed = queuedWithoutWorker && !deliveryStarted; + const selectedDryRun = dryRun && !directQueuedSendAllowed; + const deliveryPartial = cards?.partially_completed === true || ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState); + const deliveryComplete = queuedOrActiveCount === 0 + && sentCount > 0 + && failedCount === 0 + && outcomeUnknownCount === 0 + && cancelledCount === 0 + && cards?.partially_completed !== true; + const deliveryDanger = failedCount > 0 || outcomeUnknownCount > 0 || ["outcome_unknown", "failed"].includes(currentWorkflowState); + const deliveryDisplayStatus = deliverySending + ? "sending" + : deliveryQueued + ? "queued" + : deliveryPartial + ? "partially_completed" + : deliveryComplete + ? "completed" + : deliveryDanger + ? failedCount > 0 ? "failed" : "outcome_unknown" + : data.campaign?.status ?? version?.workflow_state ?? "not_started"; + const queueStatusNote = queuedWithoutWorker + ? "Queued jobs are waiting in the database, but background delivery workers are disabled on this server. Use Send queued now for a small dev run, or start Redis/Celery with CELERY_ENABLED=true." + : deliverySending + ? "Delivery is being processed. This page refreshes queue counters without reloading the whole campaign workspace." + : deliveryQueued && backgroundWorkersEnabled + ? "Queued jobs are waiting for a background delivery worker. If the counts do not change, check the worker process and queue logs." + : deliveryQueued + ? "Queued jobs are waiting. If the counts do not change, check whether background delivery workers are enabled and running." + : ""; + const queueStatusTone = queuedWithoutWorker ? "is-warning" : deliveryQueued || deliverySending ? "is-stale" : ""; const historicalVersion = isHistoricalCampaignVersion(version, data.campaign?.current_version_id); const finalVersion = isFinalLockedVersion(version); const userLockedVersion = isUserLockedVersion(version); const readOnlyVersion = historicalVersion || userLockedVersion || finalVersion; + const refreshQueueStatus = useCallback(async (silent = true) => { + if (!version?.id) return; + setQueueStatusLoading(true); + if (!silent) setMessage("Refreshing queue status…"); + setError(""); + try { + const result = await getCampaignSummary(settings, campaignId, version.id); + setLiveSummary(result); + if (!silent) setMessage("Queue status refreshed."); + } catch (err) { + if (!silent) setMessage(""); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setQueueStatusLoading(false); + } + }, [campaignId, setError, settings, version?.id]); + useEffect(() => { - if (!(deliveryQueued || currentWorkflowState === "sending") || loading || busy === "send") return; - const handle = window.setTimeout(() => { void reload(); }, 3000); + if (!(deliveryQueued || deliverySending) || loading || queueStatusLoading || busy === "send") return; + const handle = window.setTimeout(() => { void refreshQueueStatus(true); }, 3000); return () => window.clearTimeout(handle); - }, [deliveryQueued, currentWorkflowState, loading, busy, reload]); + }, [deliveryQueued, deliverySending, loading, queueStatusLoading, busy, refreshQueueStatus]); const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null; const reviewMetadata = reviewJobs.review ?? {}; @@ -265,25 +339,31 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api const mockGateSatisfied = mockComplete || downstreamDeliveryActivity; const sendState: FlowState = !mockGateSatisfied ? "locked" - : busy === "send" || deliveryQueued || currentWorkflowState === "sending" + : busy === "send" || deliverySending ? "running" - : ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState) - ? "partial" - : ["sent", "completed"].includes(currentWorkflowState) - ? "complete" - : ["outcome_unknown", "failed"].includes(currentWorkflowState) - ? "danger" - : "active"; + : queuedWithoutWorker + ? "warning" + : deliveryQueued + ? "running" + : deliveryPartial + ? "partial" + : deliveryComplete || ["sent", "completed"].includes(currentWorkflowState) + ? "complete" + : deliveryDanger + ? "danger" + : "active"; const resultState: FlowState = !deliveryStarted ? "locked" - : currentWorkflowState === "sending" + : deliverySending ? "running" - : ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState) + : deliveryPartial ? "partial" - : ["sent", "completed"].includes(currentWorkflowState) + : deliveryComplete || ["sent", "completed"].includes(currentWorkflowState) ? "complete" - : "danger"; + : deliveryDanger + ? "danger" + : "active"; const stages: FlowStageDefinition[] = useMemo(() => [ { @@ -450,9 +530,14 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api } async function runSendNow() { - if (!version || busy || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted) return; + if (!version || busy || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || (deliveryQueued && !directQueuedSendAllowed) || deliveryStarted) return; + const effectiveDryRun = dryRun && !directQueuedSendAllowed; setBusy("send"); - setMessage(dryRun ? "Checking the built queue without sending…" : "Sending the locked campaign version…"); + setMessage(effectiveDryRun + ? "Checking the built queue without sending…" + : directQueuedSendAllowed + ? "Sending the queued jobs synchronously…" + : "Sending the locked campaign version…"); setSendResult(null); setError(""); try { @@ -462,7 +547,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api check_files: false, validate_before_send: false, build_before_send: false, - dry_run: dryRun, + dry_run: effectiveDryRun, use_rate_limit: true, enqueue_imap_task: false, }); @@ -472,7 +557,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api const failed = result.failed_count ?? 0; const unknown = result.outcome_unknown_count ?? 0; setMessage( - dryRun + effectiveDryRun ? "Dry run finished. No message was sent." : `Send finished. SMTP accepted ${String(sent)} message(s), failed ${String(failed)}, outcome unknown ${String(unknown)}.`, ); @@ -599,6 +684,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
+
@@ -771,6 +859,15 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
IMAP append{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}
Version{version ? `v${version.version_number}` : "—"}
+
+ + + + +
+ {queueStatusNote && ( +

{queueStatusNote}

+ )}
{sendResult && ( @@ -810,13 +913,32 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
+ +
- + {deliveryStarted ? "Delivery activity is available in the report and audit views." : "No real delivery has started for this campaign version."}
+ {recentFailures.length > 0 && ( +
+

Recent delivery failures

+
+ {recentFailures.map((failure, index) => ( +
+
{String(failure.recipient_email ?? failure.entry_id ?? `#${String(failure.entry_index ?? index + 1)}`)}
+
+ {humanize(String(failure.send_status ?? failure.imap_status ?? "failed"))} + {failure.last_error ? `: ${String(failure.last_error)}` : ""} + {failure.updated_at ? · {formatDateTime(String(failure.updated_at))} : null} +
+
+ ))} +
+
+ )}
@@ -850,7 +972,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api open={sendConfirmOpen} title="Send this version now?" message={`This sends the frozen execution snapshot for version ${String(version?.version_number ?? "—")}: ${jobsTotal} planned job(s), ${builtCount} built, ${buildBlocked} blocked, ${String(attachmentSummary.total_matched_files ?? 0)} resolved file(s), rate limit ${String(rateLimit.messages_per_minute ?? "not set")}/minute, IMAP append ${imapAppend.enabled === true ? "enabled" : "disabled"}, snapshot ${version?.execution_snapshot_hash ? `${version.execution_snapshot_hash.slice(0, 12)}…` : "missing"}. SMTP-accepted or uncertain jobs will never be resent automatically.`} - confirmLabel="Send now" + confirmLabel={directQueuedSendAllowed ? "Send queued now" : "Send now"} tone="danger" busy={busy === "send"} onCancel={() => setSendConfirmOpen(false)} @@ -864,7 +986,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api bodyMode="text" text={selectedMockMessage.body_preview || ""} recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"} - recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined} + recipientNote={selectedMockMessage.created_at ? formatDateTime(selectedMockMessage.created_at) : undefined} metaItems={mockMessageMetaItems(selectedMockMessage)} attachments={mockMessageAttachments(selectedMockMessage)} raw={selectedMockMessage.raw_eml} diff --git a/webui/src/features/campaigns/TemplateDataPage.tsx b/webui/src/features/campaigns/TemplateDataPage.tsx index 0ce245d..c257508 100644 --- a/webui/src/features/campaigns/TemplateDataPage.tsx +++ b/webui/src/features/campaigns/TemplateDataPage.tsx @@ -13,7 +13,7 @@ import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./componen import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; -import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; +import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils/campaignView"; import { cloneJson, getBool, getText } from "./utils/draftEditor"; import { humanizeFieldName } from "./utils/fieldDefinitions"; import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders"; @@ -47,7 +47,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A currentStep: "template", unsavedTitle: "Unsaved template changes", unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.", - loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${new Date(loadedVersion.autosaved_at).toLocaleString()}` : "Loaded", + loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "Loaded", onLoaded: () => setPreviewIndex(0) }); const template = asRecord(displayDraft.template); diff --git a/webui/src/features/campaigns/context/UnsavedChangesContext.tsx b/webui/src/features/campaigns/context/UnsavedChangesContext.tsx index e1e8cd9..ee09fd4 100644 --- a/webui/src/features/campaigns/context/UnsavedChangesContext.tsx +++ b/webui/src/features/campaigns/context/UnsavedChangesContext.tsx @@ -161,12 +161,14 @@ export function CampaignUnsavedChangesProvider({ children }: { children: ReactNo ); } +const fallbackUnsavedChangesContext: UnsavedChangesContextValue = { + hasUnsavedChanges: false, + registerUnsavedChanges: () => () => undefined, + requestNavigation: (action) => action() +}; + export function useCampaignUnsavedChanges() { - const context = useContext(UnsavedChangesContext); - if (!context) { - throw new Error("useCampaignUnsavedChanges must be used inside CampaignUnsavedChangesProvider"); - } - return context; + return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext; } export function useRegisterCampaignUnsavedChanges(registration: UnsavedChangesRegistration | null) { diff --git a/webui/src/features/campaigns/utils/campaignView.ts b/webui/src/features/campaigns/utils/campaignView.ts index a877a8a..0899355 100644 --- a/webui/src/features/campaigns/utils/campaignView.ts +++ b/webui/src/features/campaigns/utils/campaignView.ts @@ -1,3 +1,4 @@ +import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui"; import type { CampaignListItem } from "../../../types"; import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns"; @@ -153,16 +154,7 @@ export function currentStepLabel(version: CampaignVersionDetail | CampaignVersio } export function formatDateTime(value?: string | null): string { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "2-digit", - hour: "2-digit", - minute: "2-digit" - }); + return formatPlatformDateTime(value); } export function humanize(value?: string | null): string { diff --git a/webui/src/module.ts b/webui/src/module.ts index 8764655..618a77b 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -1,4 +1,3 @@ -import { Activity, FileText, Form, MailCheck, Users } from "lucide-react"; import { createElement, lazy, useCallback } from "react"; import { useParams } from "react-router-dom"; import { Card, ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui"; @@ -20,17 +19,17 @@ export const campaignModule: PlatformWebModule = { dependencies: ["access"], optionalDependencies: ["files", "mail"], navItems: [ - { to: "/campaigns", label: "Campaigns", icon: MailCheck, anyOf: campaignRead, order: 20 }, + { to: "/campaigns", label: "Campaigns", iconName: "campaign", anyOf: campaignRead, order: 20 }, { to: "/operator", label: "Operator Queue", - icon: Activity, + iconName: "activity", anyOf: operatorScopes, order: 30 }, - { to: "/reports", label: "Reports", icon: FileText, anyOf: ["campaigns:report:read"], order: 70 }, - { to: "/address-book", label: "Address Book", icon: Users, order: 80 }, - { to: "/templates", label: "Templates", icon: Form, order: 90 } + { to: "/reports", label: "Reports", iconName: "reports", anyOf: ["campaigns:report:read"], order: 70 }, + { to: "/address-book", label: "Address Book", iconName: "users", order: 80 }, + { to: "/templates", label: "Templates", iconName: "form", order: 90 } ], routes: [ { path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) }, diff --git a/webui/src/styles/campaign-workspace.css b/webui/src/styles/campaign-workspace.css index 5dbda4c..62b30eb 100644 --- a/webui/src/styles/campaign-workspace.css +++ b/webui/src/styles/campaign-workspace.css @@ -2595,7 +2595,7 @@ /* Administration scope separation and ownership safeguards ---------------- */ .admin-overview-section-label { - margin: 2px 0 -4px; + margin: 2px 0 14px; color: var(--muted); font-size: 12px; font-weight: 800;