From b9f9b81aeb81ccecc90af16981f38743a48246de Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Thu, 3 Jul 2025 15:29:18 +0200 Subject: [PATCH 01/10] wip --- .../src/providers/google-calendar/channel.ts | 87 +++++++++++++++++++ packages/db/src/lib/id.ts | 17 ++++ packages/db/src/schema/calendar.ts | 26 ++++++ packages/db/src/schema/channel.ts | 18 ++++ packages/db/src/schema/resource.ts | 11 +++ 5 files changed, 159 insertions(+) create mode 100644 packages/api/src/providers/google-calendar/channel.ts create mode 100644 packages/db/src/lib/id.ts create mode 100644 packages/db/src/schema/calendar.ts create mode 100644 packages/db/src/schema/channel.ts create mode 100644 packages/db/src/schema/resource.ts 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..4b8fd2ea --- /dev/null +++ b/packages/api/src/providers/google-calendar/channel.ts @@ -0,0 +1,87 @@ +import { db } from "@repo/db"; +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-list", + subscriptionId, + resourceId: response.resourceId!, + expiresAt: new Date(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.calendar-events", + subscriptionId, + calendarId, + resourceId: response.resourceId!, + expiresAt: new Date(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, + }); +} + +export function handleCalendarListMessage(request: Request) { + +} + +export function handleEventsMessage(request: Request) { + +} + + + +export async function handler() { + const POST = async (request: Request) => { + // Handle channel message + } + + return { + POST, + }; +} \ No newline at end of file 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/calendar.ts b/packages/db/src/schema/calendar.ts new file mode 100644 index 00000000..14d8192c --- /dev/null +++ b/packages/db/src/schema/calendar.ts @@ -0,0 +1,26 @@ +import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { newId } from "../lib/id"; + +export const calendar = pgTable("calendar", { + id: text().primaryKey().$default(() => newId("calendar")), + providerId: text({ enum: ["google", "microsoft"] }).notNull(), + connectionId: text() + .notNull(), + // .references(() => connection.id, { onDelete: "cascade" }), + + name: text().notNull(), + description: text(), + + owner: text().notNull(), + + access: text({ enum: ["owner", "write", "read", "availability"] }).notNull(), + + timeZone: text(), + + enabled: boolean().notNull().default(true), + + syncToken: text(), + + createdAt: timestamp().defaultNow().notNull(), + updatedAt: timestamp().defaultNow().notNull(), +}); diff --git a/packages/db/src/schema/channel.ts b/packages/db/src/schema/channel.ts new file mode 100644 index 00000000..ae537729 --- /dev/null +++ b/packages/db/src/schema/channel.ts @@ -0,0 +1,18 @@ +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { newId } from "../lib/id"; +import { resource } from "./resource"; + +export const channel = pgTable("channel", { + id: text().primaryKey().$default(() => newId("channel")), + + providerId: text({ enum: ["google"] }).notNull(), + resourceId: text() + .notNull() + .references(() => resource.id, { onDelete: "cascade" }), + + token: text().notNull(), + expiresAt: timestamp({ withTimezone: true }).notNull(), + + createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp({ withTimezone: true }).defaultNow().notNull().$onUpdate(() => new Date()), +}); diff --git a/packages/db/src/schema/resource.ts b/packages/db/src/schema/resource.ts new file mode 100644 index 00000000..1f78d68e --- /dev/null +++ b/packages/db/src/schema/resource.ts @@ -0,0 +1,11 @@ +import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { newId } from "../lib/id"; + +export const resource = pgTable("resource", { + id: text().primaryKey().$default(() => newId("resource")), + providerId: text({ enum: ["google"] }).notNull(), + + + createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp({ withTimezone: true }).defaultNow().notNull().$onUpdate(() => new Date()), +}); \ No newline at end of file From 2a9dfd75283b4fd3b9873a0f4bb24682e1ced331 Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Thu, 3 Jul 2025 21:34:29 +0200 Subject: [PATCH 02/10] wip --- .../src/providers/google-calendar/channel.ts | 216 +++++++++++++++++- packages/auth/src/storage.ts | 26 +++ 2 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 packages/auth/src/storage.ts diff --git a/packages/api/src/providers/google-calendar/channel.ts b/packages/api/src/providers/google-calendar/channel.ts index 4b8fd2ea..2fa964d5 100644 --- a/packages/api/src/providers/google-calendar/channel.ts +++ b/packages/api/src/providers/google-calendar/channel.ts @@ -1,5 +1,9 @@ import { db } from "@repo/db"; import { GoogleCalendar } from "@repo/google-calendar"; +import { eq } from "drizzle-orm"; +import { calendars, events, account as accounts } from "@repo/db/schema"; +import { parseGoogleCalendarEvent, parseGoogleCalendarCalendarListEntry } from "./utils"; +import { Temporal } from "temporal-polyfill"; const DEFAULT_TTL = "3600"; @@ -66,20 +70,220 @@ export async function unsubscribe({ client, subscriptionId, resourceId }: Unsubs }); } -export function handleCalendarListMessage(request: Request) { - +// Utility: parse the `X-Goog-Channel-Token` header. We expect a base64 encoded JSON string +// of the shape: { type: "google.calendar-list" | "google.calendar-events", accountId: string, calendarId?: string } +function parseToken(token?: string | null): + | ({ + type: "google.calendar-list" | "google.calendar-events"; + accountId: string; + calendarId?: string; + }) + | null { + if (!token) return null; + + try { + const decoded = Buffer.from(token, "base64").toString("utf8"); + return JSON.parse(decoded); + } catch { + return null; + } } -export function handleEventsMessage(request: Request) { - +// Helper to convert Temporal.* values to javascript Date for database persistence +function temporalToDate( + value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, +): Date { + if (value instanceof Temporal.Instant) { + return new Date(value.epochMilliseconds); + } + + if (value instanceof Temporal.ZonedDateTime) { + return new Date(value.toInstant().epochMilliseconds); + } + + // Temporal.PlainDate + return new Date(value.toString()); } +export async function handleCalendarListMessage(request: Request) { + const token = parseToken(request.headers.get("X-Goog-Channel-Token")); + if (!token || token.type !== "google.calendar-list") { + return; + } + + // Locate account so that we can talk to Google on its behalf + const [accountRow] = await db + .select() + .from(accounts) + .where(eq(accounts.id, token.accountId)); + + if (!accountRow?.accessToken) return; + + const client = new GoogleCalendar({ accessToken: accountRow.accessToken }); + + // Fetch latest calendar list + const { items } = await client.users.me.calendarList.list(); + if (!items) return; + + // Upsert calendars + for (const item of items) { + if (!item.id) continue; + + // Use utils parser to transform API response into internal Calendar type + const parsedCalendar = parseGoogleCalendarCalendarListEntry({ + accountId: accountRow.id, + entry: item, + }); + + const values = { + id: parsedCalendar.id, + name: parsedCalendar.name, + description: parsedCalendar.description ?? null, + timeZone: parsedCalendar.timeZone ?? null, + primary: parsedCalendar.primary, + color: parsedCalendar.color ?? null, + calendarId: parsedCalendar.id, + providerId: "google" as const, + accountId: parsedCalendar.accountId, + updatedAt: new Date(), + }; + + const calendarIdStr = parsedCalendar.id; + + const existing = await db.query.calendars.findFirst({ + where: (table, { eq }) => eq(table.id, calendarIdStr), + }); + + if (existing) { + await db.update(calendars).set(values).where(eq(calendars.id, calendarIdStr)); + } else { + await db.insert(calendars).values({ + ...values, + createdAt: new Date(), + }); + } + } +} + +export async function handleEventsMessage(request: Request) { + const token = parseToken(request.headers.get("X-Goog-Channel-Token")); + if (!token || token.type !== "google.calendar-events" || !token.calendarId) { + return; + } + + const calendarId = token.calendarId; + + // Locate account & calendar rows + const [accountRow] = await db + .select() + .from(accounts) + .where(eq(accounts.id, token.accountId)); + if (!accountRow?.accessToken) return; + + const [calendarRow] = await db + .select() + .from(calendars) + .where(eq(calendars.id, calendarId)); + const client = new GoogleCalendar({ accessToken: accountRow.accessToken }); + + const listParams: Record = { + singleEvents: true, + showDeleted: true, + maxResults: 2500, + }; + + if (calendarRow?.syncToken) { + listParams["syncToken"] = calendarRow.syncToken; + } + + const response = await client.calendars.events.list(calendarId, listParams); + + // Persist nextSyncToken if present + if (response.nextSyncToken) { + await db + .update(calendars) + .set({ syncToken: response.nextSyncToken, updatedAt: new Date() }) + .where(eq(calendars.id, calendarId)); + } + + const items = response.items ?? []; + if (items.length === 0) return; + + const calendarObj = { + id: calendarId, + name: calendarRow?.name ?? "", + readOnly: calendarRow?.primary === false, + providerId: "google" as const, + accountId: accountRow.id, + timeZone: calendarRow?.timeZone ?? undefined, + primary: Boolean(calendarRow?.primary), + color: calendarRow?.color ?? null, + } as const; + + for (const event of items) { + if (event.status === "cancelled") { + // Delete locally + await db.delete(events).where(eq(events.id, event.id!)); + continue; + } + + const parsed = parseGoogleCalendarEvent({ + calendar: calendarObj as any, // minimal calendar satisfies function + accountId: accountRow.id, + event, + }); + + const values = { + id: parsed.id, + title: parsed.title, + description: parsed.description ?? null, + start: temporalToDate(parsed.start), + startTimeZone: "timeZone" in parsed.start ? (parsed.start as any).timeZone : null, + end: temporalToDate(parsed.end), + endTimeZone: "timeZone" in parsed.end ? (parsed.end as any).timeZone : null, + allDay: parsed.allDay, + location: parsed.location ?? null, + status: parsed.status ?? null, + url: parsed.url ?? null, + calendarId: calendarId, + providerId: "google" as const, + accountId: accountRow.id, + + } as const; + + const existingEvent = await db.query.events.findFirst({ + where: (table, { eq }) => eq(table.id, parsed.id), + }); + + await db.insert(events).values(values).onConflictDoUpdate({ + target: [events.id], + set: { + ...values, + }, + }); + } +} export async function handler() { const POST = async (request: Request) => { - // Handle channel message - } + // Quick health-check: Google expects a 2xx response to acknowledge. + + // Decide which handler to invoke based on the token header + const token = parseToken(request.headers.get("X-Goog-Channel-Token")); + + if (!token) { + return new Response("Missing or invalid channel token", { status: 202 }); + } + + if (token.type === "google.calendar-list") { + await handleCalendarListMessage(request); + } else if (token.type === "google.calendar-events") { + await handleEventsMessage(request); + } + + return new Response(null, { status: 204 }); + }; return { POST, diff --git a/packages/auth/src/storage.ts b/packages/auth/src/storage.ts new file mode 100644 index 00000000..6f8671b5 --- /dev/null +++ b/packages/auth/src/storage.ts @@ -0,0 +1,26 @@ +import { SecondaryStorage } from "better-auth"; +import { Redis } from "@upstash/redis"; +import { env } from "@repo/env/server"; + +const redis = new Redis({ + url: env.UPSTASH_REDIS_REST_URL, + token: env.UPSTASH_REDIS_REST_TOKEN, +}); + +export const secondaryStorage: SecondaryStorage = { + get: async (key) => { + const value = await redis.get(key); + + return value ?? null; + }, + set: async (key, value, ttl) => { + if (ttl) { + await redis.set(key, value, { ex: ttl }); + } else { + await redis.set(key, value); + } + }, + delete: async (key) => { + await redis.del(key); + } +} \ No newline at end of file From 9a85d446ac1517b2115c3baf650e5f534f5cc793 Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Thu, 3 Jul 2025 23:50:32 +0200 Subject: [PATCH 03/10] wip --- .../src/providers/google-calendar/channel.ts | 277 +++++------------- .../google-calendar/channels/calendars.ts | 58 ++++ .../google-calendar/channels/events.ts | 125 ++++++++ .../google-calendar/channels/headers.ts | 40 +++ packages/auth/src/storage.ts | 7 +- packages/db/src/schema/calendar.ts | 12 +- packages/db/src/schema/channel.ts | 19 +- packages/db/src/schema/index.ts | 1 + packages/db/src/schema/resource.ts | 13 +- 9 files changed, 337 insertions(+), 215 deletions(-) create mode 100644 packages/api/src/providers/google-calendar/channels/calendars.ts create mode 100644 packages/api/src/providers/google-calendar/channels/events.ts create mode 100644 packages/api/src/providers/google-calendar/channels/headers.ts diff --git a/packages/api/src/providers/google-calendar/channel.ts b/packages/api/src/providers/google-calendar/channel.ts index 2fa964d5..4ea5d1d9 100644 --- a/packages/api/src/providers/google-calendar/channel.ts +++ b/packages/api/src/providers/google-calendar/channel.ts @@ -1,9 +1,10 @@ +import { Account, auth } from "@repo/auth/server"; import { db } from "@repo/db"; import { GoogleCalendar } from "@repo/google-calendar"; -import { eq } from "drizzle-orm"; -import { calendars, events, account as accounts } from "@repo/db/schema"; -import { parseGoogleCalendarEvent, parseGoogleCalendarCalendarListEntry } from "./utils"; -import { Temporal } from "temporal-polyfill"; + +import { parseHeaders } from "./channels/headers"; +import { handleCalendarListMessage } from "./channels/calendars"; +import { handleEventsMessage } from "./channels/events"; const DEFAULT_TTL = "3600"; @@ -13,14 +14,18 @@ interface SubscribeCalendarListOptions { webhookUrl: string; } -export async function subscribeCalendarList({ client, subscriptionId, webhookUrl }: SubscribeCalendarListOptions) { +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 - } + ttl: DEFAULT_TTL, + }, }); return { @@ -38,14 +43,19 @@ interface SubscribeEventsOptions { webhookUrl: string; } -export async function subscribeEvents({ client, calendarId, subscriptionId, webhookUrl }: SubscribeEventsOptions) { +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 - } + ttl: DEFAULT_TTL, + }, }); return { @@ -63,223 +73,90 @@ interface UnsubscribeOptions { resourceId: string; } -export async function unsubscribe({ client, subscriptionId, resourceId }: UnsubscribeOptions) { +export async function unsubscribe({ + client, + subscriptionId, + resourceId, +}: UnsubscribeOptions) { await client.stopWatching.stopWatching({ id: subscriptionId, resourceId, }); } -// Utility: parse the `X-Goog-Channel-Token` header. We expect a base64 encoded JSON string -// of the shape: { type: "google.calendar-list" | "google.calendar-events", accountId: string, calendarId?: string } -function parseToken(token?: string | null): - | ({ - type: "google.calendar-list" | "google.calendar-events"; - accountId: string; - calendarId?: string; - }) - | null { - if (!token) return null; - - try { - const decoded = Buffer.from(token, "base64").toString("utf8"); - return JSON.parse(decoded); - } catch { - return null; - } +interface FindChannelOptions { + channelId: string; } -// Helper to convert Temporal.* values to javascript Date for database persistence -function temporalToDate( - value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, -): Date { - if (value instanceof Temporal.Instant) { - return new Date(value.epochMilliseconds); - } - - if (value instanceof Temporal.ZonedDateTime) { - return new Date(value.toInstant().epochMilliseconds); - } - - // Temporal.PlainDate - return new Date(value.toString()); +async function findChannel({ channelId }: FindChannelOptions) { + return await db.query.channel.findFirst({ + where: (table, { eq }) => eq(table.id, channelId), + }); } -export async function handleCalendarListMessage(request: Request) { - const token = parseToken(request.headers.get("X-Goog-Channel-Token")); - if (!token || token.type !== "google.calendar-list") { - return; - } - - // Locate account so that we can talk to Google on its behalf - const [accountRow] = await db - .select() - .from(accounts) - .where(eq(accounts.id, token.accountId)); - - if (!accountRow?.accessToken) return; - - const client = new GoogleCalendar({ accessToken: accountRow.accessToken }); - - // Fetch latest calendar list - const { items } = await client.users.me.calendarList.list(); - if (!items) return; - - // Upsert calendars - for (const item of items) { - if (!item.id) continue; - - // Use utils parser to transform API response into internal Calendar type - const parsedCalendar = parseGoogleCalendarCalendarListEntry({ - accountId: accountRow.id, - entry: item, - }); - - const values = { - id: parsedCalendar.id, - name: parsedCalendar.name, - description: parsedCalendar.description ?? null, - timeZone: parsedCalendar.timeZone ?? null, - primary: parsedCalendar.primary, - color: parsedCalendar.color ?? null, - calendarId: parsedCalendar.id, - providerId: "google" as const, - accountId: parsedCalendar.accountId, - updatedAt: new Date(), - }; - - const calendarIdStr = parsedCalendar.id; - - const existing = await db.query.calendars.findFirst({ - where: (table, { eq }) => eq(table.id, calendarIdStr), - }); +export async function withAccessToken(account: Account) { + const { accessToken } = await auth.api.getAccessToken({ + body: { + providerId: account.providerId, + accountId: account.id, + userId: account.userId, + }, + }); - if (existing) { - await db.update(calendars).set(values).where(eq(calendars.id, calendarIdStr)); - } else { - await db.insert(calendars).values({ - ...values, - createdAt: new Date(), - }); - } - } + return { + ...account, + accessToken: accessToken ?? account.accessToken, + }; } -export async function handleEventsMessage(request: Request) { - const token = parseToken(request.headers.get("X-Goog-Channel-Token")); - if (!token || token.type !== "google.calendar-events" || !token.calendarId) { - return; - } - - const calendarId = token.calendarId; - - // Locate account & calendar rows - const [accountRow] = await db - .select() - .from(accounts) - .where(eq(accounts.id, token.accountId)); - if (!accountRow?.accessToken) return; - - const [calendarRow] = await db - .select() - .from(calendars) - .where(eq(calendars.id, calendarId)); - - const client = new GoogleCalendar({ accessToken: accountRow.accessToken }); +interface FindAccountOptions { + accountId: string; +} - const listParams: Record = { - singleEvents: true, - showDeleted: true, - maxResults: 2500, - }; +async function findAccount({ accountId }: FindAccountOptions) { + const account = await db.query.account.findFirst({ + where: (table, { eq }) => eq(table.id, accountId), + }); - if (calendarRow?.syncToken) { - listParams["syncToken"] = calendarRow.syncToken; + if (!account) { + throw new Error(`Account ${accountId} not found`); } - const response = await client.calendars.events.list(calendarId, listParams); - - // Persist nextSyncToken if present - if (response.nextSyncToken) { - await db - .update(calendars) - .set({ syncToken: response.nextSyncToken, updatedAt: new Date() }) - .where(eq(calendars.id, calendarId)); - } + return await withAccessToken(account); +} - const items = response.items ?? []; - if (items.length === 0) return; +export async function handler() { + const POST = async (request: Request) => { + const headers = await parseHeaders({ headers: request.headers }); - const calendarObj = { - id: calendarId, - name: calendarRow?.name ?? "", - readOnly: calendarRow?.primary === false, - providerId: "google" as const, - accountId: accountRow.id, - timeZone: calendarRow?.timeZone ?? undefined, - primary: Boolean(calendarRow?.primary), - color: calendarRow?.color ?? null, - } as const; + if (!headers) { + return new Response("Missing or invalid headers", { status: 400 }); + } - for (const event of items) { - if (event.status === "cancelled") { - // Delete locally - await db.delete(events).where(eq(events.id, event.id!)); - continue; + if (headers.resourceState === "sync") { + return new Response("OK", { status: 200 }); } - const parsed = parseGoogleCalendarEvent({ - calendar: calendarObj as any, // minimal calendar satisfies function - accountId: accountRow.id, - event, - }); + const channel = await findChannel({ channelId: headers.id }); - const values = { - id: parsed.id, - title: parsed.title, - description: parsed.description ?? null, - start: temporalToDate(parsed.start), - startTimeZone: "timeZone" in parsed.start ? (parsed.start as any).timeZone : null, - end: temporalToDate(parsed.end), - endTimeZone: "timeZone" in parsed.end ? (parsed.end as any).timeZone : null, - allDay: parsed.allDay, - location: parsed.location ?? null, - status: parsed.status ?? null, - url: parsed.url ?? null, - calendarId: calendarId, - providerId: "google" as const, - accountId: accountRow.id, - - } as const; + if (!channel) { + return new Response("Channel not found", { status: 404 }); + } - const existingEvent = await db.query.events.findFirst({ - where: (table, { eq }) => eq(table.id, parsed.id), - }); + const account = await findAccount({ accountId: channel.accountId }); - await db.insert(events).values(values).onConflictDoUpdate({ - target: [events.id], - set: { - ...values, - }, + if (!account.accessToken) { + return new Response("Failed to obtain a valid access token", { + status: 500, }); - } -} - -export async function handler() { - const POST = async (request: Request) => { - // Quick health-check: Google expects a 2xx response to acknowledge. - - // Decide which handler to invoke based on the token header - const token = parseToken(request.headers.get("X-Goog-Channel-Token")); - - if (!token) { - return new Response("Missing or invalid channel token", { status: 202 }); } - if (token.type === "google.calendar-list") { - await handleCalendarListMessage(request); - } else if (token.type === "google.calendar-events") { - await handleEventsMessage(request); + if (channel.type === "google.calendar") { + await handleCalendarListMessage({ channel, headers, account: account as Account & { accessToken: string } }); + } else if (channel.type === "google.event") { + await handleEventsMessage({ channel, headers, account: account as Account & { accessToken: string } }); + } else { + return new Response("Invalid channel type", { status: 400 }); } return new Response(null, { status: 204 }); @@ -288,4 +165,4 @@ export async function handler() { return { POST, }; -} \ No newline at end of file +} 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..bb749dd3 --- /dev/null +++ b/packages/api/src/providers/google-calendar/channels/calendars.ts @@ -0,0 +1,58 @@ +import { Channel, ChannelHeaders } from "./headers"; +import { GoogleCalendar } from "@repo/google-calendar"; +import { db } from "@repo/db"; +import { calendars } from "@repo/db/schema"; +import { parseGoogleCalendarCalendarListEntry } from "../utils"; +import { Account } from "@repo/auth/server"; +import { revalidateTag } from "next/cache"; + +interface HandleCalendarListMessageOptions { + channel: Channel; + headers: ChannelHeaders; + account: Account & { accessToken: string }; +} + +export async function handleCalendarListMessage({ + channel, + headers, + account, +}: HandleCalendarListMessageOptions) { + const calendar = await db.query.calendars.findFirst({ + where: (table, { eq }) => eq(table.id, channel.resourceId), + }); + + if (!calendar) { + throw new Error(`Calendar ${channel.resourceId} not found`); + } + + revalidateTag(`calendar.${calendar.accountId}.${calendar.id}`); + + // const client = new GoogleCalendar({ accessToken: account.accessToken }); + + // const { items } = await client.users.me.calendarList.list(); + // if (!items) { + // return; + // } + + // for (const item of items) { + // if (!item.id) continue; + + // const parsedCalendar = parseGoogleCalendarCalendarListEntry({ + // accountId: account.id, + // entry: item, + // }); + + // const values = { + // id: parsedCalendar.id, + // name: parsedCalendar.name, + // description: parsedCalendar.description ?? null, + // timeZone: parsedCalendar.timeZone ?? null, + // primary: parsedCalendar.primary, + // color: parsedCalendar.color ?? null, + // calendarId: parsedCalendar.id, + // providerId: "google" as const, + // accountId: parsedCalendar.accountId, + // updatedAt: new Date(), + // }; + // } +} 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..c7711c13 --- /dev/null +++ b/packages/api/src/providers/google-calendar/channels/events.ts @@ -0,0 +1,125 @@ +import { 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 { parseGoogleCalendarEvent } from "../utils"; +import { Channel, ChannelHeaders } from "./headers"; +import { CalendarEvent } from "../../interfaces"; +import { revalidateTag } from "next/cache"; + +function temporalToDate( + value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, +): Date { + if (value instanceof Temporal.Instant) { + return new Date(value.epochMilliseconds); + } + + if (value instanceof Temporal.ZonedDateTime) { + return new Date(value.toInstant().epochMilliseconds); + } + + return new Date(value.toString()); +} + +async function updateEvent(event: CalendarEvent) { + const values = { + id: event.id, + title: event.title, + description: event.description ?? null, + start: temporalToDate(event.start), + startTimeZone: + event.start instanceof Temporal.ZonedDateTime + ? event.start.timeZoneId + : null, + end: temporalToDate(event.end), + endTimeZone: + event.end instanceof Temporal.ZonedDateTime + ? event.end.timeZoneId + : null, + allDay: event.allDay, + location: event.location ?? null, + status: event.status ?? null, + url: event.url ?? null, + calendarId: event.calendarId, + providerId: "google" as const, + accountId: event.accountId, + } as const; + + await db + .insert(events) + .values(values) + .onConflictDoUpdate({ + target: [events.id], + set: { + ...values, + }, + }); +} + +async function deleteEvent(event: CalendarEvent) { + await db.delete(events).where(eq(events.id, event.id!)); +} + + +interface HandleEventsMessageOptions { + channel: Channel; + headers: ChannelHeaders; + account: Account & { accessToken: string }; +} + +export async function handleEventsMessage({ + channel, + headers, + account, +}: HandleEventsMessageOptions) { + const calendar = await db.query.calendars.findFirst({ + where: (table, { eq }) => eq(table.id, channel.resourceId), + }); + + if (!calendar) { + throw new Error(`Calendar ${channel.resourceId} not found`); + } + + revalidateTag(`calendar.events.${calendar.accountId}.${calendar.id}`); + + // const client = new GoogleCalendar({ accessToken: account.accessToken }); + + // const response = await client.calendars.events.list(calendar.calendarId, { + // singleEvents: true, + // showDeleted: true, + // maxResults: 2500, + // syncToken: calendar.syncToken ?? undefined, + // }); + + // if (response.nextSyncToken) { + // await db + // .update(calendars) + // .set({ syncToken: response.nextSyncToken, updatedAt: new Date() }) + // .where(eq(calendars.id, calendar.id)); + // } + + // const items = response.items ?? []; + + // if (items.length === 0) { + // return; + // } + + // for (const event of items) { + // if (event.status === "cancelled") { + // await db.delete(events).where(eq(events.id, event.id!)); + // continue; + // } + + // const parsedEvent = parseGoogleCalendarEvent({ + // calendar, + // accountId: account.id, + // event, + // }); + + // await updateEvent(parsedEvent); + // } +} 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..9f0cb64b --- /dev/null +++ b/packages/api/src/providers/google-calendar/channels/headers.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +import { 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/auth/src/storage.ts b/packages/auth/src/storage.ts index 6f8671b5..66f833b0 100644 --- a/packages/auth/src/storage.ts +++ b/packages/auth/src/storage.ts @@ -1,5 +1,6 @@ -import { SecondaryStorage } from "better-auth"; import { Redis } from "@upstash/redis"; +import { SecondaryStorage } from "better-auth"; + import { env } from "@repo/env/server"; const redis = new Redis({ @@ -22,5 +23,5 @@ export const secondaryStorage: SecondaryStorage = { }, delete: async (key) => { await redis.del(key); - } -} \ No newline at end of file + }, +}; diff --git a/packages/db/src/schema/calendar.ts b/packages/db/src/schema/calendar.ts index 14d8192c..ca355224 100644 --- a/packages/db/src/schema/calendar.ts +++ b/packages/db/src/schema/calendar.ts @@ -1,12 +1,14 @@ import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + import { newId } from "../lib/id"; export const calendar = pgTable("calendar", { - id: text().primaryKey().$default(() => newId("calendar")), + id: text() + .primaryKey() + .$default(() => newId("calendar")), providerId: text({ enum: ["google", "microsoft"] }).notNull(), - connectionId: text() - .notNull(), - // .references(() => connection.id, { onDelete: "cascade" }), + connectionId: text().notNull(), + // .references(() => connection.id, { onDelete: "cascade" }), name: text().notNull(), description: text(), @@ -14,7 +16,7 @@ export const calendar = pgTable("calendar", { owner: text().notNull(), access: text({ enum: ["owner", "write", "read", "availability"] }).notNull(), - + timeZone: text(), enabled: boolean().notNull().default(true), diff --git a/packages/db/src/schema/channel.ts b/packages/db/src/schema/channel.ts index ae537729..36924d16 100644 --- a/packages/db/src/schema/channel.ts +++ b/packages/db/src/schema/channel.ts @@ -1,18 +1,31 @@ import { pgTable, text, timestamp } 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")), + 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().$onUpdate(() => new Date()), + updatedAt: timestamp({ withTimezone: true }) + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), }); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 96a59d8b..99c36246 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,3 +1,4 @@ export * from "./auth"; export * from "./calendars"; export * from "./waitlist"; +export * from "./channel"; diff --git a/packages/db/src/schema/resource.ts b/packages/db/src/schema/resource.ts index 1f78d68e..1436cc5d 100644 --- a/packages/db/src/schema/resource.ts +++ b/packages/db/src/schema/resource.ts @@ -1,11 +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().$default(() => newId("resource")), + id: text() + .primaryKey() + .$default(() => newId("resource")), providerId: text({ enum: ["google"] }).notNull(), - createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp({ withTimezone: true }).defaultNow().notNull().$onUpdate(() => new Date()), -}); \ No newline at end of file + updatedAt: timestamp({ withTimezone: true }) + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), +}); From c7bb7511e39e00f5679b18f3dd34f8d38f9a6f33 Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Mon, 11 Aug 2025 21:22:59 +0200 Subject: [PATCH 04/10] wip --- packages/api/src/interfaces/events.ts | 4 + .../google-calendar/channels/calendars.ts | 2 +- .../google-calendar/channels/events.ts | 2 +- packages/db/src/schema/calendar.ts | 28 ----- packages/db/src/schema/calendars.ts | 112 ++++++++++++++++-- 5 files changed, 109 insertions(+), 39 deletions(-) delete mode 100644 packages/db/src/schema/calendar.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/google-calendar/channels/calendars.ts b/packages/api/src/providers/google-calendar/channels/calendars.ts index bb749dd3..04ac549a 100644 --- a/packages/api/src/providers/google-calendar/channels/calendars.ts +++ b/packages/api/src/providers/google-calendar/channels/calendars.ts @@ -2,7 +2,7 @@ import { Channel, ChannelHeaders } from "./headers"; import { GoogleCalendar } from "@repo/google-calendar"; import { db } from "@repo/db"; import { calendars } from "@repo/db/schema"; -import { parseGoogleCalendarCalendarListEntry } from "../utils"; +import { parseGoogleCalendarCalendarListEntry } from "../../calendars/google-calendar/calendars"; import { Account } from "@repo/auth/server"; import { revalidateTag } from "next/cache"; diff --git a/packages/api/src/providers/google-calendar/channels/events.ts b/packages/api/src/providers/google-calendar/channels/events.ts index c7711c13..33e80f8e 100644 --- a/packages/api/src/providers/google-calendar/channels/events.ts +++ b/packages/api/src/providers/google-calendar/channels/events.ts @@ -6,7 +6,7 @@ import { db } from "@repo/db"; import { calendars, events } from "@repo/db/schema"; import { GoogleCalendar } from "@repo/google-calendar"; -import { parseGoogleCalendarEvent } from "../utils"; +import { parseGoogleCalendarEvent } from "../../calendars/google-calendar/events"; import { Channel, ChannelHeaders } from "./headers"; import { CalendarEvent } from "../../interfaces"; import { revalidateTag } from "next/cache"; diff --git a/packages/db/src/schema/calendar.ts b/packages/db/src/schema/calendar.ts deleted file mode 100644 index ca355224..00000000 --- a/packages/db/src/schema/calendar.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; - -import { newId } from "../lib/id"; - -export const calendar = pgTable("calendar", { - id: text() - .primaryKey() - .$default(() => newId("calendar")), - providerId: text({ enum: ["google", "microsoft"] }).notNull(), - connectionId: text().notNull(), - // .references(() => connection.id, { onDelete: "cascade" }), - - name: text().notNull(), - description: text(), - - owner: text().notNull(), - - access: text({ enum: ["owner", "write", "read", "availability"] }).notNull(), - - timeZone: text(), - - enabled: boolean().notNull().default(true), - - syncToken: text(), - - createdAt: timestamp().defaultNow().notNull(), - updatedAt: timestamp().defaultNow().notNull(), -}); diff --git a/packages/db/src/schema/calendars.ts b/packages/db/src/schema/calendars.ts index f2542558..f7b4e55a 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(), @@ -40,14 +43,6 @@ export const calendars = pgTable( (table) => [index("calendar_account_idx").on(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 +61,15 @@ 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,6 +89,7 @@ export const events = pgTable( }, (table) => [ index("event_account_idx").on(table.accountId), + index("event_recurrence_idx").on(table.recurrenceId), uniqueIndex("event_account_calendar_idx").on( table.accountId, table.calendarId, @@ -95,7 +97,87 @@ export const events = pgTable( ], ); -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 +186,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], + }), })); From 6f7642984a7a4f20d088bb8118667f4057d8efa9 Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Thu, 28 Aug 2025 00:41:28 +0200 Subject: [PATCH 05/10] wip --- .../api/google/channels/calendars/route.ts | 3 + .../app/api/google/channels/events/route.ts | 3 + packages/api/package.json | 1 + .../providers/calendars/google-calendar.ts | 15 +- .../calendars/google-calendar/events.ts | 11 +- .../providers/calendars/microsoft-calendar.ts | 16 +- .../calendars/microsoft-calendar/events.ts | 11 +- .../src/providers/google-calendar/channel.ts | 19 ++- .../google-calendar/channels/calendars.ts | 107 +++++++----- .../google-calendar/channels/events.ts | 128 ++++++-------- .../api/src/providers/google-calendar/sync.ts | 159 ++++++++++++++++++ packages/db/src/schema/auth.ts | 1 + packages/db/src/schema/calendars.ts | 16 +- 13 files changed, 343 insertions(+), 147 deletions(-) create mode 100644 apps/web/src/app/api/google/channels/calendars/route.ts create mode 100644 apps/web/src/app/api/google/channels/events/route.ts create mode 100644 packages/api/src/providers/google-calendar/sync.ts 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/providers/calendars/google-calendar.ts b/packages/api/src/providers/calendars/google-calendar.ts index 38ca56c0..ca1f3552 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: false, 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..f226a301 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, }); }); } 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 index 4ea5d1d9..9348ed1d 100644 --- a/packages/api/src/providers/google-calendar/channel.ts +++ b/packages/api/src/providers/google-calendar/channel.ts @@ -2,9 +2,9 @@ import { Account, auth } from "@repo/auth/server"; import { db } from "@repo/db"; import { GoogleCalendar } from "@repo/google-calendar"; -import { parseHeaders } from "./channels/headers"; import { handleCalendarListMessage } from "./channels/calendars"; import { handleEventsMessage } from "./channels/events"; +import { parseHeaders } from "./channels/headers"; const DEFAULT_TTL = "3600"; @@ -29,7 +29,7 @@ export async function subscribeCalendarList({ }); return { - type: "google.calendar-list", + type: "google.calendar", subscriptionId, resourceId: response.resourceId!, expiresAt: new Date(response.expiration!), @@ -59,7 +59,7 @@ export async function subscribeEvents({ }); return { - type: "google.calendar-events", + type: "google.event", subscriptionId, calendarId, resourceId: response.resourceId!, @@ -105,7 +105,7 @@ export async function withAccessToken(account: Account) { return { ...account, - accessToken: accessToken ?? account.accessToken, + accessToken: accessToken ?? account.accessToken!, }; } @@ -125,7 +125,7 @@ async function findAccount({ accountId }: FindAccountOptions) { return await withAccessToken(account); } -export async function handler() { +export function handler() { const POST = async (request: Request) => { const headers = await parseHeaders({ headers: request.headers }); @@ -152,9 +152,14 @@ export async function handler() { } if (channel.type === "google.calendar") { - await handleCalendarListMessage({ channel, headers, account: account as Account & { accessToken: string } }); + await handleCalendarListMessage({ + account, + }); } else if (channel.type === "google.event") { - await handleEventsMessage({ channel, headers, account: account as Account & { accessToken: string } }); + await handleEventsMessage({ + calendarId: channel.resourceId, + account, + }); } else { return new Response("Invalid channel type", { status: 400 }); } diff --git a/packages/api/src/providers/google-calendar/channels/calendars.ts b/packages/api/src/providers/google-calendar/channels/calendars.ts index 04ac549a..ac83fc0e 100644 --- a/packages/api/src/providers/google-calendar/channels/calendars.ts +++ b/packages/api/src/providers/google-calendar/channels/calendars.ts @@ -1,58 +1,77 @@ -import { Channel, ChannelHeaders } from "./headers"; -import { GoogleCalendar } from "@repo/google-calendar"; +import { revalidateTag } from "next/cache"; +import { and, eq } from "drizzle-orm"; + +import { Account } from "@repo/auth/server"; import { db } from "@repo/db"; -import { calendars } from "@repo/db/schema"; +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 { Account } from "@repo/auth/server"; -import { revalidateTag } from "next/cache"; +import { GoogleCalendarCalendarListEntry } from "../../calendars/google-calendar/interfaces"; +import { syncCalendarList } from "../sync"; interface HandleCalendarListMessageOptions { - channel: Channel; - headers: ChannelHeaders; - account: Account & { accessToken: string }; + account: Account; } export async function handleCalendarListMessage({ - channel, - headers, account, }: HandleCalendarListMessageOptions) { - const calendar = await db.query.calendars.findFirst({ - where: (table, { eq }) => eq(table.id, channel.resourceId), + 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 (!calendar) { - throw new Error(`Calendar ${channel.resourceId} not found`); + if (nextSyncToken) { + await db + .update(accounts) + .set({ calendarListSyncToken: nextSyncToken }) + .where(eq(accounts.id, account.id)); } - revalidateTag(`calendar.${calendar.accountId}.${calendar.id}`); - - // const client = new GoogleCalendar({ accessToken: account.accessToken }); - - // const { items } = await client.users.me.calendarList.list(); - // if (!items) { - // return; - // } - - // for (const item of items) { - // if (!item.id) continue; - - // const parsedCalendar = parseGoogleCalendarCalendarListEntry({ - // accountId: account.id, - // entry: item, - // }); - - // const values = { - // id: parsedCalendar.id, - // name: parsedCalendar.name, - // description: parsedCalendar.description ?? null, - // timeZone: parsedCalendar.timeZone ?? null, - // primary: parsedCalendar.primary, - // color: parsedCalendar.color ?? null, - // calendarId: parsedCalendar.id, - // providerId: "google" as const, - // accountId: parsedCalendar.accountId, - // updatedAt: new Date(), - // }; - // } + revalidateTag(`calendars.${account.id}`); +} + +async function upsertCalendar(calendar: Calendar) { + await db + .insert(calendars) + .values({ + ...calendar, + calendarId: calendar.id, + }) + .onConflictDoUpdate({ + target: [calendars.id], + set: calendar, + }); } diff --git a/packages/api/src/providers/google-calendar/channels/events.ts b/packages/api/src/providers/google-calendar/channels/events.ts index 33e80f8e..6f017f93 100644 --- a/packages/api/src/providers/google-calendar/channels/events.ts +++ b/packages/api/src/providers/google-calendar/channels/events.ts @@ -1,3 +1,4 @@ +import { revalidateTag } from "next/cache"; import { eq } from "drizzle-orm"; import { Temporal } from "temporal-polyfill"; @@ -6,44 +7,29 @@ import { db } from "@repo/db"; import { calendars, events } from "@repo/db/schema"; import { GoogleCalendar } from "@repo/google-calendar"; +import { CalendarEvent } from "../../../interfaces"; import { parseGoogleCalendarEvent } from "../../calendars/google-calendar/events"; -import { Channel, ChannelHeaders } from "./headers"; -import { CalendarEvent } from "../../interfaces"; -import { revalidateTag } from "next/cache"; - -function temporalToDate( - value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, -): Date { - if (value instanceof Temporal.Instant) { - return new Date(value.epochMilliseconds); - } - - if (value instanceof Temporal.ZonedDateTime) { - return new Date(value.toInstant().epochMilliseconds); - } - - return new Date(value.toString()); -} +import type { GoogleCalendarEvent } from "../../calendars/google-calendar/interfaces"; +import { syncEvents } from "../sync"; +import { toDate } from "@repo/temporal"; async function updateEvent(event: CalendarEvent) { const values = { id: event.id, title: event.title, - description: event.description ?? null, - start: temporalToDate(event.start), + description: event.description, + start: toDate(event.start, { timeZone: "UTC" }), startTimeZone: - event.start instanceof Temporal.ZonedDateTime + event.start instanceof Temporal.ZonedDateTime ? event.start.timeZoneId : null, - end: temporalToDate(event.end), + end: toDate(event.end, { timeZone: "UTC" }), endTimeZone: - event.end instanceof Temporal.ZonedDateTime - ? event.end.timeZoneId - : null, + event.end instanceof Temporal.ZonedDateTime ? event.end.timeZoneId : null, allDay: event.allDay, - location: event.location ?? null, - status: event.status ?? null, - url: event.url ?? null, + location: event.location, + status: event.status, + url: event.url, calendarId: event.calendarId, providerId: "google" as const, accountId: event.accountId, @@ -60,66 +46,60 @@ async function updateEvent(event: CalendarEvent) { }); } -async function deleteEvent(event: CalendarEvent) { - await db.delete(events).where(eq(events.id, event.id!)); -} - - interface HandleEventsMessageOptions { - channel: Channel; - headers: ChannelHeaders; - account: Account & { accessToken: string }; + calendarId: string; + account: Account; } export async function handleEventsMessage({ - channel, - headers, + calendarId, account, }: HandleEventsMessageOptions) { const calendar = await db.query.calendars.findFirst({ - where: (table, { eq }) => eq(table.id, channel.resourceId), + where: (table, { eq }) => eq(table.id, calendarId), }); if (!calendar) { - throw new Error(`Calendar ${channel.resourceId} not found`); + throw new Error(`Calendar ${calendarId} not found`); } revalidateTag(`calendar.events.${calendar.accountId}.${calendar.id}`); - // const client = new GoogleCalendar({ accessToken: account.accessToken }); - - // const response = await client.calendars.events.list(calendar.calendarId, { - // singleEvents: true, - // showDeleted: true, - // maxResults: 2500, - // syncToken: calendar.syncToken ?? undefined, - // }); - - // if (response.nextSyncToken) { - // await db - // .update(calendars) - // .set({ syncToken: response.nextSyncToken, updatedAt: new Date() }) - // .where(eq(calendars.id, calendar.id)); - // } - - // const items = response.items ?? []; - - // if (items.length === 0) { - // return; - // } - - // for (const event of items) { - // if (event.status === "cancelled") { - // await db.delete(events).where(eq(events.id, event.id!)); - // continue; - // } - - // const parsedEvent = parseGoogleCalendarEvent({ - // calendar, - // accountId: account.id, - // event, - // }); + 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(eq(events.id, eventId)); + }, + onUpsert: async (event: GoogleCalendarEvent) => { + const parsedEvent = parseGoogleCalendarEvent({ + calendarId: calendar.id, + readOnly: calendar.readOnly, + accountId: account.id, + event, + }); + + await updateEvent(parsedEvent); + }, + }); - // await updateEvent(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/sync.ts b/packages/api/src/providers/google-calendar/sync.ts new file mode 100644 index 00000000..1b4e913f --- /dev/null +++ b/packages/api/src/providers/google-calendar/sync.ts @@ -0,0 +1,159 @@ +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/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 f7b4e55a..ca222fd3 100644 --- a/packages/db/src/schema/calendars.ts +++ b/packages/db/src/schema/calendars.ts @@ -1,4 +1,4 @@ -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { boolean, index, @@ -69,7 +69,9 @@ export const events = pgTable( syncToken: text("sync_token"), recurringEventId: text("recurring_event_id"), - recurrenceId: text("recurrence_id").references(() => recurrence.id, { onDelete: "set null" }), + recurrenceId: text("recurrence_id").references(() => recurrence.id, { + onDelete: "set null", + }), calendarId: text("calendar_id") .notNull() @@ -102,7 +104,15 @@ export const recurrence = pgTable("recurrence", { // Core recurrence fields freq: text("freq", { - enum: ["SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"], + enum: [ + "SECONDLY", + "MINUTELY", + "HOURLY", + "DAILY", + "WEEKLY", + "MONTHLY", + "YEARLY", + ], }).notNull(), interval: integer("interval").default(1), count: integer("count"), From 59b26b6dbc8045a31aa32a47251b27be0a07b6f8 Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Thu, 28 Aug 2025 00:56:54 +0200 Subject: [PATCH 06/10] wip --- .../src/providers/google-calendar/channel.ts | 33 ++++++++++++++++--- packages/db/src/schema/calendars.ts | 5 +-- packages/db/src/schema/channel.ts | 2 +- packages/db/src/schema/resource.ts | 2 +- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/api/src/providers/google-calendar/channel.ts b/packages/api/src/providers/google-calendar/channel.ts index 9348ed1d..2fa06c2e 100644 --- a/packages/api/src/providers/google-calendar/channel.ts +++ b/packages/api/src/providers/google-calendar/channel.ts @@ -143,6 +143,15 @@ export function handler() { return new Response("Channel not found", { status: 404 }); } + // Basic authenticity checks: verify channel token (if present) and resource id match + 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) { @@ -156,10 +165,26 @@ export function handler() { account, }); } else if (channel.type === "google.event") { - await handleEventsMessage({ - calendarId: channel.resourceId, - account, - }); + // Extract calendarId from the Google Resource URI + const extractCalendarId = (uri: string | undefined): string | null => { + if (!uri) return null; + try { + const url = new URL(uri); + const parts = url.pathname.split("/"); + const idx = parts.indexOf("calendars"); + if (idx !== -1 && parts[idx + 1]) { + return decodeURIComponent(parts[idx + 1]!); + } + } catch {} + return null; + }; + + 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 }); } diff --git a/packages/db/src/schema/calendars.ts b/packages/db/src/schema/calendars.ts index ca222fd3..de68959a 100644 --- a/packages/db/src/schema/calendars.ts +++ b/packages/db/src/schema/calendars.ts @@ -92,10 +92,7 @@ export const events = pgTable( (table) => [ index("event_account_idx").on(table.accountId), index("event_recurrence_idx").on(table.recurrenceId), - uniqueIndex("event_account_calendar_idx").on( - table.accountId, - table.calendarId, - ), + index("event_account_calendar_idx").on(table.accountId, table.calendarId), ], ); diff --git a/packages/db/src/schema/channel.ts b/packages/db/src/schema/channel.ts index 36924d16..7c81c139 100644 --- a/packages/db/src/schema/channel.ts +++ b/packages/db/src/schema/channel.ts @@ -27,5 +27,5 @@ export const channel = pgTable("channel", { updatedAt: timestamp({ withTimezone: true }) .defaultNow() .notNull() - .$onUpdate(() => new Date()), + .$onUpdateFn(() => new Date()), }); diff --git a/packages/db/src/schema/resource.ts b/packages/db/src/schema/resource.ts index 1436cc5d..a692eb66 100644 --- a/packages/db/src/schema/resource.ts +++ b/packages/db/src/schema/resource.ts @@ -12,5 +12,5 @@ export const resource = pgTable("resource", { updatedAt: timestamp({ withTimezone: true }) .defaultNow() .notNull() - .$onUpdate(() => new Date()), + .$onUpdateFn(() => new Date()), }); From 2503268c4d8a3ce482d202a6d0cfd18a609a7bda Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Thu, 28 Aug 2025 02:26:41 +0200 Subject: [PATCH 07/10] wip --- .../providers/calendars/microsoft-calendar.ts | 5 +- .../google-calendar/channels/calendars.ts | 29 +++---- .../google-calendar/channels/events.ts | 9 +-- .../providers/google-calendar/subscribe.ts | 79 +++++++++++++++++++ .../api/src/providers/google-calendar/sync.ts | 4 +- .../src/providers/google-calendar/utils.ts | 0 6 files changed, 99 insertions(+), 27 deletions(-) create mode 100644 packages/api/src/providers/google-calendar/subscribe.ts delete mode 100644 packages/api/src/providers/google-calendar/utils.ts diff --git a/packages/api/src/providers/calendars/microsoft-calendar.ts b/packages/api/src/providers/calendars/microsoft-calendar.ts index f226a301..06626e07 100644 --- a/packages/api/src/providers/calendars/microsoft-calendar.ts +++ b/packages/api/src/providers/calendars/microsoft-calendar.ts @@ -233,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 @@ -253,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, @@ -263,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/google-calendar/channels/calendars.ts b/packages/api/src/providers/google-calendar/channels/calendars.ts index ac83fc0e..f56776b9 100644 --- a/packages/api/src/providers/google-calendar/channels/calendars.ts +++ b/packages/api/src/providers/google-calendar/channels/calendars.ts @@ -1,4 +1,3 @@ -import { revalidateTag } from "next/cache"; import { and, eq } from "drizzle-orm"; import { Account } from "@repo/auth/server"; @@ -11,6 +10,19 @@ import { parseGoogleCalendarCalendarListEntry } from "../../calendars/google-cal 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.id], + set: calendar, + }); +} + interface HandleCalendarListMessageOptions { account: Account; } @@ -59,19 +71,4 @@ export async function handleCalendarListMessage({ .set({ calendarListSyncToken: nextSyncToken }) .where(eq(accounts.id, account.id)); } - - revalidateTag(`calendars.${account.id}`); -} - -async function upsertCalendar(calendar: Calendar) { - await db - .insert(calendars) - .values({ - ...calendar, - calendarId: calendar.id, - }) - .onConflictDoUpdate({ - target: [calendars.id], - set: calendar, - }); } diff --git a/packages/api/src/providers/google-calendar/channels/events.ts b/packages/api/src/providers/google-calendar/channels/events.ts index 6f017f93..0aed89fb 100644 --- a/packages/api/src/providers/google-calendar/channels/events.ts +++ b/packages/api/src/providers/google-calendar/channels/events.ts @@ -1,4 +1,3 @@ -import { revalidateTag } from "next/cache"; import { eq } from "drizzle-orm"; import { Temporal } from "temporal-polyfill"; @@ -6,14 +5,14 @@ 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"; -import { toDate } from "@repo/temporal"; -async function updateEvent(event: CalendarEvent) { +async function upsertEvent(event: CalendarEvent) { const values = { id: event.id, title: event.title, @@ -63,8 +62,6 @@ export async function handleEventsMessage({ throw new Error(`Calendar ${calendarId} not found`); } - revalidateTag(`calendar.events.${calendar.accountId}.${calendar.id}`); - const client = new GoogleCalendar({ accessToken: account.accessToken! }); const { nextSyncToken } = await syncEvents({ @@ -92,7 +89,7 @@ export async function handleEventsMessage({ event, }); - await updateEvent(parsedEvent); + await upsertEvent(parsedEvent); }, }); 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..28d749a5 --- /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(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(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 index 1b4e913f..8504021e 100644 --- a/packages/api/src/providers/google-calendar/sync.ts +++ b/packages/api/src/providers/google-calendar/sync.ts @@ -32,9 +32,7 @@ export async function syncCalendarList({ do { try { const calendarList = await client.users.me.calendarList.list({ - ...(syncToken && !forceFullSync - ? { syncToken } - : {}), + ...(syncToken && !forceFullSync ? { syncToken } : {}), pageToken, maxResults: 250, showHidden: false, 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 From 798af0442bba78b4932e3aeba099ddd83757251a Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Thu, 28 Aug 2025 02:32:01 +0200 Subject: [PATCH 08/10] wip --- .../src/providers/google-calendar/channel.ts | 105 ++---------------- 1 file changed, 11 insertions(+), 94 deletions(-) diff --git a/packages/api/src/providers/google-calendar/channel.ts b/packages/api/src/providers/google-calendar/channel.ts index 2fa06c2e..82673748 100644 --- a/packages/api/src/providers/google-calendar/channel.ts +++ b/packages/api/src/providers/google-calendar/channel.ts @@ -1,89 +1,10 @@ import { Account, auth } from "@repo/auth/server"; import { db } from "@repo/db"; -import { GoogleCalendar } from "@repo/google-calendar"; import { handleCalendarListMessage } from "./channels/calendars"; import { handleEventsMessage } from "./channels/events"; import { parseHeaders } from "./channels/headers"; -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", - subscriptionId, - resourceId: response.resourceId!, - expiresAt: new Date(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", - subscriptionId, - calendarId, - resourceId: response.resourceId!, - expiresAt: new Date(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, - }); -} - interface FindChannelOptions { channelId: string; } @@ -143,7 +64,6 @@ export function handler() { return new Response("Channel not found", { status: 404 }); } - // Basic authenticity checks: verify channel token (if present) and resource id match if (headers.token && headers.token !== channel.token) { return new Response("Invalid channel token", { status: 401 }); } @@ -165,21 +85,8 @@ export function handler() { account, }); } else if (channel.type === "google.event") { - // Extract calendarId from the Google Resource URI - const extractCalendarId = (uri: string | undefined): string | null => { - if (!uri) return null; - try { - const url = new URL(uri); - const parts = url.pathname.split("/"); - const idx = parts.indexOf("calendars"); - if (idx !== -1 && parts[idx + 1]) { - return decodeURIComponent(parts[idx + 1]!); - } - } catch {} - return null; - }; - const calendarId = extractCalendarId(headers.resourceUri); + if (!calendarId) { return new Response("Missing calendar id", { status: 400 }); } @@ -196,3 +103,13 @@ export function handler() { POST, }; } + +function extractCalendarId(uri: string): string | null { + const match = /\/calendars\/([^/?#]+)/.exec(uri); + + if (!match) { + return null; + } + + return decodeURIComponent(match[1]!); +} From 609a07be53ad335c19d8001bd28e561bc017f46f Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Fri, 29 Aug 2025 19:40:38 +0200 Subject: [PATCH 09/10] fix --- .../providers/calendars/google-calendar.ts | 2 +- .../src/providers/google-calendar/channel.ts | 2 +- .../google-calendar/channels/calendars.ts | 2 +- .../google-calendar/channels/events.ts | 17 ++++-- .../google-calendar/channels/headers.ts | 2 +- .../providers/google-calendar/subscribe.ts | 4 +- .../api/src/providers/google-calendar/sync.ts | 2 +- packages/auth/src/storage.ts | 27 ---------- packages/db/src/schema/calendars.ts | 24 +++++---- packages/db/src/schema/channel.ts | 54 +++++++++++-------- packages/db/src/schema/index.ts | 1 + packages/db/src/schema/resource.ts | 2 +- 12 files changed, 67 insertions(+), 72 deletions(-) delete mode 100644 packages/auth/src/storage.ts diff --git a/packages/api/src/providers/calendars/google-calendar.ts b/packages/api/src/providers/calendars/google-calendar.ts index ca1f3552..8ca6cbe9 100644 --- a/packages/api/src/providers/calendars/google-calendar.ts +++ b/packages/api/src/providers/calendars/google-calendar.ts @@ -278,7 +278,7 @@ export class GoogleCalendarProvider implements CalendarProvider { return parseGoogleCalendarEvent({ calendarId: destinationCalendar.id, - readOnly: false, + readOnly: destinationCalendar.readOnly, accountId: this.accountId, event: moved, }); diff --git a/packages/api/src/providers/google-calendar/channel.ts b/packages/api/src/providers/google-calendar/channel.ts index 82673748..6aceb442 100644 --- a/packages/api/src/providers/google-calendar/channel.ts +++ b/packages/api/src/providers/google-calendar/channel.ts @@ -64,7 +64,7 @@ export function handler() { return new Response("Channel not found", { status: 404 }); } - if (headers.token && headers.token !== channel.token) { + if (!headers.token || headers.token !== channel.token) { return new Response("Invalid channel token", { status: 401 }); } diff --git a/packages/api/src/providers/google-calendar/channels/calendars.ts b/packages/api/src/providers/google-calendar/channels/calendars.ts index f56776b9..d3a846ee 100644 --- a/packages/api/src/providers/google-calendar/channels/calendars.ts +++ b/packages/api/src/providers/google-calendar/channels/calendars.ts @@ -18,7 +18,7 @@ async function upsertCalendar(calendar: Calendar) { calendarId: calendar.id, }) .onConflictDoUpdate({ - target: [calendars.id], + target: [calendars.calendarId, calendars.accountId], set: calendar, }); } diff --git a/packages/api/src/providers/google-calendar/channels/events.ts b/packages/api/src/providers/google-calendar/channels/events.ts index 0aed89fb..93d2f6fd 100644 --- a/packages/api/src/providers/google-calendar/channels/events.ts +++ b/packages/api/src/providers/google-calendar/channels/events.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { Temporal } from "temporal-polyfill"; import { Account } from "@repo/auth/server"; @@ -29,16 +29,22 @@ async function upsertEvent(event: CalendarEvent) { 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], + target: [events.id, events.calendarId, events.accountId], set: { ...values, }, @@ -55,7 +61,8 @@ export async function handleEventsMessage({ account, }: HandleEventsMessageOptions) { const calendar = await db.query.calendars.findFirst({ - where: (table, { eq }) => eq(table.id, calendarId), + where: (table, { and, eq }) => + and(eq(table.id, calendarId), eq(table.accountId, account.id)), }); if (!calendar) { @@ -79,7 +86,9 @@ export async function handleEventsMessage({ }); }, onDelete: async (eventId: string) => { - await db.delete(events).where(eq(events.id, eventId)); + await db + .delete(events) + .where(and(eq(events.id, eventId), eq(events.calendarId, calendar.id))); }, onUpsert: async (event: GoogleCalendarEvent) => { const parsedEvent = parseGoogleCalendarEvent({ diff --git a/packages/api/src/providers/google-calendar/channels/headers.ts b/packages/api/src/providers/google-calendar/channels/headers.ts index 9f0cb64b..5cbb5f3d 100644 --- a/packages/api/src/providers/google-calendar/channels/headers.ts +++ b/packages/api/src/providers/google-calendar/channels/headers.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { channel } from "@repo/db/schema"; +import type { channel } from "@repo/db/schema"; export type ChannelHeaders = z.infer; export type Channel = typeof channel.$inferSelect; diff --git a/packages/api/src/providers/google-calendar/subscribe.ts b/packages/api/src/providers/google-calendar/subscribe.ts index 28d749a5..e9891b65 100644 --- a/packages/api/src/providers/google-calendar/subscribe.ts +++ b/packages/api/src/providers/google-calendar/subscribe.ts @@ -26,7 +26,7 @@ export async function subscribeCalendarList({ type: "google.calendar" as const, subscriptionId, resourceId: response.resourceId!, - expiresAt: new Date(response.expiration!), + expiresAt: new Date(Number(response.expiration!)), }; } @@ -57,7 +57,7 @@ export async function subscribeEvents({ subscriptionId, calendarId, resourceId: response.resourceId!, - expiresAt: new Date(response.expiration!), + expiresAt: new Date(Number(response.expiration!)), }; } diff --git a/packages/api/src/providers/google-calendar/sync.ts b/packages/api/src/providers/google-calendar/sync.ts index 8504021e..f1af5119 100644 --- a/packages/api/src/providers/google-calendar/sync.ts +++ b/packages/api/src/providers/google-calendar/sync.ts @@ -68,7 +68,7 @@ export async function syncCalendarList({ let nextSyncToken = await perform(); - if (!nextSyncToken) { + if (nextSyncToken) { return { nextSyncToken }; } diff --git a/packages/auth/src/storage.ts b/packages/auth/src/storage.ts deleted file mode 100644 index 66f833b0..00000000 --- a/packages/auth/src/storage.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Redis } from "@upstash/redis"; -import { SecondaryStorage } from "better-auth"; - -import { env } from "@repo/env/server"; - -const redis = new Redis({ - url: env.UPSTASH_REDIS_REST_URL, - token: env.UPSTASH_REDIS_REST_TOKEN, -}); - -export const secondaryStorage: SecondaryStorage = { - get: async (key) => { - const value = await redis.get(key); - - return value ?? null; - }, - set: async (key, value, ttl) => { - if (ttl) { - await redis.set(key, value, { ex: ttl }); - } else { - await redis.set(key, value); - } - }, - delete: async (key) => { - await redis.del(key); - }, -}; diff --git a/packages/db/src/schema/calendars.ts b/packages/db/src/schema/calendars.ts index de68959a..cb3667ef 100644 --- a/packages/db/src/schema/calendars.ts +++ b/packages/db/src/schema/calendars.ts @@ -1,14 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { - boolean, - index, - integer, - jsonb, - pgTable, - text, - timestamp, - uniqueIndex, -} from "drizzle-orm/pg-core"; +import { boolean, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; import { account } from "./auth"; @@ -40,7 +31,13 @@ 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 events = pgTable( @@ -93,6 +90,11 @@ export const events = pgTable( index("event_account_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, + ), ], ); diff --git a/packages/db/src/schema/channel.ts b/packages/db/src/schema/channel.ts index 7c81c139..e7c9d84a 100644 --- a/packages/db/src/schema/channel.ts +++ b/packages/db/src/schema/channel.ts @@ -1,31 +1,41 @@ -import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +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")), +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" }), + // 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(), + type: text({ enum: ["google.calendar", "google.event"] }).notNull(), - token: text().notNull(), - expiresAt: timestamp({ withTimezone: true }).notNull(), + token: text().notNull(), + expiresAt: timestamp({ withTimezone: true }).notNull(), - createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp({ withTimezone: true }) - .defaultNow() - .notNull() - .$onUpdateFn(() => new Date()), -}); + 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 99c36246..503940e5 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -2,3 +2,4 @@ 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 index a692eb66..43affee8 100644 --- a/packages/db/src/schema/resource.ts +++ b/packages/db/src/schema/resource.ts @@ -5,7 +5,7 @@ import { newId } from "../lib/id"; export const resource = pgTable("resource", { id: text() .primaryKey() - .$default(() => newId("resource")), + .$defaultFn(() => newId("resource")), providerId: text({ enum: ["google"] }).notNull(), createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), From 489e9f2d399e0e31681f71dfc1348c5dd573294d Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Fri, 29 Aug 2025 19:41:26 +0200 Subject: [PATCH 10/10] format --- packages/db/src/schema/calendars.ts | 13 +++++++++++-- packages/db/src/schema/channel.ts | 8 +++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/db/src/schema/calendars.ts b/packages/db/src/schema/calendars.ts index cb3667ef..10ce327d 100644 --- a/packages/db/src/schema/calendars.ts +++ b/packages/db/src/schema/calendars.ts @@ -1,5 +1,14 @@ -import { relations, sql } from "drizzle-orm"; -import { boolean, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { + boolean, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; import { account } from "./auth"; diff --git a/packages/db/src/schema/channel.ts b/packages/db/src/schema/channel.ts index e7c9d84a..65da0d74 100644 --- a/packages/db/src/schema/channel.ts +++ b/packages/db/src/schema/channel.ts @@ -1,4 +1,10 @@ -import { index, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { + index, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; import { newId } from "../lib/id"; import { account } from "./auth";