diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1bb8051a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +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 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\"", diff --git a/src/app/features/notification-settings.tsx b/src/app/features/notification-settings.tsx index 8ae3a885..c5cfe5ae 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" @@ -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, @@ -967,11 +968,29 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) { return } - addPushDevice({ + let deviceData = { deviceName: values.deviceName, endpoint: subscriptionResult.data.endpoint, keys: subscriptionResult.data.keys, - }) + } + + 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")) + setOpen(false) + form.reset({ + deviceName: getDeviceName(), + }) + return + } + } + + addPushDevice(deviceData) toast.success(t("notifications.toast.deviceAdded")) setOpen(false) 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 new file mode 100644 index 00000000..7a9ce6b0 --- /dev/null +++ b/src/app/hooks/use-register-notifications.ts @@ -0,0 +1,161 @@ +import { useEffect, useRef } from "react" +import { useAccount } from "jazz-tools/react" +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 { 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 } + +let notificationSettingsQuery = { + root: { + notificationSettings: true, + people: { $each: { reminders: { $each: 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).catch(error => { + console.error("[Notifications] Registration error:", error) + registrationRan.current = false + }) + }, [me.$isLoaded, me]) +} + +async function registerNotificationSettings(me: LoadedAccount): Promise { + let notificationSettings = me.root.notificationSettings + if (!notificationSettings) return + + 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) { + 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 + let isShareableGroup = owner instanceof Group + + if (!isShareableGroup) { + console.log("[Notifications] Migrating to shareable group") + let migrationResult = await tryCatch( + migrateNotificationSettings(notificationSettings, serverAccountId, { + loadAs: me, + rootLanguage, + }), + ) + if (!migrationResult.ok) { + console.error("[Notifications] Migration failed:", migrationResult.error) + return + } + let { newSettings, cleanup } = migrationResult.data + // Update root to point to new settings before cleanup + me.root.$jazz.set("notificationSettings", newSettings) + // Defer cleanup to next tick so new settings are persisted first + setTimeout(cleanup, 0) + notificationSettings = newSettings + console.log("[Notifications] Migration complete") + } else { + // Ensure server worker is a member + let group = owner as Group + let serverIsMember = group.members.some( + m => m.account?.$jazz.id === serverAccountId, + ) + if (!serverIsMember) { + let addResult = await tryCatch( + addServerToGroup(group, serverAccountId, { loadAs: me }), + ) + if (!addResult.ok) { + console.error( + "[Notifications] Failed to add server to group:", + addResult.error, + ) + } + } + } + + // Register with server using Jazz auth + let authToken = generateAuthToken(me) + let registerResult = await triggerNotificationRegistration( + notificationSettings.$jazz.id, + authToken, + ) + + if (!registerResult.ok) { + console.error("[Notifications] Registration failed:", registerResult.error) + return + } + + console.log("[Notifications] Registration successful") +} + +function computeLatestReminderDueDate(me: LoadedAccount): string | undefined { + let reminders = extractReminders(me) + 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) +} + +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) continue + reminders.push({ + dueAtDate: reminder.dueAtDate, + deleted: !!reminder.deletedAt, + done: !!reminder.done, + }) + } + } + return reminders +} diff --git a/src/app/lib/notification-registration.ts b/src/app/lib/notification-registration.ts new file mode 100644 index 00000000..19f96eaf --- /dev/null +++ b/src/app/lib/notification-registration.ts @@ -0,0 +1,43 @@ +import { apiClient } from "#app/lib/api-client" +import { tryCatch } from "#shared/lib/trycatch" + +export { triggerNotificationRegistration } + +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 }, + }, + { + headers: { + Authorization: `Jazz ${authToken}`, + }, + }, + ), + ) + + if (!result.ok) { + console.error("[Notifications] Registration failed:", result.error) + return { ok: false, error: "Network error" } + } + + if (!result.data.ok) { + let errorData = await tryCatch( + result.data.json() as Promise<{ message?: string }>, + ) + let errorMessage = errorData.ok + ? errorData.data.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/notification-settings-migration.test.ts b/src/app/lib/notification-settings-migration.test.ts new file mode 100644 index 00000000..f20961c3 --- /dev/null +++ b/src/app/lib/notification-settings-migration.test.ts @@ -0,0 +1,105 @@ +import { describe, test, expect } from "vitest" +import { + copyNotificationSettingsData, + type NotificationSettingsInput, +} from "./notification-settings-migration" + +describe("copyNotificationSettingsData", () => { + test("copies all settings fields", () => { + let mockSettings: NotificationSettingsInput = { + version: 1, + timezone: "America/New_York", + notificationTime: "09:00", + lastDeliveredAt: new Date("2025-01-15T14:00:00Z"), + language: "de", + 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 = { + version: 1, + 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 = { + version: 1, + timezone: "UTC", + notificationTime: "12:00", + language: "en", + pushDevices: [], + } + + let result = copyNotificationSettingsData(mockSettings, "de") + + expect(result.language).toBe("en") + }) + + test("copies multiple devices", () => { + let mockSettings: NotificationSettingsInput = { + version: 1, + 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 = { + version: 1, + 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..e8cc170b --- /dev/null +++ b/src/app/lib/notification-settings-migration.ts @@ -0,0 +1,132 @@ +import { Account, Group, type co, type ID, deleteCoValues } from "jazz-tools" +import { NotificationSettings } from "#shared/schema/user" +import { ServerAccount } from "#shared/schema/server" + +export { + migrateNotificationSettings, + addServerToGroup, + copyNotificationSettingsData, +} +export type { MigrationContext, NotificationSettingsInput } + +type NotificationSettingsInput = { + version: 1 + 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 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: NotificationSettingsLike, + rootLanguage?: "de" | "en", +): NotificationSettingsInput { + return { + version: 1, + 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<{ + newSettings: co.loaded + cleanup: () => Promise +}> { + let group = Group.create() + + await addServerToGroup(group, serverAccountId, context) + + let settingsData = copyNotificationSettingsData( + oldSettings, + context.rootLanguage, + ) + + let newSettings = NotificationSettings.create(settingsData, { owner: group }) + + 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 } +} + +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/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/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/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/chat-messages.ts b/src/server/features/chat-messages.ts index 5c9a0a54..468d6c33 100644 --- a/src/server/features/chat-messages.ts +++ b/src/server/features/chat-messages.ts @@ -25,7 +25,12 @@ import { checkUsageLimits, updateUsage, } from "../lib/chat-usage" -import { initUserWorker, initServerWorker } from "#server/lib/utils" +import { + initUserWorker, + getServerWorker, + WorkerTimeoutError, + type ServerWorker, +} 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 +54,19 @@ 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 serverWorker: ServerWorker + try { + userWorkerResult = await initUserWorker(user) + serverWorker = await getServerWorker() + } 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 [ userWorker, @@ -93,11 +102,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-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 b8ad1ed6..20a5fd56 100644 --- a/src/server/features/push-cron.ts +++ b/src/server/features/push-cron.ts @@ -1,57 +1,126 @@ -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 { getServerWorker, WorkerTimeoutError } 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, NotificationSettingsRef } from "#shared/schema/server" +import { + baseServerMessages, + deServerMessages, +} from "#shared/intl/messages.server" +import { + isPastNotificationTime, + wasDeliveredToday, + isStaleRef, +} from "./push-cron-utils" +import { tryCatch } from "#shared/lib/trycatch" export { cronDeliveryApp } +let serverRefsQuery = { + root: { + notificationSettingsRefs: { + $each: { notificationSettings: true }, + }, + }, +} as const satisfies ResolveQuery + +type LoadedRef = co.loaded +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 + 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, + }) + + 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") + return c.json({ + message: "No notification settings refs found", + results: [], + }) + } + + let staleRefKeys: string[] = [] + let currentUtc = new Date() + + for (let [notificationSettingsId, ref] of Object.entries(refs)) { + if (!ref) continue + + let notificationSettings = ref.notificationSettings + if (!notificationSettings?.$isLoaded) { + console.log(`⚠️ User ${ref.userId}: Settings not loaded, skipping`) + continue + } + + // Check if ref is stale (no app open in 30 days after last sync or latest reminder) + if ( + isStaleRef(ref.lastSyncedAt, notificationSettings.latestReminderDueDate) + ) { + staleRefKeys.push(notificationSettingsId) + console.log(`🗑️ Marking stale ref for removal: ${ref.userId}`) + 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, + ) + .then(result => { + if (result.status === "processed") { + deliveryResults.push(result) + } }) .catch(error => { - if (typeof error === "string") { - console.log(`❌ User ${user.id}: ${error}`) - } else { - console.log(`❌ User ${user.id}: ${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)) @@ -60,6 +129,21 @@ let cronDeliveryApp = new Hono().get( await Promise.allSettled(processingPromises) + // Remove stale refs by key + for (let key of staleRefKeys) { + refs.$jazz.delete(key) + } + + if (staleRefKeys.length > 0) { + console.log(`🗑️ Removed ${staleRefKeys.length} stale refs`) + } + + // Single sync at end for all mutations + 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`, results: deliveryResults, @@ -67,88 +151,31 @@ 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 - } - } +type ProcessResult = + | { status: "skipped"; reason: string } + | { status: "processed"; userId: string; success: boolean } + | { status: "failed"; userId: string; reason: string } - 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, +): Promise { + let { userId } = ref + // Check notification time if (!isPastNotificationTime(notificationSettings, currentUtc)) { let userTimezone = notificationSettings.timezone || "UTC" 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 if (wasDeliveredToday(notificationSettings, currentUtc)) { let userTimezone = notificationSettings.timezone || "UTC" let lastDelivered = notificationSettings.lastDeliveredAt @@ -157,68 +184,42 @@ async function shouldReceiveNotification< "yyyy-MM-dd HH:mm", ) : "never" - 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 - - let enabledDevices = getEnabledDevices(notificationSettings) - if (enabledDevices.length === 0) { - console.log(`✅ User ${user.id}: No enabled devices`) return { - ...data, - devices: [], + status: "skipped", + reason: `Already delivered today (last delivered: ${lastDelivered})`, } } - console.log( - `✅ User ${user.id}: Ready to send wake notification to ${enabledDevices.length} devices`, - ) + console.log(`✅ User ${userId}: Passed notification time checks`) - return { - ...data, - devices: enabledDevices, - } -} - -async function processDevicesPipeline( - userWithDevices: DeviceNotificationContext, -) { - let { user, devices, notificationSettings, worker, currentUtc } = - userWithDevices - - if (devices.length === 0) { + // Get enabled devices + let enabledDevices = getEnabledDevices(notificationSettings) + if (enabledDevices.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)`, - ) - return [{ userID: user.id, success: true }] + console.log(`✅ User ${userId}: Marked as delivered (no devices to notify)`) + return { status: "skipped", reason: "No enabled devices" } } - let payload = createLocalizedNotificationPayload(user.id, worker) + console.log(`📤 User ${userId}: Notifying ${enabledDevices.length} device(s)`) + // 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, ) @@ -232,16 +233,16 @@ async function processDevicesPipeline( let userSuccess = deviceResults.some(r => r.success) - markNotificationSettingsAsDelivered(notificationSettings, currentUtc) - await worker.$jazz.waitForSync() - - console.log(`✅ User ${user.id}: Completed notification delivery`) - - return [{ userID: user.id, 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" } + } } -import { isPastNotificationTime, wasDeliveredToday } from "./push-cron-utils" - async function waitForConcurrencyLimit( promises: Promise[], maxConcurrency: number, @@ -257,12 +258,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 +274,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-logic.ts b/src/server/features/push-register-logic.ts new file mode 100644 index 00000000..b29661a9 --- /dev/null +++ b/src/server/features/push-register-logic.ts @@ -0,0 +1,73 @@ +import { co, type ID } from "jazz-tools" +import { NotificationSettings } from "#shared/schema/user" +import { + NotificationSettingsRef, + NotificationSettingsRefsRecord, + ServerAccount, +} from "#shared/schema/server" +import { tryCatch } from "#shared/lib/trycatch" + +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", + NotificationSettingsRefsRecord.create({}), + ) + } + + let refs = root.root.notificationSettingsRefs! + + // Keyed by notificationSettingsId - upsert is naturally idempotent + let existingRef = refs[notificationSettingsId] + + if (existingRef) { + existingRef.$jazz.set("lastSyncedAt", new Date()) + } else { + let newRef = NotificationSettingsRef.create({ + notificationSettings, + userId, + lastSyncedAt: new Date(), + }) + refs.$jazz.set(notificationSettingsId, newRef) + } + + 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 } +} diff --git a/src/server/features/push-register.test.ts b/src/server/features/push-register.test.ts new file mode 100644 index 00000000..4b898368 --- /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({})) + } + + 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 + 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 () => { + 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 refs = loadedServer.root.notificationSettingsRefs + let firstSyncTime = refs?.[notificationSettings.$jazz.id]?.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 } } }, + }) + + refs = loadedServer.root.notificationSettingsRefs + let refEntries = refs ? Object.entries(refs) : [] + expect(refEntries.length).toBe(1) + + let secondSyncTime = refs?.[notificationSettings.$jazz.id]?.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 new file mode 100644 index 00000000..81eb9083 --- /dev/null +++ b/src/server/features/push-register.ts @@ -0,0 +1,62 @@ +import { zValidator } from "@hono/zod-validator" +import { Hono } from "hono" +import { z } from "zod" +import { authenticateRequest } from "jazz-tools" +import { getServerWorker, WorkerTimeoutError } from "../lib/utils" +import { registerNotificationSettingsWithServer } from "./push-register-logic" + +export { pushRegisterApp } + +let pushRegisterApp = new Hono().post( + "/register", + 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 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, + }) + + 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, + userId, + ) + + if (!result.ok) { + return c.json({ message: result.error }, result.status) + } + + return c.json({ message: "Registered successfully" }) + }, +) 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( 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 2dc9f137..61c9e608 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,15 +10,62 @@ 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, getServerWorker, WorkerTimeoutError } +export type { ServerWorker } + +class WorkerTimeoutError extends Error { + constructor() { + super("Worker initialization timed out") + this.name = "WorkerTimeoutError" + } +} + +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 + }) + .catch(error => { + serverWorkerPromise = null + cachedServerWorker = null + throw error + }) + + return serverWorkerPromise +} 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 - let workerResult = await startWorker({ + let timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new WorkerTimeoutError()), 30000), + ) + + let workerPromise = startWorker({ AccountSchema: UserAccount, syncServer: PUBLIC_JAZZ_SYNC_SERVER, accountID: jazzAccountId, @@ -25,18 +73,27 @@ async function initUserWorker(user: { skipInboxLoad: true, }) - return { worker: workerResult.worker } -} + let workerResult = await Promise.race([workerPromise, timeoutPromise]) -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, - }) + 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 } + return { worker: workerResult.worker, shutdown } } 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/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/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": diff --git a/src/shared/schema/server.ts b/src/shared/schema/server.ts index 6f324d1e..a7cb2d35 100644 --- a/src/shared/schema/server.ts +++ b/src/shared/schema/server.ts @@ -1,6 +1,42 @@ import { co, z } from "jazz-tools" +import { NotificationSettings } from "./user" -export let ServerAccount = co.account({ - profile: co.map({ name: z.string() }), - root: co.map({}), +export let NotificationSettingsRef = co.map({ + notificationSettings: NotificationSettings, + userId: z.string(), + 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: NotificationSettingsRefsRecord.optional(), +}) + +export let ServerAccount = co + .account({ + profile: co.map({ name: z.string() }), + root: ServerAccountRoot, + }) + .withMigration(async account => { + if (!account.$jazz.has("root")) { + let newRoot = ServerAccountRoot.create({ + notificationSettingsRefs: NotificationSettingsRefsRecord.create({}), + }) + account.$jazz.set("root", newRoot) + } else { + let { root } = await account.$jazz.ensureLoaded({ + resolve: { root: { notificationSettingsRefs: { $each: true } } }, + }) + if (root.notificationSettingsRefs === undefined) { + root.$jazz.set( + "notificationSettingsRefs", + NotificationSettingsRefsRecord.create({}), + ) + } + } + }) diff --git a/src/shared/schema/user.ts b/src/shared/schema/user.ts index 2e3246dc..4e343533 100644 --- a/src/shared/schema/user.ts +++ b/src/shared/schema/user.ts @@ -27,6 +27,8 @@ export let NotificationSettings = co.map({ notificationTime: z.string().optional(), 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({