import { randomUUID } from 'node:crypto'; import type { Fact, IdentityDB, JsonValue } from 'identitydb'; import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace } from '../core/facts'; import { persistFactDrafts } from '../memory'; import type { BoxBrainAvailabilityEntry, BoxBrainAvailabilityMode, BoxBrainAvailabilitySnapshot, BoxBrainAvailabilitySourceType, } from '../core/types'; export interface SetAvailabilityStatusInput { spaceName: string; mode: BoxBrainAvailabilityMode; reason?: string | undefined; effectiveFrom: string; until?: string | undefined; sourceType?: Exclude | undefined; eventId?: string | undefined; metadata?: JsonValue | null | undefined; } export interface GetAvailabilitySnapshotInput { spaceName: string; at: string; } export interface ListAvailabilityEntriesInput { spaceName: string; } const EXPLICIT_SOURCE_PRIORITY: Record, number> = { schedule: 1, manual: 2, tool: 3, }; export class AvailabilityService { constructor(private readonly db: IdentityDB) {} async setStatus(input: SetAvailabilityStatusInput): Promise { return setAvailabilityStatusWithDb(this.db, input); } async listEntries(input: ListAvailabilityEntriesInput): Promise { return listAvailabilityEntriesWithDb(this.db, input); } async getSnapshot(input: GetAvailabilitySnapshotInput): Promise { return getAvailabilitySnapshotWithDb(this.db, input); } } export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabilityStatusInput): Promise { return new AvailabilityService(db).setStatus(input); } async function setAvailabilityStatusWithDb(db: IdentityDB, input: SetAvailabilityStatusInput): Promise { assertAvailabilityMode(input.mode); assertChronology(input.effectiveFrom, input.until); const statusId = randomUUID(); const sourceType = input.sourceType ?? 'manual'; const [fact] = await persistFactDrafts(db, { spaceName: input.spaceName, domain: 'persona.availability', source: `boxbrain:availability.${sourceType}`, facts: [ { statement: buildAvailabilityStatement(input.mode, input.reason, input.effectiveFrom, input.until), metadata: jsonObject({ availabilityStatus: jsonObject({ id: statusId, mode: input.mode, reason: input.reason, effectiveFrom: input.effectiveFrom, until: input.until, sourceType, eventId: input.eventId, customMetadata: input.metadata ?? null, }), }), topics: [ { name: dateOnly(input.effectiveFrom), category: 'temporal' }, { name: input.mode, category: 'concept' }, { name: 'availability', category: 'concept' }, ], }, ], }); const parsed = fact ? parseAvailabilityFact(fact) : null; if (!parsed) { throw new Error('Failed to persist BoxBrain availability status.'); } return parsed; } export async function listAvailabilityEntries( db: IdentityDB, input: ListAvailabilityEntriesInput, ): Promise { return new AvailabilityService(db).listEntries(input); } async function listAvailabilityEntriesWithDb( db: IdentityDB, input: ListAvailabilityEntriesInput, ): Promise { const facts = await listFactsInSpace(db, input.spaceName); const deletedScheduleEventIds = collectDeletedScheduleEventIds(facts); return facts .map(parseAvailabilityFact) .filter((entry): entry is BoxBrainAvailabilityEntry => entry !== null) .filter((entry) => entry.sourceType !== 'schedule' || !entry.eventId || !deletedScheduleEventIds.has(entry.eventId)) .sort(compareAvailabilityEntries); } export async function getAvailabilitySnapshot( db: IdentityDB, input: GetAvailabilitySnapshotInput, ): Promise { return new AvailabilityService(db).getSnapshot(input); } async function getAvailabilitySnapshotWithDb( db: IdentityDB, input: GetAvailabilitySnapshotInput, ): Promise { const entries = await listAvailabilityEntries(db, { spaceName: input.spaceName }); const current = selectAvailabilityAt(entries, input.at) ?? createDefaultOnlineAvailability(input.at); const next = selectNextAvailabilityTransition(entries, input.at, current); return { current, next }; } function selectAvailabilityAt(entries: BoxBrainAvailabilityEntry[], at: string): BoxBrainAvailabilityEntry | null { const atMs = Date.parse(at); const applicable = entries.filter((entry) => { const startMs = Date.parse(entry.effectiveFrom); const endMs = entry.until ? Date.parse(entry.until) : Number.POSITIVE_INFINITY; return Number.isFinite(startMs) && atMs >= startMs && atMs < endMs; }); if (applicable.length === 0) { return null; } applicable.sort((left, right) => { const priority = priorityOf(right.sourceType) - priorityOf(left.sourceType); if (priority !== 0) { return priority; } if (right.effectiveFrom !== left.effectiveFrom) { return right.effectiveFrom.localeCompare(left.effectiveFrom); } return (right.createdAt ?? '').localeCompare(left.createdAt ?? ''); }); return applicable[0] ?? null; } function selectNextAvailabilityTransition( entries: BoxBrainAvailabilityEntry[], at: string, current: BoxBrainAvailabilityEntry, ): BoxBrainAvailabilityEntry | null { const atMs = Date.parse(at); const transitionTimes = Array.from(new Set( entries.flatMap((entry) => [Date.parse(entry.effectiveFrom), entry.until ? Date.parse(entry.until) : Number.NaN]), )) .filter((time): time is number => Number.isFinite(time) && time > atMs) .sort((left, right) => left - right); for (const transitionTime of transitionTimes) { const transitionAt = new Date(transitionTime).toISOString(); const probeAt = new Date(transitionTime + 1).toISOString(); const candidate = selectAvailabilityAt(entries, probeAt) ?? createDefaultOnlineAvailability(transitionAt); if (!sameAvailabilityState(candidate, current)) { return { ...candidate, effectiveFrom: transitionAt, }; } } return null; } function createDefaultOnlineAvailability(at: string): BoxBrainAvailabilityEntry { return { id: 'default-online', mode: 'online', effectiveFrom: at, sourceType: 'default', }; } function parseAvailabilityFact(fact: Fact): BoxBrainAvailabilityEntry | null { if (getFactDomain(fact) !== 'persona.availability') { return null; } const metadata = getJsonObject(fact.metadata); const payload = getJsonObject(metadata?.availabilityStatus); if (!payload) { return null; } const id = typeof payload.id === 'string' ? payload.id : fact.id; const mode = payload.mode; const effectiveFrom = payload.effectiveFrom; const sourceType = payload.sourceType; if (typeof mode !== 'string' || typeof effectiveFrom !== 'string' || typeof sourceType !== 'string') { return null; } assertAvailabilityMode(mode); if (!isExplicitSourceType(sourceType)) { return null; } return { id, mode, reason: typeof payload.reason === 'string' ? payload.reason : undefined, effectiveFrom, until: typeof payload.until === 'string' ? payload.until : undefined, sourceType, eventId: typeof payload.eventId === 'string' ? payload.eventId : undefined, createdAt: fact.createdAt, metadata: payload.customMetadata ?? null, }; } function compareAvailabilityEntries(left: BoxBrainAvailabilityEntry, right: BoxBrainAvailabilityEntry): number { if (left.effectiveFrom !== right.effectiveFrom) { return left.effectiveFrom.localeCompare(right.effectiveFrom); } return (left.createdAt ?? '').localeCompare(right.createdAt ?? ''); } function buildAvailabilityStatement( mode: BoxBrainAvailabilityMode, reason: string | undefined, effectiveFrom: string, until: string | undefined, ): string { const reasonPart = reason ? ` because ${reason}` : ''; const untilPart = until ? ` until ${until}` : ''; return `Availability is ${mode} from ${effectiveFrom}${untilPart}${reasonPart}.`; } function collectDeletedScheduleEventIds(facts: Fact[]): Set { return new Set( facts .filter((fact) => getFactDomain(fact) === 'persona.schedule.deleted') .map((fact) => getJsonObject(getJsonObject(fact.metadata)?.scheduleDeletion)?.eventId) .filter((eventId): eventId is string => typeof eventId === 'string'), ); } function sameAvailabilityState(left: BoxBrainAvailabilityEntry, right: BoxBrainAvailabilityEntry): boolean { return left.mode === right.mode && left.reason === right.reason && left.until === right.until && left.sourceType === right.sourceType && left.eventId === right.eventId; } function assertValidTimestamp(value: string, fieldName: string): void { if (!Number.isFinite(Date.parse(value))) { throw new Error(`Availability ${fieldName} must be a valid ISO timestamp.`); } } function assertChronology(startAt: string, endAt: string | undefined): void { assertValidTimestamp(startAt, 'effectiveFrom'); if (!endAt) { return; } assertValidTimestamp(endAt, 'until'); if (Date.parse(endAt) <= Date.parse(startAt)) { throw new Error('Availability until must be later than effectiveFrom.'); } } function assertAvailabilityMode(mode: string): asserts mode is BoxBrainAvailabilityMode { if (mode !== 'online' && mode !== 'do_not_disturb' && mode !== 'offline') { throw new Error(`Unsupported availability mode: ${mode}`); } } function isExplicitSourceType(sourceType: string): sourceType is Exclude { return sourceType === 'schedule' || sourceType === 'manual' || sourceType === 'tool'; } function priorityOf(sourceType: BoxBrainAvailabilitySourceType): number { if (sourceType === 'default') { return 0; } return EXPLICIT_SOURCE_PRIORITY[sourceType]; }