From a092624f2ca42a9d6b15dd91e9cf6dc7419262ac Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 24 Dec 2025 18:26:09 +0900 Subject: [PATCH] add participants syncing for apple-calendar --- .../devtool/seed/shared/calendar.ts | 4 +- .../components/devtool/seed/shared/event.ts | 4 +- .../metadata/participants/chip.tsx | 23 ++- .../metadata/participants/input.tsx | 1 + .../settings/calendar/configure/apple.tsx | 46 +++-- apps/desktop/src/hooks/useAutoCloser.ts | 7 +- .../src/services/apple-calendar/ctx.ts | 13 +- .../services/apple-calendar/fetch/existing.ts | 5 +- .../services/apple-calendar/fetch/incoming.ts | 14 +- .../services/apple-calendar/fetch/types.ts | 20 ++- .../src/services/apple-calendar/index.ts | 16 +- .../apple-calendar/process/events/execute.ts | 66 +++++++ .../apple-calendar/process/events/index.ts | 8 + .../process/{ => events}/sync.ts | 59 +++++-- .../apple-calendar/process/events/types.ts | 17 ++ .../apple-calendar/process/execute.ts | 32 ---- .../services/apple-calendar/process/index.ts | 4 +- .../process/participants/execute.ts | 50 ++++++ .../process/participants/index.ts | 9 + .../process/participants/sync.ts | 164 ++++++++++++++++++ .../process/participants/types.ts | 22 +++ .../services/apple-calendar/process/types.ts | 12 -- apps/desktop/src/utils/calendar.ts | 20 +++ packages/db/src/schema.ts | 2 + packages/store/src/schema-external.ts | 13 ++ 25 files changed, 517 insertions(+), 114 deletions(-) create mode 100644 apps/desktop/src/services/apple-calendar/process/events/execute.ts create mode 100644 apps/desktop/src/services/apple-calendar/process/events/index.ts rename apps/desktop/src/services/apple-calendar/process/{ => events}/sync.ts (51%) create mode 100644 apps/desktop/src/services/apple-calendar/process/events/types.ts delete mode 100644 apps/desktop/src/services/apple-calendar/process/execute.ts create mode 100644 apps/desktop/src/services/apple-calendar/process/participants/execute.ts create mode 100644 apps/desktop/src/services/apple-calendar/process/participants/index.ts create mode 100644 apps/desktop/src/services/apple-calendar/process/participants/sync.ts create mode 100644 apps/desktop/src/services/apple-calendar/process/participants/types.ts delete mode 100644 apps/desktop/src/services/apple-calendar/process/types.ts create mode 100644 apps/desktop/src/utils/calendar.ts diff --git a/apps/desktop/src/components/devtool/seed/shared/calendar.ts b/apps/desktop/src/components/devtool/seed/shared/calendar.ts index 733c2d09c6..70017cc460 100644 --- a/apps/desktop/src/components/devtool/seed/shared/calendar.ts +++ b/apps/desktop/src/components/devtool/seed/shared/calendar.ts @@ -18,10 +18,12 @@ export const createCalendar = () => { "Shared Calendar", ]); + const calendarId = id(); return { - id: id(), + id: calendarId, data: { user_id: DEFAULT_USER_ID, + tracking_id_calendar: `mock-${calendarId}`, name: template, created_at: faker.date.past({ years: 1 }).toISOString(), enabled: faker.datatype.boolean(), diff --git a/apps/desktop/src/components/devtool/seed/shared/event.ts b/apps/desktop/src/components/devtool/seed/shared/event.ts index 78463abdb6..13ba7c0f5b 100644 --- a/apps/desktop/src/components/devtool/seed/shared/event.ts +++ b/apps/desktop/src/components/devtool/seed/shared/event.ts @@ -177,10 +177,12 @@ export const createEvent = (calendar_id: string) => { description = faker.helpers.arrayElement(topics); } + const eventId = id(); return { - id: id(), + id: eventId, data: { user_id: DEFAULT_USER_ID, + tracking_id_event: `mock-${eventId}`, calendar_id, title, started_at: startsAt.toISOString(), diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx index 17b859d9ab..e86a1f1be4 100644 --- a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx @@ -13,11 +13,13 @@ export function ParticipantChip({ mappingId }: { mappingId: string }) { const assignedHumanId = details?.humanId; const sessionId = details?.sessionId; + const source = details?.source; const handleRemove = useRemoveParticipant({ mappingId, assignedHumanId, sessionId, + source, }); const handleClick = useCallback(() => { @@ -29,7 +31,7 @@ export function ParticipantChip({ mappingId }: { mappingId: string }) { } }, [assignedHumanId]); - if (!details) { + if (!details || source === "excluded") { return null; } @@ -64,6 +66,12 @@ function useParticipantDetails(mappingId: string) { mappingId, main.STORE_ID, ); + const source = main.UI.useCell( + "mapping_session_participant", + mappingId, + "source", + main.STORE_ID, + ); if (!result) { return null; @@ -81,6 +89,7 @@ function useParticipantDetails(mappingId: string) { orgId: (result.org_id as string | undefined) || undefined, orgName: result.org_name as string | undefined, sessionId: result.session_id as string, + source: source as string | undefined, }; } @@ -106,10 +115,12 @@ function useRemoveParticipant({ mappingId, assignedHumanId, sessionId, + source, }: { mappingId: string; assignedHumanId: string | undefined; sessionId: string | undefined; + source: string | undefined; }) { const store = main.UI.useStore(main.STORE_ID); const indexes = main.UI.useIndexes(main.STORE_ID); @@ -153,6 +164,12 @@ function useRemoveParticipant({ } } - store.delRow("mapping_session_participant", mappingId); - }, [store, indexes, mappingId, assignedHumanId, sessionId]); + if (source === "auto") { + store.setPartialRow("mapping_session_participant", mappingId, { + source: "excluded", + }); + } else { + store.delRow("mapping_session_participant", mappingId); + } + }, [store, indexes, mappingId, assignedHumanId, sessionId, source]); } diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx index dce951b133..6eb1a64df4 100644 --- a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx @@ -295,6 +295,7 @@ function useLinkHumanToSession( created_at: new Date().toISOString(), session_id: sessionId, human_id: p.humanId, + source: "manual", }), [userId, sessionId], main.STORE_ID, diff --git a/apps/desktop/src/components/settings/calendar/configure/apple.tsx b/apps/desktop/src/components/settings/calendar/configure/apple.tsx index 83b348cf33..5f3fe28082 100644 --- a/apps/desktop/src/components/settings/calendar/configure/apple.tsx +++ b/apps/desktop/src/components/settings/calendar/configure/apple.tsx @@ -4,7 +4,6 @@ import { AlertCircleIcon, ArrowRightIcon, CheckIcon } from "lucide-react"; import { useMemo } from "react"; import { - type AppleCalendar, commands as appleCalendarCommands, colorToCSS, } from "@hypr/plugin-apple-calendar"; @@ -21,6 +20,7 @@ import { Button } from "@hypr/ui/components/ui/button"; import { cn } from "@hypr/utils"; import * as main from "../../../../store/tinybase/main"; +import { findCalendarByTrackingId } from "../../../../utils/calendar"; import { PROVIDERS } from "../shared"; import { type CalendarGroup, @@ -188,25 +188,6 @@ function useAppleCalendarSelection() { const calendars = main.UI.useTable("calendars", main.STORE_ID); const { user_id } = main.UI.useValues(main.STORE_ID); - const setCalendarRow = main.UI.useSetRowCallback( - "calendars", - (cal: AppleCalendar) => cal.id, - (cal: AppleCalendar, store) => { - const existing = store.getRow("calendars", cal.id); - return { - user_id: user_id!, - created_at: existing?.created_at || new Date().toISOString(), - name: cal.title, - enabled: existing?.enabled ?? false, - provider: "apple", - source: cal.source.title, - color: colorToCSS(cal.color), - }; - }, - [user_id], - main.STORE_ID, - ); - const { mutate: syncCalendars, isPending } = useMutation({ mutationKey: ["appleCalendars", "sync"], mutationFn: async () => { @@ -219,10 +200,27 @@ function useAppleCalendarSelection() { } return result.data; }, - onSuccess: (calendars) => { - store?.transaction(() => { - for (const cal of calendars) { - setCalendarRow(cal); + onSuccess: (incomingCalendars) => { + if (!store || !user_id) return; + + store.transaction(() => { + for (const cal of incomingCalendars) { + const existingRowId = findCalendarByTrackingId(store, cal.id); + const rowId = existingRowId ?? crypto.randomUUID(); + const existing = existingRowId + ? store.getRow("calendars", existingRowId) + : null; + + store.setRow("calendars", rowId, { + user_id, + created_at: existing?.created_at || new Date().toISOString(), + tracking_id_calendar: cal.id, + name: cal.title, + enabled: existing?.enabled ?? false, + provider: "apple", + source: cal.source.title, + color: colorToCSS(cal.color), + }); } }); }, diff --git a/apps/desktop/src/hooks/useAutoCloser.ts b/apps/desktop/src/hooks/useAutoCloser.ts index 190286c9b4..2771e59729 100644 --- a/apps/desktop/src/hooks/useAutoCloser.ts +++ b/apps/desktop/src/hooks/useAutoCloser.ts @@ -12,14 +12,17 @@ export function useAutoCloser( outside?: boolean; }, ) { - const ref = useRef(null!); + const ref = useRef(null); const handleClose = useCallback(() => { onClose(); }, [onClose]); useHotkeys("esc", handleClose, { enabled: esc }, [handleClose]); - useOnClickOutside(ref, outside ? handleClose : () => {}); + useOnClickOutside( + ref as React.RefObject, + outside ? handleClose : () => {}, + ); return ref; } diff --git a/apps/desktop/src/services/apple-calendar/ctx.ts b/apps/desktop/src/services/apple-calendar/ctx.ts index bd2382d25c..f6bfc1c464 100644 --- a/apps/desktop/src/services/apple-calendar/ctx.ts +++ b/apps/desktop/src/services/apple-calendar/ctx.ts @@ -8,14 +8,24 @@ export interface Ctx { from: Date; to: Date; calendarIds: Set; + calendarTrackingIdToId: Map; } export function createCtx(store: Store, queries: Queries): Ctx | null { const resultTable = queries.getResultTable(QUERIES.enabledAppleCalendars); const calendarIds = new Set(Object.keys(resultTable)); + const calendarTrackingIdToId = new Map(); + + for (const calendarId of calendarIds) { + const calendar = store.getRow("calendars", calendarId); + const trackingId = calendar?.tracking_id_calendar as string | undefined; + if (trackingId) { + calendarTrackingIdToId.set(trackingId, calendarId); + } + } - if (calendarIds.size === 0) { + if (calendarTrackingIdToId.size === 0) { return null; } @@ -32,6 +42,7 @@ export function createCtx(store: Store, queries: Queries): Ctx | null { from, to, calendarIds, + calendarTrackingIdToId, }; } diff --git a/apps/desktop/src/services/apple-calendar/fetch/existing.ts b/apps/desktop/src/services/apple-calendar/fetch/existing.ts index 3c81fbc752..ef8e0be886 100644 --- a/apps/desktop/src/services/apple-calendar/fetch/existing.ts +++ b/apps/desktop/src/services/apple-calendar/fetch/existing.ts @@ -1,8 +1,8 @@ import type { Ctx } from "../ctx"; import type { ExistingEvent } from "./types"; -export function fetchExistingEvents(ctx: Ctx): Array { - const events: Array = []; +export function fetchExistingEvents(ctx: Ctx): ExistingEvent[] { + const events: ExistingEvent[] = []; ctx.store.forEachRow("events", (rowId, _forEachCell) => { const event = ctx.store.getRow("events", rowId); @@ -20,6 +20,7 @@ export function fetchExistingEvents(ctx: Ctx): Array { if (eventDate >= ctx.from && eventDate <= ctx.to) { events.push({ id: rowId, + tracking_id_event: event.tracking_id_event as string | undefined, user_id: event.user_id as string | undefined, created_at: event.created_at as string | undefined, calendar_id: calendarId, diff --git a/apps/desktop/src/services/apple-calendar/fetch/incoming.ts b/apps/desktop/src/services/apple-calendar/fetch/incoming.ts index ecf31773d4..419def85fb 100644 --- a/apps/desktop/src/services/apple-calendar/fetch/incoming.ts +++ b/apps/desktop/src/services/apple-calendar/fetch/incoming.ts @@ -6,13 +6,13 @@ import type { EventParticipant } from "@hypr/store"; import type { Ctx } from "../ctx"; import type { IncomingEvent } from "./types"; -export async function fetchIncomingEvents( - ctx: Ctx, -): Promise> { +export async function fetchIncomingEvents(ctx: Ctx): Promise { + const trackingIds = Array.from(ctx.calendarTrackingIdToId.keys()); + const results = await Promise.all( - Array.from(ctx.calendarIds).map(async (calendarId) => { + trackingIds.map(async (trackingId) => { const result = await appleCalendarCommands.listEvents({ - calendar_tracking_id: calendarId, + calendar_tracking_id: trackingId, from: ctx.from.toISOString(), to: ctx.to.toISOString(), }); @@ -45,8 +45,8 @@ async function normalizeAppleEvent(event: AppleEvent): Promise { } return { - id: event.event_identifier, - calendar_id: event.calendar.id, + tracking_id_event: event.event_identifier, + tracking_id_calendar: event.calendar.id, title: event.title, started_at: event.start_date, ended_at: event.end_date, diff --git a/apps/desktop/src/services/apple-calendar/fetch/types.ts b/apps/desktop/src/services/apple-calendar/fetch/types.ts index fd87fec849..3b63a19d13 100644 --- a/apps/desktop/src/services/apple-calendar/fetch/types.ts +++ b/apps/desktop/src/services/apple-calendar/fetch/types.ts @@ -1,8 +1,18 @@ import { EventStorage } from "@hypr/store"; -type EventBaseForSync = { id: string }; +export type IncomingEvent = { + tracking_id_event: string; + tracking_id_calendar: string; + title?: string; + started_at?: string; + ended_at?: string; + location?: string; + meeting_link?: string; + description?: string; + participants?: string; +}; -export type IncomingEvent = EventBaseForSync & - Omit; - -export type ExistingEvent = EventBaseForSync & EventStorage; +export type ExistingEvent = { + id: string; + tracking_id_event?: string; +} & EventStorage; diff --git a/apps/desktop/src/services/apple-calendar/index.ts b/apps/desktop/src/services/apple-calendar/index.ts index 24a3d65aba..a2fabd35d8 100644 --- a/apps/desktop/src/services/apple-calendar/index.ts +++ b/apps/desktop/src/services/apple-calendar/index.ts @@ -3,7 +3,12 @@ import type { Queries } from "tinybase/with-schemas"; import type { Schemas, Store } from "../../store/tinybase/main"; import { createCtx } from "./ctx"; import { fetchExistingEvents, fetchIncomingEvents } from "./fetch"; -import { execute, sync } from "./process"; +import { + executeForEventsSync, + executeForParticipantsSync, + syncEvents, + syncParticipants, +} from "./process"; export const CALENDAR_SYNC_TASK_ID = "calendarSync"; @@ -26,6 +31,11 @@ async function run(store: Store, queries: Queries) { const incoming = await fetchIncomingEvents(ctx); const existing = fetchExistingEvents(ctx); - const out = sync(ctx, { incoming, existing }); - execute(ctx.store, out); + const out = syncEvents(ctx, { incoming, existing }); + const { addedEventIds } = executeForEventsSync(ctx, out); + + const participantsOut = syncParticipants(ctx, { + eventIds: [...out.toUpdate.map((e) => e.id), ...addedEventIds], + }); + executeForParticipantsSync(ctx, participantsOut); } diff --git a/apps/desktop/src/services/apple-calendar/process/events/execute.ts b/apps/desktop/src/services/apple-calendar/process/events/execute.ts new file mode 100644 index 0000000000..c1d3fb81aa --- /dev/null +++ b/apps/desktop/src/services/apple-calendar/process/events/execute.ts @@ -0,0 +1,66 @@ +import type { EventStorage } from "@hypr/store"; + +import { id } from "../../../../utils"; +import type { Ctx } from "../../ctx"; +import type { EventsSyncOutput } from "./types"; + +export function executeForEventsSync( + ctx: Ctx, + out: EventsSyncOutput, +): { addedEventIds: string[] } { + const userId = ctx.store.getValue("user_id"); + if (!userId) { + throw new Error("user_id is not set"); + } + + const now = new Date().toISOString(); + const addedEventIds: string[] = []; + + ctx.store.transaction(() => { + for (const eventId of out.toDelete) { + ctx.store.delRow("events", eventId); + } + + for (const event of out.toUpdate) { + ctx.store.setPartialRow("events", event.id, { + tracking_id_event: event.tracking_id_event, + calendar_id: event.calendar_id, + title: event.title, + started_at: event.started_at, + ended_at: event.ended_at, + location: event.location, + meeting_link: event.meeting_link, + description: event.description, + participants: event.participants, + }); + } + + for (const incomingEvent of out.toAdd) { + const calendarId = ctx.calendarTrackingIdToId.get( + incomingEvent.tracking_id_calendar, + ); + if (!calendarId) { + continue; + } + + const eventId = id(); + addedEventIds.push(eventId); + + ctx.store.setRow("events", eventId, { + user_id: userId, + created_at: now, + tracking_id_event: incomingEvent.tracking_id_event, + calendar_id: calendarId, + title: incomingEvent.title ?? "", + started_at: incomingEvent.started_at ?? "", + ended_at: incomingEvent.ended_at ?? "", + location: incomingEvent.location, + meeting_link: incomingEvent.meeting_link, + description: incomingEvent.description, + participants: incomingEvent.participants, + } satisfies EventStorage); + } + }); + + return { addedEventIds }; +} diff --git a/apps/desktop/src/services/apple-calendar/process/events/index.ts b/apps/desktop/src/services/apple-calendar/process/events/index.ts new file mode 100644 index 0000000000..a8ede26d54 --- /dev/null +++ b/apps/desktop/src/services/apple-calendar/process/events/index.ts @@ -0,0 +1,8 @@ +export { executeForEventsSync } from "./execute"; +export { syncEvents } from "./sync"; +export type { + EventId, + EventsSyncInput, + EventsSyncOutput, + EventToUpdate, +} from "./types"; diff --git a/apps/desktop/src/services/apple-calendar/process/sync.ts b/apps/desktop/src/services/apple-calendar/process/events/sync.ts similarity index 51% rename from apps/desktop/src/services/apple-calendar/process/sync.ts rename to apps/desktop/src/services/apple-calendar/process/events/sync.ts index f88b7611ab..f900e331aa 100644 --- a/apps/desktop/src/services/apple-calendar/process/sync.ts +++ b/apps/desktop/src/services/apple-calendar/process/events/sync.ts @@ -1,17 +1,22 @@ -import type { Ctx } from "../ctx"; -import type { ExistingEvent, IncomingEvent } from "../fetch/types"; -import type { SyncInput, SyncOutput } from "./types"; -import { getSessionForEvent, isSessionEmpty } from "./utils"; - -export function sync(ctx: Ctx, { incoming, existing }: SyncInput): SyncOutput { - const out: SyncOutput = { +import type { Ctx } from "../../ctx"; +import type { ExistingEvent, IncomingEvent } from "../../fetch/types"; +import { getSessionForEvent, isSessionEmpty } from "../utils"; +import type { EventsSyncInput, EventsSyncOutput } from "./types"; + +export function syncEvents( + ctx: Ctx, + { incoming, existing }: EventsSyncInput, +): EventsSyncOutput { + const out: EventsSyncOutput = { toDelete: [], toUpdate: [], toAdd: [], }; - const incomingEventMap = new Map(incoming.map((e) => [e.id, e])); - const handledIncomingEventIds = new Set(); + const incomingEventMap = new Map( + incoming.map((e) => [e.tracking_id_event, e]), + ); + const handledTrackingIds = new Set(); for (const storeEvent of existing) { const sessionId = getSessionForEvent(ctx.store, storeEvent.id); @@ -25,16 +30,22 @@ export function sync(ctx: Ctx, { incoming, existing }: SyncInput): SyncOutput { continue; } - const matchingIncomingEvent = incomingEventMap.get(storeEvent.id); + const trackingId = storeEvent.tracking_id_event; + const matchingIncomingEvent = trackingId + ? incomingEventMap.get(trackingId) + : undefined; - if (matchingIncomingEvent) { + if (matchingIncomingEvent && trackingId) { out.toUpdate.push({ + ...storeEvent, ...matchingIncomingEvent, id: storeEvent.id, + tracking_id_event: trackingId, user_id: storeEvent.user_id, created_at: storeEvent.created_at, + calendar_id: storeEvent.calendar_id, }); - handledIncomingEventIds.add(matchingIncomingEvent.id); + handledTrackingIds.add(matchingIncomingEvent.tracking_id_event); continue; } @@ -42,16 +53,22 @@ export function sync(ctx: Ctx, { incoming, existing }: SyncInput): SyncOutput { continue; } - const rescheduledEvent = findRescheduledEvent(storeEvent, incoming); + const rescheduledEvent = findRescheduledEvent(ctx, storeEvent, incoming); - if (rescheduledEvent && !handledIncomingEventIds.has(rescheduledEvent.id)) { + if ( + rescheduledEvent && + !handledTrackingIds.has(rescheduledEvent.tracking_id_event) + ) { out.toUpdate.push({ + ...storeEvent, ...rescheduledEvent, id: storeEvent.id, + tracking_id_event: rescheduledEvent.tracking_id_event, user_id: storeEvent.user_id, created_at: storeEvent.created_at, + calendar_id: storeEvent.calendar_id, }); - handledIncomingEventIds.add(rescheduledEvent.id); + handledTrackingIds.add(rescheduledEvent.tracking_id_event); continue; } @@ -59,7 +76,7 @@ export function sync(ctx: Ctx, { incoming, existing }: SyncInput): SyncOutput { } for (const incomingEvent of incoming) { - if (!handledIncomingEventIds.has(incomingEvent.id)) { + if (!handledTrackingIds.has(incomingEvent.tracking_id_event)) { out.toAdd.push(incomingEvent); } } @@ -68,8 +85,9 @@ export function sync(ctx: Ctx, { incoming, existing }: SyncInput): SyncOutput { } function findRescheduledEvent( + ctx: Ctx, storeEvent: ExistingEvent, - incomingEvents: Array, + incomingEvents: IncomingEvent[], ): IncomingEvent | undefined { if (!storeEvent.started_at) { return undefined; @@ -86,7 +104,10 @@ function findRescheduledEvent( return false; } - if (incoming.calendar_id !== storeEvent.calendar_id) { + const incomingCalendarId = ctx.calendarTrackingIdToId.get( + incoming.tracking_id_calendar, + ); + if (incomingCalendarId !== storeEvent.calendar_id) { return false; } @@ -99,7 +120,7 @@ function findRescheduledEvent( return false; } - if (incoming.id === storeEvent.id) { + if (incoming.tracking_id_event === storeEvent.tracking_id_event) { return false; } diff --git a/apps/desktop/src/services/apple-calendar/process/events/types.ts b/apps/desktop/src/services/apple-calendar/process/events/types.ts new file mode 100644 index 0000000000..5c0652a084 --- /dev/null +++ b/apps/desktop/src/services/apple-calendar/process/events/types.ts @@ -0,0 +1,17 @@ +import type { ExistingEvent, IncomingEvent } from "../../fetch/types"; + +export type EventId = string; + +export type EventsSyncInput = { + incoming: IncomingEvent[]; + existing: ExistingEvent[]; +}; + +export type EventToUpdate = ExistingEvent & + Omit; + +export type EventsSyncOutput = { + toDelete: EventId[]; + toUpdate: EventToUpdate[]; + toAdd: IncomingEvent[]; +}; diff --git a/apps/desktop/src/services/apple-calendar/process/execute.ts b/apps/desktop/src/services/apple-calendar/process/execute.ts deleted file mode 100644 index 115d31a61e..0000000000 --- a/apps/desktop/src/services/apple-calendar/process/execute.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { EventStorage } from "@hypr/store"; - -import type { Store } from "../../../store/tinybase/main"; -import { id } from "../../../utils"; -import type { SyncOutput } from "./types"; - -export function execute(store: Store, syncOutput: SyncOutput): void { - const userId = store.getValue("user_id"); - if (!userId) { - throw new Error("user_id is not set"); - } - - const now = new Date().toISOString(); - - store.transaction(() => { - for (const eventId of syncOutput.toDelete) { - store.delRow("events", eventId); - } - - for (const existingEvent of syncOutput.toUpdate) { - store.setPartialRow("events", existingEvent.id, existingEvent); - } - - for (const incomingEvent of syncOutput.toAdd) { - store.setRow("events", id(), { - ...incomingEvent, - user_id: userId, - created_at: now, - } satisfies EventStorage); - } - }); -} diff --git a/apps/desktop/src/services/apple-calendar/process/index.ts b/apps/desktop/src/services/apple-calendar/process/index.ts index 3559b11aaf..dd4f982d00 100644 --- a/apps/desktop/src/services/apple-calendar/process/index.ts +++ b/apps/desktop/src/services/apple-calendar/process/index.ts @@ -1,2 +1,2 @@ -export { execute } from "./execute"; -export { sync } from "./sync"; +export { executeForEventsSync, syncEvents } from "./events"; +export { executeForParticipantsSync, syncParticipants } from "./participants"; diff --git a/apps/desktop/src/services/apple-calendar/process/participants/execute.ts b/apps/desktop/src/services/apple-calendar/process/participants/execute.ts new file mode 100644 index 0000000000..bcee7a1d08 --- /dev/null +++ b/apps/desktop/src/services/apple-calendar/process/participants/execute.ts @@ -0,0 +1,50 @@ +import type { + HumanStorage, + MappingSessionParticipantStorage, +} from "@hypr/store"; + +import { id } from "../../../../utils"; +import type { Ctx } from "../../ctx"; +import type { ParticipantsSyncOutput } from "./types"; + +export function executeForParticipantsSync( + ctx: Ctx, + out: ParticipantsSyncOutput, +): void { + const userId = ctx.store.getValue("user_id"); + if (!userId) { + return; + } + + const now = new Date().toISOString(); + + ctx.store.transaction(() => { + for (const human of out.humansToCreate) { + ctx.store.setRow("humans", human.id, { + user_id: String(userId), + created_at: now, + name: human.name, + email: human.email, + org_id: "", + job_title: "", + linkedin_username: "", + is_user: false, + memo: "", + } satisfies HumanStorage); + } + + for (const mappingId of out.toDelete) { + ctx.store.delRow("mapping_session_participant", mappingId); + } + + for (const mapping of out.toAdd) { + ctx.store.setRow("mapping_session_participant", id(), { + user_id: String(userId), + created_at: now, + session_id: mapping.sessionId, + human_id: mapping.humanId, + source: "auto", + } satisfies MappingSessionParticipantStorage); + } + }); +} diff --git a/apps/desktop/src/services/apple-calendar/process/participants/index.ts b/apps/desktop/src/services/apple-calendar/process/participants/index.ts new file mode 100644 index 0000000000..1f2c3c347d --- /dev/null +++ b/apps/desktop/src/services/apple-calendar/process/participants/index.ts @@ -0,0 +1,9 @@ +export { executeForParticipantsSync } from "./execute"; +export { syncParticipants } from "./sync"; +export type { + HumanToCreate, + ParticipantMappingId, + ParticipantMappingToAdd, + ParticipantsSyncInput, + ParticipantsSyncOutput, +} from "./types"; diff --git a/apps/desktop/src/services/apple-calendar/process/participants/sync.ts b/apps/desktop/src/services/apple-calendar/process/participants/sync.ts new file mode 100644 index 0000000000..b208e8b53b --- /dev/null +++ b/apps/desktop/src/services/apple-calendar/process/participants/sync.ts @@ -0,0 +1,164 @@ +import type { EventParticipant } from "@hypr/store"; + +import type { Store } from "../../../../store/tinybase/main"; +import { id } from "../../../../utils"; +import type { Ctx } from "../../ctx"; +import { getSessionForEvent } from "../utils"; +import type { + HumanToCreate, + ParticipantMappingToAdd, + ParticipantsSyncInput, + ParticipantsSyncOutput, +} from "./types"; + +export function syncParticipants( + ctx: Ctx, + input: ParticipantsSyncInput, +): ParticipantsSyncOutput { + const output: ParticipantsSyncOutput = { + toDelete: [], + toAdd: [], + humansToCreate: [], + }; + + const humansByEmail = buildHumansByEmailIndex(ctx.store); + const humansToCreateMap = new Map(); + + for (const eventId of input.eventIds) { + const sessionId = getSessionForEvent(ctx.store, eventId); + if (!sessionId) { + continue; + } + + const eventParticipants = getEventParticipants(ctx.store, eventId); + const sessionOutput = computeSessionParticipantChanges( + ctx.store, + sessionId, + eventParticipants, + humansByEmail, + humansToCreateMap, + ); + + output.toDelete.push(...sessionOutput.toDelete); + output.toAdd.push(...sessionOutput.toAdd); + } + + output.humansToCreate = Array.from(humansToCreateMap.values()); + + return output; +} + +function buildHumansByEmailIndex(store: Store): Map { + const humansByEmail = new Map(); + + store.forEachRow("humans", (humanId, _forEachCell) => { + const human = store.getRow("humans", humanId); + const email = human?.email; + if (email && typeof email === "string" && email.trim()) { + humansByEmail.set(email.toLowerCase(), humanId); + } + }); + + return humansByEmail; +} + +function getEventParticipants( + store: Store, + eventId: string, +): EventParticipant[] { + const event = store.getRow("events", eventId); + if (!event?.participants) { + return []; + } + + try { + const parsed = JSON.parse(String(event.participants)); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function computeSessionParticipantChanges( + store: Store, + sessionId: string, + eventParticipants: EventParticipant[], + humansByEmail: Map, + humansToCreateMap: Map, +): { toDelete: string[]; toAdd: ParticipantMappingToAdd[] } { + const eventHumanIds = new Set(); + for (const participant of eventParticipants) { + if (!participant.email) { + continue; + } + + const emailLower = participant.email.toLowerCase(); + let humanId = humansByEmail.get(emailLower); + + if (!humanId) { + const existing = humansToCreateMap.get(emailLower); + if (existing) { + humanId = existing.id; + } else { + humanId = id(); + humansToCreateMap.set(emailLower, { + id: humanId, + name: participant.name || participant.email, + email: participant.email, + }); + humansByEmail.set(emailLower, humanId); + } + } + + eventHumanIds.add(humanId); + } + + const existingMappings = getExistingMappings(store, sessionId); + + const toAdd: ParticipantMappingToAdd[] = []; + const toDelete: string[] = []; + + for (const humanId of eventHumanIds) { + const existing = existingMappings.get(humanId); + if (!existing) { + toAdd.push({ sessionId, humanId }); + } else if (existing.source === "excluded") { + continue; + } + } + + for (const [humanId, mapping] of existingMappings) { + if (mapping.source === "auto" && !eventHumanIds.has(humanId)) { + toDelete.push(mapping.id); + } + } + + return { toDelete, toAdd }; +} + +type MappingInfo = { + id: string; + humanId: string; + source: string | undefined; +}; + +function getExistingMappings( + store: Store, + sessionId: string, +): Map { + const mappings = new Map(); + + store.forEachRow("mapping_session_participant", (mappingId, _forEachCell) => { + const mapping = store.getRow("mapping_session_participant", mappingId); + if (mapping?.session_id === sessionId) { + const humanId = String(mapping.human_id); + mappings.set(humanId, { + id: mappingId, + humanId, + source: mapping.source as string | undefined, + }); + } + }); + + return mappings; +} diff --git a/apps/desktop/src/services/apple-calendar/process/participants/types.ts b/apps/desktop/src/services/apple-calendar/process/participants/types.ts new file mode 100644 index 0000000000..5447c85a3a --- /dev/null +++ b/apps/desktop/src/services/apple-calendar/process/participants/types.ts @@ -0,0 +1,22 @@ +export type ParticipantMappingId = string; + +export type ParticipantsSyncInput = { + eventIds: string[]; +}; + +export type ParticipantMappingToAdd = { + sessionId: string; + humanId: string; +}; + +export type HumanToCreate = { + id: string; + name: string; + email: string; +}; + +export type ParticipantsSyncOutput = { + toDelete: ParticipantMappingId[]; + toAdd: ParticipantMappingToAdd[]; + humansToCreate: HumanToCreate[]; +}; diff --git a/apps/desktop/src/services/apple-calendar/process/types.ts b/apps/desktop/src/services/apple-calendar/process/types.ts deleted file mode 100644 index eefb8b4811..0000000000 --- a/apps/desktop/src/services/apple-calendar/process/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ExistingEvent, IncomingEvent } from "../fetch/types"; - -export type SyncInput = { - incoming: Array; - existing: Array; -}; - -export type SyncOutput = { - toDelete: string[]; - toUpdate: Array; - toAdd: Array; -}; diff --git a/apps/desktop/src/utils/calendar.ts b/apps/desktop/src/utils/calendar.ts new file mode 100644 index 0000000000..538ed01af4 --- /dev/null +++ b/apps/desktop/src/utils/calendar.ts @@ -0,0 +1,20 @@ +import type { Store } from "tinybase/with-schemas"; + +import type { Schemas } from "../store/tinybase/main"; + +export function findCalendarByTrackingId( + store: Store, + trackingId: string, +): string | null { + let foundRowId: string | null = null; + + store.forEachRow("calendars", (rowId, _forEachCell) => { + if (foundRowId) return; + const row = store.getRow("calendars", rowId); + if (row?.tracking_id_calendar === trackingId) { + foundRowId = rowId; + } + }); + + return foundRowId; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 797ee8d107..77dfcc8ef8 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -187,6 +187,7 @@ export const events = pgTable( TABLE_EVENTS, { ...SHARED, + tracking_id_event: text("tracking_id_event").notNull(), calendar_id: uuid("calendar_id") .notNull() .references(() => calendars.id, { onDelete: "cascade" }), @@ -206,6 +207,7 @@ export const calendars = pgTable( TABLE_CALENDARS, { ...SHARED, + tracking_id_calendar: text("tracking_id_calendar").notNull(), name: text("name").notNull(), }, (table) => createPolicies(TABLE_CALENDARS, table.user_id), diff --git a/packages/store/src/schema-external.ts b/packages/store/src/schema-external.ts index 3439e1ac1c..98d6340858 100644 --- a/packages/store/src/schema-external.ts +++ b/packages/store/src/schema-external.ts @@ -90,9 +90,16 @@ export const transcriptSchema = baseTranscriptSchema.omit({ id: true }).extend({ ended_at: z.preprocess((val) => val ?? undefined, z.number().optional()), }); +export const participantSourceSchema = z.enum(["manual", "auto", "excluded"]); +export type ParticipantSource = z.infer; + export const mappingSessionParticipantSchema = baseMappingSessionParticipantSchema.omit({ id: true }).extend({ created_at: z.string(), + source: z.preprocess( + (val) => val ?? undefined, + participantSourceSchema.optional(), + ), }); export const tagSchema = baseTagSchema.omit({ id: true }).extend({ @@ -220,6 +227,9 @@ export type FolderStorage = ToStorageType; export type PromptStorage = ToStorageType; export type ChatShortcutStorage = ToStorageType; export type EventStorage = ToStorageType; +export type MappingSessionParticipantStorage = ToStorageType< + typeof mappingSessionParticipantSchema +>; export const externalTableSchemaForTinybase = { folders: { @@ -282,6 +292,7 @@ export const externalTableSchemaForTinybase = { calendars: { user_id: { type: "string" }, created_at: { type: "string" }, + tracking_id_calendar: { type: "string" }, name: { type: "string" }, enabled: { type: "boolean" }, provider: { type: "string" }, @@ -291,6 +302,7 @@ export const externalTableSchemaForTinybase = { events: { user_id: { type: "string" }, created_at: { type: "string" }, + tracking_id_event: { type: "string" }, calendar_id: { type: "string" }, title: { type: "string" }, started_at: { type: "string" }, @@ -306,6 +318,7 @@ export const externalTableSchemaForTinybase = { created_at: { type: "string" }, session_id: { type: "string" }, human_id: { type: "string" }, + source: { type: "string" }, } as const satisfies InferTinyBaseSchema< typeof mappingSessionParticipantSchema >,