/** * 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; overrides: Record; googleAuth: GoogleAuthMeta; googleEvents: Record; } // ── 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; 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; } // ── 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; } // ── 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 = { 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 = { module: 'schedule', collection: 'bookings', version: 1, init: (): ScheduleBookingsDoc => ({ meta: { module: 'schedule', collection: 'bookings', version: 1, spaceSlug: '', createdAt: Date.now() }, bookings: {}, }), }; export const scheduleInvitationsSchema: DocSchema = { 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;