Send a TimeSafari FCM digest after a completed daily alertSearch with updates, using JWT consumption to avoid repeat sends without changing WAKEUP_PING.

This commit is contained in:
Jose Olarte III
2026-08-31 21:41:34 +08:00
parent 10e80c6f41
commit cfc2cdefa3
9 changed files with 583 additions and 7 deletions
+5
View File
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.13] - 2026.08.31
### Added
- AlertSearch scheduler delivers a TimeSafari FCM digest (`type: alert_search`) when a completed run consumed today's JWT and `digest.hasUpdates` is true; WAKEUP_PING and JWT/cursor rules are unchanged
## [0.1.12] - 2026.08.31
### Added
- `runDailyAlertSearch` includes a Phase 6A `digest` on retrieval attempts (`null` when no batch or today's JWT is missing); JWT consumption is still based on both sources completing, not `hasUpdates`
+2 -2
View File
@@ -77,9 +77,9 @@ The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endo
`runDailyAlertSearch(userId, now?)` uses the latest batch's stored IANA timezone to pick today's unused delegated JWT, runs `runAlertSearchCycle` with that JWT, and marks that specific JWT consumed only when both Endorser and Partner complete (`success` or `empty`, including both empty). Pagination or source failures leave the JWT unused so the same day can be retried. An invalid stored timezone is an error; there is no fallback to the server timezone. After a retrieve, the result includes `digest` from `buildAlertSearchDigest` (six bucket records and counts). `digest` is `null` when there is no batch or no unused JWT for today. Consumption does not depend on `digest.hasUpdates`.
`startAlertSearchScheduler()` (started from `src/index.ts` next to the FCM scheduler) is a **separate** user-level job. It lists distinct `userId`s from `alert_authorization_batches` and calls `runDailyAlertSearch` once per user. It does not use `fcm_registrations`, does not call `sendPushToDevice`, and does not change `WAKEUP_PING`. A process-local in-flight flag skips a tick if a pass is still running.
`startAlertSearchScheduler()` (started from `src/index.ts` next to the FCM scheduler) is a **separate** user-level job. It lists distinct `userId`s from `alert_authorization_batches` and calls `runDailyAlertSearch` once per user. After each run, if the digest is complete with updates **and** today's JWT was consumed, it sends a user-visible FCM message (`title: TimeSafari`, body `You have N new updates.`, data `type: alert_search`) to that user's registered tokens. It does not call `sendPushToDevice` or change `WAKEUP_PING`. Subsequent ticks the same local day see no unused JWT (`digest: null`) and do not resend. FCM send failures are logged and do not roll back cursors or JWT consumption. A process-local in-flight flag skips a tick if a pass is still running.
`buildAlertSearchDigest` maps a retrieve result into structured payload data: per-bucket record arrays, counts, `totalCount`, `hasUpdates`, and Endorser/Partner completion status. It does not invent a notification string; the app/plugin can format copy from those counts and records. Incomplete outcomes (`pagination`, auth, network, etc.) yield `completed: false` and `hasUpdates: false`. Empty successful retrieves are complete with `hasUpdates: false`. Nothing is sent over FCM from this layer yet.
`buildAlertSearchDigest` maps a retrieve result into structured payload data: per-bucket record arrays, counts, `totalCount`, `hasUpdates`, and Endorser/Partner completion status. It does not invent a notification string; FCM uses only `totalCount` for the short body. Incomplete outcomes (`pagination`, auth, network, etc.) yield `completed: false` and `hasUpdates: false`. Empty successful retrieves are complete with `hasUpdates: false` and do not send FCM.
## Storage
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "notification-wakeup-service",
"version": "0.1.12",
"version": "0.1.13",
"private": true,
"type": "module",
"packageManager": "pnpm@11.4.0",
+12
View File
@@ -58,6 +58,18 @@ export type {
AlertSearchSchedulerPassInput,
AlertSearchSchedulerPassResult,
} from "./scheduler.js";
export {
ALERT_SEARCH_FCM_TYPE,
ALERT_SEARCH_NOTIFICATION_TITLE,
alertSearchNotificationBody,
buildAlertSearchNotificationContent,
deliverAlertSearchNotification,
isAlertSearchNotificationEligible,
} from "./notify.js";
export type {
AlertSearchNotificationContent,
AlertSearchNotifyResult,
} from "./notify.js";
export {
ALERT_SEARCH_DIGEST_BUCKETS,
alertSearchDigestDebugSummary,
+234
View File
@@ -0,0 +1,234 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { db as fcmDb } from "../db/fcmTokensSqlite.js";
import { closeDatabase } from "../db/sqlite.js";
import type { DailyAlertSearchResult } from "./daily.js";
import type { AlertSearchDigest } from "./digest.js";
import {
ALERT_SEARCH_FCM_TYPE,
ALERT_SEARCH_NOTIFICATION_TITLE,
alertSearchNotificationBody,
buildAlertSearchNotificationContent,
deliverAlertSearchNotification,
isAlertSearchNotificationEligible,
} from "./notify.js";
const USER = "did:ethr:0xnotifyuser";
function emptyDigest(
overrides: Partial<AlertSearchDigest> = {}
): AlertSearchDigest {
return {
completed: true,
hasUpdates: false,
totalCount: 0,
counts: {
claims: 0,
personalPlanContributions: 0,
trackedPlanUpdates: 0,
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
},
records: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
profilesNearby: [],
},
endorser: { outcome: "empty", completed: true },
partner: { outcome: "empty", completed: true },
...overrides,
};
}
function updatesDigest(totalCount: number): AlertSearchDigest {
return emptyDigest({
completed: true,
hasUpdates: true,
totalCount,
counts: {
claims: totalCount,
personalPlanContributions: 0,
trackedPlanUpdates: 0,
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
},
records: {
claims: Array.from({ length: totalCount }, (_, i) => ({
id: `01H${String(i).padStart(23, "0")}`,
issuedAt: "2026-01-01T00:00:00Z",
issuer: USER,
})),
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
profilesNearby: [],
},
endorser: { outcome: "success", completed: true },
partner: { outcome: "empty", completed: true },
});
}
function daily(
overrides: Partial<DailyAlertSearchResult> = {}
): DailyAlertSearchResult {
return {
userId: USER,
localDay: "2026-08-14",
batchId: "batch-1",
jwtSequence: 1,
endorserOutcome: "success",
partnerOutcome: "empty",
completed: true,
consumed: true,
digest: updatesDigest(7),
...overrides,
};
}
describe("alertSearch notification gate", () => {
it("does not notify when digest is null", () => {
assert.equal(
isAlertSearchNotificationEligible(daily({ digest: null, consumed: false })),
false
);
});
it("does not notify when the digest is incomplete", () => {
assert.equal(
isAlertSearchNotificationEligible(
daily({
completed: false,
consumed: false,
digest: emptyDigest({
completed: false,
hasUpdates: false,
totalCount: 3,
endorser: { outcome: "success", completed: true },
partner: { outcome: "auth", completed: false },
}),
})
),
false
);
});
it("does not notify on a completed empty digest", () => {
assert.equal(
isAlertSearchNotificationEligible(
daily({
digest: emptyDigest(),
consumed: true,
})
),
false
);
});
it("notifies when completed with updates and today's JWT was consumed", () => {
assert.equal(isAlertSearchNotificationEligible(daily()), true);
});
it("does not notify when completed with updates but JWT was not consumed", () => {
assert.equal(
isAlertSearchNotificationEligible(daily({ consumed: false })),
false
);
});
it("puts totalCount in the body without record contents", () => {
const content = buildAlertSearchNotificationContent(7);
assert.equal(content.title, ALERT_SEARCH_NOTIFICATION_TITLE);
assert.equal(content.body, "You have 7 new updates.");
assert.equal(content.type, ALERT_SEARCH_FCM_TYPE);
assert.equal(content.body.includes(USER), false);
assert.equal(content.body.includes("01H"), false);
assert.equal(alertSearchNotificationBody(1), "You have 1 new updates.");
});
});
describe("deliverAlertSearchNotification", () => {
let dir: string;
let previousDataDir: string | undefined;
beforeEach(async () => {
previousDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "alert-search-notify-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (previousDataDir === undefined) {
delete process.env.NOTIFY_DATA_DIR;
} else {
process.env.NOTIFY_DATA_DIR = previousDataDir;
}
await rm(dir, { recursive: true, force: true });
});
it("sends once per distinct token for an eligible digest", async () => {
await fcmDb.upsert({
userId: USER,
deviceId: "d1",
fcmToken: "token-a",
platform: "ios",
updatedAt: new Date(),
});
await fcmDb.upsert({
userId: USER,
deviceId: "d2",
fcmToken: "token-b",
platform: "ios",
updatedAt: new Date(),
});
const sent: string[] = [];
const bodies: string[] = [];
const result = await deliverAlertSearchNotification(daily(), {
send: async (token, content) => {
sent.push(token);
bodies.push(content.body);
assert.equal(content.title, "TimeSafari");
assert.equal(content.type, "alert_search");
return "sent";
},
});
assert.equal(result.eligible, true);
assert.equal(result.sent, 2);
assert.deepEqual(sent.sort(), ["token-a", "token-b"]);
assert.equal(bodies[0], "You have 7 new updates.");
});
it("does not send when ineligible", async () => {
let sends = 0;
const result = await deliverAlertSearchNotification(
daily({ digest: null, consumed: false }),
{
send: async () => {
sends += 1;
return "sent";
},
}
);
assert.equal(result.eligible, false);
assert.equal(sends, 0);
});
it("records per-token failure without throwing", async () => {
const result = await deliverAlertSearchNotification(daily(), {
listTokens: async () => ["t1", "t2"],
send: async (token) => (token === "t1" ? "failed" : "sent"),
});
assert.equal(result.sent, 1);
assert.equal(result.failed, 1);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { db } from "../db/fcmTokensSqlite.js";
import { errorMessage } from "../util/formatElapsed.js";
import type { DailyAlertSearchResult } from "./daily.js";
export const ALERT_SEARCH_NOTIFICATION_TITLE = "TimeSafari";
export const ALERT_SEARCH_FCM_TYPE = "alert_search";
export type AlertSearchNotificationContent = {
title: string;
body: string;
type: typeof ALERT_SEARCH_FCM_TYPE;
};
export type AlertSearchTokenSender = (
fcmToken: string,
content: AlertSearchNotificationContent
) => Promise<"sent" | "failed">;
export type AlertSearchNotifyDeps = {
listTokens?: (userId: string) => Promise<string[]>;
send?: AlertSearchTokenSender;
};
export type AlertSearchNotifyResult = {
eligible: boolean;
sent: number;
failed: number;
};
export function alertSearchNotificationBody(totalCount: number): string {
return `You have ${totalCount} new updates.`;
}
export function buildAlertSearchNotificationContent(
totalCount: number
): AlertSearchNotificationContent {
return {
title: ALERT_SEARCH_NOTIFICATION_TITLE,
body: alertSearchNotificationBody(totalCount),
type: ALERT_SEARCH_FCM_TYPE,
};
}
/**
* Notify only after a completed digest with updates, and only when this run
* consumed today's JWT so later 5-minute ticks (digest null) cannot resend.
*/
export function isAlertSearchNotificationEligible(
result: DailyAlertSearchResult
): boolean {
const digest = result.digest;
return (
result.consumed &&
digest !== null &&
digest.completed &&
digest.hasUpdates &&
digest.totalCount > 0
);
}
async function defaultListTokens(userId: string): Promise<string[]> {
const rows = await db.getByUserId(userId);
const seen = new Set<string>();
const tokens: string[] = [];
for (const row of rows) {
if (seen.has(row.fcmToken)) continue;
seen.add(row.fcmToken);
tokens.push(row.fcmToken);
}
return tokens;
}
async function defaultSend(
fcmToken: string,
content: AlertSearchNotificationContent
): Promise<"sent" | "failed"> {
const { sendAlertSearchPushToDevice } = await import(
"../services/pushService.js"
);
return sendAlertSearchPushToDevice(fcmToken, {
title: content.title,
body: content.body,
});
}
export async function deliverAlertSearchNotification(
result: DailyAlertSearchResult,
deps: AlertSearchNotifyDeps = {}
): Promise<AlertSearchNotifyResult> {
if (!isAlertSearchNotificationEligible(result)) {
return { eligible: false, sent: 0, failed: 0 };
}
const digest = result.digest;
if (digest === null) {
return { eligible: false, sent: 0, failed: 0 };
}
const content = buildAlertSearchNotificationContent(digest.totalCount);
const listTokens = deps.listTokens ?? defaultListTokens;
const send = deps.send ?? defaultSend;
const tokens = await listTokens(result.userId);
let sent = 0;
let failed = 0;
for (const token of tokens) {
try {
const status = await send(token, content);
if (status === "sent") sent += 1;
else failed += 1;
} catch (err) {
failed += 1;
console.error("[AlertSearchNotify] Send threw:", errorMessage(err));
}
}
return { eligible: true, sent, failed };
}
+148 -2
View File
@@ -15,6 +15,7 @@ import {
startAlertSearchScheduler,
stopAlertSearchScheduler,
} from "./scheduler.js";
import type { AlertSearchDigest } from "./digest.js";
const USER_A = "did:ethr:0xusera";
const USER_B = "did:ethr:0xuserb";
@@ -33,6 +34,43 @@ function stubDailyResult(userId: string): DailyAlertSearchResult {
};
}
function eligibleDaily(userId: string): DailyAlertSearchResult {
const digest: AlertSearchDigest = {
completed: true,
hasUpdates: true,
totalCount: 3,
counts: {
claims: 3,
personalPlanContributions: 0,
trackedPlanUpdates: 0,
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
},
records: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
profilesNearby: [],
},
endorser: { outcome: "success", completed: true },
partner: { outcome: "empty", completed: true },
};
return {
userId,
localDay: "2026-08-14",
batchId: "batch-1",
jwtSequence: 1,
endorserOutcome: "success",
partnerOutcome: "empty",
completed: true,
consumed: true,
digest,
};
}
async function seedBatch(userId: string, batchId: string) {
await alertAuthorizationDb.replaceUnusedBatch({
userId,
@@ -168,16 +206,115 @@ describe("alertSearch scheduler pass", () => {
assert.equal(isAlertSearchSchedulerPassInFlight(), false);
stopAlertSearchScheduler();
});
it("notifies once for an eligible digest and not for a non-eligible result", async () => {
await seedBatch(USER_A, "batch-a");
await seedBatch(USER_B, "batch-b");
const notified: string[] = [];
await runAlertSearchSchedulerPass({
runDaily: async (userId) =>
userId === USER_A ? eligibleDaily(userId) : stubDailyResult(userId),
notify: async (result) => {
if (result.digest?.hasUpdates) notified.push(result.userId);
},
});
assert.deepEqual(notified, [USER_A]);
});
it("does not run alertSearch twice for two FCM devices on the same user", async () => {
await seedBatch(USER_A, "batch-a");
await seedDevice(USER_A, "device-1", "token-1");
await seedDevice(USER_A, "device-2", "token-2");
const ran: string[] = [];
const notifyCalls: number[] = [];
await runAlertSearchSchedulerPass({
runDaily: async (userId) => {
ran.push(userId);
return eligibleDaily(userId);
},
notify: async () => {
notifyCalls.push(1);
},
});
assert.deepEqual(ran, [USER_A]);
assert.equal(notifyCalls.length, 1);
});
it("does not notify again after the day's JWT was consumed", async () => {
await seedBatch(USER_A, "batch-a");
const notifies: Array<DailyAlertSearchResult["digest"]> = [];
await runAlertSearchSchedulerPass({
runDaily: async (userId) => eligibleDaily(userId),
notify: async (result) => {
notifies.push(result.digest);
},
});
await runAlertSearchSchedulerPass({
runDaily: async (userId) => stubDailyResult(userId),
notify: async (result) => {
notifies.push(result.digest);
},
});
assert.equal(notifies.length, 2);
assert.equal(notifies[0]?.hasUpdates, true);
assert.equal(notifies[1], null);
});
it("does not suppress a later notification after an incomplete run", async () => {
await seedBatch(USER_A, "batch-a");
const eligible: boolean[] = [];
const first = eligibleDaily(USER_A);
const incompleteDigest = first.digest;
assert.ok(incompleteDigest);
await runAlertSearchSchedulerPass({
runDaily: async () => ({
...first,
completed: false,
consumed: false,
digest: {
...incompleteDigest,
completed: false,
hasUpdates: false,
},
}),
notify: async (result) => {
eligible.push(Boolean(result.consumed && result.digest?.hasUpdates));
},
});
await runAlertSearchSchedulerPass({
runDaily: async (userId) => eligibleDaily(userId),
notify: async (result) => {
eligible.push(Boolean(result.consumed && result.digest?.hasUpdates));
},
});
assert.deepEqual(eligible, [false, true]);
});
it("keeps the daily result when notification throws", async () => {
await seedBatch(USER_A, "batch-a");
let dailyConsumed = false;
const result = await runAlertSearchSchedulerPass({
runDaily: async (userId) => {
const daily = eligibleDaily(userId);
dailyConsumed = daily.consumed;
return daily;
},
notify: async () => {
throw new Error("fcm down");
},
});
assert.equal(result.failed, 0);
assert.equal(dailyConsumed, true);
});
});
describe("alertSearch scheduler isolation from FCM", () => {
it("does not call sendPushToDevice or read fcm_registrations", () => {
it("does not call sendPushToDevice or WAKEUP_PING", () => {
const alertSched = readFileSync(
path.join(process.cwd(), "src/alertSearch/scheduler.ts"),
"utf8"
);
assert.equal(alertSched.includes("sendPushToDevice"), false);
assert.equal(alertSched.includes("fcmTokensSqlite"), false);
assert.equal(alertSched.includes("WAKEUP_PING"), false);
});
@@ -191,4 +328,13 @@ describe("alertSearch scheduler isolation from FCM", () => {
assert.equal(fcmSched.includes("runAlertSearchSchedulerPass"), false);
assert.equal(fcmSched.includes("startAlertSearchScheduler"), false);
});
it("leaves WAKEUP_PING last_notified_at logic on sendPushToDevice", () => {
const push = readFileSync(
path.join(process.cwd(), "src/services/pushService.ts"),
"utf8"
);
assert.equal(push.includes('type: "WAKEUP_PING"'), true);
assert.equal(push.includes("sendAlertSearchPushToDevice"), true);
});
});
+19 -2
View File
@@ -4,6 +4,7 @@ import {
runDailyAlertSearch,
type DailyAlertSearchResult,
} from "./daily.js";
import { deliverAlertSearchNotification } from "./notify.js";
/** Independent of the FCM wakeup interval; does not share that timer. */
export const ALERT_SEARCH_SCHEDULER_INTERVAL_MS = 5 * 60 * 1000;
@@ -12,9 +13,14 @@ export type AlertSearchUserRunner = (
userId: string
) => Promise<DailyAlertSearchResult>;
export type AlertSearchNotifyRunner = (
result: DailyAlertSearchResult
) => Promise<unknown>;
export type AlertSearchSchedulerPassInput = {
listUserIds?: () => Promise<string[]>;
runDaily?: AlertSearchUserRunner;
notify?: AlertSearchNotifyRunner;
};
export type AlertSearchSchedulerPassResult = {
@@ -33,7 +39,8 @@ export function isAlertSearchSchedulerPassInFlight(): boolean {
/**
* One user-oriented alertSearch pass. Skips if a pass is already running.
* Does not send FCM or read fcm_registrations.
* After each daily run, may send an AlertSearch FCM digest. Does not use the
* device wakeup ping path.
*/
export async function runAlertSearchSchedulerPass(
input: AlertSearchSchedulerPassInput = {}
@@ -52,12 +59,22 @@ export async function runAlertSearchSchedulerPass(
input.listUserIds ??
(() => alertAuthorizationDb.listDistinctUserIds());
const runDaily = input.runDaily ?? runDailyAlertSearch;
const notify = input.notify ?? deliverAlertSearchNotification;
const userIds = await listUserIds();
let failed = 0;
for (const userId of userIds) {
try {
await runDaily(userId);
const daily = await runDaily(userId);
try {
await notify(daily);
} catch (err) {
console.error(
"[AlertSearchScheduler] Notification failed",
userId + ":",
errorMessage(err)
);
}
} catch (err) {
failed += 1;
console.error(
+44
View File
@@ -106,3 +106,47 @@ export async function sendPushToDevice(
return "failed";
}
}
export const ALERT_SEARCH_FCM_TYPE = "alert_search";
/**
* User-visible AlertSearch digest push. Does not apply the WAKEUP_PING
* last_notified_at window and does not write last_notified_at.
*/
export async function sendAlertSearchPushToDevice(
fcmToken: string,
content: { title: string; body: string }
): Promise<"sent" | "failed"> {
const suffix = maskToken(fcmToken);
const sendStarted = Date.now();
console.log("[AlertSearchPush] Send attempt, token suffix:", suffix);
try {
await messaging.send({
token: fcmToken,
notification: {
title: content.title,
body: content.body,
},
data: {
type: ALERT_SEARCH_FCM_TYPE,
},
});
console.log(
"[AlertSearchPush] Send completed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
suffix
);
return "sent";
} catch (err) {
console.error(
"[AlertSearchPush] Send failed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
suffix + ":",
errorMessage(err)
);
return "failed";
}
}