417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
import type {
|
|
SchedulingAvailabilityValue,
|
|
SchedulingParticipant,
|
|
SchedulingParticipantPayload,
|
|
SchedulingRequest
|
|
} from "../../api/scheduling";
|
|
import type { PeoplePickerItem } from "@govoplan/core-webui";
|
|
|
|
const DIRECTORY_SELECTION_METADATA_KEY = "directory_selection";
|
|
|
|
export type SchedulingParticipantDraft = PeoplePickerItem & {
|
|
draftId: string;
|
|
sourceId?: string;
|
|
revision?: string;
|
|
respondent_id?: string | null;
|
|
display_name: string;
|
|
email: string;
|
|
participant_type: "internal" | "external" | "resource";
|
|
required: boolean;
|
|
metadata?: Record<string, unknown>;
|
|
identityLocked?: boolean;
|
|
};
|
|
|
|
export type SchedulingActor = {
|
|
accountId?: string | null;
|
|
userId?: string | null;
|
|
membershipId?: string | null;
|
|
identityId?: string | null;
|
|
email?: string | null;
|
|
};
|
|
|
|
export type SchedulingRequestGroups = {
|
|
owned: SchedulingRequest[];
|
|
invited: SchedulingRequest[];
|
|
other: SchedulingRequest[];
|
|
};
|
|
|
|
export type SchedulingLifecycleStageId = "prepare" | "participate" | "decide";
|
|
|
|
export type SchedulingLifecycleStageState =
|
|
| "complete"
|
|
| "current"
|
|
| "locked"
|
|
| "stopped";
|
|
|
|
export type SchedulingLifecycleStage = {
|
|
id: SchedulingLifecycleStageId;
|
|
state: SchedulingLifecycleStageState;
|
|
current: boolean;
|
|
locked: boolean;
|
|
};
|
|
|
|
export type SchedulingSortPhase =
|
|
| "unanswered"
|
|
| "answered"
|
|
| "closed"
|
|
| "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"];
|
|
reference_id?: string | null;
|
|
source_module?: string | null;
|
|
source_label?: string | null;
|
|
source_revision?: string | null;
|
|
};
|
|
|
|
function directorySelection(metadata?: Record<string, unknown>): DirectorySelection | null {
|
|
const value = metadata?.[DIRECTORY_SELECTION_METADATA_KEY];
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return value as DirectorySelection;
|
|
}
|
|
|
|
function normalizedParticipantType(value: string | null): SchedulingParticipantDraft["participant_type"] {
|
|
return value === "internal" || value === "resource" ? value : "external";
|
|
}
|
|
|
|
function selectionMetadata(item: PeoplePickerItem): Record<string, unknown> {
|
|
if (item.kind === "external") return {};
|
|
return {
|
|
[DIRECTORY_SELECTION_METADATA_KEY]: {
|
|
selection_key: item.selection_key,
|
|
kind: item.kind,
|
|
reference_id: item.reference_id ?? null,
|
|
source_module: item.source_module ?? null,
|
|
source_label: item.source_label ?? null,
|
|
source_revision: item.source_revision ?? null
|
|
}
|
|
};
|
|
}
|
|
|
|
export function participantDraftFromResponse(
|
|
participant: SchedulingParticipant,
|
|
draftId: string
|
|
): SchedulingParticipantDraft {
|
|
const selection = directorySelection(participant.metadata);
|
|
const kind = selection?.kind ?? (participant.respondent_id ? "account" : "external");
|
|
const referenceId = selection?.reference_id
|
|
?? (kind === "account" ? participant.respondent_id : null);
|
|
const email = participant.email?.trim().toLowerCase() || null;
|
|
return {
|
|
selection_key: selection?.selection_key
|
|
?? (email ? `${kind}:${email}` : `${kind}:participant:${participant.id}`),
|
|
kind,
|
|
reference_id: referenceId,
|
|
display_name: participant.display_name?.trim() || email || "—",
|
|
email: email ?? "",
|
|
source_module: selection?.source_module ?? null,
|
|
source_label: selection?.source_label ?? null,
|
|
source_revision: selection?.source_revision ?? null,
|
|
draftId,
|
|
sourceId: participant.id,
|
|
revision: participant.revision ?? undefined,
|
|
respondent_id: participant.respondent_id,
|
|
participant_type: normalizedParticipantType(participant.participant_type),
|
|
required: participant.required ?? true,
|
|
metadata: participant.metadata ?? {},
|
|
identityLocked: Boolean(participant.poll_invitation_id)
|
|
};
|
|
}
|
|
|
|
export function participantDraftsFromPicker(
|
|
selected: PeoplePickerItem[],
|
|
current: SchedulingParticipantDraft[],
|
|
nextDraftId: () => string
|
|
): SchedulingParticipantDraft[] {
|
|
const currentByKey = new Map(current.map((participant) => [participant.selection_key, participant]));
|
|
return selected.map((item) => {
|
|
const existing = currentByKey.get(item.selection_key);
|
|
if (existing) return existing;
|
|
const kind = item.kind === "account" ? "account" : item.kind === "contact" ? "contact" : "external";
|
|
return {
|
|
...item,
|
|
kind,
|
|
email: item.email?.trim().toLowerCase() || "",
|
|
draftId: nextDraftId(),
|
|
respondent_id: kind === "account" ? item.reference_id ?? null : null,
|
|
participant_type: kind === "account" ? "internal" : "external",
|
|
required: true,
|
|
metadata: selectionMetadata(item),
|
|
identityLocked: false
|
|
};
|
|
});
|
|
}
|
|
|
|
export function participantPayload(
|
|
participant: SchedulingParticipantDraft
|
|
): SchedulingParticipantPayload & { id?: string; revision?: string } {
|
|
return {
|
|
...(participant.sourceId
|
|
? { id: participant.sourceId, revision: participant.revision }
|
|
: {}),
|
|
respondent_id: participant.respondent_id ?? null,
|
|
display_name: participant.display_name.trim() || null,
|
|
email: participant.email.trim() || null,
|
|
participant_type: participant.participant_type,
|
|
required: participant.required,
|
|
metadata: participant.metadata ?? {}
|
|
};
|
|
}
|
|
|
|
export function schedulingActorIds(actor: SchedulingActor): string[] {
|
|
return Array.from(new Set([
|
|
actor.accountId,
|
|
actor.userId,
|
|
actor.membershipId,
|
|
actor.identityId,
|
|
actor.email
|
|
].filter((value): value is string => Boolean(value)).flatMap((value) =>
|
|
value.includes("@") ? [value, value.trim().toLowerCase()] : [value]
|
|
)));
|
|
}
|
|
|
|
export function schedulingParticipantForActor(
|
|
request: SchedulingRequest,
|
|
actor: SchedulingActor
|
|
): SchedulingParticipant | null {
|
|
const projected = request.participants.find(
|
|
(participant) => participant.is_current_participant
|
|
);
|
|
if (projected) return projected;
|
|
const ids = new Set(schedulingActorIds(actor));
|
|
return request.participants.find((participant) => {
|
|
const email = participant.email?.trim().toLowerCase();
|
|
return Boolean(
|
|
(participant.respondent_id && ids.has(participant.respondent_id)) ||
|
|
(email && ids.has(email))
|
|
);
|
|
}) ?? null;
|
|
}
|
|
|
|
export function schedulingRequestIsOwned(
|
|
request: SchedulingRequest,
|
|
actor: SchedulingActor
|
|
): boolean {
|
|
return Boolean(
|
|
request.organizer_user_id &&
|
|
schedulingActorIds(actor).includes(request.organizer_user_id)
|
|
);
|
|
}
|
|
|
|
export function schedulingSortPhase(
|
|
request: SchedulingRequest,
|
|
actor: SchedulingActor,
|
|
now = new Date()
|
|
): SchedulingSortPhase {
|
|
if (schedulingRequestIsPast(request, now)) return "past";
|
|
if (["decided", "handed_off"].includes(request.status)) return "determined";
|
|
if (["closed", "cancelled", "archived"].includes(request.status)) return "closed";
|
|
if (schedulingRequestIsOwned(request, actor)) return "unanswered";
|
|
const participant = schedulingParticipantForActor(request, actor);
|
|
return participant && ["responded", "declined"].includes(participant.status)
|
|
? "answered"
|
|
: "unanswered";
|
|
}
|
|
|
|
export function compareSchedulingRequests(
|
|
left: SchedulingRequest,
|
|
right: SchedulingRequest,
|
|
actor: SchedulingActor,
|
|
now = new Date()
|
|
): number {
|
|
const leftPhase = schedulingSortPhase(left, actor, now);
|
|
const rightPhase = schedulingSortPhase(right, actor, now);
|
|
const phaseDifference = SORT_PHASE_ORDER[leftPhase] - SORT_PHASE_ORDER[rightPhase];
|
|
if (phaseDifference !== 0) return phaseDifference;
|
|
const leftDate = schedulingRelevantTimestamp(left, now);
|
|
const rightDate = schedulingRelevantTimestamp(right, now);
|
|
const dateDifference = leftPhase === "past"
|
|
? rightDate - leftDate
|
|
: leftDate - rightDate;
|
|
if (dateDifference !== 0) return dateDifference;
|
|
const titleDifference = left.title.localeCompare(right.title);
|
|
return titleDifference || left.id.localeCompare(right.id);
|
|
}
|
|
|
|
export function groupSchedulingRequests(
|
|
requests: SchedulingRequest[],
|
|
actor: SchedulingActor,
|
|
now = new Date()
|
|
): SchedulingRequestGroups {
|
|
const groups: SchedulingRequestGroups = { owned: [], invited: [], other: [] };
|
|
for (const request of requests) {
|
|
if (schedulingRequestIsOwned(request, actor)) {
|
|
groups.owned.push(request);
|
|
} else if (schedulingParticipantForActor(request, actor)) {
|
|
groups.invited.push(request);
|
|
} else {
|
|
groups.other.push(request);
|
|
}
|
|
}
|
|
for (const group of Object.values(groups)) {
|
|
group.sort((left, right) => compareSchedulingRequests(left, right, actor, now));
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
export function schedulingRelevantTimestamp(
|
|
request: SchedulingRequest,
|
|
now = new Date()
|
|
): number {
|
|
const nowValue = now.getTime();
|
|
const futureStarts = request.slots
|
|
.map((slot) => Date.parse(slot.start_at))
|
|
.filter((value) => Number.isFinite(value) && value >= nowValue)
|
|
.sort((left, right) => left - right);
|
|
if (futureStarts[0] !== undefined) return futureStarts[0];
|
|
const slotEnds = request.slots
|
|
.map((slot) => Date.parse(slot.end_at))
|
|
.filter(Number.isFinite);
|
|
if (slotEnds.length) return Math.max(...slotEnds);
|
|
const fallback = Date.parse(request.deadline_at || request.updated_at || request.created_at);
|
|
return Number.isFinite(fallback) ? fallback : Number.MAX_SAFE_INTEGER;
|
|
}
|
|
|
|
export function schedulingRequestIsPast(
|
|
request: SchedulingRequest,
|
|
now = new Date()
|
|
): boolean {
|
|
if (!request.slots.length) return false;
|
|
const slotEnds = request.slots.map((slot) => Date.parse(slot.end_at));
|
|
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime());
|
|
}
|
|
|
|
export function schedulingLifecycleStages(
|
|
status: SchedulingRequest["status"]
|
|
): SchedulingLifecycleStage[] {
|
|
const currentIndex = status === "draft"
|
|
? 0
|
|
: status === "collecting" || status === "cancelled"
|
|
? 1
|
|
: 2;
|
|
const completedThrough = status === "draft"
|
|
? -1
|
|
: status === "collecting" || status === "cancelled"
|
|
? 0
|
|
: status === "closed"
|
|
? 1
|
|
: 2;
|
|
const stopped = status === "cancelled";
|
|
|
|
return (["prepare", "participate", "decide"] as const).map((id, index) => {
|
|
const current = index === currentIndex;
|
|
const locked = index > completedThrough + 1;
|
|
const state: SchedulingLifecycleStageState = stopped && current
|
|
? "stopped"
|
|
: index <= completedThrough
|
|
? "complete"
|
|
: current
|
|
? "current"
|
|
: "locked";
|
|
return { id, state, current, locked };
|
|
});
|
|
}
|
|
|
|
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 | "">,
|
|
slotId: string,
|
|
value: SchedulingAvailabilityValue | "",
|
|
singleChoice: boolean
|
|
): Record<string, SchedulingAvailabilityValue | ""> {
|
|
if (!singleChoice || !["available", "maybe"].includes(value)) {
|
|
return { ...current, [slotId]: value };
|
|
}
|
|
return Object.fromEntries(slotIds.map((candidateId) => [
|
|
candidateId,
|
|
candidateId === slotId
|
|
? value
|
|
: current[candidateId] && current[candidateId] !== "unavailable"
|
|
? "unavailable"
|
|
: current[candidateId] ?? ""
|
|
]));
|
|
}
|
|
|
|
const SORT_PHASE_ORDER: Record<SchedulingSortPhase, number> = {
|
|
unanswered: 0,
|
|
answered: 1,
|
|
closed: 2,
|
|
determined: 3,
|
|
past: 4
|
|
};
|