From ad10dfac00b55961dac76954c7b6c6b4c87dca41 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Sun, 25 Jan 2026 20:29:20 +0100 Subject: [PATCH 01/24] chore: create a plan --- PLAN.md | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..3e5fbd16 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,107 @@ +# Server-Side Push Notification Settings Access + +## Goal + +Enable the server to access user notification settings directly without needing Clerk credentials, as a step toward removing Clerk dependency for E2E encryption. + +## Current State + +1. Cron job iterates all Clerk users, extracts `jazzAccountID` + `jazzAccountSecret` from `unsafeMetadata` +2. Per user: Creates a user worker, loads their `NotificationSettings` from their private Jazz data +3. `NotificationSettings` is owned by the user's private group (inherited from `UserAccountRoot`) +4. Server has no access to user data without credentials + +## Target State + +1. Server maintains a list of notification settings CoValue refs it can access +2. Client shares `NotificationSettings` with server worker on device setup / app start +3. Cron job iterates server's list directly, no Clerk iteration needed + +--- + +## Schema Changes + +### 1. `src/shared/schema/server.ts` + +```typescript +import { NotificationSettings } from "./user" + +let NotificationSettingsRef = co.map({ + notificationSettings: NotificationSettings, + userId: z.string(), // for debugging + lastSyncedAt: z.date(), +}) + +let ServerAccountRoot = co.map({ + notificationSettingsRefs: co.list(NotificationSettingsRef).optional(), +}) + +let ServerAccount = co.account({ + profile: co.map({ name: z.string() }), + root: ServerAccountRoot, +}) +``` + +### 2. `src/shared/schema/user.ts` + +- Add `language` field to `NotificationSettings` +- Change creation to use its own group (not inherited from root) + +--- + +## New Files + +### 3. `src/server/features/push-register.ts` + +- POST endpoint receiving `notificationSettingsId` +- Load settings via server worker (server must already be writer on group) +- Upsert ref in server's list (update `lastSyncedAt` if exists, otherwise add) + +### 4. `src/app/hooks/use-register-notifications.ts` + +- Hook called on app start +- Ensures `NotificationSettings` has proper group (migrates if needed) +- Adds server worker as collaborator to the group +- Calls registration endpoint with notification settings ID + +--- + +## Modified Files + +### 5. `src/app/features/notification-settings.tsx` + +- After `addPushDevice`: trigger registration (can reuse the hook or call directly) + +### 6. `src/server/features/push-cron.ts` + +- Remove Clerk iteration entirely +- Iterate `notificationSettingsRefs` from server root +- Filter out refs with `lastSyncedAt` > 30 days (and remove them from list) +- Read language from `NotificationSettings.language` + +--- + +## Migration Logic + +When existing `NotificationSettings` doesn't have a shareable group (in `use-register-notifications.ts`): + +1. Get existing group via `notificationSettings.$jazz.owner` +2. If group is user's private group (not shareable), create new group with user as owner +3. Add server worker as writer to group (using server account ID from env) +4. Create new `NotificationSettings` with that group, copy all data including new `language` field +5. Update `UserAccountRoot.notificationSettings` to point to new one + +Detecting if group is shareable: Check if `group.id` starts with `co_z` (Group) vs account ID pattern. + +--- + +## Implementation Order + +1. [ ] Update `NotificationSettings` schema to add `language` field +2. [ ] Update `ServerAccount` schema with `NotificationSettingsRef` and root +3. [ ] Create `push-register.ts` endpoint +4. [ ] Create `use-register-notifications.ts` hook with migration logic +5. [ ] Update `notification-settings.tsx` to trigger registration after device setup +6. [ ] Refactor `push-cron.ts` to use server list instead of Clerk +7. [ ] Test migration path for existing users +8. [ ] Test new user flow From 4bee727bdcb8ec941a6e92252b72a88e9e69ba8f Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Sun, 25 Jan 2026 22:37:59 +0100 Subject: [PATCH 02/24] chore: change push notification logic to not rely on clerk --- PLAN.md | 12 +- src/app/features/notification-settings.tsx | 6 + src/app/hooks/use-register-notifications.ts | 157 +++++++++++ src/app/lib/notification-registration.ts | 34 +++ src/app/routes/_app.tsx | 2 + src/server/features/push-cron.ts | 281 +++++++++----------- src/server/features/push-register.ts | 81 ++++++ src/server/main.ts | 2 + src/shared/schema/server.ts | 13 +- src/shared/schema/user.ts | 1 + 10 files changed, 429 insertions(+), 160 deletions(-) create mode 100644 src/app/hooks/use-register-notifications.ts create mode 100644 src/app/lib/notification-registration.ts create mode 100644 src/server/features/push-register.ts diff --git a/PLAN.md b/PLAN.md index 3e5fbd16..a063e5f2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -97,11 +97,11 @@ Detecting if group is shareable: Check if `group.id` starts with `co_z` (Group) ## Implementation Order -1. [ ] Update `NotificationSettings` schema to add `language` field -2. [ ] Update `ServerAccount` schema with `NotificationSettingsRef` and root -3. [ ] Create `push-register.ts` endpoint -4. [ ] Create `use-register-notifications.ts` hook with migration logic -5. [ ] Update `notification-settings.tsx` to trigger registration after device setup -6. [ ] Refactor `push-cron.ts` to use server list instead of Clerk +1. [x] Update `NotificationSettings` schema to add `language` field +2. [x] Update `ServerAccount` schema with `NotificationSettingsRef` and root +3. [x] Create `push-register.ts` endpoint +4. [x] Create `use-register-notifications.ts` hook with migration logic +5. [x] Update `notification-settings.tsx` to trigger registration after device setup +6. [x] Refactor `push-cron.ts` to use server list instead of Clerk 7. [ ] Test migration path for existing users 8. [ ] Test new user flow diff --git a/src/app/features/notification-settings.tsx b/src/app/features/notification-settings.tsx index 8ae3a885..be2440b4 100644 --- a/src/app/features/notification-settings.tsx +++ b/src/app/features/notification-settings.tsx @@ -55,6 +55,7 @@ import { PUBLIC_VAPID_KEY } from "astro:env/client" import { getServiceWorkerRegistration } from "#app/lib/service-worker" import { tryCatch } from "#shared/lib/trycatch" import { isInAppBrowser } from "#app/hooks/use-pwa" +import { triggerNotificationRegistration } from "#app/lib/notification-registration" export function NotificationSettings({ me, @@ -973,6 +974,11 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) { keys: subscriptionResult.data.keys, }) + // Trigger registration with server after adding device + if (notifications?.$jazz.id) { + triggerNotificationRegistration(notifications.$jazz.id) + } + toast.success(t("notifications.toast.deviceAdded")) setOpen(false) diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts new file mode 100644 index 00000000..31678ceb --- /dev/null +++ b/src/app/hooks/use-register-notifications.ts @@ -0,0 +1,157 @@ +import { useEffect, useRef } from "react" +import { useAccount } from "jazz-tools/react" +import { Group, type co, type ResolveQuery, type ID } from "jazz-tools" +import { PUBLIC_JAZZ_WORKER_ACCOUNT } from "astro:env/client" +import { UserAccount, NotificationSettings } from "#shared/schema/user" +import { ServerAccount } from "#shared/schema/server" +import { apiClient } from "#app/lib/api-client" +import { tryCatch } from "#shared/lib/trycatch" + +export { useRegisterNotifications } + +let notificationSettingsQuery = { + root: { notificationSettings: true }, +} as const satisfies ResolveQuery + +type LoadedAccount = co.loaded< + typeof UserAccount, + typeof notificationSettingsQuery +> + +/** + * Hook that registers notification settings with the server. + * Handles migration from account-owned to group-owned settings. + * Runs once on app start. + */ +function useRegisterNotifications(): void { + let registrationRan = useRef(false) + let me = useAccount(UserAccount, { resolve: notificationSettingsQuery }) + + useEffect(() => { + if (registrationRan.current || !me.$isLoaded) return + if (!me.root.notificationSettings) return + + registrationRan.current = true + registerNotificationSettings(me) + }, [me.$isLoaded, me]) +} + +async function registerNotificationSettings(me: LoadedAccount): Promise { + let notificationSettings = me.root.notificationSettings + if (!notificationSettings) return + + // Sync language from root to notification settings + let rootLanguage = me.root.language + if (rootLanguage && notificationSettings.language !== rootLanguage) { + notificationSettings.$jazz.set("language", rootLanguage) + } + + // Check if settings are owned by a shareable group + // The key difference: if owner is an Account vs a Group + let owner = notificationSettings.$jazz.owner + let isShareableGroup = owner instanceof Group + + if (!isShareableGroup) { + // Need to migrate to a shareable group + let migrationResult = await tryCatch( + migrateNotificationSettings(me, notificationSettings), + ) + if (!migrationResult.ok) { + console.error("[Notifications] Migration failed:", migrationResult.error) + return + } + notificationSettings = migrationResult.data + } else { + // Ensure server worker is a member + let group = owner as Group + let serverAccountId = PUBLIC_JAZZ_WORKER_ACCOUNT + let serverIsMember = group.members.some( + m => m.account?.$jazz.id === serverAccountId, + ) + if (!serverIsMember) { + let addResult = await tryCatch( + addServerToGroup(me, group, serverAccountId), + ) + if (!addResult.ok) { + console.error( + "[Notifications] Failed to add server to group:", + addResult.error, + ) + } + } + } + + // Register with server + let registerResult = await tryCatch( + apiClient.push.register.$post({ + json: { notificationSettingsId: notificationSettings.$jazz.id }, + }), + ) + + if (!registerResult.ok) { + console.error("[Notifications] Registration failed:", registerResult.error) + return + } + + if (!registerResult.data.ok) { + let errorData = await tryCatch(registerResult.data.json()) + console.error( + "[Notifications] Registration error:", + errorData.ok ? errorData.data : "Unknown error", + ) + return + } + + console.log("[Notifications] Registration successful") +} + +async function migrateNotificationSettings( + me: LoadedAccount, + oldSettings: co.loaded, +): Promise> { + console.log("[Notifications] Migrating to shareable group") + + // Create new group with current user as owner + let group = Group.create() + + // Add server worker as writer + let serverAccountId = PUBLIC_JAZZ_WORKER_ACCOUNT + await addServerToGroup(me, group, serverAccountId) + + // Create new notification settings in the new group + let newSettings = NotificationSettings.create( + { + version: 1, + timezone: oldSettings.timezone, + notificationTime: oldSettings.notificationTime, + lastDeliveredAt: oldSettings.lastDeliveredAt, + pushDevices: [...oldSettings.pushDevices], + language: oldSettings.language || me.root.language, + }, + { owner: group }, + ) + + // Update root to point to new settings + me.root.$jazz.set("notificationSettings", newSettings) + + console.log("[Notifications] Migration complete") + return newSettings +} + +async function addServerToGroup( + me: LoadedAccount, + group: Group, + serverAccountId: string, +): Promise { + // Load the server account to add as member + let serverAccount = await ServerAccount.load( + serverAccountId as ID, + { loadAs: me }, + ) + + if (!serverAccount || !serverAccount.$isLoaded) { + throw new Error("Failed to load server account") + } + + group.addMember(serverAccount, "writer") +} diff --git a/src/app/lib/notification-registration.ts b/src/app/lib/notification-registration.ts new file mode 100644 index 00000000..6b4d5d6e --- /dev/null +++ b/src/app/lib/notification-registration.ts @@ -0,0 +1,34 @@ +import { apiClient } from "#app/lib/api-client" +import { tryCatch } from "#shared/lib/trycatch" + +export { triggerNotificationRegistration } + +/** + * Triggers notification settings registration with the server. + * Call this after adding a new push device. + */ +async function triggerNotificationRegistration( + notificationSettingsId: string, +): Promise { + let result = await tryCatch( + apiClient.push.register.$post({ + json: { notificationSettingsId }, + }), + ) + + if (!result.ok) { + console.error("[Notifications] Registration failed:", result.error) + return + } + + if (!result.data.ok) { + let errorData = await tryCatch(result.data.json()) + console.error( + "[Notifications] Registration error:", + errorData.ok ? errorData.data : "Unknown error", + ) + return + } + + console.log("[Notifications] Registration triggered successfully") +} diff --git a/src/app/routes/_app.tsx b/src/app/routes/_app.tsx index e5413fcf..f70a1f8d 100644 --- a/src/app/routes/_app.tsx +++ b/src/app/routes/_app.tsx @@ -9,6 +9,7 @@ import { useCleanupEmptyGroups, useCleanupInaccessiblePeople, } from "#app/hooks/use-cleanups" +import { useRegisterNotifications } from "#app/hooks/use-register-notifications" import { useSafariSwipeHack } from "#shared/ui/swipeable-list-item" export const Route = createFileRoute("/_app")({ @@ -24,6 +25,7 @@ function AppComponent() { useCleanupInactiveLists() useCleanupEmptyGroups() useCleanupInaccessiblePeople() + useRegisterNotifications() let dueReminderCount = useDueReminders() diff --git a/src/server/features/push-cron.ts b/src/server/features/push-cron.ts index b8ad1ed6..9786b278 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -1,65 +1,135 @@ -import { CRON_SECRET, CLERK_SECRET_KEY } from "astro:env/server" -import { PUBLIC_CLERK_PUBLISHABLE_KEY } from "astro:env/client" -import { createClerkClient } from "@clerk/backend" -import type { User } from "@clerk/backend" -import { initUserWorker } from "../lib/utils" -import { tryCatch } from "#shared/lib/trycatch" +import { CRON_SECRET } from "astro:env/server" +import { initServerWorker } from "../lib/utils" + import { toZonedTime, format } from "date-fns-tz" import { Hono } from "hono" import { bearerAuth } from "hono/bearer-auth" +import { co, type ResolveQuery } from "jazz-tools" import { getEnabledDevices, sendNotificationToDevice, markNotificationSettingsAsDelivered, removeDeviceByEndpoint, - settingsQuery, - getLocalizedMessages, -} from "./push-shared" -import type { - PushDevice, - NotificationPayload, - LoadedNotificationSettings, - LoadedUserAccountSettings, } from "./push-shared" +import type { NotificationPayload } from "./push-shared" +import { NotificationSettings } from "#shared/schema/user" +import { ServerAccount } from "#shared/schema/server" +import { + baseServerMessages, + deServerMessages, +} from "#shared/intl/messages.server" +import { isPastNotificationTime, wasDeliveredToday } from "./push-cron-utils" export { cronDeliveryApp } +let STALE_THRESHOLD_DAYS = 30 + +let serverRefsQuery = { + root: { + notificationSettingsRefs: { + $each: { notificationSettings: true }, + }, + }, +} as const satisfies ResolveQuery + +type LoadedServerAccount = co.loaded< + typeof ServerAccount, + typeof serverRefsQuery +> +type LoadedRef = NonNullable< + NonNullable[number] +> +type LoadedNotificationSettings = co.loaded + let cronDeliveryApp = new Hono().get( "/deliver-notifications", bearerAuth({ token: CRON_SECRET }), async c => { console.log("πŸ”” Starting notification delivery cron job") let deliveryResults: Array<{ - userID: string + userId: string success: boolean }> = [] let processingPromises: Promise[] = [] let maxConcurrentUsers = 50 - for await (let user of userGenerator()) { + let { worker } = await initServerWorker() + let serverAccount = await worker.$jazz.ensureLoaded({ + resolve: serverRefsQuery, + }) + + let refs = serverAccount.root.notificationSettingsRefs + if (!refs) { + console.log("πŸ”” No notification settings refs found") + return c.json({ + message: "No notification settings refs found", + results: [], + }) + } + + let staleRefIndices: number[] = [] + let currentUtc = new Date() + let refIndex = 0 + + for (let ref of refs.values()) { + if (!ref) { + refIndex++ + continue + } + + // Check if ref is stale + if (isStaleRef(ref.lastSyncedAt)) { + staleRefIndices.push(refIndex) + console.log(`πŸ—‘οΈ Marking stale ref for removal: ${ref.userId}`) + refIndex++ + continue + } + + let notificationSettings = ref.notificationSettings + if (!notificationSettings?.$isLoaded) { + console.log(`❌ User ${ref.userId}: Settings not loaded`) + refIndex++ + continue + } + await waitForConcurrencyLimit(processingPromises, maxConcurrentUsers) - let userPromise = loadNotificationSettings(user) - .then(data => shouldReceiveNotification(data)) - .then(data => getDevices(data)) - .then(userWithDevices => processDevicesPipeline(userWithDevices)) - .then(results => { - deliveryResults.push(...results) + let userPromise = processNotificationRef( + ref, + notificationSettings, + currentUtc, + worker, + ) + .then(result => { + if (result) { + deliveryResults.push(result) + } }) .catch(error => { if (typeof error === "string") { - console.log(`❌ User ${user.id}: ${error}`) + console.log(`❌ User ${ref.userId}: ${error}`) } else { - console.log(`❌ User ${user.id}: ${error.message || error}`) + console.log(`❌ User ${ref.userId}: ${error.message || error}`) } }) .finally(() => removeFromList(processingPromises, userPromise)) processingPromises.push(userPromise) + refIndex++ } await Promise.allSettled(processingPromises) + // Remove stale refs (in reverse order to maintain indices) + for (let i = staleRefIndices.length - 1; i >= 0; i--) { + refs.$jazz.splice(staleRefIndices[i], 1) + } + + if (staleRefIndices.length > 0) { + await worker.$jazz.waitForSync() + console.log(`πŸ—‘οΈ Removed ${staleRefIndices.length} stale refs`) + } + return c.json({ message: `Processed ${deliveryResults.length} notification deliveries`, results: deliveryResults, @@ -67,80 +137,15 @@ let cronDeliveryApp = new Hono().get( }, ) -async function* userGenerator() { - let clerkClient = createClerkClient({ - secretKey: CLERK_SECRET_KEY, - publishableKey: PUBLIC_CLERK_PUBLISHABLE_KEY, - }) - - let offset = 0 - let limit = 500 - let totalUsers = 0 - let jazzUsers = 0 - - while (true) { - let response = await clerkClient.users.getUserList({ - limit, - offset, - }) - - totalUsers += response.data.length - - for (let user of response.data) { - if ( - user.unsafeMetadata.jazzAccountID && - user.unsafeMetadata.jazzAccountSecret - ) { - jazzUsers++ - yield user - } - } - - if (response.data.length < limit) { - break - } - - offset += limit - } - - console.log( - `πŸš€ Found ${jazzUsers} users with Jazz accounts out of ${totalUsers} total users`, - ) -} - -async function loadNotificationSettings(user: User) { - let workerResult = await tryCatch(initUserWorker(user)) - if (!workerResult.ok) { - throw `Failed to init worker - ${workerResult.error}` - } - - let workerWithSettings = await workerResult.data.worker.$jazz.ensureLoaded({ - resolve: settingsQuery, - }) - let notificationSettings = workerWithSettings.root.notificationSettings - if (!notificationSettings) { - throw "No notification settings configured" - } - - console.log(`βœ… User ${user.id}: Loaded notification settings`) - - return { - user, - notificationSettings, - worker: workerWithSettings, - currentUtc: new Date(), - } -} - -async function shouldReceiveNotification< - T extends { - notificationSettings: LoadedNotificationSettings - currentUtc: Date - user: User - }, ->(data: T) { - let { notificationSettings, currentUtc, user } = data +async function processNotificationRef( + ref: LoadedRef, + notificationSettings: LoadedNotificationSettings, + currentUtc: Date, + worker: co.loaded, +): Promise<{ userId: string; success: boolean } | null> { + let { userId } = ref + // Check notification time if (!isPastNotificationTime(notificationSettings, currentUtc)) { let userTimezone = notificationSettings.timezone || "UTC" let userNotificationTime = notificationSettings.notificationTime || "12:00" @@ -149,6 +154,7 @@ async function shouldReceiveNotification< throw `Not past notification time (current: ${userLocalTimeStr}, configured: ${userNotificationTime}, timezone: ${userTimezone})` } + // Check if already delivered today if (wasDeliveredToday(notificationSettings, currentUtc)) { let userTimezone = notificationSettings.timezone || "UTC" let lastDelivered = notificationSettings.lastDeliveredAt @@ -160,65 +166,41 @@ async function shouldReceiveNotification< throw `Already delivered today (last delivered: ${lastDelivered})` } - console.log(`βœ… User ${user.id}: Passed notification time checks`) - - return data -} - -async function getDevices( - data: NotificationProcessingContext, -): Promise { - let { user, notificationSettings } = data + console.log(`βœ… User ${userId}: Passed notification time checks`) + // Get enabled devices let enabledDevices = getEnabledDevices(notificationSettings) if (enabledDevices.length === 0) { - console.log(`βœ… User ${user.id}: No enabled devices`) - return { - ...data, - devices: [], - } - } - - console.log( - `βœ… User ${user.id}: Ready to send wake notification to ${enabledDevices.length} devices`, - ) - - return { - ...data, - devices: enabledDevices, - } -} - -async function processDevicesPipeline( - userWithDevices: DeviceNotificationContext, -) { - let { user, devices, notificationSettings, worker, currentUtc } = - userWithDevices - - if (devices.length === 0) { + console.log(`βœ… User ${userId}: No enabled devices`) markNotificationSettingsAsDelivered(notificationSettings, currentUtc) await worker.$jazz.waitForSync() console.log( - `βœ… User ${user.id}: Marked as delivered (skipped - no action needed)`, + `βœ… User ${userId}: Marked as delivered (skipped - no action needed)`, ) - return [{ userID: user.id, success: true }] + return { userId, success: true } } - let payload = createLocalizedNotificationPayload(user.id, worker) + console.log( + `βœ… User ${userId}: Ready to send wake notification to ${enabledDevices.length} devices`, + ) + + // Create localized payload + let payload = createLocalizedNotificationPayload(userId, notificationSettings) + // Send to all devices let deviceResults: { success: boolean }[] = [] - for (let device of devices) { + for (let device of enabledDevices) { let result = await sendNotificationToDevice(device, payload) if (result.ok) { console.log( - `βœ… User ${user.id}: Successfully sent to device ${device.endpoint.slice(-10)}`, + `βœ… User ${userId}: Successfully sent to device ${device.endpoint.slice(-10)}`, ) deviceResults.push({ success: true }) } else { console.error( - `❌ User ${user.id}: Failed to send to device ${device.endpoint.slice(-10)}:`, + `❌ User ${userId}: Failed to send to device ${device.endpoint.slice(-10)}:`, result.error, ) @@ -235,12 +217,16 @@ async function processDevicesPipeline( markNotificationSettingsAsDelivered(notificationSettings, currentUtc) await worker.$jazz.waitForSync() - console.log(`βœ… User ${user.id}: Completed notification delivery`) + console.log(`βœ… User ${userId}: Completed notification delivery`) - return [{ userID: user.id, success: userSuccess }] + return { userId, success: userSuccess } } -import { isPastNotificationTime, wasDeliveredToday } from "./push-cron-utils" +function isStaleRef(lastSyncedAt: Date): boolean { + let staleDate = new Date() + staleDate.setDate(staleDate.getDate() - STALE_THRESHOLD_DAYS) + return lastSyncedAt < staleDate +} async function waitForConcurrencyLimit( promises: Promise[], @@ -257,12 +243,12 @@ function removeFromList(list: T[], item: T) { } // Create localized notification payload with {count} placeholder for SW interpolation -// Note: We access raw message strings directly (not via t()) to preserve {count} placeholder function createLocalizedNotificationPayload( userId: string, - worker: LoadedUserAccountSettings, + notificationSettings: LoadedNotificationSettings, ): NotificationPayload { - let messages = getLocalizedMessages(worker) + let language = notificationSettings.language || "en" + let messages = language === "de" ? deServerMessages : baseServerMessages return { titleOne: messages["server.push.dueReminders.titleOne"], titleMany: messages["server.push.dueReminders.titleMany"], @@ -273,14 +259,3 @@ function createLocalizedNotificationPayload( userId, } } - -type NotificationProcessingContext = { - user: User - notificationSettings: LoadedNotificationSettings - worker: LoadedUserAccountSettings - currentUtc: Date -} - -type DeviceNotificationContext = NotificationProcessingContext & { - devices: PushDevice[] -} diff --git a/src/server/features/push-register.ts b/src/server/features/push-register.ts new file mode 100644 index 00000000..eff9f7a5 --- /dev/null +++ b/src/server/features/push-register.ts @@ -0,0 +1,81 @@ +import { zValidator } from "@hono/zod-validator" +import { Hono } from "hono" +import { z } from "zod" +import { co, type ID } from "jazz-tools" +import { authMiddleware, requireAuth } from "../lib/auth-middleware" +import { initServerWorker } from "../lib/utils" +import { NotificationSettings } from "#shared/schema/user" +import { NotificationSettingsRef } from "#shared/schema/server" + +export { pushRegisterApp } + +let pushRegisterApp = new Hono().post( + "/register", + authMiddleware, + requireAuth, + zValidator("json", z.object({ notificationSettingsId: z.string() })), + async c => { + let { notificationSettingsId } = c.req.valid("json") + let user = c.get("user") + + let { worker } = await initServerWorker() + + // Load the notification settings by ID (server must already be a writer on the group) + let notificationSettings = await NotificationSettings.load( + notificationSettingsId as ID, + { loadAs: worker }, + ) + + if (!notificationSettings || !notificationSettings.$isLoaded) { + return c.json( + { + message: + "Failed to load notification settings - ensure server has access", + }, + 400, + ) + } + + // Ensure server root has the refs list + if (!worker.root) { + return c.json({ message: "Server root not initialized" }, 500) + } + + let root = await worker.$jazz.ensureLoaded({ + resolve: { root: { notificationSettingsRefs: { $each: true } } }, + }) + + if (!root.root.notificationSettingsRefs) { + root.root.$jazz.set( + "notificationSettingsRefs", + co.list(NotificationSettingsRef).create([]), + ) + } + + let refs = root.root.notificationSettingsRefs! + + // Check if ref already exists + let existingRef = refs + .values() + .find( + ref => ref?.notificationSettings?.$jazz.id === notificationSettingsId, + ) + + if (existingRef) { + // Update lastSyncedAt + existingRef.$jazz.set("lastSyncedAt", new Date()) + } else { + // Add new ref + let newRef = NotificationSettingsRef.create({ + notificationSettings, + userId: user.id, + lastSyncedAt: new Date(), + }) + refs.$jazz.push(newRef) + } + + await worker.$jazz.waitForSync() + + return c.json({ message: "Registered successfully" }) + }, +) diff --git a/src/server/main.ts b/src/server/main.ts index 7ee0b92e..92581075 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -4,6 +4,7 @@ import { logger } from "hono/logger" import { chatMessagesApp } from "./features/chat-messages" import { cronDeliveryApp } from "./features/push-cron" import { testNotificationApp } from "./features/push-test" +import { pushRegisterApp } from "./features/push-register" import { authMiddleware } from "./lib/auth-middleware" let authenticatedRoutes = new Hono() @@ -15,6 +16,7 @@ export let app = new Hono() .use(cors()) .route("/push", testNotificationApp) .route("/push", cronDeliveryApp) + .route("/push", pushRegisterApp) .route("/", authenticatedRoutes) export type AppType = typeof app diff --git a/src/shared/schema/server.ts b/src/shared/schema/server.ts index 6f324d1e..383b3a1c 100644 --- a/src/shared/schema/server.ts +++ b/src/shared/schema/server.ts @@ -1,6 +1,17 @@ import { co, z } from "jazz-tools" +import { NotificationSettings } from "./user" + +export let NotificationSettingsRef = co.map({ + notificationSettings: NotificationSettings, + userId: z.string(), + lastSyncedAt: z.date(), +}) + +export let ServerAccountRoot = co.map({ + notificationSettingsRefs: co.list(NotificationSettingsRef).optional(), +}) export let ServerAccount = co.account({ profile: co.map({ name: z.string() }), - root: co.map({}), + root: ServerAccountRoot, }) diff --git a/src/shared/schema/user.ts b/src/shared/schema/user.ts index 2e3246dc..ab32b6a6 100644 --- a/src/shared/schema/user.ts +++ b/src/shared/schema/user.ts @@ -27,6 +27,7 @@ export let NotificationSettings = co.map({ notificationTime: z.string().optional(), lastDeliveredAt: z.date().optional(), pushDevices: z.array(PushDevice), + language: z.enum(["de", "en"]).optional(), }) export let Assistant = co.map({ From 83c9e81cea157013116d8cb7c6361dc177a6f1dd Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Sun, 25 Jan 2026 22:46:31 +0100 Subject: [PATCH 03/24] fix: address review issues in push notification refactor - Add explicit owner to co.list creation in push-register - Copy pushDevices correctly during migration (map instead of spread) - Add null check for PUBLIC_JAZZ_WORKER_ACCOUNT - Remove PLAN.md --- PLAN.md | 107 -------------------- src/app/hooks/use-register-notifications.ts | 25 ++++- src/server/features/push-register.ts | 2 +- 3 files changed, 22 insertions(+), 112 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index a063e5f2..00000000 --- a/PLAN.md +++ /dev/null @@ -1,107 +0,0 @@ -# Server-Side Push Notification Settings Access - -## Goal - -Enable the server to access user notification settings directly without needing Clerk credentials, as a step toward removing Clerk dependency for E2E encryption. - -## Current State - -1. Cron job iterates all Clerk users, extracts `jazzAccountID` + `jazzAccountSecret` from `unsafeMetadata` -2. Per user: Creates a user worker, loads their `NotificationSettings` from their private Jazz data -3. `NotificationSettings` is owned by the user's private group (inherited from `UserAccountRoot`) -4. Server has no access to user data without credentials - -## Target State - -1. Server maintains a list of notification settings CoValue refs it can access -2. Client shares `NotificationSettings` with server worker on device setup / app start -3. Cron job iterates server's list directly, no Clerk iteration needed - ---- - -## Schema Changes - -### 1. `src/shared/schema/server.ts` - -```typescript -import { NotificationSettings } from "./user" - -let NotificationSettingsRef = co.map({ - notificationSettings: NotificationSettings, - userId: z.string(), // for debugging - lastSyncedAt: z.date(), -}) - -let ServerAccountRoot = co.map({ - notificationSettingsRefs: co.list(NotificationSettingsRef).optional(), -}) - -let ServerAccount = co.account({ - profile: co.map({ name: z.string() }), - root: ServerAccountRoot, -}) -``` - -### 2. `src/shared/schema/user.ts` - -- Add `language` field to `NotificationSettings` -- Change creation to use its own group (not inherited from root) - ---- - -## New Files - -### 3. `src/server/features/push-register.ts` - -- POST endpoint receiving `notificationSettingsId` -- Load settings via server worker (server must already be writer on group) -- Upsert ref in server's list (update `lastSyncedAt` if exists, otherwise add) - -### 4. `src/app/hooks/use-register-notifications.ts` - -- Hook called on app start -- Ensures `NotificationSettings` has proper group (migrates if needed) -- Adds server worker as collaborator to the group -- Calls registration endpoint with notification settings ID - ---- - -## Modified Files - -### 5. `src/app/features/notification-settings.tsx` - -- After `addPushDevice`: trigger registration (can reuse the hook or call directly) - -### 6. `src/server/features/push-cron.ts` - -- Remove Clerk iteration entirely -- Iterate `notificationSettingsRefs` from server root -- Filter out refs with `lastSyncedAt` > 30 days (and remove them from list) -- Read language from `NotificationSettings.language` - ---- - -## Migration Logic - -When existing `NotificationSettings` doesn't have a shareable group (in `use-register-notifications.ts`): - -1. Get existing group via `notificationSettings.$jazz.owner` -2. If group is user's private group (not shareable), create new group with user as owner -3. Add server worker as writer to group (using server account ID from env) -4. Create new `NotificationSettings` with that group, copy all data including new `language` field -5. Update `UserAccountRoot.notificationSettings` to point to new one - -Detecting if group is shareable: Check if `group.id` starts with `co_z` (Group) vs account ID pattern. - ---- - -## Implementation Order - -1. [x] Update `NotificationSettings` schema to add `language` field -2. [x] Update `ServerAccount` schema with `NotificationSettingsRef` and root -3. [x] Create `push-register.ts` endpoint -4. [x] Create `use-register-notifications.ts` hook with migration logic -5. [x] Update `notification-settings.tsx` to trigger registration after device setup -6. [x] Refactor `push-cron.ts` to use server list instead of Clerk -7. [ ] Test migration path for existing users -8. [ ] Test new user flow diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts index 31678ceb..4da02814 100644 --- a/src/app/hooks/use-register-notifications.ts +++ b/src/app/hooks/use-register-notifications.ts @@ -40,6 +40,13 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { let notificationSettings = me.root.notificationSettings if (!notificationSettings) return + // Bail early if server account ID is not configured + let serverAccountId = PUBLIC_JAZZ_WORKER_ACCOUNT + if (!serverAccountId) { + console.error("[Notifications] PUBLIC_JAZZ_WORKER_ACCOUNT not configured") + return + } + // Sync language from root to notification settings let rootLanguage = me.root.language if (rootLanguage && notificationSettings.language !== rootLanguage) { @@ -54,7 +61,7 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { if (!isShareableGroup) { // Need to migrate to a shareable group let migrationResult = await tryCatch( - migrateNotificationSettings(me, notificationSettings), + migrateNotificationSettings(me, notificationSettings, serverAccountId), ) if (!migrationResult.ok) { console.error("[Notifications] Migration failed:", migrationResult.error) @@ -64,7 +71,6 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { } else { // Ensure server worker is a member let group = owner as Group - let serverAccountId = PUBLIC_JAZZ_WORKER_ACCOUNT let serverIsMember = group.members.some( m => m.account?.$jazz.id === serverAccountId, ) @@ -108,6 +114,7 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { async function migrateNotificationSettings( me: LoadedAccount, oldSettings: co.loaded, + serverAccountId: string, ): Promise> { console.log("[Notifications] Migrating to shareable group") @@ -115,17 +122,27 @@ async function migrateNotificationSettings( let group = Group.create() // Add server worker as writer - let serverAccountId = PUBLIC_JAZZ_WORKER_ACCOUNT await addServerToGroup(me, group, serverAccountId) // Create new notification settings in the new group + // Copy pushDevices as plain array (schema expects z.array, not CoList) + let devicesCopy = oldSettings.pushDevices.map(device => ({ + isEnabled: device.isEnabled, + deviceName: device.deviceName, + endpoint: device.endpoint, + keys: { + p256dh: device.keys.p256dh, + auth: device.keys.auth, + }, + })) + let newSettings = NotificationSettings.create( { version: 1, timezone: oldSettings.timezone, notificationTime: oldSettings.notificationTime, lastDeliveredAt: oldSettings.lastDeliveredAt, - pushDevices: [...oldSettings.pushDevices], + pushDevices: devicesCopy, language: oldSettings.language || me.root.language, }, { owner: group }, diff --git a/src/server/features/push-register.ts b/src/server/features/push-register.ts index eff9f7a5..642d5a75 100644 --- a/src/server/features/push-register.ts +++ b/src/server/features/push-register.ts @@ -48,7 +48,7 @@ let pushRegisterApp = new Hono().post( if (!root.root.notificationSettingsRefs) { root.root.$jazz.set( "notificationSettingsRefs", - co.list(NotificationSettingsRef).create([]), + co.list(NotificationSettingsRef).create([], { owner: worker }), ) } From ab68fdaaf0eb0cc94ba616ebf27ab4eb66ec79e0 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Sun, 25 Jan 2026 22:48:23 +0100 Subject: [PATCH 04/24] feat: dynamic stale threshold based on future reminders - Add latestReminderDueDate to NotificationSettings schema - Client computes and syncs latest future reminder date on app start - Server keeps refs until 30 days after max(lastSyncedAt, latestReminderDueDate) - Users with far-future reminders stay registered even if inactive --- src/app/hooks/use-register-notifications.ts | 35 ++++++++++++++++++- src/server/features/push-cron.ts | 37 ++++++++++++++++----- src/shared/schema/user.ts | 1 + 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts index 4da02814..48e72406 100644 --- a/src/app/hooks/use-register-notifications.ts +++ b/src/app/hooks/use-register-notifications.ts @@ -10,7 +10,10 @@ import { tryCatch } from "#shared/lib/trycatch" export { useRegisterNotifications } let notificationSettingsQuery = { - root: { notificationSettings: true }, + root: { + notificationSettings: true, + people: { $each: { reminders: { $each: true } } }, + }, } as const satisfies ResolveQuery type LoadedAccount = co.loaded< @@ -53,6 +56,12 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { notificationSettings.$jazz.set("language", rootLanguage) } + // Compute and sync latestReminderDueDate + let latestDueDate = computeLatestReminderDueDate(me) + if (latestDueDate !== notificationSettings.latestReminderDueDate) { + notificationSettings.$jazz.set("latestReminderDueDate", latestDueDate) + } + // Check if settings are owned by a shareable group // The key difference: if owner is an Account vs a Group let owner = notificationSettings.$jazz.owner @@ -172,3 +181,27 @@ async function addServerToGroup( group.addMember(serverAccount, "writer") } + +/** + * Find the latest (furthest future) reminder due date across all people. + * Returns undefined if no future reminders exist. + */ +function computeLatestReminderDueDate(me: LoadedAccount): string | undefined { + let today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD + let latestDate: string | undefined + + for (let person of me.root.people.values()) { + if (!person || person.deletedAt) continue + for (let reminder of person.reminders.values()) { + if (!reminder || reminder.deletedAt || reminder.done) continue + // Only consider future reminders + if (reminder.dueAtDate >= today) { + if (!latestDate || reminder.dueAtDate > latestDate) { + latestDate = reminder.dueAtDate + } + } + } + } + + return latestDate +} diff --git a/src/server/features/push-cron.ts b/src/server/features/push-cron.ts index 9786b278..f46f8305 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -77,17 +77,19 @@ let cronDeliveryApp = new Hono().get( continue } - // Check if ref is stale - if (isStaleRef(ref.lastSyncedAt)) { - staleRefIndices.push(refIndex) - console.log(`πŸ—‘οΈ Marking stale ref for removal: ${ref.userId}`) + let notificationSettings = ref.notificationSettings + if (!notificationSettings?.$isLoaded) { + console.log(`❌ User ${ref.userId}: Settings not loaded`) refIndex++ continue } - let notificationSettings = ref.notificationSettings - if (!notificationSettings?.$isLoaded) { - console.log(`❌ User ${ref.userId}: Settings not loaded`) + // Check if ref is stale (no app open in 30 days after last sync or latest reminder) + if ( + isStaleRef(ref.lastSyncedAt, notificationSettings.latestReminderDueDate) + ) { + staleRefIndices.push(refIndex) + console.log(`πŸ—‘οΈ Marking stale ref for removal: ${ref.userId}`) refIndex++ continue } @@ -222,10 +224,27 @@ async function processNotificationRef( return { userId, success: userSuccess } } -function isStaleRef(lastSyncedAt: Date): boolean { +/** + * Check if a notification ref is stale and should be removed. + * Stale = 30 days after whichever is later: lastSyncedAt or latestReminderDueDate + */ +function isStaleRef( + lastSyncedAt: Date, + latestReminderDueDate: string | undefined, +): boolean { + let referenceDate = lastSyncedAt + + // If there's a future reminder, use that as reference instead + if (latestReminderDueDate) { + let reminderDate = new Date(latestReminderDueDate) + if (reminderDate > referenceDate) { + referenceDate = reminderDate + } + } + let staleDate = new Date() staleDate.setDate(staleDate.getDate() - STALE_THRESHOLD_DAYS) - return lastSyncedAt < staleDate + return referenceDate < staleDate } async function waitForConcurrencyLimit( diff --git a/src/shared/schema/user.ts b/src/shared/schema/user.ts index ab32b6a6..4e343533 100644 --- a/src/shared/schema/user.ts +++ b/src/shared/schema/user.ts @@ -28,6 +28,7 @@ export let NotificationSettings = co.map({ lastDeliveredAt: z.date().optional(), pushDevices: z.array(PushDevice), language: z.enum(["de", "en"]).optional(), + latestReminderDueDate: z.string().optional(), // YYYY-MM-DD format }) export let Assistant = co.map({ From ed0c0f9a85fc8106f02774b2bb20d0b9f2095e1f Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Tue, 27 Jan 2026 21:34:04 +0100 Subject: [PATCH 05/24] chore: add tests --- src/app/features/notification-settings.tsx | 7 +- .../hooks/use-register-notifications.test.ts | 62 +++++++ src/app/hooks/use-register-notifications.ts | 42 ++--- src/app/lib/notification-registration.ts | 21 ++- src/app/lib/reminder-utils.ts | 23 +++ src/server/features/push-cron-utils.ts | 23 ++- src/server/features/push-cron.test.ts | 44 +++++ src/server/features/push-cron.ts | 39 +---- src/server/features/push-register-logic.ts | 65 ++++++++ src/server/features/push-register.test.ts | 153 ++++++++++++++++++ src/server/features/push-register.ts | 62 +------ src/shared/intl/messages.settings.ts | 4 + 12 files changed, 428 insertions(+), 117 deletions(-) create mode 100644 src/app/hooks/use-register-notifications.test.ts create mode 100644 src/app/lib/reminder-utils.ts create mode 100644 src/server/features/push-register-logic.ts create mode 100644 src/server/features/push-register.test.ts diff --git a/src/app/features/notification-settings.tsx b/src/app/features/notification-settings.tsx index be2440b4..2acf5c2c 100644 --- a/src/app/features/notification-settings.tsx +++ b/src/app/features/notification-settings.tsx @@ -976,7 +976,12 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) { // Trigger registration with server after adding device if (notifications?.$jazz.id) { - triggerNotificationRegistration(notifications.$jazz.id) + let registrationResult = await triggerNotificationRegistration( + notifications.$jazz.id, + ) + if (!registrationResult.ok) { + toast.warning(t("notifications.toast.registrationFailed")) + } } toast.success(t("notifications.toast.deviceAdded")) diff --git a/src/app/hooks/use-register-notifications.test.ts b/src/app/hooks/use-register-notifications.test.ts new file mode 100644 index 00000000..2a09583e --- /dev/null +++ b/src/app/hooks/use-register-notifications.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect } from "vitest" +import { findLatestFutureDate } from "#app/lib/reminder-utils" + +describe("findLatestFutureDate", () => { + let today = "2025-01-15" + + test("returns undefined for empty list", () => { + expect(findLatestFutureDate([], today)).toBeUndefined() + }) + + test("returns undefined when all reminders are in the past", () => { + let reminders = [ + { dueAtDate: "2025-01-10", deleted: false, done: false }, + { dueAtDate: "2025-01-14", deleted: false, done: false }, + ] + expect(findLatestFutureDate(reminders, today)).toBeUndefined() + }) + + test("returns the only future reminder", () => { + let reminders = [{ dueAtDate: "2025-01-20", deleted: false, done: false }] + expect(findLatestFutureDate(reminders, today)).toBe("2025-01-20") + }) + + test("returns today's date as valid future", () => { + let reminders = [{ dueAtDate: "2025-01-15", deleted: false, done: false }] + expect(findLatestFutureDate(reminders, today)).toBe("2025-01-15") + }) + + test("returns the latest of multiple future reminders", () => { + let reminders = [ + { dueAtDate: "2025-01-20", deleted: false, done: false }, + { dueAtDate: "2025-02-15", deleted: false, done: false }, + { dueAtDate: "2025-01-25", deleted: false, done: false }, + ] + expect(findLatestFutureDate(reminders, today)).toBe("2025-02-15") + }) + + test("ignores deleted reminders", () => { + let reminders = [ + { dueAtDate: "2025-02-15", deleted: true, done: false }, + { dueAtDate: "2025-01-20", deleted: false, done: false }, + ] + expect(findLatestFutureDate(reminders, today)).toBe("2025-01-20") + }) + + test("ignores done reminders", () => { + let reminders = [ + { dueAtDate: "2025-02-15", deleted: false, done: true }, + { dueAtDate: "2025-01-20", deleted: false, done: false }, + ] + expect(findLatestFutureDate(reminders, today)).toBe("2025-01-20") + }) + + test("returns undefined when all future reminders are deleted or done", () => { + let reminders = [ + { dueAtDate: "2025-02-15", deleted: true, done: false }, + { dueAtDate: "2025-01-20", deleted: false, done: true }, + { dueAtDate: "2025-01-10", deleted: false, done: false }, + ] + expect(findLatestFutureDate(reminders, today)).toBeUndefined() + }) +}) diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts index 48e72406..8c0f5854 100644 --- a/src/app/hooks/use-register-notifications.ts +++ b/src/app/hooks/use-register-notifications.ts @@ -35,7 +35,10 @@ function useRegisterNotifications(): void { if (!me.root.notificationSettings) return registrationRan.current = true - registerNotificationSettings(me) + registerNotificationSettings(me).catch(() => { + // Allow retry on next mount if registration fails + registrationRan.current = false + }) }, [me.$isLoaded, me]) } @@ -133,8 +136,6 @@ async function migrateNotificationSettings( // Add server worker as writer await addServerToGroup(me, group, serverAccountId) - // Create new notification settings in the new group - // Copy pushDevices as plain array (schema expects z.array, not CoList) let devicesCopy = oldSettings.pushDevices.map(device => ({ isEnabled: device.isEnabled, deviceName: device.deviceName, @@ -160,6 +161,9 @@ async function migrateNotificationSettings( // Update root to point to new settings me.root.$jazz.set("notificationSettings", newSettings) + // Delete old settings to avoid orphaned data + oldSettings.$jazz.raw.core.deleteCoValue() + console.log("[Notifications] Migration complete") return newSettings } @@ -182,26 +186,28 @@ async function addServerToGroup( group.addMember(serverAccount, "writer") } -/** - * Find the latest (furthest future) reminder due date across all people. - * Returns undefined if no future reminders exist. - */ function computeLatestReminderDueDate(me: LoadedAccount): string | undefined { - let today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD - let latestDate: string | undefined + let reminders = extractReminders(me) + let today = new Date().toISOString().slice(0, 10) + return findLatestFutureDate(reminders, today) +} +function extractReminders( + me: LoadedAccount, +): { dueAtDate: string; deleted: boolean; done: boolean }[] { + let reminders: { dueAtDate: string; deleted: boolean; done: boolean }[] = [] for (let person of me.root.people.values()) { if (!person || person.deletedAt) continue for (let reminder of person.reminders.values()) { - if (!reminder || reminder.deletedAt || reminder.done) continue - // Only consider future reminders - if (reminder.dueAtDate >= today) { - if (!latestDate || reminder.dueAtDate > latestDate) { - latestDate = reminder.dueAtDate - } - } + if (!reminder) continue + reminders.push({ + dueAtDate: reminder.dueAtDate, + deleted: !!reminder.deletedAt, + done: !!reminder.done, + }) } } - - return latestDate + return reminders } + +import { findLatestFutureDate } from "#app/lib/reminder-utils" diff --git a/src/app/lib/notification-registration.ts b/src/app/lib/notification-registration.ts index 6b4d5d6e..41fe6019 100644 --- a/src/app/lib/notification-registration.ts +++ b/src/app/lib/notification-registration.ts @@ -3,13 +3,11 @@ import { tryCatch } from "#shared/lib/trycatch" export { triggerNotificationRegistration } -/** - * Triggers notification settings registration with the server. - * Call this after adding a new push device. - */ +type RegistrationResult = { ok: true } | { ok: false; error: string } + async function triggerNotificationRegistration( notificationSettingsId: string, -): Promise { +): Promise { let result = await tryCatch( apiClient.push.register.$post({ json: { notificationSettingsId }, @@ -18,17 +16,18 @@ async function triggerNotificationRegistration( if (!result.ok) { console.error("[Notifications] Registration failed:", result.error) - return + return { ok: false, error: "Network error" } } if (!result.data.ok) { let errorData = await tryCatch(result.data.json()) - console.error( - "[Notifications] Registration error:", - errorData.ok ? errorData.data : "Unknown error", - ) - return + let errorMessage = errorData.ok + ? (errorData.data as { message?: string }).message || "Unknown error" + : "Unknown error" + console.error("[Notifications] Registration error:", errorMessage) + return { ok: false, error: errorMessage } } console.log("[Notifications] Registration triggered successfully") + return { ok: true } } diff --git a/src/app/lib/reminder-utils.ts b/src/app/lib/reminder-utils.ts new file mode 100644 index 00000000..12081d9c --- /dev/null +++ b/src/app/lib/reminder-utils.ts @@ -0,0 +1,23 @@ +export type ReminderInfo = { + dueAtDate: string + deleted: boolean + done: boolean +} + +export function findLatestFutureDate( + reminders: ReminderInfo[], + today: string, +): string | undefined { + let latestDate: string | undefined + + for (let reminder of reminders) { + if (reminder.deleted || reminder.done) continue + if (reminder.dueAtDate >= today) { + if (!latestDate || reminder.dueAtDate > latestDate) { + latestDate = reminder.dueAtDate + } + } + } + + return latestDate +} diff --git a/src/server/features/push-cron-utils.ts b/src/server/features/push-cron-utils.ts index 8c7e4846..343a5e10 100644 --- a/src/server/features/push-cron-utils.ts +++ b/src/server/features/push-cron-utils.ts @@ -1,6 +1,7 @@ -// Pure utility functions extracted from push-cron.ts for testing import { toZonedTime, format, fromZonedTime } from "date-fns-tz" +let STALE_THRESHOLD_DAYS = 30 + export type NotificationTimeSettings = { timezone?: string notificationTime?: string @@ -49,3 +50,23 @@ export function wasDeliveredToday( return settings.lastDeliveredAt >= todayNotificationUtc } + +export function isStaleRef( + lastSyncedAt: Date, + latestReminderDueDate: string | undefined, + now: Date = new Date(), +): boolean { + let staleThreshold = new Date(now) + staleThreshold.setDate(staleThreshold.getDate() - STALE_THRESHOLD_DAYS) + + // Keep if app was opened recently + if (lastSyncedAt >= staleThreshold) return false + + // Keep if there's a reminder today or in the future + if (latestReminderDueDate) { + let todayStr = now.toISOString().slice(0, 10) + if (latestReminderDueDate >= todayStr) return false + } + + return true +} diff --git a/src/server/features/push-cron.test.ts b/src/server/features/push-cron.test.ts index 98bccfd7..00160684 100644 --- a/src/server/features/push-cron.test.ts +++ b/src/server/features/push-cron.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "vitest" import { isPastNotificationTime, wasDeliveredToday, + isStaleRef, type NotificationTimeSettings, } from "./push-cron-utils" @@ -149,3 +150,46 @@ describe("wasDeliveredToday", () => { expect(wasDeliveredToday(settings, currentUtc)).toBe(true) }) }) + +describe("isStaleRef", () => { + let now = new Date("2025-01-15T12:00:00Z") + + test("returns false when synced yesterday", () => { + let lastSyncedAt = new Date("2025-01-14T12:00:00Z") + expect(isStaleRef(lastSyncedAt, undefined, now)).toBe(false) + }) + + test("returns false when synced 29 days ago", () => { + let lastSyncedAt = new Date("2024-12-17T12:00:00Z") + expect(isStaleRef(lastSyncedAt, undefined, now)).toBe(false) + }) + + test("returns true when synced 31 days ago with no reminders", () => { + let lastSyncedAt = new Date("2024-12-15T12:00:00Z") + expect(isStaleRef(lastSyncedAt, undefined, now)).toBe(true) + }) + + test("returns true when synced 31 days ago with past reminder", () => { + let lastSyncedAt = new Date("2024-12-15T12:00:00Z") + let pastReminder = "2025-01-10" + expect(isStaleRef(lastSyncedAt, pastReminder, now)).toBe(true) + }) + + test("returns false when synced 31 days ago but has future reminder", () => { + let lastSyncedAt = new Date("2024-12-15T12:00:00Z") + let futureReminder = "2025-01-20" + expect(isStaleRef(lastSyncedAt, futureReminder, now)).toBe(false) + }) + + test("returns false when synced 31 days ago but has reminder today", () => { + let lastSyncedAt = new Date("2024-12-15T12:00:00Z") + let todayReminder = "2025-01-15" + expect(isStaleRef(lastSyncedAt, todayReminder, now)).toBe(false) + }) + + test("returns true when both sync and reminder are old", () => { + let lastSyncedAt = new Date("2024-11-01T12:00:00Z") + let oldReminder = "2024-12-01" + expect(isStaleRef(lastSyncedAt, oldReminder, now)).toBe(true) + }) +}) diff --git a/src/server/features/push-cron.ts b/src/server/features/push-cron.ts index f46f8305..5bc46c7f 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -18,12 +18,14 @@ import { baseServerMessages, deServerMessages, } from "#shared/intl/messages.server" -import { isPastNotificationTime, wasDeliveredToday } from "./push-cron-utils" +import { + isPastNotificationTime, + wasDeliveredToday, + isStaleRef, +} from "./push-cron-utils" export { cronDeliveryApp } -let STALE_THRESHOLD_DAYS = 30 - let serverRefsQuery = { root: { notificationSettingsRefs: { @@ -100,7 +102,6 @@ let cronDeliveryApp = new Hono().get( ref, notificationSettings, currentUtc, - worker, ) .then(result => { if (result) { @@ -128,10 +129,12 @@ let cronDeliveryApp = new Hono().get( } if (staleRefIndices.length > 0) { - await worker.$jazz.waitForSync() console.log(`πŸ—‘οΈ Removed ${staleRefIndices.length} stale refs`) } + // Single sync at end for all mutations + await worker.$jazz.waitForSync() + return c.json({ message: `Processed ${deliveryResults.length} notification deliveries`, results: deliveryResults, @@ -143,7 +146,6 @@ async function processNotificationRef( ref: LoadedRef, notificationSettings: LoadedNotificationSettings, currentUtc: Date, - worker: co.loaded, ): Promise<{ userId: string; success: boolean } | null> { let { userId } = ref @@ -175,7 +177,6 @@ async function processNotificationRef( if (enabledDevices.length === 0) { console.log(`βœ… User ${userId}: No enabled devices`) markNotificationSettingsAsDelivered(notificationSettings, currentUtc) - await worker.$jazz.waitForSync() console.log( `βœ… User ${userId}: Marked as delivered (skipped - no action needed)`, ) @@ -217,36 +218,12 @@ async function processNotificationRef( let userSuccess = deviceResults.some(r => r.success) markNotificationSettingsAsDelivered(notificationSettings, currentUtc) - await worker.$jazz.waitForSync() console.log(`βœ… User ${userId}: Completed notification delivery`) return { userId, success: userSuccess } } -/** - * Check if a notification ref is stale and should be removed. - * Stale = 30 days after whichever is later: lastSyncedAt or latestReminderDueDate - */ -function isStaleRef( - lastSyncedAt: Date, - latestReminderDueDate: string | undefined, -): boolean { - let referenceDate = lastSyncedAt - - // If there's a future reminder, use that as reference instead - if (latestReminderDueDate) { - let reminderDate = new Date(latestReminderDueDate) - if (reminderDate > referenceDate) { - referenceDate = reminderDate - } - } - - let staleDate = new Date() - staleDate.setDate(staleDate.getDate() - STALE_THRESHOLD_DAYS) - return referenceDate < staleDate -} - async function waitForConcurrencyLimit( promises: Promise[], maxConcurrency: number, diff --git a/src/server/features/push-register-logic.ts b/src/server/features/push-register-logic.ts new file mode 100644 index 00000000..2604d648 --- /dev/null +++ b/src/server/features/push-register-logic.ts @@ -0,0 +1,65 @@ +import { co, type ID } from "jazz-tools" +import { NotificationSettings } from "#shared/schema/user" +import { NotificationSettingsRef, ServerAccount } from "#shared/schema/server" + +export { registerNotificationSettingsWithServer } +export type { RegisterResult } + +type RegisterResult = + | { ok: true } + | { ok: false; error: string; status: 400 | 500 } + +async function registerNotificationSettingsWithServer( + worker: co.loaded, + notificationSettingsId: string, + userId: string, +): Promise { + let notificationSettings = await NotificationSettings.load( + notificationSettingsId as ID, + { loadAs: worker }, + ) + + if (!notificationSettings || !notificationSettings.$isLoaded) { + return { + ok: false, + error: "Failed to load notification settings - ensure server has access", + status: 400, + } + } + + if (!worker.root) { + return { ok: false, error: "Server root not initialized", status: 500 } + } + + let root = await worker.$jazz.ensureLoaded({ + resolve: { root: { notificationSettingsRefs: { $each: true } } }, + }) + + if (!root.root.notificationSettingsRefs) { + root.root.$jazz.set( + "notificationSettingsRefs", + co.list(NotificationSettingsRef).create([], { owner: worker }), + ) + } + + let refs = root.root.notificationSettingsRefs! + + let existingRef = refs + .values() + .find(ref => ref?.notificationSettings?.$jazz.id === notificationSettingsId) + + if (existingRef) { + existingRef.$jazz.set("lastSyncedAt", new Date()) + } else { + let newRef = NotificationSettingsRef.create({ + notificationSettings, + userId, + lastSyncedAt: new Date(), + }) + refs.$jazz.push(newRef) + } + + await worker.$jazz.waitForSync() + + return { ok: true } +} diff --git a/src/server/features/push-register.test.ts b/src/server/features/push-register.test.ts new file mode 100644 index 00000000..8d0bdde0 --- /dev/null +++ b/src/server/features/push-register.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, test, expect } from "vitest" +import { + createJazzTestAccount, + setupJazzTestSync, + setActiveAccount, +} from "jazz-tools/testing" +import { Group, co } from "jazz-tools" +import { NotificationSettings } from "#shared/schema/user" +import { ServerAccount, ServerAccountRoot } from "#shared/schema/server" +import { registerNotificationSettingsWithServer } from "./push-register-logic" + +describe("registerNotificationSettingsWithServer", () => { + let serverAccount: co.loaded + let userAccount: co.loaded + + beforeEach(async () => { + await setupJazzTestSync() + + serverAccount = await createJazzTestAccount({ + isCurrentActiveAccount: true, + AccountSchema: ServerAccount, + }) + + // Initialize server root manually since test accounts may not run migrations + if (!serverAccount.root) { + serverAccount.$jazz.set( + "root", + ServerAccountRoot.create({}, { owner: serverAccount }), + ) + } + + userAccount = await createJazzTestAccount({ + AccountSchema: ServerAccount, + }) + }) + + test("registers new notification settings", async () => { + setActiveAccount(userAccount) + + let group = Group.create() + group.addMember(serverAccount, "writer") + + let notificationSettings = NotificationSettings.create( + { + version: 1, + timezone: "UTC", + notificationTime: "09:00", + pushDevices: [], + }, + { owner: group }, + ) + + await userAccount.$jazz.waitForAllCoValuesSync() + await serverAccount.$jazz.waitForAllCoValuesSync() + + setActiveAccount(serverAccount) + + let result = await registerNotificationSettingsWithServer( + serverAccount, + notificationSettings.$jazz.id, + "test-user-123", + ) + + expect(result.ok).toBe(true) + + let loadedServer = await serverAccount.$jazz.ensureLoaded({ + resolve: { root: { notificationSettingsRefs: { $each: true } } }, + }) + let refs = loadedServer.root.notificationSettingsRefs + expect(refs?.length).toBe(1) + expect(refs?.[0]?.userId).toBe("test-user-123") + }) + + test("updates lastSyncedAt for existing registration", async () => { + setActiveAccount(userAccount) + + let group = Group.create() + group.addMember(serverAccount, "writer") + + let notificationSettings = NotificationSettings.create( + { + version: 1, + timezone: "UTC", + notificationTime: "09:00", + pushDevices: [], + }, + { owner: group }, + ) + + await userAccount.$jazz.waitForAllCoValuesSync() + await serverAccount.$jazz.waitForAllCoValuesSync() + + setActiveAccount(serverAccount) + + let firstResult = await registerNotificationSettingsWithServer( + serverAccount, + notificationSettings.$jazz.id, + "test-user-123", + ) + expect(firstResult.ok).toBe(true) + + let loadedServer = await serverAccount.$jazz.ensureLoaded({ + resolve: { root: { notificationSettingsRefs: { $each: true } } }, + }) + let firstSyncTime = + loadedServer.root.notificationSettingsRefs?.[0]?.lastSyncedAt + + await new Promise(r => setTimeout(r, 10)) + + await registerNotificationSettingsWithServer( + serverAccount, + notificationSettings.$jazz.id, + "test-user-123", + ) + + loadedServer = await serverAccount.$jazz.ensureLoaded({ + resolve: { root: { notificationSettingsRefs: { $each: true } } }, + }) + + expect(loadedServer.root.notificationSettingsRefs?.length).toBe(1) + + let secondSyncTime = + loadedServer.root.notificationSettingsRefs?.[0]?.lastSyncedAt + expect(secondSyncTime?.getTime()).toBeGreaterThan(firstSyncTime!.getTime()) + }) + + test("returns error when server cannot access settings", async () => { + setActiveAccount(userAccount) + + let notificationSettings = NotificationSettings.create({ + version: 1, + timezone: "UTC", + notificationTime: "09:00", + pushDevices: [], + }) + + await userAccount.$jazz.waitForAllCoValuesSync() + + setActiveAccount(serverAccount) + + let result = await registerNotificationSettingsWithServer( + serverAccount, + notificationSettings.$jazz.id, + "test-user-123", + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.status).toBe(400) + expect(result.error).toContain("ensure server has access") + } + }) +}) diff --git a/src/server/features/push-register.ts b/src/server/features/push-register.ts index 642d5a75..ee5fec1f 100644 --- a/src/server/features/push-register.ts +++ b/src/server/features/push-register.ts @@ -1,11 +1,9 @@ import { zValidator } from "@hono/zod-validator" import { Hono } from "hono" import { z } from "zod" -import { co, type ID } from "jazz-tools" import { authMiddleware, requireAuth } from "../lib/auth-middleware" import { initServerWorker } from "../lib/utils" -import { NotificationSettings } from "#shared/schema/user" -import { NotificationSettingsRef } from "#shared/schema/server" +import { registerNotificationSettingsWithServer } from "./push-register-logic" export { pushRegisterApp } @@ -20,62 +18,16 @@ let pushRegisterApp = new Hono().post( let { worker } = await initServerWorker() - // Load the notification settings by ID (server must already be a writer on the group) - let notificationSettings = await NotificationSettings.load( - notificationSettingsId as ID, - { loadAs: worker }, + let result = await registerNotificationSettingsWithServer( + worker, + notificationSettingsId, + user.id, ) - if (!notificationSettings || !notificationSettings.$isLoaded) { - return c.json( - { - message: - "Failed to load notification settings - ensure server has access", - }, - 400, - ) + if (!result.ok) { + return c.json({ message: result.error }, result.status) } - // Ensure server root has the refs list - if (!worker.root) { - return c.json({ message: "Server root not initialized" }, 500) - } - - let root = await worker.$jazz.ensureLoaded({ - resolve: { root: { notificationSettingsRefs: { $each: true } } }, - }) - - if (!root.root.notificationSettingsRefs) { - root.root.$jazz.set( - "notificationSettingsRefs", - co.list(NotificationSettingsRef).create([], { owner: worker }), - ) - } - - let refs = root.root.notificationSettingsRefs! - - // Check if ref already exists - let existingRef = refs - .values() - .find( - ref => ref?.notificationSettings?.$jazz.id === notificationSettingsId, - ) - - if (existingRef) { - // Update lastSyncedAt - existingRef.$jazz.set("lastSyncedAt", new Date()) - } else { - // Add new ref - let newRef = NotificationSettingsRef.create({ - notificationSettings, - userId: user.id, - lastSyncedAt: new Date(), - }) - refs.$jazz.push(newRef) - } - - await worker.$jazz.waitForSync() - return c.json({ message: "Registered successfully" }) }, ) diff --git a/src/shared/intl/messages.settings.ts b/src/shared/intl/messages.settings.ts index 630a15ce..87ffe9ad 100644 --- a/src/shared/intl/messages.settings.ts +++ b/src/shared/intl/messages.settings.ts @@ -300,6 +300,8 @@ const baseSettingsMessages = messages({ "notifications.toast.deviceRemoved": "Device removed successfully", "notifications.toast.deviceAdded": "Device added successfully!", "notifications.toast.nameUpdated": "Device name updated", + "notifications.toast.registrationFailed": + "Device added but server registration failed. Notifications may not work.", "notifications.lastDelivery.label": "Last Notification Check", "notifications.lastDelivery.reset": "Reset", "notifications.lastDelivery.description": @@ -664,6 +666,8 @@ let deSettingsMessages = translate(baseSettingsMessages, { "notifications.toast.deviceRemoved": "GerΓ€t erfolgreich entfernt", "notifications.toast.deviceAdded": "GerΓ€t erfolgreich hinzugefΓΌgt!", "notifications.toast.nameUpdated": "GerΓ€tename aktualisiert", + "notifications.toast.registrationFailed": + "GerΓ€t hinzugefΓΌgt, aber Serverregistrierung fehlgeschlagen. Benachrichtigungen funktionieren mΓΆglicherweise nicht.", "notifications.lastDelivery.label": "Letzter Check", "notifications.lastDelivery.reset": "ZurΓΌcksetzen", "notifications.lastDelivery.description": From 4c472bb33beb2b6acd23d46c3db5008409436d49 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 30 Jan 2026 07:56:13 +0100 Subject: [PATCH 06/24] chore: improve testability --- src/app/hooks/use-register-notifications.ts | 85 +++---------- .../notification-settings-migration.test.ts | 100 +++++++++++++++ .../lib/notification-settings-migration.ts | 114 ++++++++++++++++++ src/server/features/push-cron.ts | 44 +++---- src/server/features/push-register-logic.ts | 7 +- 5 files changed, 258 insertions(+), 92 deletions(-) create mode 100644 src/app/lib/notification-settings-migration.test.ts create mode 100644 src/app/lib/notification-settings-migration.ts diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts index 8c0f5854..f161c04c 100644 --- a/src/app/hooks/use-register-notifications.ts +++ b/src/app/hooks/use-register-notifications.ts @@ -1,11 +1,14 @@ import { useEffect, useRef } from "react" import { useAccount } from "jazz-tools/react" -import { Group, type co, type ResolveQuery, type ID } from "jazz-tools" +import { Group, type co, type ResolveQuery } from "jazz-tools" import { PUBLIC_JAZZ_WORKER_ACCOUNT } from "astro:env/client" -import { UserAccount, NotificationSettings } from "#shared/schema/user" -import { ServerAccount } from "#shared/schema/server" +import { UserAccount } from "#shared/schema/user" import { apiClient } from "#app/lib/api-client" import { tryCatch } from "#shared/lib/trycatch" +import { + migrateNotificationSettings, + addServerToGroup, +} from "#app/lib/notification-settings-migration" export { useRegisterNotifications } @@ -46,7 +49,6 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { let notificationSettings = me.root.notificationSettings if (!notificationSettings) return - // Bail early if server account ID is not configured let serverAccountId = PUBLIC_JAZZ_WORKER_ACCOUNT if (!serverAccountId) { console.error("[Notifications] PUBLIC_JAZZ_WORKER_ACCOUNT not configured") @@ -71,15 +73,21 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { let isShareableGroup = owner instanceof Group if (!isShareableGroup) { - // Need to migrate to a shareable group + console.log("[Notifications] Migrating to shareable group") let migrationResult = await tryCatch( - migrateNotificationSettings(me, notificationSettings, serverAccountId), + migrateNotificationSettings(notificationSettings, serverAccountId, { + loadAs: me, + rootLanguage, + }), ) if (!migrationResult.ok) { console.error("[Notifications] Migration failed:", migrationResult.error) return } + // Update root to point to new settings + me.root.$jazz.set("notificationSettings", migrationResult.data) notificationSettings = migrationResult.data + console.log("[Notifications] Migration complete") } else { // Ensure server worker is a member let group = owner as Group @@ -88,7 +96,7 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { ) if (!serverIsMember) { let addResult = await tryCatch( - addServerToGroup(me, group, serverAccountId), + addServerToGroup(group, serverAccountId, { loadAs: me }), ) if (!addResult.ok) { console.error( @@ -123,69 +131,6 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { console.log("[Notifications] Registration successful") } -async function migrateNotificationSettings( - me: LoadedAccount, - oldSettings: co.loaded, - serverAccountId: string, -): Promise> { - console.log("[Notifications] Migrating to shareable group") - - // Create new group with current user as owner - let group = Group.create() - - // Add server worker as writer - await addServerToGroup(me, group, serverAccountId) - - let devicesCopy = oldSettings.pushDevices.map(device => ({ - isEnabled: device.isEnabled, - deviceName: device.deviceName, - endpoint: device.endpoint, - keys: { - p256dh: device.keys.p256dh, - auth: device.keys.auth, - }, - })) - - let newSettings = NotificationSettings.create( - { - version: 1, - timezone: oldSettings.timezone, - notificationTime: oldSettings.notificationTime, - lastDeliveredAt: oldSettings.lastDeliveredAt, - pushDevices: devicesCopy, - language: oldSettings.language || me.root.language, - }, - { owner: group }, - ) - - // Update root to point to new settings - me.root.$jazz.set("notificationSettings", newSettings) - - // Delete old settings to avoid orphaned data - oldSettings.$jazz.raw.core.deleteCoValue() - - console.log("[Notifications] Migration complete") - return newSettings -} - -async function addServerToGroup( - me: LoadedAccount, - group: Group, - serverAccountId: string, -): Promise { - // Load the server account to add as member - let serverAccount = await ServerAccount.load( - serverAccountId as ID, - { loadAs: me }, - ) - - if (!serverAccount || !serverAccount.$isLoaded) { - throw new Error("Failed to load server account") - } - - group.addMember(serverAccount, "writer") -} - function computeLatestReminderDueDate(me: LoadedAccount): string | undefined { let reminders = extractReminders(me) let today = new Date().toISOString().slice(0, 10) diff --git a/src/app/lib/notification-settings-migration.test.ts b/src/app/lib/notification-settings-migration.test.ts new file mode 100644 index 00000000..4d66523a --- /dev/null +++ b/src/app/lib/notification-settings-migration.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect } from "vitest" +import { + copyNotificationSettingsData, + type NotificationSettingsInput, +} from "./notification-settings-migration" + +describe("copyNotificationSettingsData", () => { + test("copies all settings fields", () => { + let mockSettings: NotificationSettingsInput = { + timezone: "America/New_York", + notificationTime: "09:00", + lastDeliveredAt: new Date("2025-01-15T14:00:00Z"), + language: "de" as const, + latestReminderDueDate: "2025-02-01", + pushDevices: [ + { + isEnabled: true, + deviceName: "iPhone", + endpoint: "https://push.example.com/abc123", + keys: { p256dh: "key1", auth: "auth1" }, + }, + ], + } + + let result = copyNotificationSettingsData(mockSettings, undefined) + + expect(result.timezone).toBe("America/New_York") + expect(result.notificationTime).toBe("09:00") + expect(result.lastDeliveredAt).toEqual(new Date("2025-01-15T14:00:00Z")) + expect(result.language).toBe("de") + expect(result.latestReminderDueDate).toBe("2025-02-01") + expect(result.pushDevices).toHaveLength(1) + expect(result.pushDevices[0].deviceName).toBe("iPhone") + }) + + test("uses rootLanguage when settings language is undefined", () => { + let mockSettings: NotificationSettingsInput = { + timezone: "UTC", + notificationTime: "12:00", + pushDevices: [], + } + + let result = copyNotificationSettingsData(mockSettings, "de") + + expect(result.language).toBe("de") + }) + + test("preserves settings language over rootLanguage", () => { + let mockSettings: NotificationSettingsInput = { + timezone: "UTC", + notificationTime: "12:00", + language: "en" as const, + pushDevices: [], + } + + let result = copyNotificationSettingsData(mockSettings, "de") + + expect(result.language).toBe("en") + }) + + test("copies multiple devices", () => { + let mockSettings: NotificationSettingsInput = { + timezone: "UTC", + pushDevices: [ + { + isEnabled: true, + deviceName: "Device 1", + endpoint: "https://push.example.com/1", + keys: { p256dh: "key1", auth: "auth1" }, + }, + { + isEnabled: false, + deviceName: "Device 2", + endpoint: "https://push.example.com/2", + keys: { p256dh: "key2", auth: "auth2" }, + }, + ], + } + + let result = copyNotificationSettingsData(mockSettings, undefined) + + expect(result.pushDevices).toHaveLength(2) + expect(result.pushDevices[0].isEnabled).toBe(true) + expect(result.pushDevices[1].isEnabled).toBe(false) + }) + + test("handles undefined optional fields", () => { + let mockSettings: NotificationSettingsInput = { + pushDevices: [], + } + + let result = copyNotificationSettingsData(mockSettings, undefined) + + expect(result.timezone).toBeUndefined() + expect(result.notificationTime).toBeUndefined() + expect(result.lastDeliveredAt).toBeUndefined() + expect(result.language).toBeUndefined() + expect(result.latestReminderDueDate).toBeUndefined() + }) +}) diff --git a/src/app/lib/notification-settings-migration.ts b/src/app/lib/notification-settings-migration.ts new file mode 100644 index 00000000..66578d61 --- /dev/null +++ b/src/app/lib/notification-settings-migration.ts @@ -0,0 +1,114 @@ +import { Account, Group, type co, type ID } from "jazz-tools" +import { NotificationSettings } from "#shared/schema/user" +import { ServerAccount } from "#shared/schema/server" + +export { + migrateNotificationSettings, + addServerToGroup, + copyNotificationSettingsData, +} +export type { + MigrationContext, + NotificationSettingsData, + NotificationSettingsInput, +} + +type NotificationSettingsInput = { + timezone?: string + notificationTime?: string + lastDeliveredAt?: Date + language?: "de" | "en" + latestReminderDueDate?: string + pushDevices: Array<{ + isEnabled: boolean + deviceName: string + endpoint: string + keys: { p256dh: string; auth: string } + }> +} + +type MigrationContext = { + loadAs: Account + rootLanguage?: "de" | "en" +} + +type NotificationSettingsData = { + timezone?: string + notificationTime?: string + lastDeliveredAt?: Date + language?: "de" | "en" + latestReminderDueDate?: string + pushDevices: Array<{ + isEnabled: boolean + deviceName: string + endpoint: string + keys: { p256dh: string; auth: string } + }> +} + +function copyNotificationSettingsData( + settings: NotificationSettingsInput, + rootLanguage?: "de" | "en", +): NotificationSettingsData { + return { + timezone: settings.timezone, + notificationTime: settings.notificationTime, + lastDeliveredAt: settings.lastDeliveredAt, + language: settings.language || rootLanguage, + latestReminderDueDate: settings.latestReminderDueDate, + pushDevices: settings.pushDevices.map(device => ({ + isEnabled: device.isEnabled, + deviceName: device.deviceName, + endpoint: device.endpoint, + keys: { + p256dh: device.keys.p256dh, + auth: device.keys.auth, + }, + })), + } +} + +async function migrateNotificationSettings( + oldSettings: co.loaded, + serverAccountId: string, + context: MigrationContext, +): Promise> { + let group = Group.create() + + await addServerToGroup(group, serverAccountId, context) + + let settingsData = copyNotificationSettingsData( + oldSettings, + context.rootLanguage, + ) + + let newSettings = NotificationSettings.create( + { + version: 1, + ...settingsData, + }, + { owner: group }, + ) + + // Delete old settings to avoid orphaned data + oldSettings.$jazz.raw.core.deleteCoValue() + + return newSettings +} + +async function addServerToGroup( + group: Group, + serverAccountId: string, + context: MigrationContext, +): Promise { + let serverAccount = await ServerAccount.load( + serverAccountId as ID, + { loadAs: context.loadAs }, + ) + + if (!serverAccount || !serverAccount.$isLoaded) { + throw new Error("Failed to load server account") + } + + group.addMember(serverAccount, "writer") +} diff --git a/src/server/features/push-cron.ts b/src/server/features/push-cron.ts index 5bc46c7f..e04afac4 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -23,6 +23,7 @@ import { wasDeliveredToday, isStaleRef, } from "./push-cron-utils" +import { tryCatch } from "#shared/lib/trycatch" export { cronDeliveryApp } @@ -69,20 +70,15 @@ let cronDeliveryApp = new Hono().get( }) } - let staleRefIndices: number[] = [] + let staleRefIds: string[] = [] let currentUtc = new Date() - let refIndex = 0 for (let ref of refs.values()) { - if (!ref) { - refIndex++ - continue - } + if (!ref) continue let notificationSettings = ref.notificationSettings if (!notificationSettings?.$isLoaded) { console.log(`❌ User ${ref.userId}: Settings not loaded`) - refIndex++ continue } @@ -90,9 +86,8 @@ let cronDeliveryApp = new Hono().get( if ( isStaleRef(ref.lastSyncedAt, notificationSettings.latestReminderDueDate) ) { - staleRefIndices.push(refIndex) + staleRefIds.push(ref.$jazz.id) console.log(`πŸ—‘οΈ Marking stale ref for removal: ${ref.userId}`) - refIndex++ continue } @@ -109,31 +104,38 @@ let cronDeliveryApp = new Hono().get( } }) .catch(error => { - if (typeof error === "string") { - console.log(`❌ User ${ref.userId}: ${error}`) - } else { - console.log(`❌ User ${ref.userId}: ${error.message || error}`) - } + let message = + typeof error === "string" + ? error + : error instanceof Error + ? error.message + : String(error) + console.log(`❌ User ${ref.userId}: ${message}`) }) .finally(() => removeFromList(processingPromises, userPromise)) processingPromises.push(userPromise) - refIndex++ } await Promise.allSettled(processingPromises) - // Remove stale refs (in reverse order to maintain indices) - for (let i = staleRefIndices.length - 1; i >= 0; i--) { - refs.$jazz.splice(staleRefIndices[i], 1) + // Remove stale refs by ID (avoids index shifting issues) + for (let staleId of staleRefIds) { + let index = [...refs.values()].findIndex(r => r?.$jazz.id === staleId) + if (index > -1) { + refs.$jazz.splice(index, 1) + } } - if (staleRefIndices.length > 0) { - console.log(`πŸ—‘οΈ Removed ${staleRefIndices.length} stale refs`) + if (staleRefIds.length > 0) { + console.log(`πŸ—‘οΈ Removed ${staleRefIds.length} stale refs`) } // Single sync at end for all mutations - await worker.$jazz.waitForSync() + let syncResult = await tryCatch(worker.$jazz.waitForSync()) + if (!syncResult.ok) { + console.error("❌ Failed to sync mutations:", syncResult.error) + } return c.json({ message: `Processed ${deliveryResults.length} notification deliveries`, diff --git a/src/server/features/push-register-logic.ts b/src/server/features/push-register-logic.ts index 2604d648..439c9658 100644 --- a/src/server/features/push-register-logic.ts +++ b/src/server/features/push-register-logic.ts @@ -1,6 +1,7 @@ import { co, type ID } from "jazz-tools" import { NotificationSettings } from "#shared/schema/user" import { NotificationSettingsRef, ServerAccount } from "#shared/schema/server" +import { tryCatch } from "#shared/lib/trycatch" export { registerNotificationSettingsWithServer } export type { RegisterResult } @@ -59,7 +60,11 @@ async function registerNotificationSettingsWithServer( refs.$jazz.push(newRef) } - await worker.$jazz.waitForSync() + let syncResult = await tryCatch(worker.$jazz.waitForSync()) + if (!syncResult.ok) { + console.error("Failed to sync registration:", syncResult.error) + return { ok: false, error: "Failed to sync registration", status: 500 } + } return { ok: true } } From e999d97c7ce86f23336257b55b2fc736ebb95c2c Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 30 Jan 2026 16:13:41 +0100 Subject: [PATCH 07/24] chore: address minor issues --- src/app/hooks/use-register-notifications.ts | 7 +++--- .../notification-settings-migration.test.ts | 12 +++++----- .../lib/notification-settings-migration.ts | 24 +++---------------- 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts index f161c04c..bb0ce4b0 100644 --- a/src/app/hooks/use-register-notifications.ts +++ b/src/app/hooks/use-register-notifications.ts @@ -9,6 +9,7 @@ import { migrateNotificationSettings, addServerToGroup, } from "#app/lib/notification-settings-migration" +import { findLatestFutureDate } from "#app/lib/reminder-utils" export { useRegisterNotifications } @@ -38,8 +39,8 @@ function useRegisterNotifications(): void { if (!me.root.notificationSettings) return registrationRan.current = true - registerNotificationSettings(me).catch(() => { - // Allow retry on next mount if registration fails + registerNotificationSettings(me).catch(error => { + console.error("[Notifications] Registration error:", error) registrationRan.current = false }) }, [me.$isLoaded, me]) @@ -154,5 +155,3 @@ function extractReminders( } return reminders } - -import { findLatestFutureDate } from "#app/lib/reminder-utils" diff --git a/src/app/lib/notification-settings-migration.test.ts b/src/app/lib/notification-settings-migration.test.ts index 4d66523a..45870392 100644 --- a/src/app/lib/notification-settings-migration.test.ts +++ b/src/app/lib/notification-settings-migration.test.ts @@ -1,12 +1,12 @@ import { describe, test, expect } from "vitest" import { copyNotificationSettingsData, - type NotificationSettingsInput, + type NotificationSettingsData, } from "./notification-settings-migration" describe("copyNotificationSettingsData", () => { test("copies all settings fields", () => { - let mockSettings: NotificationSettingsInput = { + let mockSettings: NotificationSettingsData = { timezone: "America/New_York", notificationTime: "09:00", lastDeliveredAt: new Date("2025-01-15T14:00:00Z"), @@ -34,7 +34,7 @@ describe("copyNotificationSettingsData", () => { }) test("uses rootLanguage when settings language is undefined", () => { - let mockSettings: NotificationSettingsInput = { + let mockSettings: NotificationSettingsData = { timezone: "UTC", notificationTime: "12:00", pushDevices: [], @@ -46,7 +46,7 @@ describe("copyNotificationSettingsData", () => { }) test("preserves settings language over rootLanguage", () => { - let mockSettings: NotificationSettingsInput = { + let mockSettings: NotificationSettingsData = { timezone: "UTC", notificationTime: "12:00", language: "en" as const, @@ -59,7 +59,7 @@ describe("copyNotificationSettingsData", () => { }) test("copies multiple devices", () => { - let mockSettings: NotificationSettingsInput = { + let mockSettings: NotificationSettingsData = { timezone: "UTC", pushDevices: [ { @@ -85,7 +85,7 @@ describe("copyNotificationSettingsData", () => { }) test("handles undefined optional fields", () => { - let mockSettings: NotificationSettingsInput = { + let mockSettings: NotificationSettingsData = { pushDevices: [], } diff --git a/src/app/lib/notification-settings-migration.ts b/src/app/lib/notification-settings-migration.ts index 66578d61..7330b809 100644 --- a/src/app/lib/notification-settings-migration.ts +++ b/src/app/lib/notification-settings-migration.ts @@ -7,13 +7,9 @@ export { addServerToGroup, copyNotificationSettingsData, } -export type { - MigrationContext, - NotificationSettingsData, - NotificationSettingsInput, -} +export type { MigrationContext, NotificationSettingsData } -type NotificationSettingsInput = { +type NotificationSettingsData = { timezone?: string notificationTime?: string lastDeliveredAt?: Date @@ -32,22 +28,8 @@ type MigrationContext = { rootLanguage?: "de" | "en" } -type NotificationSettingsData = { - timezone?: string - notificationTime?: string - lastDeliveredAt?: Date - language?: "de" | "en" - latestReminderDueDate?: string - pushDevices: Array<{ - isEnabled: boolean - deviceName: string - endpoint: string - keys: { p256dh: string; auth: string } - }> -} - function copyNotificationSettingsData( - settings: NotificationSettingsInput, + settings: NotificationSettingsData, rootLanguage?: "de" | "en", ): NotificationSettingsData { return { From 1dec5eb3cc148002cc81e65b2c0f4c08585ec1f3 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 30 Jan 2026 16:13:53 +0100 Subject: [PATCH 08/24] chore: write ATPs --- ATP.md | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 ATP.md diff --git a/ATP.md b/ATP.md new file mode 100644 index 00000000..ab438692 --- /dev/null +++ b/ATP.md @@ -0,0 +1,107 @@ +# ATP: Push Notification Migration + +## Prerequisites + +- Device with existing push notifications enabled +- Device/browser for new user signup +- Server deployed with new code +- Ability to trigger cron job (`/push/deliver-notifications`) +- Jazz dev tools access +- `CRON_SECRET` from `.env` + +## Trigger Cron Job + +```bash +curl -X GET http://localhost:4321/api/push/deliver-notifications \ + -H "Authorization: Bearer $CRON_SECRET" +``` + +For local testing: + +```bash +curl -X GET http://localhost:4321/api/push/deliver-notifications \ + -H "Authorization: Bearer $CRON_SECRET" +``` + +--- + +## ATP 1: Existing User Migration + +**Goal:** Verify existing users migrate to group-owned settings and continue receiving notifications + +### Pre-flight + +1. Open Jazz dev tools as existing user with push enabled +2. Note `notificationSettings` owner (should be Account, not Group) + +### Steps + +| Step | Action | Expected | +| ---- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| 1 | Open app | App loads | +| 2 | Check browser console | `[Notifications] Migrating to shareable group` β†’ `Migration complete` β†’ `Registration successful` | +| 3 | Check Jazz dev tools | `notificationSettings` owner is now Group with server as member | +| 4 | Check `root.notificationSettingsRefs` on server account | Entry exists with your `userId`, recent `lastSyncedAt`, link to your settings | +| 5 | Verify notification settings UI | All previously registered devices still visible | +| 6 | Trigger cron job | User appears in results, `success: true` | +| 7 | Receive push notification | Notification arrives on device | + +### Failure indicators + +- Console shows `Migration failed` or `Registration failed` +- Devices disappeared from settings +- Server refs list empty or missing user +- Cron returns no results or `success: false` + +--- + +## ATP 2: New User Push Setup + +**Goal:** Verify new users can enable push and receive notifications + +| Step | Action | Expected | +| ---- | ----------------------------------------- | ------------------------------------------------ | +| 1 | Sign up as new user | Account created | +| 2 | Go to Settings β†’ Notifications | Page loads | +| 3 | Enable push notifications, add device | Device added, success toast | +| 4 | Check console | `Registration successful` (no migration message) | +| 5 | Check server's `notificationSettingsRefs` | New user entry exists | +| 6 | Trigger cron job | New user in results | +| 7 | Receive push notification | Notification arrives | + +--- + +## ATP 3: Re-registration on App Open + +**Goal:** Verify `latestReminderDueDate` updates on each app open + +| Step | Action | Expected | +| ---- | --------------------------------------------------------------------- | -------------------------------------------------------------- | +| 1 | As existing user, create reminder for future date (e.g., 2 weeks out) | Reminder created | +| 2 | Close and reopen app | App loads | +| 3 | Check console | `Registration successful` | +| 4 | Check `notificationSettings` in Jazz dev tools | `latestReminderDueDate` matches furthest reminder (YYYY-MM-DD) | +| 5 | Check server ref | `lastSyncedAt` updated to recent time | + +--- + +## ATP 4: Stale Ref Cleanup + +**Goal:** Verify inactive users are cleaned up from server refs + +| Step | Action | Expected | +| ---- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| 1 | Register a test user | User in server refs | +| 2 | In Jazz dev tools, manually set `lastSyncedAt` to 31+ days ago | - | +| 3 | Ensure user has no future reminders (or set `latestReminderDueDate` to past date) | - | +| 4 | Trigger cron | Console shows `Marking stale ref for removal`, user removed from refs | +| 5 | Open app as that user | Re-registers, back in refs | + +--- + +## Verification Checklist + +- [ ] ATP 1: Existing user migration +- [ ] ATP 2: New user setup +- [ ] ATP 3: Re-registration updates +- [ ] ATP 4: Stale cleanup From 5c03912495682e3fc5bd710796602aa24dacc750 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 30 Jan 2026 16:58:32 +0100 Subject: [PATCH 09/24] fix: address coderabbit findings --- src/app/hooks/use-register-notifications.ts | 7 ++++++- src/server/features/push-register-logic.ts | 13 ++++++++----- src/shared/schema/server.ts | 18 ++++++++++++++---- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts index bb0ce4b0..40be4068 100644 --- a/src/app/hooks/use-register-notifications.ts +++ b/src/app/hooks/use-register-notifications.ts @@ -134,7 +134,12 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { function computeLatestReminderDueDate(me: LoadedAccount): string | undefined { let reminders = extractReminders(me) - let today = new Date().toISOString().slice(0, 10) + let timezone = + me.root.notificationSettings?.timezone || + Intl.DateTimeFormat().resolvedOptions().timeZone + let today = new Date() + .toLocaleDateString("sv-SE", { timeZone: timezone }) + .slice(0, 10) return findLatestFutureDate(reminders, today) } diff --git a/src/server/features/push-register-logic.ts b/src/server/features/push-register-logic.ts index 439c9658..9a023c5d 100644 --- a/src/server/features/push-register-logic.ts +++ b/src/server/features/push-register-logic.ts @@ -52,11 +52,14 @@ async function registerNotificationSettingsWithServer( if (existingRef) { existingRef.$jazz.set("lastSyncedAt", new Date()) } else { - let newRef = NotificationSettingsRef.create({ - notificationSettings, - userId, - lastSyncedAt: new Date(), - }) + let newRef = NotificationSettingsRef.create( + { + notificationSettings, + userId, + lastSyncedAt: new Date(), + }, + { owner: worker }, + ) refs.$jazz.push(newRef) } diff --git a/src/shared/schema/server.ts b/src/shared/schema/server.ts index 383b3a1c..f2400edf 100644 --- a/src/shared/schema/server.ts +++ b/src/shared/schema/server.ts @@ -11,7 +11,17 @@ export let ServerAccountRoot = co.map({ notificationSettingsRefs: co.list(NotificationSettingsRef).optional(), }) -export let ServerAccount = co.account({ - profile: co.map({ name: z.string() }), - root: ServerAccountRoot, -}) +export let ServerAccount = co + .account({ + profile: co.map({ name: z.string() }), + root: ServerAccountRoot, + }) + .withMigration(async account => { + if (account.root === undefined) { + let root = ServerAccountRoot.create( + { notificationSettingsRefs: [] }, + { owner: account }, + ) + account.$jazz.set("root", root) + } + }) From ba0a413364b55ae627e7f900d1b0811ce18e7ad7 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 30 Jan 2026 19:59:35 +0100 Subject: [PATCH 10/24] fix: address nitpick changes --- src/app/features/notification-settings.tsx | 5 +++ src/server/features/push-cron.ts | 36 +++++++++++++++------- src/server/features/push-register.ts | 12 +++++++- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/app/features/notification-settings.tsx b/src/app/features/notification-settings.tsx index 2acf5c2c..8cbf86c3 100644 --- a/src/app/features/notification-settings.tsx +++ b/src/app/features/notification-settings.tsx @@ -981,6 +981,11 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) { ) if (!registrationResult.ok) { toast.warning(t("notifications.toast.registrationFailed")) + setOpen(false) + form.reset({ + deviceName: getDeviceName(), + }) + return } } diff --git a/src/server/features/push-cron.ts b/src/server/features/push-cron.ts index e04afac4..9cd253b0 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -99,7 +99,7 @@ let cronDeliveryApp = new Hono().get( currentUtc, ) .then(result => { - if (result) { + if (result.status === "processed") { deliveryResults.push(result) } }) @@ -120,11 +120,15 @@ let cronDeliveryApp = new Hono().get( await Promise.allSettled(processingPromises) // Remove stale refs by ID (avoids index shifting issues) - for (let staleId of staleRefIds) { - let index = [...refs.values()].findIndex(r => r?.$jazz.id === staleId) - if (index > -1) { - refs.$jazz.splice(index, 1) - } + let refIndexMap = new Map( + [...refs.values()].map((r, i) => [r?.$jazz.id, i]), + ) + let sortedStaleIndices = staleRefIds + .map(id => refIndexMap.get(id)) + .filter((i): i is number => i !== undefined) + .sort((a, b) => b - a) + for (let index of sortedStaleIndices) { + refs.$jazz.splice(index, 1) } if (staleRefIds.length > 0) { @@ -144,11 +148,15 @@ let cronDeliveryApp = new Hono().get( }, ) +type ProcessResult = + | { status: "skipped"; reason: string } + | { status: "processed"; userId: string; success: boolean } + async function processNotificationRef( ref: LoadedRef, notificationSettings: LoadedNotificationSettings, currentUtc: Date, -): Promise<{ userId: string; success: boolean } | null> { +): Promise { let { userId } = ref // Check notification time @@ -157,7 +165,10 @@ async function processNotificationRef( let userNotificationTime = notificationSettings.notificationTime || "12:00" let userLocalTime = toZonedTime(currentUtc, userTimezone) let userLocalTimeStr = format(userLocalTime, "HH:mm") - throw `Not past notification time (current: ${userLocalTimeStr}, configured: ${userNotificationTime}, timezone: ${userTimezone})` + return { + status: "skipped", + reason: `Not past notification time (current: ${userLocalTimeStr}, configured: ${userNotificationTime}, timezone: ${userTimezone})`, + } } // Check if already delivered today @@ -169,7 +180,10 @@ async function processNotificationRef( "yyyy-MM-dd HH:mm", ) : "never" - throw `Already delivered today (last delivered: ${lastDelivered})` + return { + status: "skipped", + reason: `Already delivered today (last delivered: ${lastDelivered})`, + } } console.log(`βœ… User ${userId}: Passed notification time checks`) @@ -182,7 +196,7 @@ async function processNotificationRef( console.log( `βœ… User ${userId}: Marked as delivered (skipped - no action needed)`, ) - return { userId, success: true } + return { status: "skipped", reason: "No enabled devices" } } console.log( @@ -223,7 +237,7 @@ async function processNotificationRef( console.log(`βœ… User ${userId}: Completed notification delivery`) - return { userId, success: userSuccess } + return { status: "processed", userId, success: userSuccess } } async function waitForConcurrencyLimit( diff --git a/src/server/features/push-register.ts b/src/server/features/push-register.ts index ee5fec1f..5270eb50 100644 --- a/src/server/features/push-register.ts +++ b/src/server/features/push-register.ts @@ -11,7 +11,17 @@ let pushRegisterApp = new Hono().post( "/register", authMiddleware, requireAuth, - zValidator("json", z.object({ notificationSettingsId: z.string() })), + zValidator( + "json", + z.object({ + notificationSettingsId: z + .string() + .min(1) + .refine(id => /^[a-zA-Z0-9_-]+$/.test(id), { + message: "Invalid Jazz ID format", + }), + }), + ), async c => { let { notificationSettingsId } = c.req.valid("json") let user = c.get("user") From e052adaefd7c65198b615f601afa239ac161eda2 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 30 Jan 2026 20:15:31 +0100 Subject: [PATCH 11/24] fix: address minor issues --- src/server/features/push-cron.ts | 8 ++++++++ src/shared/schema/server.ts | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/server/features/push-cron.ts b/src/server/features/push-cron.ts index 9cd253b0..dce9df95 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -61,6 +61,14 @@ let cronDeliveryApp = new Hono().get( resolve: serverRefsQuery, }) + if (!serverAccount || !serverAccount.root) { + console.log("πŸ”” Missing server account or root, aborting cron") + return c.json({ + message: "Missing server account or root", + results: [], + }) + } + let refs = serverAccount.root.notificationSettingsRefs if (!refs) { console.log("πŸ”” No notification settings refs found") diff --git a/src/shared/schema/server.ts b/src/shared/schema/server.ts index f2400edf..40954389 100644 --- a/src/shared/schema/server.ts +++ b/src/shared/schema/server.ts @@ -17,11 +17,21 @@ export let ServerAccount = co root: ServerAccountRoot, }) .withMigration(async account => { - if (account.root === undefined) { - let root = ServerAccountRoot.create( + let { root } = await account.$jazz.ensureLoaded({ + resolve: { root: true }, + }) + + if (root === undefined) { + let newRoot = ServerAccountRoot.create( + { notificationSettingsRefs: [] }, + { owner: account }, + ) + account.$jazz.set("root", newRoot) + } else if (root.notificationSettingsRefs === undefined) { + let updatedRoot = ServerAccountRoot.create( { notificationSettingsRefs: [] }, { owner: account }, ) - account.$jazz.set("root", root) + account.$jazz.set("root", updatedRoot) } }) From df27bad88dde75ed6f97651f3aef75036fecf199 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 30 Jan 2026 20:26:02 +0100 Subject: [PATCH 12/24] feat: add CI and don't run checks on vercel builds --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ package.json | 5 +++-- 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..055264f5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run typecheck, lint, and format checks + run: bun run check + + - name: Run tests + run: bun run test:run + + build: + runs-on: ubuntu-latest + needs: check + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build:prod diff --git a/package.json b/package.json index 0e883cef..e4ca831f 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "type": "module", "scripts": { "dev": "astro dev", - "build": "bun test:run && astro check && astro build", - "build:node": "ASTRO_ADAPTER=node astro check && ASTRO_ADAPTER=node astro build", + "build": "astro build", + "build:prod": "astro build", + "build:node": "ASTRO_ADAPTER=node astro build", "preview": "astro preview --port 4322", "preview:node": "ASTRO_ADAPTER=node dotenv -e .env -- astro preview --port 4322", "check": "concurrently -n Astro,Prettier,ESLint \"astro check\" \"prettier --check .\" \"eslint . --ext .ts,.tsx,.js,.jsx,.astro\"", From 779f9afd0dd01e87499e7c49f9b4a272301d8c8d Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 30 Jan 2026 20:28:19 +0100 Subject: [PATCH 13/24] fix: address findings --- src/server/features/push-cron.ts | 14 +++++++++----- src/shared/schema/server.ts | 9 ++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/server/features/push-cron.ts b/src/server/features/push-cron.ts index dce9df95..8ca84d41 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -159,6 +159,7 @@ let cronDeliveryApp = new Hono().get( type ProcessResult = | { status: "skipped"; reason: string } | { status: "processed"; userId: string; success: boolean } + | { status: "failed"; userId: string; reason: string } async function processNotificationRef( ref: LoadedRef, @@ -241,11 +242,14 @@ async function processNotificationRef( let userSuccess = deviceResults.some(r => r.success) - markNotificationSettingsAsDelivered(notificationSettings, currentUtc) - - console.log(`βœ… User ${userId}: Completed notification delivery`) - - return { status: "processed", userId, success: userSuccess } + if (userSuccess) { + markNotificationSettingsAsDelivered(notificationSettings, currentUtc) + console.log(`βœ… User ${userId}: Completed notification delivery`) + return { status: "processed", userId, success: true } + } else { + console.log(`❌ User ${userId}: All device sends failed, will retry`) + return { status: "failed", userId, reason: "All device sends failed" } + } } async function waitForConcurrencyLimit( diff --git a/src/shared/schema/server.ts b/src/shared/schema/server.ts index 40954389..1f1b83f5 100644 --- a/src/shared/schema/server.ts +++ b/src/shared/schema/server.ts @@ -18,7 +18,7 @@ export let ServerAccount = co }) .withMigration(async account => { let { root } = await account.$jazz.ensureLoaded({ - resolve: { root: true }, + resolve: { root: { notificationSettingsRefs: { $each: true } } }, }) if (root === undefined) { @@ -28,10 +28,9 @@ export let ServerAccount = co ) account.$jazz.set("root", newRoot) } else if (root.notificationSettingsRefs === undefined) { - let updatedRoot = ServerAccountRoot.create( - { notificationSettingsRefs: [] }, - { owner: account }, + root.$jazz.set( + "notificationSettingsRefs", + co.list(NotificationSettingsRef).create([], { owner: account }), ) - account.$jazz.set("root", updatedRoot) } }) From 655e87ec26ebf6ddcd8343f50eb3e9564bd6ca9d Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 30 Jan 2026 21:36:37 +0100 Subject: [PATCH 14/24] fix: tests --- src/app/hooks/use-register-notifications.ts | 23 ++++++++++++++++----- src/server/features/push-register.ts | 21 ++++++++++++++----- src/shared/schema/server.ts | 21 ++++++++++--------- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts index 40be4068..2c51d421 100644 --- a/src/app/hooks/use-register-notifications.ts +++ b/src/app/hooks/use-register-notifications.ts @@ -1,6 +1,11 @@ import { useEffect, useRef } from "react" import { useAccount } from "jazz-tools/react" -import { Group, type co, type ResolveQuery } from "jazz-tools" +import { + Group, + generateAuthToken, + type co, + type ResolveQuery, +} from "jazz-tools" import { PUBLIC_JAZZ_WORKER_ACCOUNT } from "astro:env/client" import { UserAccount } from "#shared/schema/user" import { apiClient } from "#app/lib/api-client" @@ -108,11 +113,19 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { } } - // Register with server + // Register with server using Jazz auth + let authToken = generateAuthToken(me) let registerResult = await tryCatch( - apiClient.push.register.$post({ - json: { notificationSettingsId: notificationSettings.$jazz.id }, - }), + apiClient.push.register.$post( + { + json: { notificationSettingsId: notificationSettings.$jazz.id }, + }, + { + headers: { + Authorization: `Jazz ${authToken}`, + }, + }, + ), ) if (!registerResult.ok) { diff --git a/src/server/features/push-register.ts b/src/server/features/push-register.ts index 5270eb50..07254b5e 100644 --- a/src/server/features/push-register.ts +++ b/src/server/features/push-register.ts @@ -1,7 +1,7 @@ import { zValidator } from "@hono/zod-validator" import { Hono } from "hono" import { z } from "zod" -import { authMiddleware, requireAuth } from "../lib/auth-middleware" +import { authenticateRequest } from "jazz-tools" import { initServerWorker } from "../lib/utils" import { registerNotificationSettingsWithServer } from "./push-register-logic" @@ -9,8 +9,6 @@ export { pushRegisterApp } let pushRegisterApp = new Hono().post( "/register", - authMiddleware, - requireAuth, zValidator( "json", z.object({ @@ -24,14 +22,27 @@ let pushRegisterApp = new Hono().post( ), async c => { let { notificationSettingsId } = c.req.valid("json") - let user = c.get("user") let { worker } = await initServerWorker() + let { account, error } = await authenticateRequest(c.req.raw, { + loadAs: worker, + }) + + if (error) { + return c.json({ message: error.message }, 401) + } + + if (!account) { + return c.json({ message: "Authentication required" }, 401) + } + + let userId = account.$jazz.id + let result = await registerNotificationSettingsWithServer( worker, notificationSettingsId, - user.id, + userId, ) if (!result.ok) { diff --git a/src/shared/schema/server.ts b/src/shared/schema/server.ts index 1f1b83f5..ccd40768 100644 --- a/src/shared/schema/server.ts +++ b/src/shared/schema/server.ts @@ -17,20 +17,21 @@ export let ServerAccount = co root: ServerAccountRoot, }) .withMigration(async account => { - let { root } = await account.$jazz.ensureLoaded({ - resolve: { root: { notificationSettingsRefs: { $each: true } } }, - }) - - if (root === undefined) { + if (!account.$jazz.has("root")) { let newRoot = ServerAccountRoot.create( { notificationSettingsRefs: [] }, { owner: account }, ) account.$jazz.set("root", newRoot) - } else if (root.notificationSettingsRefs === undefined) { - root.$jazz.set( - "notificationSettingsRefs", - co.list(NotificationSettingsRef).create([], { owner: account }), - ) + } else { + let { root } = await account.$jazz.ensureLoaded({ + resolve: { root: { notificationSettingsRefs: { $each: true } } }, + }) + if (root.notificationSettingsRefs === undefined) { + root.$jazz.set( + "notificationSettingsRefs", + co.list(NotificationSettingsRef).create([], { owner: account }), + ) + } } }) From f70ed20ceb40c5dba6e4da3153545f1bc097b8b1 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 30 Jan 2026 22:09:49 +0100 Subject: [PATCH 15/24] fix: tests --- src/server/features/push-register-logic.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/features/push-register-logic.ts b/src/server/features/push-register-logic.ts index 9a023c5d..fcdbdc69 100644 --- a/src/server/features/push-register-logic.ts +++ b/src/server/features/push-register-logic.ts @@ -45,9 +45,9 @@ async function registerNotificationSettingsWithServer( let refs = root.root.notificationSettingsRefs! - let existingRef = refs - .values() - .find(ref => ref?.notificationSettings?.$jazz.id === notificationSettingsId) + let existingRef = Array.from(refs.values()).find( + ref => ref?.notificationSettings?.$jazz.id === notificationSettingsId, + ) if (existingRef) { existingRef.$jazz.set("lastSyncedAt", new Date()) From 7a6256b1b9813273b576528c133b188de3c7d942 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Sat, 31 Jan 2026 13:32:45 +0100 Subject: [PATCH 16/24] chore: test push notification logic, harmonize logs --- ATP.md | 107 ----------------------------- src/server/features/push-cron.ts | 12 ++-- src/server/features/push-shared.ts | 6 +- 3 files changed, 5 insertions(+), 120 deletions(-) delete mode 100644 ATP.md diff --git a/ATP.md b/ATP.md deleted file mode 100644 index ab438692..00000000 --- a/ATP.md +++ /dev/null @@ -1,107 +0,0 @@ -# ATP: Push Notification Migration - -## Prerequisites - -- Device with existing push notifications enabled -- Device/browser for new user signup -- Server deployed with new code -- Ability to trigger cron job (`/push/deliver-notifications`) -- Jazz dev tools access -- `CRON_SECRET` from `.env` - -## Trigger Cron Job - -```bash -curl -X GET http://localhost:4321/api/push/deliver-notifications \ - -H "Authorization: Bearer $CRON_SECRET" -``` - -For local testing: - -```bash -curl -X GET http://localhost:4321/api/push/deliver-notifications \ - -H "Authorization: Bearer $CRON_SECRET" -``` - ---- - -## ATP 1: Existing User Migration - -**Goal:** Verify existing users migrate to group-owned settings and continue receiving notifications - -### Pre-flight - -1. Open Jazz dev tools as existing user with push enabled -2. Note `notificationSettings` owner (should be Account, not Group) - -### Steps - -| Step | Action | Expected | -| ---- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| 1 | Open app | App loads | -| 2 | Check browser console | `[Notifications] Migrating to shareable group` β†’ `Migration complete` β†’ `Registration successful` | -| 3 | Check Jazz dev tools | `notificationSettings` owner is now Group with server as member | -| 4 | Check `root.notificationSettingsRefs` on server account | Entry exists with your `userId`, recent `lastSyncedAt`, link to your settings | -| 5 | Verify notification settings UI | All previously registered devices still visible | -| 6 | Trigger cron job | User appears in results, `success: true` | -| 7 | Receive push notification | Notification arrives on device | - -### Failure indicators - -- Console shows `Migration failed` or `Registration failed` -- Devices disappeared from settings -- Server refs list empty or missing user -- Cron returns no results or `success: false` - ---- - -## ATP 2: New User Push Setup - -**Goal:** Verify new users can enable push and receive notifications - -| Step | Action | Expected | -| ---- | ----------------------------------------- | ------------------------------------------------ | -| 1 | Sign up as new user | Account created | -| 2 | Go to Settings β†’ Notifications | Page loads | -| 3 | Enable push notifications, add device | Device added, success toast | -| 4 | Check console | `Registration successful` (no migration message) | -| 5 | Check server's `notificationSettingsRefs` | New user entry exists | -| 6 | Trigger cron job | New user in results | -| 7 | Receive push notification | Notification arrives | - ---- - -## ATP 3: Re-registration on App Open - -**Goal:** Verify `latestReminderDueDate` updates on each app open - -| Step | Action | Expected | -| ---- | --------------------------------------------------------------------- | -------------------------------------------------------------- | -| 1 | As existing user, create reminder for future date (e.g., 2 weeks out) | Reminder created | -| 2 | Close and reopen app | App loads | -| 3 | Check console | `Registration successful` | -| 4 | Check `notificationSettings` in Jazz dev tools | `latestReminderDueDate` matches furthest reminder (YYYY-MM-DD) | -| 5 | Check server ref | `lastSyncedAt` updated to recent time | - ---- - -## ATP 4: Stale Ref Cleanup - -**Goal:** Verify inactive users are cleaned up from server refs - -| Step | Action | Expected | -| ---- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| 1 | Register a test user | User in server refs | -| 2 | In Jazz dev tools, manually set `lastSyncedAt` to 31+ days ago | - | -| 3 | Ensure user has no future reminders (or set `latestReminderDueDate` to past date) | - | -| 4 | Trigger cron | Console shows `Marking stale ref for removal`, user removed from refs | -| 5 | Open app as that user | Re-registers, back in refs | - ---- - -## Verification Checklist - -- [ ] ATP 1: Existing user migration -- [ ] ATP 2: New user setup -- [ ] ATP 3: Re-registration updates -- [ ] ATP 4: Stale cleanup diff --git a/src/server/features/push-cron.ts b/src/server/features/push-cron.ts index 8ca84d41..1624132d 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -62,7 +62,7 @@ let cronDeliveryApp = new Hono().get( }) if (!serverAccount || !serverAccount.root) { - console.log("πŸ”” Missing server account or root, aborting cron") + console.log("⚠️ Missing server account or root, aborting cron") return c.json({ message: "Missing server account or root", results: [], @@ -86,7 +86,7 @@ let cronDeliveryApp = new Hono().get( let notificationSettings = ref.notificationSettings if (!notificationSettings?.$isLoaded) { - console.log(`❌ User ${ref.userId}: Settings not loaded`) + console.log(`⚠️ User ${ref.userId}: Settings not loaded, skipping`) continue } @@ -202,15 +202,11 @@ async function processNotificationRef( if (enabledDevices.length === 0) { console.log(`βœ… User ${userId}: No enabled devices`) markNotificationSettingsAsDelivered(notificationSettings, currentUtc) - console.log( - `βœ… User ${userId}: Marked as delivered (skipped - no action needed)`, - ) + console.log(`βœ… User ${userId}: Marked as delivered (no devices to notify)`) return { status: "skipped", reason: "No enabled devices" } } - console.log( - `βœ… User ${userId}: Ready to send wake notification to ${enabledDevices.length} devices`, - ) + console.log(`πŸ“€ User ${userId}: Notifying ${enabledDevices.length} device(s)`) // Create localized payload let payload = createLocalizedNotificationPayload(userId, notificationSettings) diff --git a/src/server/features/push-shared.ts b/src/server/features/push-shared.ts index 9be3ba72..32f6c202 100644 --- a/src/server/features/push-shared.ts +++ b/src/server/features/push-shared.ts @@ -81,11 +81,7 @@ async function sendNotificationToDevice( device: PushDevice, payload: NotificationPayload, ): Promise { - console.log("[Push] Sending to endpoint:", device.endpoint.slice(-20)) - console.log( - "[Push] Using VAPID public key:", - PUBLIC_VAPID_KEY.slice(0, 20) + "...", - ) + console.log(`πŸ“€ Sending to: ${device.endpoint.slice(-20)}`) let result = await tryCatch( webpush.sendNotification( From 359ef7870087f31a87439522276f3f7d166e13ac Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Mon, 2 Feb 2026 09:50:09 +0100 Subject: [PATCH 17/24] chore: final review and fixes --- src/app/features/notification-settings.tsx | 4 ++- src/app/lib/notification-registration.ts | 14 +++++++--- src/app/routes/_app.assistant.tsx | 9 +++++++ src/server/features/chat-messages.ts | 31 ++++++++++++++-------- src/server/features/push-register-logic.ts | 15 +++++------ src/server/features/push-register.test.ts | 5 +--- src/server/lib/utils.ts | 30 +++++++++++++++------ src/shared/intl/messages.assistant.ts | 6 +++++ src/shared/schema/server.ts | 7 ++--- 9 files changed, 80 insertions(+), 41 deletions(-) diff --git a/src/app/features/notification-settings.tsx b/src/app/features/notification-settings.tsx index 8cbf86c3..63c9b900 100644 --- a/src/app/features/notification-settings.tsx +++ b/src/app/features/notification-settings.tsx @@ -1,7 +1,7 @@ import { de as dfnsDe } from "date-fns/locale" import { formatDistanceToNow } from "date-fns" import { useIsAuthenticated } from "jazz-tools/react" -import { co } from "jazz-tools" +import { co, generateAuthToken } from "jazz-tools" import { PushDevice, UserAccount } from "#shared/schema/user" import { Alert, AlertTitle, AlertDescription } from "#shared/ui/alert" import { ExclamationTriangle } from "react-bootstrap-icons" @@ -976,8 +976,10 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) { // Trigger registration with server after adding device if (notifications?.$jazz.id) { + let authToken = generateAuthToken(me) let registrationResult = await triggerNotificationRegistration( notifications.$jazz.id, + authToken, ) if (!registrationResult.ok) { toast.warning(t("notifications.toast.registrationFailed")) diff --git a/src/app/lib/notification-registration.ts b/src/app/lib/notification-registration.ts index 41fe6019..1c3d9d4c 100644 --- a/src/app/lib/notification-registration.ts +++ b/src/app/lib/notification-registration.ts @@ -7,11 +7,19 @@ type RegistrationResult = { ok: true } | { ok: false; error: string } async function triggerNotificationRegistration( notificationSettingsId: string, + authToken: string, ): Promise { let result = await tryCatch( - apiClient.push.register.$post({ - json: { notificationSettingsId }, - }), + apiClient.push.register.$post( + { + json: { notificationSettingsId }, + }, + { + headers: { + Authorization: `Jazz ${authToken}`, + }, + }, + ), ) if (!result.ok) { diff --git a/src/app/routes/_app.assistant.tsx b/src/app/routes/_app.assistant.tsx index b1770ed6..bb798474 100644 --- a/src/app/routes/_app.assistant.tsx +++ b/src/app/routes/_app.assistant.tsx @@ -447,6 +447,8 @@ function SendingError({ error }: { error: Error | null }) { ) : isRequestTooLargeError(error) ? ( + ) : isWorkerTimeoutError(error) ? ( + ) : isEmptyMessagesError(error) ? ( ) : ( @@ -465,6 +467,8 @@ function SendingError({ error }: { error: Error | null }) { ) : isRequestTooLargeError(error) ? ( + ) : isWorkerTimeoutError(error) ? ( + ) : isEmptyMessagesError(error) ? ( ) : ( @@ -847,6 +851,11 @@ function isEmptyMessagesError(error: unknown): boolean { return payload?.code === "empty-messages" } +function isWorkerTimeoutError(error: unknown): boolean { + let payload = extractErrorPayload(error) + return payload?.code === "worker-timeout" +} + type ErrorPayload = { code?: string error?: string diff --git a/src/server/features/chat-messages.ts b/src/server/features/chat-messages.ts index 5c9a0a54..88dbd4a6 100644 --- a/src/server/features/chat-messages.ts +++ b/src/server/features/chat-messages.ts @@ -25,7 +25,11 @@ import { checkUsageLimits, updateUsage, } from "../lib/chat-usage" -import { initUserWorker, initServerWorker } from "#server/lib/utils" +import { + initUserWorker, + initServerWorker, + WorkerTimeoutError, +} from "#server/lib/utils" import type { User } from "@clerk/backend" import { co, type Loaded, type ResolveQuery } from "jazz-tools" import { UserAccount, Assistant } from "#shared/schema/user" @@ -49,15 +53,22 @@ let chatMessagesApp = new Hono() let logger = (s: string) => logStep(s, { requestStartTime, userId: user.id }) - let [ - { worker: userWorker_ }, - { worker: serverWorker }, // - ] = await Promise.all([ - initUserWorker(user), - initServerWorker(), // - ]) + let userWorkerResult: Awaited> + let serverWorkerResult: Awaited> + try { + ;[userWorkerResult, serverWorkerResult] = await Promise.all([ + initUserWorker(user), + initServerWorker(), + ]) + } catch (error) { + if (error instanceof WorkerTimeoutError) { + return c.json({ error: error.message, code: "worker-timeout" }, 504) + } + throw error + } - logger("Workers initialized") + let userWorker_ = userWorkerResult.worker + let serverWorker = serverWorkerResult.worker let [ userWorker, @@ -93,11 +104,9 @@ let chatMessagesApp = new Hono() let { overflow } = checkInputSize(modelMessages) if (overflow !== 0) { let msg = `Messages size exceed limit by ${overflow}` - logger(msg) return c.json({ error: msg, code: "request-too-large" }, 413) } - logger("Alright. Starting streaming generation.") return streamSSE(c, async stream => { // we need to tell the client we are starting now // so they know that until now everything was correct diff --git a/src/server/features/push-register-logic.ts b/src/server/features/push-register-logic.ts index fcdbdc69..2110babd 100644 --- a/src/server/features/push-register-logic.ts +++ b/src/server/features/push-register-logic.ts @@ -39,7 +39,7 @@ async function registerNotificationSettingsWithServer( if (!root.root.notificationSettingsRefs) { root.root.$jazz.set( "notificationSettingsRefs", - co.list(NotificationSettingsRef).create([], { owner: worker }), + co.list(NotificationSettingsRef).create([]), ) } @@ -52,14 +52,11 @@ async function registerNotificationSettingsWithServer( if (existingRef) { existingRef.$jazz.set("lastSyncedAt", new Date()) } else { - let newRef = NotificationSettingsRef.create( - { - notificationSettings, - userId, - lastSyncedAt: new Date(), - }, - { owner: worker }, - ) + let newRef = NotificationSettingsRef.create({ + notificationSettings, + userId, + lastSyncedAt: new Date(), + }) refs.$jazz.push(newRef) } diff --git a/src/server/features/push-register.test.ts b/src/server/features/push-register.test.ts index 8d0bdde0..9af31976 100644 --- a/src/server/features/push-register.test.ts +++ b/src/server/features/push-register.test.ts @@ -23,10 +23,7 @@ describe("registerNotificationSettingsWithServer", () => { // Initialize server root manually since test accounts may not run migrations if (!serverAccount.root) { - serverAccount.$jazz.set( - "root", - ServerAccountRoot.create({}, { owner: serverAccount }), - ) + serverAccount.$jazz.set("root", ServerAccountRoot.create({})) } userAccount = await createJazzTestAccount({ diff --git a/src/server/lib/utils.ts b/src/server/lib/utils.ts index 2dc9f137..67a1ac54 100644 --- a/src/server/lib/utils.ts +++ b/src/server/lib/utils.ts @@ -9,7 +9,14 @@ import { JAZZ_WORKER_SECRET } from "astro:env/server" import { UserAccount } from "#shared/schema/user" import { ServerAccount } from "#shared/schema/server" -export { initUserWorker, initServerWorker } +export { initUserWorker, initServerWorker, WorkerTimeoutError } + +class WorkerTimeoutError extends Error { + constructor() { + super("Worker initialization timed out") + this.name = "WorkerTimeoutError" + } +} async function initUserWorker(user: { unsafeMetadata: Record @@ -17,13 +24,20 @@ async function initUserWorker(user: { let jazzAccountId = user.unsafeMetadata.jazzAccountID as string let jazzAccountSecret = user.unsafeMetadata.jazzAccountSecret as string - let workerResult = await startWorker({ - AccountSchema: UserAccount, - syncServer: PUBLIC_JAZZ_SYNC_SERVER, - accountID: jazzAccountId, - accountSecret: jazzAccountSecret, - skipInboxLoad: true, - }) + let timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new WorkerTimeoutError()), 30000), + ) + + let workerResult = await Promise.race([ + startWorker({ + AccountSchema: UserAccount, + syncServer: PUBLIC_JAZZ_SYNC_SERVER, + accountID: jazzAccountId, + accountSecret: jazzAccountSecret, + skipInboxLoad: true, + }), + timeoutPromise, + ]) return { worker: workerResult.worker } } diff --git a/src/shared/intl/messages.assistant.ts b/src/shared/intl/messages.assistant.ts index e0a86fd2..3d694110 100644 --- a/src/shared/intl/messages.assistant.ts +++ b/src/shared/intl/messages.assistant.ts @@ -48,6 +48,9 @@ const baseAssistantMessages = messages({ "assistant.requestTooLarge.title": "Message too long", "assistant.requestTooLarge.description": "Your message exceeds the size limit. Try a shorter message or clear the chat to start fresh.", + "assistant.workerTimeout.title": "Sync timeout", + "assistant.workerTimeout.description": + "Couldn't sync your data in time. Please try again.", "assistant.usageLimit.title": "Usage limit reached", "assistant.usageLimit.description": "You've reached your usage limit. Check your settings to see when limits reset.", @@ -279,6 +282,9 @@ const deAssistantMessages = translate(baseAssistantMessages, { "assistant.requestTooLarge.title": "Nachricht zu lang", "assistant.requestTooLarge.description": "Deine Nachricht ΓΌberschreitet das Grâßenlimit. Versuche eine kΓΌrzere Nachricht oder leere den Chat fΓΌr einen Neustart.", + "assistant.workerTimeout.title": "Sync-Timeout", + "assistant.workerTimeout.description": + "Daten konnten nicht rechtzeitig synchronisiert werden. Bitte versuche es erneut.", "assistant.usageLimit.title": "Nutzungsgrenze erreicht", "assistant.usageLimit.description": "Du hast deine Nutzungsgrenze erreicht. Schaue in den Einstellungen wann die Grenzen zurΓΌckgesetzt werden.", diff --git a/src/shared/schema/server.ts b/src/shared/schema/server.ts index ccd40768..c0395639 100644 --- a/src/shared/schema/server.ts +++ b/src/shared/schema/server.ts @@ -18,10 +18,7 @@ export let ServerAccount = co }) .withMigration(async account => { if (!account.$jazz.has("root")) { - let newRoot = ServerAccountRoot.create( - { notificationSettingsRefs: [] }, - { owner: account }, - ) + let newRoot = ServerAccountRoot.create({ notificationSettingsRefs: [] }) account.$jazz.set("root", newRoot) } else { let { root } = await account.$jazz.ensureLoaded({ @@ -30,7 +27,7 @@ export let ServerAccount = co if (root.notificationSettingsRefs === undefined) { root.$jazz.set( "notificationSettingsRefs", - co.list(NotificationSettingsRef).create([], { owner: account }), + co.list(NotificationSettingsRef).create([]), ) } } From 70aa8ebbe57c84a40fe05d3d47200d4cd1fdd637 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Mon, 2 Feb 2026 11:20:58 +0100 Subject: [PATCH 18/24] chore: no build in CI --- .github/workflows/ci.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 055264f5..1bb8051a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,20 +25,3 @@ jobs: - name: Run tests run: bun run test:run - - build: - runs-on: ubuntu-latest - needs: check - - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build - run: bun run build:prod From 4f107e8804c4341825cc809e3d07c766b0a2f367 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Wed, 4 Feb 2026 20:35:55 +0100 Subject: [PATCH 19/24] chore: improve implementation --- src/app/hooks/use-register-notifications.ts | 25 ++-------- .../notification-settings-migration.test.ts | 21 +++++---- .../lib/notification-settings-migration.ts | 32 ++++++++----- src/server/features/chat-messages.ts | 12 ++--- src/server/features/push-cron.ts | 37 +++++---------- src/server/features/push-register-logic.ts | 15 +++--- src/server/features/push-register.test.ts | 17 ++++--- src/server/features/push-register.ts | 4 +- src/server/lib/chat-usage.ts | 12 ++--- src/server/lib/utils.ts | 46 +++++++++++++------ src/shared/schema/server.ts | 14 ++++-- 11 files changed, 123 insertions(+), 112 deletions(-) diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts index 2c51d421..4f7431db 100644 --- a/src/app/hooks/use-register-notifications.ts +++ b/src/app/hooks/use-register-notifications.ts @@ -8,12 +8,12 @@ import { } from "jazz-tools" import { PUBLIC_JAZZ_WORKER_ACCOUNT } from "astro:env/client" import { UserAccount } from "#shared/schema/user" -import { apiClient } from "#app/lib/api-client" import { tryCatch } from "#shared/lib/trycatch" import { migrateNotificationSettings, addServerToGroup, } from "#app/lib/notification-settings-migration" +import { triggerNotificationRegistration } from "#app/lib/notification-registration" import { findLatestFutureDate } from "#app/lib/reminder-utils" export { useRegisterNotifications } @@ -115,17 +115,9 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { // Register with server using Jazz auth let authToken = generateAuthToken(me) - let registerResult = await tryCatch( - apiClient.push.register.$post( - { - json: { notificationSettingsId: notificationSettings.$jazz.id }, - }, - { - headers: { - Authorization: `Jazz ${authToken}`, - }, - }, - ), + let registerResult = await triggerNotificationRegistration( + notificationSettings.$jazz.id, + authToken, ) if (!registerResult.ok) { @@ -133,15 +125,6 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { return } - if (!registerResult.data.ok) { - let errorData = await tryCatch(registerResult.data.json()) - console.error( - "[Notifications] Registration error:", - errorData.ok ? errorData.data : "Unknown error", - ) - return - } - console.log("[Notifications] Registration successful") } diff --git a/src/app/lib/notification-settings-migration.test.ts b/src/app/lib/notification-settings-migration.test.ts index 45870392..f20961c3 100644 --- a/src/app/lib/notification-settings-migration.test.ts +++ b/src/app/lib/notification-settings-migration.test.ts @@ -1,16 +1,17 @@ import { describe, test, expect } from "vitest" import { copyNotificationSettingsData, - type NotificationSettingsData, + type NotificationSettingsInput, } from "./notification-settings-migration" describe("copyNotificationSettingsData", () => { test("copies all settings fields", () => { - let mockSettings: NotificationSettingsData = { + let mockSettings: NotificationSettingsInput = { + version: 1, timezone: "America/New_York", notificationTime: "09:00", lastDeliveredAt: new Date("2025-01-15T14:00:00Z"), - language: "de" as const, + language: "de", latestReminderDueDate: "2025-02-01", pushDevices: [ { @@ -34,7 +35,8 @@ describe("copyNotificationSettingsData", () => { }) test("uses rootLanguage when settings language is undefined", () => { - let mockSettings: NotificationSettingsData = { + let mockSettings: NotificationSettingsInput = { + version: 1, timezone: "UTC", notificationTime: "12:00", pushDevices: [], @@ -46,10 +48,11 @@ describe("copyNotificationSettingsData", () => { }) test("preserves settings language over rootLanguage", () => { - let mockSettings: NotificationSettingsData = { + let mockSettings: NotificationSettingsInput = { + version: 1, timezone: "UTC", notificationTime: "12:00", - language: "en" as const, + language: "en", pushDevices: [], } @@ -59,7 +62,8 @@ describe("copyNotificationSettingsData", () => { }) test("copies multiple devices", () => { - let mockSettings: NotificationSettingsData = { + let mockSettings: NotificationSettingsInput = { + version: 1, timezone: "UTC", pushDevices: [ { @@ -85,7 +89,8 @@ describe("copyNotificationSettingsData", () => { }) test("handles undefined optional fields", () => { - let mockSettings: NotificationSettingsData = { + let mockSettings: NotificationSettingsInput = { + version: 1, pushDevices: [], } diff --git a/src/app/lib/notification-settings-migration.ts b/src/app/lib/notification-settings-migration.ts index 7330b809..1588abe1 100644 --- a/src/app/lib/notification-settings-migration.ts +++ b/src/app/lib/notification-settings-migration.ts @@ -7,9 +7,10 @@ export { addServerToGroup, copyNotificationSettingsData, } -export type { MigrationContext, NotificationSettingsData } +export type { MigrationContext, NotificationSettingsInput } -type NotificationSettingsData = { +type NotificationSettingsInput = { + version: 1 timezone?: string notificationTime?: string lastDeliveredAt?: Date @@ -28,11 +29,26 @@ type MigrationContext = { rootLanguage?: "de" | "en" } +type NotificationSettingsLike = { + timezone?: string + notificationTime?: string + lastDeliveredAt?: Date + language?: "de" | "en" + latestReminderDueDate?: string + pushDevices: Array<{ + isEnabled: boolean + deviceName: string + endpoint: string + keys: { p256dh: string; auth: string } + }> +} + function copyNotificationSettingsData( - settings: NotificationSettingsData, + settings: NotificationSettingsLike, rootLanguage?: "de" | "en", -): NotificationSettingsData { +): NotificationSettingsInput { return { + version: 1, timezone: settings.timezone, notificationTime: settings.notificationTime, lastDeliveredAt: settings.lastDeliveredAt, @@ -64,13 +80,7 @@ async function migrateNotificationSettings( context.rootLanguage, ) - let newSettings = NotificationSettings.create( - { - version: 1, - ...settingsData, - }, - { owner: group }, - ) + let newSettings = NotificationSettings.create(settingsData, { owner: group }) // Delete old settings to avoid orphaned data oldSettings.$jazz.raw.core.deleteCoValue() diff --git a/src/server/features/chat-messages.ts b/src/server/features/chat-messages.ts index 88dbd4a6..468d6c33 100644 --- a/src/server/features/chat-messages.ts +++ b/src/server/features/chat-messages.ts @@ -27,8 +27,9 @@ import { } from "../lib/chat-usage" import { initUserWorker, - initServerWorker, + getServerWorker, WorkerTimeoutError, + type ServerWorker, } from "#server/lib/utils" import type { User } from "@clerk/backend" import { co, type Loaded, type ResolveQuery } from "jazz-tools" @@ -54,12 +55,10 @@ let chatMessagesApp = new Hono() logStep(s, { requestStartTime, userId: user.id }) let userWorkerResult: Awaited> - let serverWorkerResult: Awaited> + let serverWorker: ServerWorker try { - ;[userWorkerResult, serverWorkerResult] = await Promise.all([ - initUserWorker(user), - initServerWorker(), - ]) + userWorkerResult = await initUserWorker(user) + serverWorker = await getServerWorker() } catch (error) { if (error instanceof WorkerTimeoutError) { return c.json({ error: error.message, code: "worker-timeout" }, 504) @@ -68,7 +67,6 @@ let chatMessagesApp = new Hono() } let userWorker_ = userWorkerResult.worker - let serverWorker = serverWorkerResult.worker let [ userWorker, diff --git a/src/server/features/push-cron.ts b/src/server/features/push-cron.ts index 1624132d..e76a5a0e 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -1,5 +1,5 @@ import { CRON_SECRET } from "astro:env/server" -import { initServerWorker } from "../lib/utils" +import { getServerWorker } from "../lib/utils" import { toZonedTime, format } from "date-fns-tz" import { Hono } from "hono" @@ -13,7 +13,7 @@ import { } from "./push-shared" import type { NotificationPayload } from "./push-shared" import { NotificationSettings } from "#shared/schema/user" -import { ServerAccount } from "#shared/schema/server" +import { ServerAccount, NotificationSettingsRef } from "#shared/schema/server" import { baseServerMessages, deServerMessages, @@ -35,13 +35,7 @@ let serverRefsQuery = { }, } as const satisfies ResolveQuery -type LoadedServerAccount = co.loaded< - typeof ServerAccount, - typeof serverRefsQuery -> -type LoadedRef = NonNullable< - NonNullable[number] -> +type LoadedRef = co.loaded type LoadedNotificationSettings = co.loaded let cronDeliveryApp = new Hono().get( @@ -56,7 +50,7 @@ let cronDeliveryApp = new Hono().get( let processingPromises: Promise[] = [] let maxConcurrentUsers = 50 - let { worker } = await initServerWorker() + let worker = await getServerWorker() let serverAccount = await worker.$jazz.ensureLoaded({ resolve: serverRefsQuery, }) @@ -78,10 +72,10 @@ let cronDeliveryApp = new Hono().get( }) } - let staleRefIds: string[] = [] + let staleRefKeys: string[] = [] let currentUtc = new Date() - for (let ref of refs.values()) { + for (let [notificationSettingsId, ref] of Object.entries(refs)) { if (!ref) continue let notificationSettings = ref.notificationSettings @@ -94,7 +88,7 @@ let cronDeliveryApp = new Hono().get( if ( isStaleRef(ref.lastSyncedAt, notificationSettings.latestReminderDueDate) ) { - staleRefIds.push(ref.$jazz.id) + staleRefKeys.push(notificationSettingsId) console.log(`πŸ—‘οΈ Marking stale ref for removal: ${ref.userId}`) continue } @@ -127,20 +121,13 @@ let cronDeliveryApp = new Hono().get( await Promise.allSettled(processingPromises) - // Remove stale refs by ID (avoids index shifting issues) - let refIndexMap = new Map( - [...refs.values()].map((r, i) => [r?.$jazz.id, i]), - ) - let sortedStaleIndices = staleRefIds - .map(id => refIndexMap.get(id)) - .filter((i): i is number => i !== undefined) - .sort((a, b) => b - a) - for (let index of sortedStaleIndices) { - refs.$jazz.splice(index, 1) + // Remove stale refs by key + for (let key of staleRefKeys) { + refs.$jazz.delete(key) } - if (staleRefIds.length > 0) { - console.log(`πŸ—‘οΈ Removed ${staleRefIds.length} stale refs`) + if (staleRefKeys.length > 0) { + console.log(`πŸ—‘οΈ Removed ${staleRefKeys.length} stale refs`) } // Single sync at end for all mutations diff --git a/src/server/features/push-register-logic.ts b/src/server/features/push-register-logic.ts index 2110babd..b29661a9 100644 --- a/src/server/features/push-register-logic.ts +++ b/src/server/features/push-register-logic.ts @@ -1,6 +1,10 @@ import { co, type ID } from "jazz-tools" import { NotificationSettings } from "#shared/schema/user" -import { NotificationSettingsRef, ServerAccount } from "#shared/schema/server" +import { + NotificationSettingsRef, + NotificationSettingsRefsRecord, + ServerAccount, +} from "#shared/schema/server" import { tryCatch } from "#shared/lib/trycatch" export { registerNotificationSettingsWithServer } @@ -39,15 +43,14 @@ async function registerNotificationSettingsWithServer( if (!root.root.notificationSettingsRefs) { root.root.$jazz.set( "notificationSettingsRefs", - co.list(NotificationSettingsRef).create([]), + NotificationSettingsRefsRecord.create({}), ) } let refs = root.root.notificationSettingsRefs! - let existingRef = Array.from(refs.values()).find( - ref => ref?.notificationSettings?.$jazz.id === notificationSettingsId, - ) + // Keyed by notificationSettingsId - upsert is naturally idempotent + let existingRef = refs[notificationSettingsId] if (existingRef) { existingRef.$jazz.set("lastSyncedAt", new Date()) @@ -57,7 +60,7 @@ async function registerNotificationSettingsWithServer( userId, lastSyncedAt: new Date(), }) - refs.$jazz.push(newRef) + refs.$jazz.set(notificationSettingsId, newRef) } let syncResult = await tryCatch(worker.$jazz.waitForSync()) diff --git a/src/server/features/push-register.test.ts b/src/server/features/push-register.test.ts index 9af31976..4b898368 100644 --- a/src/server/features/push-register.test.ts +++ b/src/server/features/push-register.test.ts @@ -64,8 +64,10 @@ describe("registerNotificationSettingsWithServer", () => { resolve: { root: { notificationSettingsRefs: { $each: true } } }, }) let refs = loadedServer.root.notificationSettingsRefs - expect(refs?.length).toBe(1) - expect(refs?.[0]?.userId).toBe("test-user-123") + let refEntries = refs ? Object.entries(refs) : [] + expect(refEntries.length).toBe(1) + expect(refEntries[0]?.[0]).toBe(notificationSettings.$jazz.id) + expect(refEntries[0]?.[1]?.userId).toBe("test-user-123") }) test("updates lastSyncedAt for existing registration", async () => { @@ -99,8 +101,8 @@ describe("registerNotificationSettingsWithServer", () => { let loadedServer = await serverAccount.$jazz.ensureLoaded({ resolve: { root: { notificationSettingsRefs: { $each: true } } }, }) - let firstSyncTime = - loadedServer.root.notificationSettingsRefs?.[0]?.lastSyncedAt + let refs = loadedServer.root.notificationSettingsRefs + let firstSyncTime = refs?.[notificationSettings.$jazz.id]?.lastSyncedAt await new Promise(r => setTimeout(r, 10)) @@ -114,10 +116,11 @@ describe("registerNotificationSettingsWithServer", () => { resolve: { root: { notificationSettingsRefs: { $each: true } } }, }) - expect(loadedServer.root.notificationSettingsRefs?.length).toBe(1) + refs = loadedServer.root.notificationSettingsRefs + let refEntries = refs ? Object.entries(refs) : [] + expect(refEntries.length).toBe(1) - let secondSyncTime = - loadedServer.root.notificationSettingsRefs?.[0]?.lastSyncedAt + let secondSyncTime = refs?.[notificationSettings.$jazz.id]?.lastSyncedAt expect(secondSyncTime?.getTime()).toBeGreaterThan(firstSyncTime!.getTime()) }) diff --git a/src/server/features/push-register.ts b/src/server/features/push-register.ts index 07254b5e..8c598ecc 100644 --- a/src/server/features/push-register.ts +++ b/src/server/features/push-register.ts @@ -2,7 +2,7 @@ import { zValidator } from "@hono/zod-validator" import { Hono } from "hono" import { z } from "zod" import { authenticateRequest } from "jazz-tools" -import { initServerWorker } from "../lib/utils" +import { getServerWorker } from "../lib/utils" import { registerNotificationSettingsWithServer } from "./push-register-logic" export { pushRegisterApp } @@ -23,7 +23,7 @@ let pushRegisterApp = new Hono().post( async c => { let { notificationSettingsId } = c.req.valid("json") - let { worker } = await initServerWorker() + let worker = await getServerWorker() let { account, error } = await authenticateRequest(c.req.raw, { loadAs: worker, diff --git a/src/server/lib/chat-usage.ts b/src/server/lib/chat-usage.ts index 63920a84..cc88aa42 100644 --- a/src/server/lib/chat-usage.ts +++ b/src/server/lib/chat-usage.ts @@ -11,7 +11,7 @@ import { import { addDays, isPast } from "date-fns" import { co, Group, type ResolveQuery } from "jazz-tools" import { clerkClient } from "./auth-client" -import { initServerWorker, initUserWorker } from "./utils" +import { getServerWorker, initUserWorker } from "./utils" export { checkInputSize, checkUsageLimits, updateUsage } export type { ModelMessages } @@ -56,7 +56,7 @@ async function updateUsage( worker: UserWorker, usage: UsageUpdatePayload, ): Promise { - let serverWorkerResult = await tryCatch(initServerWorker()) + let serverWorkerResult = await tryCatch(getServerWorker()) if (!serverWorkerResult.ok) { console.error( `[Usage] ${user.id} | Failed to init server worker for update`, @@ -65,11 +65,7 @@ async function updateUsage( throw new Error("Failed to init server worker") } - let context = await ensureUsageContext( - user, - worker, - serverWorkerResult.data.worker, - ) + let context = await ensureUsageContext(user, worker, serverWorkerResult.data) let updateResult = await tryCatch( applyUsageUpdate(context.usageTracking, usage), @@ -118,7 +114,7 @@ type UsageLimitResult = { } type UserWorker = Awaited>["worker"] -type ServerWorker = Awaited>["worker"] +import type { ServerWorker } from "./utils" type UsageContext = { worker: UserWorker diff --git a/src/server/lib/utils.ts b/src/server/lib/utils.ts index 67a1ac54..0000b335 100644 --- a/src/server/lib/utils.ts +++ b/src/server/lib/utils.ts @@ -1,4 +1,5 @@ import { startWorker } from "jazz-tools/worker" +import { co } from "jazz-tools" import { PUBLIC_JAZZ_SYNC_SERVER, @@ -9,7 +10,8 @@ import { JAZZ_WORKER_SECRET } from "astro:env/server" import { UserAccount } from "#shared/schema/user" import { ServerAccount } from "#shared/schema/server" -export { initUserWorker, initServerWorker, WorkerTimeoutError } +export { initUserWorker, getServerWorker, WorkerTimeoutError } +export type { ServerWorker } class WorkerTimeoutError extends Error { constructor() { @@ -18,6 +20,35 @@ class WorkerTimeoutError extends Error { } } +type ServerWorker = co.loaded + +let cachedServerWorker: ServerWorker | null = null +let serverWorkerPromise: Promise | null = null + +async function getServerWorker(): Promise { + if (cachedServerWorker) { + return cachedServerWorker + } + + if (serverWorkerPromise) { + return serverWorkerPromise + } + + serverWorkerPromise = startWorker({ + AccountSchema: ServerAccount, + syncServer: PUBLIC_JAZZ_SYNC_SERVER, + accountID: PUBLIC_JAZZ_WORKER_ACCOUNT, + accountSecret: JAZZ_WORKER_SECRET, + skipInboxLoad: true, + asActiveAccount: false, + }).then(result => { + cachedServerWorker = result.worker + return result.worker + }) + + return serverWorkerPromise +} + async function initUserWorker(user: { unsafeMetadata: Record }) { @@ -41,16 +72,3 @@ async function initUserWorker(user: { return { worker: workerResult.worker } } - -async function initServerWorker() { - let workerResult = await startWorker({ - AccountSchema: ServerAccount, - syncServer: PUBLIC_JAZZ_SYNC_SERVER, - accountID: PUBLIC_JAZZ_WORKER_ACCOUNT, - accountSecret: JAZZ_WORKER_SECRET, - skipInboxLoad: true, - asActiveAccount: false, - }) - - return { worker: workerResult.worker } -} diff --git a/src/shared/schema/server.ts b/src/shared/schema/server.ts index c0395639..a7cb2d35 100644 --- a/src/shared/schema/server.ts +++ b/src/shared/schema/server.ts @@ -7,8 +7,14 @@ export let NotificationSettingsRef = co.map({ lastSyncedAt: z.date(), }) +// Keyed by notificationSettingsId for idempotent upserts (prevents duplicates under concurrent requests) +export let NotificationSettingsRefsRecord = co.record( + z.string(), + NotificationSettingsRef, +) + export let ServerAccountRoot = co.map({ - notificationSettingsRefs: co.list(NotificationSettingsRef).optional(), + notificationSettingsRefs: NotificationSettingsRefsRecord.optional(), }) export let ServerAccount = co @@ -18,7 +24,9 @@ export let ServerAccount = co }) .withMigration(async account => { if (!account.$jazz.has("root")) { - let newRoot = ServerAccountRoot.create({ notificationSettingsRefs: [] }) + let newRoot = ServerAccountRoot.create({ + notificationSettingsRefs: NotificationSettingsRefsRecord.create({}), + }) account.$jazz.set("root", newRoot) } else { let { root } = await account.$jazz.ensureLoaded({ @@ -27,7 +35,7 @@ export let ServerAccount = co if (root.notificationSettingsRefs === undefined) { root.$jazz.set( "notificationSettingsRefs", - co.list(NotificationSettingsRef).create([]), + NotificationSettingsRefsRecord.create({}), ) } } From 2c9d2eb65df81842506e7b10d6d915fb30fd7db2 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Wed, 4 Feb 2026 21:05:50 +0100 Subject: [PATCH 20/24] fix: delete old settings only after new ones are persisted --- src/app/hooks/use-register-notifications.ts | 9 ++++++--- src/app/lib/notification-settings-migration.ts | 12 ++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts index 4f7431db..fd978564 100644 --- a/src/app/hooks/use-register-notifications.ts +++ b/src/app/hooks/use-register-notifications.ts @@ -90,9 +90,12 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { console.error("[Notifications] Migration failed:", migrationResult.error) return } - // Update root to point to new settings - me.root.$jazz.set("notificationSettings", migrationResult.data) - notificationSettings = migrationResult.data + let { newSettings, cleanup } = migrationResult.data + // Update root to point to new settings before cleanup + me.root.$jazz.set("notificationSettings", newSettings) + // Only cleanup old settings after new settings are persisted + cleanup() + notificationSettings = newSettings console.log("[Notifications] Migration complete") } else { // Ensure server worker is a member diff --git a/src/app/lib/notification-settings-migration.ts b/src/app/lib/notification-settings-migration.ts index 1588abe1..110d082d 100644 --- a/src/app/lib/notification-settings-migration.ts +++ b/src/app/lib/notification-settings-migration.ts @@ -70,7 +70,10 @@ async function migrateNotificationSettings( oldSettings: co.loaded, serverAccountId: string, context: MigrationContext, -): Promise> { +): Promise<{ + newSettings: co.loaded + cleanup: () => void +}> { let group = Group.create() await addServerToGroup(group, serverAccountId, context) @@ -82,10 +85,11 @@ async function migrateNotificationSettings( let newSettings = NotificationSettings.create(settingsData, { owner: group }) - // Delete old settings to avoid orphaned data - oldSettings.$jazz.raw.core.deleteCoValue() + let cleanup = () => { + oldSettings.$jazz.raw.core.deleteCoValue() + } - return newSettings + return { newSettings, cleanup } } async function addServerToGroup( From 27573b6c8ed41fb1d315a34f114de88b47e93d63 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Wed, 4 Feb 2026 21:10:28 +0100 Subject: [PATCH 21/24] fix: clear cached worker on rejection to allow retries --- src/server/lib/utils.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/server/lib/utils.ts b/src/server/lib/utils.ts index 0000b335..fceb536d 100644 --- a/src/server/lib/utils.ts +++ b/src/server/lib/utils.ts @@ -41,10 +41,16 @@ async function getServerWorker(): Promise { accountSecret: JAZZ_WORKER_SECRET, skipInboxLoad: true, asActiveAccount: false, - }).then(result => { - cachedServerWorker = result.worker - return result.worker }) + .then(result => { + cachedServerWorker = result.worker + return result.worker + }) + .catch(error => { + serverWorkerPromise = null + cachedServerWorker = null + throw error + }) return serverWorkerPromise } From aaa0018c8e6d2180b9b8a7b0b49c07d02fdd7f62 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Wed, 4 Feb 2026 21:15:01 +0100 Subject: [PATCH 22/24] fix: Add device only after successful server registration --- src/app/features/notification-settings.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/features/notification-settings.tsx b/src/app/features/notification-settings.tsx index 63c9b900..c5cfe5ae 100644 --- a/src/app/features/notification-settings.tsx +++ b/src/app/features/notification-settings.tsx @@ -968,13 +968,12 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) { return } - addPushDevice({ + let deviceData = { deviceName: values.deviceName, endpoint: subscriptionResult.data.endpoint, keys: subscriptionResult.data.keys, - }) + } - // Trigger registration with server after adding device if (notifications?.$jazz.id) { let authToken = generateAuthToken(me) let registrationResult = await triggerNotificationRegistration( @@ -991,6 +990,8 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) { } } + addPushDevice(deviceData) + toast.success(t("notifications.toast.deviceAdded")) setOpen(false) From 69fa7afa042dc176f53e77a340c3254bcc4271ff Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Wed, 4 Feb 2026 22:31:07 +0100 Subject: [PATCH 23/24] fix: more improvements --- src/app/hooks/use-register-notifications.ts | 4 +- .../lib/notification-settings-migration.ts | 30 +++++++++++-- src/server/features/push-cron.ts | 12 ++++- src/server/features/push-register.ts | 12 ++++- src/server/lib/utils.ts | 45 +++++++++++++------ 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/src/app/hooks/use-register-notifications.ts b/src/app/hooks/use-register-notifications.ts index fd978564..7a9ce6b0 100644 --- a/src/app/hooks/use-register-notifications.ts +++ b/src/app/hooks/use-register-notifications.ts @@ -93,8 +93,8 @@ async function registerNotificationSettings(me: LoadedAccount): Promise { let { newSettings, cleanup } = migrationResult.data // Update root to point to new settings before cleanup me.root.$jazz.set("notificationSettings", newSettings) - // Only cleanup old settings after new settings are persisted - cleanup() + // Defer cleanup to next tick so new settings are persisted first + setTimeout(cleanup, 0) notificationSettings = newSettings console.log("[Notifications] Migration complete") } else { diff --git a/src/app/lib/notification-settings-migration.ts b/src/app/lib/notification-settings-migration.ts index 110d082d..e8cc170b 100644 --- a/src/app/lib/notification-settings-migration.ts +++ b/src/app/lib/notification-settings-migration.ts @@ -1,4 +1,4 @@ -import { Account, Group, type co, type ID } from "jazz-tools" +import { Account, Group, type co, type ID, deleteCoValues } from "jazz-tools" import { NotificationSettings } from "#shared/schema/user" import { ServerAccount } from "#shared/schema/server" @@ -72,7 +72,7 @@ async function migrateNotificationSettings( context: MigrationContext, ): Promise<{ newSettings: co.loaded - cleanup: () => void + cleanup: () => Promise }> { let group = Group.create() @@ -85,8 +85,30 @@ async function migrateNotificationSettings( let newSettings = NotificationSettings.create(settingsData, { owner: group }) - let cleanup = () => { - oldSettings.$jazz.raw.core.deleteCoValue() + let cleanup = async () => { + let owner = oldSettings.$jazz.owner + if (owner instanceof Group) { + let hasAdminPermission = owner.members.some( + m => + m.account?.$jazz.id === context.loadAs.$jazz.id && m.role === "admin", + ) + if (!hasAdminPermission) { + console.error( + "[NotificationSettingsMigration] Caller lacks admin permission on owning group", + ) + throw new Error("Caller lacks admin permission on owning group") + } + } + + try { + await deleteCoValues(NotificationSettings, oldSettings.$jazz.id) + } catch (error) { + console.error( + "[NotificationSettingsMigration] Failed to delete old settings:", + error, + ) + throw error + } } return { newSettings, cleanup } diff --git a/src/server/features/push-cron.ts b/src/server/features/push-cron.ts index e76a5a0e..20a5fd56 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -1,5 +1,5 @@ import { CRON_SECRET } from "astro:env/server" -import { getServerWorker } from "../lib/utils" +import { getServerWorker, WorkerTimeoutError } from "../lib/utils" import { toZonedTime, format } from "date-fns-tz" import { Hono } from "hono" @@ -50,7 +50,15 @@ let cronDeliveryApp = new Hono().get( let processingPromises: Promise[] = [] let maxConcurrentUsers = 50 - let worker = await getServerWorker() + let worker + try { + worker = await getServerWorker() + } catch (error) { + if (error instanceof WorkerTimeoutError) { + return c.json({ error: error.message, code: "worker-timeout" }, 504) + } + throw error + } let serverAccount = await worker.$jazz.ensureLoaded({ resolve: serverRefsQuery, }) diff --git a/src/server/features/push-register.ts b/src/server/features/push-register.ts index 8c598ecc..81eb9083 100644 --- a/src/server/features/push-register.ts +++ b/src/server/features/push-register.ts @@ -2,7 +2,7 @@ import { zValidator } from "@hono/zod-validator" import { Hono } from "hono" import { z } from "zod" import { authenticateRequest } from "jazz-tools" -import { getServerWorker } from "../lib/utils" +import { getServerWorker, WorkerTimeoutError } from "../lib/utils" import { registerNotificationSettingsWithServer } from "./push-register-logic" export { pushRegisterApp } @@ -23,7 +23,15 @@ let pushRegisterApp = new Hono().post( async c => { let { notificationSettingsId } = c.req.valid("json") - let worker = await getServerWorker() + let worker + try { + worker = await getServerWorker() + } catch (error) { + if (error instanceof WorkerTimeoutError) { + return c.json({ error: error.message, code: "worker-timeout" }, 504) + } + throw error + } let { account, error } = await authenticateRequest(c.req.raw, { loadAs: worker, diff --git a/src/server/lib/utils.ts b/src/server/lib/utils.ts index fceb536d..61c9e608 100644 --- a/src/server/lib/utils.ts +++ b/src/server/lib/utils.ts @@ -57,7 +57,7 @@ async function getServerWorker(): Promise { async function initUserWorker(user: { unsafeMetadata: Record -}) { +}): Promise<{ worker: co.loaded; shutdown(): void }> { let jazzAccountId = user.unsafeMetadata.jazzAccountID as string let jazzAccountSecret = user.unsafeMetadata.jazzAccountSecret as string @@ -65,16 +65,35 @@ async function initUserWorker(user: { setTimeout(() => reject(new WorkerTimeoutError()), 30000), ) - let workerResult = await Promise.race([ - startWorker({ - AccountSchema: UserAccount, - syncServer: PUBLIC_JAZZ_SYNC_SERVER, - accountID: jazzAccountId, - accountSecret: jazzAccountSecret, - skipInboxLoad: true, - }), - timeoutPromise, - ]) - - return { worker: workerResult.worker } + let workerPromise = startWorker({ + AccountSchema: UserAccount, + syncServer: PUBLIC_JAZZ_SYNC_SERVER, + accountID: jazzAccountId, + accountSecret: jazzAccountSecret, + skipInboxLoad: true, + }) + + let workerResult = await Promise.race([workerPromise, timeoutPromise]) + + let resolved = false + const shutdown = async () => { + if (resolved) return + resolved = true + try { + const result = await workerPromise + await result.shutdownWorker?.() + } catch { + // ignore cleanup errors + } + } + + workerPromise + .then(() => { + resolved = true + }) + .catch(() => { + resolved = true + }) + + return { worker: workerResult.worker, shutdown } } From 8d60d95175c2b2ce9b98b07006886ce8660e223d Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Wed, 4 Feb 2026 22:34:14 +0100 Subject: [PATCH 24/24] fix: type error in notification-registration --- src/app/lib/notification-registration.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/lib/notification-registration.ts b/src/app/lib/notification-registration.ts index 1c3d9d4c..19f96eaf 100644 --- a/src/app/lib/notification-registration.ts +++ b/src/app/lib/notification-registration.ts @@ -28,9 +28,11 @@ async function triggerNotificationRegistration( } if (!result.data.ok) { - let errorData = await tryCatch(result.data.json()) + let errorData = await tryCatch( + result.data.json() as Promise<{ message?: string }>, + ) let errorMessage = errorData.ok - ? (errorData.data as { message?: string }).message || "Unknown error" + ? errorData.data.message || "Unknown error" : "Unknown error" console.error("[Notifications] Registration error:", errorMessage) return { ok: false, error: errorMessage }