From be23a259542134fdd6ca683cb22fe746e6c37693 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo-io[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:43:24 +0000 Subject: [PATCH 1/2] feat(pwa): Implement PWA with tRPC & DB subscriptions Co-authored-by: Jean --- .env.example | 7 +- PWA_IMPLEMENTATION.md | 339 ++++++++++++++++++ apps/web/next.config.ts | 47 ++- apps/web/public/PWA_ICONS_README.md | 40 +++ apps/web/public/sw.js | 67 ++++ apps/web/src/app/manifest.ts | 40 +++ apps/web/src/components/pwa/index.ts | 2 + .../web/src/components/pwa/install-prompt.tsx | 133 +++++++ .../pwa/push-notification-manager.tsx | 224 ++++++++++++ bun.lock | 29 +- packages/api/package.json | 3 +- packages/api/src/root.ts | 2 + .../api/src/routers/push-notifications.ts | 190 ++++++++++ packages/db/src/schema/index.ts | 1 + packages/db/src/schema/push-subscriptions.ts | 36 ++ packages/env/src/client.ts | 2 + packages/env/src/server.ts | 1 + 17 files changed, 1150 insertions(+), 13 deletions(-) create mode 100644 PWA_IMPLEMENTATION.md create mode 100644 apps/web/public/PWA_ICONS_README.md create mode 100644 apps/web/public/sw.js create mode 100644 apps/web/src/app/manifest.ts create mode 100644 apps/web/src/components/pwa/index.ts create mode 100644 apps/web/src/components/pwa/install-prompt.tsx create mode 100644 apps/web/src/components/pwa/push-notification-manager.tsx create mode 100644 packages/api/src/routers/push-notifications.ts create mode 100644 packages/db/src/schema/push-subscriptions.ts diff --git a/.env.example b/.env.example index 617f951a..bb08a5be 100644 --- a/.env.example +++ b/.env.example @@ -29,4 +29,9 @@ SRH_CONNECTION_STRING="redis://redis:6379" # Marble Blog (optional) MARBLE_WORKSPACE_KEY= -MARBLE_API_URL=https://api.marblecms.com \ No newline at end of file +MARBLE_API_URL=https://api.marblecms.com + +# Web Push Notifications (PWA) +# Generate VAPID keys using: npx web-push generate-vapid-keys +NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= \ No newline at end of file diff --git a/PWA_IMPLEMENTATION.md b/PWA_IMPLEMENTATION.md new file mode 100644 index 00000000..2e08efb6 --- /dev/null +++ b/PWA_IMPLEMENTATION.md @@ -0,0 +1,339 @@ +# PWA Implementation Guide + +This document describes the Progressive Web Application (PWA) features that have been implemented in Analog Calendar. + +## Overview + +The PWA implementation includes: + +- **Web App Manifest**: Allows users to install the app on their home screen +- **Service Worker**: Enables push notifications +- **Push Notifications**: Server-side push notification support with tRPC +- **Database Persistence**: Push subscriptions are stored in the database +- **Security Headers**: Enhanced security for PWA features + +## Setup Instructions + +### 1. Generate VAPID Keys + +VAPID (Voluntary Application Server Identification) keys are required for web push notifications. + +```bash +# Install web-push globally (if not already installed) +npm install -g web-push + +# Generate VAPID keys +npx web-push generate-vapid-keys +``` + +This will output something like: + +``` +======================================= +Public Key: +BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U + +Private Key: +UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls +======================================= +``` + +### 2. Configure Environment Variables + +Add the generated VAPID keys to your `.env` file: + +```bash +NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U +VAPID_PRIVATE_KEY=UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls +``` + +**Important**: Keep the private key secret! Never commit it to version control. + +### 3. Run Database Migration + +The PWA implementation adds a new `push_subscription` table to store user push notification subscriptions. + +```bash +# Generate the migration +bun run db:generate + +# Apply the migration +bun run db:migrate +``` + +### 4. Create PWA Icons + +You need to create the following icon files and place them in the `apps/web/public/` directory: + +- `icon-192x192.png` - 192x192px icon +- `icon-512x512.png` - 512x512px icon +- `icon-192x192-maskable.png` - 192x192px maskable icon +- `icon-512x512-maskable.png` - 512x512px maskable icon + +See `apps/web/public/PWA_ICONS_README.md` for detailed instructions on creating these icons. + +**Tools for generating icons:** +- [RealFaviconGenerator](https://realfavicongenerator.net/) +- [PWA Asset Generator](https://github.com/elegantapp/pwa-asset-generator) +- [Maskable App Tool](https://maskable.app/) + +### 5. Start the Development Server + +```bash +# For local HTTPS testing (recommended for PWA features) +bun run dev --experimental-https + +# Or regular HTTP +bun run dev +``` + +## Features + +### 1. App Installation + +Users can install Analog Calendar as a Progressive Web App: + +- **Desktop (Chrome/Edge)**: Click the install button in the address bar +- **Mobile (Android)**: Tap "Add to Home Screen" from the browser menu +- **Mobile (iOS)**: Tap the share button and select "Add to Home Screen" + +The app includes an `InstallPrompt` component that guides users through the installation process. + +### 2. Push Notifications + +Users can subscribe to push notifications through the app: + +1. Navigate to the settings or notification management page +2. Click "Subscribe" to enable push notifications +3. Grant permission when prompted by the browser +4. Send test notifications to verify functionality + +**Browser Support:** +- Chrome/Edge (Desktop & Android): Full support +- Safari (iOS 16.4+ & macOS 13+): Full support +- Firefox (Desktop & Android): Full support + +### 3. Using the PWA Components + +Import and use the PWA components in your app: + +```tsx +import { PushNotificationManager, InstallPrompt } from "@/components/pwa"; + +export default function SettingsPage() { + return ( +
+ + +
+ ); +} +``` + +## API Reference + +### tRPC Router: `pushNotifications` + +The push notifications functionality is exposed through the tRPC API: + +#### `pushNotifications.subscribe` + +Subscribe to push notifications. + +```typescript +const subscription = await trpc.pushNotifications.subscribe.mutate({ + endpoint: "https://...", + keys: { + p256dh: "...", + auth: "...", + }, + expirationTime: null, +}); +``` + +#### `pushNotifications.unsubscribe` + +Unsubscribe from all push notifications for the current user. + +```typescript +await trpc.pushNotifications.unsubscribe.mutate(); +``` + +#### `pushNotifications.send` + +Send a push notification to the current user's subscribed devices. + +```typescript +await trpc.pushNotifications.send.mutate({ + title: "New Event", + body: "You have a meeting in 15 minutes", + icon: "/icon-192x192.png", +}); +``` + +#### `pushNotifications.list` + +List all push subscriptions for the current user. + +```typescript +const subscriptions = await trpc.pushNotifications.list.query(); +``` + +## Database Schema + +### `push_subscription` Table + +```typescript +{ + id: string (primary key) + userId: string (foreign key to user.id) + endpoint: string + keys: { + p256dh: string + auth: string + } + expirationTime: timestamp | null + createdAt: timestamp + updatedAt: timestamp +} +``` + +Indexes: +- `push_subscription_user_id_idx` on `userId` +- `push_subscription_endpoint_idx` on `endpoint` + +## Security Considerations + +### 1. VAPID Keys + +- The public key is safe to expose in client-side code +- The private key must be kept secret on the server +- Never commit the private key to version control +- Rotate keys periodically for security + +### 2. Security Headers + +The following security headers are automatically configured: + +**Global Headers:** +- `X-Content-Type-Options: nosniff` +- `X-Frame-Options: DENY` +- `Referrer-Policy: strict-origin-when-cross-origin` + +**Service Worker Headers:** +- `Content-Type: application/javascript; charset=utf-8` +- `Cache-Control: no-cache, no-store, must-revalidate` +- `Content-Security-Policy: default-src 'self'; script-src 'self'` + +### 3. HTTPS Requirement + +PWAs require HTTPS in production. For local development: + +```bash +# Use Next.js experimental HTTPS +bun run dev --experimental-https +``` + +### 4. User Permissions + +Push notifications require explicit user permission. The browser will prompt users to grant or deny permission when they attempt to subscribe. + +## Testing + +### Local Testing + +1. Start the dev server with HTTPS: + ```bash + bun run dev --experimental-https + ``` + +2. Open the app in a supported browser (Chrome recommended) + +3. Navigate to the notifications settings page + +4. Subscribe to push notifications + +5. Send a test notification + +### Browser DevTools + +**Chrome DevTools:** +1. Open DevTools (F12) +2. Go to Application → Service Workers +3. Verify the service worker is registered +4. Go to Application → Manifest +5. Verify the manifest is valid + +**Testing Push Notifications:** +1. Open DevTools → Application → Service Workers +2. Find your service worker +3. Use "Push" button to simulate a push event +4. Or use the test notification feature in the app + +## Troubleshooting + +### Service Worker Not Registering + +- Ensure you're serving over HTTPS (or localhost) +- Check browser console for errors +- Verify the service worker file is accessible at `/sw.js` + +### Push Notifications Not Working + +- Verify VAPID keys are correctly configured +- Check that browser notifications are enabled +- Ensure the user has granted notification permissions +- Check browser console and server logs for errors + +### Icons Not Showing + +- Verify icon files exist in the `public/` directory +- Check file names match those in `manifest.ts` +- Clear browser cache and reload + +### Manifest Not Loading + +- Verify `manifest.ts` exports a valid manifest +- Check browser console for manifest errors +- Use Chrome DevTools → Application → Manifest to debug + +## Production Deployment + +### Checklist + +- [ ] Generate production VAPID keys +- [ ] Add VAPID keys to production environment variables +- [ ] Run database migrations in production +- [ ] Create and upload PWA icons +- [ ] Ensure production site is served over HTTPS +- [ ] Test PWA installation on production +- [ ] Test push notifications on production +- [ ] Monitor service worker updates + +### Vercel Deployment + +If deploying to Vercel, the environment variables can be set in the Vercel dashboard: + +1. Go to Project Settings → Environment Variables +2. Add `NEXT_PUBLIC_VAPID_PUBLIC_KEY` +3. Add `VAPID_PRIVATE_KEY` +4. Redeploy the application + +## Future Enhancements + +Potential improvements to consider: + +1. **Offline Support**: Add caching strategies for offline functionality +2. **Background Sync**: Queue actions when offline and sync when online +3. **Periodic Background Sync**: Update calendar data in the background +4. **Web Share API**: Share events with other apps +5. **Badging API**: Show unread notifications count on the app icon +6. **Push Notification Actions**: Add buttons to notifications (e.g., "Snooze", "View") + +## Resources + +- [MDN: Progressive Web Apps](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) +- [web.dev: PWA](https://web.dev/progressive-web-apps/) +- [Web Push Protocol](https://datatracker.ietf.org/doc/html/rfc8030) +- [VAPID Specification](https://datatracker.ietf.org/doc/html/rfc8292) +- [Next.js: Progressive Web Apps](https://nextjs.org/docs/app/building-your-application/configuring/progressive-web-apps) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index e4c98330..85cb662c 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -21,7 +21,7 @@ const nextConfig: NextConfig = { }, async headers() { // For routes that are public - const headers = [ + const corsHeaders = [ { key: "Access-Control-Allow-Credentials", value: "true" }, { key: "Access-Control-Allow-Origin", value: "*" }, { @@ -34,22 +34,59 @@ const nextConfig: NextConfig = { }, ]; + // Global security headers + const securityHeaders = [ + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + ]; + return [ { source: "/api/mcp/:path*", - headers, + headers: corsHeaders, }, { source: "/api/auth/mcp/:path*", - headers, + headers: corsHeaders, }, { source: "/api/v1/:path*", - headers, + headers: corsHeaders, }, { source: "/api/openapi.json", - headers, + headers: corsHeaders, + }, + { + source: "/(.*)", + headers: securityHeaders, + }, + { + source: "/sw.js", + headers: [ + { + key: "Content-Type", + value: "application/javascript; charset=utf-8", + }, + { + key: "Cache-Control", + value: "no-cache, no-store, must-revalidate", + }, + { + key: "Content-Security-Policy", + value: "default-src 'self'; script-src 'self'", + }, + ], }, ]; }, diff --git a/apps/web/public/PWA_ICONS_README.md b/apps/web/public/PWA_ICONS_README.md new file mode 100644 index 00000000..d46e0e9e --- /dev/null +++ b/apps/web/public/PWA_ICONS_README.md @@ -0,0 +1,40 @@ +# PWA Icons Required + +This PWA implementation requires the following icon files to be placed in the `public/` directory: + +## Required Icon Files + +- `icon-192x192.png` - 192x192px icon (any purpose) +- `icon-512x512.png` - 512x512px icon (any purpose) +- `icon-192x192-maskable.png` - 192x192px maskable icon +- `icon-512x512-maskable.png` - 512x512px maskable icon + +## Generating Icons + +You can use the following tools to generate PWA icons: + +1. **RealFaviconGenerator**: https://realfavicongenerator.net/ +2. **PWA Asset Generator**: https://github.com/elegantapp/pwa-asset-generator + +## Icon Specifications + +### Standard Icons (any purpose) + +- Use your app logo with transparent or colored background +- Should look good on any background color +- Export as PNG with the specified dimensions + +### Maskable Icons + +- Must include a safe zone (inner 80% of the image) +- Logo should be centered with padding +- Background should be opaque (not transparent) +- Use https://maskable.app/ to test your maskable icons + +## Temporary Placeholder + +Until proper icons are created, you can: + +1. Create simple placeholder icons using an image editor +2. Or use the existing favicon if one exists +3. Or use a solid color square with the app initial letter diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 00000000..860dc732 --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,67 @@ +/* eslint-disable no-undef */ +// Service Worker for Analog Calendar PWA + +self.addEventListener("push", function (event) { + if (event.data) { + const data = event.data.json(); + const options = { + body: data.body, + icon: data.icon || "/icon.png", + badge: "/icon-192x192.png", + vibrate: [100, 50, 100], + data: { + dateOfArrival: Date.now(), + url: data.url || "/", + }, + actions: data.actions || [], + requireInteraction: false, + tag: data.tag || "notification", + }; + + event.waitUntil(self.registration.showNotification(data.title, options)); + } +}); + +self.addEventListener("notificationclick", function (event) { + console.log("Notification click received."); + + event.notification.close(); + + // Open the app at the specified URL or default to home + const urlToOpen = event.notification.data?.url || "/"; + + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clientList) { + // Check if there's already a window/tab open with the target URL + for (let i = 0; i < clientList.length; i++) { + const client = clientList[i]; + if (client.url === urlToOpen && "focus" in client) { + return client.focus(); + } + } + + // If no window/tab is open, open a new one + if (clients.openWindow) { + return clients.openWindow(urlToOpen); + } + }) + ); +}); + +self.addEventListener("notificationclose", function (event) { + console.log("Notification closed", event); +}); + +// Handle service worker installation +self.addEventListener("install", function (event) { + console.log("Service Worker installing."); + // Force the waiting service worker to become the active service worker + self.skipWaiting(); +}); + +// Handle service worker activation +self.addEventListener("activate", function (event) { + console.log("Service Worker activating."); + // Claim all clients immediately + event.waitUntil(clients.claim()); +}); diff --git a/apps/web/src/app/manifest.ts b/apps/web/src/app/manifest.ts new file mode 100644 index 00000000..f59e74e5 --- /dev/null +++ b/apps/web/src/app/manifest.ts @@ -0,0 +1,40 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Analog Calendar", + short_name: "Analog", + description: + "A beautiful, open-source calendar that syncs with Google Calendar and Microsoft 365", + start_url: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: "#000000", + icons: [ + { + src: "/icon-192x192.png", + sizes: "192x192", + type: "image/png", + purpose: "any", + }, + { + src: "/icon-512x512.png", + sizes: "512x512", + type: "image/png", + purpose: "any", + }, + { + src: "/icon-192x192-maskable.png", + sizes: "192x192", + type: "image/png", + purpose: "maskable", + }, + { + src: "/icon-512x512-maskable.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + }; +} diff --git a/apps/web/src/components/pwa/index.ts b/apps/web/src/components/pwa/index.ts new file mode 100644 index 00000000..d5543794 --- /dev/null +++ b/apps/web/src/components/pwa/index.ts @@ -0,0 +1,2 @@ +export { PushNotificationManager } from "./push-notification-manager"; +export { InstallPrompt } from "./install-prompt"; diff --git a/apps/web/src/components/pwa/install-prompt.tsx b/apps/web/src/components/pwa/install-prompt.tsx new file mode 100644 index 00000000..6958bc89 --- /dev/null +++ b/apps/web/src/components/pwa/install-prompt.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Download, Smartphone } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +export function InstallPrompt() { + const [isIOS, setIsIOS] = useState(false); + const [isStandalone, setIsStandalone] = useState(false); + const [deferredPrompt, setDeferredPrompt] = + useState(null); + + useEffect(() => { + // Check if running on iOS + const userAgent = window.navigator.userAgent; + const isIOSDevice = + /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; + setIsIOS(isIOSDevice); + + // Check if already installed (running in standalone mode) + const isInStandaloneMode = window.matchMedia( + "(display-mode: standalone)", + ).matches; + setIsStandalone(isInStandaloneMode); + + // Listen for the beforeinstallprompt event (Chrome, Edge, etc.) + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault(); + setDeferredPrompt(e as BeforeInstallPromptEvent); + }; + + window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + + return () => { + window.removeEventListener( + "beforeinstallprompt", + handleBeforeInstallPrompt, + ); + }; + }, []); + + async function handleInstallClick() { + if (!deferredPrompt) { + return; + } + + try { + await deferredPrompt.prompt(); + + const choiceResult = await deferredPrompt.userChoice; + + if (choiceResult.outcome === "accepted") { + toast.success("App installed successfully"); + } + + setDeferredPrompt(null); + } catch (error) { + console.error("Error installing app:", error); + toast.error("Failed to install app"); + } + } + + // Don't show if already installed + if (isStandalone) { + return null; + } + + return ( + + + Install App + + Install Analog Calendar on your device for a native app experience. + + + + {isIOS ? ( +
+

+ To install this app on your iOS device: +

+
    +
  1. + Tap the share button{" "} + + ⎋ + {" "} + in your browser +
  2. +
  3. + Scroll down and tap "Add to Home Screen"{" "} + + ➕ + +
  4. +
  5. Confirm by tapping "Add"
  6. +
+
+ ) : deferredPrompt ? ( + + ) : ( +
+

+ To install this app, open the browser menu and look for + "Install App" or "Add to Home Screen" option. +

+
+ + Available on Chrome, Edge, and other modern browsers +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/pwa/push-notification-manager.tsx b/apps/web/src/components/pwa/push-notification-manager.tsx new file mode 100644 index 00000000..5caef29d --- /dev/null +++ b/apps/web/src/components/pwa/push-notification-manager.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Bell, BellOff } from "lucide-react"; +import { toast } from "sonner"; + +import { env } from "@repo/env/client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { trpc } from "@/lib/trpc/client"; + +function urlBase64ToUint8Array(base64String: string) { + 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; +} + +export function PushNotificationManager() { + const [isSupported, setIsSupported] = useState(false); + const [subscription, setSubscription] = useState( + null, + ); + const [message, setMessage] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const subscribeMutation = trpc.pushNotifications.subscribe.useMutation(); + const unsubscribeMutation = trpc.pushNotifications.unsubscribe.useMutation(); + const sendMutation = trpc.pushNotifications.send.useMutation(); + + useEffect(() => { + if ("serviceWorker" in navigator && "PushManager" in window) { + setIsSupported(true); + registerServiceWorker(); + } + }, []); + + async function registerServiceWorker() { + try { + const registration = await navigator.serviceWorker.register("/sw.js", { + scope: "/", + updateViaCache: "none", + }); + + const sub = await registration.pushManager.getSubscription(); + setSubscription(sub); + } catch (error) { + console.error("Service worker registration failed:", error); + toast.error("Failed to register service worker"); + } + } + + async function subscribeToPush() { + setIsLoading(true); + try { + const registration = await navigator.serviceWorker.ready; + + const sub = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array( + env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + ), + }); + + setSubscription(sub); + + // Serialize the subscription for sending to server + const serializedSub = JSON.parse(JSON.stringify(sub)); + + await subscribeMutation.mutateAsync(serializedSub); + + toast.success("Successfully subscribed to push notifications"); + } catch (error) { + console.error("Failed to subscribe to push notifications:", error); + toast.error("Failed to subscribe to push notifications"); + } finally { + setIsLoading(false); + } + } + + async function unsubscribeFromPush() { + setIsLoading(true); + try { + await subscription?.unsubscribe(); + setSubscription(null); + + await unsubscribeMutation.mutateAsync(); + + toast.success("Successfully unsubscribed from push notifications"); + } catch (error) { + console.error("Failed to unsubscribe from push notifications:", error); + toast.error("Failed to unsubscribe from push notifications"); + } finally { + setIsLoading(false); + } + } + + async function sendTestNotification() { + if (!subscription) { + toast.error("No active subscription"); + return; + } + + if (!message.trim()) { + toast.error("Please enter a message"); + return; + } + + setIsLoading(true); + try { + await sendMutation.mutateAsync({ + title: "Test Notification", + body: message, + icon: "/icon-192x192.png", + }); + + setMessage(""); + toast.success("Test notification sent"); + } catch (error) { + console.error("Failed to send notification:", error); + toast.error("Failed to send notification"); + } finally { + setIsLoading(false); + } + } + + if (!isSupported) { + return ( + + + Push Notifications + + Push notifications are not supported in this browser. + + + + ); + } + + return ( + + + Push Notifications + + Manage your push notification preferences and send test notifications. + + + + {subscription ? ( + <> +
+ + You are subscribed to push notifications +
+ +
+ +
+ setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendTestNotification(); + } + }} + /> + +
+
+ + + + ) : ( + <> +

+ You are not subscribed to push notifications. +

+ + + )} +
+
+ ); +} diff --git a/bun.lock b/bun.lock index ba8e8187..16538c74 100644 --- a/bun.lock +++ b/bun.lock @@ -185,6 +185,7 @@ "@repo/temporal": "workspace:*", "@upstash/ratelimit": "^2.0.5", "@upstash/redis": "^1.35.6", + "web-push": "^3.6.7", }, "devDependencies": { "@microsoft/microsoft-graph-types": "^2.40.0", @@ -1807,7 +1808,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], @@ -1847,6 +1848,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], @@ -1893,6 +1896,8 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -2377,7 +2382,9 @@ "http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], - "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], @@ -2713,6 +2720,8 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -3331,6 +3340,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-push": ["web-push@3.6.7", "", { "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" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -3417,6 +3428,8 @@ "@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], + "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "@sentry/nextjs/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "@sentry/nextjs/resolve": ["resolve@1.22.8", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw=="], @@ -3509,8 +3522,6 @@ "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], - "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="], @@ -3531,6 +3542,8 @@ "hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="], + "http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "jest-worker/@types/node": ["@types/node@22.18.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -3577,6 +3590,8 @@ "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "teeny-request/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "teeny-request/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -3637,6 +3652,8 @@ "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="], + "@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "@sentry/node/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@todesktop/client-core/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -3671,8 +3688,6 @@ "express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], @@ -3701,6 +3716,8 @@ "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "teeny-request/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "webpack/eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], "body-parser/type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], diff --git a/packages/api/package.json b/packages/api/package.json index 727dedd4..7a0e8ff9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -43,7 +43,8 @@ "@repo/schemas": "workspace:*", "@repo/temporal": "workspace:*", "@upstash/ratelimit": "^2.0.5", - "@upstash/redis": "^1.35.6" + "@upstash/redis": "^1.35.6", + "web-push": "^3.6.7" }, "devDependencies": { "@microsoft/microsoft-graph-types": "^2.40.0", diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 16c681e8..9a69e8b3 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -9,6 +9,7 @@ import { freeBusyRouter } from "./routers/free-busy"; import { integrationsRouter } from "./routers/integrations"; import { mapsRouter } from "./routers/maps"; import { placesRouter } from "./routers/places"; +import { pushNotificationsRouter } from "./routers/push-notifications"; import { tasksRouter } from "./routers/tasks"; import { userRouter } from "./routers/user"; import { @@ -29,6 +30,7 @@ export const appRouter = createTRPCRouter({ conferencing: conferencingRouter, places: placesRouter, maps: mapsRouter, + pushNotifications: pushNotificationsRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/push-notifications.ts b/packages/api/src/routers/push-notifications.ts new file mode 100644 index 00000000..a33b4547 --- /dev/null +++ b/packages/api/src/routers/push-notifications.ts @@ -0,0 +1,190 @@ +import "server-only"; + +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import webpush from "web-push"; +import { z } from "zod"; + +import { pushSubscription } from "@repo/db/schema"; +import { env } from "@repo/env/server"; + +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +// Initialize web-push with VAPID details +webpush.setVapidDetails( + "mailto:noreply@analog.email", + process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, + env.VAPID_PRIVATE_KEY, +); + +const pushSubscriptionSchema = z.object({ + endpoint: z.string().url(), + expirationTime: z.number().nullable().optional(), + keys: z.object({ + p256dh: z.string(), + auth: z.string(), + }), +}); + +export const pushNotificationsRouter = createTRPCRouter({ + subscribe: protectedProcedure + .input(pushSubscriptionSchema) + .mutation(async ({ ctx, input }) => { + const { endpoint, expirationTime, keys } = input; + + // Check if subscription already exists for this endpoint and user + const existingSubscription = + await ctx.db.query.pushSubscription.findFirst({ + where: and( + eq(pushSubscription.userId, ctx.user.id), + eq(pushSubscription.endpoint, endpoint), + ), + }); + + if (existingSubscription) { + // Update existing subscription + await ctx.db + .update(pushSubscription) + .set({ + keys, + expirationTime: expirationTime ? new Date(expirationTime) : null, + updatedAt: new Date(), + }) + .where(eq(pushSubscription.id, existingSubscription.id)); + + return { success: true, id: existingSubscription.id }; + } + + // Create new subscription + const id = nanoid(); + await ctx.db.insert(pushSubscription).values({ + id, + userId: ctx.user.id, + endpoint, + keys, + expirationTime: expirationTime ? new Date(expirationTime) : null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + return { success: true, id }; + }), + + unsubscribe: protectedProcedure.mutation(async ({ ctx }) => { + // Delete all subscriptions for the current user + await ctx.db + .delete(pushSubscription) + .where(eq(pushSubscription.userId, ctx.user.id)); + + return { success: true }; + }), + + unsubscribeByEndpoint: protectedProcedure + .input(z.object({ endpoint: z.string().url() })) + .mutation(async ({ ctx, input }) => { + // Delete specific subscription by endpoint + await ctx.db + .delete(pushSubscription) + .where( + and( + eq(pushSubscription.userId, ctx.user.id), + eq(pushSubscription.endpoint, input.endpoint), + ), + ); + + return { success: true }; + }), + + send: protectedProcedure + .input( + z.object({ + title: z.string(), + body: z.string(), + icon: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { title, body, icon } = input; + + // Get all subscriptions for the current user + const subscriptions = await ctx.db.query.pushSubscription.findMany({ + where: eq(pushSubscription.userId, ctx.user.id), + }); + + if (subscriptions.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No push subscriptions found", + }); + } + + const payload = JSON.stringify({ + title, + body, + icon: icon || "/icon.png", + }); + + // Send notification to all subscriptions + const results = await Promise.allSettled( + subscriptions.map((sub) => + webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: sub.keys as { p256dh: string; auth: string }, + }, + payload, + ), + ), + ); + + // Count successful and failed sends + const successful = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected").length; + + // Clean up invalid subscriptions (410 Gone or 404 Not Found) + const invalidIndices = results + .map((result, index) => { + if (result.status === "rejected") { + const error = result.reason as { statusCode?: number }; + if (error.statusCode === 410 || error.statusCode === 404) { + return index; + } + } + return -1; + }) + .filter((i) => i !== -1); + + if (invalidIndices.length > 0) { + await Promise.all( + invalidIndices.map((i) => + ctx.db + .delete(pushSubscription) + .where(eq(pushSubscription.id, subscriptions[i]!.id)), + ), + ); + } + + return { + success: true, + sent: successful, + failed, + cleaned: invalidIndices.length, + }; + }), + + list: protectedProcedure.query(async ({ ctx }) => { + const subscriptions = await ctx.db.query.pushSubscription.findMany({ + where: eq(pushSubscription.userId, ctx.user.id), + columns: { + id: true, + endpoint: true, + expirationTime: true, + createdAt: true, + updatedAt: true, + }, + }); + + return subscriptions; + }), +}); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 84ab37f5..20e5e7ba 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,2 +1,3 @@ export * from "./auth"; export * from "./calendars"; +export * from "./push-subscriptions"; diff --git a/packages/db/src/schema/push-subscriptions.ts b/packages/db/src/schema/push-subscriptions.ts new file mode 100644 index 00000000..268fe021 --- /dev/null +++ b/packages/db/src/schema/push-subscriptions.ts @@ -0,0 +1,36 @@ +import { relations } from "drizzle-orm"; +import { index, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +import { user } from "./auth"; + +export const pushSubscription = pgTable( + "push_subscription", + { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + endpoint: text().notNull(), + keys: jsonb().notNull().$type<{ + p256dh: string; + auth: string; + }>(), + expirationTime: timestamp(), + createdAt: timestamp().defaultNow().notNull(), + updatedAt: timestamp().defaultNow().notNull(), + }, + (table) => [ + index("push_subscription_user_id_idx").on(table.userId), + index("push_subscription_endpoint_idx").on(table.endpoint), + ], +); + +export const pushSubscriptionRelations = relations( + pushSubscription, + ({ one }) => ({ + user: one(user, { + fields: [pushSubscription.userId], + references: [user.id], + }), + }), +); diff --git a/packages/env/src/client.ts b/packages/env/src/client.ts index bd437814..716224bd 100644 --- a/packages/env/src/client.ts +++ b/packages/env/src/client.ts @@ -7,6 +7,7 @@ export const env = createEnv({ NEXT_PUBLIC_ENV: z.enum(["development", "test", "production"]), NEXT_PUBLIC_VERCEL_ENV: z.enum(["development", "preview", "production"]), NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: z.string().optional(), + NEXT_PUBLIC_VAPID_PUBLIC_KEY: z.string().min(1), }, runtimeEnv: { NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL, @@ -14,6 +15,7 @@ export const env = createEnv({ NEXT_PUBLIC_VERCEL_ENV: process.env.NEXT_PUBLIC_VERCEL_ENV, NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY, + NEXT_PUBLIC_VAPID_PUBLIC_KEY: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, }, skipValidation: process.env.NODE_ENV !== "production", }); diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index 863da8b7..76ba411e 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -22,6 +22,7 @@ export const env = createEnv({ COMPOSIO_API_KEY: z.string().optional(), FIRECRAWL_API_KEY: z.string().min(1), BROWSERBASE_API_KEY: z.string().min(1), + VAPID_PRIVATE_KEY: z.string().min(1), }, experimental__runtimeEnv: process.env, skipValidation: process.env.NODE_ENV !== "production", From a4d4b00cfc1c0b1bf5035bfd5e4b788cfe5048f5 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo-io[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 02:59:09 +0000 Subject: [PATCH 2/2] fix(pwa): address review feedback - normalize URL, add unique constraint, format code --- PWA_IMPLEMENTATION.md | 10 +++++++++- apps/web/public/sw.js | 2 +- bun.lock | 3 +++ packages/api/package.json | 1 + packages/db/src/schema/push-subscriptions.ts | 13 ++++++++++++- 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/PWA_IMPLEMENTATION.md b/PWA_IMPLEMENTATION.md index 2e08efb6..93dde369 100644 --- a/PWA_IMPLEMENTATION.md +++ b/PWA_IMPLEMENTATION.md @@ -73,6 +73,7 @@ You need to create the following icon files and place them in the `apps/web/publ See `apps/web/public/PWA_ICONS_README.md` for detailed instructions on creating these icons. **Tools for generating icons:** + - [RealFaviconGenerator](https://realfavicongenerator.net/) - [PWA Asset Generator](https://github.com/elegantapp/pwa-asset-generator) - [Maskable App Tool](https://maskable.app/) @@ -109,6 +110,7 @@ Users can subscribe to push notifications through the app: 4. Send test notifications to verify functionality **Browser Support:** + - Chrome/Edge (Desktop & Android): Full support - Safari (iOS 16.4+ & macOS 13+): Full support - Firefox (Desktop & Android): Full support @@ -118,7 +120,7 @@ Users can subscribe to push notifications through the app: Import and use the PWA components in your app: ```tsx -import { PushNotificationManager, InstallPrompt } from "@/components/pwa"; +import { InstallPrompt, PushNotificationManager } from "@/components/pwa"; export default function SettingsPage() { return ( @@ -199,6 +201,7 @@ const subscriptions = await trpc.pushNotifications.list.query(); ``` Indexes: + - `push_subscription_user_id_idx` on `userId` - `push_subscription_endpoint_idx` on `endpoint` @@ -216,11 +219,13 @@ Indexes: The following security headers are automatically configured: **Global Headers:** + - `X-Content-Type-Options: nosniff` - `X-Frame-Options: DENY` - `Referrer-Policy: strict-origin-when-cross-origin` **Service Worker Headers:** + - `Content-Type: application/javascript; charset=utf-8` - `Cache-Control: no-cache, no-store, must-revalidate` - `Content-Security-Policy: default-src 'self'; script-src 'self'` @@ -243,6 +248,7 @@ Push notifications require explicit user permission. The browser will prompt use ### Local Testing 1. Start the dev server with HTTPS: + ```bash bun run dev --experimental-https ``` @@ -258,6 +264,7 @@ Push notifications require explicit user permission. The browser will prompt use ### Browser DevTools **Chrome DevTools:** + 1. Open DevTools (F12) 2. Go to Application → Service Workers 3. Verify the service worker is registered @@ -265,6 +272,7 @@ Push notifications require explicit user permission. The browser will prompt use 5. Verify the manifest is valid **Testing Push Notifications:** + 1. Open DevTools → Application → Service Workers 2. Find your service worker 3. Use "Push" button to simulate a push event diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 860dc732..40c24c9b 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -28,7 +28,7 @@ self.addEventListener("notificationclick", function (event) { event.notification.close(); // Open the app at the specified URL or default to home - const urlToOpen = event.notification.data?.url || "/"; + const urlToOpen = new URL(event.notification.data?.url || "/", self.location.origin).href; event.waitUntil( clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clientList) { diff --git a/bun.lock b/bun.lock index 16538c74..e9008ad1 100644 --- a/bun.lock +++ b/bun.lock @@ -192,6 +192,7 @@ "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^24.8.1", + "@types/web-push": "^3.6.4", "eslint": "^9.38.0", "typescript": "^5.9.2", }, @@ -1690,6 +1691,8 @@ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/type-utils": "8.46.1", "@typescript-eslint/utils": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.46.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA=="], diff --git a/packages/api/package.json b/packages/api/package.json index 7a0e8ff9..d95d0b84 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -51,6 +51,7 @@ "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^24.8.1", + "@types/web-push": "^3.6.4", "eslint": "^9.38.0", "typescript": "^5.9.2" } diff --git a/packages/db/src/schema/push-subscriptions.ts b/packages/db/src/schema/push-subscriptions.ts index 268fe021..bf22a18b 100644 --- a/packages/db/src/schema/push-subscriptions.ts +++ b/packages/db/src/schema/push-subscriptions.ts @@ -1,5 +1,12 @@ import { relations } from "drizzle-orm"; -import { index, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { + index, + jsonb, + pgTable, + text, + timestamp, + unique, +} from "drizzle-orm/pg-core"; import { user } from "./auth"; @@ -22,6 +29,10 @@ export const pushSubscription = pgTable( (table) => [ index("push_subscription_user_id_idx").on(table.userId), index("push_subscription_endpoint_idx").on(table.endpoint), + unique("push_subscription_user_endpoint_unique").on( + table.userId, + table.endpoint, + ), ], );