feat(scheduling-webui): model invitation actions

This commit is contained in:
2026-07-22 04:24:01 +02:00
parent f512784dd3
commit fc356e22c6
3 changed files with 215 additions and 0 deletions

View File

@@ -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<SchedulingNotificationListResponse>(settings, `/api/v1/scheduling/notifications${query}`);
}
export function issueSchedulingParticipantInvitation(
settings: ApiSettings,
requestId: string,
participantId: string,
participantRevision: string,
action: Exclude<SchedulingInvitationAction, "revoke">
): Promise<SchedulingInvitationActionResponse> {
return apiFetch<SchedulingInvitationActionResponse>(
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<SchedulingInvitationActionResponse> {
return apiFetch<SchedulingInvitationActionResponse>(
settings,
`/api/v1/scheduling/requests/${encodeURIComponent(requestId)}/participants/${encodeURIComponent(participantId)}/invitation`,
{ method: "DELETE", body: JSON.stringify({ participant_revision: participantRevision }) }
);
}

View File

@@ -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<string, SchedulingAvailabilityValue | "">,

View File

@@ -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"],