diff --git a/apps/web/src/app/api/google/channels/calendars/route.ts b/apps/web/src/app/api/google/channels/calendars/route.ts new file mode 100644 index 00000000..cab468ea --- /dev/null +++ b/apps/web/src/app/api/google/channels/calendars/route.ts @@ -0,0 +1,3 @@ +import { handler } from "@repo/api/providers/google-calendar/channel"; + +export const { POST } = handler(); diff --git a/apps/web/src/app/api/google/channels/events/route.ts b/apps/web/src/app/api/google/channels/events/route.ts new file mode 100644 index 00000000..cab468ea --- /dev/null +++ b/apps/web/src/app/api/google/channels/events/route.ts @@ -0,0 +1,3 @@ +import { handler } from "@repo/api/providers/google-calendar/channel"; + +export const { POST } = handler(); diff --git a/packages/api/package.json b/packages/api/package.json index 95fbd571..e3abb034 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -6,6 +6,7 @@ ".": "./src/root.ts", "./trpc": "./src/trpc.ts", "./providers": "./src/providers/index.ts", + "./providers/google-calendar/channel": "./src/providers/google-calendar/channel.ts", "./interfaces": "./src/interfaces/index.ts", "./schemas": "./src/schemas/index.ts" }, diff --git a/packages/api/src/interfaces/events.ts b/packages/api/src/interfaces/events.ts index cf1fde9d..bf364859 100644 --- a/packages/api/src/interfaces/events.ts +++ b/packages/api/src/interfaces/events.ts @@ -1,5 +1,6 @@ import type { Temporal } from "temporal-polyfill"; +// table export interface CalendarEvent { id: string; title?: string; @@ -28,6 +29,7 @@ export interface CalendarEvent { recurringEventId?: string; } +// conference + entry point : are jsonb in the database export interface ConferenceEntryPoint { joinUrl: { label?: string; @@ -66,6 +68,7 @@ export interface Conference { extra?: Record; } +// table export interface Attendee { id?: string; email: string; @@ -89,6 +92,7 @@ export type Frequency = | "MONTHLY" | "YEARLY"; +// table export interface Recurrence { freq: Frequency; interval?: number; diff --git a/packages/api/src/providers/calendars/google-calendar.ts b/packages/api/src/providers/calendars/google-calendar.ts index 38ca56c0..8ca6cbe9 100644 --- a/packages/api/src/providers/calendars/google-calendar.ts +++ b/packages/api/src/providers/calendars/google-calendar.ts @@ -111,7 +111,8 @@ export class GoogleCalendarProvider implements CalendarProvider { const events: CalendarEvent[] = items?.map((event) => parseGoogleCalendarEvent({ - calendar, + calendarId: calendar.id, + readOnly: calendar.readOnly, accountId: this.accountId, event, defaultTimeZone: timeZone ?? "UTC", @@ -148,7 +149,8 @@ export class GoogleCalendarProvider implements CalendarProvider { }); return parseGoogleCalendarEvent({ - calendar, + calendarId: calendar.id, + readOnly: calendar.readOnly, accountId: this.accountId, event, defaultTimeZone: timeZone ?? "UTC", @@ -168,7 +170,8 @@ export class GoogleCalendarProvider implements CalendarProvider { ); return parseGoogleCalendarEvent({ - calendar, + calendarId: calendar.id, + readOnly: calendar.readOnly, accountId: this.accountId, event: createdEvent, }); @@ -239,7 +242,8 @@ export class GoogleCalendarProvider implements CalendarProvider { ); return parseGoogleCalendarEvent({ - calendar, + calendarId: calendar.id, + readOnly: calendar.readOnly, accountId: this.accountId, event: updatedEvent, }); @@ -273,7 +277,8 @@ export class GoogleCalendarProvider implements CalendarProvider { }); return parseGoogleCalendarEvent({ - calendar: destinationCalendar, + calendarId: destinationCalendar.id, + readOnly: destinationCalendar.readOnly, accountId: this.accountId, event: moved, }); diff --git a/packages/api/src/providers/calendars/google-calendar/events.ts b/packages/api/src/providers/calendars/google-calendar/events.ts index 6af33cd1..d3c120b9 100644 --- a/packages/api/src/providers/calendars/google-calendar/events.ts +++ b/packages/api/src/providers/calendars/google-calendar/events.ts @@ -4,7 +4,6 @@ import { Temporal } from "temporal-polyfill"; import { Attendee, AttendeeStatus, - Calendar, CalendarEvent, Conference, Recurrence, @@ -117,14 +116,16 @@ function parseRecurrence( } interface ParsedGoogleCalendarEventOptions { - calendar: Calendar; + calendarId: string; + readOnly: boolean; accountId: string; event: GoogleCalendarEvent; defaultTimeZone?: string; } export function parseGoogleCalendarEvent({ - calendar, + calendarId, + readOnly, accountId, event, defaultTimeZone = "UTC", @@ -157,9 +158,9 @@ export function parseGoogleCalendarEvent({ etag: event.etag, providerId: "google", accountId, - calendarId: calendar.id, + calendarId, readOnly: - calendar.readOnly || + readOnly || ["birthday", "focusTime", "outOfOffice", "workingLocation"].includes( event.eventType ?? "", ), diff --git a/packages/api/src/providers/calendars/microsoft-calendar.ts b/packages/api/src/providers/calendars/microsoft-calendar.ts index 5aeec4d5..06626e07 100644 --- a/packages/api/src/providers/calendars/microsoft-calendar.ts +++ b/packages/api/src/providers/calendars/microsoft-calendar.ts @@ -125,7 +125,12 @@ export class MicrosoftCalendarProvider implements CalendarProvider { const events = (response.value as MicrosoftEvent[]).map( (event: MicrosoftEvent) => - parseMicrosoftEvent({ event, accountId: this.accountId, calendar }), + parseMicrosoftEvent({ + event, + accountId: this.accountId, + calendarId: calendar.id, + readOnly: calendar.readOnly, + }), ); return { events, recurringMasterEvents: [] }; @@ -151,7 +156,8 @@ export class MicrosoftCalendarProvider implements CalendarProvider { return parseMicrosoftEvent({ event, accountId: this.accountId, - calendar, + calendarId: calendar.id, + readOnly: calendar.readOnly, }); }); } @@ -168,7 +174,8 @@ export class MicrosoftCalendarProvider implements CalendarProvider { return parseMicrosoftEvent({ event: createdEvent, accountId: this.accountId, - calendar, + calendarId: calendar.id, + readOnly: calendar.readOnly, }); }); } @@ -211,7 +218,8 @@ export class MicrosoftCalendarProvider implements CalendarProvider { return parseMicrosoftEvent({ event: updatedEvent, accountId: this.accountId, - calendar, + calendarId: calendar.id, + readOnly: calendar.readOnly, }); }); } @@ -225,7 +233,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { async deleteEvent( calendarId: string, eventId: string, - sendUpdate: boolean = true, + _sendUpdate: boolean = true, ): Promise { await this.withErrorHandler("deleteEvent", async () => { await this.graphClient @@ -245,6 +253,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { // This could be implemented by creating a new event in destination and deleting the original, // preserving fields as needed. const event = await this.event(sourceCalendar, eventId); + return { ...event, calendarId: destinationCalendar.id, @@ -255,7 +264,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { } async responseToEvent( - calendarId: string, + _calendarId: string, eventId: string, response: ResponseToEventInput, ): Promise { diff --git a/packages/api/src/providers/calendars/microsoft-calendar/events.ts b/packages/api/src/providers/calendars/microsoft-calendar/events.ts index fbea12b5..f96ee398 100644 --- a/packages/api/src/providers/calendars/microsoft-calendar/events.ts +++ b/packages/api/src/providers/calendars/microsoft-calendar/events.ts @@ -11,7 +11,6 @@ import { Temporal } from "temporal-polyfill"; import type { Attendee, AttendeeStatus, - Calendar, CalendarEvent, Conference, } from "../../../interfaces"; @@ -69,7 +68,8 @@ function parseDate(date: string) { interface ParseMicrosoftEventOptions { accountId: string; - calendar: Calendar; + calendarId: string; + readOnly: boolean; event: MicrosoftEvent; } @@ -102,7 +102,8 @@ function parseResponseStatus( export function parseMicrosoftEvent({ accountId, - calendar, + calendarId, + readOnly, event, }: ParseMicrosoftEventOptions): CalendarEvent { const { start, end, isAllDay } = event; @@ -132,8 +133,8 @@ export function parseMicrosoftEvent({ etag: event["@odata.etag"], providerId: "microsoft", accountId, - calendarId: calendar.id, - readOnly: calendar.readOnly, + calendarId, + readOnly, conference: parseMicrosoftConference(event), ...(responseStatus && { response: { status: responseStatus } }), metadata: { diff --git a/packages/api/src/providers/google-calendar/channel.ts b/packages/api/src/providers/google-calendar/channel.ts new file mode 100644 index 00000000..6aceb442 --- /dev/null +++ b/packages/api/src/providers/google-calendar/channel.ts @@ -0,0 +1,115 @@ +import { Account, auth } from "@repo/auth/server"; +import { db } from "@repo/db"; + +import { handleCalendarListMessage } from "./channels/calendars"; +import { handleEventsMessage } from "./channels/events"; +import { parseHeaders } from "./channels/headers"; + +interface FindChannelOptions { + channelId: string; +} + +async function findChannel({ channelId }: FindChannelOptions) { + return await db.query.channel.findFirst({ + where: (table, { eq }) => eq(table.id, channelId), + }); +} + +export async function withAccessToken(account: Account) { + const { accessToken } = await auth.api.getAccessToken({ + body: { + providerId: account.providerId, + accountId: account.id, + userId: account.userId, + }, + }); + + return { + ...account, + accessToken: accessToken ?? account.accessToken!, + }; +} + +interface FindAccountOptions { + accountId: string; +} + +async function findAccount({ accountId }: FindAccountOptions) { + const account = await db.query.account.findFirst({ + where: (table, { eq }) => eq(table.id, accountId), + }); + + if (!account) { + throw new Error(`Account ${accountId} not found`); + } + + return await withAccessToken(account); +} + +export function handler() { + const POST = async (request: Request) => { + const headers = await parseHeaders({ headers: request.headers }); + + if (!headers) { + return new Response("Missing or invalid headers", { status: 400 }); + } + + if (headers.resourceState === "sync") { + return new Response("OK", { status: 200 }); + } + + const channel = await findChannel({ channelId: headers.id }); + + if (!channel) { + return new Response("Channel not found", { status: 404 }); + } + + if (!headers.token || headers.token !== channel.token) { + return new Response("Invalid channel token", { status: 401 }); + } + + if (headers.resourceId !== channel.resourceId) { + return new Response("Mismatched resource", { status: 400 }); + } + + const account = await findAccount({ accountId: channel.accountId }); + + if (!account.accessToken) { + return new Response("Failed to obtain a valid access token", { + status: 500, + }); + } + + if (channel.type === "google.calendar") { + await handleCalendarListMessage({ + account, + }); + } else if (channel.type === "google.event") { + const calendarId = extractCalendarId(headers.resourceUri); + + if (!calendarId) { + return new Response("Missing calendar id", { status: 400 }); + } + + await handleEventsMessage({ calendarId, account }); + } else { + return new Response("Invalid channel type", { status: 400 }); + } + + return new Response(null, { status: 204 }); + }; + + return { + POST, + }; +} + +function extractCalendarId(uri: string): string | null { + const match = /\/calendars\/([^/?#]+)/.exec(uri); + + if (!match) { + return null; + } + + return decodeURIComponent(match[1]!); +} diff --git a/packages/api/src/providers/google-calendar/channels/calendars.ts b/packages/api/src/providers/google-calendar/channels/calendars.ts new file mode 100644 index 00000000..d3a846ee --- /dev/null +++ b/packages/api/src/providers/google-calendar/channels/calendars.ts @@ -0,0 +1,74 @@ +import { and, eq } from "drizzle-orm"; + +import { Account } from "@repo/auth/server"; +import { db } from "@repo/db"; +import { account as accounts, calendars } from "@repo/db/schema"; +import { GoogleCalendar } from "@repo/google-calendar"; + +import type { Calendar } from "../../../interfaces"; +import { parseGoogleCalendarCalendarListEntry } from "../../calendars/google-calendar/calendars"; +import { GoogleCalendarCalendarListEntry } from "../../calendars/google-calendar/interfaces"; +import { syncCalendarList } from "../sync"; + +async function upsertCalendar(calendar: Calendar) { + await db + .insert(calendars) + .values({ + ...calendar, + calendarId: calendar.id, + }) + .onConflictDoUpdate({ + target: [calendars.calendarId, calendars.accountId], + set: calendar, + }); +} + +interface HandleCalendarListMessageOptions { + account: Account; +} + +export async function handleCalendarListMessage({ + account, +}: HandleCalendarListMessageOptions) { + const client = new GoogleCalendar({ accessToken: account.accessToken! }); + + const syncToken = account.calendarListSyncToken; + + const { nextSyncToken } = await syncCalendarList({ + client, + syncToken, + onInvalidSyncToken: async () => { + await db + .delete(calendars) + .where( + and( + eq(calendars.accountId, account.id), + eq(calendars.providerId, "google"), + ), + ); + + await db + .update(accounts) + .set({ calendarListSyncToken: null }) + .where(eq(accounts.id, account.id)); + }, + onUpsert: async (item: GoogleCalendarCalendarListEntry) => { + const parsedCalendar = parseGoogleCalendarCalendarListEntry({ + accountId: account.id, + entry: item, + }); + + await upsertCalendar(parsedCalendar); + }, + onDelete: async (calendarId: string) => { + await db.delete(calendars).where(eq(calendars.id, calendarId)); + }, + }); + + if (nextSyncToken) { + await db + .update(accounts) + .set({ calendarListSyncToken: nextSyncToken }) + .where(eq(accounts.id, account.id)); + } +} diff --git a/packages/api/src/providers/google-calendar/channels/events.ts b/packages/api/src/providers/google-calendar/channels/events.ts new file mode 100644 index 00000000..93d2f6fd --- /dev/null +++ b/packages/api/src/providers/google-calendar/channels/events.ts @@ -0,0 +1,111 @@ +import { and, eq } from "drizzle-orm"; +import { Temporal } from "temporal-polyfill"; + +import { Account } from "@repo/auth/server"; +import { db } from "@repo/db"; +import { calendars, events } from "@repo/db/schema"; +import { GoogleCalendar } from "@repo/google-calendar"; +import { toDate } from "@repo/temporal"; + +import { CalendarEvent } from "../../../interfaces"; +import { parseGoogleCalendarEvent } from "../../calendars/google-calendar/events"; +import type { GoogleCalendarEvent } from "../../calendars/google-calendar/interfaces"; +import { syncEvents } from "../sync"; + +async function upsertEvent(event: CalendarEvent) { + const values = { + id: event.id, + title: event.title, + description: event.description, + start: toDate(event.start, { timeZone: "UTC" }), + startTimeZone: + event.start instanceof Temporal.ZonedDateTime + ? event.start.timeZoneId + : null, + end: toDate(event.end, { timeZone: "UTC" }), + endTimeZone: + event.end instanceof Temporal.ZonedDateTime ? event.end.timeZoneId : null, + allDay: event.allDay, + location: event.location, + status: event.status, + url: event.url, + etag: event.etag, + readOnly: event.readOnly, + calendarId: event.calendarId, + providerId: "google" as const, + accountId: event.accountId, + recurringEventId: event.recurringEventId, + conference: event.conference, + metadata: event.metadata, + response: event.response, + } as const; + + await db + .insert(events) + .values(values) + .onConflictDoUpdate({ + target: [events.id, events.calendarId, events.accountId], + set: { + ...values, + }, + }); +} + +interface HandleEventsMessageOptions { + calendarId: string; + account: Account; +} + +export async function handleEventsMessage({ + calendarId, + account, +}: HandleEventsMessageOptions) { + const calendar = await db.query.calendars.findFirst({ + where: (table, { and, eq }) => + and(eq(table.id, calendarId), eq(table.accountId, account.id)), + }); + + if (!calendar) { + throw new Error(`Calendar ${calendarId} not found`); + } + + const client = new GoogleCalendar({ accessToken: account.accessToken! }); + + const { nextSyncToken } = await syncEvents({ + client, + calendarId, + syncToken: calendar.syncToken, + onInvalidSyncToken: async () => { + await db.transaction(async (tx) => { + await tx.delete(events).where(eq(events.calendarId, calendar.id)); + + await tx + .update(calendars) + .set({ syncToken: null }) + .where(eq(calendars.id, calendar.id)); + }); + }, + onDelete: async (eventId: string) => { + await db + .delete(events) + .where(and(eq(events.id, eventId), eq(events.calendarId, calendar.id))); + }, + onUpsert: async (event: GoogleCalendarEvent) => { + const parsedEvent = parseGoogleCalendarEvent({ + calendarId: calendar.id, + readOnly: calendar.readOnly, + accountId: account.id, + event, + }); + + await upsertEvent(parsedEvent); + }, + }); + + if (nextSyncToken) { + await db + .update(calendars) + .set({ syncToken: nextSyncToken, updatedAt: new Date() }) + .where(eq(calendars.id, calendar.id)); + } +} diff --git a/packages/api/src/providers/google-calendar/channels/headers.ts b/packages/api/src/providers/google-calendar/channels/headers.ts new file mode 100644 index 00000000..5cbb5f3d --- /dev/null +++ b/packages/api/src/providers/google-calendar/channels/headers.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +import type { channel } from "@repo/db/schema"; + +export type ChannelHeaders = z.infer; +export type Channel = typeof channel.$inferSelect; + +interface ParseHeadersOptions { + headers: Headers; +} + +const headersSchema = z.object({ + id: z.string(), + resourceId: z.string(), + resourceUri: z.string(), + resourceState: z.string(), + messageNumber: z.string(), + expiration: z.string().optional(), + token: z.string().optional(), +}); + +export async function parseHeaders({ + headers, +}: ParseHeadersOptions): Promise { + const channelHeaders = headersSchema.safeParse({ + id: headers.get("X-Goog-Channel-ID"), + messageNumber: headers.get("X-Goog-Message-Number"), + resourceId: headers.get("X-Goog-Resource-ID"), + resourceState: headers.get("X-Goog-Resource-State"), + resourceUri: headers.get("X-Goog-Resource-URI"), + expiration: headers.get("X-Goog-Channel-Expiration"), + token: headers.get("X-Goog-Channel-Token"), + }); + + if (!channelHeaders.success) { + return null; + } + + return channelHeaders.data; +} diff --git a/packages/api/src/providers/google-calendar/subscribe.ts b/packages/api/src/providers/google-calendar/subscribe.ts new file mode 100644 index 00000000..e9891b65 --- /dev/null +++ b/packages/api/src/providers/google-calendar/subscribe.ts @@ -0,0 +1,79 @@ +import { GoogleCalendar } from "@repo/google-calendar"; + +const DEFAULT_TTL = "3600"; + +interface SubscribeCalendarListOptions { + client: GoogleCalendar; + subscriptionId: string; + webhookUrl: string; +} + +export async function subscribeCalendarList({ + client, + subscriptionId, + webhookUrl, +}: SubscribeCalendarListOptions) { + const response = await client.users.me.calendarList.watch({ + id: subscriptionId, + type: "web_hook", + address: webhookUrl, + params: { + ttl: DEFAULT_TTL, + }, + }); + + return { + type: "google.calendar" as const, + subscriptionId, + resourceId: response.resourceId!, + expiresAt: new Date(Number(response.expiration!)), + }; +} + +interface SubscribeEventsOptions { + client: GoogleCalendar; + calendarId: string; + subscriptionId: string; + webhookUrl: string; +} + +export async function subscribeEvents({ + client, + calendarId, + subscriptionId, + webhookUrl, +}: SubscribeEventsOptions) { + const response = await client.calendars.events.watch(calendarId, { + id: subscriptionId, + type: "web_hook", + address: webhookUrl, + params: { + ttl: DEFAULT_TTL, + }, + }); + + return { + type: "google.event" as const, + subscriptionId, + calendarId, + resourceId: response.resourceId!, + expiresAt: new Date(Number(response.expiration!)), + }; +} + +interface UnsubscribeOptions { + client: GoogleCalendar; + subscriptionId: string; + resourceId: string; +} + +export async function unsubscribe({ + client, + subscriptionId, + resourceId, +}: UnsubscribeOptions) { + await client.stopWatching.stopWatching({ + id: subscriptionId, + resourceId, + }); +} diff --git a/packages/api/src/providers/google-calendar/sync.ts b/packages/api/src/providers/google-calendar/sync.ts new file mode 100644 index 00000000..f1af5119 --- /dev/null +++ b/packages/api/src/providers/google-calendar/sync.ts @@ -0,0 +1,157 @@ +import { APIError, GoogleCalendar } from "@repo/google-calendar"; + +import type { + GoogleCalendarCalendarListEntry, + GoogleCalendarEvent, +} from "../calendars/google-calendar/interfaces"; + +interface BaseSyncResult { + nextSyncToken: string | null; +} + +export interface SyncCalendarListOptions { + client: GoogleCalendar; + syncToken: string | null; + onUpsert: (entry: GoogleCalendarCalendarListEntry) => Promise | void; + onDelete: (calendarId: string) => Promise | void; + onInvalidSyncToken: () => Promise | void; +} + +export async function syncCalendarList({ + client, + syncToken, + onUpsert, + onDelete, + onInvalidSyncToken, +}: SyncCalendarListOptions): Promise { + let pageToken: string | undefined = undefined; + + const perform = async (forceFullSync?: boolean) => { + let nextSyncToken: string | null = null; + + do { + try { + const calendarList = await client.users.me.calendarList.list({ + ...(syncToken && !forceFullSync ? { syncToken } : {}), + pageToken, + maxResults: 250, + showHidden: false, + showDeleted: true, + minAccessRole: "reader", + }); + + const items = calendarList.items ?? []; + + for (const entry of items) { + if (entry.deleted) { + await onDelete(entry.id!); + } else { + await onUpsert(entry); + } + } + + pageToken = calendarList.nextPageToken; + nextSyncToken = calendarList.nextSyncToken ?? null; + } catch (error: unknown) { + if (error instanceof APIError && error.status === 410) { + await onInvalidSyncToken(); + + return null; + } + + throw error; + } + } while (pageToken); + + return nextSyncToken; + }; + + let nextSyncToken = await perform(); + + if (nextSyncToken) { + return { nextSyncToken }; + } + + // Full sync + nextSyncToken = await perform(true); + + return { + nextSyncToken, + }; +} + +export interface SyncEventsOptions { + client: GoogleCalendar; + calendarId: string; + syncToken: string | null; + timeMin?: string; + timeMax?: string; + onUpsert: (event: GoogleCalendarEvent) => Promise | void; + onDelete: (eventId: string) => Promise | void; + onInvalidSyncToken: () => Promise | void; +} + +export async function syncEvents({ + client, + calendarId, + syncToken, + timeMin, + timeMax, + onUpsert, + onDelete, + onInvalidSyncToken, +}: SyncEventsOptions): Promise { + let pageToken: string | undefined = undefined; + + const perform = async (forceFullSync?: boolean) => { + let nextSyncToken: string | null = null; + + do { + try { + const result = await client.calendars.events.list(calendarId, { + ...(syncToken && !forceFullSync + ? { syncToken } + : { timeMin, timeMax }), + pageToken, + maxResults: 2500, + singleEvents: true, + showDeleted: true, + }); + + const items = result.items ?? []; + + for (const event of items) { + if (event.status === "cancelled") { + await onDelete(event.id!); + } else { + await onUpsert(event); + } + } + + pageToken = result.nextPageToken; + nextSyncToken = result.nextSyncToken ?? null; + } catch (error: unknown) { + if (error instanceof APIError && error.status === 410) { + await onInvalidSyncToken(); + + return null; + } + + throw error; + } + } while (pageToken); + + return nextSyncToken; + }; + + let nextSyncToken = await perform(); + + if (nextSyncToken) { + return { nextSyncToken }; + } + + // Full sync + nextSyncToken = await perform(true); + + return { nextSyncToken }; +} diff --git a/packages/api/src/providers/google-calendar/utils.ts b/packages/api/src/providers/google-calendar/utils.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/db/src/lib/id.ts b/packages/db/src/lib/id.ts new file mode 100644 index 00000000..81260cae --- /dev/null +++ b/packages/db/src/lib/id.ts @@ -0,0 +1,17 @@ +import { customAlphabet } from "nanoid"; + +export const nanoid = customAlphabet( + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", +); + +const prefixes = { + calendar: "cal", + event: "event", + connection: "conn", + resource: "res", + channel: "chan", +} as const; + +export function newId(prefix: keyof typeof prefixes): string { + return [prefixes[prefix], nanoid(16)].join("_"); +} diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index 1823d881..919c266d 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -77,6 +77,7 @@ export const account = pgTable( refreshTokenExpiresAt: timestamp(), scope: text(), password: text(), + calendarListSyncToken: text("calendar_list_sync_token"), createdAt: timestamp().notNull(), updatedAt: timestamp().notNull(), }, diff --git a/packages/db/src/schema/calendars.ts b/packages/db/src/schema/calendars.ts index f2542558..10ce327d 100644 --- a/packages/db/src/schema/calendars.ts +++ b/packages/db/src/schema/calendars.ts @@ -2,6 +2,8 @@ import { relations } from "drizzle-orm"; import { boolean, index, + integer, + jsonb, pgTable, text, timestamp, @@ -20,6 +22,7 @@ export const calendars = pgTable( primary: boolean("primary").default(false).notNull(), color: text("color"), etag: text("etag"), + readOnly: boolean("read_only").default(false).notNull(), calendarId: text("calendar_id").notNull(), @@ -37,17 +40,15 @@ export const calendars = pgTable( .notNull() .$onUpdateFn(() => new Date()), }, - (table) => [index("calendar_account_idx").on(table.accountId)], + (table) => [ + index("calendar_account_idx").on(table.accountId), + uniqueIndex("calendar_calendar_id_account_unique").on( + table.calendarId, + table.accountId, + ), + ], ); -export const calendarsRelations = relations(calendars, ({ one, many }) => ({ - account: one(account, { - fields: [calendars.accountId], - references: [account.id], - }), - events: many(events), -})); - export const events = pgTable( "event", { @@ -66,9 +67,17 @@ export const events = pgTable( status: text("status"), url: text("url"), etag: text("etag"), + readOnly: boolean("read_only").default(false).notNull(), + + conference: jsonb("conference"), + metadata: jsonb("metadata"), + response: jsonb("response"), syncToken: text("sync_token"), recurringEventId: text("recurring_event_id"), + recurrenceId: text("recurrence_id").references(() => recurrence.id, { + onDelete: "set null", + }), calendarId: text("calendar_id") .notNull() @@ -88,14 +97,105 @@ export const events = pgTable( }, (table) => [ index("event_account_idx").on(table.accountId), - uniqueIndex("event_account_calendar_idx").on( - table.accountId, + index("event_recurrence_idx").on(table.recurrenceId), + index("event_account_calendar_idx").on(table.accountId, table.calendarId), + uniqueIndex("event_id_calendar_account_unique").on( + table.id, table.calendarId, + table.accountId, ), ], ); -export const eventsRelations = relations(events, ({ one }) => ({ +export const recurrence = pgTable("recurrence", { + id: text("id").primaryKey(), + + // Core recurrence fields + freq: text("freq", { + enum: [ + "SECONDLY", + "MINUTELY", + "HOURLY", + "DAILY", + "WEEKLY", + "MONTHLY", + "YEARLY", + ], + }).notNull(), + interval: integer("interval").default(1), + count: integer("count"), + until: timestamp("until", { withTimezone: true }), + wkst: text("wkst", { + enum: ["MO", "TU", "WE", "TH", "FR", "SA", "SU"], + }), + + // BY* rules stored as JSONB arrays + byDay: jsonb("by_day"), // Weekday[] + byMonth: jsonb("by_month"), // number[] + byMonthDay: jsonb("by_month_day"), // number[] + byYearDay: jsonb("by_year_day"), // number[] + byWeekNo: jsonb("by_week_no"), // number[] + byHour: jsonb("by_hour"), // number[] + byMinute: jsonb("by_minute"), // number[] + bySecond: jsonb("by_second"), // number[] + bySetPos: jsonb("by_set_pos"), // number[] + + // Exception and inclusion dates + exDate: jsonb("ex_date"), // Temporal dates array + rDate: jsonb("r_date"), // Temporal dates array + + createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp({ withTimezone: true }) + .defaultNow() + .notNull() + .$onUpdateFn(() => new Date()), +}); + +export const attendees = pgTable( + "attendee", + { + id: text("id").primaryKey(), + email: text("email").notNull(), + name: text("name"), + status: text("status", { + enum: ["accepted", "tentative", "declined", "unknown"], + }).notNull(), + type: text("type", { + enum: ["required", "optional", "resource"], + }).notNull(), + comment: text("comment"), + organizer: boolean("organizer").default(false), + additionalGuests: integer("additional_guests"), + + eventId: text("event_id") + .notNull() + .references(() => events.id, { onDelete: "cascade" }), + + createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp({ withTimezone: true }) + .defaultNow() + .notNull() + .$onUpdateFn(() => new Date()), + }, + (table) => [ + index("attendee_event_idx").on(table.eventId), + index("attendee_email_idx").on(table.email), + ], +); + +export const calendarsRelations = relations(calendars, ({ one, many }) => ({ + account: one(account, { + fields: [calendars.accountId], + references: [account.id], + }), + events: many(events), +})); + +export const recurrenceRelations = relations(recurrence, ({ many }) => ({ + events: many(events), +})); + +export const eventsRelations = relations(events, ({ one, many }) => ({ calendar: one(calendars, { fields: [events.calendarId], references: [calendars.id], @@ -104,4 +204,16 @@ export const eventsRelations = relations(events, ({ one }) => ({ fields: [events.accountId], references: [account.id], }), + recurrence: one(recurrence, { + fields: [events.recurrenceId], + references: [recurrence.id], + }), + attendees: many(attendees), +})); + +export const attendeesRelations = relations(attendees, ({ one }) => ({ + event: one(events, { + fields: [attendees.eventId], + references: [events.id], + }), })); diff --git a/packages/db/src/schema/channel.ts b/packages/db/src/schema/channel.ts new file mode 100644 index 00000000..65da0d74 --- /dev/null +++ b/packages/db/src/schema/channel.ts @@ -0,0 +1,47 @@ +import { + index, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +import { newId } from "../lib/id"; +import { account } from "./auth"; +import { resource } from "./resource"; + +export const channel = pgTable( + "channel", + { + id: text() + .primaryKey() + .$default(() => newId("channel")), + + // TODO: when an account is deleted, we should first stop channel subscriptions and then delete all channels associated with it + accountId: text() + .notNull() + .references(() => account.id, { onDelete: "cascade" }), + providerId: text({ enum: ["google"] }).notNull(), + resourceId: text() + .notNull() + .references(() => resource.id, { onDelete: "cascade" }), + + type: text({ enum: ["google.calendar", "google.event"] }).notNull(), + + token: text().notNull(), + expiresAt: timestamp({ withTimezone: true }).notNull(), + + createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp({ withTimezone: true }) + .defaultNow() + .notNull() + .$onUpdateFn(() => new Date()), + }, + (table) => [ + index("channel_account_idx").on(table.accountId), + index("channel_resource_idx").on(table.resourceId), + index("channel_account_resource_idx").on(table.accountId, table.resourceId), + index("channel_expires_at_idx").on(table.expiresAt), + uniqueIndex("channel_token_unique").on(table.token), + ], +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 96a59d8b..503940e5 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,3 +1,5 @@ export * from "./auth"; export * from "./calendars"; export * from "./waitlist"; +export * from "./channel"; +export * from "./resource"; diff --git a/packages/db/src/schema/resource.ts b/packages/db/src/schema/resource.ts new file mode 100644 index 00000000..43affee8 --- /dev/null +++ b/packages/db/src/schema/resource.ts @@ -0,0 +1,16 @@ +import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +import { newId } from "../lib/id"; + +export const resource = pgTable("resource", { + id: text() + .primaryKey() + .$defaultFn(() => newId("resource")), + providerId: text({ enum: ["google"] }).notNull(), + + createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp({ withTimezone: true }) + .defaultNow() + .notNull() + .$onUpdateFn(() => new Date()), +});