diff --git a/src/app/availability/[slug]/page.tsx b/src/app/availability/[slug]/page.tsx index 22d7535a9..2d539f4eb 100644 --- a/src/app/availability/[slug]/page.tsx +++ b/src/app/availability/[slug]/page.tsx @@ -5,9 +5,11 @@ import { OPEN_INVITE_AFTER_CREATE_PARAM, OPEN_INVITE_AFTER_CREATE_VALUE, } from "@/lib/meeting-open-invite"; +import { loadMergedScheduledInterval } from "@/server/actions/availability/google/calendar/action"; import { getAllMemberAvailability, getExistingMeeting, + getMeetingGoogleCalendarSnapshot, getScheduledTimeBlocks, } from "@/server/data/meeting/queries"; @@ -62,13 +64,24 @@ export default async function Page(props: PageProps) { notFound(); } - const allAvailabilities = await getAllMemberAvailability({ - meetingId: meetingData.id, - }); - - const scheduledBlocks = await getScheduledTimeBlocks(meetingData.id); const session = await getCurrentSession(); - const rawSearch = await props.searchParams; + + const [ + allAvailabilities, + scheduledBlocks, + mergedScheduledInterval, + googleCalendarLinkSnapshot, + rawSearch, + ] = await Promise.all([ + getAllMemberAvailability({ meetingId: meetingData.id }), + getScheduledTimeBlocks(meetingData.id), + loadMergedScheduledInterval(meetingData.id), + session.user + ? getMeetingGoogleCalendarSnapshot(meetingData.id, session.user.memberId) + : Promise.resolve(null), + props.searchParams, + ]); + const openInviteRaw = rawSearch[OPEN_INVITE_AFTER_CREATE_PARAM]; const openInviteFlag = Array.isArray(openInviteRaw) ? openInviteRaw[0] @@ -86,6 +99,8 @@ export default async function Page(props: PageProps) { allAvailabilities={allAvailabilities} user={session.user} scheduledBlocks={scheduledBlocks} + mergedScheduledInterval={mergedScheduledInterval} + googleCalendarLinkSnapshot={googleCalendarLinkSnapshot} autoOpenInviteDialog={autoOpenInviteDialog} inviteQueryInUrl={inviteQueryInUrl} /> diff --git a/src/components/availability/add-to-calendar-button.tsx b/src/components/availability/add-to-calendar-button.tsx new file mode 100644 index 000000000..e95632372 --- /dev/null +++ b/src/components/availability/add-to-calendar-button.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { addMeetingToMyGoogleCalendar } from "@actions/availability/google/calendar/action"; +import { Cached, Check } from "@mui/icons-material"; +import GoogleIcon from "@mui/icons-material/Google"; +import { Button } from "@mui/material"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { useSnackbar } from "@/components/ui/snackbar-provider"; +import type { MeetingGoogleCalendarSnapshot } from "@/db/schema"; +import type { UserProfile } from "@/lib/auth/user"; +import { deriveAddToCalendarLabelState } from "@/lib/google-calendar/snapshot"; + +type AddToCalendarButtonProps = { + meetingId: string; + user: UserProfile | null; + mergedScheduledInterval: MeetingGoogleCalendarSnapshot | null; + googleCalendarLinkSnapshot: MeetingGoogleCalendarSnapshot | null; + className?: string; +}; + +export function AddToCalendarButton({ + meetingId, + user, + mergedScheduledInterval, + googleCalendarLinkSnapshot, + className, +}: AddToCalendarButtonProps) { + const router = useRouter(); + const { showSuccess, showError } = useSnackbar(); + const [isPending, startTransition] = useTransition(); + + const state = deriveAddToCalendarLabelState({ + storedSnapshot: googleCalendarLinkSnapshot, + currentInterval: mergedScheduledInterval, + }); + + if (state === "non_contiguous") { + // Schedule is non-contiguous (multi-interval) — API write isn't supported. + return null; + } + + let label: string; + let icon: React.ReactNode; + let disabled = isPending; + + switch (state) { + case "add": + label = "Add to Calendar"; + icon = ; + break; + case "drifted": + label = "Update Calendar Event"; + icon = ; + break; + case "in_sync": + label = "Added to Calendar"; + icon = ; + disabled = true; + break; + } + + const handleClick = () => { + if (!user) { + router.push("/auth/login/google"); + return; + } + + startTransition(async () => { + try { + const response = await addMeetingToMyGoogleCalendar({ meetingId }); + if (!response.success) { + showError(`Could not sync to Google Calendar: ${response.error}.`); + return; + } + + const result = response.result; + if (result.status === "synced") { + showSuccess("Synced to your Google Calendar."); + router.refresh(); + } else if (result.status === "skipped") { + showError(`Skipped: ${result.reason}.`); + } else { + showError(`Sync failed: ${result.reason}.`); + } + } catch (error) { + console.error("AddToCalendarButton error", error); + showError("An error occurred syncing to Google Calendar."); + } + }); + }; + + return ( + + ); +} diff --git a/src/components/availability/availability-actions.tsx b/src/components/availability/availability-actions.tsx index 871503a0b..eff6cae8c 100644 --- a/src/components/availability/availability-actions.tsx +++ b/src/components/availability/availability-actions.tsx @@ -1,19 +1,18 @@ "use client"; -import { getGoogleCalendarPrefilledLink } from "@actions/availability/google/calendar/action"; import { updateMeetingInvitePermissions } from "@actions/meeting/invite/action"; import { Create, GroupAddOutlined, InsertInvitationRounded, } from "@mui/icons-material"; -import GoogleIcon from "@mui/icons-material/Google"; import { Button, FormControlLabel, Switch } from "@mui/material"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { useShallow } from "zustand/shallow"; +import { AddToCalendarButton } from "@/components/availability/add-to-calendar-button"; import { useSnackbar } from "@/components/ui/snackbar-provider"; -import type { SelectMeeting } from "@/db/schema"; +import type { MeetingGoogleCalendarSnapshot, SelectMeeting } from "@/db/schema"; import { useReturnToPath } from "@/hooks/use-return-to-path"; import { loginPathWithReturnTo } from "@/lib/auth/return-to"; import type { UserProfile } from "@/lib/auth/user"; @@ -31,6 +30,8 @@ export interface AvailabilityActionsProps { setTimezone: (timezone: string) => void; onOpenInviteDialog: () => void; isMeetingDeletionPending?: boolean; + mergedScheduledInterval?: MeetingGoogleCalendarSnapshot | null; + googleCalendarLinkSnapshot?: MeetingGoogleCalendarSnapshot | null; } export function AvailabilityActions({ @@ -45,11 +46,12 @@ export function AvailabilityActions({ setTimezone, onOpenInviteDialog, isMeetingDeletionPending = false, + mergedScheduledInterval = null, + googleCalendarLinkSnapshot = null, }: AvailabilityActionsProps) { const router = useRouter(); const returnToPath = useReturnToPath(); const { showSuccess, showError } = useSnackbar(); - const [isGeneratingLink, setIsGeneratingLink] = useState(false); const [membersCanInvite, setMembersCanInvite] = useState( meetingData.membersCanInvite, ); @@ -153,47 +155,12 @@ export function AvailabilityActions({ )} {isScheduled && (
- +
)}
diff --git a/src/components/availability/availability.tsx b/src/components/availability/availability.tsx index 026904629..3ce5c70c4 100644 --- a/src/components/availability/availability.tsx +++ b/src/components/availability/availability.tsx @@ -20,7 +20,11 @@ import { AvailabilityTableHeader } from "@/components/availability/table/availab import { TimeZoneDropdown } from "@/components/availability/table/availability-timezone"; import { StudyRoomHoverProvider } from "@/components/availability/table/study-room-hover-context"; import { InviteMembersDialog } from "@/components/groups/add-member-dialog"; -import type { SelectMeeting, SelectScheduledMeeting } from "@/db/schema"; +import type { + MeetingGoogleCalendarSnapshot, + SelectMeeting, + SelectScheduledMeeting, +} from "@/db/schema"; import { useAvailabilityActionHandlers } from "@/hooks/use-availability-action-handlers"; import { useAvailabilityData } from "@/hooks/use-availability-data"; import { useCalendarOverlays } from "@/hooks/use-calendar-overlays"; @@ -54,6 +58,8 @@ export function Availability({ allAvailabilities, user, scheduledBlocks, + mergedScheduledInterval = null, + googleCalendarLinkSnapshot = null, autoOpenInviteDialog = false, inviteQueryInUrl = false, }: { @@ -61,6 +67,8 @@ export function Availability({ allAvailabilities: MemberMeetingAvailability[]; user: UserProfile | null; scheduledBlocks: SelectScheduledMeeting[]; + mergedScheduledInterval?: MeetingGoogleCalendarSnapshot | null; + googleCalendarLinkSnapshot?: MeetingGoogleCalendarSnapshot | null; autoOpenInviteDialog?: boolean; inviteQueryInUrl?: boolean; }) { @@ -373,6 +381,8 @@ export function Availability({ setTimezone: setUserTimezone, onOpenInviteDialog: handleOpenInviteDialog, isMeetingDeletionPending, + mergedScheduledInterval, + googleCalendarLinkSnapshot, }; const isMeetingOwner = Boolean(user && meetingData.hostId === user.memberId); diff --git a/src/components/meetings/delete-modal.tsx b/src/components/meetings/delete-modal.tsx index 93655b529..4b82fdd6c 100644 --- a/src/components/meetings/delete-modal.tsx +++ b/src/components/meetings/delete-modal.tsx @@ -1,6 +1,9 @@ "use client"; -import { archiveMeeting } from "@actions/meeting/archive/action"; +import { + type ArchiveMeetingResult, + archiveMeeting, +} from "@actions/meeting/archive/action"; import { leaveMeeting } from "@actions/meeting/leave/action"; import { Button, @@ -38,7 +41,7 @@ export const DeleteModal = ({ }: DeleteModalProps) => { const router = useRouter(); const pathname = usePathname(); - const { showSuccess, showError } = useSnackbar(); + const { showSuccess, showError, showWarning } = useSnackbar(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm"), { noSsr: true, @@ -57,24 +60,35 @@ export const DeleteModal = ({ const handleConfirm = async () => { onDeletionPendingChange(true); try { - const { success, error } = isOwner - ? await archiveMeeting(meetingData) - : await leaveMeeting(meetingData); + if (isOwner) { + const result: ArchiveMeetingResult = await archiveMeeting(meetingData); + if (!result.success) { + showError(result.error ?? "Something went wrong."); + return; + } - if (success) { - showSuccess( - isOwner - ? `You successfully deleted "${meetingData.title}".` - : `You left "${meetingData.title}".`, - ); - handleOpenChange(false); - if (pathname === "/summary") { - router.refresh(); + const partialFailures = result.calendarOutcome?.failed ?? 0; + if (partialFailures > 0) { + showWarning( + `Deleted "${meetingData.title}", but could not remove the event from ${partialFailures} member calendar(s). Members may need to delete it manually.`, + ); } else { - router.push("/summary"); + showSuccess(`You successfully deleted "${meetingData.title}".`); + } + } else { + const result = await leaveMeeting(meetingData); + if (!result.success) { + showError(result.error ?? "Something went wrong."); + return; } + showSuccess(`You left "${meetingData.title}".`); + } + + handleOpenChange(false); + if (pathname === "/summary") { + router.refresh(); } else { - showError(error ?? "Something went wrong."); + router.push("/summary"); } } finally { onDeletionPendingChange(false); diff --git a/src/components/ui/snackbar-provider.tsx b/src/components/ui/snackbar-provider.tsx index b86e12898..193d3240c 100644 --- a/src/components/ui/snackbar-provider.tsx +++ b/src/components/ui/snackbar-provider.tsx @@ -14,6 +14,7 @@ interface SnackbarState { interface SnackbarContextType { showSuccess: (message: string) => void; showError: (message: string) => void; + showWarning: (message: string) => void; } const SnackbarContext = createContext( @@ -48,13 +49,18 @@ export function SnackbarProvider({ children }: { children: React.ReactNode }) { [show], ); + const showWarning = useCallback( + (message: string) => show(message, "warning"), + [show], + ); + const handleClose = (_: unknown, reason?: string) => { if (reason === "clickaway") return; setState((s) => ({ ...s, open: false })); }; return ( - + {children} statement-breakpoint +DO $$ BEGIN + ALTER TABLE "meeting_google_calendar_events" ADD CONSTRAINT "meeting_google_calendar_events_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "meeting_google_calendar_events" ADD CONSTRAINT "meeting_google_calendar_events_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/src/db/migrations/meta/0018_snapshot.json b/src/db/migrations/meta/0018_snapshot.json new file mode 100644 index 000000000..d376f6998 --- /dev/null +++ b/src/db/migrations/meta/0018_snapshot.json @@ -0,0 +1,1367 @@ +{ + "id": "3baca779-b35c-4c33-9b85-421c7ca106e1", + "prevId": "994d6447-5510-4538-867f-4b641182950b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.availabilities": { + "name": "availabilities", + "schema": "", + "columns": { + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "meeting_id": { + "name": "meeting_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "attendance", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "meeting_availabilities": { + "name": "meeting_availabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "ifNeeded_availabilities": { + "name": "ifNeeded_availabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "availabilities_member_id_members_id_fk": { + "name": "availabilities_member_id_members_id_fk", + "tableFrom": "availabilities", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "availabilities_meeting_id_meetings_id_fk": { + "name": "availabilities_meeting_id_meetings_id_fk", + "tableFrom": "availabilities", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "availabilities_member_id_meeting_id_pk": { + "name": "availabilities_member_id_meeting_id_pk", + "columns": [ + "member_id", + "meeting_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_invite_responses": { + "name": "group_invite_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invite_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_invite_responses_invite_id_group_invites_id_fk": { + "name": "group_invite_responses_invite_id_group_invites_id_fk", + "tableFrom": "group_invite_responses", + "tableTo": "group_invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_invite_responses_user_id_users_id_fk": { + "name": "group_invite_responses_user_id_users_id_fk", + "tableFrom": "group_invite_responses", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_invite_responses_invite_user_unique": { + "name": "group_invite_responses_invite_user_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_invites": { + "name": "group_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitee_email": { + "name": "invitee_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_invites_group_id_groups_id_fk": { + "name": "group_invites_group_id_groups_id_fk", + "tableFrom": "group_invites", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_invites_inviter_id_users_id_fk": { + "name": "group_invites_inviter_id_users_id_fk", + "tableFrom": "group_invites", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_invites_invite_token_unique": { + "name": "group_invites_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groups": { + "name": "groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "groups_user_id_users_id_fk": { + "name": "groups_user_id_users_id_fk", + "tableFrom": "groups", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_google_calendar_events": { + "name": "meeting_google_calendar_events", + "schema": "", + "columns": { + "meeting_id": { + "name": "meeting_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "google_calendar_id": { + "name": "google_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "google_event_id": { + "name": "google_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_snapshot": { + "name": "last_synced_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "meeting_google_calendar_events_meeting_id_meetings_id_fk": { + "name": "meeting_google_calendar_events_meeting_id_meetings_id_fk", + "tableFrom": "meeting_google_calendar_events", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_google_calendar_events_member_id_members_id_fk": { + "name": "meeting_google_calendar_events_member_id_members_id_fk", + "tableFrom": "meeting_google_calendar_events", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "meeting_google_calendar_events_meeting_id_member_id_pk": { + "name": "meeting_google_calendar_events_meeting_id_member_id_pk", + "columns": [ + "meeting_id", + "member_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_invite_responses": { + "name": "meeting_invite_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invite_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meeting_invite_responses_invite_id_meeting_invites_id_fk": { + "name": "meeting_invite_responses_invite_id_meeting_invites_id_fk", + "tableFrom": "meeting_invite_responses", + "tableTo": "meeting_invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_invite_responses_user_id_users_id_fk": { + "name": "meeting_invite_responses_user_id_users_id_fk", + "tableFrom": "meeting_invite_responses", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "meeting_invite_responses_invite_user_unique": { + "name": "meeting_invite_responses_invite_user_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_invites": { + "name": "meeting_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "meeting_id": { + "name": "meeting_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meeting_invites_meeting_id_meetings_id_fk": { + "name": "meeting_invites_meeting_id_meetings_id_fk", + "tableFrom": "meeting_invites", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_invites_inviter_id_users_id_fk": { + "name": "meeting_invites_inviter_id_users_id_fk", + "tableFrom": "meeting_invites", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "meeting_invites_invite_token_unique": { + "name": "meeting_invites_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled": { + "name": "scheduled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "from_time": { + "name": "from_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "to_time": { + "name": "to_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dates": { + "name": "dates", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "meeting_type": { + "name": "meeting_type", + "type": "meeting_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dates'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "members_can_invite": { + "name": "members_can_invite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "meetings_group_id_groups_id_fk": { + "name": "meetings_group_id_groups_id_fk", + "tableFrom": "meetings", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meetings_host_id_members_id_fk": { + "name": "meetings_host_id_members_id_fk", + "tableFrom": "meetings", + "tableTo": "members", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_name": { + "name": "google_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "school": { + "name": "school", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_picture": { + "name": "profile_picture", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_username_unique": { + "name": "members_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "meeting_invites": { + "name": "meeting_invites", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "group_invites": { + "name": "group_invites", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "nudges": { + "name": "nudges", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "notification_preferences_member_id_members_id_fk": { + "name": "notification_preferences_member_id_members_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_member_id_unique": { + "name": "notification_preferences_member_id_unique", + "nullsNotDistinct": false, + "columns": [ + "member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect": { + "name": "redirect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_members_id_fk": { + "name": "notifications_user_id_members_id_fk", + "tableFrom": "notifications", + "tableTo": "members", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_group_id_groups_id_fk": { + "name": "notifications_group_id_groups_id_fk", + "tableFrom": "notifications", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_id_provider_user_id_pk": { + "name": "oauth_accounts_provider_id_provider_user_id_pk", + "columns": [ + "provider_id", + "provider_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_meetings": { + "name": "scheduled_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "meeting_id": { + "name": "meeting_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scheduled_from_time": { + "name": "scheduled_from_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "scheduled_to_time": { + "name": "scheduled_to_time", + "type": "time", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "scheduled_meetings_meeting_id_meetings_id_fk": { + "name": "scheduled_meetings_meeting_id_meetings_id_fk", + "tableFrom": "scheduled_meetings", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_access_token": { + "name": "oidc_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_refresh_token": { + "name": "oidc_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_access_token": { + "name": "google_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_refresh_token": { + "name": "google_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_access_token_expires_at": { + "name": "google_access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_idx_sessions": { + "name": "user_idx_sessions", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "theme_mode": { + "name": "theme_mode", + "type": "theme_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + } + }, + "indexes": {}, + "foreignKeys": { + "users_member_id_members_id_fk": { + "name": "users_member_id_members_id_fk", + "tableFrom": "users", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users_in_group": { + "name": "users_in_group", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "group_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": {}, + "foreignKeys": { + "users_in_group_user_id_users_id_fk": { + "name": "users_in_group_user_id_users_id_fk", + "tableFrom": "users_in_group", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_in_group_group_id_groups_id_fk": { + "name": "users_in_group_group_id_groups_id_fk", + "tableFrom": "users_in_group", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_in_group_group_id_user_id_pk": { + "name": "users_in_group_group_id_user_id_pk", + "columns": [ + "group_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.attendance": { + "name": "attendance", + "schema": "public", + "values": [ + "accepted", + "maybe", + "declined" + ] + }, + "public.group_role": { + "name": "group_role", + "schema": "public", + "values": [ + "member", + "admin" + ] + }, + "public.invite_status": { + "name": "invite_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "expired" + ] + }, + "public.meeting_type": { + "name": "meeting_type", + "schema": "public", + "values": [ + "dates", + "days" + ] + }, + "public.theme_mode": { + "name": "theme_mode", + "schema": "public", + "values": [ + "light", + "dark", + "system" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 054d925e6..4bf58452e 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1779145500000, "tag": "0017_notification_preferences", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1779365656823, + "tag": "0018_google_calendar_events", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index a6f0bbbff..b51c37375 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -247,6 +247,45 @@ export type SelectScheduledMeeting = InferSelectModel; export type InsertMeeting = InferInsertModel; export type SelectMeeting = InferSelectModel; +export type MeetingGoogleCalendarSnapshot = { + date: string; + fromTime: string; + toTime: string; +}; + +export const meetingGoogleCalendarEvents = pgTable( + "meeting_google_calendar_events", + { + meetingId: uuid("meeting_id") + .notNull() + .references(() => meetings.id, { onDelete: "cascade" }), + memberId: uuid("member_id") + .notNull() + .references(() => members.id, { onDelete: "cascade" }), + googleCalendarId: text("google_calendar_id").notNull().default("primary"), + googleEventId: text("google_event_id").notNull(), + lastSyncedSnapshot: jsonb("last_synced_snapshot") + .$type() + .notNull(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + mode: "date", + }) + .notNull() + .defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.meetingId, table.memberId] }), + }), +); + +export type InsertMeetingGoogleCalendarEvent = InferInsertModel< + typeof meetingGoogleCalendarEvents +>; +export type SelectMeetingGoogleCalendarEvent = InferSelectModel< + typeof meetingGoogleCalendarEvents +>; + export const meetingInvites = pgTable("meeting_invites", { id: uuid("id").defaultRandom().primaryKey(), meetingId: uuid("meeting_id") @@ -468,6 +507,20 @@ export const scheduledMeetingsRelations = relations( }), ); +export const meetingGoogleCalendarEventsRelations = relations( + meetingGoogleCalendarEvents, + ({ one }) => ({ + meeting: one(meetings, { + fields: [meetingGoogleCalendarEvents.meetingId], + references: [meetings.id], + }), + member: one(members, { + fields: [meetingGoogleCalendarEvents.memberId], + references: [members.id], + }), + }), +); + export const oauthAccountsRelations = relations(oauthAccounts, ({ one }) => ({ user: one(users, { fields: [oauthAccounts.userId], diff --git a/src/hooks/use-availability-action-handlers.ts b/src/hooks/use-availability-action-handlers.ts index 048122e97..9c93a05ba 100644 --- a/src/hooks/use-availability-action-handlers.ts +++ b/src/hooks/use-availability-action-handlers.ts @@ -1,10 +1,7 @@ "use client"; import { savePersonalAvailability } from "@actions/availability/save/action"; -import { - deleteScheduledTimeBlock, - saveScheduledTimeBlock, -} from "@actions/meeting/schedule/action"; +import { commitMeetingSchedule } from "@actions/meeting/schedule/action"; import { useCallback } from "react"; import { useShallow } from "zustand/shallow"; import { useSnackbar } from "@/components/ui/snackbar-provider"; @@ -34,7 +31,7 @@ export function useAvailabilityActionHandlers({ setChangeableTimezone, isMeetingDeletionPending = false, }: UseAvailabilityActionHandlersParams) { - const { showError } = useSnackbar(); + const { showError, showSuccess } = useSnackbar(); const { setHasAvailability, @@ -120,36 +117,14 @@ export function useAvailabilityActionHandlers({ async (opts?: { skipExitToGroup?: boolean }): Promise => { if (isMeetingDeletionPending) return false; try { - const { pendingAdds, pendingRemovals } = + const { scheduledTimes, pendingAdds, pendingRemovals } = useAvailabilityStore.getState(); - for (const timestamp of pendingRemovals) { - const date = new Date(timestamp); - const scheduledDate = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate(), - ); - const scheduledFromTime = date.toTimeString().slice(0, 8); - const scheduledToTime = new Date(date.getTime() + 15 * 60 * 1000) - .toTimeString() - .slice(0, 8); - - const removalResult = await deleteScheduledTimeBlock({ - meetingId: meetingData.id, - scheduledDate, - scheduledFromTime, - scheduledToTime, - }); - if ("error" in removalResult) { - showError( - removalResult.error ?? "Failed to delete scheduled meeting.", - ); - return false; - } - } + const finalTimestamps = new Set(scheduledTimes); + for (const ts of pendingRemovals) finalTimestamps.delete(ts); + for (const ts of pendingAdds) finalTimestamps.add(ts); - for (const timestamp of pendingAdds) { + const blocks = Array.from(finalTimestamps).map((timestamp) => { const date = new Date(timestamp); const scheduledDate = new Date( date.getFullYear(), @@ -160,22 +135,30 @@ export function useAvailabilityActionHandlers({ const scheduledToTime = new Date(date.getTime() + 15 * 60 * 1000) .toTimeString() .slice(0, 8); + return { scheduledDate, scheduledFromTime, scheduledToTime }; + }); + + const response = await commitMeetingSchedule({ + meetingId: meetingData.id, + blocks, + }); - const saveResult = await saveScheduledTimeBlock({ - meetingId: meetingData.id, - scheduledDate, - scheduledFromTime, - scheduledToTime, - }); - if ("error" in saveResult) { - showError(saveResult.error ?? "Failed to save scheduled meeting."); - return false; - } + if (!response.success) { + showError(response.error ?? "Failed to save meeting schedule."); + return false; } if (pendingAdds.size > 0 || pendingRemovals.size > 0) { commitPendingTimes(); } + + const { synced, skipped, failed } = response.outcome; + if (blocks.length > 0 && synced + skipped + failed > 0) { + showSuccess( + `Scheduled. ${synced} added to calendars, ${skipped} skipped, ${failed} failed.`, + ); + } + if (!opts?.skipExitToGroup) { setAvailabilityView("group"); } @@ -190,6 +173,7 @@ export function useAvailabilityActionHandlers({ isMeetingDeletionPending, meetingData.id, showError, + showSuccess, commitPendingTimes, setAvailabilityView, ], diff --git a/src/lib/auth/google.ts b/src/lib/auth/google.ts index 8d699165b..d9e37daa5 100644 --- a/src/lib/auth/google.ts +++ b/src/lib/auth/google.ts @@ -1,24 +1,25 @@ // biome-ignore-all lint: fix notnull later +import { and, desc, eq, gt, isNotNull } from "drizzle-orm"; +import { db } from "@/db"; +import type { InsertSession } from "@/db/schema"; +import { sessions } from "@/db/schema"; import { getCurrentSession } from "@/lib/auth"; import { updateSessionGoogleTokens } from "@/lib/auth/session"; export type ValidateOAuthAccessTokenError = | "Not authenticated" | "No OAuth refresh token" + | "No valid session" | "Failed to refresh OAuth token"; export type OAuthTokenResult = | { accessToken: string; error: null } | { accessToken: null; error: ValidateOAuthAccessTokenError }; -export async function validateGoogleAccessToken(): Promise { - const { session } = await getCurrentSession(); - - if (session === null) { - return { accessToken: null, error: "Not authenticated" }; - } - +async function refreshGoogleAccessTokenForSession( + session: InsertSession, +): Promise { if (!session.oidcRefreshToken) { return { accessToken: null, error: "No OAuth refresh token" }; } @@ -26,7 +27,8 @@ export async function validateGoogleAccessToken(): Promise { const now = Date.now(); if ( session.googleAccessToken && - session.googleAccessTokenExpiresAt!.getTime() > now + session.googleAccessTokenExpiresAt && + session.googleAccessTokenExpiresAt.getTime() > now ) { return { accessToken: session.googleAccessToken, error: null }; } @@ -66,9 +68,9 @@ export async function validateGoogleAccessToken(): Promise { throw new Error("No Google access token in refresh response"); } - await updateSessionGoogleTokens(session.id, { - oauthAccessToken: googleAccessToken, - oauthAccessTokenExpiresAt: googleTokenExpiry, + await updateSessionGoogleTokens(session.id!, { + googleAccessToken, + googleAccessTokenExpiresAt: googleTokenExpiry, }); return { accessToken: googleAccessToken, error: null }; @@ -76,3 +78,48 @@ export async function validateGoogleAccessToken(): Promise { return { accessToken: null, error: "Failed to refresh OAuth token" }; } } + +export async function validateGoogleAccessToken(): Promise { + const { session } = await getCurrentSession(); + + if (session === null) { + return { accessToken: null, error: "Not authenticated" }; + } + + return refreshGoogleAccessTokenForSession(session); +} + +const SESSION_FALLBACK_LIMIT = 5; + +export async function getGoogleAccessTokenForUser( + userId: string, +): Promise { + const now = new Date(); + + const candidates = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.userId, userId), + gt(sessions.expiresAt, now), + isNotNull(sessions.oidcRefreshToken), + ), + ) + .orderBy(desc(sessions.expiresAt)) + .limit(SESSION_FALLBACK_LIMIT); + + if (candidates.length === 0) { + return { accessToken: null, error: "No valid session" }; + } + + let lastError: ValidateOAuthAccessTokenError = "No valid session"; + for (const candidate of candidates) { + const result = await refreshGoogleAccessTokenForSession(candidate); + if (!result.error) { + return result; + } + lastError = result.error; + } + return { accessToken: null, error: lastError }; +} diff --git a/src/lib/auth/providers.ts b/src/lib/auth/providers.ts index e2ae8e82e..1105b14a7 100644 --- a/src/lib/auth/providers.ts +++ b/src/lib/auth/providers.ts @@ -19,6 +19,7 @@ export const OAUTH_LOGIN_CONFIG = { "profile", "email", "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events.owned", ], }, apple: { diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index f7f53dd7b..4a015138f 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -100,15 +100,15 @@ export async function invalidateSession(sessionId: string): Promise { export async function updateSessionGoogleTokens( sessionId: string, tokens: { - oauthAccessToken: string; - oauthAccessTokenExpiresAt: Date; + googleAccessToken: string; + googleAccessTokenExpiresAt: Date; }, ): Promise { await db .update(sessions) .set({ - googleAccessToken: tokens.oauthAccessToken, - googleAccessTokenExpiresAt: tokens.oauthAccessTokenExpiresAt, + googleAccessToken: tokens.googleAccessToken, + googleAccessTokenExpiresAt: tokens.googleAccessTokenExpiresAt, }) .where(eq(sessions.id, sessionId)); } diff --git a/src/lib/google-calendar/snapshot.ts b/src/lib/google-calendar/snapshot.ts new file mode 100644 index 000000000..8b4508c8c --- /dev/null +++ b/src/lib/google-calendar/snapshot.ts @@ -0,0 +1,34 @@ +import type { MeetingGoogleCalendarSnapshot } from "@/db/schema"; + +export function snapshotsMatch( + a: MeetingGoogleCalendarSnapshot, + b: MeetingGoogleCalendarSnapshot, +): boolean { + return ( + a.date === b.date && a.fromTime === b.fromTime && a.toTime === b.toTime + ); +} + +export type AddToCalendarLabelState = + | "add" + | "in_sync" + | "drifted" + | "non_contiguous"; + +export function deriveAddToCalendarLabelState({ + storedSnapshot, + currentInterval, +}: { + storedSnapshot: MeetingGoogleCalendarSnapshot | null; + currentInterval: MeetingGoogleCalendarSnapshot | null; +}): AddToCalendarLabelState { + if (currentInterval === null) { + return "non_contiguous"; + } + if (storedSnapshot === null) { + return "add"; + } + return snapshotsMatch(storedSnapshot, currentInterval) + ? "in_sync" + : "drifted"; +} diff --git a/src/lib/meetings/utils.ts b/src/lib/meetings/utils.ts index fda3d68ec..201308b1a 100644 --- a/src/lib/meetings/utils.ts +++ b/src/lib/meetings/utils.ts @@ -119,6 +119,16 @@ export function formatLocalDateKey(date: Date): string { return `${yyyy}-${mm}-${dd}`; } +// Returns YYYY-MM-DD in the given IANA timezone. en-CA produces ISO ordering. +export function formatDateInTimezone(date: Date, timeZone: string): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(date); +} + export function groupScheduledBlocksByDate( blocks: SelectScheduledMeeting[] = [], ) { @@ -139,12 +149,23 @@ export function groupScheduledBlocksByDate( .sort((a, b) => a.date.getTime() - b.date.getTime()); } -// Merge 15-min contiguous blocks into a single interval +// Merge 15-min contiguous blocks into a single interval. `timeZone` is the +// IANA TZ in which date-equality is evaluated (the meeting's timezone), so a +// schedule that's contiguous in the meeting's own day doesn't get split by +// the server's UTC midnight. export const mergeContiguousTimeBlocks = ( blocks: SelectScheduledMeeting[], + timeZone: string, ): TimeInterval | null => { if (blocks.length === 0) return null; + const dateKey = formatDateInTimezone(blocks[0].scheduledDate, timeZone); + for (const block of blocks) { + if (formatDateInTimezone(block.scheduledDate, timeZone) !== dateKey) { + return null; + } + } + // Sort by start time const sorted = [...blocks].sort((a, b) => a.scheduledFromTime.localeCompare(b.scheduledFromTime), diff --git a/src/server/actions/availability/google/calendar/action.ts b/src/server/actions/availability/google/calendar/action.ts index 7d5107b05..6119e788f 100644 --- a/src/server/actions/availability/google/calendar/action.ts +++ b/src/server/actions/availability/google/calendar/action.ts @@ -1,11 +1,131 @@ "use server"; -import { getScheduledTimeBlocks } from "@data/meeting/queries"; -import { google as googleClient } from "googleapis"; -import { validateGoogleAccessToken } from "@/lib/auth/google"; -import { mergeContiguousTimeBlocks } from "@/lib/meetings/utils"; +import { createHash } from "node:crypto"; +import { + getExistingMeeting, + getScheduledTimeBlocks, +} from "@data/meeting/queries"; +import { and, eq, gt, inArray, isNotNull } from "drizzle-orm"; +import { + type Auth, + type calendar_v3, + google as googleClient, +} from "googleapis"; +import { db } from "@/db"; +import { + availabilities, + type MeetingGoogleCalendarSnapshot, + meetingGoogleCalendarEvents, + members, + type SelectMeeting, + sessions, + users, +} from "@/db/schema"; +import { getCurrentSession } from "@/lib/auth"; +import { + getGoogleAccessTokenForUser, + validateGoogleAccessToken, +} from "@/lib/auth/google"; +import { + formatDateInTimezone, + mergeContiguousTimeBlocks, +} from "@/lib/meetings/utils"; import type { GoogleCalendarEvent } from "@/lib/types/availability"; +const PRIMARY_CALENDAR_ID = "primary"; +const FANOUT_CONCURRENCY = 5; + +const BASE32HEX_ALPHABET = "0123456789abcdefghijklmnopqrstuv"; + +function deterministicEventId(meetingId: string, memberId: string): string { + const digest = createHash("sha256") + .update(`${meetingId}:${memberId}`) + .digest(); + let id = ""; + for (let i = 0; i < 32; i++) { + id += BASE32HEX_ALPHABET[digest[i] & 0x1f]; + } + return id; +} + +function googleErrorCode(error: unknown): number | undefined { + if (typeof error !== "object" || error === null) return undefined; + const e = error as { code?: number; status?: number }; + return typeof e.code === "number" ? e.code : e.status; +} + +function isGoogleNotFound(error: unknown): boolean { + const code = googleErrorCode(error); + return code === 404 || code === 410; +} + +function isGoogleConflict(error: unknown): boolean { + return googleErrorCode(error) === 409; +} + +function isRetryableGoogleError(error: unknown): boolean { + const code = googleErrorCode(error); + if (code === undefined) return false; + if (code >= 500 && code < 600) return true; + if (code === 403) { + // Google distinguishes "rateLimitExceeded" / "userRateLimitExceeded" + // (retryable) from quota / auth 403s (not retryable). Without the + // reason field we'd retry every 403, so dig it out. + const reason = ( + error as { + errors?: Array<{ reason?: string }>; + } + ).errors?.[0]?.reason; + return reason === "rateLimitExceeded" || reason === "userRateLimitExceeded"; + } + return false; +} + +async function withRetry(fn: () => Promise, attempts = 3): Promise { + let lastErr: unknown; + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (i === attempts - 1 || !isRetryableGoogleError(err)) { + throw err; + } + const base = 250 * 2 ** i; + const jitter = Math.random() * base; + await new Promise((resolve) => setTimeout(resolve, base + jitter)); + } + } + throw lastErr; +} + +async function runBounded( + items: T[], + limit: number, + worker: (item: T) => Promise, +): Promise { + const results: R[] = Array.from({ length: items.length }); + let cursor = 0; + const runners = Array.from( + { length: Math.min(limit, items.length) }, + async () => { + while (true) { + const i = cursor++; + if (i >= items.length) return; + results[i] = await worker(items[i]); + } + }, + ); + await Promise.all(runners); + return results; +} + +function meetingDeepLink(meetingId: string): string { + const baseUrl = + process.env.NEXT_PUBLIC_BASE_URL?.trim() || "http://localhost:3000"; + return `${baseUrl}/availability/${meetingId}`; +} + export async function fetchGoogleCalendarEvents( startDate: string, endDate: string, @@ -20,10 +140,7 @@ export async function fetchGoogleCalendarEvents( throw new Error(error ?? "Could not retrieve OAuth access token"); } - const auth = new googleClient.auth.OAuth2(); - auth.setCredentials({ access_token: accessToken }); - - const calendar = googleClient.calendar({ version: "v3", auth }); + const calendar = buildCalendarClient(accessToken); try { const calendarListRes = await calendar.calendarList.list(); @@ -73,71 +190,477 @@ export async function fetchGoogleCalendarEvents( } } -// helper for adding to google calendar -function combineDateAndTime(date: Date, time: string): string { - const [hours, minutes, seconds = "00"] = time.split(":"); +function buildCalendarClient(accessToken: string): calendar_v3.Calendar { + const auth: Auth.OAuth2Client = new googleClient.auth.OAuth2(); + auth.setCredentials({ access_token: accessToken }); - const yyyy = date.getFullYear(); - const mm = String(date.getMonth() + 1).padStart(2, "0"); // 1-indexed months for gcal - const dd = String(date.getDate()).padStart(2, "0"); + return googleClient.calendar({ version: "v3", auth }); +} - const hh = String(hours).padStart(2, "0"); - const min = String(minutes).padStart(2, "0"); - const ss = String(seconds).padStart(2, "0"); +export async function loadMergedScheduledInterval( + meetingId: string, +): Promise { + const meeting = await getExistingMeeting(meetingId); + return loadMergedScheduledIntervalFor(meeting); +} - return `${yyyy}${mm}${dd}T${hh}${min}${ss}`; +export async function loadMergedScheduledIntervalFor( + meeting: SelectMeeting, +): Promise { + const blocks = await getScheduledTimeBlocks(meeting.id); + + if (!blocks.length) { + return null; + } + + const merged = mergeContiguousTimeBlocks(blocks, meeting.timezone); + if (!merged) { + return null; + } + + return { + date: formatDateInTimezone(blocks[0].scheduledDate, meeting.timezone), + fromTime: merged.from, + toTime: merged.to, + }; +} + +export type CalendarWorkerResult = + | { status: "synced"; eventId: string } + | { status: "skipped"; reason: string } + | { status: "failed"; reason: string }; + +function buildEventRequestBody( + meeting: SelectMeeting, + memberId: string, + interval: MeetingGoogleCalendarSnapshot, +): calendar_v3.Schema$Event { + return { + summary: meeting.title, + description: meeting.description ?? "Meeting scheduled via ZotMeet.", + location: meeting.location ?? undefined, + start: { + dateTime: `${interval.date}T${interval.fromTime}`, + timeZone: meeting.timezone, + }, + end: { + dateTime: `${interval.date}T${interval.toTime}`, + timeZone: meeting.timezone, + }, + extendedProperties: { + private: { + zotmeetMeetingId: meeting.id, + zotmeetMemberId: memberId, + }, + }, + source: { + title: "ZotMeet", + url: meetingDeepLink(meeting.id), + }, + }; } -export async function getGoogleCalendarPrefilledLink({ +async function deleteTrackingRow( + meetingId: string, + memberId: string, +): Promise { + await db + .delete(meetingGoogleCalendarEvents) + .where( + and( + eq(meetingGoogleCalendarEvents.meetingId, meetingId), + eq(meetingGoogleCalendarEvents.memberId, memberId), + ), + ); +} + +async function upsertTrackingRow({ meetingId, - meetingTitle, - meetingDescription, - meetingLocation, - timezone, + memberId, + googleCalendarId, + googleEventId, + lastSyncedSnapshot, }: { meetingId: string; - meetingTitle: string; - meetingDescription: string | null; - meetingLocation: string | null; - timezone: string; -}) { - const blocks = await getScheduledTimeBlocks(meetingId); + memberId: string; + googleCalendarId: string; + googleEventId: string; + lastSyncedSnapshot: MeetingGoogleCalendarSnapshot; +}): Promise { + const updatedAt = new Date(); + await db + .insert(meetingGoogleCalendarEvents) + .values({ + meetingId, + memberId, + googleCalendarId, + googleEventId, + lastSyncedSnapshot, + updatedAt, + }) + .onConflictDoUpdate({ + target: [ + meetingGoogleCalendarEvents.meetingId, + meetingGoogleCalendarEvents.memberId, + ], + set: { + googleCalendarId, + googleEventId, + lastSyncedSnapshot, + updatedAt, + }, + }); +} - if (!blocks.length) { +export async function addOrUpdateMeetingGoogleCalendarEvent({ + meeting, + interval, + memberId, + accessToken, +}: { + meeting: SelectMeeting; + interval: MeetingGoogleCalendarSnapshot; + memberId: string; + accessToken: string; +}): Promise { + const meetingId = meeting.id; + const calendar = buildCalendarClient(accessToken); + const requestBody = buildEventRequestBody(meeting, memberId, interval); + const desiredEventId = deterministicEventId(meetingId, memberId); + + const existing = await db + .select() + .from(meetingGoogleCalendarEvents) + .where( + and( + eq(meetingGoogleCalendarEvents.meetingId, meetingId), + eq(meetingGoogleCalendarEvents.memberId, memberId), + ), + ) + .limit(1); + + let eventId: string | null = null; + let calendarId = PRIMARY_CALENDAR_ID; + + if (existing.length > 0) { + const row = existing[0]; + calendarId = row.googleCalendarId; + try { + const patched = await withRetry(() => + calendar.events.patch({ + calendarId, + eventId: row.googleEventId, + requestBody, + sendUpdates: "none", + }), + ); + eventId = patched.data.id ?? row.googleEventId; + } catch (err) { + if (!isGoogleNotFound(err)) { + console.error("events.patch failed", { meetingId, memberId, err }); + return { status: "failed", reason: "google_patch_failed" }; + } + await deleteTrackingRow(meetingId, memberId); + calendarId = PRIMARY_CALENDAR_ID; + } + } + + if (eventId === null) { + try { + const inserted = await withRetry(() => + calendar.events.insert({ + calendarId: PRIMARY_CALENDAR_ID, + sendUpdates: "none", + requestBody: { ...requestBody, id: desiredEventId }, + }), + ); + eventId = inserted.data.id ?? desiredEventId; + calendarId = PRIMARY_CALENDAR_ID; + } catch (err) { + if (!isGoogleConflict(err)) { + console.error("events.insert failed", { meetingId, memberId, err }); + return { status: "failed", reason: "google_insert_failed" }; + } + + try { + const fetched = await withRetry(() => + calendar.events.get({ + calendarId: PRIMARY_CALENDAR_ID, + eventId: desiredEventId, + }), + ); + eventId = fetched.data.id ?? desiredEventId; + calendarId = PRIMARY_CALENDAR_ID; + } catch (getErr) { + console.error("events.get after 409 failed", { + meetingId, + memberId, + getErr, + }); + return { status: "failed", reason: "google_recover_failed" }; + } + } + } + + await upsertTrackingRow({ + meetingId, + memberId, + googleCalendarId: calendarId, + googleEventId: eventId, + lastSyncedSnapshot: interval, + }); + + return { status: "synced", eventId }; +} + +export async function removeMeetingGoogleCalendarEvent({ + meetingId, + memberId, + accessToken, +}: { + meetingId: string; + memberId: string; + accessToken: string; +}): Promise<{ status: "removed" | "no_row" | "failed"; reason?: string }> { + const existing = await db + .select() + .from(meetingGoogleCalendarEvents) + .where( + and( + eq(meetingGoogleCalendarEvents.meetingId, meetingId), + eq(meetingGoogleCalendarEvents.memberId, memberId), + ), + ) + .limit(1); + + if (existing.length === 0) { + return { status: "no_row" }; + } + + const row = existing[0]; + const calendar = buildCalendarClient(accessToken); + + try { + await withRetry(() => + calendar.events.delete({ + calendarId: row.googleCalendarId, + eventId: row.googleEventId, + sendUpdates: "none", + }), + ); + } catch (err) { + if (!isGoogleNotFound(err)) { + console.error("events.delete failed", { meetingId, memberId, err }); + return { status: "failed", reason: "google_delete_failed" }; + } + } + + await deleteTrackingRow(meetingId, memberId); + + return { status: "removed" }; +} + +export type FanOutOutcome = { + synced: number; + skipped: number; + failed: number; + errors: Array<{ memberId: string; reason: string }>; +}; + +function emptyOutcome(): FanOutOutcome { + return { synced: 0, skipped: 0, failed: 0, errors: [] }; +} + +type FanOutEntry = { + status: "synced" | "skipped" | "failed"; + memberId: string; + reason?: string; +}; + +function tallyOutcome(entries: FanOutEntry[]): FanOutOutcome { + const outcome = emptyOutcome(); + for (const entry of entries) { + if (entry.status === "synced") { + outcome.synced += 1; + } else if (entry.status === "skipped") { + outcome.skipped += 1; + if (entry.reason) { + outcome.errors.push({ memberId: entry.memberId, reason: entry.reason }); + } + } else { + outcome.failed += 1; + if (entry.reason) { + outcome.errors.push({ memberId: entry.memberId, reason: entry.reason }); + } + } + } + return outcome; +} + +export async function syncMeetingToAllMemberCalendars( + meetingId: string, +): Promise { + const meeting = await getExistingMeeting(meetingId); + const interval = await loadMergedScheduledIntervalFor(meeting); + if (!interval) { return { - success: false, - link: null, + synced: 0, + skipped: 0, + failed: 1, + errors: [{ memberId: "*", reason: "non_contiguous_or_empty_schedule" }], }; } - // merge all 15-min blocks into 1 contiguous interval - const mergedInterval = mergeContiguousTimeBlocks(blocks); - if (!mergedInterval) { + const respondingMemberIds = ( + await db + .select({ memberId: availabilities.memberId }) + .from(availabilities) + .where(eq(availabilities.meetingId, meetingId)) + ).map((row) => row.memberId); + + if (respondingMemberIds.length === 0) { + return emptyOutcome(); + } + + const eligibleRows = await db + .selectDistinct({ + memberId: members.id, + userId: users.id, + }) + .from(members) + .innerJoin(users, eq(users.memberId, members.id)) + .innerJoin( + sessions, + and( + eq(sessions.userId, users.id), + gt(sessions.expiresAt, new Date()), + isNotNull(sessions.oidcRefreshToken), + ), + ) + .where(inArray(members.id, respondingMemberIds)); + + const seen = new Set(); + const eligible = eligibleRows.filter((row) => { + if (seen.has(row.memberId)) return false; + seen.add(row.memberId); + return true; + }); + + const entries = await runBounded( + eligible, + FANOUT_CONCURRENCY, + async (row) => { + const tokenResult = await getGoogleAccessTokenForUser(row.userId); + if (tokenResult.error || !tokenResult.accessToken) { + return { + status: "skipped" as const, + memberId: row.memberId, + reason: tokenResult.error ?? "no_access_token", + }; + } + + const result = await addOrUpdateMeetingGoogleCalendarEvent({ + meeting, + interval, + memberId: row.memberId, + accessToken: tokenResult.accessToken, + }); + + if (result.status === "synced") { + return { status: "synced" as const, memberId: row.memberId }; + } + return { + status: result.status, + memberId: row.memberId, + reason: result.reason, + }; + }, + ); + + return tallyOutcome(entries); +} + +export async function unsyncMeetingFromAllMemberCalendars( + meetingId: string, +): Promise { + const rows = await db + .select({ + memberId: meetingGoogleCalendarEvents.memberId, + userId: users.id, + }) + .from(meetingGoogleCalendarEvents) + .innerJoin(users, eq(users.memberId, meetingGoogleCalendarEvents.memberId)) + .where(eq(meetingGoogleCalendarEvents.meetingId, meetingId)); + + if (rows.length === 0) { + return emptyOutcome(); + } + + const entries = await runBounded(rows, FANOUT_CONCURRENCY, async (row) => { + const tokenResult = await getGoogleAccessTokenForUser(row.userId); + if (tokenResult.error || !tokenResult.accessToken) { + return { + status: "failed" as const, + memberId: row.memberId, + reason: tokenResult.error ?? "no_access_token", + }; + } + + const result = await removeMeetingGoogleCalendarEvent({ + meetingId, + memberId: row.memberId, + accessToken: tokenResult.accessToken, + }); + + if (result.status === "removed" || result.status === "no_row") { + return { status: "synced" as const, memberId: row.memberId }; + } return { - success: false, - link: null, + status: "failed" as const, + memberId: row.memberId, + reason: result.reason ?? "google_delete_failed", }; + }); + + return tallyOutcome(entries); +} + +export async function addMeetingToMyGoogleCalendar({ + meetingId, +}: { + meetingId: string; +}): Promise< + | { success: true; result: CalendarWorkerResult } + | { success: false; error: string } +> { + const { user } = await getCurrentSession(); + if (!user) { + return { success: false, error: "Not authenticated" }; } - // all blocks are now on the same date - const date = blocks[0].scheduledDate; + const tokenResult = await validateGoogleAccessToken(); + if (tokenResult.error || !tokenResult.accessToken) { + return { + success: false, + error: tokenResult.error ?? "Could not obtain Google access token", + }; + } - const start = combineDateAndTime(date, mergedInterval.from); - const end = combineDateAndTime(date, mergedInterval.to); + const meeting = await getExistingMeeting(meetingId); + const interval = await loadMergedScheduledIntervalFor(meeting); + if (!interval) { + return { + success: true, + result: { status: "failed", reason: "non_contiguous_schedule" }, + }; + } - const params = new URLSearchParams({ - action: "TEMPLATE", - text: meetingTitle, - dates: `${start}/${end}`, - details: meetingDescription ?? "Meeting scheduled via ZotMeet.", - location: meetingLocation ?? "", - ctz: timezone, + const result = await addOrUpdateMeetingGoogleCalendarEvent({ + meeting, + interval, + memberId: user.memberId, + accessToken: tokenResult.accessToken, }); - const link = `https://calendar.google.com/calendar/render?${params.toString()}`; - - return { - success: true, - link, - }; + return { success: true, result }; } diff --git a/src/server/actions/meeting/archive/action.ts b/src/server/actions/meeting/archive/action.ts index 37de204ce..4dd035a5d 100644 --- a/src/server/actions/meeting/archive/action.ts +++ b/src/server/actions/meeting/archive/action.ts @@ -4,8 +4,18 @@ import { eq } from "drizzle-orm"; import { db } from "@/db"; import { meetings, type SelectMeeting } from "@/db/schema"; import { getCurrentSession } from "@/lib/auth"; +import { + type FanOutOutcome, + unsyncMeetingFromAllMemberCalendars, +} from "@/server/actions/availability/google/calendar/action"; -export async function archiveMeeting(meetingData: SelectMeeting) { +export type ArchiveMeetingResult = + | { success: true; calendarOutcome: FanOutOutcome; error?: undefined } + | { success?: undefined; error: string }; + +export async function archiveMeeting( + meetingData: SelectMeeting, +): Promise { const meetingId = meetingData.id; const { user } = await getCurrentSession(); @@ -25,5 +35,33 @@ export async function archiveMeeting(meetingData: SelectMeeting) { .set({ archived: true }) .where(eq(meetings.id, meetingId)); - return { success: true }; + let calendarOutcome: FanOutOutcome; + try { + calendarOutcome = await unsyncMeetingFromAllMemberCalendars(meetingId); + if (calendarOutcome.failed > 0) { + console.warn("archiveMeeting: partial Google Calendar unsync", { + meetingId, + synced: calendarOutcome.synced, + skipped: calendarOutcome.skipped, + failed: calendarOutcome.failed, + errors: calendarOutcome.errors, + }); + } + } catch (error) { + console.error( + "Failed to unsync Google Calendar events on archiveMeeting:", + { + meetingId, + error, + }, + ); + calendarOutcome = { + synced: 0, + skipped: 0, + failed: 1, + errors: [{ memberId: "*", reason: "unsync_threw" }], + }; + } + + return { success: true, calendarOutcome }; } diff --git a/src/server/actions/meeting/leave/action.ts b/src/server/actions/meeting/leave/action.ts index 9306930fa..113a02ced 100644 --- a/src/server/actions/meeting/leave/action.ts +++ b/src/server/actions/meeting/leave/action.ts @@ -4,6 +4,8 @@ import { and, eq } from "drizzle-orm"; import { db } from "@/db"; import { availabilities, meetings, type SelectMeeting } from "@/db/schema"; import { getCurrentSession } from "@/lib/auth"; +import { validateGoogleAccessToken } from "@/lib/auth/google"; +import { removeMeetingGoogleCalendarEvent } from "@/server/actions/availability/google/calendar/action"; export type MeetingMemberActionResult = | { success: true; error?: undefined } @@ -79,6 +81,22 @@ export async function leaveMeeting( ), ); + try { + const tokenResult = await validateGoogleAccessToken(); + if (tokenResult.accessToken) { + await removeMeetingGoogleCalendarEvent({ + meetingId: meetingData.id, + memberId: user.memberId, + accessToken: tokenResult.accessToken, + }); + } + } catch (error) { + console.error( + "Failed to remove Google Calendar event on leaveMeeting:", + error, + ); + } + return { success: true }; } diff --git a/src/server/actions/meeting/schedule/action.ts b/src/server/actions/meeting/schedule/action.ts index 8b3868228..4531a4826 100644 --- a/src/server/actions/meeting/schedule/action.ts +++ b/src/server/actions/meeting/schedule/action.ts @@ -1,105 +1,109 @@ "use server"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; import { db } from "@/db"; import { meetings, scheduledMeetings } from "@/db/schema"; +import { getCurrentSession } from "@/lib/auth"; +import { + type FanOutOutcome, + syncMeetingToAllMemberCalendars, + unsyncMeetingFromAllMemberCalendars, +} from "@/server/actions/availability/google/calendar/action"; import { getExistingMeeting } from "@/server/data/meeting/queries"; +export type CommitMeetingScheduleResult = + | { success: true; outcome: FanOutOutcome } + | { success: false; error: string }; + +export type CommitMeetingScheduleBlock = { + scheduledDate: Date; + scheduledFromTime: string; + scheduledToTime: string; +}; + /** - * Delete a scheduled meeting block. - * If no blocks remain, mark meeting as unscheduled. + * Atomic schedule commit for the host. Wipes and rewrites + * `scheduled_meetings` for the meeting in a single transaction, flips the + * `meetings.scheduled` flag, then reconciles the final schedule against + * every eligible member's primary Google Calendar: + * - blocks present → fan-out add/update via `syncMeetingToAllMemberCalendars` + * - blocks empty → fan-out delete via `unsyncMeetingFromAllMemberCalendars` + * so previously-synced events don't linger when the host unschedules. + * + * Fan-out runs *after* the DB transaction commits so a partial Google API + * failure cannot roll back the local schedule. */ -export async function deleteScheduledTimeBlock({ +export async function commitMeetingSchedule({ meetingId, - scheduledFromTime, - scheduledToTime, - scheduledDate, + blocks, }: { meetingId: string; - scheduledFromTime: string; // format "HH:mm:ss" - scheduledToTime: string; // format "HH:mm:ss" - scheduledDate: Date; -}) { + blocks: CommitMeetingScheduleBlock[]; +}): Promise { try { - // Ensure the meeting exists + const { user } = await getCurrentSession(); + if (!user) { + return { + success: false, + error: "You must be logged in to commit a meeting schedule.", + }; + } + const meeting = await getExistingMeeting(meetingId); - if (!meeting) { - return { error: "Invalid meeting ID" }; + if (meeting.hostId !== user.memberId) { + return { + success: false, + error: "Only the meeting owner can commit a schedule.", + }; } - // Delete the specific scheduled block - await db - .delete(scheduledMeetings) - .where( - and( - eq(scheduledMeetings.meetingId, meetingId), - eq(scheduledMeetings.scheduledDate, scheduledDate), - eq(scheduledMeetings.scheduledFromTime, scheduledFromTime), - eq(scheduledMeetings.scheduledToTime, scheduledToTime), - ), - ); + await db.transaction(async (tx) => { + await tx + .delete(scheduledMeetings) + .where(eq(scheduledMeetings.meetingId, meetingId)); - // Check if any scheduled blocks remain - const remaining = await db - .select() - .from(scheduledMeetings) - .where(eq(scheduledMeetings.meetingId, meetingId)); + if (blocks.length > 0) { + await tx.insert(scheduledMeetings).values( + blocks.map((b) => ({ + meetingId, + scheduledDate: b.scheduledDate, + scheduledFromTime: b.scheduledFromTime, + scheduledToTime: b.scheduledToTime, + })), + ); + } - // Update meetings.scheduled if no blocks remain - if (remaining.length === 0) { - await db + await tx .update(meetings) - .set({ scheduled: false }) + .set({ scheduled: blocks.length > 0 }) .where(eq(meetings.id, meetingId)); - } - - return { success: true }; - } catch (error) { - console.error("Error deleting scheduled meeting:", error); - return { error: "Failed to delete scheduled meeting." }; - } -} + }); -/** - * Save a scheduled meeting block and update the meetings table - */ -export async function saveScheduledTimeBlock({ - meetingId, - scheduledFromTime, - scheduledToTime, - scheduledDate, -}: { - meetingId: string; - scheduledFromTime: string; // format: "HH:mm:ss" - scheduledToTime: string; // format: "HH:mm:ss" - scheduledDate: Date; -}) { - try { - // Ensure the meeting exists - const meeting = await getExistingMeeting(meetingId); - if (!meeting) { - return { error: "Invalid meeting ID" }; + let outcome: FanOutOutcome; + try { + outcome = + blocks.length > 0 + ? await syncMeetingToAllMemberCalendars(meetingId) + : await unsyncMeetingFromAllMemberCalendars(meetingId); + } catch (error) { + console.error("Calendar fan-out threw after schedule commit", { + meetingId, + error, + }); + outcome = { + synced: 0, + skipped: 0, + failed: 1, + errors: [{ memberId: "*", reason: "fanout_threw" }], + }; } - // Insert into scheduled_meetings table - await db.insert(scheduledMeetings).values({ - meetingId, - scheduledDate, - scheduledFromTime, - scheduledToTime, - }); - - // Update the meetings table to mark as scheduled - await db - .update(meetings) - .set({ - scheduled: true, - }) - .where(eq(meetings.id, meetingId)); + revalidatePath(`/availability/${meetingId}`); - return { success: true }; + return { success: true, outcome }; } catch (error) { - console.error("Error saving scheduled meeting:", error); - return { error: "Failed to save scheduled meeting." }; + console.error("Error committing meeting schedule:", error); + return { success: false, error: "Failed to commit meeting schedule." }; } } diff --git a/src/server/data/meeting/queries.ts b/src/server/data/meeting/queries.ts index 4f4b9562a..f0bbb5239 100644 --- a/src/server/data/meeting/queries.ts +++ b/src/server/data/meeting/queries.ts @@ -14,6 +14,8 @@ import { import { db } from "@/db"; import { availabilities, + type MeetingGoogleCalendarSnapshot, + meetingGoogleCalendarEvents, meetings, members, type SelectMeeting, @@ -282,6 +284,26 @@ export async function getMeetingsWithMemberAvailabilityExcluding({ .orderBy(desc(meetings.createdAt)); } +export async function getMeetingGoogleCalendarSnapshot( + meetingId: string, + memberId: string, +): Promise { + const rows = await db + .select({ + lastSyncedSnapshot: meetingGoogleCalendarEvents.lastSyncedSnapshot, + }) + .from(meetingGoogleCalendarEvents) + .where( + and( + eq(meetingGoogleCalendarEvents.meetingId, meetingId), + eq(meetingGoogleCalendarEvents.memberId, memberId), + ), + ) + .limit(1); + + return rows[0]?.lastSyncedSnapshot ?? null; +} + /** Availability rows for one member across many meetings (e.g. import/copy flows). */ export async function getMemberAvailabilitiesForMeetingIds({ memberId,