diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..370ca77 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Database +DATABASE_URL="file:./dev.db" + +# Web Push Notifications +# Generate these with: npx web-push generate-vapid-keys +NEXT_PUBLIC_VAPID_PUBLIC_KEY="" +VAPID_PRIVATE_KEY="" +VAPID_SUBJECT="mailto:admin@example.com" diff --git a/.gitignore b/.gitignore index e1d1e44..78f96d7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/app/actions/booking.ts b/app/actions/booking.ts index fb3ac86..15a4d44 100644 --- a/app/actions/booking.ts +++ b/app/actions/booking.ts @@ -3,7 +3,7 @@ import prisma from "@/lib/prisma" import { revalidatePath } from "next/cache" import { headers } from "next/headers" -import { sendNotification } from "@/lib/notifications" +import { sendPlanNotification } from "@/lib/notifications" import { getDictionary } from "@/get-dictionary" export async function createBooking(planId: string, date: Date, name: string, type: "SITTER" | "OWNER_HOME" = "SITTER", lang: string = "en") { @@ -34,7 +34,7 @@ export async function createBooking(planId: string, date: Date, name: string, ty } }) - if (plan?.webhookUrl && plan.notifyAll) { + if (plan?.notifyAll) { const host = (await headers()).get("host") const protocol = host?.includes("localhost") ? "http" : "https" const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}` @@ -44,7 +44,7 @@ export async function createBooking(planId: string, date: Date, name: string, ty ? dict.notifications.ownerHome.replace("{date}", dateStr).replace("{url}", planUrl) : dict.notifications.newBooking.replace("{name}", name).replace("{date}", dateStr).replace("{url}", planUrl) - await sendNotification(plan.webhookUrl, message) + await sendPlanNotification(planId, message, plan.webhookUrl) } revalidatePath(`/${lang}/dashboard/${planId}`) @@ -64,7 +64,7 @@ export async function deleteBooking(bookingId: number, planId: string, lang: str where: { id: bookingId } }) - if (booking.plan.webhookUrl) { + if (booking.plan.notifyAll) { const host = (await headers()).get("host") const protocol = host?.includes("localhost") ? "http" : "https" const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}` @@ -77,7 +77,7 @@ export async function deleteBooking(bookingId: number, planId: string, lang: str .replace("{url}", planUrl) .replace("{message}", messageDisplay) - await sendNotification(booking.plan.webhookUrl, message) + await sendPlanNotification(planId, message, booking.plan.webhookUrl) } revalidatePath(`/${lang}/dashboard/${planId}`) @@ -98,7 +98,7 @@ export async function completeBooking(bookingId: number, planId: string, lang: s data: { completedAt: new Date() } }) - if (booking.plan.webhookUrl) { + if (booking.plan.notifyAll) { const host = (await headers()).get("host") const protocol = host?.includes("localhost") ? "http" : "https" const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}` @@ -109,7 +109,7 @@ export async function completeBooking(bookingId: number, planId: string, lang: s .replace("{date}", dateStr) .replace("{url}", planUrl) - await sendNotification(booking.plan.webhookUrl, message) + await sendPlanNotification(planId, message, booking.plan.webhookUrl) } revalidatePath(`/${lang}/dashboard/${planId}`) diff --git a/app/actions/plan.ts b/app/actions/plan.ts index 35c7221..9ba7d67 100644 --- a/app/actions/plan.ts +++ b/app/actions/plan.ts @@ -3,7 +3,7 @@ import prisma from "@/lib/prisma" import { revalidatePath } from "next/cache" import { headers } from "next/headers" -import { sendNotification } from "@/lib/notifications" +import { sendPlanNotification } from "@/lib/notifications" import { getDictionary } from "@/get-dictionary" export async function updatePlan( @@ -34,14 +34,15 @@ export async function updatePlan( } }) - if (data.instructions && plan.webhookUrl && plan.notifyAll) { + if (data.instructions && plan.notifyAll) { const host = (await headers()).get("host") const protocol = host?.includes("localhost") ? "http" : "https" const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}` - await sendNotification( - plan.webhookUrl, - dict.notifications.instructionsUpdated.replace("{url}", planUrl) + await sendPlanNotification( + planId, + dict.notifications.instructionsUpdated.replace("{url}", planUrl), + plan.webhookUrl ) } diff --git a/app/actions/subscription.ts b/app/actions/subscription.ts new file mode 100644 index 0000000..42127eb --- /dev/null +++ b/app/actions/subscription.ts @@ -0,0 +1,45 @@ +"use server"; + +import prisma from "@/lib/prisma"; + +export async function subscribeUser(planId: string, subscription: any) { + if (!subscription || !subscription.endpoint || !subscription.keys) { + throw new Error("Invalid subscription object"); + } + + const { endpoint, keys: { p256dh, auth } } = subscription; + + // Use upsert to handle updates gracefully + // If endpoint exists, update the planId. A device follows the last plan logged into (or explicitly subscribed to). + + // Check existence first to decide logic (or just upsert) + // Actually, if we want to allow multiple plans per device, we need a different model. + // For now, simple model: One device -> One Plan. + + await prisma.pushSubscription.upsert({ + where: { endpoint }, + create: { + planId, + endpoint, + p256dh, + auth, + }, + update: { + planId, + p256dh, + auth, + } + }); + + return { success: true }; +} + +export async function unsubscribeUser(endpoint: string) { + if (!endpoint) return; + + await prisma.pushSubscription.deleteMany({ + where: { endpoint }, + }); + + return { success: true }; +} diff --git a/components/plan-settings.tsx b/components/plan-settings.tsx index de04e1a..477ec11 100644 --- a/components/plan-settings.tsx +++ b/components/plan-settings.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import * as z from "zod" +import { PushSubscriptionSettings } from "@/components/push-manager" import { Settings } from "lucide-react" import { toast } from "sonner" @@ -112,6 +113,9 @@ export function PlanSettings({ {dict.description} + + +
(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if ('serviceWorker' in navigator && 'PushManager' in window && process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) { + setIsSupported(true) + registerServiceWorker() + } + }, []) + + async function registerServiceWorker() { + try { + const registration = await navigator.serviceWorker.ready + const sub = await registration.pushManager.getSubscription() + setSubscription(sub) + } catch (e) { + console.error("SW registration error", e) + } + } + + async function subscribe() { + setLoading(true) + try { + const registration = await navigator.serviceWorker.ready + const sub = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!) + }) + setSubscription(sub) + await subscribeUser(planId, sub.toJSON()) + toast.success("Push Notifications enabled") + } catch (error) { + console.error(error) + toast.error("Failed to enable notifications. check permissions.") + } finally { + setLoading(false) + } + } + + async function unsubscribe() { + setLoading(true) + try { + if (subscription) { + await subscription.unsubscribe() + await unsubscribeUser(subscription.endpoint) + setSubscription(null) + toast.success("Notifications disabled") + } + } catch (error) { + console.error(error) + toast.error("Failed to disable.") + } finally { + setLoading(false) + } + } + + if (!isSupported) return null + + return ( +
+
+

Device Notifications

+

+ Receive updates on this device +

+
+ {subscription ? ( + + ) : ( + + )} +
+ ) +} diff --git a/implementation_plan_pwa_push.md b/implementation_plan_pwa_push.md new file mode 100644 index 0000000..da4d412 --- /dev/null +++ b/implementation_plan_pwa_push.md @@ -0,0 +1,82 @@ +# Implementation Plan - PWA Push Notifications + +This plan outlines the steps to implement Push Notifications for the Cat Sitting Planner PWA. + +## Goals +- Allow users to subscribe to Push Notifications from the browser. +- Send Push Notifications for existing events (New Booking, Cancellation, Completion, Instructions Update). +- Ensure integration with the Service Worker. + +## User Actions Required +- [ ] Generate VAPID Keys: + Run `npx web-push generate-vapid-keys` in your terminal. + Add the output to your `.env` (create if needed) or `.env.local`: + ```env + NEXT_PUBLIC_VAPID_PUBLIC_KEY="" + VAPID_PRIVATE_KEY="" + VAPID_SUBJECT="mailto:your-email@example.com" + ``` + *Note: `NEXT_PUBLIC_` prefix is needed for the frontend to access the public key.* + +## Steps + +### 1. Dependencies and Configuration +- [ ] Install `web-push`: `npm install web-push && npm install -D @types/web-push` +- [ ] Install `serwist` or ensure `next-pwa` can handle custom service workers. (We will use `importScripts` with `next-pwa`). + +### 2. Database Schema +- [ ] Update `prisma/schema.prisma`: + Add `PushSubscription` model linked to `Plan`. + ```prisma + model PushSubscription { + id String @id @default(cuid()) + planId String + plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + endpoint String @unique + p256dh String + auth String + createdAt DateTime @default(now()) + } + ``` +- [ ] Run `npx prisma migrate dev --name add_push_subscription` + +### 3. Service Worker +- [ ] Create `public/push-sw.js`: + Implement the `push` event listener to show notifications. + Implement the `notificationclick` event listener to focus/open the app. +- [ ] Update `next.config.mjs`: + Configure `next-pwa` (Workbox) to import `push-sw.js` via `importScripts`. + +### 4. Server Actions & Library +- [ ] Create `lib/push.ts`: + Helper functions to initialize `web-push` and send notifications. +- [ ] Create `app/actions/subscription.ts`: + `subscribeUser(planId, subscription)`: Saves subscription to DB. + `unsubscribeUser(endpoint)`: Removes subscription. +- [ ] Update `lib/notifications.ts`: + Add `sendPlanNotification(planId, message, webhookUrl?)`. + This function will: + 1. Send to Webhook (if exists). + 2. Fetch all subscriptions for `planId`. + 3. Send Web Push to all. + 4. Handle 410 (Gone) by deleting sub. + +### 5. Backend Integration +- [ ] Update `app/actions/booking.ts`: + Replace `sendNotification` with `sendPlanNotification`. +- [ ] Update `app/actions/plan.ts`: + Replace `sendNotification` with `sendPlanNotification`. + +### 6. Frontend Integration +- [ ] Create `components/push-notification-manager.tsx`: + UI component to check permission, register SW (if not handled), and subscribe. + Show "Enable Notifications" button. +- [ ] Add `PushNotificationManager` to `Dashboard` or `Settings`. + +## Verification +- [ ] Test on Localhost (requires HTTPS or localhost exception). +- [ ] Verify notifications appear for: + - Booking creation + - Booking completion + - Cancellation + - Instructions update diff --git a/lib/notifications.ts b/lib/notifications.ts index b35be64..f3dfaa0 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -21,3 +21,51 @@ export async function sendNotification(webhookUrl: string | null, message: strin console.error("Failed to send notification:", error); } } + +import { sendPushNotification } from "./push"; +import prisma from "@/lib/prisma"; + +export async function sendPlanNotification(planId: string, message: string, webhookUrl?: string | null) { + // Parallelize sending + const promises: Promise[] = []; + + if (webhookUrl) { + promises.push(sendNotification(webhookUrl, message)); + } + + try { + const subscriptions = await prisma.pushSubscription.findMany({ + where: { planId } + }); + + if (subscriptions.length > 0) { + const payload = { + title: "Cat Sitting Planner", + body: message, + url: `/` + // We could pass specific URL if needed, but for now root is okay or dashboard? + // The service worker opens the URL. + // Ideally, we want to open `/dashboard/[planId]`. + }; + + // Refine URL in payload + // We need 'lang'. We don't have it here easily unless passed. + // But we can guess or just link to root and let redirect handle it? + // Or just link to context. + // Let's rely on SW opening `/`. + + subscriptions.forEach(sub => { + promises.push((async () => { + const res = await sendPushNotification(sub, payload); + if (!res.success && (res.statusCode === 410 || res.statusCode === 404)) { + await prisma.pushSubscription.delete({ where: { id: sub.id } }); + } + })()); + }); + } + } catch (e) { + console.error("Failed to fetch/send push subscriptions", e); + } + + await Promise.allSettled(promises); +} diff --git a/lib/push.ts b/lib/push.ts new file mode 100644 index 0000000..ff98dcc --- /dev/null +++ b/lib/push.ts @@ -0,0 +1,37 @@ +import webpush from 'web-push'; + +if (!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) { + console.warn("VAPID keys are missing. Push notifications will not work."); +} else { + webpush.setVapidDetails( + process.env.VAPID_SUBJECT || 'mailto:admin@localhost', + process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + process.env.VAPID_PRIVATE_KEY + ); +} + +interface PushSubscriptionData { + endpoint: string; + p256dh: string; + auth: string; +} + +export async function sendPushNotification(subscription: PushSubscriptionData, payload: any) { + try { + await webpush.sendNotification({ + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.p256dh, + auth: subscription.auth, + } + }, JSON.stringify(payload)); + return { success: true, statusCode: 201 }; + } catch (error: any) { + if (error.statusCode === 410 || error.statusCode === 404) { + // Subscription is gone + return { success: false, statusCode: error.statusCode }; + } + console.error("Error sending push:", error); + return { success: false, statusCode: error.statusCode || 500 }; + } +} diff --git a/next.config.mjs b/next.config.mjs index a840bff..53bf917 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -5,6 +5,9 @@ const withPWA = withPWAInit({ disable: process.env.NODE_ENV === "development", register: true, skipWaiting: true, + workboxOptions: { + importScripts: ["/push-sw.js"], + }, }); /** @type {import('next').NextConfig} */ diff --git a/package-lock.json b/package-lock.json index d5950ec..98aaa67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,12 +23,14 @@ "lucide-react": "^0.562.0", "next": "16.1.1", "next-themes": "^0.4.6", + "prisma": "^6.19.1", "react": "19.2.3", "react-day-picker": "^9.13.0", "react-dom": "19.2.3", "react-hook-form": "^7.71.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", + "web-push": "^3.6.7", "zod": "^4.3.5" }, "devDependencies": { @@ -36,10 +38,10 @@ "@types/node": "^20.19.28", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.1", - "prisma": "^6.19.1", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5" @@ -2584,7 +2586,6 @@ "version": "6.19.1", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.1.tgz", "integrity": "sha512-bUL/aYkGXLwxVGhJmQMtslLT7KPEfUqmRa919fKI4wQFX4bIFUKiY8Jmio/2waAjjPYrtuDHa7EsNCnJTXxiOw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -2597,14 +2598,12 @@ "version": "6.19.1", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.1.tgz", "integrity": "sha512-h1JImhlAd/s5nhY/e9qkAzausWldbeT+e4nZF7A4zjDYBF4BZmKDt4y0jK7EZapqOm1kW7V0e9agV/iFDy3fWw==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.1", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.1.tgz", "integrity": "sha512-xy95dNJ7DiPf9IJ3oaVfX785nbFl7oNDzclUF+DIiJw6WdWCvPl0LPU0YqQLsrwv8N64uOQkH391ujo3wSo+Nw==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -2618,14 +2617,12 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.1", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.1.tgz", "integrity": "sha512-mmgcotdaq4VtAHO6keov3db+hqlBzQS6X7tR7dFCbvXjLVTxBYdSJFRWz+dq7F9p6dvWyy1X0v8BlfRixyQK6g==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.1", @@ -2637,7 +2634,6 @@ "version": "6.19.1", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.1.tgz", "integrity": "sha512-zsg44QUiQAnFUyh6Fbt7c9HjMXHwFTqtrgcX7DAZmRgnkPyYT7Sh8Mn8D5PuuDYNtMOYcpLGg576MLfIORsBYw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.1" @@ -3896,7 +3892,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -4299,6 +4294,16 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.53.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", @@ -5047,6 +5052,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5309,6 +5323,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5439,6 +5465,12 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -5494,6 +5526,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5504,7 +5542,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -5627,7 +5664,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -5653,7 +5689,6 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -5731,14 +5766,12 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", - "devOptional": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -5905,7 +5938,6 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -5949,14 +5981,12 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "devOptional": true, "license": "MIT" }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -5992,7 +6022,6 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -6015,11 +6044,19 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/effect": { "version": "3.18.4", "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", - "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -6058,7 +6095,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -6718,14 +6754,12 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, "funding": [ { "type": "individual", @@ -7103,7 +7137,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -7309,6 +7342,28 @@ "hermes-estree": "0.25.1" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -7892,7 +7947,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -8012,6 +8066,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8483,6 +8558,12 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -8499,7 +8580,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8654,7 +8734,6 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, "license": "MIT" }, "node_modules/node-releases": { @@ -8667,7 +8746,6 @@ "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", - "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -8807,7 +8885,6 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, "license": "MIT" }, "node_modules/once": { @@ -8938,14 +9015,12 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -8970,7 +9045,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -9042,7 +9116,6 @@ "version": "6.19.1", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.1.tgz", "integrity": "sha512-XRfmGzh6gtkc/Vq3LqZJcS2884dQQW3UhPo6jNRoiTW95FFQkXFg8vkYEy6og+Pyv0aY7zRQ7Wn1Cvr56XjhQQ==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -9089,7 +9162,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, "funding": [ { "type": "individual", @@ -9135,7 +9207,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -9280,7 +9351,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -9554,6 +9624,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -10228,7 +10304,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -10722,6 +10797,25 @@ "node": ">=10.13.0" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/package.json b/package.json index ea44553..b7bcb96 100644 --- a/package.json +++ b/package.json @@ -24,20 +24,22 @@ "lucide-react": "^0.562.0", "next": "16.1.1", "next-themes": "^0.4.6", + "prisma": "^6.19.1", "react": "19.2.3", "react-day-picker": "^9.13.0", "react-dom": "19.2.3", "react-hook-form": "^7.71.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", - "zod": "^4.3.5", - "prisma": "^6.19.1" + "web-push": "^3.6.7", + "zod": "^4.3.5" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20.19.28", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.1", @@ -45,4 +47,4 @@ "tw-animate-css": "^1.4.0", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/prisma/migrations/20260113074411_add_push_subscription/migration.sql b/prisma/migrations/20260113074411_add_push_subscription/migration.sql new file mode 100644 index 0000000..d956eaa --- /dev/null +++ b/prisma/migrations/20260113074411_add_push_subscription/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "PushSubscription" ( + "id" TEXT NOT NULL PRIMARY KEY, + "planId" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "p256dh" TEXT NOT NULL, + "auth" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PushSubscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a4f676e..e5d5e91 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,6 +21,7 @@ model Plan { litterInterval Int @default(2) createdAt DateTime @default(now()) bookings Booking[] + pushSubscriptions PushSubscription[] } model Booking { @@ -35,3 +36,13 @@ model Booking { @@unique([planId, date, sitterName]) } + +model PushSubscription { + id String @id @default(cuid()) + planId String + plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + endpoint String @unique + p256dh String + auth String + createdAt DateTime @default(now()) +} diff --git a/public/push-sw.js b/public/push-sw.js new file mode 100644 index 0000000..2b43add --- /dev/null +++ b/public/push-sw.js @@ -0,0 +1,43 @@ +self.addEventListener('push', function (event) { + if (!event.data) return; + + try { + const data = event.data.json(); + const title = data.title || 'Cat Sitting Planner'; + const options = { + body: data.body || '', + icon: '/icon.png', + badge: '/icon.png', + data: { + url: data.url || '/' + } + }; + + event.waitUntil( + self.registration.showNotification(title, options) + ); + } catch (err) { + console.error('Error processing push event:', err); + } +}); + +self.addEventListener('notificationclick', function (event) { + event.notification.close(); + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => { + const urlToOpen = event.notification.data.url; + + // Check if there is already a window/tab open with the target URL + for (let i = 0; i < windowClients.length; i++) { + const client = windowClients[i]; + if (client.url === urlToOpen && 'focus' in client) { + return client.focus(); + } + } + // If not, open a new window + if (clients.openWindow) { + return clients.openWindow(urlToOpen); + } + }) + ); +});