feat(db): track last push time by device id with numeric timestamps
Assign stable ids to stored tokens, migrate legacy ISO lastNotifiedAt to epoch ms, replace setLastNotifiedAt with db.update, and persist lastNotifiedAt only after a successful FCM send. Extend Device with optional lastNotifiedAt (ms).
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -5,19 +6,50 @@ const dataDir =
|
||||
process.env.FCM_TOKEN_DATA_DIR ?? path.join(process.cwd(), "data");
|
||||
const dataFile = path.join(dataDir, "fcm-tokens.json");
|
||||
|
||||
type StoredRow = {
|
||||
export type StoredRow = {
|
||||
id: string;
|
||||
fcmToken: string;
|
||||
platform: string;
|
||||
testMode?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastNotifiedAt?: string;
|
||||
lastNotifiedAt?: number;
|
||||
};
|
||||
|
||||
type ParsedRow = Omit<StoredRow, "id" | "lastNotifiedAt"> & {
|
||||
id?: string;
|
||||
lastNotifiedAt?: number | string;
|
||||
};
|
||||
|
||||
async function load(): Promise<Record<string, StoredRow>> {
|
||||
try {
|
||||
const raw = await readFile(dataFile, "utf8");
|
||||
return JSON.parse(raw) as Record<string, StoredRow>;
|
||||
const records = JSON.parse(raw) as Record<string, ParsedRow>;
|
||||
let dirty = false;
|
||||
|
||||
for (const key of Object.keys(records)) {
|
||||
let r: ParsedRow = records[key];
|
||||
|
||||
if (r.id === undefined || r.id === "") {
|
||||
r = { ...r, id: randomUUID() };
|
||||
records[key] = r;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
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 normalized = records as Record<string, StoredRow>;
|
||||
if (dirty) await save(normalized);
|
||||
return normalized;
|
||||
} catch (e: unknown) {
|
||||
const code = (e as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") return {};
|
||||
@@ -45,6 +77,7 @@ export const db = {
|
||||
const prev = all[key];
|
||||
const now = row.updatedAt.toISOString();
|
||||
all[key] = {
|
||||
id: prev?.id ?? randomUUID(),
|
||||
fcmToken: row.fcmToken,
|
||||
platform: row.platform,
|
||||
testMode: row.testMode,
|
||||
@@ -65,11 +98,12 @@ export const db = {
|
||||
return all[fcmToken];
|
||||
},
|
||||
|
||||
async setLastNotifiedAt(fcmToken: string, at: Date): Promise<void> {
|
||||
async update(id: string, patch: { lastNotifiedAt: number }): Promise<void> {
|
||||
const all = await load();
|
||||
const row = all[fcmToken];
|
||||
if (row === undefined) return;
|
||||
all[fcmToken] = { ...row, lastNotifiedAt: at.toISOString() };
|
||||
const found = Object.entries(all).find(([, r]) => r.id === id);
|
||||
if (found === undefined) return;
|
||||
const [key, row] = found;
|
||||
all[key] = { ...row, ...patch };
|
||||
await save(all);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,4 +4,6 @@ export interface Device {
|
||||
platform: "ios" | "android" | "web";
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
/** Epoch ms; set only after a successful push send. */
|
||||
lastNotifiedAt?: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db, type StoredRow } from "../db/fcmTokens.js";
|
||||
import { messaging } from "./firebase.js";
|
||||
import { db } from "../db/fcmTokens.js";
|
||||
|
||||
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
|
||||
const MS_TEST = 10 * 60 * 1000;
|
||||
@@ -8,6 +8,13 @@ function notifyThresholdMs(testMode?: boolean): number {
|
||||
return testMode === true ? MS_TEST : MS_PRODUCTION;
|
||||
}
|
||||
|
||||
function lastNotifiedMs(row: StoredRow | undefined): number | undefined {
|
||||
const v = row?.lastNotifiedAt;
|
||||
if (v === undefined) return undefined;
|
||||
if (typeof v === "number") return Number.isNaN(v) ? undefined : v;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringifyData(
|
||||
payload: Record<string, unknown>
|
||||
): Record<string, string> {
|
||||
@@ -28,14 +35,11 @@ export async function sendPushToDevice(
|
||||
): Promise<"sent" | "skipped" | "failed"> {
|
||||
const row = await db.getByFcmToken(fcmToken);
|
||||
const now = Date.now();
|
||||
const lastNotifiedAt = row?.lastNotifiedAt
|
||||
? Date.parse(row.lastNotifiedAt)
|
||||
: undefined;
|
||||
const last = lastNotifiedMs(row);
|
||||
|
||||
if (
|
||||
lastNotifiedAt !== undefined &&
|
||||
!Number.isNaN(lastNotifiedAt) &&
|
||||
now - lastNotifiedAt < notifyThresholdMs(row?.testMode)
|
||||
last !== undefined &&
|
||||
now - last < notifyThresholdMs(row?.testMode)
|
||||
) {
|
||||
return "skipped";
|
||||
}
|
||||
@@ -62,7 +66,11 @@ export async function sendPushToDevice(
|
||||
},
|
||||
data,
|
||||
});
|
||||
await db.setLastNotifiedAt(fcmToken, new Date());
|
||||
|
||||
const persisted = await db.getByFcmToken(fcmToken);
|
||||
if (persisted !== undefined) {
|
||||
await db.update(persisted.id, { lastNotifiedAt: Date.now() });
|
||||
}
|
||||
return "sent";
|
||||
} catch (err) {
|
||||
console.error("FCM send failed", err);
|
||||
|
||||
Reference in New Issue
Block a user