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