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
@@ -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)} />