rspace-online/modules/rschedule/schemas.ts

261 lines
6.3 KiB
TypeScript

/**
* rSchedule Automerge document schemas — Calendly-style booking.
*
* Three docs per space (or per user-space):
* {space}:schedule:config — settings + availability rules + overrides + gcal state
* {space}:schedule:bookings — bookings this space HOSTS (it is the bookee)
* {space}:schedule:invitations — bookings this space is INVITED to (as attendee)
*
* On booking create, server writes to host's `bookings` doc AND each invitee's
* `invitations` doc so every party sees the booking in their own schedule view
* without cross-space reads.
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Entity reference (space slug or user DID) ──
export type EntityKind = 'space' | 'user';
export interface EntityRef {
kind: EntityKind;
/** For `space`: the space slug. For `user`: the did:key:... */
id: string;
/** Display label — denormalized for UI, refreshed on render. */
label?: string;
}
// ── Config doc ──
export interface ScheduleSettings {
displayName: string;
email: string;
bookingMessage: string;
/** Minutes per slot (15, 30, 45, 60, 90, 120). */
slotDurationMin: number;
bufferBeforeMin: number;
bufferAfterMin: number;
minNoticeHours: number;
maxAdvanceDays: number;
timezone: string;
autoShiftTimezone: boolean;
}
export interface AvailabilityRule {
id: string;
/** 0 = Sunday, 6 = Saturday (JS Date convention) */
dayOfWeek: number;
/** "HH:MM" 24h format, in `settings.timezone` */
startTime: string;
endTime: string;
isActive: boolean;
createdAt: number;
}
export interface AvailabilityOverride {
id: string;
/** ISO date "YYYY-MM-DD" in `settings.timezone` */
date: string;
/** If `isBlocked=true`, the whole day is unavailable. Otherwise uses startTime/endTime. */
isBlocked: boolean;
startTime: string | null;
endTime: string | null;
reason: string;
createdAt: number;
}
export interface GoogleAuthMeta {
connected: boolean;
connectedAt: number | null;
email: string | null;
calendarIds: string[];
/** Opaque sync token used for incremental list calls. */
syncToken: string | null;
lastSyncAt: number | null;
lastSyncStatus: 'ok' | 'error' | null;
lastSyncError: string | null;
}
/** Cached Google Calendar busy events (opaque + non-cancelled). */
export interface CachedGcalEvent {
id: string;
googleEventId: string;
calendarId: string;
title: string;
startTime: number;
endTime: number;
allDay: boolean;
status: 'confirmed' | 'tentative' | 'cancelled';
transparency: 'opaque' | 'transparent';
}
export interface ScheduleConfigDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
settings: ScheduleSettings;
rules: Record<string, AvailabilityRule>;
overrides: Record<string, AvailabilityOverride>;
googleAuth: GoogleAuthMeta;
googleEvents: Record<string, CachedGcalEvent>;
}
// ── Booking doc (host side) ──
export type BookingStatus = 'confirmed' | 'cancelled' | 'completed';
export interface BookingAttendee {
id: string;
entity: EntityRef | null;
name: string;
email: string;
status: 'invited' | 'accepted' | 'declined';
invitedAt: number;
respondedAt: number | null;
}
export interface Booking {
id: string;
host: EntityRef;
guestName: string;
guestEmail: string;
guestNote: string;
attendees: Record<string, BookingAttendee>;
startTime: number;
endTime: number;
timezone: string;
status: BookingStatus;
meetingLink: string | null;
googleEventId: string | null;
cancelToken: string;
cancellationReason: string | null;
reminderSentAt: number | null;
createdAt: number;
updatedAt: number;
}
export interface ScheduleBookingsDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
bookings: Record<string, Booking>;
}
// ── Invitations doc (attendee side) ──
export interface Invitation {
id: string;
bookingId: string;
host: EntityRef;
title: string;
startTime: number;
endTime: number;
timezone: string;
status: BookingStatus;
response: 'invited' | 'accepted' | 'declined';
meetingLink: string | null;
createdAt: number;
updatedAt: number;
}
export interface ScheduleInvitationsDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
invitations: Record<string, Invitation>;
}
// ── Defaults ──
export const DEFAULT_SETTINGS: ScheduleSettings = {
displayName: '',
email: '',
bookingMessage: 'Book a time to chat.',
slotDurationMin: 30,
bufferBeforeMin: 0,
bufferAfterMin: 0,
minNoticeHours: 2,
maxAdvanceDays: 60,
timezone: 'America/Vancouver',
autoShiftTimezone: false,
};
export const DEFAULT_GOOGLE_AUTH: GoogleAuthMeta = {
connected: false,
connectedAt: null,
email: null,
calendarIds: [],
syncToken: null,
lastSyncAt: null,
lastSyncStatus: null,
lastSyncError: null,
};
// ── Schema registrations ──
export const scheduleConfigSchema: DocSchema<ScheduleConfigDoc> = {
module: 'schedule',
collection: 'config',
version: 1,
init: (): ScheduleConfigDoc => ({
meta: { module: 'schedule', collection: 'config', version: 1, spaceSlug: '', createdAt: Date.now() },
settings: { ...DEFAULT_SETTINGS },
rules: {},
overrides: {},
googleAuth: { ...DEFAULT_GOOGLE_AUTH },
googleEvents: {},
}),
};
export const scheduleBookingsSchema: DocSchema<ScheduleBookingsDoc> = {
module: 'schedule',
collection: 'bookings',
version: 1,
init: (): ScheduleBookingsDoc => ({
meta: { module: 'schedule', collection: 'bookings', version: 1, spaceSlug: '', createdAt: Date.now() },
bookings: {},
}),
};
export const scheduleInvitationsSchema: DocSchema<ScheduleInvitationsDoc> = {
module: 'schedule',
collection: 'invitations',
version: 1,
init: (): ScheduleInvitationsDoc => ({
meta: { module: 'schedule', collection: 'invitations', version: 1, spaceSlug: '', createdAt: Date.now() },
invitations: {},
}),
};
// ── DocId helpers ──
export function scheduleConfigDocId(space: string) {
return `${space}:schedule:config` as const;
}
export function scheduleBookingsDocId(space: string) {
return `${space}:schedule:bookings` as const;
}
export function scheduleInvitationsDocId(space: string) {
return `${space}:schedule:invitations` as const;
}
// ── Limits ──
export const MAX_RULES_PER_SPACE = 50;
export const MAX_OVERRIDES_PER_SPACE = 500;
export const MAX_GCAL_EVENTS_CACHED = 2000;