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 && (
-
}
- onClick={async () => {
- if (isGeneratingLink) return;
- setIsGeneratingLink(true);
- try {
- const { success, link } =
- await getGoogleCalendarPrefilledLink({
- meetingId: meetingData.id,
- meetingTitle: meetingData.title,
- meetingDescription: meetingData.description,
- meetingLocation: meetingData.location,
- timezone: meetingData.timezone,
- });
-
- if (!success || !link) {
- showError("Failed to generate Google Calendar link.");
- return;
- }
-
- window.open(link, "_blank", "noopener,noreferrer");
- showSuccess(
- "Google Calendar link opened! Confirm the event in your calendar.",
- );
- } catch (error) {
- console.error(
- "Error generating Google Calendar link:",
- error,
- );
- showError(
- "An error occurred while generating the Google Calendar link.",
- );
- } finally {
- setIsGeneratingLink(false);
- }
- }}
- >
- Add to Calendar
-
+
)}
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,