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