Skip to content

Commit 84aea3b

Browse files
authored
Push Notifications without Clerk (#76)
* chore: create a plan * chore: change push notification logic to not rely on clerk * 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 * 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 * chore: add tests * chore: improve testability * chore: address minor issues * chore: write ATPs * fix: address coderabbit findings * fix: address nitpick changes * fix: address minor issues * feat: add CI and don't run checks on vercel builds * fix: address findings * fix: tests * fix: tests * chore: test push notification logic, harmonize logs * chore: final review and fixes * chore: no build in CI * chore: improve implementation * fix: delete old settings only after new ones are persisted * fix: clear cached worker on rejection to allow retries * fix: Add device only after successful server registration * fix: more improvements * fix: type error in notification-registration
1 parent 3904a5b commit 84aea3b

26 files changed

Lines changed: 1246 additions & 213 deletions

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
check:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: oven-sh/setup-bun@v2
17+
with:
18+
bun-version: latest
19+
20+
- name: Install dependencies
21+
run: bun install --frozen-lockfile
22+
23+
- name: Run typecheck, lint, and format checks
24+
run: bun run check
25+
26+
- name: Run tests
27+
run: bun run test:run

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
"type": "module",
55
"scripts": {
66
"dev": "astro dev",
7-
"build": "bun test:run && astro check && astro build",
8-
"build:node": "ASTRO_ADAPTER=node astro check && ASTRO_ADAPTER=node astro build",
7+
"build": "astro build",
8+
"build:prod": "astro build",
9+
"build:node": "ASTRO_ADAPTER=node astro build",
910
"preview": "astro preview --port 4322",
1011
"preview:node": "ASTRO_ADAPTER=node dotenv -e .env -- astro preview --port 4322",
1112
"check": "concurrently -n Astro,Prettier,ESLint \"astro check\" \"prettier --check .\" \"eslint . --ext .ts,.tsx,.js,.jsx,.astro\"",

src/app/features/notification-settings.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { de as dfnsDe } from "date-fns/locale"
22
import { formatDistanceToNow } from "date-fns"
33
import { useIsAuthenticated } from "jazz-tools/react"
4-
import { co } from "jazz-tools"
4+
import { co, generateAuthToken } from "jazz-tools"
55
import { PushDevice, UserAccount } from "#shared/schema/user"
66
import { Alert, AlertTitle, AlertDescription } from "#shared/ui/alert"
77
import { ExclamationTriangle } from "react-bootstrap-icons"
@@ -55,6 +55,7 @@ import { PUBLIC_VAPID_KEY } from "astro:env/client"
5555
import { getServiceWorkerRegistration } from "#app/lib/service-worker"
5656
import { tryCatch } from "#shared/lib/trycatch"
5757
import { isInAppBrowser } from "#app/hooks/use-pwa"
58+
import { triggerNotificationRegistration } from "#app/lib/notification-registration"
5859

5960
export function NotificationSettings({
6061
me,
@@ -967,11 +968,29 @@ function AddDeviceDialog({ me, disabled }: AddDeviceDialogProps) {
967968
return
968969
}
969970

970-
addPushDevice({
971+
let deviceData = {
971972
deviceName: values.deviceName,
972973
endpoint: subscriptionResult.data.endpoint,
973974
keys: subscriptionResult.data.keys,
974-
})
975+
}
976+
977+
if (notifications?.$jazz.id) {
978+
let authToken = generateAuthToken(me)
979+
let registrationResult = await triggerNotificationRegistration(
980+
notifications.$jazz.id,
981+
authToken,
982+
)
983+
if (!registrationResult.ok) {
984+
toast.warning(t("notifications.toast.registrationFailed"))
985+
setOpen(false)
986+
form.reset({
987+
deviceName: getDeviceName(),
988+
})
989+
return
990+
}
991+
}
992+
993+
addPushDevice(deviceData)
975994

976995
toast.success(t("notifications.toast.deviceAdded"))
977996
setOpen(false)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, test, expect } from "vitest"
2+
import { findLatestFutureDate } from "#app/lib/reminder-utils"
3+
4+
describe("findLatestFutureDate", () => {
5+
let today = "2025-01-15"
6+
7+
test("returns undefined for empty list", () => {
8+
expect(findLatestFutureDate([], today)).toBeUndefined()
9+
})
10+
11+
test("returns undefined when all reminders are in the past", () => {
12+
let reminders = [
13+
{ dueAtDate: "2025-01-10", deleted: false, done: false },
14+
{ dueAtDate: "2025-01-14", deleted: false, done: false },
15+
]
16+
expect(findLatestFutureDate(reminders, today)).toBeUndefined()
17+
})
18+
19+
test("returns the only future reminder", () => {
20+
let reminders = [{ dueAtDate: "2025-01-20", deleted: false, done: false }]
21+
expect(findLatestFutureDate(reminders, today)).toBe("2025-01-20")
22+
})
23+
24+
test("returns today's date as valid future", () => {
25+
let reminders = [{ dueAtDate: "2025-01-15", deleted: false, done: false }]
26+
expect(findLatestFutureDate(reminders, today)).toBe("2025-01-15")
27+
})
28+
29+
test("returns the latest of multiple future reminders", () => {
30+
let reminders = [
31+
{ dueAtDate: "2025-01-20", deleted: false, done: false },
32+
{ dueAtDate: "2025-02-15", deleted: false, done: false },
33+
{ dueAtDate: "2025-01-25", deleted: false, done: false },
34+
]
35+
expect(findLatestFutureDate(reminders, today)).toBe("2025-02-15")
36+
})
37+
38+
test("ignores deleted reminders", () => {
39+
let reminders = [
40+
{ dueAtDate: "2025-02-15", deleted: true, done: false },
41+
{ dueAtDate: "2025-01-20", deleted: false, done: false },
42+
]
43+
expect(findLatestFutureDate(reminders, today)).toBe("2025-01-20")
44+
})
45+
46+
test("ignores done reminders", () => {
47+
let reminders = [
48+
{ dueAtDate: "2025-02-15", deleted: false, done: true },
49+
{ dueAtDate: "2025-01-20", deleted: false, done: false },
50+
]
51+
expect(findLatestFutureDate(reminders, today)).toBe("2025-01-20")
52+
})
53+
54+
test("returns undefined when all future reminders are deleted or done", () => {
55+
let reminders = [
56+
{ dueAtDate: "2025-02-15", deleted: true, done: false },
57+
{ dueAtDate: "2025-01-20", deleted: false, done: true },
58+
{ dueAtDate: "2025-01-10", deleted: false, done: false },
59+
]
60+
expect(findLatestFutureDate(reminders, today)).toBeUndefined()
61+
})
62+
})
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { useEffect, useRef } from "react"
2+
import { useAccount } from "jazz-tools/react"
3+
import {
4+
Group,
5+
generateAuthToken,
6+
type co,
7+
type ResolveQuery,
8+
} from "jazz-tools"
9+
import { PUBLIC_JAZZ_WORKER_ACCOUNT } from "astro:env/client"
10+
import { UserAccount } from "#shared/schema/user"
11+
import { tryCatch } from "#shared/lib/trycatch"
12+
import {
13+
migrateNotificationSettings,
14+
addServerToGroup,
15+
} from "#app/lib/notification-settings-migration"
16+
import { triggerNotificationRegistration } from "#app/lib/notification-registration"
17+
import { findLatestFutureDate } from "#app/lib/reminder-utils"
18+
19+
export { useRegisterNotifications }
20+
21+
let notificationSettingsQuery = {
22+
root: {
23+
notificationSettings: true,
24+
people: { $each: { reminders: { $each: true } } },
25+
},
26+
} as const satisfies ResolveQuery<typeof UserAccount>
27+
28+
type LoadedAccount = co.loaded<
29+
typeof UserAccount,
30+
typeof notificationSettingsQuery
31+
>
32+
33+
/**
34+
* Hook that registers notification settings with the server.
35+
* Handles migration from account-owned to group-owned settings.
36+
* Runs once on app start.
37+
*/
38+
function useRegisterNotifications(): void {
39+
let registrationRan = useRef(false)
40+
let me = useAccount(UserAccount, { resolve: notificationSettingsQuery })
41+
42+
useEffect(() => {
43+
if (registrationRan.current || !me.$isLoaded) return
44+
if (!me.root.notificationSettings) return
45+
46+
registrationRan.current = true
47+
registerNotificationSettings(me).catch(error => {
48+
console.error("[Notifications] Registration error:", error)
49+
registrationRan.current = false
50+
})
51+
}, [me.$isLoaded, me])
52+
}
53+
54+
async function registerNotificationSettings(me: LoadedAccount): Promise<void> {
55+
let notificationSettings = me.root.notificationSettings
56+
if (!notificationSettings) return
57+
58+
let serverAccountId = PUBLIC_JAZZ_WORKER_ACCOUNT
59+
if (!serverAccountId) {
60+
console.error("[Notifications] PUBLIC_JAZZ_WORKER_ACCOUNT not configured")
61+
return
62+
}
63+
64+
// Sync language from root to notification settings
65+
let rootLanguage = me.root.language
66+
if (rootLanguage && notificationSettings.language !== rootLanguage) {
67+
notificationSettings.$jazz.set("language", rootLanguage)
68+
}
69+
70+
// Compute and sync latestReminderDueDate
71+
let latestDueDate = computeLatestReminderDueDate(me)
72+
if (latestDueDate !== notificationSettings.latestReminderDueDate) {
73+
notificationSettings.$jazz.set("latestReminderDueDate", latestDueDate)
74+
}
75+
76+
// Check if settings are owned by a shareable group
77+
// The key difference: if owner is an Account vs a Group
78+
let owner = notificationSettings.$jazz.owner
79+
let isShareableGroup = owner instanceof Group
80+
81+
if (!isShareableGroup) {
82+
console.log("[Notifications] Migrating to shareable group")
83+
let migrationResult = await tryCatch(
84+
migrateNotificationSettings(notificationSettings, serverAccountId, {
85+
loadAs: me,
86+
rootLanguage,
87+
}),
88+
)
89+
if (!migrationResult.ok) {
90+
console.error("[Notifications] Migration failed:", migrationResult.error)
91+
return
92+
}
93+
let { newSettings, cleanup } = migrationResult.data
94+
// Update root to point to new settings before cleanup
95+
me.root.$jazz.set("notificationSettings", newSettings)
96+
// Defer cleanup to next tick so new settings are persisted first
97+
setTimeout(cleanup, 0)
98+
notificationSettings = newSettings
99+
console.log("[Notifications] Migration complete")
100+
} else {
101+
// Ensure server worker is a member
102+
let group = owner as Group
103+
let serverIsMember = group.members.some(
104+
m => m.account?.$jazz.id === serverAccountId,
105+
)
106+
if (!serverIsMember) {
107+
let addResult = await tryCatch(
108+
addServerToGroup(group, serverAccountId, { loadAs: me }),
109+
)
110+
if (!addResult.ok) {
111+
console.error(
112+
"[Notifications] Failed to add server to group:",
113+
addResult.error,
114+
)
115+
}
116+
}
117+
}
118+
119+
// Register with server using Jazz auth
120+
let authToken = generateAuthToken(me)
121+
let registerResult = await triggerNotificationRegistration(
122+
notificationSettings.$jazz.id,
123+
authToken,
124+
)
125+
126+
if (!registerResult.ok) {
127+
console.error("[Notifications] Registration failed:", registerResult.error)
128+
return
129+
}
130+
131+
console.log("[Notifications] Registration successful")
132+
}
133+
134+
function computeLatestReminderDueDate(me: LoadedAccount): string | undefined {
135+
let reminders = extractReminders(me)
136+
let timezone =
137+
me.root.notificationSettings?.timezone ||
138+
Intl.DateTimeFormat().resolvedOptions().timeZone
139+
let today = new Date()
140+
.toLocaleDateString("sv-SE", { timeZone: timezone })
141+
.slice(0, 10)
142+
return findLatestFutureDate(reminders, today)
143+
}
144+
145+
function extractReminders(
146+
me: LoadedAccount,
147+
): { dueAtDate: string; deleted: boolean; done: boolean }[] {
148+
let reminders: { dueAtDate: string; deleted: boolean; done: boolean }[] = []
149+
for (let person of me.root.people.values()) {
150+
if (!person || person.deletedAt) continue
151+
for (let reminder of person.reminders.values()) {
152+
if (!reminder) continue
153+
reminders.push({
154+
dueAtDate: reminder.dueAtDate,
155+
deleted: !!reminder.deletedAt,
156+
done: !!reminder.done,
157+
})
158+
}
159+
}
160+
return reminders
161+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { apiClient } from "#app/lib/api-client"
2+
import { tryCatch } from "#shared/lib/trycatch"
3+
4+
export { triggerNotificationRegistration }
5+
6+
type RegistrationResult = { ok: true } | { ok: false; error: string }
7+
8+
async function triggerNotificationRegistration(
9+
notificationSettingsId: string,
10+
authToken: string,
11+
): Promise<RegistrationResult> {
12+
let result = await tryCatch(
13+
apiClient.push.register.$post(
14+
{
15+
json: { notificationSettingsId },
16+
},
17+
{
18+
headers: {
19+
Authorization: `Jazz ${authToken}`,
20+
},
21+
},
22+
),
23+
)
24+
25+
if (!result.ok) {
26+
console.error("[Notifications] Registration failed:", result.error)
27+
return { ok: false, error: "Network error" }
28+
}
29+
30+
if (!result.data.ok) {
31+
let errorData = await tryCatch(
32+
result.data.json() as Promise<{ message?: string }>,
33+
)
34+
let errorMessage = errorData.ok
35+
? errorData.data.message || "Unknown error"
36+
: "Unknown error"
37+
console.error("[Notifications] Registration error:", errorMessage)
38+
return { ok: false, error: errorMessage }
39+
}
40+
41+
console.log("[Notifications] Registration triggered successfully")
42+
return { ok: true }
43+
}

0 commit comments

Comments
 (0)