feat(push): dedupe FCM sends with 23h / 10m windows

Track lastNotifiedAt on stored tokens, preserve it on register upsert,
and skip messaging.send when inside the production or test-mode window.
This commit is contained in:
Jose Olarte III
2026-05-11 16:56:07 +08:00
parent 2b57ec0e1c
commit 1115929437
2 changed files with 67 additions and 6 deletions

View File

@@ -11,6 +11,7 @@ type StoredRow = {
testMode?: boolean;
createdAt: string;
updatedAt: string;
lastNotifiedAt?: string;
};
async function load(): Promise<Record<string, StoredRow>> {
@@ -49,7 +50,21 @@ export const db = {
testMode: row.testMode,
updatedAt: now,
createdAt: prev?.createdAt ?? now,
lastNotifiedAt: prev?.lastNotifiedAt,
};
await save(all);
},
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {
const all = await load();
return all[fcmToken];
},
async setLastNotifiedAt(fcmToken: string, at: Date): Promise<void> {
const all = await load();
const row = all[fcmToken];
if (row === undefined) return;
all[fcmToken] = { ...row, lastNotifiedAt: at.toISOString() };
await save(all);
},
};

View File

@@ -1,8 +1,54 @@
import type { Device } from "../models/device.js";
import { messaging } from "./firebase.js";
import { db } from "../db/fcmTokens.js";
export async function sendPushToDevice(
_device: Device,
_payload: Record<string, unknown>
): Promise<void> {
// TODO: integrate with push provider (FCM, APNs, etc.)
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
const MS_TEST = 10 * 60 * 1000;
function notifyThresholdMs(testMode?: boolean): number {
return testMode === true ? MS_TEST : MS_PRODUCTION;
}
function stringifyData(
payload: Record<string, unknown>
): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(payload)) {
out[k] = v === undefined || v === null ? "" : String(v);
}
return out;
}
/**
* Sends an FCM data message if the token is outside the dedupe window
* (23h production, 10m test).
*/
export async function sendPushToDevice(
fcmToken: string,
payload: Record<string, unknown> = {}
): Promise<"sent" | "skipped" | "failed"> {
const row = await db.getByFcmToken(fcmToken);
const now = Date.now();
const lastNotifiedAt = row?.lastNotifiedAt
? Date.parse(row.lastNotifiedAt)
: undefined;
if (
lastNotifiedAt !== undefined &&
!Number.isNaN(lastNotifiedAt) &&
now - lastNotifiedAt < notifyThresholdMs(row?.testMode)
) {
return "skipped";
}
try {
await messaging.send({
token: fcmToken,
data: stringifyData(payload),
});
await db.setLastNotifiedAt(fcmToken, new Date());
return "sent";
} catch (err) {
console.error("FCM send failed", err);
return "failed";
}
}