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.
This commit is contained in:
Jose Olarte III
2026-05-12 21:44:59 +08:00
parent e92ddb7da9
commit fc0cad4f2e
3 changed files with 130 additions and 24 deletions

View File

@@ -8,6 +8,7 @@ const dataFile = path.join(dataDir, "fcm-tokens.json");
export type StoredRow = { export type StoredRow = {
id: string; id: string;
deviceId: string;
fcmToken: string; fcmToken: string;
platform: string; platform: string;
testMode?: boolean; testMode?: boolean;
@@ -16,40 +17,114 @@ export type StoredRow = {
lastNotifiedAt?: number; lastNotifiedAt?: number;
}; };
type ParsedRow = Omit<StoredRow, "id" | "lastNotifiedAt"> & { type ParsedRow = {
id?: string; id?: string;
deviceId?: string;
fcmToken: string;
platform: string;
testMode?: boolean;
createdAt: string;
updatedAt: string;
lastNotifiedAt?: number | 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<Record<string, StoredRow>> { async function load(): Promise<Record<string, StoredRow>> {
try { try {
const raw = await readFile(dataFile, "utf8"); const raw = await readFile(dataFile, "utf8");
const records = JSON.parse(raw) as Record<string, ParsedRow>; const parsed = JSON.parse(raw) as Record<string, ParsedRow>;
let dirty = false; let dirty = false;
const markDirty = (): void => {
dirty = true;
};
for (const key of Object.keys(records)) { const buckets = new Map<string, StoredRow[]>();
let r: ParsedRow = records[key];
if (r.id === undefined || r.id === "") { for (const [mapKey, rawRow] of Object.entries(parsed)) {
r = { ...r, id: randomUUID() }; const row = normalizeParsedRow(mapKey, rawRow, markDirty);
records[key] = r; if (mapKey !== row.deviceId) markDirty();
dirty = true; const list = buckets.get(row.deviceId) ?? [];
} list.push(row);
buckets.set(row.deviceId, list);
}
if (typeof r.lastNotifiedAt === "string") { const out: Record<string, StoredRow> = {};
const ms = Date.parse(r.lastNotifiedAt); for (const [did, rows] of buckets) {
r = { if (rows.length === 1) {
...r, out[did] = rows[0];
lastNotifiedAt: Number.isNaN(ms) ? undefined : ms, } else {
}; out[did] = rows
records[key] = r; .slice(1)
dirty = true; .reduce(
(acc, cur) => mergeDeviceRows(did, acc, cur),
rows[0]
);
markDirty();
} }
} }
const normalized = records as Record<string, StoredRow>; if (dirty) await save(out);
if (dirty) await save(normalized); return out;
return normalized;
} catch (e: unknown) { } catch (e: unknown) {
const code = (e as NodeJS.ErrnoException).code; const code = (e as NodeJS.ErrnoException).code;
if (code === "ENOENT") return {}; if (code === "ENOENT") return {};
@@ -67,17 +142,19 @@ async function save(records: Record<string, StoredRow>): Promise<void> {
export const db = { export const db = {
async upsert(row: { async upsert(row: {
deviceId: string;
fcmToken: string; fcmToken: string;
platform: string; platform: string;
testMode?: boolean; testMode?: boolean;
updatedAt: Date; updatedAt: Date;
}): Promise<void> { }): Promise<void> {
const all = await load(); const all = await load();
const key = row.fcmToken; const key = row.deviceId;
const prev = all[key]; const prev = all[key];
const now = row.updatedAt.toISOString(); const now = row.updatedAt.toISOString();
all[key] = { all[key] = {
id: prev?.id ?? randomUUID(), id: prev?.id ?? randomUUID(),
deviceId: row.deviceId,
fcmToken: row.fcmToken, fcmToken: row.fcmToken,
platform: row.platform, platform: row.platform,
testMode: row.testMode, testMode: row.testMode,
@@ -85,6 +162,13 @@ export const db = {
createdAt: prev?.createdAt ?? now, createdAt: prev?.createdAt ?? now,
lastNotifiedAt: prev?.lastNotifiedAt, lastNotifiedAt: prev?.lastNotifiedAt,
}; };
for (const k of [...Object.keys(all)]) {
if (k !== key && all[k].fcmToken === row.fcmToken) {
delete all[k];
}
}
await save(all); await save(all);
}, },
@@ -93,9 +177,14 @@ export const db = {
return Object.values(all); return Object.values(all);
}, },
async getByDeviceId(deviceId: string): Promise<StoredRow | undefined> {
const all = await load();
return all[deviceId];
},
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> { async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {
const all = await load(); const all = await load();
return all[fcmToken]; return Object.values(all).find((r) => r.fcmToken === fcmToken);
}, },
async update(id: string, patch: { lastNotifiedAt: number }): Promise<void> { async update(id: string, patch: { lastNotifiedAt: number }): Promise<void> {

View File

@@ -1,6 +1,9 @@
export interface Device { export interface Device {
/** Internal row id used for persistence updates. */
id: string; id: string;
pushToken: string; /** Client-provided stable physical device identity. */
deviceId: string;
fcmToken: string;
platform: "ios" | "android" | "web"; platform: "ios" | "android" | "web";
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;

View File

@@ -18,12 +18,17 @@ notificationsRouter.post("/refresh", async (_req, res) => {
}); });
notificationsRouter.post("/register", 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; fcmToken?: unknown;
platform?: unknown; platform?: unknown;
testMode?: 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) { if (typeof fcmToken !== "string" || fcmToken.length === 0) {
res.status(400).json({ error: "fcmToken is required" }); res.status(400).json({ error: "fcmToken is required" });
return; return;
@@ -33,8 +38,17 @@ notificationsRouter.post("/register", async (req, res) => {
return; return;
} }
const canonicalDeviceId = deviceId.trim();
try { 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({ await db.upsert({
deviceId: canonicalDeviceId,
fcmToken, fcmToken,
platform, platform,
testMode: typeof testMode === "boolean" ? testMode : undefined, testMode: typeof testMode === "boolean" ? testMode : undefined,