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