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 = {
id: string;
deviceId: string;
fcmToken: string;
platform: string;
testMode?: boolean;
@@ -16,40 +17,114 @@ export type StoredRow = {
lastNotifiedAt?: number;
};
type ParsedRow = Omit<StoredRow, "id" | "lastNotifiedAt"> & {
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<Record<string, StoredRow>> {
try {
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;
const markDirty = (): void => {
dirty = true;
};
for (const key of Object.keys(records)) {
let r: ParsedRow = records[key];
const buckets = new Map<string, StoredRow[]>();
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<string, StoredRow> = {};
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<string, StoredRow>;
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<string, StoredRow>): Promise<void> {
export const db = {
async upsert(row: {
deviceId: string;
fcmToken: string;
platform: string;
testMode?: boolean;
updatedAt: Date;
}): Promise<void> {
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<StoredRow | undefined> {
const all = await load();
return all[deviceId];
},
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {
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> {

View File

@@ -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;

View File

@@ -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,