@omg-dev/server 0.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +3278 -0
- package/dist/trigger-scan.mjs +95 -0
- package/package.json +40 -0
- package/src/auto-crud.ts +244 -0
- package/src/billing.ts +297 -0
- package/src/broker.ts +85 -0
- package/src/codec.ts +41 -0
- package/src/ctx.ts +33 -0
- package/src/db.ts +258 -0
- package/src/dispatcher.ts +257 -0
- package/src/http-error.ts +20 -0
- package/src/index.ts +702 -0
- package/src/migrator.ts +167 -0
- package/src/notifications.ts +628 -0
- package/src/predicate.ts +440 -0
- package/src/storage.ts +384 -0
- package/src/subscriptions.ts +654 -0
- package/src/test/auto-crud.test.ts +385 -0
- package/src/test/dispatcher.test.ts +271 -0
- package/src/test/migrator.test.ts +166 -0
- package/src/test/notifications.test.ts +96 -0
- package/src/test/predicate.test.ts +267 -0
- package/src/test/schema-swap.test.ts +252 -0
- package/src/test/security.test.ts +323 -0
- package/src/test/subscriptions.test.ts +878 -0
- package/src/test/trigger-scan.test.ts +78 -0
- package/src/trigger-scan.ts +173 -0
- package/src/triggers.ts +837 -0
- package/src/web-push.d.ts +18 -0
- package/src/workflows.test.ts +127 -0
- package/src/workflows.ts +438 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { collection, defineSchema, fields, type Schema } from "@omg-dev/schema"
|
|
2
|
+
import { ctx } from "./ctx.ts"
|
|
3
|
+
import { getDbInstance, type VibesDb } from "./db.ts"
|
|
4
|
+
import { invalidate } from "./broker.ts"
|
|
5
|
+
import { notifyRowChange } from "./subscriptions.ts"
|
|
6
|
+
|
|
7
|
+
const NOTIFICATIONS_TABLE = "vibesNotifications"
|
|
8
|
+
const PREFERENCES_TABLE = "vibesNotificationPreferences"
|
|
9
|
+
const SUBSCRIPTIONS_TABLE = "vibesPushSubscriptions"
|
|
10
|
+
const DELIVERIES_TABLE = "vibesNotificationDeliveries"
|
|
11
|
+
export const notificationSystemCollectionNames = new Set([
|
|
12
|
+
NOTIFICATIONS_TABLE,
|
|
13
|
+
PREFERENCES_TABLE,
|
|
14
|
+
SUBSCRIPTIONS_TABLE,
|
|
15
|
+
DELIVERIES_TABLE,
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
export const notificationSystemSchema = defineSchema({
|
|
19
|
+
collections: {
|
|
20
|
+
[NOTIFICATIONS_TABLE]: collection({
|
|
21
|
+
fields: {
|
|
22
|
+
appId: fields.string(),
|
|
23
|
+
userId: fields.string(),
|
|
24
|
+
kind: fields.string(),
|
|
25
|
+
title: fields.string(),
|
|
26
|
+
body: fields.string(),
|
|
27
|
+
url: fields.string(),
|
|
28
|
+
status: fields.enum(["unread", "read", "archived"]),
|
|
29
|
+
priority: fields.enum(["low", "normal", "high"]),
|
|
30
|
+
sourceType: fields.string(),
|
|
31
|
+
sourceId: fields.string(),
|
|
32
|
+
dedupeKey: fields.string(),
|
|
33
|
+
dataJson: fields.string(),
|
|
34
|
+
readAt: fields.number(),
|
|
35
|
+
createdAt: fields.number(),
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
.scoped("user")
|
|
39
|
+
.index("_owner", "status", "createdAt")
|
|
40
|
+
.index("_owner", "createdAt")
|
|
41
|
+
.index("appId", "userId", "createdAt"),
|
|
42
|
+
|
|
43
|
+
[PREFERENCES_TABLE]: collection({
|
|
44
|
+
fields: {
|
|
45
|
+
appId: fields.string(),
|
|
46
|
+
userId: fields.string(),
|
|
47
|
+
kind: fields.string(),
|
|
48
|
+
inApp: fields.boolean(),
|
|
49
|
+
push: fields.boolean(),
|
|
50
|
+
createdAt: fields.number(),
|
|
51
|
+
updatedAt: fields.number(),
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
.scoped("user")
|
|
55
|
+
.index("_owner", "appId", "kind"),
|
|
56
|
+
|
|
57
|
+
[SUBSCRIPTIONS_TABLE]: collection({
|
|
58
|
+
fields: {
|
|
59
|
+
appId: fields.string(),
|
|
60
|
+
userId: fields.string(),
|
|
61
|
+
endpoint: fields.string(),
|
|
62
|
+
p256dh: fields.string(),
|
|
63
|
+
auth: fields.string(),
|
|
64
|
+
userAgent: fields.string(),
|
|
65
|
+
disabledAt: fields.number(),
|
|
66
|
+
lastSeenAt: fields.number(),
|
|
67
|
+
createdAt: fields.number(),
|
|
68
|
+
updatedAt: fields.number(),
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
.scoped("user")
|
|
72
|
+
.index("_owner", "appId")
|
|
73
|
+
.index("endpoint"),
|
|
74
|
+
|
|
75
|
+
[DELIVERIES_TABLE]: collection({
|
|
76
|
+
fields: {
|
|
77
|
+
notificationId: fields.ref(NOTIFICATIONS_TABLE),
|
|
78
|
+
appId: fields.string(),
|
|
79
|
+
userId: fields.string(),
|
|
80
|
+
channel: fields.enum(["in_app", "push"]),
|
|
81
|
+
status: fields.enum(["pending", "sent", "failed", "skipped"]),
|
|
82
|
+
attempts: fields.number(),
|
|
83
|
+
error: fields.string(),
|
|
84
|
+
createdAt: fields.number(),
|
|
85
|
+
updatedAt: fields.number(),
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
.scoped("user")
|
|
89
|
+
.index("_owner", "createdAt")
|
|
90
|
+
.index("notificationId", "channel"),
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export function withNotificationSystemSchema(schema: Schema | undefined): Schema | undefined {
|
|
95
|
+
if (!schema) return notificationSystemSchema
|
|
96
|
+
return {
|
|
97
|
+
collections: {
|
|
98
|
+
...schema.collections,
|
|
99
|
+
...notificationSystemSchema.collections,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function ensureNotificationIndexes(db: VibesDb): void {
|
|
105
|
+
db.raw().exec(
|
|
106
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS uniq_vibes_notifications_owner_dedupe
|
|
107
|
+
ON ${NOTIFICATIONS_TABLE} (_owner, appId, dedupeKey)
|
|
108
|
+
WHERE dedupeKey != ''`,
|
|
109
|
+
)
|
|
110
|
+
db.raw().exec(
|
|
111
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS uniq_vibes_push_subscriptions_owner_endpoint
|
|
112
|
+
ON ${SUBSCRIPTIONS_TABLE} (_owner, appId, endpoint)`,
|
|
113
|
+
)
|
|
114
|
+
db.raw().exec(
|
|
115
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS uniq_vibes_notification_prefs_owner_kind
|
|
116
|
+
ON ${PREFERENCES_TABLE} (_owner, appId, kind)`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface NotifyInput {
|
|
121
|
+
userId?: string | null
|
|
122
|
+
appId?: string
|
|
123
|
+
kind: string
|
|
124
|
+
title: string
|
|
125
|
+
body?: string
|
|
126
|
+
url?: string
|
|
127
|
+
priority?: "low" | "normal" | "high"
|
|
128
|
+
source?: {
|
|
129
|
+
type: string
|
|
130
|
+
id: string
|
|
131
|
+
}
|
|
132
|
+
dedupeKey?: string
|
|
133
|
+
data?: Record<string, unknown>
|
|
134
|
+
channels?: {
|
|
135
|
+
inApp?: boolean
|
|
136
|
+
push?: boolean
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface NotificationRow {
|
|
141
|
+
id: string
|
|
142
|
+
appId: string
|
|
143
|
+
userId: string
|
|
144
|
+
kind: string
|
|
145
|
+
title: string
|
|
146
|
+
body: string
|
|
147
|
+
url: string
|
|
148
|
+
status: "unread" | "read" | "archived"
|
|
149
|
+
priority: "low" | "normal" | "high"
|
|
150
|
+
sourceType: string
|
|
151
|
+
sourceId: string
|
|
152
|
+
dedupeKey: string
|
|
153
|
+
dataJson: string
|
|
154
|
+
readAt: number
|
|
155
|
+
createdAt: number
|
|
156
|
+
_owner: string
|
|
157
|
+
created_at: string
|
|
158
|
+
updated_at: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface WebPushSubscriptionPayload {
|
|
162
|
+
endpoint: string
|
|
163
|
+
keys: {
|
|
164
|
+
p256dh: string
|
|
165
|
+
auth: string
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface WebPushSender {
|
|
170
|
+
setVapidDetails(subject: string, publicKey: string, privateKey: string): void
|
|
171
|
+
sendNotification(subscription: WebPushSubscriptionPayload, payload: string): Promise<unknown>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function dbOrThrow(): VibesDb {
|
|
175
|
+
const db = getDbInstance()
|
|
176
|
+
if (!db) throw new Error("[vibes:notifications] db not initialized")
|
|
177
|
+
return db
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function nowIso(): string {
|
|
181
|
+
return new Date().toISOString()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function insertRaw(db: VibesDb, table: string, data: Record<string, unknown>): Record<string, unknown> {
|
|
185
|
+
const id = typeof data.id === "string" ? data.id : crypto.randomUUID()
|
|
186
|
+
const iso = nowIso()
|
|
187
|
+
const record = { ...data, id, created_at: iso, updated_at: iso }
|
|
188
|
+
const cols = Object.keys(record)
|
|
189
|
+
const placeholders = cols.map(() => "?").join(", ")
|
|
190
|
+
db.raw()
|
|
191
|
+
.prepare(`INSERT INTO ${table} (${cols.join(", ")}) VALUES (${placeholders})`)
|
|
192
|
+
.run(...Object.values(record))
|
|
193
|
+
invalidate(table)
|
|
194
|
+
void notifyRowChange(table, "insert", null, record)
|
|
195
|
+
return record
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function patchRaw(db: VibesDb, table: string, id: string, patch: Record<string, unknown>): Record<string, unknown> | null {
|
|
199
|
+
const before = db.raw()
|
|
200
|
+
.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
|
|
201
|
+
.get(id) as Record<string, unknown> | null
|
|
202
|
+
if (!before) return null
|
|
203
|
+
const write: Record<string, unknown> = { ...patch, updated_at: nowIso() }
|
|
204
|
+
const cols = Object.keys(write)
|
|
205
|
+
db.raw()
|
|
206
|
+
.prepare(`UPDATE ${table} SET ${cols.map((c) => `${c} = ?`).join(", ")} WHERE id = ?`)
|
|
207
|
+
.run(...cols.map((c) => write[c]), id)
|
|
208
|
+
const after = { ...before, ...write }
|
|
209
|
+
invalidate(table)
|
|
210
|
+
void notifyRowChange(table, "update", before, after)
|
|
211
|
+
return after
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function getPreference(db: VibesDb, userId: string, appId: string, kind: string): { inApp: number; push: number } | null {
|
|
215
|
+
return db.raw()
|
|
216
|
+
.prepare(
|
|
217
|
+
`SELECT inApp, push FROM ${PREFERENCES_TABLE}
|
|
218
|
+
WHERE _owner = ? AND appId = ? AND kind = ?
|
|
219
|
+
LIMIT 1`,
|
|
220
|
+
)
|
|
221
|
+
.get(userId, appId, kind) as { inApp: number; push: number } | null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function desiredChannels(db: VibesDb, input: Required<Pick<NotifyInput, "kind">> & NotifyInput, userId: string, appId: string) {
|
|
225
|
+
const pref = getPreference(db, userId, appId, input.kind)
|
|
226
|
+
return {
|
|
227
|
+
inApp: input.channels?.inApp ?? (pref ? Boolean(pref.inApp) : true),
|
|
228
|
+
push: input.channels?.push ?? (pref ? Boolean(pref.push) : true),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function configureWebPush(): Promise<{ sender: WebPushSender | null; error: string; skipped: boolean }> {
|
|
233
|
+
const publicKey = process.env.VIBES_VAPID_PUBLIC_KEY
|
|
234
|
+
const privateKey = process.env.VIBES_VAPID_PRIVATE_KEY
|
|
235
|
+
if (!publicKey || !privateKey) {
|
|
236
|
+
return { sender: null, error: "VAPID keys not configured", skipped: true }
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
// Keep web-push out of generated app bundles unless push delivery is actually
|
|
240
|
+
// configured at runtime. The package e2e fixture intentionally installs
|
|
241
|
+
// local tarballs without all optional transitive npm dependencies.
|
|
242
|
+
const runtimeImport = new Function("specifier", "return import(specifier)") as (
|
|
243
|
+
specifier: string,
|
|
244
|
+
) => Promise<{ default?: WebPushSender } & Partial<WebPushSender>>
|
|
245
|
+
const mod = await runtimeImport("web-push")
|
|
246
|
+
const sender = (mod.default ?? mod) as WebPushSender
|
|
247
|
+
sender.setVapidDetails(
|
|
248
|
+
process.env.VIBES_VAPID_SUBJECT || "mailto:support@omg.dev",
|
|
249
|
+
publicKey,
|
|
250
|
+
privateKey,
|
|
251
|
+
)
|
|
252
|
+
return { sender, error: "", skipped: false }
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return {
|
|
255
|
+
sender: null,
|
|
256
|
+
error: err instanceof Error ? err.message : String(err),
|
|
257
|
+
skipped: false,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function fanoutPush(db: VibesDb, row: NotificationRow): Promise<void> {
|
|
263
|
+
const subscriptions = db.raw()
|
|
264
|
+
.prepare(
|
|
265
|
+
`SELECT * FROM ${SUBSCRIPTIONS_TABLE}
|
|
266
|
+
WHERE _owner = ? AND appId = ? AND disabledAt = 0`,
|
|
267
|
+
)
|
|
268
|
+
.all(row.userId, row.appId) as Array<Record<string, unknown>>
|
|
269
|
+
if (subscriptions.length === 0) {
|
|
270
|
+
insertRaw(db, DELIVERIES_TABLE, {
|
|
271
|
+
_owner: row.userId,
|
|
272
|
+
notificationId: row.id,
|
|
273
|
+
appId: row.appId,
|
|
274
|
+
userId: row.userId,
|
|
275
|
+
channel: "push",
|
|
276
|
+
status: "skipped",
|
|
277
|
+
attempts: 0,
|
|
278
|
+
error: "no push subscriptions",
|
|
279
|
+
createdAt: Date.now(),
|
|
280
|
+
updatedAt: Date.now(),
|
|
281
|
+
})
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
const webPush = await configureWebPush()
|
|
285
|
+
for (const sub of subscriptions) {
|
|
286
|
+
const delivery = insertRaw(db, DELIVERIES_TABLE, {
|
|
287
|
+
_owner: row.userId,
|
|
288
|
+
notificationId: row.id,
|
|
289
|
+
appId: row.appId,
|
|
290
|
+
userId: row.userId,
|
|
291
|
+
channel: "push",
|
|
292
|
+
status: webPush.sender ? "pending" : webPush.skipped ? "skipped" : "failed",
|
|
293
|
+
attempts: 0,
|
|
294
|
+
error: webPush.sender ? "" : webPush.error,
|
|
295
|
+
createdAt: Date.now(),
|
|
296
|
+
updatedAt: Date.now(),
|
|
297
|
+
})
|
|
298
|
+
if (!webPush.sender) continue
|
|
299
|
+
const pushSub: WebPushSubscriptionPayload = {
|
|
300
|
+
endpoint: String(sub.endpoint),
|
|
301
|
+
keys: {
|
|
302
|
+
p256dh: String(sub.p256dh),
|
|
303
|
+
auth: String(sub.auth),
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
await webPush.sender.sendNotification(
|
|
308
|
+
pushSub,
|
|
309
|
+
JSON.stringify({
|
|
310
|
+
id: row.id,
|
|
311
|
+
title: row.title,
|
|
312
|
+
body: row.body,
|
|
313
|
+
url: row.url,
|
|
314
|
+
tag: row.dedupeKey || row.id,
|
|
315
|
+
data: safeJson(row.dataJson),
|
|
316
|
+
}),
|
|
317
|
+
)
|
|
318
|
+
patchRaw(db, DELIVERIES_TABLE, String(delivery.id), {
|
|
319
|
+
status: "sent",
|
|
320
|
+
attempts: 1,
|
|
321
|
+
error: "",
|
|
322
|
+
updatedAt: Date.now(),
|
|
323
|
+
})
|
|
324
|
+
} catch (err) {
|
|
325
|
+
const statusCode = typeof err === "object" && err && "statusCode" in err
|
|
326
|
+
? Number((err as { statusCode?: unknown }).statusCode)
|
|
327
|
+
: 0
|
|
328
|
+
if (statusCode === 404 || statusCode === 410) {
|
|
329
|
+
patchRaw(db, SUBSCRIPTIONS_TABLE, String(sub.id), {
|
|
330
|
+
disabledAt: Date.now(),
|
|
331
|
+
updatedAt: Date.now(),
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
patchRaw(db, DELIVERIES_TABLE, String(delivery.id), {
|
|
335
|
+
status: "failed",
|
|
336
|
+
attempts: 1,
|
|
337
|
+
error: err instanceof Error ? err.message : String(err),
|
|
338
|
+
updatedAt: Date.now(),
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function safeJson(raw: string): unknown {
|
|
345
|
+
if (!raw) return {}
|
|
346
|
+
try {
|
|
347
|
+
return JSON.parse(raw)
|
|
348
|
+
} catch {
|
|
349
|
+
return {}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function notify(input: NotifyInput): Promise<NotificationRow | null> {
|
|
354
|
+
const db = dbOrThrow()
|
|
355
|
+
const userId = input.userId ?? ctx.userId
|
|
356
|
+
if (!userId) return null
|
|
357
|
+
const appId = input.appId ?? ctx.appId ?? "app"
|
|
358
|
+
const channels = desiredChannels(db, input, userId, appId)
|
|
359
|
+
if (!channels.inApp && !channels.push) return null
|
|
360
|
+
|
|
361
|
+
const dedupeKey = input.dedupeKey ?? ""
|
|
362
|
+
if (dedupeKey) {
|
|
363
|
+
const existing = db.raw()
|
|
364
|
+
.prepare(
|
|
365
|
+
`SELECT * FROM ${NOTIFICATIONS_TABLE}
|
|
366
|
+
WHERE _owner = ? AND appId = ? AND dedupeKey = ?
|
|
367
|
+
LIMIT 1`,
|
|
368
|
+
)
|
|
369
|
+
.get(userId, appId, dedupeKey) as NotificationRow | null
|
|
370
|
+
if (existing) return existing
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const now = Date.now()
|
|
374
|
+
let inserted: NotificationRow
|
|
375
|
+
try {
|
|
376
|
+
inserted = insertRaw(db, NOTIFICATIONS_TABLE, {
|
|
377
|
+
_owner: userId,
|
|
378
|
+
appId,
|
|
379
|
+
userId,
|
|
380
|
+
kind: input.kind,
|
|
381
|
+
title: input.title,
|
|
382
|
+
body: input.body ?? "",
|
|
383
|
+
url: input.url ?? "",
|
|
384
|
+
status: "unread",
|
|
385
|
+
priority: input.priority ?? "normal",
|
|
386
|
+
sourceType: input.source?.type ?? "",
|
|
387
|
+
sourceId: input.source?.id ?? "",
|
|
388
|
+
dedupeKey,
|
|
389
|
+
dataJson: input.data ? JSON.stringify(input.data) : "{}",
|
|
390
|
+
readAt: 0,
|
|
391
|
+
createdAt: now,
|
|
392
|
+
}) as unknown as NotificationRow
|
|
393
|
+
} catch (err) {
|
|
394
|
+
if (dedupeKey && err instanceof Error && /UNIQUE/i.test(err.message)) {
|
|
395
|
+
return db.raw()
|
|
396
|
+
.prepare(
|
|
397
|
+
`SELECT * FROM ${NOTIFICATIONS_TABLE}
|
|
398
|
+
WHERE _owner = ? AND appId = ? AND dedupeKey = ?
|
|
399
|
+
LIMIT 1`,
|
|
400
|
+
)
|
|
401
|
+
.get(userId, appId, dedupeKey) as NotificationRow | null
|
|
402
|
+
}
|
|
403
|
+
throw err
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (channels.inApp) {
|
|
407
|
+
insertRaw(db, DELIVERIES_TABLE, {
|
|
408
|
+
_owner: userId,
|
|
409
|
+
notificationId: inserted.id,
|
|
410
|
+
appId,
|
|
411
|
+
userId,
|
|
412
|
+
channel: "in_app",
|
|
413
|
+
status: "sent",
|
|
414
|
+
attempts: 0,
|
|
415
|
+
error: "",
|
|
416
|
+
createdAt: now,
|
|
417
|
+
updatedAt: now,
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
if (channels.push) void fanoutPush(db, inserted)
|
|
421
|
+
return inserted
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function readBody(req: Request): Promise<Record<string, unknown>> {
|
|
425
|
+
try {
|
|
426
|
+
const body = await req.json()
|
|
427
|
+
return body && typeof body === "object" ? body as Record<string, unknown> : {}
|
|
428
|
+
} catch {
|
|
429
|
+
return {}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function requireUser(): string | Response {
|
|
434
|
+
if (!ctx.userId) return Response.json({ error: "Authentication required" }, { status: 401 })
|
|
435
|
+
return ctx.userId
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export async function notificationsConfigHandler(): Promise<Response> {
|
|
439
|
+
return Response.json({
|
|
440
|
+
vapidPublicKey: process.env.VIBES_VAPID_PUBLIC_KEY ?? "",
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function notificationsListHandler(req: Request): Promise<Response> {
|
|
445
|
+
const userId = requireUser()
|
|
446
|
+
if (userId instanceof Response) return userId
|
|
447
|
+
const db = dbOrThrow()
|
|
448
|
+
const url = new URL(req.url)
|
|
449
|
+
const unreadOnly = url.searchParams.get("unread") === "1"
|
|
450
|
+
const rows = db.raw()
|
|
451
|
+
.prepare(
|
|
452
|
+
`SELECT * FROM ${NOTIFICATIONS_TABLE}
|
|
453
|
+
WHERE _owner = ? ${unreadOnly ? "AND status = 'unread'" : ""}
|
|
454
|
+
ORDER BY createdAt DESC
|
|
455
|
+
LIMIT 100`,
|
|
456
|
+
)
|
|
457
|
+
.all(userId) as Array<Record<string, unknown>>
|
|
458
|
+
return Response.json(rows)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export async function notificationsUnreadCountHandler(): Promise<Response> {
|
|
462
|
+
const userId = requireUser()
|
|
463
|
+
if (userId instanceof Response) return userId
|
|
464
|
+
const db = dbOrThrow()
|
|
465
|
+
const row = db.raw()
|
|
466
|
+
.prepare(`SELECT COUNT(*) AS count FROM ${NOTIFICATIONS_TABLE} WHERE _owner = ? AND status = 'unread'`)
|
|
467
|
+
.get(userId) as { count: number } | null
|
|
468
|
+
return Response.json({ count: row?.count ?? 0 })
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export async function notificationsSubscribeHandler(req: Request): Promise<Response> {
|
|
472
|
+
const userId = requireUser()
|
|
473
|
+
if (userId instanceof Response) return userId
|
|
474
|
+
const body = await readBody(req)
|
|
475
|
+
const subscription = body.subscription && typeof body.subscription === "object"
|
|
476
|
+
? body.subscription as Record<string, unknown>
|
|
477
|
+
: body
|
|
478
|
+
const endpoint = typeof subscription.endpoint === "string" ? subscription.endpoint : ""
|
|
479
|
+
const keys = subscription.keys && typeof subscription.keys === "object"
|
|
480
|
+
? subscription.keys as Record<string, unknown>
|
|
481
|
+
: {}
|
|
482
|
+
const p256dh = typeof keys.p256dh === "string" ? keys.p256dh : ""
|
|
483
|
+
const auth = typeof keys.auth === "string" ? keys.auth : ""
|
|
484
|
+
if (!endpoint || !p256dh || !auth) {
|
|
485
|
+
return Response.json({ error: "invalid push subscription" }, { status: 400 })
|
|
486
|
+
}
|
|
487
|
+
const db = dbOrThrow()
|
|
488
|
+
const appId = typeof body.appId === "string" && body.appId ? body.appId : ctx.appId ?? "app"
|
|
489
|
+
const existing = db.raw()
|
|
490
|
+
.prepare(
|
|
491
|
+
`SELECT id FROM ${SUBSCRIPTIONS_TABLE}
|
|
492
|
+
WHERE _owner = ? AND appId = ? AND endpoint = ?
|
|
493
|
+
LIMIT 1`,
|
|
494
|
+
)
|
|
495
|
+
.get(userId, appId, endpoint) as { id: string } | null
|
|
496
|
+
const patch = {
|
|
497
|
+
p256dh,
|
|
498
|
+
auth,
|
|
499
|
+
userAgent: req.headers.get("user-agent") ?? "",
|
|
500
|
+
disabledAt: 0,
|
|
501
|
+
lastSeenAt: Date.now(),
|
|
502
|
+
updatedAt: Date.now(),
|
|
503
|
+
}
|
|
504
|
+
if (existing) {
|
|
505
|
+
patchRaw(db, SUBSCRIPTIONS_TABLE, existing.id, patch)
|
|
506
|
+
return Response.json({ ok: true, id: existing.id })
|
|
507
|
+
}
|
|
508
|
+
const row = insertRaw(db, SUBSCRIPTIONS_TABLE, {
|
|
509
|
+
_owner: userId,
|
|
510
|
+
appId,
|
|
511
|
+
userId,
|
|
512
|
+
endpoint,
|
|
513
|
+
...patch,
|
|
514
|
+
createdAt: Date.now(),
|
|
515
|
+
})
|
|
516
|
+
return Response.json({ ok: true, id: row.id })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export async function notificationsUnsubscribeHandler(req: Request): Promise<Response> {
|
|
520
|
+
const userId = requireUser()
|
|
521
|
+
if (userId instanceof Response) return userId
|
|
522
|
+
const body = await readBody(req)
|
|
523
|
+
const endpoint = typeof body.endpoint === "string" ? body.endpoint : ""
|
|
524
|
+
if (!endpoint) return Response.json({ error: "endpoint required" }, { status: 400 })
|
|
525
|
+
const db = dbOrThrow()
|
|
526
|
+
const appId = typeof body.appId === "string" && body.appId ? body.appId : ctx.appId ?? "app"
|
|
527
|
+
const rows = db.raw()
|
|
528
|
+
.prepare(
|
|
529
|
+
`SELECT id FROM ${SUBSCRIPTIONS_TABLE}
|
|
530
|
+
WHERE _owner = ? AND appId = ? AND endpoint = ?`,
|
|
531
|
+
)
|
|
532
|
+
.all(userId, appId, endpoint) as Array<{ id: string }>
|
|
533
|
+
for (const row of rows) {
|
|
534
|
+
patchRaw(db, SUBSCRIPTIONS_TABLE, row.id, { disabledAt: Date.now(), updatedAt: Date.now() })
|
|
535
|
+
}
|
|
536
|
+
return Response.json({ ok: true })
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function notificationsReadHandler(req: Request): Promise<Response> {
|
|
540
|
+
const userId = requireUser()
|
|
541
|
+
if (userId instanceof Response) return userId
|
|
542
|
+
const body = await readBody(req)
|
|
543
|
+
const db = dbOrThrow()
|
|
544
|
+
const now = Date.now()
|
|
545
|
+
if (Array.isArray(body.ids)) {
|
|
546
|
+
for (const id of body.ids) {
|
|
547
|
+
if (typeof id !== "string") continue
|
|
548
|
+
const row = db.raw()
|
|
549
|
+
.prepare(`SELECT * FROM ${NOTIFICATIONS_TABLE} WHERE id = ? AND _owner = ? LIMIT 1`)
|
|
550
|
+
.get(id, userId) as Record<string, unknown> | null
|
|
551
|
+
if (row) patchRaw(db, NOTIFICATIONS_TABLE, id, { status: "read", readAt: now })
|
|
552
|
+
}
|
|
553
|
+
return Response.json({ ok: true })
|
|
554
|
+
}
|
|
555
|
+
if (body.all === true) {
|
|
556
|
+
const rows = db.raw()
|
|
557
|
+
.prepare(`SELECT id FROM ${NOTIFICATIONS_TABLE} WHERE _owner = ? AND status = 'unread'`)
|
|
558
|
+
.all(userId) as Array<{ id: string }>
|
|
559
|
+
for (const row of rows) {
|
|
560
|
+
patchRaw(db, NOTIFICATIONS_TABLE, row.id, { status: "read", readAt: now })
|
|
561
|
+
}
|
|
562
|
+
return Response.json({ ok: true, count: rows.length })
|
|
563
|
+
}
|
|
564
|
+
return Response.json({ error: "ids or all required" }, { status: 400 })
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export async function notificationsCreateHandler(req: Request): Promise<Response> {
|
|
568
|
+
const userId = requireUser()
|
|
569
|
+
if (userId instanceof Response) return userId
|
|
570
|
+
const body = await readBody(req)
|
|
571
|
+
const row = await notify({
|
|
572
|
+
userId,
|
|
573
|
+
appId: typeof body.appId === "string" ? body.appId : undefined,
|
|
574
|
+
kind: typeof body.kind === "string" ? body.kind : "app_custom",
|
|
575
|
+
title: typeof body.title === "string" ? body.title : "Notification",
|
|
576
|
+
body: typeof body.body === "string" ? body.body : "",
|
|
577
|
+
url: typeof body.url === "string" ? body.url : "",
|
|
578
|
+
priority: body.priority === "low" || body.priority === "high" ? body.priority : "normal",
|
|
579
|
+
source: {
|
|
580
|
+
type: typeof body.sourceType === "string" ? body.sourceType : "",
|
|
581
|
+
id: typeof body.sourceId === "string" ? body.sourceId : "",
|
|
582
|
+
},
|
|
583
|
+
dedupeKey: typeof body.dedupeKey === "string" ? body.dedupeKey : undefined,
|
|
584
|
+
data: body.data && typeof body.data === "object" ? body.data as Record<string, unknown> : undefined,
|
|
585
|
+
})
|
|
586
|
+
return Response.json(row)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function notificationServiceWorkerHandler(): Response {
|
|
590
|
+
return new Response(
|
|
591
|
+
`self.addEventListener("push", (event) => {
|
|
592
|
+
let payload = {};
|
|
593
|
+
try { payload = event.data ? event.data.json() : {}; } catch {}
|
|
594
|
+
const title = payload.title || "Notification";
|
|
595
|
+
const options = {
|
|
596
|
+
body: payload.body || "",
|
|
597
|
+
tag: payload.tag || payload.id || undefined,
|
|
598
|
+
data: { url: payload.url || "/", id: payload.id || "" },
|
|
599
|
+
icon: "/icons/pwa-192x192.png",
|
|
600
|
+
badge: "/icons/pwa-192x192.png",
|
|
601
|
+
};
|
|
602
|
+
event.waitUntil(self.registration.showNotification(title, options));
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
self.addEventListener("notificationclick", (event) => {
|
|
606
|
+
event.notification.close();
|
|
607
|
+
const url = (event.notification.data && event.notification.data.url) || "/";
|
|
608
|
+
event.waitUntil((async () => {
|
|
609
|
+
const allClients = await clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
610
|
+
for (const client of allClients) {
|
|
611
|
+
if ("focus" in client) {
|
|
612
|
+
client.focus();
|
|
613
|
+
if ("navigate" in client) return client.navigate(url);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (clients.openWindow) return clients.openWindow(url);
|
|
618
|
+
})());
|
|
619
|
+
});`,
|
|
620
|
+
{
|
|
621
|
+
headers: {
|
|
622
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
623
|
+
"cache-control": "no-cache",
|
|
624
|
+
"service-worker-allowed": "/__vibes_push/",
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
)
|
|
628
|
+
}
|