Add bulk calendar invitation delivery

This commit is contained in:
2026-08-02 16:38:48 +02:00
parent 7733265cc8
commit bf6e07f307
17 changed files with 1356 additions and 20 deletions
@@ -388,6 +388,16 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: QUEUE_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
{ id: "send", header: "Delivery", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, value: (row) => String(row.send_status ?? "unknown") },
{ id: "postbox", header: "Postbox", width: 155, sortable: true, filterable: true, columnType: "from-list", list: { options: POSTBOX_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(row.postbox_status ?? "unknown"))} />, value: (row) => String(row.postbox_status ?? "unknown") },
{
id: "rsvp",
header: "RSVP",
width: 145,
value: (row) => calendarRsvpStatus(row),
render: (row) => {
const status = calendarRsvpStatus(row);
return status === "—" ? <span className="muted"></span> : <StatusBadge status={status.toLowerCase()} label={humanize(status)} />;
}
},
{ id: "print", header: "Print", width: 135, sortable: true, filterable: true, columnType: "from-list", list: { options: PRINT_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.print_status ?? "unknown")} label={deliveryStatusLabel(String(row.print_status ?? "unknown"))} />, value: (row) => String(row.print_status ?? "unknown") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} label={deliveryStatusLabel(String(row.imap_status ?? "unknown"))} />, value: (row) => String(row.imap_status ?? "unknown") },
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0), render: (row) => String(Number(row.attempt_count ?? 0) + Number(row.postbox_attempt_count ?? 0)) },
@@ -603,6 +613,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
<div><dt>Postbox state</dt><dd><StatusBadge status={String(detail.job.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.postbox_status ?? "unknown"))} /></dd></div>
<div><dt>Postbox targets</dt><dd>{String(detail.job.postbox_target_count ?? 0)}</dd></div>
<div><dt>Calendar RSVP</dt><dd>{calendarRsvpStatus(detail.job)}</dd></div>
<div><dt>Print state</dt><dd><StatusBadge status={String(detail.job.print_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.print_status ?? "unknown"))} /></dd></div>
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
@@ -1037,6 +1048,11 @@ function deliveryStatusLabel(status: string): string | undefined {
return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7" : undefined;
}
function calendarRsvpStatus(row: Record<string, unknown>): string {
const invitation = asRecord(row.calendar_invitation);
return String(invitation.rsvp_status || "—");
}
function initialReportQuery(): string {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("q")?.trim() ?? "";
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, AuthInfo } from "../../types";
import {
getCampaignCalendarInvitationCatalog,
getCampaignPostboxCatalog,
type CampaignCalendarCatalog,
type CampaignPostboxCatalog
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
@@ -49,8 +51,14 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
templates: [],
organization_units: []
});
const [calendarCatalog, setCalendarCatalog] = useState<CampaignCalendarCatalog>({
available: false,
calendars: []
});
const [postboxTargetsOpen, setPostboxTargetsOpen] = useState(false);
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
const calendarModuleInstalled = usePlatformModuleInstalled("calendar");
const mailModuleInstalled = usePlatformModuleInstalled("mail");
const isPolicyView = view === "policy";
const version = data.currentVersion;
@@ -77,6 +85,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
const rateLimit = asRecord(delivery.rate_limit);
const retry = asRecord(delivery.retry);
const postboxDelivery = asRecord(delivery.postbox);
const calendarInvitation = asRecord(delivery.calendar_invitation);
const fieldDefinitions = useMemo(
() => getDraftFields(displayDraft),
[displayDraft]
@@ -123,6 +132,40 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
settings.apiKey
]);
useEffect(() => {
if (!calendarModuleInstalled) {
setCalendarCatalog({
available: false,
reason: "The Calendar module is not active.",
calendars: []
});
return;
}
let cancelled = false;
void getCampaignCalendarInvitationCatalog(settings, campaignId)
.then((catalog) => {
if (!cancelled) setCalendarCatalog(catalog);
})
.catch((cause) => {
if (!cancelled) {
setCalendarCatalog({
available: false,
reason: cause instanceof Error ? cause.message : String(cause),
calendars: []
});
}
});
return () => {
cancelled = true;
};
}, [
campaignId,
calendarModuleInstalled,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
function patchEditor(path: string[], value: unknown) {
if (locked) return;
setEditorState((current) => updateNested(current, path, value));
@@ -329,6 +372,107 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
}
</Card>
<Card title="Calendar invitations" collapsible>
<div className="form-grid compact responsive-form-grid">
<ToggleSwitch
label="Send individualized invitations"
checked={getBool(calendarInvitation, "enabled")}
disabled={locked || !calendarCatalog.available || !mailModuleInstalled}
onChange={(checked) => patch(["delivery", "calendar_invitation", "enabled"], checked)}
/>
<FormField label="Tracking calendar">
<select
value={getText(calendarInvitation, "calendar_id")}
disabled={locked || !calendarCatalog.available}
onChange={(event) => patch(["delivery", "calendar_invitation", "calendar_id"], event.target.value || null)}
>
<option value="">Select a calendar</option>
{calendarCatalog.calendars.map((calendar) =>
<option key={calendar.id} value={calendar.id} disabled={!calendar.writable}>
{calendar.name}{calendar.writable ? "" : " (read-only)"}
</option>
)}
</select>
</FormField>
<FormField label="Summary template">
<input
value={getText(calendarInvitation, "summary_template")}
disabled={locked}
placeholder="Appointment with {{name}}"
onChange={(event) => patch(["delivery", "calendar_invitation", "summary_template"], event.target.value)}
/>
</FormField>
<FormField label="Start (ISO 8601 or field template)">
<input
value={getText(calendarInvitation, "start_at_template")}
disabled={locked}
placeholder="2026-08-12T10:00 or {{appointment_start}}"
onChange={(event) => patch(["delivery", "calendar_invitation", "start_at_template"], event.target.value)}
/>
</FormField>
<FormField label="End (ISO 8601 or field template)">
<input
value={getText(calendarInvitation, "end_at_template")}
disabled={locked}
placeholder="2026-08-12T11:00 or {{appointment_end}}"
onChange={(event) => patch(["delivery", "calendar_invitation", "end_at_template"], event.target.value)}
/>
</FormField>
<FormField label="Timezone">
<input
value={getText(calendarInvitation, "timezone", "UTC")}
disabled={locked}
placeholder="Europe/Berlin"
onChange={(event) => patch(["delivery", "calendar_invitation", "timezone"], event.target.value)}
/>
</FormField>
<FormField label="Location template">
<input
value={getText(calendarInvitation, "location_template")}
disabled={locked}
onChange={(event) => patch(["delivery", "calendar_invitation", "location_template"], event.target.value)}
/>
</FormField>
<FormField label="Categories">
<input
value={(Array.isArray(calendarInvitation.categories) ? calendarInvitation.categories : []).join(", ")}
disabled={locked}
placeholder="Invitation, Campaign"
onChange={(event) => patch(
["delivery", "calendar_invitation", "categories"],
event.target.value.split(",").map((value) => value.trim()).filter(Boolean)
)}
/>
</FormField>
<FormField label="Description template">
<textarea
rows={3}
value={getText(calendarInvitation, "description_template")}
disabled={locked}
onChange={(event) => patch(["delivery", "calendar_invitation", "description_template"], event.target.value)}
/>
</FormField>
</div>
{!mailModuleInstalled &&
<DismissibleAlert tone="warning" dismissible={false}>
Mail must be active to deliver iCalendar invitations.
</DismissibleAlert>
}
{!calendarCatalog.available &&
<DismissibleAlert tone="warning" dismissible={false}>
{calendarCatalog.reason || "Calendar invitation tracking is unavailable."}
</DismissibleAlert>
}
{calendarCatalog.available && calendarCatalog.calendars.every((item) => !item.writable) &&
<DismissibleAlert tone="warning" dismissible={false}>
No writable calendar is available for invitation tracking.
</DismissibleAlert>
}
<p className="muted small-note">
One METHOD:REQUEST attachment is frozen per recipient at build time. The Calendar mirror is created after delivery acceptance; attendee replies are shown in Campaign reports.
</p>
</Card>
<Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
<div className="toggle-grid">
<ToggleSwitch label="i18n:govoplan-campaign.suggest_addresses_from_this_campaign.5ebe2aea" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />