307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
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<BoxBrainAvailabilitySourceType, 'default'> | 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<Exclude<BoxBrainAvailabilitySourceType, 'default'>, number> = {
|
|
schedule: 1,
|
|
manual: 2,
|
|
tool: 3,
|
|
};
|
|
|
|
export class AvailabilityService {
|
|
constructor(private readonly db: IdentityDB) {}
|
|
|
|
async setStatus(input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
|
|
return setAvailabilityStatusWithDb(this.db, input);
|
|
}
|
|
|
|
async listEntries(input: ListAvailabilityEntriesInput): Promise<BoxBrainAvailabilityEntry[]> {
|
|
return listAvailabilityEntriesWithDb(this.db, input);
|
|
}
|
|
|
|
async getSnapshot(input: GetAvailabilitySnapshotInput): Promise<BoxBrainAvailabilitySnapshot> {
|
|
return getAvailabilitySnapshotWithDb(this.db, input);
|
|
}
|
|
}
|
|
|
|
export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
|
|
return new AvailabilityService(db).setStatus(input);
|
|
}
|
|
|
|
async function setAvailabilityStatusWithDb(db: IdentityDB, input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
|
|
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<BoxBrainAvailabilityEntry[]> {
|
|
return new AvailabilityService(db).listEntries(input);
|
|
}
|
|
|
|
async function listAvailabilityEntriesWithDb(
|
|
db: IdentityDB,
|
|
input: ListAvailabilityEntriesInput,
|
|
): Promise<BoxBrainAvailabilityEntry[]> {
|
|
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<BoxBrainAvailabilitySnapshot> {
|
|
return new AvailabilityService(db).getSnapshot(input);
|
|
}
|
|
|
|
async function getAvailabilitySnapshotWithDb(
|
|
db: IdentityDB,
|
|
input: GetAvailabilitySnapshotInput,
|
|
): Promise<BoxBrainAvailabilitySnapshot> {
|
|
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<string> {
|
|
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<BoxBrainAvailabilitySourceType, 'default'> {
|
|
return sourceType === 'schedule' || sourceType === 'manual' || sourceType === 'tool';
|
|
}
|
|
|
|
function priorityOf(sourceType: BoxBrainAvailabilitySourceType): number {
|
|
if (sourceType === 'default') {
|
|
return 0;
|
|
}
|
|
|
|
return EXPLICIT_SOURCE_PRIORITY[sourceType];
|
|
}
|