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:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user