diff --git a/.env.example b/.env.example index 4edc1be..9cb2a0f 100644 --- a/.env.example +++ b/.env.example @@ -19,9 +19,10 @@ AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/ AUTH_OIDC_CLIENT_ID=replace-me AUTH_OIDC_CLIENT_SECRET=replace-me -# Web Push (generate with: pnpm vapid:generate) +# Web Push (generate with: pnpm vapid:generate — copy all three lines to .env) VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= +NEXT_PUBLIC_VAPID_PUBLIC_KEY= VAPID_SUBJECT=mailto:you@example.com # ntfy (optional fallback channel; leave blank to disable) diff --git a/STATUS.md b/STATUS.md index 715db8a..96f5dd2 100644 --- a/STATUS.md +++ b/STATUS.md @@ -30,6 +30,12 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **50 — PWA shell**. `public/manifest.webmanifest` (name, short_name, icons, theme_color, display: standalone, start_url `/`). Placeholder PNG icons at 180, 192, 384, 512 (regular + maskable) generated by `scripts/generate-icons.mjs` (`pnpm gen:icons`); `public/icon.svg` committed as source. Hand-rolled service worker at `public/sw.js`: precaches `offline.html` on install, cache-first for `/_next/static/`, network-first for navigation with offline fallback, network-only for API routes. `src/components/pwa-register.tsx` registers the SW client-side. `src/components/install-prompt.tsx` shows a dismissible banner: `beforeinstallprompt` on Android/Chrome, a one-time "Add to Home Screen" hint on iOS (detected via UA + `navigator.maxTouchPoints`, suppressed in standalone mode). Root layout exports `viewport` (themeColor), updated `metadata` (manifest, appleWebApp, apple-touch-icon), and mounts both new components. `pnpm typecheck`, `pnpm lint`, `pnpm build` all clean. - **51 — Offline shell + service worker caching**. `next.config.ts` generates `public/sw.js` as a side effect on every `next build` / `next dev` invocation, embedding a build timestamp as `CACHE_VERSION` (stable `"dev"` string in development to avoid hot-reload cache churn; epoch milliseconds in production). SW strategies: stale-while-revalidate for `/_next/static/` chunks and navigation HTML (cached page served instantly, network update fires in background); network-first with 2-second abort timeout for API GETs falling back to cache; network-only for mutations (POST/PATCH/DELETE/PUT) — if offline, all controlled clients receive `{ type: "OFFLINE_MUTATION" }` via `postMessage` and a synthetic 503 is returned. Activate handler evicts all `famapp-*` caches whose suffix doesn't match the current version, then claims clients. `pwa-register.tsx` extended with three inline toasts: amber "offline" banner (persistent, driven by `navigator.onLine` + `online`/`offline` events), red "changes can't be saved" toast (auto-dismisses in 4 s, driven by SW postMessage), and indigo "new version available — refresh" bottom toast (driven by `controllerchange` with `hadController` guard). `pnpm typecheck`, `pnpm build` pass. +- **40 — Web Push (VAPID)**. Installed `web-push` + `@types/web-push`. Added `pnpm vapid:generate` script (`scripts/vapid-generate.mjs`) that prints all three env vars to stdout. Added `push_subscriptions` table to `_core/schema.ts` + migration `0013_push_notify_reminders.sql`. Created `_core/push.ts` with `sendPush(userId, payload)` — iterates subscriptions, removes 404/410 stale entries. Added `push` and `notificationclick` event handlers to the generated `public/sw.js` template. Created `` client component on `/settings` (opt-in button → `subscribeToPush` server action, disable button → `unsubscribeFromPush`, test button → `sendTestNotification`). Documented `NEXT_PUBLIC_VAPID_PUBLIC_KEY` in `.env.example`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass. + +- **42 — Notification bus + ntfy adapter**. Added `notifications` table and `notif_push`/`notif_inapp`/`notif_ntfy` columns on `users` (migration `0013_push_notify_reminders.sql`). Created `_core/notify.ts` with `notify(userId, { title, body, url, channels? })` — fans out to push (if VAPID configured), in-app DB insert, and ntfy POST (if `NTFY_URL`+`NTFY_TOPIC` set). Added `` async server component in `AppNav`: queries last 20 notifications, shows unread badge, dropdown inbox with mark-read and mark-all-read. Added `` client component with per-channel checkboxes in `/settings`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass. + +- **41 — Reminders engine**. Added `fired_at` and `created_by` columns to `reminders` table (migration `0013_push_notify_reminders.sql`); default channel changed to `'auto'`. Created `_core/reminders.ts` with `scheduleReminder` (upsert by entity), `cancelReminder`, `listReminders`, and `tickReminders` (30 s tick, `pg_try_advisory_xact_lock` guard). `startReminderWorker()` started via `src/instrumentation.ts` on the Node.js runtime. Notes actions updated to use `scheduleReminder`/`cancelReminder` instead of raw SQL. Calendar `createEvent` accepts optional `remindMinutesBefore` and schedules a reminder; `deleteEvent` calls `cancelReminder`. Calendar-shell event dialog shows "Remind me 30 min before" checkbox (new events only, checked by default). Reminder worker confirmed starting on server boot (logged in dev server output). `pnpm typecheck`, `pnpm lint`, `pnpm build` pass. + ## Next up - Next task in `docs/tasks/`. diff --git a/drizzle/0013_push_notify_reminders.sql b/drizzle/0013_push_notify_reminders.sql new file mode 100644 index 0000000..496b1c8 --- /dev/null +++ b/drizzle/0013_push_notify_reminders.sql @@ -0,0 +1,39 @@ +-- Task 40: push_subscriptions table +CREATE TABLE "push_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "endpoint" text NOT NULL UNIQUE, + "p256dh" text NOT NULL, + "auth" text NOT NULL, + "user_agent" text, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +CREATE INDEX "push_subscriptions_user_idx" ON "push_subscriptions" ("user_id"); + +-- Task 42: notifications table +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "title" text NOT NULL, + "body" text NOT NULL, + "url" text, + "read_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +CREATE INDEX "notifications_user_read_idx" ON "notifications" ("user_id", "read_at"); + +-- Task 42: per-user notification channel preferences +ALTER TABLE "users" + ADD COLUMN "notif_push" boolean NOT NULL DEFAULT true, + ADD COLUMN "notif_inapp" boolean NOT NULL DEFAULT true, + ADD COLUMN "notif_ntfy" boolean NOT NULL DEFAULT false; + +-- Task 41: add fired_at and created_by to reminders +ALTER TABLE "reminders" + ADD COLUMN "fired_at" timestamp with time zone, + ADD COLUMN "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL; + +-- Task 41: update default channel value to 'auto' +UPDATE "reminders" SET "channel" = 'auto' WHERE "channel" = 'in_app'; diff --git a/eslint.config.mjs b/eslint.config.mjs index b10345d..3b0c028 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,7 @@ export default tseslint.config( ".claude/**", "dist/**", "drizzle/**", + "public/sw.js", ], }, js.configs.recommended, diff --git a/next.config.ts b/next.config.ts index 7a5fbd1..f82c2b8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -89,6 +89,38 @@ self.addEventListener("fetch", (e) => { } }); +// --- push notifications --- + +self.addEventListener("push", (e) => { + if (!e.data) return; + const data = e.data.json(); + e.waitUntil( + self.registration.showNotification(data.title || "famapp", { + body: data.body || "", + data: { url: data.url || "/" }, + icon: "/icon-192.png", + badge: "/icon-192.png", + }) + ); +}); + +self.addEventListener("notificationclick", (e) => { + e.notification.close(); + const url = e.notification.data?.url || "/"; + e.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((cs) => { + const match = cs.find((c) => c.url.includes(self.location.origin)); + if (match) { + match.focus(); + return match.navigate(url); + } + return self.clients.openWindow(url); + }) + ); +}); + // --- strategy helpers --- async function staleWhileRevalidate(request, cacheName) { diff --git a/package.json b/package.json index 0c50fd8..04d9ffa 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "db:migrate": "drizzle-kit migrate", "db:seed": "tsx --env-file=.env scripts/seed.ts", "db:studio": "drizzle-kit studio", - "gen:icons": "node scripts/generate-icons.mjs" + "gen:icons": "node scripts/generate-icons.mjs", + "vapid:generate": "node scripts/vapid-generate.mjs" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", @@ -32,6 +33,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-grid-layout": "^2.1.0", + "@types/web-push": "^3.6.4", "drizzle-kit": "^0.31.10", "eslint": "^9.15.0", "eslint-config-next": "^16.2.4", @@ -64,6 +66,7 @@ "shadcn": "^4.7.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "web-push": "^3.6.7", "zod": "^4.4.3", "zod-to-json-schema": "^3.25.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5adb69c..50fd8fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + web-push: + specifier: ^3.6.7 + version: 3.6.7 zod: specifier: ^4.4.3 version: 4.4.3 @@ -102,6 +105,9 @@ importers: '@types/react-grid-layout': specifier: ^2.1.0 version: 2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -1564,6 +1570,9 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@typescript-eslint/eslint-plugin@8.59.2': resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1813,6 +1822,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -1848,6 +1860,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -1868,6 +1883,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2201,6 +2219,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + eciesjs@0.4.18: resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -2692,6 +2713,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -2972,6 +2997,12 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3136,6 +3167,9 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3589,6 +3623,9 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3966,6 +4003,11 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -5195,6 +5237,10 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} + '@types/web-push@3.6.4': + dependencies: + '@types/node': 22.19.17 + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5459,6 +5505,13 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + ast-types-flow@0.0.8: {} ast-types@0.16.1: @@ -5481,6 +5534,8 @@ snapshots: baseline-browser-mapping@2.10.27: {} + bn.js@4.12.3: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -5516,6 +5571,8 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bundle-name@4.1.0: @@ -5724,6 +5781,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + eciesjs@0.4.18: dependencies: '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) @@ -6462,6 +6523,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http_ece@1.2.0: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -6706,6 +6769,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6828,6 +6902,8 @@ snapshots: mimic-function@5.0.1: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -7299,6 +7375,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -7803,6 +7881,16 @@ snapshots: vary@1.1.2: {} + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + web-streams-polyfill@3.3.3: {} which-boxed-primitive@1.1.1: diff --git a/public/sw.js b/public/sw.js index 7a1faf6..02254c2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,6 @@ -// famapp service worker — v1778101829038 +// famapp service worker — vdev // Generated at build time. Do not edit directly. -const CACHE_VERSION = "1778101829038"; +const CACHE_VERSION = "dev"; const SHELL = "famapp-shell-" + CACHE_VERSION; const API = "famapp-api-" + CACHE_VERSION; const OFFLINE = "/offline.html"; @@ -78,6 +78,38 @@ self.addEventListener("fetch", (e) => { } }); +// --- push notifications --- + +self.addEventListener("push", (e) => { + if (!e.data) return; + const data = e.data.json(); + e.waitUntil( + self.registration.showNotification(data.title || "famapp", { + body: data.body || "", + data: { url: data.url || "/" }, + icon: "/icon-192.png", + badge: "/icon-192.png", + }) + ); +}); + +self.addEventListener("notificationclick", (e) => { + e.notification.close(); + const url = e.notification.data?.url || "/"; + e.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((cs) => { + const match = cs.find((c) => c.url.includes(self.location.origin)); + if (match) { + match.focus(); + return match.navigate(url); + } + return self.clients.openWindow(url); + }) + ); +}); + // --- strategy helpers --- async function staleWhileRevalidate(request, cacheName) { diff --git a/scripts/vapid-generate.mjs b/scripts/vapid-generate.mjs new file mode 100644 index 0000000..6deeee1 --- /dev/null +++ b/scripts/vapid-generate.mjs @@ -0,0 +1,6 @@ +import webPush from "web-push"; + +const { publicKey, privateKey } = webPush.generateVAPIDKeys(); +console.log(`VAPID_PUBLIC_KEY=${publicKey}`); +console.log(`VAPID_PRIVATE_KEY=${privateKey}`); +console.log(`NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`); diff --git a/src/app/settings/notify-actions.ts b/src/app/settings/notify-actions.ts new file mode 100644 index 0000000..c2ba80a --- /dev/null +++ b/src/app/settings/notify-actions.ts @@ -0,0 +1,40 @@ +"use server"; + +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/lib/db"; +import { notifications, users } from "@/modules/_core/schema"; +import { getCurrentSession } from "@/lib/session"; + +export async function markNotificationRead(id: string): Promise { + const { user } = await getCurrentSession(); + await db + .update(notifications) + .set({ readAt: new Date() }) + .where(and(eq(notifications.id, id), eq(notifications.userId, user.id))); + revalidatePath("/"); +} + +export async function markAllNotificationsRead(): Promise { + const { user } = await getCurrentSession(); + await db + .update(notifications) + .set({ readAt: new Date() }) + .where(and(eq(notifications.userId, user.id), isNull(notifications.readAt))); + revalidatePath("/"); +} + +export async function setNotifChannel( + channel: "push" | "inapp" | "ntfy", + enabled: boolean, +): Promise { + const { user } = await getCurrentSession(); + const col = + channel === "push" + ? { notifPush: enabled } + : channel === "inapp" + ? { notifInApp: enabled } + : { notifNtfy: enabled }; + await db.update(users).set(col).where(eq(users.id, user.id)); + revalidatePath("/settings"); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 90c2161..ab9bb57 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -5,12 +5,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ThemePicker } from "@/components/theme-picker"; import { CompletionDelaySetting } from "@/components/completion-delay-setting"; +import { PushOptIn } from "@/components/push-opt-in"; +import { NotifyChannelToggles } from "@/components/notify-channel-toggles"; import { revokeShareLinkAction } from "./actions"; import Link from "next/link"; export default async function SettingsPage() { const { user } = await getCurrentSession(); const shareLinks = await getActiveShareLinks(); + const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]); return (
@@ -38,6 +41,29 @@ export default async function SettingsPage() { + + + Push Notifications + + + + + + + + + Notification Channels + + + + + + Active Share Links diff --git a/src/app/settings/push-actions.ts b/src/app/settings/push-actions.ts new file mode 100644 index 0000000..8752abf --- /dev/null +++ b/src/app/settings/push-actions.ts @@ -0,0 +1,50 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { pushSubscriptions } from "@/modules/_core/schema"; +import { getCurrentSession } from "@/lib/session"; +import { sendPush } from "@/modules/_core/push"; + +type PushSubscriptionJSON = { + endpoint: string; + keys: { p256dh: string; auth: string }; +}; + +export async function subscribeToPush( + sub: PushSubscriptionJSON, + userAgent: string, +): Promise { + const { user } = await getCurrentSession(); + await db + .insert(pushSubscriptions) + .values({ + userId: user.id, + endpoint: sub.endpoint, + p256dh: sub.keys.p256dh, + auth: sub.keys.auth, + userAgent: userAgent.slice(0, 512), + }) + .onConflictDoUpdate({ + target: pushSubscriptions.endpoint, + set: { p256dh: sub.keys.p256dh, auth: sub.keys.auth }, + }); +} + +export async function unsubscribeFromPush(endpoint: string): Promise { + const { user } = await getCurrentSession(); + await db + .delete(pushSubscriptions) + .where( + and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)), + ); +} + +export async function sendTestNotification(): Promise { + const { user } = await getCurrentSession(); + await sendPush(user.id, { + title: "famapp test", + body: "Push notifications are working!", + url: "/settings", + }); +} diff --git a/src/components/app-nav.tsx b/src/components/app-nav.tsx index 260fe43..4863a60 100644 --- a/src/components/app-nav.tsx +++ b/src/components/app-nav.tsx @@ -1,14 +1,36 @@ import Link from "next/link"; import { Settings } from "lucide-react"; +import { desc, eq } from "drizzle-orm"; import { getRegistry } from "@/modules/_core/registry"; import type { DashboardMeta } from "@/app/d/actions"; +import { notifications } from "@/modules/_core/schema"; +import { db } from "@/lib/db"; +import { auth } from "@/lib/auth"; import { DashboardSwitcher } from "./dashboard-switcher"; import { DashboardTab } from "./dashboard-tab"; +import { NotificationBell } from "./notification-bell"; -export function AppNav({ dashboards = [] }: { dashboards?: DashboardMeta[] }) { +async function getNotifications(userId: string) { + const rows = await db + .select() + .from(notifications) + .where(eq(notifications.userId, userId)) + .orderBy(desc(notifications.createdAt)) + .limit(20); + const unread = rows.filter((n) => !n.readAt).length; + return { rows, unread }; +} + +export async function AppNav({ dashboards = [] }: { dashboards?: DashboardMeta[] }) { const { modules } = getRegistry(); const navItems = modules.flatMap((m) => (m.nav ? [m.nav] : [])); + const session = await auth(); + const userId = session?.user?.id; + const { rows: notifRows, unread } = userId + ? await getNotifications(userId) + : { rows: [], unread: 0 }; + return (
{dashboards.length > 0 && ( diff --git a/src/components/notification-bell.tsx b/src/components/notification-bell.tsx new file mode 100644 index 0000000..557fbd8 --- /dev/null +++ b/src/components/notification-bell.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; +import { Bell } from "lucide-react"; +import { markNotificationRead, markAllNotificationsRead } from "@/app/settings/notify-actions"; + +type NotifItem = { + id: string; + title: string; + body: string; + url: string | null; + createdAt: Date; +}; + +export function NotificationBell({ + initialUnread, + initialItems, +}: { + initialUnread: number; + initialItems: NotifItem[]; +}) { + const [open, setOpen] = useState(false); + const [unread, setUnread] = useState(initialUnread); + const [items, setItems] = useState(initialItems); + const [isPending, startTransition] = useTransition(); + const panelRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + function markRead(id: string) { + setItems((prev) => prev.map((n) => (n.id === id ? { ...n, readAt: new Date() } : n))); + setUnread((u) => Math.max(0, u - 1)); + startTransition(() => markNotificationRead(id)); + } + + function markAll() { + setItems((prev) => prev.map((n) => ({ ...n, readAt: new Date() }))); + setUnread(0); + startTransition(() => markAllNotificationsRead()); + } + + return ( +
+ + + {open && ( +
+
+ Notifications + {unread > 0 && ( + + )} +
+
    + {items.length === 0 && ( +
  • + No notifications +
  • + )} + {items.map((n) => { + const isUnread = !("readAt" in n && (n as { readAt?: Date }).readAt); + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/notify-channel-toggles.tsx b/src/components/notify-channel-toggles.tsx new file mode 100644 index 0000000..dad5721 --- /dev/null +++ b/src/components/notify-channel-toggles.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useTransition } from "react"; +import { setNotifChannel } from "@/app/settings/notify-actions"; + +type Channel = "push" | "inapp" | "ntfy"; + +const LABEL: Record = { + push: "Web push", + inapp: "In-app inbox", + ntfy: "ntfy", +}; + +export function NotifyChannelToggles({ + push, + inapp, + ntfy, + ntfyConfigured, +}: { + push: boolean; + inapp: boolean; + ntfy: boolean; + ntfyConfigured: boolean; +}) { + const [isPending, startTransition] = useTransition(); + + function toggle(channel: Channel, enabled: boolean) { + startTransition(async () => { + await setNotifChannel(channel, enabled); + }); + } + + const channels: { key: Channel; value: boolean; disabled?: boolean }[] = [ + { key: "push", value: push }, + { key: "inapp", value: inapp }, + { key: "ntfy", value: ntfy, disabled: !ntfyConfigured }, + ]; + + return ( +
+ {channels.map(({ key, value, disabled }) => ( + + ))} +
+ ); +} diff --git a/src/components/push-opt-in.tsx b/src/components/push-opt-in.tsx new file mode 100644 index 0000000..fa14560 --- /dev/null +++ b/src/components/push-opt-in.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { subscribeToPush, unsubscribeFromPush, sendTestNotification } from "@/app/settings/push-actions"; + +const VAPID_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? ""; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(base64); + const buf = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i); + return buf; +} + +export function PushOptIn() { + const [status, setStatus] = useState<"idle" | "subscribed" | "denied" | "unsupported">( + "idle", + ); + const [endpoint, setEndpoint] = useState(null); + const [isPending, startTransition] = useTransition(); + const [testSent, setTestSent] = useState(false); + + if (!VAPID_KEY) return null; + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + return

Push notifications not supported in this browser.

; + } + + async function subscribe() { + try { + const registration = await navigator.serviceWorker.ready; + const sub = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(VAPID_KEY), + }); + const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } }; + startTransition(async () => { + await subscribeToPush(json, navigator.userAgent); + setEndpoint(json.endpoint); + setStatus("subscribed"); + }); + } catch { + setStatus("denied"); + } + } + + async function unsubscribe() { + if (!endpoint) return; + const registration = await navigator.serviceWorker.ready; + const sub = await registration.pushManager.getSubscription(); + if (sub) await sub.unsubscribe(); + startTransition(async () => { + await unsubscribeFromPush(endpoint); + setEndpoint(null); + setStatus("idle"); + }); + } + + function sendTest() { + startTransition(async () => { + await sendTestNotification(); + setTestSent(true); + setTimeout(() => setTestSent(false), 3000); + }); + } + + if (status === "denied") { + return

Notification permission denied. Enable it in browser settings.

; + } + + if (status === "subscribed") { + return ( +
+ Push notifications enabled + + +
+ ); + } + + return ( + + ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..b132bed --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,6 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { startReminderWorker } = await import("@/modules/_core/reminders"); + startReminderWorker(); + } +} diff --git a/src/modules/_core/index.ts b/src/modules/_core/index.ts index a014e26..4c84bf1 100644 --- a/src/modules/_core/index.ts +++ b/src/modules/_core/index.ts @@ -15,3 +15,6 @@ export type { QuickAddItem, SerializedQuickAddItem, SerializedWidgetMeta } from export { logActivity, logShareActivity } from "./activity"; export { createShareLink, resolveShareToken, revokeShareLink } from "./share"; export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share"; +export { sendPush } from "./push"; +export { notify } from "./notify"; +export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders"; diff --git a/src/modules/_core/notify.ts b/src/modules/_core/notify.ts new file mode 100644 index 0000000..aac44b3 --- /dev/null +++ b/src/modules/_core/notify.ts @@ -0,0 +1,52 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { notifications, users } from "./schema"; +import { sendPush } from "./push"; + +type NotifyPayload = { + title: string; + body: string; + url?: string; + channels?: ("push" | "inapp" | "ntfy")[]; +}; + +export async function notify(userId: string, payload: NotifyPayload) { + const channels = payload.channels ?? ["push", "inapp"]; + + const [user] = await db + .select({ notifPush: users.notifPush, notifInApp: users.notifInApp, notifNtfy: users.notifNtfy }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) return; + + const pushEnabled = process.env["VAPID_PUBLIC_KEY"] && process.env["VAPID_PRIVATE_KEY"]; + + if (channels.includes("push") && user.notifPush && pushEnabled) { + await sendPush(userId, payload).catch((err) => + console.error("[famapp] push channel failed:", err), + ); + } + + if (channels.includes("inapp") && user.notifInApp) { + await db.insert(notifications).values({ + userId, + title: payload.title, + body: payload.body, + url: payload.url, + }); + } + + if (channels.includes("ntfy") && user.notifNtfy) { + const ntfyUrl = process.env["NTFY_URL"]; + const ntfyTopic = process.env["NTFY_TOPIC"]; + if (ntfyUrl && ntfyTopic) { + await fetch(`${ntfyUrl}/${ntfyTopic}`, { + method: "POST", + headers: { Title: payload.title, "Content-Type": "text/plain" }, + body: payload.body, + }).catch((err) => console.error("[famapp] ntfy delivery failed:", err)); + } + } +} diff --git a/src/modules/_core/push.ts b/src/modules/_core/push.ts new file mode 100644 index 0000000..082baf7 --- /dev/null +++ b/src/modules/_core/push.ts @@ -0,0 +1,54 @@ +import webPush from "web-push"; +import { and, eq, inArray } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { pushSubscriptions } from "./schema"; + +function ensureVapidConfigured() { + const subject = process.env["VAPID_SUBJECT"]; + const publicKey = process.env["VAPID_PUBLIC_KEY"]; + const privateKey = process.env["VAPID_PRIVATE_KEY"]; + if (!subject || !publicKey || !privateKey) { + throw new Error("VAPID_SUBJECT, VAPID_PUBLIC_KEY, and VAPID_PRIVATE_KEY must be set"); + } + webPush.setVapidDetails(subject, publicKey, privateKey); +} + +export async function sendPush( + userId: string, + payload: { title: string; body: string; url?: string }, +) { + ensureVapidConfigured(); + + const subs = await db + .select() + .from(pushSubscriptions) + .where(eq(pushSubscriptions.userId, userId)); + + if (subs.length === 0) return; + + const staleIds: string[] = []; + + await Promise.allSettled( + subs.map(async (sub) => { + try { + await webPush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify({ title: payload.title, body: payload.body, url: payload.url ?? "/" }), + ); + } catch (err) { + const status = (err as { statusCode?: number }).statusCode; + if (status === 404 || status === 410) { + staleIds.push(sub.id); + } else { + console.error("[famapp] push delivery failed:", err); + } + } + }), + ); + + if (staleIds.length > 0) { + await db + .delete(pushSubscriptions) + .where(and(eq(pushSubscriptions.userId, userId), inArray(pushSubscriptions.id, staleIds))); + } +} diff --git a/src/modules/_core/reminders.ts b/src/modules/_core/reminders.ts new file mode 100644 index 0000000..2065486 --- /dev/null +++ b/src/modules/_core/reminders.ts @@ -0,0 +1,99 @@ +import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { reminders } from "./schema"; +import { notify } from "./notify"; + +const REMINDER_LOCK_KEY = 7_777_777; + +export async function scheduleReminder(input: { + householdId: string; + entityType: string; + entityId: string; + fireAt: Date; + createdBy: string; + channel?: string; +}) { + await db + .insert(reminders) + .values({ + householdId: input.householdId, + entityType: input.entityType, + entityId: input.entityId, + fireAt: input.fireAt, + channel: input.channel ?? "auto", + createdBy: input.createdBy, + firedAt: null, + }) + .onConflictDoUpdate({ + target: [reminders.entityType, reminders.entityId], + set: { fireAt: input.fireAt, firedAt: null, createdBy: input.createdBy }, + }); +} + +export async function cancelReminder(entityType: string, entityId: string) { + await db + .delete(reminders) + .where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId))); +} + +export async function listReminders(entityType: string, entityId: string) { + return db + .select() + .from(reminders) + .where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId))); +} + +export async function tickReminders() { + let dueReminders: (typeof reminders.$inferSelect)[] = []; + + try { + await db.transaction(async (tx) => { + const lockRows = await tx.execute<{ acquired: boolean }>( + sql`SELECT pg_try_advisory_xact_lock(${REMINDER_LOCK_KEY}) AS acquired`, + ); + if (!lockRows[0]?.acquired) return; + + const now = new Date(); + dueReminders = await tx + .select() + .from(reminders) + .where(and(lte(reminders.fireAt, now), isNull(reminders.firedAt))); + + if (dueReminders.length > 0) { + await tx + .update(reminders) + .set({ firedAt: now }) + .where(inArray(reminders.id, dueReminders.map((r) => r.id))); + } + }); + } catch (err) { + console.error("[famapp] reminder tick error:", err); + return; + } + + await Promise.allSettled( + dueReminders.map(async (reminder) => { + if (!reminder.createdBy) return; + try { + await notify(reminder.createdBy, { + title: "Reminder", + body: `You have a reminder`, + url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/", + channels: ["push", "inapp"], + }); + } catch (err) { + console.error("[famapp] reminder delivery failed:", reminder.id, err); + } + }), + ); +} + +let workerTimer: ReturnType | null = null; + +export function startReminderWorker() { + if (workerTimer) return; + workerTimer = setInterval(() => { + tickReminders().catch((err) => console.error("[famapp] reminder worker uncaught:", err)); + }, 30_000); + console.log("[famapp] reminder worker started (30s tick)"); +} diff --git a/src/modules/_core/schema.ts b/src/modules/_core/schema.ts index 61df661..d1b688b 100644 --- a/src/modules/_core/schema.ts +++ b/src/modules/_core/schema.ts @@ -26,6 +26,9 @@ export const users = pgTable("users", { theme: text("theme").notNull().default("default"), themeMode: text("theme_mode").notNull().default("system"), completionVisibilityHours: integer("completion_visibility_hours").notNull().default(24), + notifPush: boolean("notif_push").notNull().default(true), + notifInApp: boolean("notif_inapp").notNull().default(true), + notifNtfy: boolean("notif_ntfy").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); @@ -158,7 +161,9 @@ export const reminders = pgTable( entityType: text("entity_type").notNull(), entityId: uuid("entity_id").notNull(), fireAt: timestamp("fire_at", { withTimezone: true }).notNull(), - channel: text("channel").notNull().default("in_app"), + channel: text("channel").notNull().default("auto"), + firedAt: timestamp("fired_at", { withTimezone: true }), + createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [ @@ -166,3 +171,35 @@ export const reminders = pgTable( index("reminders_household_fire_at_idx").on(t.householdId, t.fireAt), ], ); + +export const pushSubscriptions = pgTable( + "push_subscriptions", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + endpoint: text("endpoint").notNull().unique(), + p256dh: text("p256dh").notNull(), + auth: text("auth").notNull(), + userAgent: text("user_agent"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("push_subscriptions_user_idx").on(t.userId)], +); + +export const notifications = pgTable( + "notifications", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + title: text("title").notNull(), + body: text("body").notNull(), + url: text("url"), + readAt: timestamp("read_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("notifications_user_read_idx").on(t.userId, t.readAt)], +); diff --git a/src/modules/calendar/components/calendar-shell.tsx b/src/modules/calendar/components/calendar-shell.tsx index b62c790..ee5d37c 100644 --- a/src/modules/calendar/components/calendar-shell.tsx +++ b/src/modules/calendar/components/calendar-shell.tsx @@ -40,6 +40,7 @@ type EventDraft = { allDay: boolean; location: string; notes: string; + remindMinutesBefore: number | null; }; const DEFAULT_COLOR = "#2563eb"; @@ -92,6 +93,7 @@ export function CalendarShell({ allDay, location: "", notes: "", + remindMinutesBefore: 30, }); } @@ -107,6 +109,7 @@ export function CalendarShell({ allDay: row.allDay, location: row.location ?? "", notes: row.notes ?? "", + remindMinutesBefore: null, }); } @@ -148,7 +151,10 @@ export function CalendarShell({ ), ); } else { - const created = await createEvent(payload); + const created = await createEvent({ + ...payload, + remindMinutesBefore: selectedEvent.remindMinutesBefore, + }); setLastCalendarId(payload.calendarId); setEventRows((current) => [...current, created]); } @@ -489,6 +495,22 @@ export function CalendarShell({ setSelectedEvent({ ...selectedEvent, notes: event.target.value }) } /> + {!selectedEvent.id && ( + + )}
{selectedEvent.id && ( diff --git a/src/modules/calendar/server/actions.ts b/src/modules/calendar/server/actions.ts index f6218b4..ffb0a1a 100644 --- a/src/modules/calendar/server/actions.ts +++ b/src/modules/calendar/server/actions.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; import { logActivity } from "@/modules/_core/activity"; +import { scheduleReminder, cancelReminder } from "@/modules/_core/reminders"; import { calendarEvents, calendars } from "../schema"; import { canSeeCalendar } from "./queries"; @@ -23,6 +24,7 @@ const eventBaseInput = z.object({ allDay: z.boolean().default(false), location: z.string().trim().max(300).nullable().optional(), notes: z.string().trim().max(3000).nullable().optional(), + remindMinutesBefore: z.number().int().min(0).nullable().optional(), }); const eventInput = eventBaseInput.refine((value) => value.endAt >= value.startAt, { @@ -115,13 +117,17 @@ export async function deleteCalendar(input: { id: string }) { export async function createEvent(input: z.input) { const parsed = eventInput.parse(input); - const { user } = await getCurrentSession(); + const { user, household } = await getCurrentSession(); if (!(await canSeeCalendar(user.id, parsed.calendarId))) throw new Error("Forbidden"); const [event] = await db .insert(calendarEvents) .values({ - ...parsed, + calendarId: parsed.calendarId, + title: parsed.title, + startAt: parsed.startAt, + endAt: parsed.endAt, + allDay: parsed.allDay, ownerId: user.id, location: parsed.location || null, notes: parsed.notes || null, @@ -129,6 +135,20 @@ export async function createEvent(input: z.input) { .returning(); if (!event) throw new Error("Event was not created"); + + if (parsed.remindMinutesBefore != null) { + const fireAt = new Date(parsed.startAt.getTime() - parsed.remindMinutesBefore * 60_000); + if (fireAt > new Date()) { + await scheduleReminder({ + householdId: household.id, + entityType: "calendar.event", + entityId: event.id, + fireAt, + createdBy: user.id, + }); + } + } + await logActivity({ entityType: "calendar.event", entityId: event.id, action: "create", payload: { title: event.title } }); revalidatePath("/calendar"); return { @@ -181,6 +201,7 @@ export async function deleteEvent(input: { id: string }) { if (!existing) return; if (!(await canSeeCalendar(user.id, existing.calendarId))) throw new Error("Forbidden"); await logActivity({ entityType: "calendar.event", entityId: parsed.id, action: "delete" }); + await cancelReminder("calendar.event", parsed.id); await db.delete(calendarEvents).where(eq(calendarEvents.id, parsed.id)); revalidatePath("/calendar"); } diff --git a/src/modules/notes/server/actions.ts b/src/modules/notes/server/actions.ts index 584db9a..b480a79 100644 --- a/src/modules/notes/server/actions.ts +++ b/src/modules/notes/server/actions.ts @@ -1,12 +1,12 @@ "use server"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; import { logActivity } from "@/modules/_core/activity"; -import { reminders } from "@/modules/_core/schema"; +import { scheduleReminder, cancelReminder } from "@/modules/_core/reminders"; import { notes } from "../schema"; import { canAccessNote, getNote } from "./queries"; @@ -29,63 +29,69 @@ export async function createNote(input: z.input) { const parsed = noteInput.parse(input); const { household, user } = await getCurrentSession(); - const [note] = await db.transaction(async (tx) => { - const [created] = await tx - .insert(notes) - .values({ - householdId: household.id, - authorId: user.id, - title: parsed.title, - body: parsed.body, - pinned: parsed.pinned, - remindAt: parsed.remindAt ?? null, - }) - .returning(); - - if (!created) throw new Error("Note was not created"); - await syncNoteReminder(tx, { + const [note] = await db + .insert(notes) + .values({ householdId: household.id, - noteId: created.id, - remindAt: created.remindAt, - }); - return [created]; - }); + authorId: user.id, + title: parsed.title, + body: parsed.body, + pinned: parsed.pinned, + remindAt: parsed.remindAt ?? null, + }) + .returning(); - if (note) await logActivity({ entityType: "notes.note", entityId: note.id, action: "create", payload: { title: note.title } }); + if (!note) throw new Error("Note was not created"); + + if (parsed.remindAt) { + await scheduleReminder({ + householdId: household.id, + entityType: "notes.note", + entityId: note.id, + fireAt: parsed.remindAt, + createdBy: user.id, + }); + } + + await logActivity({ entityType: "notes.note", entityId: note.id, action: "create", payload: { title: note.title } }); revalidatePath("/notes"); return note; } export async function updateNote(input: z.input) { const parsed = updateNoteInput.parse(input); - const { household } = await getCurrentSession(); + const { household, user } = await getCurrentSession(); await assertCanAccessNote(parsed.id, household.id); - const [note] = await db.transaction(async (tx) => { - const [updated] = await tx - .update(notes) - .set({ - title: parsed.title, - body: parsed.body, - pinned: parsed.pinned, - remindAt: parsed.remindAt === undefined ? undefined : parsed.remindAt, - updatedAt: new Date(), - }) - .where(eq(notes.id, parsed.id)) - .returning(); + const [note] = await db + .update(notes) + .set({ + title: parsed.title, + body: parsed.body, + pinned: parsed.pinned, + remindAt: parsed.remindAt === undefined ? undefined : parsed.remindAt, + updatedAt: new Date(), + }) + .where(eq(notes.id, parsed.id)) + .returning(); - if (!updated) throw new Error("Note was not updated"); - if (parsed.remindAt !== undefined) { - await syncNoteReminder(tx, { + if (!note) throw new Error("Note was not updated"); + + if (parsed.remindAt !== undefined) { + if (parsed.remindAt) { + await scheduleReminder({ householdId: household.id, - noteId: updated.id, - remindAt: updated.remindAt, + entityType: "notes.note", + entityId: note.id, + fireAt: parsed.remindAt, + createdBy: user.id, }); + } else { + await cancelReminder("notes.note", note.id); } - return [updated]; - }); + } - if (note) await logActivity({ entityType: "notes.note", entityId: note.id, action: "update", payload: { title: note.title } }); + await logActivity({ entityType: "notes.note", entityId: note.id, action: "update", payload: { title: note.title } }); revalidatePath("/notes"); revalidatePath(`/notes/${parsed.id}`); return note; @@ -116,12 +122,8 @@ export async function deleteNote(input: { id: string }) { const note = await getNote(parsed.id); await logActivity({ entityType: "notes.note", entityId: parsed.id, action: "delete", payload: note ? { title: note.title } : undefined }); - await db.transaction(async (tx) => { - await tx - .delete(reminders) - .where(and(eq(reminders.entityType, "notes.note"), eq(reminders.entityId, parsed.id))); - await tx.delete(notes).where(eq(notes.id, parsed.id)); - }); + await cancelReminder("notes.note", parsed.id); + await db.delete(notes).where(eq(notes.id, parsed.id)); revalidatePath("/notes"); } @@ -129,22 +131,3 @@ export async function deleteNote(input: { id: string }) { async function assertCanAccessNote(noteId: string, householdId: string) { if (!(await canAccessNote(noteId, householdId))) throw new Error("Forbidden"); } - -async function syncNoteReminder( - tx: Parameters[0]>[0], - input: { householdId: string; noteId: string; remindAt: Date | null }, -) { - await tx - .delete(reminders) - .where(and(eq(reminders.entityType, "notes.note"), eq(reminders.entityId, input.noteId))); - - if (!input.remindAt) return; - - await tx.insert(reminders).values({ - householdId: input.householdId, - entityType: "notes.note", - entityId: input.noteId, - fireAt: input.remindAt, - channel: "in_app", - }); -}