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.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants.tsx deleted file mode 100644 index 4d7428a7a0..0000000000 --- a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants.tsx +++ /dev/null @@ -1,432 +0,0 @@ -import { X } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import type { SpeakerHintStorage, Transcript } from "@hypr/store"; -import { Badge } from "@hypr/ui/components/ui/badge"; -import { Button } from "@hypr/ui/components/ui/button"; -import { cn } from "@hypr/utils"; - -import * as main from "../../../../../../store/tinybase/main"; -import { useTabs } from "../../../../../../store/zustand/tabs/index"; - -function createHuman(store: any, userId: string, name: string) { - const humanId = crypto.randomUUID(); - store.setRow("humans", humanId, { - user_id: userId, - created_at: new Date().toISOString(), - name, - email: "", - org_id: "", - job_title: "", - linkedin_username: "", - is_user: false, - memo: "", - }); - return humanId; -} - -function linkHumanToSession( - store: any, - userId: string, - sessionId: string, - humanId: string, -) { - const mappingId = crypto.randomUUID(); - store.setRow("mapping_session_participant", mappingId, { - user_id: userId, - created_at: new Date().toISOString(), - session_id: sessionId, - human_id: humanId, - }); -} - -function createAndLinkHuman( - store: any, - userId: string, - sessionId: string, - name: string, -) { - const humanId = createHuman(store, userId, name); - linkHumanToSession(store, userId, sessionId, humanId); - return humanId; -} - -export function ParticipantsDisplay({ sessionId }: { sessionId: string }) { - const mappingIds = main.UI.useSliceRowIds( - main.INDEXES.sessionParticipantsBySession, - sessionId, - main.STORE_ID, - ) as string[]; - - return ( -
-
- -
- ); -} - -function useParticipantDetails(mappingId: string) { - const result = main.UI.useResultRow( - main.QUERIES.sessionParticipantsWithDetails, - mappingId, - main.STORE_ID, - ); - - if (!result) { - return null; - } - - return { - mappingId, - humanId: result.human_id as string, - humanName: (result.human_name as string) || "", - humanEmail: (result.human_email as string | undefined) || undefined, - humanJobTitle: (result.human_job_title as string | undefined) || undefined, - humanLinkedinUsername: - (result.human_linkedin_username as string | undefined) || undefined, - humanIsUser: result.human_is_user as boolean, - orgId: (result.org_id as string | undefined) || undefined, - orgName: result.org_name as string | undefined, - sessionId: result.session_id as string, - }; -} - -function parseHumanIdFromHintValue(value: unknown): string | undefined { - const data = - typeof value === "string" - ? (() => { - try { - return JSON.parse(value); - } catch { - return undefined; - } - })() - : value; - - if (data && typeof data === "object" && "human_id" in data) { - const humanId = (data as Record).human_id; - return typeof humanId === "string" ? humanId : undefined; - } - - return undefined; -} - -function useRemoveParticipant({ - mappingId, - assignedHumanId, - sessionId, -}: { - mappingId: string; - assignedHumanId: string | undefined; - sessionId: string | undefined; -}) { - const store = main.UI.useStore(main.STORE_ID); - - return useCallback(() => { - if (!store) { - return; - } - - if (assignedHumanId && sessionId) { - const hintIdsToDelete: string[] = []; - - store.forEachRow("speaker_hints", (hintId, _forEachCell) => { - const hint = store.getRow("speaker_hints", hintId) as - | SpeakerHintStorage - | undefined; - if (!hint || hint.type !== "user_speaker_assignment") { - return; - } - - const transcriptId = hint.transcript_id; - if (typeof transcriptId !== "string") { - return; - } - - const transcript = store.getRow("transcripts", transcriptId) as - | Transcript - | undefined; - if (!transcript || transcript.session_id !== sessionId) { - return; - } - - const hintHumanId = parseHumanIdFromHintValue(hint.value); - if (hintHumanId === assignedHumanId) { - hintIdsToDelete.push(hintId); - } - }); - - hintIdsToDelete.forEach((hintId) => { - store.delRow("speaker_hints", hintId); - }); - } - - store.delRow("mapping_session_participant", mappingId); - }, [store, mappingId, assignedHumanId, sessionId]); -} - -function ParticipantChip({ mappingId }: { mappingId: string }) { - const details = useParticipantDetails(mappingId); - const openNew = useTabs.getState().openNew; - - const assignedHumanId = details?.humanId; - const sessionId = details?.sessionId; - - const handleRemove = useRemoveParticipant({ - mappingId, - assignedHumanId, - sessionId, - }); - - const handleClick = useCallback(() => { - if (assignedHumanId) { - openNew({ - type: "contacts", - state: { selectedOrganization: null, selectedPerson: assignedHumanId }, - }); - } - }, [openNew, assignedHumanId]); - - if (!details) { - return null; - } - - const { humanName } = details; - - return ( - - {humanName || "Unknown"} - - - ); -} - -function ParticipantChipInput({ - sessionId, - mappingIds, -}: { - sessionId: string; - mappingIds: string[]; -}) { - const [inputValue, setInputValue] = useState(""); - const [showDropdown, setShowDropdown] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(0); - const inputRef = useRef(null); - const containerRef = useRef(null); - const store = main.UI.useStore(main.STORE_ID); - const userId = main.UI.useValue("user_id", main.STORE_ID); - const allHumanIds = main.UI.useRowIds("humans", main.STORE_ID) as string[]; - const queries = main.UI.useQueries(main.STORE_ID); - - const existingHumanIds = useMemo(() => { - if (!queries) { - return new Set(); - } - - const ids = new Set(); - for (const mappingId of mappingIds) { - const result = queries.getResultRow( - main.QUERIES.sessionParticipantsWithDetails, - mappingId, - ); - if (result?.human_id) { - ids.add(result.human_id as string); - } - } - return ids; - }, [mappingIds, queries]); - - const candidates = useMemo(() => { - const searchLower = inputValue.toLowerCase(); - return allHumanIds - .filter((humanId: string) => !existingHumanIds.has(humanId)) - .map((humanId: string) => { - const human = store?.getRow("humans", humanId); - if (!human) { - return null; - } - - const name = (human.name || "") as string; - const email = (human.email || "") as string; - const nameMatch = name.toLowerCase().includes(searchLower); - const emailMatch = email.toLowerCase().includes(searchLower); - - if (inputValue && !nameMatch && !emailMatch) { - return null; - } - - return { - id: humanId, - name, - email, - orgId: human.org_id as string | undefined, - jobTitle: human.job_title as string | undefined, - isNew: false, - }; - }) - .filter((h): h is NonNullable => h !== null); - }, [inputValue, allHumanIds, existingHumanIds, store]); - - const showCustomOption = - inputValue.trim() && - !candidates.some((c) => c.name.toLowerCase() === inputValue.toLowerCase()); - - const dropdownOptions = showCustomOption - ? [ - { - id: "new", - name: inputValue.trim(), - isNew: true, - email: "", - orgId: undefined, - jobTitle: undefined, - }, - ...candidates, - ] - : candidates; - - const handleAddParticipant = useCallback( - (option: { - id: string; - name: string; - isNew?: boolean; - email?: string; - orgId?: string; - jobTitle?: string; - }) => { - if (!store || !userId) { - return; - } - - if (option.isNew) { - createAndLinkHuman(store, userId, sessionId, option.name); - } else { - linkHumanToSession(store, userId, sessionId, option.id); - } - - setInputValue(""); - setShowDropdown(false); - setSelectedIndex(0); - inputRef.current?.focus(); - }, - [store, userId, sessionId], - ); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && inputValue.trim()) { - e.preventDefault(); - if (dropdownOptions.length > 0) { - handleAddParticipant(dropdownOptions[selectedIndex]); - } - } else if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedIndex((prev) => - prev < dropdownOptions.length - 1 ? prev + 1 : prev, - ); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); - } else if (e.key === "Escape") { - setShowDropdown(false); - setSelectedIndex(0); - } else if (e.key === "Backspace" && !inputValue && mappingIds.length > 0) { - const lastMappingId = mappingIds[mappingIds.length - 1]; - if (store) { - store.delRow("mapping_session_participant", lastMappingId); - } - } - }; - - const handleInputChange = (value: string) => { - setInputValue(value); - setShowDropdown(true); - setSelectedIndex(0); - }; - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setShowDropdown(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - return ( -
-
inputRef.current?.focus()} - > - {mappingIds.map((mappingId) => ( - - ))} - handleInputChange(e.target.value)} - onKeyDown={handleKeyDown} - onFocus={() => setShowDropdown(true)} - /> -
- {showDropdown && inputValue.trim() && dropdownOptions.length > 0 && ( -
-
- {dropdownOptions.map((option, index) => ( - - ))} -
-
- )} -
- ); -} 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 new file mode 100644 index 0000000000..e86a1f1be4 --- /dev/null +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx @@ -0,0 +1,175 @@ +import { X } from "lucide-react"; +import { useCallback } from "react"; + +import type { SpeakerHintStorage } from "@hypr/store"; +import { Badge } from "@hypr/ui/components/ui/badge"; +import { Button } from "@hypr/ui/components/ui/button"; + +import * as main from "../../../../../../../store/tinybase/main"; +import { useTabs } from "../../../../../../../store/zustand/tabs/index"; + +export function ParticipantChip({ mappingId }: { mappingId: string }) { + const details = useParticipantDetails(mappingId); + + const assignedHumanId = details?.humanId; + const sessionId = details?.sessionId; + const source = details?.source; + + const handleRemove = useRemoveParticipant({ + mappingId, + assignedHumanId, + sessionId, + source, + }); + + const handleClick = useCallback(() => { + if (assignedHumanId) { + useTabs.getState().openNew({ + type: "contacts", + state: { selectedOrganization: null, selectedPerson: assignedHumanId }, + }); + } + }, [assignedHumanId]); + + if (!details || source === "excluded") { + return null; + } + + const { humanName } = details; + + return ( + + {humanName || "Unknown"} + + + ); +} + +function useParticipantDetails(mappingId: string) { + const result = main.UI.useResultRow( + main.QUERIES.sessionParticipantsWithDetails, + mappingId, + main.STORE_ID, + ); + const source = main.UI.useCell( + "mapping_session_participant", + mappingId, + "source", + main.STORE_ID, + ); + + if (!result) { + return null; + } + + return { + mappingId, + humanId: result.human_id as string, + humanName: (result.human_name as string) || "", + humanEmail: (result.human_email as string | undefined) || undefined, + humanJobTitle: (result.human_job_title as string | undefined) || undefined, + humanLinkedinUsername: + (result.human_linkedin_username as string | undefined) || undefined, + humanIsUser: result.human_is_user as boolean, + 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, + }; +} + +function parseHumanIdFromHintValue(value: unknown): string | undefined { + let data = value; + if (typeof value === "string") { + try { + data = JSON.parse(value); + } catch { + return undefined; + } + } + + if (data && typeof data === "object" && "human_id" in data) { + const humanId = (data as Record).human_id; + return typeof humanId === "string" ? humanId : undefined; + } + + return undefined; +} + +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); + + return useCallback(() => { + if (!store) { + return; + } + + if (assignedHumanId && sessionId && indexes) { + const hintIdsToDelete: string[] = []; + + const transcriptIds = indexes.getSliceRowIds( + main.INDEXES.transcriptBySession, + sessionId, + ); + + for (const transcriptId of transcriptIds) { + const hintIds = indexes.getSliceRowIds( + main.INDEXES.speakerHintsByTranscript, + transcriptId, + ); + + for (const hintId of hintIds) { + const hint = store.getRow("speaker_hints", hintId) as + | SpeakerHintStorage + | undefined; + if (!hint || hint.type !== "user_speaker_assignment") { + continue; + } + + const hintHumanId = parseHumanIdFromHintValue(hint.value); + if (hintHumanId === assignedHumanId) { + hintIdsToDelete.push(hintId); + } + } + } + + for (const hintId of hintIdsToDelete) { + store.delRow("speaker_hints", hintId); + } + } + + 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/dropdown.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/dropdown.tsx new file mode 100644 index 0000000000..1c750a423d --- /dev/null +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/dropdown.tsx @@ -0,0 +1,61 @@ +import { cn } from "@hypr/utils"; + +type DropdownOption = { + id: string; + name: string; + isNew?: boolean; + email?: string; + orgId?: string; + jobTitle?: string; +}; + +export function ParticipantDropdown({ + options, + selectedIndex, + onSelect, + onHover, +}: { + options: DropdownOption[]; + selectedIndex: number; + onSelect: (option: DropdownOption) => void; + onHover: (index: number) => void; +}) { + if (options.length === 0) { + return null; + } + + return ( +
+
+ {options.map((option, index) => ( + + ))} +
+
+ ); +} diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/index.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/index.tsx new file mode 100644 index 0000000000..9c82602832 --- /dev/null +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/index.tsx @@ -0,0 +1,10 @@ +import { ParticipantInput } from "./input"; + +export function ParticipantsDisplay({ sessionId }: { sessionId: string }) { + return ( +
+
+ +
+ ); +} 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 new file mode 100644 index 0000000000..6eb1a64df4 --- /dev/null +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx @@ -0,0 +1,339 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useAutoCloser } from "../../../../../../../hooks/useAutoCloser"; +import * as main from "../../../../../../../store/tinybase/main"; +import { ParticipantChip } from "./chip"; +import { ParticipantDropdown } from "./dropdown"; + +export function ParticipantInput({ sessionId }: { sessionId: string }) { + const { + inputValue, + showDropdown, + setShowDropdown, + selectedIndex, + setSelectedIndex, + mappingIds, + dropdownOptions, + handleAddParticipant, + handleInputChange, + deleteLastParticipant, + resetInput, + } = useParticipantInput(sessionId); + + const inputRef = useRef(null); + const containerRef = useAutoCloser(() => setShowDropdown(false), { + esc: false, + outside: true, + }); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && inputValue.trim()) { + e.preventDefault(); + if (dropdownOptions.length > 0) { + handleAddParticipant(dropdownOptions[selectedIndex]); + inputRef.current?.focus(); + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => + prev < dropdownOptions.length - 1 ? prev + 1 : prev, + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (e.key === "Escape") { + resetInput(); + } else if (e.key === "Backspace" && !inputValue) { + deleteLastParticipant(); + } + }; + + const handleSelect = (option: Candidate) => { + handleAddParticipant(option); + inputRef.current?.focus(); + }; + + return ( +
+
inputRef.current?.focus()} + > + {mappingIds.map((mappingId) => ( + + ))} + + handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => setShowDropdown(true)} + /> +
+ + {showDropdown && inputValue.trim() && ( + + )} +
+ ); +} + +type Candidate = { + id: string; + name: string; + email?: string; + orgId?: string; + jobTitle?: string; + isNew?: boolean; +}; + +function useSessionParticipants(sessionId: string) { + const queries = main.UI.useQueries(main.STORE_ID); + + const mappingIds = main.UI.useSliceRowIds( + main.INDEXES.sessionParticipantsBySession, + sessionId, + main.STORE_ID, + ) as string[]; + + const existingHumanIds = useMemo(() => { + if (!queries) { + return new Set(); + } + + const ids = new Set(); + for (const mappingId of mappingIds) { + const result = queries.getResultRow( + main.QUERIES.sessionParticipantsWithDetails, + mappingId, + ); + if (result?.human_id) { + ids.add(result.human_id as string); + } + } + return ids; + }, [mappingIds, queries]); + + return { mappingIds, existingHumanIds }; +} + +function useCandidateSearch( + inputValue: string, + existingHumanIds: Set, +): Candidate[] { + const store = main.UI.useStore(main.STORE_ID); + const allHumanIds = main.UI.useRowIds("humans", main.STORE_ID) as string[]; + + return useMemo(() => { + const searchLower = inputValue.toLowerCase(); + return allHumanIds + .filter((humanId: string) => !existingHumanIds.has(humanId)) + .map((humanId: string) => { + const human = store?.getRow("humans", humanId); + if (!human) { + return null; + } + + const name = (human.name || "") as string; + const email = (human.email || "") as string; + const nameMatch = name.toLowerCase().includes(searchLower); + const emailMatch = email.toLowerCase().includes(searchLower); + + if (inputValue && !nameMatch && !emailMatch) { + return null; + } + + return { + id: humanId, + name, + email, + orgId: human.org_id as string | undefined, + jobTitle: human.job_title as string | undefined, + isNew: false, + }; + }) + .filter((h): h is NonNullable => h !== null); + }, [inputValue, allHumanIds, existingHumanIds, store]); +} + +function useDropdownOptions( + inputValue: string, + candidates: Candidate[], +): Candidate[] { + return useMemo(() => { + const showCustomOption = + inputValue.trim() && + !candidates.some( + (c) => c.name.toLowerCase() === inputValue.toLowerCase(), + ); + + if (!showCustomOption) { + return candidates; + } + + return [ + { + id: "new", + name: inputValue.trim(), + isNew: true, + email: "", + orgId: undefined, + jobTitle: undefined, + }, + ...candidates, + ]; + }, [inputValue, candidates]); +} + +function useParticipantMutations(sessionId: string, mappingIds: string[]) { + const store = main.UI.useStore(main.STORE_ID); + const userId = main.UI.useValue("user_id", main.STORE_ID); + + const createHuman = useCreateHuman(userId || ""); + const linkHumanToSession = useLinkHumanToSession(userId || "", sessionId); + + const addParticipant = useCallback( + (option: Candidate) => { + if (!userId) { + return; + } + + if (option.isNew) { + const humanId = createHuman(option.name); + linkHumanToSession(humanId); + } else { + linkHumanToSession(option.id); + } + }, + [userId, createHuman, linkHumanToSession], + ); + + const deleteLastParticipant = useCallback(() => { + if (mappingIds.length > 0 && store) { + const lastMappingId = mappingIds[mappingIds.length - 1]; + store.delRow("mapping_session_participant", lastMappingId); + } + }, [mappingIds, store]); + + return { addParticipant, deleteLastParticipant }; +} + +function useParticipantInput(sessionId: string) { + const [inputValue, setInputValue] = useState(""); + const [showDropdown, setShowDropdown] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + const { mappingIds, existingHumanIds } = useSessionParticipants(sessionId); + const candidates = useCandidateSearch(inputValue, existingHumanIds); + const dropdownOptions = useDropdownOptions(inputValue, candidates); + const { addParticipant, deleteLastParticipant } = useParticipantMutations( + sessionId, + mappingIds, + ); + + useEffect(() => { + if (selectedIndex >= dropdownOptions.length && dropdownOptions.length > 0) { + setSelectedIndex(dropdownOptions.length - 1); + } else if (dropdownOptions.length === 0) { + setSelectedIndex(0); + } + }, [dropdownOptions.length, selectedIndex]); + + const resetInput = useCallback(() => { + setInputValue(""); + setShowDropdown(false); + setSelectedIndex(0); + }, []); + + const handleAddParticipant = useCallback( + (option: Candidate) => { + addParticipant(option); + resetInput(); + }, + [addParticipant, resetInput], + ); + + const handleInputChange = useCallback((value: string) => { + setInputValue(value); + setShowDropdown(true); + setSelectedIndex(0); + }, []); + + return { + inputValue, + showDropdown, + setShowDropdown, + selectedIndex, + setSelectedIndex, + mappingIds, + dropdownOptions, + handleAddParticipant, + handleInputChange, + deleteLastParticipant, + resetInput, + }; +} + +function useLinkHumanToSession( + userId: string, + sessionId: string, +): (humanId: string) => void { + const linkMapping = main.UI.useSetRowCallback( + "mapping_session_participant", + () => crypto.randomUUID(), + (p: { humanId: string }) => ({ + user_id: userId, + created_at: new Date().toISOString(), + session_id: sessionId, + human_id: p.humanId, + source: "manual", + }), + [userId, sessionId], + main.STORE_ID, + ); + + return useCallback( + (humanId: string) => { + linkMapping({ humanId }); + }, + [linkMapping], + ); +} + +function useCreateHuman(userId: string): (name: string) => string { + const createHuman = main.UI.useSetRowCallback( + "humans", + (p: { name: string; humanId: string }) => p.humanId, + (p: { name: string; humanId: string }) => ({ + user_id: userId, + created_at: new Date().toISOString(), + name: p.name, + email: "", + org_id: "", + job_title: "", + linkedin_username: "", + is_user: false, + memo: "", + }), + [userId], + main.STORE_ID, + ); + + return useCallback( + (name: string) => { + const humanId = crypto.randomUUID(); + createHuman({ name, humanId }); + return humanId; + }, + [createHuman], + ); +} 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 0a9e3c73a4..2771e59729 100644 --- a/apps/desktop/src/hooks/useAutoCloser.ts +++ b/apps/desktop/src/hooks/useAutoCloser.ts @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import { useOnClickOutside } from "usehooks-ts"; export function useAutoCloser( onClose: () => void, @@ -18,23 +19,10 @@ export function useAutoCloser( }, [onClose]); useHotkeys("esc", handleClose, { enabled: esc }, [handleClose]); - - useEffect(() => { - if (!outside) { - return; - } - - const handleClickOutside = (event: MouseEvent) => { - if (ref.current && !ref.current.contains(event.target as Node)) { - handleClose(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [handleClose, outside]); + 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 >,