feat(scheduling-webui): model invitation actions
This commit is contained in:
@@ -84,6 +84,7 @@ export type SchedulingRequest = {
|
|||||||
anonymous_password_protection_enabled: boolean;
|
anonymous_password_protection_enabled: boolean;
|
||||||
public_participation_policy_enforcement_available: boolean | null;
|
public_participation_policy_enforcement_available: boolean | null;
|
||||||
public_participation_policy_enforcement_reason?: string | null;
|
public_participation_policy_enforcement_reason?: string | null;
|
||||||
|
participant_invitation_delivery_available: boolean | null;
|
||||||
calendar_integration_enabled: boolean | null;
|
calendar_integration_enabled: boolean | null;
|
||||||
calendar_id?: string | null;
|
calendar_id?: string | null;
|
||||||
calendar_freebusy_enabled: boolean | null;
|
calendar_freebusy_enabled: boolean | null;
|
||||||
@@ -288,6 +289,18 @@ export type SchedulingNotification = {
|
|||||||
|
|
||||||
export type SchedulingNotificationListResponse = { notifications: 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 = {
|
export type SchedulingPollOptionResult = {
|
||||||
option_id: string;
|
option_id: string;
|
||||||
option_key: string;
|
option_key: string;
|
||||||
@@ -446,3 +459,30 @@ export function listSchedulingNotifications(settings: ApiSettings, requestId?: s
|
|||||||
const query = requestId ? `?request_id=${encodeURIComponent(requestId)}` : "";
|
const query = requestId ? `?request_id=${encodeURIComponent(requestId)}` : "";
|
||||||
return apiFetch<SchedulingNotificationListResponse>(settings, `/api/v1/scheduling/notifications${query}`);
|
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 }) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export type SchedulingSortPhase =
|
|||||||
| "determined"
|
| "determined"
|
||||||
| "past";
|
| "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 = {
|
type DirectorySelection = {
|
||||||
selection_key?: string;
|
selection_key?: string;
|
||||||
kind?: PeoplePickerItem["kind"];
|
kind?: PeoplePickerItem["kind"];
|
||||||
@@ -268,6 +282,65 @@ export function schedulingRequestIsPast(
|
|||||||
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime());
|
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(
|
export function applySchedulingAvailabilityChoice(
|
||||||
slotIds: string[],
|
slotIds: string[],
|
||||||
current: Record<string, SchedulingAvailabilityValue | "">,
|
current: Record<string, SchedulingAvailabilityValue | "">,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
participantDraftFromResponse,
|
participantDraftFromResponse,
|
||||||
participantDraftsFromPicker,
|
participantDraftsFromPicker,
|
||||||
participantPayload,
|
participantPayload,
|
||||||
|
schedulingInvitationActionBlocks,
|
||||||
|
schedulingPublicInvitationUrl,
|
||||||
schedulingSortPhase,
|
schedulingSortPhase,
|
||||||
type SchedulingActor
|
type SchedulingActor
|
||||||
} from "../src/features/scheduling/schedulingViewModel.ts";
|
} from "../src/features/scheduling/schedulingViewModel.ts";
|
||||||
@@ -148,6 +150,7 @@ function request(
|
|||||||
anonymous_password_protection_enabled: false,
|
anonymous_password_protection_enabled: false,
|
||||||
public_participation_policy_enforcement_available: false,
|
public_participation_policy_enforcement_available: false,
|
||||||
public_participation_policy_enforcement_reason: "Public participation gateway not installed",
|
public_participation_policy_enforcement_reason: "Public participation gateway not installed",
|
||||||
|
participant_invitation_delivery_available: false,
|
||||||
calendar_integration_enabled: false,
|
calendar_integration_enabled: false,
|
||||||
calendar_freebusy_enabled: false,
|
calendar_freebusy_enabled: false,
|
||||||
calendar_hold_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");
|
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", () => {
|
test("single-choice availability keeps one positive choice while preserving explicit no answers", () => {
|
||||||
const next = applySchedulingAvailabilityChoice(
|
const next = applySchedulingAvailabilityChoice(
|
||||||
["slot-a", "slot-b", "slot-c"],
|
["slot-a", "slot-b", "slot-c"],
|
||||||
|
|||||||
Reference in New Issue
Block a user