From fc356e22c66085ac206b296f95c39d3b43168522 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 04:24:01 +0200 Subject: [PATCH] feat(scheduling-webui): model invitation actions --- webui/src/api/scheduling.ts | 40 +++++++ .../scheduling/schedulingViewModel.ts | 73 +++++++++++++ webui/tests/scheduling-view-model.test.ts | 102 ++++++++++++++++++ 3 files changed, 215 insertions(+) diff --git a/webui/src/api/scheduling.ts b/webui/src/api/scheduling.ts index 83a30d3..5a01b0b 100644 --- a/webui/src/api/scheduling.ts +++ b/webui/src/api/scheduling.ts @@ -84,6 +84,7 @@ export type SchedulingRequest = { anonymous_password_protection_enabled: boolean; public_participation_policy_enforcement_available: boolean | null; public_participation_policy_enforcement_reason?: string | null; + participant_invitation_delivery_available: boolean | null; calendar_integration_enabled: boolean | null; calendar_id?: string | null; calendar_freebusy_enabled: boolean | null; @@ -288,6 +289,18 @@ export type SchedulingNotification = { export type SchedulingNotificationListResponse = { notifications: SchedulingNotification[] }; +export type SchedulingInvitationAction = "copy" | "send" | "revoke"; + +export type SchedulingInvitationActionResponse = { + participant_id: string; + action: SchedulingInvitationAction; + status: string; + action_url?: string | null; + issued_at?: string | null; + replayed: boolean; + notification?: SchedulingNotification | null; +}; + export type SchedulingPollOptionResult = { option_id: string; option_key: string; @@ -446,3 +459,30 @@ export function listSchedulingNotifications(settings: ApiSettings, requestId?: s const query = requestId ? `?request_id=${encodeURIComponent(requestId)}` : ""; return apiFetch(settings, `/api/v1/scheduling/notifications${query}`); } + +export function issueSchedulingParticipantInvitation( + settings: ApiSettings, + requestId: string, + participantId: string, + participantRevision: string, + action: Exclude +): Promise { + return apiFetch( + settings, + `/api/v1/scheduling/requests/${encodeURIComponent(requestId)}/participants/${encodeURIComponent(participantId)}/invitation`, + json({ action, participant_revision: participantRevision }) + ); +} + +export function revokeSchedulingParticipantInvitation( + settings: ApiSettings, + requestId: string, + participantId: string, + participantRevision: string +): Promise { + return apiFetch( + settings, + `/api/v1/scheduling/requests/${encodeURIComponent(requestId)}/participants/${encodeURIComponent(participantId)}/invitation`, + { method: "DELETE", body: JSON.stringify({ participant_revision: participantRevision }) } + ); +} diff --git a/webui/src/features/scheduling/schedulingViewModel.ts b/webui/src/features/scheduling/schedulingViewModel.ts index e1f032c..0440d7a 100644 --- a/webui/src/features/scheduling/schedulingViewModel.ts +++ b/webui/src/features/scheduling/schedulingViewModel.ts @@ -42,6 +42,20 @@ export type SchedulingSortPhase = | "determined" | "past"; +export type SchedulingInvitationActionBlock = + | "participation_policy_unavailable" + | "cancellation_notice_expired" + | "delivery_unavailable" + | "no_delivery_target" + | "no_active_invitation" + | "participant_revision_unavailable"; + +export type SchedulingInvitationActionBlocks = { + copy: SchedulingInvitationActionBlock | null; + send: SchedulingInvitationActionBlock | null; + revoke: SchedulingInvitationActionBlock | null; +}; + type DirectorySelection = { selection_key?: string; kind?: PeoplePickerItem["kind"]; @@ -268,6 +282,65 @@ export function schedulingRequestIsPast( return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime()); } +export function schedulingInvitationActionBlocks( + request: SchedulingRequest, + participant: SchedulingParticipant, + now = new Date() +): SchedulingInvitationActionBlocks { + if (!participant.revision) { + return { + copy: "participant_revision_unavailable", + send: "participant_revision_unavailable", + revoke: "participant_revision_unavailable" + }; + } + const policyAvailable = Boolean( + request.poll_id && + request.public_participation_policy_enforcement_available === true + ); + let issueBlock: SchedulingInvitationActionBlock | null = policyAvailable + ? null + : "participation_policy_unavailable"; + if (!issueBlock && request.status === "cancelled") { + const noticeUntil = request.cancellation_notice_until + ? Date.parse(request.cancellation_notice_until) + : Number.NaN; + if (!Number.isFinite(noticeUntil) || noticeUntil <= now.getTime()) { + issueBlock = "cancellation_notice_expired"; + } + } + + const respondentId = participant.respondent_id?.trim() ?? ""; + const hasDeliveryTarget = Boolean( + participant.email?.trim() || + (respondentId && !respondentId.startsWith("scheduling-participant:")) + ); + const sendBlock = issueBlock + ?? (request.participant_invitation_delivery_available === true + ? null + : "delivery_unavailable") + ?? (hasDeliveryTarget ? null : "no_delivery_target"); + const revokeBlock = participant.poll_invitation_id + ? (policyAvailable ? null : "participation_policy_unavailable") + : "no_active_invitation"; + + return { copy: issueBlock, send: sendBlock, revoke: revokeBlock }; +} + +export function schedulingPublicInvitationUrl( + actionUrl: string, + applicationOrigin: string +): string | null { + try { + const origin = new URL(applicationOrigin); + const url = new URL(actionUrl, origin); + if (url.origin !== origin.origin || !url.pathname.startsWith("/scheduling/public/")) return null; + return url.toString(); + } catch { + return null; + } +} + export function applySchedulingAvailabilityChoice( slotIds: string[], current: Record, diff --git a/webui/tests/scheduling-view-model.test.ts b/webui/tests/scheduling-view-model.test.ts index 9e40d4a..f56fa64 100644 --- a/webui/tests/scheduling-view-model.test.ts +++ b/webui/tests/scheduling-view-model.test.ts @@ -7,6 +7,8 @@ import { participantDraftFromResponse, participantDraftsFromPicker, participantPayload, + schedulingInvitationActionBlocks, + schedulingPublicInvitationUrl, schedulingSortPhase, type SchedulingActor } from "../src/features/scheduling/schedulingViewModel.ts"; @@ -148,6 +150,7 @@ function request( anonymous_password_protection_enabled: false, public_participation_policy_enforcement_available: false, public_participation_policy_enforcement_reason: "Public participation gateway not installed", + participant_invitation_delivery_available: false, calendar_integration_enabled: false, calendar_freebusy_enabled: false, calendar_hold_enabled: false, @@ -249,6 +252,105 @@ test("orders unanswered by nearest slot before answered, closed, determined, and assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past"); }); +test("derives stable invitation action blocks from policy, delivery, and participant state", () => { + const managed = request("invitation-actions", { participantStatus: "invited" }); + managed.poll_id = "poll-1"; + managed.public_participation_policy_enforcement_available = true; + managed.public_participation_policy_enforcement_reason = null; + managed.participant_invitation_delivery_available = true; + const participant = managed.participants[0]; + assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), { + copy: "participant_revision_unavailable", + send: "participant_revision_unavailable", + revoke: "participant_revision_unavailable" + }); + participant.revision = "a".repeat(64); + participant.poll_invitation_id = "invitation-1"; + + assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), { + copy: null, + send: null, + revoke: null + }); + + managed.participant_invitation_delivery_available = false; + assert.equal( + schedulingInvitationActionBlocks(managed, participant, now).send, + "delivery_unavailable" + ); + + managed.participant_invitation_delivery_available = true; + participant.email = null; + participant.respondent_id = `scheduling-participant:${participant.id}`; + assert.equal( + schedulingInvitationActionBlocks(managed, participant, now).send, + "no_delivery_target" + ); + + managed.public_participation_policy_enforcement_available = false; + assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), { + copy: "participation_policy_unavailable", + send: "participation_policy_unavailable", + revoke: "participation_policy_unavailable" + }); + + participant.poll_invitation_id = null; + assert.equal( + schedulingInvitationActionBlocks(managed, participant, now).revoke, + "no_active_invitation" + ); +}); + +test("allows bounded cancelled-request links until the notice expires without blocking revocation", () => { + const cancelled = request("cancelled-invitation", { + participantStatus: "invited", + status: "cancelled" + }); + cancelled.poll_id = "poll-1"; + cancelled.public_participation_policy_enforcement_available = true; + cancelled.participant_invitation_delivery_available = true; + cancelled.cancellation_notice_until = "2026-07-20T11:00:00Z"; + cancelled.participants[0].revision = "b".repeat(64); + cancelled.participants[0].poll_invitation_id = "invitation-1"; + + assert.deepEqual(schedulingInvitationActionBlocks(cancelled, cancelled.participants[0], now), { + copy: null, + send: null, + revoke: null + }); + + cancelled.cancellation_notice_until = "2026-07-20T09:00:00Z"; + assert.deepEqual(schedulingInvitationActionBlocks(cancelled, cancelled.participants[0], now), { + copy: "cancellation_notice_expired", + send: "cancellation_notice_expired", + revoke: null + }); +}); + +test("accepts only same-origin Scheduling public invitation URLs", () => { + assert.equal( + schedulingPublicInvitationUrl( + "/scheduling/public/request-1/token-1", + "https://govoplan.example" + ), + "https://govoplan.example/scheduling/public/request-1/token-1" + ); + assert.equal( + schedulingPublicInvitationUrl( + "https://attacker.example/scheduling/public/request-1/token-1", + "https://govoplan.example" + ), + null + ); + assert.equal( + schedulingPublicInvitationUrl( + "/scheduling/publicity/request-1/token-1", + "https://govoplan.example" + ), + null + ); +}); + test("single-choice availability keeps one positive choice while preserving explicit no answers", () => { const next = applySchedulingAvailabilityChoice( ["slot-a", "slot-b", "slot-c"],