refactor: group domain services into folders
This commit is contained in:
306
src/availability/index.ts
Normal file
306
src/availability/index.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
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];
|
||||
}
|
||||
Reference in New Issue
Block a user