From fc0cad4f2eb3b10df0a4b5f706f9c2c3a5e68d90 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Tue, 12 May 2026 21:44:59 +0800 Subject: [PATCH] feat(register): key devices by deviceId and replace FCM tokens in place Require deviceId on POST /notifications/register, upsert by deviceId while preserving lastNotifiedAt and internal id, prune duplicate token rows, migrate legacy fcmToken-keyed JSON, and add register logs. Extend StoredRow and Device with deviceId; resolve pushes by scanning fcmToken. --- src/db/fcmTokens.ts | 133 ++++++++++++++++++++++++++++++------ src/models/device.ts | 5 +- src/routes/notifications.ts | 16 ++++- 3 files changed, 130 insertions(+), 24 deletions(-) diff --git a/src/db/fcmTokens.ts b/src/db/fcmTokens.ts index e3854bf..178e810 100644 --- a/src/db/fcmTokens.ts +++ b/src/db/fcmTokens.ts @@ -8,6 +8,7 @@ const dataFile = path.join(dataDir, "fcm-tokens.json"); export type StoredRow = { id: string; + deviceId: string; fcmToken: string; platform: string; testMode?: boolean; @@ -16,40 +17,114 @@ export type StoredRow = { lastNotifiedAt?: number; }; -type ParsedRow = Omit & { +type ParsedRow = { id?: string; + deviceId?: string; + fcmToken: string; + platform: string; + testMode?: boolean; + createdAt: string; + updatedAt: string; lastNotifiedAt?: number | string; }; +function mergeDeviceRows( + deviceId: string, + a: StoredRow, + b: StoredRow +): StoredRow { + const primary = + new Date(a.updatedAt) >= new Date(b.updatedAt) ? a : b; + const lastMs = Math.max(a.lastNotifiedAt ?? 0, b.lastNotifiedAt ?? 0); + const created = + new Date(a.createdAt) <= new Date(b.createdAt) + ? a.createdAt + : b.createdAt; + return { + ...primary, + id: primary.id, + deviceId, + fcmToken: primary.fcmToken, + lastNotifiedAt: lastMs > 0 ? lastMs : undefined, + createdAt: created, + }; +} + +function normalizeParsedRow( + mapKey: string, + r: ParsedRow, + onMutate: () => void +): StoredRow { + let id = r.id; + if (id === undefined || id === "") { + id = randomUUID(); + onMutate(); + } + + let lastNotifiedAt: number | undefined; + if (typeof r.lastNotifiedAt === "string") { + const ms = Date.parse(r.lastNotifiedAt); + lastNotifiedAt = Number.isNaN(ms) ? undefined : ms; + onMutate(); + } else if (typeof r.lastNotifiedAt === "number") { + lastNotifiedAt = Number.isNaN(r.lastNotifiedAt) + ? undefined + : r.lastNotifiedAt; + } + + const deviceId = (r.deviceId ?? r.fcmToken ?? mapKey).trim(); + if (r.deviceId === undefined || r.deviceId === "") { + onMutate(); + } + + return { + id, + deviceId, + fcmToken: r.fcmToken, + platform: r.platform, + testMode: r.testMode, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + lastNotifiedAt, + }; +} + async function load(): Promise> { try { const raw = await readFile(dataFile, "utf8"); - const records = JSON.parse(raw) as Record; + const parsed = JSON.parse(raw) as Record; let dirty = false; + const markDirty = (): void => { + dirty = true; + }; - for (const key of Object.keys(records)) { - let r: ParsedRow = records[key]; + const buckets = new Map(); - if (r.id === undefined || r.id === "") { - r = { ...r, id: randomUUID() }; - records[key] = r; - dirty = true; - } + for (const [mapKey, rawRow] of Object.entries(parsed)) { + const row = normalizeParsedRow(mapKey, rawRow, markDirty); + if (mapKey !== row.deviceId) markDirty(); + const list = buckets.get(row.deviceId) ?? []; + list.push(row); + buckets.set(row.deviceId, list); + } - if (typeof r.lastNotifiedAt === "string") { - const ms = Date.parse(r.lastNotifiedAt); - r = { - ...r, - lastNotifiedAt: Number.isNaN(ms) ? undefined : ms, - }; - records[key] = r; - dirty = true; + const out: Record = {}; + for (const [did, rows] of buckets) { + if (rows.length === 1) { + out[did] = rows[0]; + } else { + out[did] = rows + .slice(1) + .reduce( + (acc, cur) => mergeDeviceRows(did, acc, cur), + rows[0] + ); + markDirty(); } } - const normalized = records as Record; - if (dirty) await save(normalized); - return normalized; + if (dirty) await save(out); + return out; } catch (e: unknown) { const code = (e as NodeJS.ErrnoException).code; if (code === "ENOENT") return {}; @@ -67,17 +142,19 @@ async function save(records: Record): Promise { export const db = { async upsert(row: { + deviceId: string; fcmToken: string; platform: string; testMode?: boolean; updatedAt: Date; }): Promise { const all = await load(); - const key = row.fcmToken; + const key = row.deviceId; const prev = all[key]; const now = row.updatedAt.toISOString(); all[key] = { id: prev?.id ?? randomUUID(), + deviceId: row.deviceId, fcmToken: row.fcmToken, platform: row.platform, testMode: row.testMode, @@ -85,6 +162,13 @@ export const db = { createdAt: prev?.createdAt ?? now, lastNotifiedAt: prev?.lastNotifiedAt, }; + + for (const k of [...Object.keys(all)]) { + if (k !== key && all[k].fcmToken === row.fcmToken) { + delete all[k]; + } + } + await save(all); }, @@ -93,9 +177,14 @@ export const db = { return Object.values(all); }, + async getByDeviceId(deviceId: string): Promise { + const all = await load(); + return all[deviceId]; + }, + async getByFcmToken(fcmToken: string): Promise { const all = await load(); - return all[fcmToken]; + return Object.values(all).find((r) => r.fcmToken === fcmToken); }, async update(id: string, patch: { lastNotifiedAt: number }): Promise { diff --git a/src/models/device.ts b/src/models/device.ts index 60122e0..711c9ab 100644 --- a/src/models/device.ts +++ b/src/models/device.ts @@ -1,6 +1,9 @@ export interface Device { + /** Internal row id used for persistence updates. */ id: string; - pushToken: string; + /** Client-provided stable physical device identity. */ + deviceId: string; + fcmToken: string; platform: "ios" | "android" | "web"; createdAt: Date; updatedAt: Date; diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index b69910a..b1e0973 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -18,12 +18,17 @@ notificationsRouter.post("/refresh", async (_req, res) => { }); notificationsRouter.post("/register", async (req, res) => { - const { fcmToken, platform, testMode } = req.body as { + const { deviceId, fcmToken, platform, testMode } = req.body as { + deviceId?: unknown; fcmToken?: unknown; platform?: unknown; testMode?: unknown; }; + if (typeof deviceId !== "string" || deviceId.trim().length === 0) { + res.status(400).json({ error: "deviceId is required" }); + return; + } if (typeof fcmToken !== "string" || fcmToken.length === 0) { res.status(400).json({ error: "fcmToken is required" }); return; @@ -33,8 +38,17 @@ notificationsRouter.post("/register", async (req, res) => { return; } + const canonicalDeviceId = deviceId.trim(); + try { + const existing = await db.getByDeviceId(canonicalDeviceId); + console.log("[Register] Upserting device:", canonicalDeviceId); + if (existing !== undefined && existing.fcmToken !== fcmToken) { + console.log("[Register] Replacing token for device:", canonicalDeviceId); + } + await db.upsert({ + deviceId: canonicalDeviceId, fcmToken, platform, testMode: typeof testMode === "boolean" ? testMode : undefined,