From 0eaedb47f1b73b05a24b824066f4cfc7a5568c2b Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Sat, 2 May 2026 17:31:50 -0700 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20=E2=9C=A8=20added=20testing=20fun?= =?UTF-8?q?ctions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/public/sw.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/next/public/sw.js b/apps/next/public/sw.js index 584c04e8..cf497c28 100644 --- a/apps/next/public/sw.js +++ b/apps/next/public/sw.js @@ -26,3 +26,21 @@ self.addEventListener('notificationclick', function (event) { event.notification.close() event.waitUntil(clients.openWindow('/')) }) + +function askForNotificationPermission() { + if (Notification.permission === 'granted') return; + + Notification.requestPermission().then(permission => { + if (permission !== 'granted') { + alert("You will not receive notifications from PeterPlate"); + } + }); +} + +async function triggerLocalNotification() { + const registration = await navigator.serviceWorker.ready; + registration.showNotification('Hello!', { + body: 'This is a local test notification.', + icon: '/icons/icon-192x192.png' + }); +} \ No newline at end of file From f454757684a2723f34ae430237f982a84822f3a8 Mon Sep 17 00:00:00 2001 From: Shyam Thangaraj Senthil Nathan Date: Sun, 3 May 2026 01:04:00 -0700 Subject: [PATCH 02/22] =?UTF-8?q?feat(db):=20=E2=9C=A8=20add=20push=5Fsubs?= =?UTF-8?q?criptions=20table=20and=20migration=20for=20PWA=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/db/drizzle.config.ts | 1 + packages/db/migrations/0010_peterplate.sql | 2 +- packages/db/migrations/0013_peterplate.sql | 9 +++++++ packages/db/migrations/meta/_journal.json | 9 ++++++- packages/db/src/schema/index.ts | 1 + packages/db/src/schema/pushNotifications.ts | 30 +++++++++++++++++++++ 6 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 packages/db/migrations/0013_peterplate.sql create mode 100644 packages/db/src/schema/pushNotifications.ts diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 73c80665..25b812a2 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -22,6 +22,7 @@ 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/stations.ts", diff --git a/packages/db/migrations/0010_peterplate.sql b/packages/db/migrations/0010_peterplate.sql index 4c24f399..ddd90f38 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 00000000..66ff73a1 --- /dev/null +++ b/packages/db/migrations/0013_peterplate.sql @@ -0,0 +1,9 @@ +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; \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index f130a995..c7e4603c 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1777076172216, "tag": "0012_peterplate", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1777773209370, + "tag": "0013_peterplate", + "breakpoints": true } ] -} \ No newline at end of file +} \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 3d98b68d..10eff461 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -5,6 +5,7 @@ export * from "./dishes"; export * from "./enums"; export * from "./favorites"; export * from "./loggedMeals"; +export * from "./pushNotifications"; export * from "./pushTokens"; export * from "./ratings"; export * from "./userAllergies"; diff --git a/packages/db/src/schema/pushNotifications.ts b/packages/db/src/schema/pushNotifications.ts new file mode 100644 index 00000000..61c6b55a --- /dev/null +++ b/packages/db/src/schema/pushNotifications.ts @@ -0,0 +1,30 @@ +import { relations } from "drizzle-orm"; +import { 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(), +}); + +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; From 41820a731a6a6cb0a7004972b6e9e6994feb196a Mon Sep 17 00:00:00 2001 From: Shyam Thangaraj Senthil Nathan Date: Sun, 3 May 2026 16:19:08 -0700 Subject: [PATCH 03/22] =?UTF-8?q?feat(api):=20=E2=9C=A8=20add=20subscribe?= =?UTF-8?q?=20and=20unsubscribe=20procedures=20for=20PWA=20push=20notifica?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/notifications/router.ts | 22 ++++++++++++++- packages/api/src/notifications/services.ts | 31 ++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/api/src/notifications/router.ts b/packages/api/src/notifications/router.ts index 32fd5332..0d43d43c 100644 --- a/packages/api/src/notifications/router.ts +++ b/packages/api/src/notifications/router.ts @@ -1,7 +1,13 @@ 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 { subscribeUser, unsubscribeUser } from "./services"; const registerPushToken = publicProcedure .input(PushTokenSchema) @@ -19,4 +25,18 @@ const registerPushToken = publicProcedure export const notificationRouter = createTRPCRouter({ /** 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); + }), }); diff --git a/packages/api/src/notifications/services.ts b/packages/api/src/notifications/services.ts index d56d68f8..22f6f815 100644 --- a/packages/api/src/notifications/services.ts +++ b/packages/api/src/notifications/services.ts @@ -1,5 +1,6 @@ -import type { Drizzle } from "@peterplate/db"; -import { pushTokens } from "@peterplate/db"; +import type { Drizzle, InsertPushSubscription } from "@peterplate/db"; +import { pushSubscriptions, pushTokens } from "@peterplate/db"; +import { eq } from "drizzle-orm"; import type { ExpoPushErrorReceipt, ExpoPushMessage, @@ -137,3 +138,29 @@ export async function handleNotificationReceipts( } })(); } + +export async function subscribeUser( + db: Drizzle, + subscription: InsertPushSubscription, +): Promise { + await db + .insert(pushSubscriptions) + .values(subscription) + .onConflictDoUpdate({ + target: pushSubscriptions.userId, + set: { + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + }, + }); +} + +export async function unsubscribeUser( + db: Drizzle, + userId: string, +): Promise { + await db + .delete(pushSubscriptions) + .where(eq(pushSubscriptions.userId, userId)); +} From 406ef658107159a8da55d42f5fec9123a22b1fbd Mon Sep 17 00:00:00 2001 From: Shyam Thangaraj Senthil Nathan Date: Sun, 3 May 2026 16:28:37 -0700 Subject: [PATCH 04/22] =?UTF-8?q?feat(api):=20=E2=9C=A8=20add=20getSubscri?= =?UTF-8?q?ption=20query=20for=20PWA=20push=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/notifications/router.ts | 15 +++++++++++---- packages/api/src/notifications/services.ts | 8 ++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/api/src/notifications/router.ts b/packages/api/src/notifications/router.ts index 0d43d43c..bdc99670 100644 --- a/packages/api/src/notifications/router.ts +++ b/packages/api/src/notifications/router.ts @@ -7,7 +7,7 @@ import { import { TRPCError } from "@trpc/server"; import { Expo } from "expo-server-sdk"; import { z } from "zod"; -import { subscribeUser, unsubscribeUser } from "./services"; +import { getSubscription, subscribeUser, unsubscribeUser } from "./services"; const registerPushToken = publicProcedure .input(PushTokenSchema) @@ -23,20 +23,27 @@ 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 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 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); + }), }); diff --git a/packages/api/src/notifications/services.ts b/packages/api/src/notifications/services.ts index 22f6f815..7a71eba3 100644 --- a/packages/api/src/notifications/services.ts +++ b/packages/api/src/notifications/services.ts @@ -164,3 +164,11 @@ export async function unsubscribeUser( .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; +} From 501bd980d05de46ba75146ab94b0ef995cc06a74 Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Sun, 3 May 2026 16:55:42 -0700 Subject: [PATCH 05/22] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20notification=20?= =?UTF-8?q?subscribe=20btn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using new trpc funcs --- apps/next/public/sw.js | 18 --- .../src/components/PushSubscriptionButton.tsx | 148 ++++++++++++++++++ 2 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 apps/next/src/components/PushSubscriptionButton.tsx diff --git a/apps/next/public/sw.js b/apps/next/public/sw.js index cf497c28..584c04e8 100644 --- a/apps/next/public/sw.js +++ b/apps/next/public/sw.js @@ -26,21 +26,3 @@ self.addEventListener('notificationclick', function (event) { event.notification.close() event.waitUntil(clients.openWindow('/')) }) - -function askForNotificationPermission() { - if (Notification.permission === 'granted') return; - - Notification.requestPermission().then(permission => { - if (permission !== 'granted') { - alert("You will not receive notifications from PeterPlate"); - } - }); -} - -async function triggerLocalNotification() { - const registration = await navigator.serviceWorker.ready; - registration.showNotification('Hello!', { - body: 'This is a local test notification.', - icon: '/icons/icon-192x192.png' - }); -} \ No newline at end of file diff --git a/apps/next/src/components/PushSubscriptionButton.tsx b/apps/next/src/components/PushSubscriptionButton.tsx new file mode 100644 index 00000000..5c4034f6 --- /dev/null +++ b/apps/next/src/components/PushSubscriptionButton.tsx @@ -0,0 +1,148 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useSession } from "@/utils/auth-client"; +import { trpc } from "@/utils/trpc"; + +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 requestNotificationPermission(): Promise { + return await Notification.requestPermission(); +} + +async function subscribeToPushManager( + vapidPublicKey: string, +): Promise { + const registration = await navigator.serviceWorker.ready; + return await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource, + }); +} + +export default function PushSubscriptionButton() { + const { data: session } = useSession(); + const [isSubscribed, setIsSubscribed] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const subscribeMutation = trpc.notification.subscribe.useMutation(); + const unsubscribeMutation = trpc.notification.unsubscribe.useMutation(); + + const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY as string; + const utils = trpc.useUtils(); + + useEffect(() => { + const checkSubscription = async () => { + if ( + isPushSupported() && + Notification.permission === "granted" && + session?.user?.id + ) { + try { + const subscription = await utils.notification.getSubscription.fetch({ + userId: session.user.id, + }); + setIsSubscribed(!!subscription); + } catch (error) { + console.error("Failed to fetch subscription:", error); + setIsSubscribed(false); + } + } + }; + + checkSubscription(); + }, [session?.user?.id, utils]); + + const handleToggleSubscription = async () => { + if (!session?.user?.id) { + alert("You must be logged in to enable notifications."); + return; + } + + setIsLoading(true); + + try { + if (isSubscribed) { + await unsubscribeMutation.mutateAsync({ + userId: session.user.id, + }); + + setIsSubscribed(false); + alert("Notifications disabled."); + } else { + const permission = await requestNotificationPermission(); + if (permission !== "granted") { + alert("Permission denied. Reset permissions in your address bar."); + return; + } + + const subscription = await subscribeToPushManager(VAPID_PUBLIC_KEY); + const subJSON = subscription.toJSON(); + + if (!subJSON.endpoint || !subJSON.keys?.p256dh || !subJSON.keys?.auth) { + throw new Error("Invalid subscription keys generated."); + } + + await subscribeMutation.mutateAsync({ + userId: session.user.id, + endpoint: subJSON.endpoint, + p256dh: subJSON.keys.p256dh, + auth: subJSON.keys.auth, + }); + + setIsSubscribed(true); + alert("Meal notifications enabled!"); + } + } catch (err) { + console.error("Subscription error:", err); + alert("Action failed. Please try again."); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} From 8f2929530e4c9b38cde46787df3d0748bafffefa Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Sun, 3 May 2026 16:56:40 -0700 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20subscribe=20btn?= =?UTF-8?q?=20to=20page=20(temp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing the code onto Shyam who will handle placement/style. --- apps/next/src/app/my-foods/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/next/src/app/my-foods/page.tsx b/apps/next/src/app/my-foods/page.tsx index bccf22c9..043cbccc 100644 --- a/apps/next/src/app/my-foods/page.tsx +++ b/apps/next/src/app/my-foods/page.tsx @@ -11,6 +11,7 @@ import { import type { DishWithRating } from "@peterplate/validators"; import { useTheme } from "next-themes"; import { useMemo, useState } from "react"; +import PushSubscriptionButton from "@/components/PushSubscriptionButton"; import MyFoodsCard from "@/components/ui/card/my-foods-card"; import FoodCardSkeleton from "@/components/ui/skeleton/food-card-skeleton"; import { useUserStore } from "@/context/useUserStore"; @@ -156,6 +157,7 @@ export default function MyFoodsPage() { My Foods + Date: Sun, 3 May 2026 17:08:27 -0700 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20.env.example=20?= =?UTF-8?q?vapid=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/.env.example | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 apps/next/.env.example diff --git a/apps/next/.env.example b/apps/next/.env.example new file mode 100644 index 00000000..b513e154 --- /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 From ab2a0d4e369a01837dc51885e78f6939a3db07d2 Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Tue, 5 May 2026 14:53:11 -0700 Subject: [PATCH 08/22] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20cron=20job=20fo?= =?UTF-8?q?r=20PWA=20push=20notifs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + apps/server/package.json | 5 +- .../functions/cron/sendMenuNotification.ts | 114 ++++++++++++++++++ apps/server/src/functions/env.js | 2 + pnpm-lock.yaml | 12 ++ pnpm-workspace.yaml | 2 + 6 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/functions/cron/sendMenuNotification.ts diff --git a/.env.example b/.env.example index 9f726845..b7c6b0a7 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ DATABASE_URL="postgres://postgres:postgres@localhost:5432/peterplate" +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/server/package.json b/apps/server/package.json index 73dee828..6fed790e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -5,7 +5,8 @@ "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())\"" }, "license": "MIT", "dependencies": { @@ -18,6 +19,7 @@ "dotenv": "catalog:", "expo-server-sdk": "catalog:", "pino": "catalog:", + "web-push": "catalog:", "zod": "catalog:" }, "devDependencies": { @@ -26,6 +28,7 @@ "@types/minimatch": "^6.0.0", "@types/node": "catalog:", "dotenv-cli": "catalog:", + "@types/web-push": "catalog:", "tsx": "catalog:" } } diff --git a/apps/server/src/functions/cron/sendMenuNotification.ts b/apps/server/src/functions/cron/sendMenuNotification.ts new file mode 100644 index 00000000..e634e7b5 --- /dev/null +++ b/apps/server/src/functions/cron/sendMenuNotification.ts @@ -0,0 +1,114 @@ +import webpush from "web-push"; +import { eq } from "drizzle-orm"; +import { formatInTimeZone } from "date-fns-tz"; + +import { + createDrizzle, + dishes, + dishesToMenus, + favorites, + menus, + pool, + pushSubscriptions, +} 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 today = formatInTimeZone(new Date(), TIMEZONE, "yyyy-MM-dd"); + + const matches = await db + .select({ + endpoint: pushSubscriptions.endpoint, + p256dh: pushSubscriptions.p256dh, + auth: pushSubscriptions.auth, + dishName: dishes.name, + }) + .from(pushSubscriptions) + .innerJoin(favorites, eq(pushSubscriptions.userId, favorites.userId)) + .innerJoin(dishesToMenus, eq(favorites.dishId, dishesToMenus.dishId)) + .innerJoin(menus, eq(dishesToMenus.menuId, menus.id)) + .innerJoin(dishes, eq(favorites.dishId, dishes.id)) + .where(eq(menus.date, today)); + + if (matches.length === 0) { + logger.info("No users have favorited dishes serving today."); + return; + } + + // Group by endpoint so each device gets one notification listing all matching favorites + const notificationMap = new Map< + string, + { keys: { p256dh: string; auth: string }; dishNames: string[] } + >(); + + for (const match of matches) { + if (!notificationMap.has(match.endpoint)) { + notificationMap.set(match.endpoint, { + keys: { p256dh: match.p256dh, auth: match.auth }, + dishNames: [], + }); + } + notificationMap.get(match.endpoint)!.dishNames.push(match.dishName); + } + + logger.info(`Sending notifications to ${notificationMap.size} device(s)...`); + + const results = await Promise.allSettled( + Array.from(notificationMap.entries()).map(async ([endpoint, { 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" }, + }); + + 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/env.js b/apps/server/src/functions/env.js index f363a8c1..164fa189 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(), + VAPID_PRIVATE_KEY: z.string(), }); const env = envSchema.parse(process.env); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9987dc5..6a7f2d1c 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 08a061e7..b499e60d 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 From 2d4f90e6aa8135239334df0525bcf62b4f314c3b Mon Sep 17 00:00:00 2001 From: Shyam Thangaraj Senthil Nathan Date: Tue, 5 May 2026 15:45:14 -0700 Subject: [PATCH 09/22] =?UTF-8?q?feat(next):=20=E2=9C=A8=20style=20notific?= =?UTF-8?q?ation=20bell=20button=20and=20replace=20alerts=20with=20MUI=20d?= =?UTF-8?q?ialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/PushSubscriptionButton.tsx | 122 ++++++++++++++---- 1 file changed, 96 insertions(+), 26 deletions(-) diff --git a/apps/next/src/components/PushSubscriptionButton.tsx b/apps/next/src/components/PushSubscriptionButton.tsx index 5c4034f6..89fb0aec 100644 --- a/apps/next/src/components/PushSubscriptionButton.tsx +++ b/apps/next/src/components/PushSubscriptionButton.tsx @@ -1,4 +1,17 @@ "use client"; +import NotificationsIcon from "@mui/icons-material/Notifications"; +import NotificationsOffIcon from "@mui/icons-material/NotificationsOff"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + Tooltip, + Typography, +} from "@mui/material"; import { useEffect, useState } from "react"; import { useSession } from "@/utils/auth-client"; import { trpc } from "@/utils/trpc"; @@ -45,6 +58,19 @@ export default function PushSubscriptionButton() { const { data: session } = useSession(); const [isSubscribed, setIsSubscribed] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [dialog, setDialog] = useState<{ + open: boolean; + title: string; + message: string; + }>({ + open: false, + title: "", + message: "", + }); + + const showDialog = (title: string, message: string) => { + setDialog({ open: true, title, message }); + }; const subscribeMutation = trpc.notification.subscribe.useMutation(); const unsubscribeMutation = trpc.notification.unsubscribe.useMutation(); @@ -76,7 +102,10 @@ export default function PushSubscriptionButton() { const handleToggleSubscription = async () => { if (!session?.user?.id) { - alert("You must be logged in to enable notifications."); + showDialog( + "Sign In Required", + "You must be logged in to enable notifications.", + ); return; } @@ -89,11 +118,17 @@ export default function PushSubscriptionButton() { }); setIsSubscribed(false); - alert("Notifications disabled."); + showDialog( + "Notifications Disabled", + "You will no longer receive notifications for your favorite meals.", + ); } else { const permission = await requestNotificationPermission(); if (permission !== "granted") { - alert("Permission denied. Reset permissions in your address bar."); + showDialog( + "Permission Denied", + "Reset notification permissions in your browser's address bar and try again.", + ); return; } @@ -112,37 +147,72 @@ export default function PushSubscriptionButton() { }); setIsSubscribed(true); - alert("Meal notifications enabled!"); + showDialog( + "Notifications Enabled", + "You'll be notified at 9am when your favorite meals are being served!", + ); } } catch (err) { console.error("Subscription error:", err); - alert("Action failed. Please try again."); + showDialog("Something Went Wrong", "Action failed. Please try again."); } finally { setIsLoading(false); } }; return ( - + <> + + + + {isSubscribed ? : } + + + + + setDialog((d) => ({ ...d, open: false }))} + slotProps={{ + paper: { + sx: { + borderRadius: "16px", + backgroundImage: "none", + ".dark &": { + backgroundColor: "#303035", + backgroundImage: "none", + }, + }, + }, + }} + > + + + {dialog.title} + + + + + {dialog.message} + + + + + + + ); } From ddb5226cb02b766b730c249e8a3c139f7e2224c0 Mon Sep 17 00:00:00 2001 From: Shyam Thangaraj Senthil Nathan Date: Tue, 5 May 2026 16:02:18 -0700 Subject: [PATCH 10/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20sort=20devDependenc?= =?UTF-8?q?ies=20alphabetically=20in=20server=20package.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/package.json b/apps/server/package.json index 6fed790e..d75dc937 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,8 +27,8 @@ "@types/aws-lambda": "catalog:", "@types/minimatch": "^6.0.0", "@types/node": "catalog:", - "dotenv-cli": "catalog:", "@types/web-push": "catalog:", + "dotenv-cli": "catalog:", "tsx": "catalog:" } } From a76d472bdef4e709a93fb31bc2ef97edc2aba1ee Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Tue, 5 May 2026 16:11:35 -0700 Subject: [PATCH 11/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20cron=20jobs=20were?= =?UTF-8?q?=20failing=20due=20to=20zod=20reqs=20This=20is=20a=20temp=20fix?= =?UTF-8?q?=20until=20the=20new=20env=20vars=20are=20added=20to=20cloud=20?= =?UTF-8?q?deployment.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/functions/env.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/server/src/functions/env.js b/apps/server/src/functions/env.js index 164fa189..f363a8c1 100644 --- a/apps/server/src/functions/env.js +++ b/apps/server/src/functions/env.js @@ -14,8 +14,6 @@ if (process.env.NODE_ENV !== "production") { const envSchema = z.object({ DATABASE_URL: z.string(), - NEXT_PUBLIC_VAPID_PUBLIC_KEY: z.string(), - VAPID_PRIVATE_KEY: z.string(), }); const env = envSchema.parse(process.env); From ff85fdacee3fe47c8f66bfee4f3776cc30bc04f2 Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Wed, 6 May 2026 11:58:21 -0700 Subject: [PATCH 12/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20improve=20subscribe?= =?UTF-8?q?=20btn=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/src/app/my-foods/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/next/src/app/my-foods/page.tsx b/apps/next/src/app/my-foods/page.tsx index 043cbccc..59691731 100644 --- a/apps/next/src/app/my-foods/page.tsx +++ b/apps/next/src/app/my-foods/page.tsx @@ -156,8 +156,8 @@ export default function MyFoodsPage() {
My Foods + - Date: Mon, 11 May 2026 23:55:39 -0700 Subject: [PATCH 13/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20add=20vapid=20keys?= =?UTF-8?q?=20to=20env=20loader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/functions/env.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/functions/env.js b/apps/server/src/functions/env.js index f363a8c1..93033d7c 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); From f95e5e055da8bb4bd31cf14378f658e6f5490bfc Mon Sep 17 00:00:00 2001 From: Shyam Thangaraj Senthil Nathan Date: Tue, 19 May 2026 02:25:56 -0700 Subject: [PATCH 14/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20resolve=20merge=20c?= =?UTF-8?q?onflicts=20and=20regenerate=20migration=20for=20push=5Fsubscrip?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migrations/meta/0013_snapshot.json | 1178 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 4 +- 2 files changed, 1180 insertions(+), 2 deletions(-) create mode 100644 packages/db/migrations/meta/0013_snapshot.json diff --git a/packages/db/migrations/meta/0013_snapshot.json b/packages/db/migrations/meta/0013_snapshot.json new file mode 100644 index 00000000..89901102 --- /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/_journal.json b/packages/db/migrations/meta/_journal.json index c7e4603c..9945e969 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -96,9 +96,9 @@ { "idx": 13, "version": "7", - "when": 1777773209370, + "when": 1779182541857, "tag": "0013_peterplate", "breakpoints": true } ] -} \ No newline at end of file +} \ No newline at end of file From eb4139903c3bd3c699f02fb1c71262d135a05f06 Mon Sep 17 00:00:00 2001 From: Shyam Thangaraj Senthil Nathan Date: Tue, 19 May 2026 13:42:13 -0700 Subject: [PATCH 15/22] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20notifications?= =?UTF-8?q?=20panel,=20saved=5Fnotifications=20table,=20subscription=20tog?= =?UTF-8?q?gles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ui/notifications-panel.tsx | 138 ++ .../components/ui/sidebar/sidebar-content.tsx | 31 +- packages/db/drizzle.config.ts | 1 + packages/db/migrations/0014_peterplate.sql | 2 + packages/db/migrations/0015_peterplate.sql | 11 + .../db/migrations/meta/0014_snapshot.json | 1192 ++++++++++++++++ .../db/migrations/meta/0015_snapshot.json | 1267 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 14 + packages/db/src/schema/index.ts | 1 + packages/db/src/schema/pushNotifications.ts | 6 +- packages/db/src/schema/savedNotifications.ts | 44 + 11 files changed, 2705 insertions(+), 2 deletions(-) create mode 100644 apps/next/src/components/ui/notifications-panel.tsx create mode 100644 packages/db/migrations/0014_peterplate.sql create mode 100644 packages/db/migrations/0015_peterplate.sql create mode 100644 packages/db/migrations/meta/0014_snapshot.json create mode 100644 packages/db/migrations/meta/0015_snapshot.json create mode 100644 packages/db/src/schema/savedNotifications.ts 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 00000000..1e8c5c9a --- /dev/null +++ b/apps/next/src/components/ui/notifications-panel.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { + ArrowBack as ArrowBackIcon, + Close as CloseIcon, + Notifications as NotificationsIcon, +} from "@mui/icons-material"; +import { Box, Switch, Typography } from "@mui/material"; +import { useState } from "react"; + +export default function NotificationsPanel({ onBack }: { onBack: () => void }) { + // TODO: Add userId prop when wiring to DB + // TODO: Replace with trpc.notification.getSavedNotifications.useQuery({ userId }) + // TODO: Replace with trpc.notification.getSubscription.useQuery({ userId }) for toggle initial values + const [dishNotifs, setDishNotifs] = useState(false); + const [eventNotifs, setEventNotifs] = useState(false); + + return ( + + {/* Header */} +
+
+ + + + +
+ + Notifications + + {/* TODO: Replace 0 with unreadCount from query */} + + 0 Unread + +
+
+ +
+ + {/* Placeholder list — replace with real data from saved_notifications table */} + {/* Each item: { id, message, createdAt, isRead, type } */} + {/* On click: trpc.notification.markAsRead.mutate({ id }) */} +
+ {[ + { + id: "1", + text: "Chicken Teriyaki is being served in Brandywine today at Lunch!", + date: "Today", + unread: true, + }, + { + id: "2", + text: "Chicken Teriyaki is being served in Brandywine today at Lunch!", + date: "Thursday", + unread: true, + }, + { + id: "3", + text: "Friendsgiving is happening in Anteatery today from 11:00 AM - 3:00 PM!", + date: "January 30", + unread: true, + }, + { + id: "4", + text: "Chicken Teriyaki is being served in Brandywine today at Lunch!", + date: "January 14", + unread: false, + }, + { + id: "5", + text: "Chicken Teriyaki is being served in Brandywine today at Lunch!", + date: "January 10", + unread: false, + }, + ].map((n) => ( +
+
+ + {n.text} + + + {n.date} + +
+ ))} +
+ + {/* Toggles — wire to push_subscriptions.isSubscribedFoodFavorites / isSubscribedEvents */} +
+
+ + Dish Notifications + + {/* TODO: trpc.notification.updateSubscription.mutate({ userId, isSubscribedFoodFavorites }) */} + setDishNotifs(e.target.checked)} + size="small" + /> +
+
+ + Event Notifications + + {/* TODO: trpc.notification.updateSubscription.mutate({ userId, isSubscribedEvents }) */} + setEventNotifs(e.target.checked)} + size="small" + /> +
+
+ + ); +} diff --git a/apps/next/src/components/ui/sidebar/sidebar-content.tsx b/apps/next/src/components/ui/sidebar/sidebar-content.tsx index 80f2f955..e5352a0c 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 ( + + + + + +
@@ -55,69 +74,56 @@ export default function NotificationsPanel({ onBack }: { onBack: () => void }) { - {/* Placeholder list — replace with real data from saved_notifications table */} - {/* Each item: { id, message, createdAt, isRead, type } */} - {/* On click: trpc.notification.markAsRead.mutate({ id }) */} + {/* Notification list from saved_notifications table */}
- {[ - { - id: "1", - text: "Chicken Teriyaki is being served in Brandywine today at Lunch!", - date: "Today", - unread: true, - }, - { - id: "2", - text: "Chicken Teriyaki is being served in Brandywine today at Lunch!", - date: "Thursday", - unread: true, - }, - { - id: "3", - text: "Friendsgiving is happening in Anteatery today from 11:00 AM - 3:00 PM!", - date: "January 30", - unread: true, - }, - { - id: "4", - text: "Chicken Teriyaki is being served in Brandywine today at Lunch!", - date: "January 14", - unread: false, - }, - { - id: "5", - text: "Chicken Teriyaki is being served in Brandywine today at Lunch!", - date: "January 10", - unread: false, - }, - ].map((n) => ( -
-
- - {n.text} - + {notifications?.length ? ( + notifications.map((n) => ( +
+
+ + {n.message} + + + {new Date(n.createdAt).toLocaleDateString()} + +
+ )) + ) : ( +
- {n.date} + No notifications yet.
- ))} + )}
- {/* Toggles — wire to push_subscriptions.isSubscribedFoodFavorites / isSubscribedEvents */} + {/* Toggles — wired to push_subscriptions.isSubscribedFoodFavorites / isSubscribedEvents */}
Dish Notifications - {/* TODO: trpc.notification.updateSubscription.mutate({ userId, isSubscribedFoodFavorites }) */} setDishNotifs(e.target.checked)} + onChange={(e) => { + setDishNotifs(e.target.checked); + updateSubscriptionMutation.mutate({ + userId, + isSubscribedFoodFavorites: e.target.checked, + }); + }} size="small" />
@@ -125,10 +131,15 @@ export default function NotificationsPanel({ onBack }: { onBack: () => void }) { Event Notifications - {/* TODO: trpc.notification.updateSubscription.mutate({ userId, isSubscribedEvents }) */} setEventNotifs(e.target.checked)} + onChange={(e) => { + setEventNotifs(e.target.checked); + updateSubscriptionMutation.mutate({ + userId, + isSubscribedEvents: e.target.checked, + }); + }} size="small" />
diff --git a/packages/api/src/notifications/router.ts b/packages/api/src/notifications/router.ts index bdc99670..3a08584d 100644 --- a/packages/api/src/notifications/router.ts +++ b/packages/api/src/notifications/router.ts @@ -7,7 +7,13 @@ import { import { TRPCError } from "@trpc/server"; import { Expo } from "expo-server-sdk"; import { z } from "zod"; -import { getSubscription, subscribeUser, unsubscribeUser } from "./services"; +import { + getSavedNotifications, + getSubscription, + subscribeUser, + unsubscribeUser, + updateSubscription, +} from "./services"; const registerPushToken = publicProcedure .input(PushTokenSchema) @@ -46,4 +52,25 @@ export const notificationRouter = createTRPCRouter({ .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); + }), }); diff --git a/packages/api/src/notifications/services.ts b/packages/api/src/notifications/services.ts index 7a71eba3..ce766d94 100644 --- a/packages/api/src/notifications/services.ts +++ b/packages/api/src/notifications/services.ts @@ -1,5 +1,9 @@ import type { Drizzle, InsertPushSubscription } from "@peterplate/db"; -import { pushSubscriptions, pushTokens } from "@peterplate/db"; +import { + pushSubscriptions, + pushTokens, + savedNotifications, +} from "@peterplate/db"; import { eq } from "drizzle-orm"; import type { ExpoPushErrorReceipt, @@ -172,3 +176,22 @@ export async function getSubscription(db: Drizzle, userId: string) { .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); +} From af02808c59b86e3d9dbf71ee3c773b9e3839df23 Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Tue, 19 May 2026 15:50:18 -0700 Subject: [PATCH 17/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20remove=20deprecated?= =?UTF-8?q?=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 5be295a2..39494350 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", From 27ca9f06158f9663d7f769833968024d6fcdad26 Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Tue, 19 May 2026 15:51:39 -0700 Subject: [PATCH 18/22] =?UTF-8?q?feat:=20=E2=9C=A8=20delete=20PushSubscrip?= =?UTF-8?q?tionButton=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/src/app/my-foods/page.tsx | 2 - .../src/components/PushSubscriptionButton.tsx | 218 ------------------ 2 files changed, 220 deletions(-) delete mode 100644 apps/next/src/components/PushSubscriptionButton.tsx diff --git a/apps/next/src/app/my-foods/page.tsx b/apps/next/src/app/my-foods/page.tsx index 59691731..bccf22c9 100644 --- a/apps/next/src/app/my-foods/page.tsx +++ b/apps/next/src/app/my-foods/page.tsx @@ -11,7 +11,6 @@ import { import type { DishWithRating } from "@peterplate/validators"; import { useTheme } from "next-themes"; import { useMemo, useState } from "react"; -import PushSubscriptionButton from "@/components/PushSubscriptionButton"; import MyFoodsCard from "@/components/ui/card/my-foods-card"; import FoodCardSkeleton from "@/components/ui/skeleton/food-card-skeleton"; import { useUserStore } from "@/context/useUserStore"; @@ -156,7 +155,6 @@ export default function MyFoodsPage() {
My Foods - { - return await Notification.requestPermission(); -} - -async function subscribeToPushManager( - vapidPublicKey: string, -): Promise { - const registration = await navigator.serviceWorker.ready; - return await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource, - }); -} - -export default function PushSubscriptionButton() { - const { data: session } = useSession(); - const [isSubscribed, setIsSubscribed] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [dialog, setDialog] = useState<{ - open: boolean; - title: string; - message: string; - }>({ - open: false, - title: "", - message: "", - }); - - const showDialog = (title: string, message: string) => { - setDialog({ open: true, title, message }); - }; - - const subscribeMutation = trpc.notification.subscribe.useMutation(); - const unsubscribeMutation = trpc.notification.unsubscribe.useMutation(); - - const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY as string; - const utils = trpc.useUtils(); - - useEffect(() => { - const checkSubscription = async () => { - if ( - isPushSupported() && - Notification.permission === "granted" && - session?.user?.id - ) { - try { - const subscription = await utils.notification.getSubscription.fetch({ - userId: session.user.id, - }); - setIsSubscribed(!!subscription); - } catch (error) { - console.error("Failed to fetch subscription:", error); - setIsSubscribed(false); - } - } - }; - - checkSubscription(); - }, [session?.user?.id, utils]); - - const handleToggleSubscription = async () => { - if (!session?.user?.id) { - showDialog( - "Sign In Required", - "You must be logged in to enable notifications.", - ); - return; - } - - setIsLoading(true); - - try { - if (isSubscribed) { - await unsubscribeMutation.mutateAsync({ - userId: session.user.id, - }); - - setIsSubscribed(false); - showDialog( - "Notifications Disabled", - "You will no longer receive notifications for your favorite meals.", - ); - } else { - const permission = await requestNotificationPermission(); - if (permission !== "granted") { - showDialog( - "Permission Denied", - "Reset notification permissions in your browser's address bar and try again.", - ); - return; - } - - const subscription = await subscribeToPushManager(VAPID_PUBLIC_KEY); - const subJSON = subscription.toJSON(); - - if (!subJSON.endpoint || !subJSON.keys?.p256dh || !subJSON.keys?.auth) { - throw new Error("Invalid subscription keys generated."); - } - - await subscribeMutation.mutateAsync({ - userId: session.user.id, - endpoint: subJSON.endpoint, - p256dh: subJSON.keys.p256dh, - auth: subJSON.keys.auth, - }); - - setIsSubscribed(true); - showDialog( - "Notifications Enabled", - "You'll be notified at 9am when your favorite meals are being served!", - ); - } - } catch (err) { - console.error("Subscription error:", err); - showDialog("Something Went Wrong", "Action failed. Please try again."); - } finally { - setIsLoading(false); - } - }; - - return ( - <> - - - - {isSubscribed ? : } - - - - - setDialog((d) => ({ ...d, open: false }))} - slotProps={{ - paper: { - sx: { - borderRadius: "16px", - backgroundImage: "none", - ".dark &": { - backgroundColor: "#303035", - backgroundImage: "none", - }, - }, - }, - }} - > - - - {dialog.title} - - - - - {dialog.message} - - - - - - - - ); -} From 4414b5eea60ba9ddb73ed793ca6176b2050f8912 Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Tue, 19 May 2026 15:57:08 -0700 Subject: [PATCH 19/22] =?UTF-8?q?feat:=20=E2=9C=A8=20complete=20notif=20su?= =?UTF-8?q?bscrbe=20logic=20&=20ark=20notifs=20read=20on=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches will ask for browser permissions and save unique URL to db. --- .../src/components/ui/notifications-panel.tsx | 245 ++++++++++++++++-- packages/api/src/notifications/router.ts | 8 + packages/api/src/notifications/services.ts | 45 +++- 3 files changed, 259 insertions(+), 39 deletions(-) diff --git a/apps/next/src/components/ui/notifications-panel.tsx b/apps/next/src/components/ui/notifications-panel.tsx index 1f8a6795..1f2e4803 100644 --- a/apps/next/src/components/ui/notifications-panel.tsx +++ b/apps/next/src/components/ui/notifications-panel.tsx @@ -5,35 +5,203 @@ import { Close as CloseIcon, Notifications as NotificationsIcon, } from "@mui/icons-material"; -import { Box, Switch, Typography } from "@mui/material"; -import { useState } from "react"; +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 } = trpc.notification.getSubscription.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( - subscription?.isSubscribedFoodFavorites ?? false, - ); - const [eventNotifs, setEventNotifs] = useState( - subscription?.isSubscribedEvents ?? false, - ); + 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 ( void }) { )}
- {/* Toggles — wired to push_subscriptions.isSubscribedFoodFavorites / isSubscribedEvents */} + {/* Toggles — drive push_subscriptions row + isSubscribedFoodFavorites / isSubscribedEvents */}
@@ -117,13 +285,8 @@ export default function NotificationsPanel({ onBack }: { onBack: () => void }) { { - setDishNotifs(e.target.checked); - updateSubscriptionMutation.mutate({ - userId, - isSubscribedFoodFavorites: e.target.checked, - }); - }} + disabled={isBusy || subLoading || !userId} + onChange={(e) => handleToggle("food", e.target.checked)} size="small" />
@@ -133,17 +296,45 @@ export default function NotificationsPanel({ onBack }: { onBack: () => void }) { { - setEventNotifs(e.target.checked); - updateSubscriptionMutation.mutate({ - userId, - isSubscribedEvents: e.target.checked, - }); - }} + disabled={isBusy || subLoading || !userId} + onChange={(e) => 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} + + + + + + ); } diff --git a/packages/api/src/notifications/router.ts b/packages/api/src/notifications/router.ts index 3a08584d..bc9cbd2e 100644 --- a/packages/api/src/notifications/router.ts +++ b/packages/api/src/notifications/router.ts @@ -10,6 +10,7 @@ import { z } from "zod"; import { getSavedNotifications, getSubscription, + markAllNotificationsRead, subscribeUser, unsubscribeUser, updateSubscription, @@ -73,4 +74,11 @@ export const notificationRouter = createTRPCRouter({ .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 ce766d94..1956f28c 100644 --- a/packages/api/src/notifications/services.ts +++ b/packages/api/src/notifications/services.ts @@ -4,7 +4,7 @@ import { pushTokens, savedNotifications, } from "@peterplate/db"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { ExpoPushErrorReceipt, ExpoPushMessage, @@ -147,17 +147,23 @@ export async function subscribeUser( db: Drizzle, subscription: InsertPushSubscription, ): Promise { - await db - .insert(pushSubscriptions) - .values(subscription) - .onConflictDoUpdate({ - target: pushSubscriptions.userId, - set: { - endpoint: subscription.endpoint, - p256dh: subscription.p256dh, - auth: subscription.auth, - }, - }); + 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( @@ -195,3 +201,18 @@ export async function getSavedNotifications(db: Drizzle, userId: string) { .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), + ), + ); +} From 9a1d49b0c61ee836d3e66fe56420e787ec0b5a28 Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Tue, 19 May 2026 15:58:18 -0700 Subject: [PATCH 20/22] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20script=20for=20?= =?UTF-8?q?sending=20event=20notifs=20&=20update=20menu=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu script will save the notifs to new table for display on frontend now. --- apps/server/package.json | 5 +- .../functions/cron/sendEventNotifications.ts | 123 +++++++++++++++ .../functions/cron/sendMenuNotification.ts | 146 ++++++++++++------ .../cron/testFakeEventNotification.ts | 107 +++++++++++++ 4 files changed, 336 insertions(+), 45 deletions(-) create mode 100644 apps/server/src/functions/cron/sendEventNotifications.ts create mode 100644 apps/server/src/functions/cron/testFakeEventNotification.ts diff --git a/apps/server/package.json b/apps/server/package.json index d75dc937..b367e658 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,7 +6,10 @@ "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:menu-notifications": "tsx -e \"import('./src/functions/cron/sendMenuNotification.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": { diff --git a/apps/server/src/functions/cron/sendEventNotifications.ts b/apps/server/src/functions/cron/sendEventNotifications.ts new file mode 100644 index 00000000..7291804c --- /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 index e634e7b5..1adb0f99 100644 --- a/apps/server/src/functions/cron/sendMenuNotification.ts +++ b/apps/server/src/functions/cron/sendMenuNotification.ts @@ -2,14 +2,14 @@ import webpush from "web-push"; import { eq } from "drizzle-orm"; import { formatInTimeZone } from "date-fns-tz"; +import { createCaller, createTRPCContext } from "@peterplate/api"; import { createDrizzle, - dishes, - dishesToMenus, favorites, - menus, pool, pushSubscriptions, + type RestaurantId, + savedNotifications, } from "@peterplate/db"; import { logger } from "../../../logger"; @@ -30,72 +30,130 @@ export const main = async (_event, _context) => { logger.info("Starting menu push notification job..."); const db = createDrizzle({ connectionString }); - const today = formatInTimeZone(new Date(), TIMEZONE, "yyyy-MM-dd"); + const ctx = createTRPCContext({ + headers: new Headers({ "x-trpc-source": "cron" }), + connectionString, + }); + const trpc = createCaller(ctx); - const matches = await db + // 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, - dishName: dishes.name, + dishId: favorites.dishId, + restaurant: favorites.restaurant, }) .from(pushSubscriptions) .innerJoin(favorites, eq(pushSubscriptions.userId, favorites.userId)) - .innerJoin(dishesToMenus, eq(favorites.dishId, dishesToMenus.dishId)) - .innerJoin(menus, eq(dishesToMenus.menuId, menus.id)) - .innerJoin(dishes, eq(favorites.dishId, dishes.id)) - .where(eq(menus.date, today)); + .where(eq(pushSubscriptions.isSubscribedFoodFavorites, true)); - if (matches.length === 0) { - logger.info("No users have favorited dishes serving today."); + if (rows.length === 0) { + logger.info("No subscribed users have favorited dishes."); return; } - // Group by endpoint so each device gets one notification listing all matching favorites + // 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, - { keys: { p256dh: string; auth: string }; dishNames: string[] } + { + userId: string; + keys: { p256dh: string; auth: string }; + dishNames: string[]; + } >(); - for (const match of matches) { - if (!notificationMap.has(match.endpoint)) { - notificationMap.set(match.endpoint, { - keys: { p256dh: match.p256dh, auth: match.auth }, + 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(match.endpoint)!.dishNames.push(match.dishName); + 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, { 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" }, - }); - - 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; + 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 diff --git a/apps/server/src/functions/cron/testFakeEventNotification.ts b/apps/server/src/functions/cron/testFakeEventNotification.ts new file mode 100644 index 00000000..743c6219 --- /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."); + } +}; From 568260cd5c6a092d18e65b2d41411a9516e7b502 Mon Sep 17 00:00:00 2001 From: Jacob Moy <79670488+EightBitByte@users.noreply.github.com> Date: Tue, 19 May 2026 16:51:19 -0700 Subject: [PATCH 21/22] chore(db): :wrench: drop any existing push_sub table before creating it --- packages/db/migrations/0013_peterplate.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/db/migrations/0013_peterplate.sql b/packages/db/migrations/0013_peterplate.sql index 66ff73a1..432baac0 100644 --- a/packages/db/migrations/0013_peterplate.sql +++ b/packages/db/migrations/0013_peterplate.sql @@ -1,3 +1,5 @@ +DROP TABLE IF EXISTS "push_subscriptions"; + CREATE TABLE "push_subscriptions" ( "user_id" text PRIMARY KEY NOT NULL, "endpoint" text NOT NULL, @@ -6,4 +8,4 @@ CREATE TABLE "push_subscriptions" ( 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; \ No newline at end of file +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; From 642769112da7d704d20543ac5350b4799937e8b4 Mon Sep 17 00:00:00 2001 From: Mihai Zecheru Date: Tue, 19 May 2026 17:14:23 -0700 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20add=20Lex's=20new?= =?UTF-8?q?=20better=20auth=20secret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index b7c6b0a7..fe70a955 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +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