261 lines
6.3 KiB
TypeScript
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;
|