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; 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): 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 { 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, slotId: string, value: SchedulingAvailabilityValue | "", singleChoice: boolean ): Record { 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 = { unanswered: 0, answered: 1, closed: 2, determined: 3, past: 4 };