diff --git a/.env.example b/.env.example index 9f7268457..fe70a9559 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ DATABASE_URL="postgres://postgres:postgres@localhost:5432/peterplate" +BETTER_AUTH_SECRET="insert secret here" +NEXT_PUBLIC_VAPID_PUBLIC_KEY="generate with: npx web-push generate-vapid-keys" +VAPID_PRIVATE_KEY="generate with: npx web-push generate-vapid-keys" \ No newline at end of file diff --git a/apps/next/.env.example b/apps/next/.env.example new file mode 100644 index 000000000..b513e1543 --- /dev/null +++ b/apps/next/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_VAPID_PUBLIC_KEY="generate with: npx web-push generate-vapid-keys" +VAPID_PRIVATE_KEY="generate with: npx web-push generate-vapid-keys" \ No newline at end of file diff --git a/apps/next/src/components/ui/notifications-panel.tsx b/apps/next/src/components/ui/notifications-panel.tsx new file mode 100644 index 000000000..1f2e48031 --- /dev/null +++ b/apps/next/src/components/ui/notifications-panel.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { + ArrowBack as ArrowBackIcon, + Close as CloseIcon, + Notifications as NotificationsIcon, +} from "@mui/icons-material"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Switch, + Typography, +} from "@mui/material"; +import { useEffect, useRef, useState } from "react"; +import { useSession } from "@/utils/auth-client"; +import { trpc } from "@/utils/trpc"; + +const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY as string; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +function isPushSupported(): boolean { + if (typeof window === "undefined" || typeof navigator === "undefined") { + return false; + } + return ( + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window + ); +} + +async function ensurePushSubscription(): Promise<{ + endpoint: string; + p256dh: string; + auth: string; +}> { + const registration = await navigator.serviceWorker.ready; + let sub = await registration.pushManager.getSubscription(); + if (!sub) { + sub = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array( + VAPID_PUBLIC_KEY, + ) as BufferSource, + }); + } + const json = sub.toJSON(); + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { + throw new Error("Invalid push subscription keys generated."); + } + return { + endpoint: json.endpoint, + p256dh: json.keys.p256dh, + auth: json.keys.auth, + }; +} + +type ToggleKind = "food" | "event"; + +export default function NotificationsPanel({ onBack }: { onBack: () => void }) { + const { data: session } = useSession(); + const userId = session?.user?.id ?? ""; + + const utils = trpc.useUtils(); + + const { data: notifications } = + trpc.notification.getSavedNotifications.useQuery( + { userId }, + { enabled: !!userId }, + ); + + const { data: subscription, isLoading: subLoading } = + trpc.notification.getSubscription.useQuery( + { userId }, + { enabled: !!userId }, + ); + + const subscribeMutation = trpc.notification.subscribe.useMutation(); + const updateSubscriptionMutation = + trpc.notification.updateSubscription.useMutation(); + const markAllReadMutation = trpc.notification.markAllRead.useMutation(); + + const [dishNotifs, setDishNotifs] = useState(false); + const [eventNotifs, setEventNotifs] = useState(false); + const [isBusy, setIsBusy] = useState(false); + const [dialog, setDialog] = useState<{ + open: boolean; + title: string; + message: string; + }>({ open: false, title: "", message: "" }); + + useEffect(() => { + setDishNotifs(subscription?.isSubscribedFoodFavorites ?? false); + setEventNotifs(subscription?.isSubscribedEvents ?? false); + }, [subscription]); + + const hasMarkedReadRef = useRef(false); + useEffect(() => { + if (!userId || hasMarkedReadRef.current) return; + hasMarkedReadRef.current = true; + + utils.notification.getSavedNotifications.setData({ userId }, (prev) => + prev?.map((n) => ({ ...n, isRead: true })), + ); + + markAllReadMutation.mutate( + { userId }, + { + onSettled: () => { + void utils.notification.getSavedNotifications.invalidate({ userId }); + }, + }, + ); + }, [userId, utils, markAllReadMutation]); + + const showDialog = (title: string, message: string) => + setDialog({ open: true, title, message }); + + const handleToggle = async (kind: ToggleKind, nextValue: boolean) => { + if (!userId) { + showDialog( + "Sign In Required", + "You must be logged in to manage notifications.", + ); + return; + } + if (isBusy) return; + + const prevDish = dishNotifs; + const prevEvent = eventNotifs; + const revert = () => { + setDishNotifs(prevDish); + setEventNotifs(prevEvent); + }; + + if (kind === "food") setDishNotifs(nextValue); + else setEventNotifs(nextValue); + + const nextDish = kind === "food" ? nextValue : prevDish; + const nextEvent = kind === "event" ? nextValue : prevEvent; + + setIsBusy(true); + try { + if (nextValue) { + if (!isPushSupported()) { + showDialog( + "Not Supported", + "Push notifications are not supported in this browser.", + ); + revert(); + return; + } + + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + showDialog( + "Permission Denied", + "Reset notification permissions in your browser's address bar and try again.", + ); + revert(); + return; + } + + const subData = await ensurePushSubscription(); + await subscribeMutation.mutateAsync({ + userId, + endpoint: subData.endpoint, + p256dh: subData.p256dh, + auth: subData.auth, + isSubscribedFoodFavorites: nextDish, + isSubscribedEvents: nextEvent, + }); + } else { + await updateSubscriptionMutation.mutateAsync({ + userId, + isSubscribedFoodFavorites: nextDish, + isSubscribedEvents: nextEvent, + }); + } + + await utils.notification.getSubscription.invalidate({ userId }); + } catch (err) { + console.error("Notification toggle failed:", err); + showDialog("Something Went Wrong", "Action failed. Please try again."); + revert(); + } finally { + setIsBusy(false); + } + }; + + return ( + + {/* Header */} + + + + + + + + + + + Notifications + + + {notifications?.filter((n) => !n.isRead).length ?? 0} Unread + + + + + + + + + {/* Notification list from saved_notifications table */} + + {notifications?.length ? ( + notifications.map((n) => ( + + + + {n.message} + + + {new Date(n.createdAt).toLocaleDateString()} + + + )) + ) : ( + + + No notifications yet. + + + )} + + + {/* Toggles — drive push_subscriptions row + isSubscribedFoodFavorites / isSubscribedEvents */} + + + + Dish Notifications + + handleToggle("food", e.target.checked)} + size="small" + /> + + + + Event Notifications + + handleToggle("event", e.target.checked)} + size="small" + /> + + + + setDialog((d) => ({ ...d, open: false }))} + slotProps={{ + paper: { + sx: { + borderRadius: "16px", + backgroundImage: "none", + ".dark &": { + backgroundColor: "#303035", + backgroundImage: "none", + }, + }, + }, + }} + > + + + {dialog.title} + + + + + {dialog.message} + + + + setDialog((d) => ({ ...d, open: false }))}> + OK + + + + + ); +} diff --git a/apps/next/src/components/ui/sidebar/sidebar-content.tsx b/apps/next/src/components/ui/sidebar/sidebar-content.tsx index 80f2f955e..e5352a0c3 100644 --- a/apps/next/src/components/ui/sidebar/sidebar-content.tsx +++ b/apps/next/src/components/ui/sidebar/sidebar-content.tsx @@ -9,6 +9,7 @@ import { Info as InfoIcon, LightMode as LightModeIcon, Logout as LogoutIcon, + Notifications as NotificationsIcon, } from "@mui/icons-material"; import { Box, Tooltip, Typography } from "@mui/material"; import Image from "next/image"; @@ -18,6 +19,7 @@ import type React from "react"; import type { ReactNode } from "react"; import { useEffect, useState } from "react"; import { GoogleSignInButton } from "@/components/auth/google-sign-in"; +import NotificationsPanel from "@/components/ui/notifications-panel"; import { signOut, useSession } from "@/utils/auth-client"; import { formatDietaryKey } from "@/utils/dietary"; import { trpc } from "@/utils/trpc"; @@ -36,6 +38,7 @@ export default function SidebarContent({ const userId = user?.id; const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); + const [showNotifications, setShowNotifications] = useState(false); useEffect(() => setMounted(true), []); @@ -60,6 +63,10 @@ export default function SidebarContent({ window.location.href = "/"; }; + if (showNotifications) { + return setShowNotifications(false)} />; + } + return ( + + + setShowNotifications(true)} + className="w-full flex items-center gap-3 rounded-lg px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:dark:hover:bg-transparent" + > + + + + + Notifications + + + + + diff --git a/apps/server/package.json b/apps/server/package.json index 73dee828b..b367e6580 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -5,7 +5,11 @@ "scripts": { "clean": "rm -rf .turbo node_modules", "test:daily": "tsx -e \"import('./src/functions/cron/daily.ts').then(m => m.main())\"", - "test:weekly": "tsx -e \"import('./src/functions/cron/weekly.ts').then(m => m.main())\"" + "test:weekly": "tsx -e \"import('./src/functions/cron/weekly.ts').then(m => m.main())\"", + "test:menu-notifications": "tsx -e \"import('./src/functions/cron/sendMenuNotification.ts').then(m => m.main())\"", + "test:event-notifications": "tsx -e \"import('./src/functions/cron/sendEventNotifications.ts').then(m => m.main())\"", + "test:fake-event-notification": "tsx -e \"import('./src/functions/cron/testFakeEventNotification.ts').then(m => m.main())\"", + "test:notifications": "pnpm test:menu-notifications && pnpm test:event-notifications" }, "license": "MIT", "dependencies": { @@ -18,6 +22,7 @@ "dotenv": "catalog:", "expo-server-sdk": "catalog:", "pino": "catalog:", + "web-push": "catalog:", "zod": "catalog:" }, "devDependencies": { @@ -25,6 +30,7 @@ "@types/aws-lambda": "catalog:", "@types/minimatch": "^6.0.0", "@types/node": "catalog:", + "@types/web-push": "catalog:", "dotenv-cli": "catalog:", "tsx": "catalog:" } diff --git a/apps/server/src/functions/cron/sendEventNotifications.ts b/apps/server/src/functions/cron/sendEventNotifications.ts new file mode 100644 index 000000000..7291804c6 --- /dev/null +++ b/apps/server/src/functions/cron/sendEventNotifications.ts @@ -0,0 +1,123 @@ +import webpush from "web-push"; +import { eq } from "drizzle-orm"; +import { formatInTimeZone } from "date-fns-tz"; + +import { createCaller, createTRPCContext } from "@peterplate/api"; +import { + createDrizzle, + getRestaurantNameById, + pool, + pushSubscriptions, + savedNotifications, +} from "@peterplate/db"; + +import { logger } from "../../../logger"; +import { env } from "../env"; + +const TIMEZONE = "America/Los_Angeles"; + +webpush.setVapidDetails( + "mailto:admin@peterplate.com", + env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + env.VAPID_PRIVATE_KEY, +); + +export const main = async (_event, _context) => { + const connectionString = env.DATABASE_URL; + + try { + logger.info("Starting event push notification job..."); + const db = createDrizzle({ connectionString }); + + const ctx = createTRPCContext({ + headers: new Headers({ "x-trpc-source": "cron" }), + connectionString, + }); + const trpc = createCaller(ctx); + + const upcomingEvents = await trpc.event.upcoming(); + + const today = formatInTimeZone(new Date(), TIMEZONE, "yyyy-MM-dd"); + const eventsToday = upcomingEvents.filter( + (event) => + event.start !== null && + formatInTimeZone(event.start, TIMEZONE, "yyyy-MM-dd") === today, + ); + + if (eventsToday.length === 0) { + logger.info("No dining hall events serving today."); + return; + } + + const subscriptions = await db + .select({ + userId: pushSubscriptions.userId, + endpoint: pushSubscriptions.endpoint, + p256dh: pushSubscriptions.p256dh, + auth: pushSubscriptions.auth, + }) + .from(pushSubscriptions) + .where(eq(pushSubscriptions.isSubscribedEvents, true)); + + if (subscriptions.length === 0) { + logger.info("No users subscribed to event notifications."); + return; + } + + logger.info( + `Sending ${eventsToday.length} event notification(s) to ${subscriptions.length} device(s)...`, + ); + + const results = await Promise.allSettled( + eventsToday.flatMap((event) => { + const restaurantName = getRestaurantNameById(event.restaurantId); + const body = event.shortDescription ?? event.title; + const message = `Special event at ${restaurantName}: ${body}`; + const payload = JSON.stringify({ + title: `Special event at ${restaurantName} 🎉`, + body, + icon: "/icons/icon-192x192.png", + data: { url: "/events" }, + }); + + return subscriptions.map(async ({ userId, endpoint, p256dh, auth }) => { + await db.insert(savedNotifications).values({ + userId, + message, + type: "event", + }); + + try { + await webpush.sendNotification( + { endpoint, keys: { p256dh, auth } }, + payload, + ); + } catch (error: any) { + // 410 Gone / 404 means the user revoked the subscription in their browser + if (error.statusCode === 410 || error.statusCode === 404) { + logger.info({ endpoint }, "Subscription expired, removing from DB..."); + await db + .delete(pushSubscriptions) + .where(eq(pushSubscriptions.endpoint, endpoint)); + } else { + throw error; + } + } + }); + }), + ); + + results + .filter((r) => r.status === "rejected") + .forEach((r) => + logger.error((r as PromiseRejectedResult).reason, "Failed to send notification"), + ); + } catch (error) { + logger.error(error, "Failed to execute event push notification job"); + } finally { + logger.info("Closing connection pool..."); + await pool({ connectionString }).end(); + logger.info("Closed connection pool."); + logger.info("Finished event push notification job."); + } +}; diff --git a/apps/server/src/functions/cron/sendMenuNotification.ts b/apps/server/src/functions/cron/sendMenuNotification.ts new file mode 100644 index 000000000..1adb0f99f --- /dev/null +++ b/apps/server/src/functions/cron/sendMenuNotification.ts @@ -0,0 +1,172 @@ +import webpush from "web-push"; +import { eq } from "drizzle-orm"; +import { formatInTimeZone } from "date-fns-tz"; + +import { createCaller, createTRPCContext } from "@peterplate/api"; +import { + createDrizzle, + favorites, + pool, + pushSubscriptions, + type RestaurantId, + savedNotifications, +} from "@peterplate/db"; + +import { logger } from "../../../logger"; +import { env } from "../env"; + +const TIMEZONE = "America/Los_Angeles"; + +webpush.setVapidDetails( + "mailto:admin@peterplate.com", + env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + env.VAPID_PRIVATE_KEY, +); + +export const main = async (_event, _context) => { + const connectionString = env.DATABASE_URL; + + try { + logger.info("Starting menu push notification job..."); + const db = createDrizzle({ connectionString }); + + const ctx = createTRPCContext({ + headers: new Headers({ "x-trpc-source": "cron" }), + connectionString, + }); + const trpc = createCaller(ctx); + + // Build a Date at noon UTC on the current Pacific calendar day so that + // AAPI's local-date formatting (toLocaleDateString) lands on the right day + // regardless of the server's timezone. + const todayStr = formatInTimeZone(new Date(), TIMEZONE, "yyyy-MM-dd"); + const [y, m, d] = todayStr.split("-").map(Number) as [number, number, number]; + const today = new Date(Date.UTC(y, m - 1, d, 12)); + + const rows = await db + .select({ + userId: pushSubscriptions.userId, + endpoint: pushSubscriptions.endpoint, + p256dh: pushSubscriptions.p256dh, + auth: pushSubscriptions.auth, + dishId: favorites.dishId, + restaurant: favorites.restaurant, + }) + .from(pushSubscriptions) + .innerJoin(favorites, eq(pushSubscriptions.userId, favorites.userId)) + .where(eq(pushSubscriptions.isSubscribedFoodFavorites, true)); + + if (rows.length === 0) { + logger.info("No subscribed users have favorited dishes."); + return; + } + + // Fetch today's served dishes only for restaurants we need. + const neededRestaurants = Array.from( + new Set(rows.map((r) => r.restaurant)), + ) as RestaurantId[]; + + const servedByRestaurant = new Map>(); + for (const restaurant of neededRestaurants) { + try { + const data = await trpc.restaurant({ date: today, restaurant }); + const dishMap = new Map(); + for (const period of data.periods) { + for (const station of period.stations) { + for (const dish of station.dishes) { + dishMap.set(dish.id, dish.name); + } + } + } + servedByRestaurant.set(restaurant, dishMap); + } catch (err) { + logger.error( + err, + `Failed to fetch today's menu for restaurant ${restaurant}`, + ); + } + } + + // Group matches by endpoint so each device gets one combined notification. + const notificationMap = new Map< + string, + { + userId: string; + keys: { p256dh: string; auth: string }; + dishNames: string[]; + } + >(); + + for (const row of rows) { + const servedDishes = servedByRestaurant.get(row.restaurant); + const dishName = servedDishes?.get(row.dishId); + if (!dishName) continue; + + if (!notificationMap.has(row.endpoint)) { + notificationMap.set(row.endpoint, { + userId: row.userId, + keys: { p256dh: row.p256dh, auth: row.auth }, + dishNames: [], + }); + } + notificationMap.get(row.endpoint)!.dishNames.push(dishName); + } + + if (notificationMap.size === 0) { + logger.info("No subscribed users have favorited dishes serving today."); + return; + } + + logger.info(`Sending notifications to ${notificationMap.size} device(s)...`); + + const results = await Promise.allSettled( + Array.from(notificationMap.entries()).map( + async ([endpoint, { userId, keys, dishNames }]) => { + const uniqueDishes = [...new Set(dishNames)]; + const body = + uniqueDishes.length === 1 + ? `${uniqueDishes[0]} is serving today!` + : `Your favorites are serving today: ${uniqueDishes.join(", ")}`; + + const payload = JSON.stringify({ + title: "Favorite Dish Alert!", + body, + icon: "/icons/icon-192x192.png", + data: { url: "/my-favorites" }, + }); + + await db.insert(savedNotifications).values({ + userId, + message: body, + type: "food", + }); + + try { + await webpush.sendNotification({ endpoint, keys }, payload); + } catch (error: any) { + // 410 Gone / 404 means the user revoked the subscription in their browser + if (error.statusCode === 410 || error.statusCode === 404) { + logger.info({ endpoint }, "Subscription expired, removing from DB..."); + await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint)); + } else { + throw error; + } + } + }, + ), + ); + + results + .filter((r) => r.status === "rejected") + .forEach((r) => + logger.error((r as PromiseRejectedResult).reason, "Failed to send notification"), + ); + } catch (error) { + logger.error(error, "Failed to execute menu push notification job"); + } finally { + logger.info("Closing connection pool..."); + await pool({ connectionString }).end(); + logger.info("Closed connection pool."); + logger.info("Finished menu push notification job."); + } +}; diff --git a/apps/server/src/functions/cron/testFakeEventNotification.ts b/apps/server/src/functions/cron/testFakeEventNotification.ts new file mode 100644 index 000000000..743c62192 --- /dev/null +++ b/apps/server/src/functions/cron/testFakeEventNotification.ts @@ -0,0 +1,107 @@ +import webpush from "web-push"; +import { eq } from "drizzle-orm"; + +import { + createDrizzle, + getRestaurantNameById, + pool, + pushSubscriptions, + savedNotifications, +} from "@peterplate/db"; + +import { logger } from "../../../logger"; +import { env } from "../env"; + +webpush.setVapidDetails( + "mailto:admin@peterplate.com", + env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + env.VAPID_PRIVATE_KEY, +); + +// Hardcoded fake event mirroring the AnteaterAPI dining-events shape so this +// script can be run on demand to verify the event-notification flow without +// needing a real upcoming event in the AAPI feed. +const FAKE_EVENT = { + title: "[TEST] Lobster Night", + restaurantId: "anteatery" as const, + description: "Fake test event — surf & turf served all evening.", + start: new Date(), + end: new Date(Date.now() + 2 * 60 * 60 * 1000), +}; + +export const main = async (_event, _context) => { + const connectionString = env.DATABASE_URL; + + try { + logger.info("Starting FAKE event push notification job..."); + const db = createDrizzle({ connectionString }); + + const subscriptions = await db + .select({ + userId: pushSubscriptions.userId, + endpoint: pushSubscriptions.endpoint, + p256dh: pushSubscriptions.p256dh, + auth: pushSubscriptions.auth, + }) + .from(pushSubscriptions) + .where(eq(pushSubscriptions.isSubscribedEvents, true)); + + if (subscriptions.length === 0) { + logger.info("No users subscribed to event notifications."); + return; + } + + logger.info( + `Sending fake event notification to ${subscriptions.length} device(s)...`, + ); + + const restaurantName = getRestaurantNameById(FAKE_EVENT.restaurantId); + const body = FAKE_EVENT.description || FAKE_EVENT.title; + const message = `Special event at ${restaurantName}: ${body}`; + const payload = JSON.stringify({ + title: `Special event at ${restaurantName} 🎉`, + body, + icon: "/icons/icon-192x192.png", + data: { url: "/events" }, + }); + + const results = await Promise.allSettled( + subscriptions.map(async ({ userId, endpoint, p256dh, auth }) => { + await db.insert(savedNotifications).values({ + userId, + message, + type: "event", + }); + + try { + await webpush.sendNotification( + { endpoint, keys: { p256dh, auth } }, + payload, + ); + } catch (error: any) { + if (error.statusCode === 410 || error.statusCode === 404) { + logger.info({ endpoint }, "Subscription expired, removing from DB..."); + await db + .delete(pushSubscriptions) + .where(eq(pushSubscriptions.endpoint, endpoint)); + } else { + throw error; + } + } + }), + ); + + results + .filter((r) => r.status === "rejected") + .forEach((r) => + logger.error((r as PromiseRejectedResult).reason, "Failed to send notification"), + ); + } catch (error) { + logger.error(error, "Failed to execute fake event push notification job"); + } finally { + logger.info("Closing connection pool..."); + await pool({ connectionString }).end(); + logger.info("Closed connection pool."); + logger.info("Finished fake event push notification job."); + } +}; diff --git a/apps/server/src/functions/env.js b/apps/server/src/functions/env.js index f363a8c1d..93033d7ce 100644 --- a/apps/server/src/functions/env.js +++ b/apps/server/src/functions/env.js @@ -14,6 +14,8 @@ if (process.env.NODE_ENV !== "production") { const envSchema = z.object({ DATABASE_URL: z.string(), + NEXT_PUBLIC_VAPID_PUBLIC_KEY: z.string().min(1), + VAPID_PRIVATE_KEY: z.string().min(1), }); const env = envSchema.parse(process.env); diff --git a/package.json b/package.json index 5be295a2e..394943506 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "build": "turbo build", "clean": "git clean -xdf node_modules", "clean:workspaces": "turbo clean", - "cron:daily": "pnpm -F @peterplate/server test:daily", "cron:weekly": "pnpm -F @peterplate/server test:weekly", "db:generate": "pnpm -F db db:generate", "db:migrate": "pnpm -F db db:migrate", diff --git a/packages/api/src/notifications/router.ts b/packages/api/src/notifications/router.ts index 32fd53321..bc9cbd2ee 100644 --- a/packages/api/src/notifications/router.ts +++ b/packages/api/src/notifications/router.ts @@ -1,7 +1,20 @@ import { createTRPCRouter, publicProcedure } from "@api/trpc"; -import { PushTokenSchema, pushTokens } from "@peterplate/db"; +import { + PushSubscriptionSchema, + PushTokenSchema, + pushTokens, +} from "@peterplate/db"; import { TRPCError } from "@trpc/server"; import { Expo } from "expo-server-sdk"; +import { z } from "zod"; +import { + getSavedNotifications, + getSubscription, + markAllNotificationsRead, + subscribeUser, + unsubscribeUser, + updateSubscription, +} from "./services"; const registerPushToken = publicProcedure .input(PushTokenSchema) @@ -17,6 +30,55 @@ const registerPushToken = publicProcedure }); export const notificationRouter = createTRPCRouter({ - /** Register a push token and save it to the database. */ + // Register a push token and save it to the database. register: registerPushToken, + + // Subscribe user to PWA push notifications. + subscribe: publicProcedure + .input(PushSubscriptionSchema) + .mutation(async ({ ctx: { db }, input }) => { + await subscribeUser(db, input); + }), + + //Unsubscribe user from PWA push notifications. + unsubscribe: publicProcedure + .input(z.object({ userId: z.string() })) + .mutation(async ({ ctx: { db }, input }) => { + await unsubscribeUser(db, input.userId); + }), + + // Get a user's push subscription if it exists. + getSubscription: publicProcedure + .input(z.object({ userId: z.string() })) + .query(async ({ ctx: { db }, input }) => { + return await getSubscription(db, input.userId); + }), + + // Update food/event subscription toggles. + updateSubscription: publicProcedure + .input( + z.object({ + userId: z.string(), + isSubscribedFoodFavorites: z.boolean().optional(), + isSubscribedEvents: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx: { db }, input }) => { + const { userId, ...data } = input; + await updateSubscription(db, userId, data); + }), + + // Get saved notifications for a user. + getSavedNotifications: publicProcedure + .input(z.object({ userId: z.string() })) + .query(async ({ ctx: { db }, input }) => { + return await getSavedNotifications(db, input.userId); + }), + + // Mark all of a user's notifications as read. + markAllRead: publicProcedure + .input(z.object({ userId: z.string() })) + .mutation(async ({ ctx: { db }, input }) => { + await markAllNotificationsRead(db, input.userId); + }), }); diff --git a/packages/api/src/notifications/services.ts b/packages/api/src/notifications/services.ts index d56d68f80..1956f28cb 100644 --- a/packages/api/src/notifications/services.ts +++ b/packages/api/src/notifications/services.ts @@ -1,5 +1,10 @@ -import type { Drizzle } from "@peterplate/db"; -import { pushTokens } from "@peterplate/db"; +import type { Drizzle, InsertPushSubscription } from "@peterplate/db"; +import { + pushSubscriptions, + pushTokens, + savedNotifications, +} from "@peterplate/db"; +import { and, eq } from "drizzle-orm"; import type { ExpoPushErrorReceipt, ExpoPushMessage, @@ -137,3 +142,77 @@ export async function handleNotificationReceipts( } })(); } + +export async function subscribeUser( + db: Drizzle, + subscription: InsertPushSubscription, +): Promise { + const updateSet: Record = { + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + }; + if (subscription.isSubscribedFoodFavorites !== undefined) { + updateSet.isSubscribedFoodFavorites = + subscription.isSubscribedFoodFavorites; + } + if (subscription.isSubscribedEvents !== undefined) { + updateSet.isSubscribedEvents = subscription.isSubscribedEvents; + } + + await db.insert(pushSubscriptions).values(subscription).onConflictDoUpdate({ + target: pushSubscriptions.userId, + set: updateSet, + }); +} + +export async function unsubscribeUser( + db: Drizzle, + userId: string, +): Promise { + await db + .delete(pushSubscriptions) + .where(eq(pushSubscriptions.userId, userId)); +} + +export async function getSubscription(db: Drizzle, userId: string) { + const result = await db + .select() + .from(pushSubscriptions) + .where(eq(pushSubscriptions.userId, userId)); + return result[0] ?? null; +} + +export async function updateSubscription( + db: Drizzle, + userId: string, + data: { isSubscribedFoodFavorites?: boolean; isSubscribedEvents?: boolean }, +): Promise { + await db + .update(pushSubscriptions) + .set(data) + .where(eq(pushSubscriptions.userId, userId)); +} + +export async function getSavedNotifications(db: Drizzle, userId: string) { + return await db + .select() + .from(savedNotifications) + .where(eq(savedNotifications.userId, userId)) + .orderBy(savedNotifications.createdAt); +} + +export async function markAllNotificationsRead( + db: Drizzle, + userId: string, +): Promise { + await db + .update(savedNotifications) + .set({ isRead: true }) + .where( + and( + eq(savedNotifications.userId, userId), + eq(savedNotifications.isRead, false), + ), + ); +} diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 73c80665c..9e6b6f664 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -22,8 +22,10 @@ export default defineConfig({ "./src/schema/loggedMeals.ts", "./src/schema/periods.ts", "./src/schema/pushTokens.ts", + "./src/schema/pushNotifications.ts", "./src/schema/ratings.ts", "./src/schema/restaurants.ts", + "./src/schema/savedNotifications.ts", "./src/schema/stations.ts", "./src/schema/users.ts", "./src/schema/auth-schema.ts", diff --git a/packages/db/migrations/0010_peterplate.sql b/packages/db/migrations/0010_peterplate.sql index 4c24f3997..ddd90f385 100644 --- a/packages/db/migrations/0010_peterplate.sql +++ b/packages/db/migrations/0010_peterplate.sql @@ -25,4 +25,4 @@ ALTER TABLE "dishes" DROP COLUMN "description";--> statement-breakpoint ALTER TABLE "dishes" DROP COLUMN "ingredients";--> statement-breakpoint ALTER TABLE "dishes" DROP COLUMN "category";--> statement-breakpoint ALTER TABLE "dishes" DROP COLUMN "image_url";--> statement-breakpoint -ALTER TABLE "logged_meals" DROP COLUMN "dish_name"; \ No newline at end of file +ALTER TABLE "logged_meals" DROP COLUMN "dish_name"; diff --git a/packages/db/migrations/0013_peterplate.sql b/packages/db/migrations/0013_peterplate.sql new file mode 100644 index 000000000..432baac01 --- /dev/null +++ b/packages/db/migrations/0013_peterplate.sql @@ -0,0 +1,11 @@ +DROP TABLE IF EXISTS "push_subscriptions"; + +CREATE TABLE "push_subscriptions" ( + "user_id" text PRIMARY KEY NOT NULL, + "endpoint" text NOT NULL, + "p256dh" text NOT NULL, + "auth" text NOT NULL, + CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint") +); +--> statement-breakpoint +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE cascade; diff --git a/packages/db/migrations/0014_peterplate.sql b/packages/db/migrations/0014_peterplate.sql new file mode 100644 index 000000000..810df4bdb --- /dev/null +++ b/packages/db/migrations/0014_peterplate.sql @@ -0,0 +1,2 @@ +ALTER TABLE "push_subscriptions" ADD COLUMN "is_subscribed_food_favorites" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "push_subscriptions" ADD COLUMN "is_subscribed_events" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/0015_peterplate.sql b/packages/db/migrations/0015_peterplate.sql new file mode 100644 index 000000000..2ac6e40f2 --- /dev/null +++ b/packages/db/migrations/0015_peterplate.sql @@ -0,0 +1,11 @@ +CREATE TYPE "public"."notification_type" AS ENUM('food', 'event');--> statement-breakpoint +CREATE TABLE "saved_notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "message" text NOT NULL, + "type" "notification_type" NOT NULL, + "is_read" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "saved_notifications" ADD CONSTRAINT "saved_notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE cascade; \ No newline at end of file diff --git a/packages/db/migrations/meta/0013_snapshot.json b/packages/db/migrations/meta/0013_snapshot.json new file mode 100644 index 000000000..89901102b --- /dev/null +++ b/packages/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,1178 @@ +{ + "id": "97132362-750c-4907-aa28-577e27aae262", + "prevId": "2d2f94ff-35b9-4f25-9806-6c8a051b516b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contributors": { + "name": "contributors", + "schema": "", + "columns": { + "login": { + "name": "login", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contributions": { + "name": "contributions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'PeterPlate Contributor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.diet_restrictions": { + "name": "diet_restrictions", + "schema": "", + "columns": { + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contains_eggs": { + "name": "contains_eggs", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_fish": { + "name": "contains_fish", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_milk": { + "name": "contains_milk", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_peanuts": { + "name": "contains_peanuts", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_sesame": { + "name": "contains_sesame", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_shellfish": { + "name": "contains_shellfish", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_soy": { + "name": "contains_soy", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_tree_nuts": { + "name": "contains_tree_nuts", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_wheat": { + "name": "contains_wheat", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_gluten_free": { + "name": "is_gluten_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_halal": { + "name": "is_halal", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_kosher": { + "name": "is_kosher", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_locally_grown": { + "name": "is_locally_grown", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_organic": { + "name": "is_organic", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_vegan": { + "name": "is_vegan", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_vegetarian": { + "name": "is_vegetarian", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "diet_restrictions_dish_id_dishes_id_fk": { + "name": "diet_restrictions_dish_id_dishes_id_fk", + "tableFrom": "diet_restrictions", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dishes": { + "name": "dishes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "num_ratings": { + "name": "num_ratings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_rating": { + "name": "total_rating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "restaurant": { + "name": "restaurant", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "favorites_dish_id_dishes_id_fk": { + "name": "favorites_dish_id_dishes_id_fk", + "tableFrom": "favorites", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "favorites_pk": { + "name": "favorites_pk", + "columns": [ + "user_id", + "dish_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.logged_meals": { + "name": "logged_meals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "servings": { + "name": "servings", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "eaten_at": { + "name": "eaten_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "logged_meals_user_id_users_id_fk": { + "name": "logged_meals_user_id_users_id_fk", + "tableFrom": "logged_meals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "logged_meals_dish_id_dishes_id_fk": { + "name": "logged_meals_dish_id_dishes_id_fk", + "tableFrom": "logged_meals", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "servings_is_valid": { + "name": "servings_is_valid", + "value": "((\"logged_meals\".\"servings\" * 2) = floor(\"logged_meals\".\"servings\" * 2)) AND (\"logged_meals\".\"servings\" >= 0.5)" + } + }, + "isRLSEnabled": false + }, + "public.push_tokens": { + "name": "push_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "restaurant": { + "name": "restaurant", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ratings_dish_id_dishes_id_fk": { + "name": "ratings_dish_id_dishes_id_fk", + "tableFrom": "ratings", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "ratings_pk": { + "name": "ratings_pk", + "columns": [ + "user_id", + "dish_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hasOnboarded": { + "name": "hasOnboarded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_users_id_fk": { + "name": "account_userId_users_id_fk", + "tableFrom": "account", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_users_id_fk": { + "name": "session_userId_users_id_fk", + "tableFrom": "session", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergies": { + "name": "user_allergies", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergy": { + "name": "allergy", + "type": "allergy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergies_userId_users_id_fk": { + "name": "user_allergies_userId_users_id_fk", + "tableFrom": "user_allergies", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "user_allergies_userId_allergy_pk": { + "name": "user_allergies_userId_allergy_pk", + "columns": [ + "userId", + "allergy" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_dietary_preferences": { + "name": "user_dietary_preferences", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preference": { + "name": "preference", + "type": "preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_dietary_preferences_userId_users_id_fk": { + "name": "user_dietary_preferences_userId_users_id_fk", + "tableFrom": "user_dietary_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "user_dietary_preferences_userId_preference_pk": { + "name": "user_dietary_preferences_userId_preference_pk", + "columns": [ + "userId", + "preference" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_goals": { + "name": "user_goals", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "calorie_goal": { + "name": "calorie_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2000 + }, + "protein_goal": { + "name": "protein_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "carb_goal": { + "name": "carb_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 250 + }, + "fat_goal": { + "name": "fat_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + } + }, + "indexes": {}, + "foreignKeys": { + "user_goals_user_id_users_id_fk": { + "name": "user_goals_user_id_users_id_fk", + "tableFrom": "user_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_goals_by_day": { + "name": "user_goals_by_day", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "calorie_goal": { + "name": "calorie_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2000 + }, + "protein_goal": { + "name": "protein_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "carb_goal": { + "name": "carb_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 250 + }, + "fat_goal": { + "name": "fat_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + } + }, + "indexes": {}, + "foreignKeys": { + "user_goals_by_day_user_id_users_id_fk": { + "name": "user_goals_by_day_user_id_users_id_fk", + "tableFrom": "user_goals_by_day", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_goals_by_day_user_id_date_pk": { + "name": "user_goals_by_day_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.restaurant_id_enum": { + "name": "restaurant_id_enum", + "schema": "public", + "values": [ + "anteatery", + "brandywine" + ] + }, + "public.restaurant_name_enum": { + "name": "restaurant_name_enum", + "schema": "public", + "values": [ + "anteatery", + "brandywine" + ] + }, + "public.allergy": { + "name": "allergy", + "schema": "public", + "values": [ + "eggs", + "fish", + "milk", + "peanuts", + "sesame", + "shellfish", + "soy", + "treeNuts", + "wheat" + ] + }, + "public.preference": { + "name": "preference", + "schema": "public", + "values": [ + "glutenFree", + "halal", + "kosher", + "locallyGrown", + "organic", + "vegan", + "vegetarian" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0014_snapshot.json b/packages/db/migrations/meta/0014_snapshot.json new file mode 100644 index 000000000..f4d564ae2 --- /dev/null +++ b/packages/db/migrations/meta/0014_snapshot.json @@ -0,0 +1,1192 @@ +{ + "id": "673e483a-1f15-494a-b11f-84a9c1ffc025", + "prevId": "97132362-750c-4907-aa28-577e27aae262", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contributors": { + "name": "contributors", + "schema": "", + "columns": { + "login": { + "name": "login", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contributions": { + "name": "contributions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'PeterPlate Contributor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.diet_restrictions": { + "name": "diet_restrictions", + "schema": "", + "columns": { + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contains_eggs": { + "name": "contains_eggs", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_fish": { + "name": "contains_fish", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_milk": { + "name": "contains_milk", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_peanuts": { + "name": "contains_peanuts", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_sesame": { + "name": "contains_sesame", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_shellfish": { + "name": "contains_shellfish", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_soy": { + "name": "contains_soy", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_tree_nuts": { + "name": "contains_tree_nuts", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_wheat": { + "name": "contains_wheat", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_gluten_free": { + "name": "is_gluten_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_halal": { + "name": "is_halal", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_kosher": { + "name": "is_kosher", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_locally_grown": { + "name": "is_locally_grown", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_organic": { + "name": "is_organic", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_vegan": { + "name": "is_vegan", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_vegetarian": { + "name": "is_vegetarian", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "diet_restrictions_dish_id_dishes_id_fk": { + "name": "diet_restrictions_dish_id_dishes_id_fk", + "tableFrom": "diet_restrictions", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dishes": { + "name": "dishes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "num_ratings": { + "name": "num_ratings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_rating": { + "name": "total_rating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "restaurant": { + "name": "restaurant", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "favorites_dish_id_dishes_id_fk": { + "name": "favorites_dish_id_dishes_id_fk", + "tableFrom": "favorites", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "favorites_pk": { + "name": "favorites_pk", + "columns": [ + "user_id", + "dish_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.logged_meals": { + "name": "logged_meals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "servings": { + "name": "servings", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "eaten_at": { + "name": "eaten_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "logged_meals_user_id_users_id_fk": { + "name": "logged_meals_user_id_users_id_fk", + "tableFrom": "logged_meals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "logged_meals_dish_id_dishes_id_fk": { + "name": "logged_meals_dish_id_dishes_id_fk", + "tableFrom": "logged_meals", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "servings_is_valid": { + "name": "servings_is_valid", + "value": "((\"logged_meals\".\"servings\" * 2) = floor(\"logged_meals\".\"servings\" * 2)) AND (\"logged_meals\".\"servings\" >= 0.5)" + } + }, + "isRLSEnabled": false + }, + "public.push_tokens": { + "name": "push_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_subscribed_food_favorites": { + "name": "is_subscribed_food_favorites", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_subscribed_events": { + "name": "is_subscribed_events", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "restaurant": { + "name": "restaurant", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ratings_dish_id_dishes_id_fk": { + "name": "ratings_dish_id_dishes_id_fk", + "tableFrom": "ratings", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "ratings_pk": { + "name": "ratings_pk", + "columns": [ + "user_id", + "dish_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hasOnboarded": { + "name": "hasOnboarded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_users_id_fk": { + "name": "account_userId_users_id_fk", + "tableFrom": "account", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_users_id_fk": { + "name": "session_userId_users_id_fk", + "tableFrom": "session", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergies": { + "name": "user_allergies", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergy": { + "name": "allergy", + "type": "allergy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergies_userId_users_id_fk": { + "name": "user_allergies_userId_users_id_fk", + "tableFrom": "user_allergies", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "user_allergies_userId_allergy_pk": { + "name": "user_allergies_userId_allergy_pk", + "columns": [ + "userId", + "allergy" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_dietary_preferences": { + "name": "user_dietary_preferences", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preference": { + "name": "preference", + "type": "preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_dietary_preferences_userId_users_id_fk": { + "name": "user_dietary_preferences_userId_users_id_fk", + "tableFrom": "user_dietary_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "user_dietary_preferences_userId_preference_pk": { + "name": "user_dietary_preferences_userId_preference_pk", + "columns": [ + "userId", + "preference" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_goals": { + "name": "user_goals", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "calorie_goal": { + "name": "calorie_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2000 + }, + "protein_goal": { + "name": "protein_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "carb_goal": { + "name": "carb_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 250 + }, + "fat_goal": { + "name": "fat_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + } + }, + "indexes": {}, + "foreignKeys": { + "user_goals_user_id_users_id_fk": { + "name": "user_goals_user_id_users_id_fk", + "tableFrom": "user_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_goals_by_day": { + "name": "user_goals_by_day", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "calorie_goal": { + "name": "calorie_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2000 + }, + "protein_goal": { + "name": "protein_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "carb_goal": { + "name": "carb_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 250 + }, + "fat_goal": { + "name": "fat_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + } + }, + "indexes": {}, + "foreignKeys": { + "user_goals_by_day_user_id_users_id_fk": { + "name": "user_goals_by_day_user_id_users_id_fk", + "tableFrom": "user_goals_by_day", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_goals_by_day_user_id_date_pk": { + "name": "user_goals_by_day_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.restaurant_id_enum": { + "name": "restaurant_id_enum", + "schema": "public", + "values": [ + "anteatery", + "brandywine" + ] + }, + "public.restaurant_name_enum": { + "name": "restaurant_name_enum", + "schema": "public", + "values": [ + "anteatery", + "brandywine" + ] + }, + "public.allergy": { + "name": "allergy", + "schema": "public", + "values": [ + "eggs", + "fish", + "milk", + "peanuts", + "sesame", + "shellfish", + "soy", + "treeNuts", + "wheat" + ] + }, + "public.preference": { + "name": "preference", + "schema": "public", + "values": [ + "glutenFree", + "halal", + "kosher", + "locallyGrown", + "organic", + "vegan", + "vegetarian" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0015_snapshot.json b/packages/db/migrations/meta/0015_snapshot.json new file mode 100644 index 000000000..dd30582ef --- /dev/null +++ b/packages/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,1267 @@ +{ + "id": "e4298415-61cc-40c9-a8d6-02e1534fb7c3", + "prevId": "673e483a-1f15-494a-b11f-84a9c1ffc025", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contributors": { + "name": "contributors", + "schema": "", + "columns": { + "login": { + "name": "login", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contributions": { + "name": "contributions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'PeterPlate Contributor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.diet_restrictions": { + "name": "diet_restrictions", + "schema": "", + "columns": { + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contains_eggs": { + "name": "contains_eggs", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_fish": { + "name": "contains_fish", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_milk": { + "name": "contains_milk", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_peanuts": { + "name": "contains_peanuts", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_sesame": { + "name": "contains_sesame", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_shellfish": { + "name": "contains_shellfish", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_soy": { + "name": "contains_soy", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_tree_nuts": { + "name": "contains_tree_nuts", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_wheat": { + "name": "contains_wheat", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_gluten_free": { + "name": "is_gluten_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_halal": { + "name": "is_halal", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_kosher": { + "name": "is_kosher", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_locally_grown": { + "name": "is_locally_grown", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_organic": { + "name": "is_organic", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_vegan": { + "name": "is_vegan", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_vegetarian": { + "name": "is_vegetarian", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "diet_restrictions_dish_id_dishes_id_fk": { + "name": "diet_restrictions_dish_id_dishes_id_fk", + "tableFrom": "diet_restrictions", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dishes": { + "name": "dishes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "num_ratings": { + "name": "num_ratings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_rating": { + "name": "total_rating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "restaurant": { + "name": "restaurant", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "favorites_dish_id_dishes_id_fk": { + "name": "favorites_dish_id_dishes_id_fk", + "tableFrom": "favorites", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "favorites_pk": { + "name": "favorites_pk", + "columns": [ + "user_id", + "dish_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.logged_meals": { + "name": "logged_meals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "servings": { + "name": "servings", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "eaten_at": { + "name": "eaten_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "logged_meals_user_id_users_id_fk": { + "name": "logged_meals_user_id_users_id_fk", + "tableFrom": "logged_meals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "logged_meals_dish_id_dishes_id_fk": { + "name": "logged_meals_dish_id_dishes_id_fk", + "tableFrom": "logged_meals", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "servings_is_valid": { + "name": "servings_is_valid", + "value": "((\"logged_meals\".\"servings\" * 2) = floor(\"logged_meals\".\"servings\" * 2)) AND (\"logged_meals\".\"servings\" >= 0.5)" + } + }, + "isRLSEnabled": false + }, + "public.push_tokens": { + "name": "push_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_subscribed_food_favorites": { + "name": "is_subscribed_food_favorites", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_subscribed_events": { + "name": "is_subscribed_events", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "restaurant": { + "name": "restaurant", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ratings_dish_id_dishes_id_fk": { + "name": "ratings_dish_id_dishes_id_fk", + "tableFrom": "ratings", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "ratings_pk": { + "name": "ratings_pk", + "columns": [ + "user_id", + "dish_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_notifications": { + "name": "saved_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "saved_notifications_user_id_users_id_fk": { + "name": "saved_notifications_user_id_users_id_fk", + "tableFrom": "saved_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hasOnboarded": { + "name": "hasOnboarded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_users_id_fk": { + "name": "account_userId_users_id_fk", + "tableFrom": "account", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_users_id_fk": { + "name": "session_userId_users_id_fk", + "tableFrom": "session", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergies": { + "name": "user_allergies", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergy": { + "name": "allergy", + "type": "allergy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergies_userId_users_id_fk": { + "name": "user_allergies_userId_users_id_fk", + "tableFrom": "user_allergies", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "user_allergies_userId_allergy_pk": { + "name": "user_allergies_userId_allergy_pk", + "columns": [ + "userId", + "allergy" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_dietary_preferences": { + "name": "user_dietary_preferences", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preference": { + "name": "preference", + "type": "preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_dietary_preferences_userId_users_id_fk": { + "name": "user_dietary_preferences_userId_users_id_fk", + "tableFrom": "user_dietary_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "user_dietary_preferences_userId_preference_pk": { + "name": "user_dietary_preferences_userId_preference_pk", + "columns": [ + "userId", + "preference" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_goals": { + "name": "user_goals", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "calorie_goal": { + "name": "calorie_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2000 + }, + "protein_goal": { + "name": "protein_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "carb_goal": { + "name": "carb_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 250 + }, + "fat_goal": { + "name": "fat_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + } + }, + "indexes": {}, + "foreignKeys": { + "user_goals_user_id_users_id_fk": { + "name": "user_goals_user_id_users_id_fk", + "tableFrom": "user_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_goals_by_day": { + "name": "user_goals_by_day", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "calorie_goal": { + "name": "calorie_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2000 + }, + "protein_goal": { + "name": "protein_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "carb_goal": { + "name": "carb_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 250 + }, + "fat_goal": { + "name": "fat_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + } + }, + "indexes": {}, + "foreignKeys": { + "user_goals_by_day_user_id_users_id_fk": { + "name": "user_goals_by_day_user_id_users_id_fk", + "tableFrom": "user_goals_by_day", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_goals_by_day_user_id_date_pk": { + "name": "user_goals_by_day_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.restaurant_id_enum": { + "name": "restaurant_id_enum", + "schema": "public", + "values": [ + "anteatery", + "brandywine" + ] + }, + "public.restaurant_name_enum": { + "name": "restaurant_name_enum", + "schema": "public", + "values": [ + "anteatery", + "brandywine" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "food", + "event" + ] + }, + "public.allergy": { + "name": "allergy", + "schema": "public", + "values": [ + "eggs", + "fish", + "milk", + "peanuts", + "sesame", + "shellfish", + "soy", + "treeNuts", + "wheat" + ] + }, + "public.preference": { + "name": "preference", + "schema": "public", + "values": [ + "glutenFree", + "halal", + "kosher", + "locallyGrown", + "organic", + "vegan", + "vegetarian" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index f130a995b..f72c52010 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -92,6 +92,27 @@ "when": 1777076172216, "tag": "0012_peterplate", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1779182541857, + "tag": "0013_peterplate", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1779189021901, + "tag": "0014_peterplate", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1779221557656, + "tag": "0015_peterplate", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 3d98b68da..36bd32577 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -5,8 +5,10 @@ export * from "./dishes"; export * from "./enums"; export * from "./favorites"; export * from "./loggedMeals"; +export * from "./pushNotifications"; export * from "./pushTokens"; export * from "./ratings"; +export * from "./savedNotifications"; export * from "./userAllergies"; export * from "./userDietaryPreferences"; export * from "./userGoals"; diff --git a/packages/db/src/schema/pushNotifications.ts b/packages/db/src/schema/pushNotifications.ts new file mode 100644 index 000000000..9671ae15b --- /dev/null +++ b/packages/db/src/schema/pushNotifications.ts @@ -0,0 +1,34 @@ +import { relations } from "drizzle-orm"; +import { boolean, pgTable, text } from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { users } from "./users"; + +export const pushSubscriptions = pgTable("push_subscriptions", { + userId: text("user_id") + .primaryKey() + .references(() => users.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + endpoint: text("endpoint").notNull().unique(), + p256dh: text("p256dh").notNull(), + auth: text("auth").notNull(), + isSubscribedFoodFavorites: boolean("is_subscribed_food_favorites") + .notNull() + .default(false), + isSubscribedEvents: boolean("is_subscribed_events").notNull().default(false), +}); + +export const pushSubscriptionsRelations = relations( + pushSubscriptions, + ({ one }) => ({ + user: one(users, { + fields: [pushSubscriptions.userId], + references: [users.id], + }), + }), +); + +export const PushSubscriptionSchema = createInsertSchema(pushSubscriptions); +export type InsertPushSubscription = typeof pushSubscriptions.$inferInsert; +export type SelectPushSubscription = typeof pushSubscriptions.$inferSelect; diff --git a/packages/db/src/schema/savedNotifications.ts b/packages/db/src/schema/savedNotifications.ts new file mode 100644 index 000000000..660245df6 --- /dev/null +++ b/packages/db/src/schema/savedNotifications.ts @@ -0,0 +1,44 @@ +import { relations } from "drizzle-orm"; +import { + boolean, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { users } from "./users"; + +export const notificationTypeEnum = pgEnum("notification_type", [ + "food", + "event", +]); + +export const savedNotifications = pgTable("saved_notifications", { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + message: text("message").notNull(), + type: notificationTypeEnum("type").notNull(), + isRead: boolean("is_read").notNull().default(false), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + +export const savedNotificationsRelations = relations( + savedNotifications, + ({ one }) => ({ + user: one(users, { + fields: [savedNotifications.userId], + references: [users.id], + }), + }), +); + +export const SavedNotificationSchema = createInsertSchema(savedNotifications); +export type InsertSavedNotification = typeof savedNotifications.$inferInsert; +export type SelectSavedNotification = typeof savedNotifications.$inferSelect; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9987dc5a..6a7f2d1c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ catalogs: '@types/pg': specifier: ^8.11.2 version: 8.11.6 + '@types/web-push': + specifier: ^3.6.3 + version: 3.6.4 '@typescript-eslint/eslint-plugin': specifier: ^7.5.0 version: 7.12.0 @@ -135,6 +138,9 @@ catalogs: vite-tsconfig-paths: specifier: ^4.3.2 version: 4.3.2 + web-push: + specifier: ^3.6.0 + version: 3.6.7 zod: specifier: ^3.22.4 version: 3.23.8 @@ -399,6 +405,9 @@ importers: pino: specifier: 'catalog:' version: 8.21.0 + web-push: + specifier: 'catalog:' + version: 3.6.7 zod: specifier: 'catalog:' version: 3.23.8 @@ -415,6 +424,9 @@ importers: '@types/node': specifier: 'catalog:' version: 20.14.9 + '@types/web-push': + specifier: 'catalog:' + version: 3.6.4 dotenv-cli: specifier: 'catalog:' version: 7.4.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 08a061e7b..b499e60dd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -105,5 +105,7 @@ catalog: typescript: ^5.5.4 vite-tsconfig-paths: ^4.3.2 vitest: ^1.4.0 + web-push: ^3.6.0 + "@types/web-push": ^3.6.3 zod: ^3.22.4 zustand: ^4.5.2