Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ad10dfa
chore: create a plan
carlassmann Jan 25, 2026
4bee727
chore: change push notification logic to not rely on clerk
carlassmann Jan 25, 2026
83c9e81
fix: address review issues in push notification refactor
carlassmann Jan 25, 2026
ab68fda
feat: dynamic stale threshold based on future reminders
carlassmann Jan 25, 2026
ed0c0f9
chore: add tests
carlassmann Jan 27, 2026
4c472bb
chore: improve testability
carlassmann Jan 30, 2026
e999d97
chore: address minor issues
carlassmann Jan 30, 2026
1dec5eb
chore: write ATPs
carlassmann Jan 30, 2026
a257c34
Merge branch 'main' into ccssmnn/push-notifications-without-clerk
carlassmann Jan 30, 2026
5c03912
fix: address coderabbit findings
carlassmann Jan 30, 2026
ba0a413
fix: address nitpick changes
carlassmann Jan 30, 2026
e052ada
fix: address minor issues
carlassmann Jan 30, 2026
df27bad
feat: add CI and don't run checks on vercel builds
carlassmann Jan 30, 2026
779f9af
fix: address findings
carlassmann Jan 30, 2026
655e87e
fix: tests
carlassmann Jan 30, 2026
f70ed20
fix: tests
carlassmann Jan 30, 2026
7a6256b
chore: test push notification logic, harmonize logs
carlassmann Jan 31, 2026
359ef78
chore: final review and fixes
carlassmann Feb 2, 2026
70aa8eb
chore: no build in CI
carlassmann Feb 2, 2026
4f107e8
chore: improve implementation
carlassmann Feb 4, 2026
2c9d2eb
fix: delete old settings only after new ones are persisted
carlassmann Feb 4, 2026
27573b6
fix: clear cached worker on rejection to allow retries
carlassmann Feb 4, 2026
aaa0018
fix: Add device only after successful server registration
carlassmann Feb 4, 2026
69fa7af
fix: more improvements
carlassmann Feb 4, 2026
8d60d95
fix: type error in notification-registration
carlassmann Feb 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions ATP.md
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions src/app/features/notification-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -973,6 +974,21 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) {
keys: subscriptionResult.data.keys,
})

// Trigger registration with server after adding device
if (notifications?.$jazz.id) {
let registrationResult = await triggerNotificationRegistration(
notifications.$jazz.id,
)
if (!registrationResult.ok) {
toast.warning(t("notifications.toast.registrationFailed"))
setOpen(false)
form.reset({
deviceName: getDeviceName(),
})
return
}
}

toast.success(t("notifications.toast.deviceAdded"))
setOpen(false)

Expand Down
62 changes: 62 additions & 0 deletions src/app/hooks/use-register-notifications.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
162 changes: 162 additions & 0 deletions src/app/hooks/use-register-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { useEffect, useRef } from "react"
import { useAccount } from "jazz-tools/react"
import { Group, 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"
import { tryCatch } from "#shared/lib/trycatch"
import {
migrateNotificationSettings,
addServerToGroup,
} from "#app/lib/notification-settings-migration"
import { findLatestFutureDate } from "#app/lib/reminder-utils"

export { useRegisterNotifications }

let notificationSettingsQuery = {
root: {
notificationSettings: true,
people: { $each: { reminders: { $each: true } } },
},
} as const satisfies ResolveQuery<typeof UserAccount>

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<void> {
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
}
// 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
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
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")
}

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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
}
33 changes: 33 additions & 0 deletions src/app/lib/notification-registration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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,
): Promise<RegistrationResult> {
let result = await tryCatch(
apiClient.push.register.$post({
json: { notificationSettingsId },
}),
)

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())
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 }
}
Loading