Add a callable daily alertSearch run that picks today’s unused delegated JWT from the batch timezone and consumes it only after both sources complete.
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.9] - 2026.08.28
|
||||
### Added
|
||||
- `runDailyAlertSearch` selects today's unused delegated JWT from the stored batch timezone, runs `runAlertSearchCycle`, and consumes that JWT only when both Endorser and Partner complete (`success` or `empty`)
|
||||
|
||||
|
||||
## [0.1.8] - 2026.08.27
|
||||
### Added
|
||||
- `runAlertSearchCycle` integration of cursor load, alertSearch retrieve, and independent cursor advance
|
||||
|
||||
@@ -73,7 +73,9 @@ Authorization: Bearer <current-user-JWT>
|
||||
|
||||
The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endorserAfterId` / `partnerAfterDate` (or omit them on first run). Nearby search uses the alertSearch bbox (`minLocLat`, `maxLocLat`, `minLocLon`, `maxLocLon`).
|
||||
|
||||
`loadAlertSearchCursors` / `retrieveAlertSearch` / `advanceAlertSearchCursors` (or `runAlertSearchCycle`) persist those bounds per user DID in SQLite. Cursors advance only after a complete `success` retrieval (not `empty`, `pagination`, or errors). A Partner page of 50 rows that share the oldest `updatedAt` is `pagination` because exclusive `beforeDate` cannot drain timestamp ties. They are not wired to the scheduler yet.
|
||||
`loadAlertSearchCursors` / `retrieveAlertSearch` / `advanceAlertSearchCursors` (or `runAlertSearchCycle`) persist those bounds per user DID in SQLite. Cursors advance only after a complete `success` retrieval (not `empty`, `pagination`, or errors). A Partner page of 50 rows that share the oldest `updatedAt` is `pagination` because exclusive `beforeDate` cannot drain timestamp ties.
|
||||
|
||||
`runDailyAlertSearch(userId, now?)` is a callable daily orchestrator (not a cron job). It 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. This is not wired to the existing FCM scheduler.
|
||||
|
||||
## Storage
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "notification-wakeup-service",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.4.0",
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
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 {
|
||||
ALERT_JWT_STATUS_CONSUMED,
|
||||
ALERT_JWT_STATUS_UNUSED,
|
||||
alertAuthorizationDb,
|
||||
type AlertAuthorizationJwtInput,
|
||||
} from "../db/alertAuthorizationSqlite.js";
|
||||
import { alertSearchCursorsDb } from "../db/alertSearchCursorsSqlite.js";
|
||||
import { closeDatabase } from "../db/sqlite.js";
|
||||
import type { FetchLike } from "./client.js";
|
||||
import {
|
||||
InvalidAlertAuthorizationTimezoneError,
|
||||
runDailyAlertSearch,
|
||||
} from "./daily.js";
|
||||
import { ALERT_SEARCH_PAGE_SIZE } from "./types.js";
|
||||
|
||||
const USER = "did:ethr:0xdailyuser";
|
||||
const ENDORSER_BASE = "https://api.endorser.ch";
|
||||
const PARTNER_BASE = "https://partner-api.endorser.ch";
|
||||
|
||||
/** 2026-08-15T06:00:00Z is 2026-08-14 in America/Los_Angeles and 2026-08-15 in Pacific/Auckland. */
|
||||
const NOW_SPLIT = new Date("2026-08-15T06:00:00.000Z");
|
||||
const DAY_LA = "2026-08-14";
|
||||
const DAY_AUCKLAND = "2026-08-15";
|
||||
const JWT_LA = "delegated.jwt.los-angeles-day";
|
||||
const JWT_AUCKLAND = "delegated.jwt.auckland-day";
|
||||
|
||||
function ulid(n: number): string {
|
||||
return `01H${String(n).padStart(23, "0")}`;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function emptyEndorserBody(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
data: {
|
||||
claims: [],
|
||||
personalPlanContributions: [],
|
||||
trackedPlanUpdates: [],
|
||||
trackedPlanClaims: [],
|
||||
plansNearby: [],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPartnerBody(overrides?: Record<string, unknown>) {
|
||||
return { data: { profilesNearby: [], ...overrides } };
|
||||
}
|
||||
|
||||
function claimRows(from: number, count: number) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: ulid(from + i),
|
||||
issuedAt: "2026-01-01T00:00:00Z",
|
||||
issuer: "did:ethr:0x1",
|
||||
}));
|
||||
}
|
||||
|
||||
function profileRows(
|
||||
count: number,
|
||||
updatedAt: (index: number) => string
|
||||
) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
issuerDid: `did:ethr:0x${i}`,
|
||||
description: "p",
|
||||
updatedAt: updatedAt(i),
|
||||
rowId: i + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function captureFetch(
|
||||
handler: (url: string, init?: RequestInit) => Response | Promise<Response>
|
||||
): { fetch: FetchLike; urls: string[]; auths: string[] } {
|
||||
const urls: string[] = [];
|
||||
const auths: string[] = [];
|
||||
const fetchImpl: FetchLike = async (url, init) => {
|
||||
urls.push(url);
|
||||
auths.push(new Headers(init?.headers).get("Authorization") ?? "");
|
||||
return handler(url, init);
|
||||
};
|
||||
return { fetch: fetchImpl, urls, auths };
|
||||
}
|
||||
|
||||
function jwtInput(
|
||||
sequence: number,
|
||||
day: string,
|
||||
jwt: string
|
||||
): AlertAuthorizationJwtInput {
|
||||
return { sequence, day, jwt, nbf: 1, exp: 2 };
|
||||
}
|
||||
|
||||
async function seedBatch(
|
||||
timezone: string,
|
||||
jwts: AlertAuthorizationJwtInput[],
|
||||
userId = USER,
|
||||
batchId = "batch-1"
|
||||
) {
|
||||
await alertAuthorizationDb.replaceUnusedBatch({
|
||||
userId,
|
||||
batchId,
|
||||
timezone,
|
||||
jwts,
|
||||
});
|
||||
}
|
||||
|
||||
function cycleOpts(fetch: FetchLike) {
|
||||
return {
|
||||
endorserBaseUrl: ENDORSER_BASE,
|
||||
partnerBaseUrl: PARTNER_BASE,
|
||||
config: { fetch },
|
||||
};
|
||||
}
|
||||
|
||||
function bothEmptyFetch() {
|
||||
return captureFetch((url) => {
|
||||
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
||||
return jsonResponse(emptyEndorserBody());
|
||||
});
|
||||
}
|
||||
|
||||
function bothSuccessFetch() {
|
||||
return captureFetch((url) => {
|
||||
if (url.includes("/api/partner/")) {
|
||||
return jsonResponse(
|
||||
emptyPartnerBody({
|
||||
profilesNearby: [
|
||||
{
|
||||
issuerDid: "did:ethr:0x1",
|
||||
description: "p",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
rowId: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return jsonResponse(
|
||||
emptyEndorserBody({
|
||||
claims: claimRows(10, 1),
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("runDailyAlertSearch", () => {
|
||||
let dir: string;
|
||||
let previousDataDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousDataDir = process.env.NOTIFY_DATA_DIR;
|
||||
dir = await mkdtemp(path.join(tmpdir(), "alert-search-daily-"));
|
||||
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("selects today's JWT using the batch stored timezone", async () => {
|
||||
await seedBatch("America/Los_Angeles", [
|
||||
jwtInput(1, DAY_LA, JWT_LA),
|
||||
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND),
|
||||
]);
|
||||
const cap = bothEmptyFetch();
|
||||
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
||||
assert.equal(result.localDay, DAY_LA);
|
||||
assert.equal(result.jwtSequence, 1);
|
||||
assert.equal(result.completed, true);
|
||||
assert.equal(result.consumed, true);
|
||||
for (const auth of cap.auths) {
|
||||
assert.equal(auth, `Bearer ${JWT_LA}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not select a JWT belonging to another local day", async () => {
|
||||
await seedBatch("Pacific/Auckland", [
|
||||
jwtInput(1, DAY_LA, JWT_LA),
|
||||
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND),
|
||||
]);
|
||||
const cap = bothEmptyFetch();
|
||||
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
||||
assert.equal(result.localDay, DAY_AUCKLAND);
|
||||
assert.equal(result.jwtSequence, 2);
|
||||
for (const auth of cap.auths) {
|
||||
assert.equal(auth, `Bearer ${JWT_AUCKLAND}`);
|
||||
assert.equal(auth.includes(JWT_LA), false);
|
||||
}
|
||||
const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA);
|
||||
assert.equal(other?.jwt, JWT_LA);
|
||||
assert.equal(other?.status, ALERT_JWT_STATUS_UNUSED);
|
||||
});
|
||||
|
||||
it("throws a clear error for an invalid stored timezone", async () => {
|
||||
await seedBatch("Not/AZone", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
await assert.rejects(
|
||||
() => runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(bothEmptyFetch().fetch)),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof InvalidAlertAuthorizationTimezoneError);
|
||||
assert.equal(err.timezone, "Not/AZone");
|
||||
assert.match(err.message, /IANA/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("throws a clear error for a missing stored timezone", async () => {
|
||||
await seedBatch("", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
await assert.rejects(
|
||||
() => runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(bothEmptyFetch().fetch)),
|
||||
InvalidAlertAuthorizationTimezoneError
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a structured no-JWT result when today has no unused JWT", async () => {
|
||||
await seedBatch("America/Los_Angeles", [
|
||||
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND),
|
||||
]);
|
||||
const result = await runDailyAlertSearch(
|
||||
USER,
|
||||
NOW_SPLIT,
|
||||
cycleOpts(bothEmptyFetch().fetch)
|
||||
);
|
||||
assert.equal(result.userId, USER);
|
||||
assert.equal(result.localDay, DAY_LA);
|
||||
assert.equal(result.batchId, "batch-1");
|
||||
assert.equal(result.jwtSequence, null);
|
||||
assert.equal(result.endorserOutcome, null);
|
||||
assert.equal(result.partnerOutcome, null);
|
||||
assert.equal(result.completed, false);
|
||||
assert.equal(result.consumed, false);
|
||||
});
|
||||
|
||||
it("returns a structured no-JWT result when the user has no batch", async () => {
|
||||
const result = await runDailyAlertSearch(USER, NOW_SPLIT);
|
||||
assert.equal(result.localDay, null);
|
||||
assert.equal(result.batchId, null);
|
||||
assert.equal(result.jwtSequence, null);
|
||||
assert.equal(result.completed, false);
|
||||
assert.equal(result.consumed, false);
|
||||
});
|
||||
|
||||
it("passes today's JWT to runAlertSearchCycle", async () => {
|
||||
await seedBatch("America/Los_Angeles", [
|
||||
jwtInput(1, DAY_LA, JWT_LA),
|
||||
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND),
|
||||
]);
|
||||
const cap = bothSuccessFetch();
|
||||
await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
||||
assert.ok(cap.urls.some((u) => u.includes("/api/v2/report/alertSearch")));
|
||||
assert.ok(cap.urls.some((u) => u.includes("/api/partner/alertSearch")));
|
||||
assert.ok(cap.auths.length >= 2);
|
||||
for (const auth of cap.auths) {
|
||||
assert.equal(auth, `Bearer ${JWT_LA}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes today's JWT when both sources succeed", async () => {
|
||||
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
const result = await runDailyAlertSearch(
|
||||
USER,
|
||||
NOW_SPLIT,
|
||||
cycleOpts(bothSuccessFetch().fetch)
|
||||
);
|
||||
assert.equal(result.endorserOutcome, "success");
|
||||
assert.equal(result.partnerOutcome, "success");
|
||||
assert.equal(result.completed, true);
|
||||
assert.equal(result.consumed, true);
|
||||
const leftover = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA);
|
||||
assert.equal(leftover, undefined);
|
||||
});
|
||||
|
||||
it("consumes today's JWT when both sources are empty", async () => {
|
||||
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
const result = await runDailyAlertSearch(
|
||||
USER,
|
||||
NOW_SPLIT,
|
||||
cycleOpts(bothEmptyFetch().fetch)
|
||||
);
|
||||
assert.equal(result.endorserOutcome, "empty");
|
||||
assert.equal(result.partnerOutcome, "empty");
|
||||
assert.equal(result.completed, true);
|
||||
assert.equal(result.consumed, true);
|
||||
});
|
||||
|
||||
it("does not consume when Endorser succeeds and Partner fails", async () => {
|
||||
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
const cap = captureFetch((url) => {
|
||||
if (url.includes("/api/partner/")) {
|
||||
return jsonResponse({ error: "unauthorized" }, 401);
|
||||
}
|
||||
return jsonResponse(emptyEndorserBody({ claims: claimRows(10, 1) }));
|
||||
});
|
||||
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
||||
assert.equal(result.endorserOutcome, "success");
|
||||
assert.equal(result.partnerOutcome, "auth");
|
||||
assert.equal(result.completed, false);
|
||||
assert.equal(result.consumed, false);
|
||||
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA);
|
||||
assert.equal(unused?.jwt, JWT_LA);
|
||||
const stored = await alertSearchCursorsDb.get(USER);
|
||||
assert.equal(stored?.endorserAfterId, ulid(10));
|
||||
assert.equal(stored?.partnerAfterAt, null);
|
||||
});
|
||||
|
||||
it("does not consume when Partner succeeds and Endorser fails", async () => {
|
||||
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
const cap = captureFetch((url) => {
|
||||
if (url.includes("/api/partner/")) {
|
||||
return jsonResponse(
|
||||
emptyPartnerBody({
|
||||
profilesNearby: [
|
||||
{
|
||||
issuerDid: "did:ethr:0x1",
|
||||
description: "p",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
rowId: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return jsonResponse({ error: "unauthorized" }, 401);
|
||||
});
|
||||
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
||||
assert.equal(result.endorserOutcome, "auth");
|
||||
assert.equal(result.partnerOutcome, "success");
|
||||
assert.equal(result.completed, false);
|
||||
assert.equal(result.consumed, false);
|
||||
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA);
|
||||
assert.equal(unused?.status, ALERT_JWT_STATUS_UNUSED);
|
||||
const stored = await alertSearchCursorsDb.get(USER);
|
||||
assert.equal(stored?.endorserAfterId, null);
|
||||
assert.equal(stored?.partnerAfterAt, "2026-03-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("does not consume on Endorser pagination", async () => {
|
||||
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
const cap = captureFetch((url) => {
|
||||
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
||||
return jsonResponse({
|
||||
...emptyEndorserBody(),
|
||||
userMessage:
|
||||
"Some data was not available in this search. Check the detail screens for the full set of data.",
|
||||
});
|
||||
});
|
||||
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
||||
assert.equal(result.endorserOutcome, "pagination");
|
||||
assert.equal(result.partnerOutcome, "empty");
|
||||
assert.equal(result.completed, false);
|
||||
assert.equal(result.consumed, false);
|
||||
});
|
||||
|
||||
it("does not consume on Partner pagination", async () => {
|
||||
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
const cap = captureFetch((url) => {
|
||||
if (url.includes("/api/partner/")) {
|
||||
const tied = "2026-01-01T12:00:00.000Z";
|
||||
return jsonResponse(
|
||||
emptyPartnerBody({
|
||||
profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied),
|
||||
})
|
||||
);
|
||||
}
|
||||
return jsonResponse(emptyEndorserBody());
|
||||
});
|
||||
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
||||
assert.equal(result.endorserOutcome, "empty");
|
||||
assert.equal(result.partnerOutcome, "pagination");
|
||||
assert.equal(result.completed, false);
|
||||
assert.equal(result.consumed, false);
|
||||
});
|
||||
|
||||
it("does not consume on network, auth, timeout, malformed, or http failure", async () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
fetch: FetchLike;
|
||||
expected: string;
|
||||
}> = [
|
||||
{
|
||||
name: "network",
|
||||
expected: "network",
|
||||
fetch: async () => {
|
||||
throw new TypeError("fetch failed");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "auth",
|
||||
expected: "auth",
|
||||
fetch: async () => jsonResponse({ error: "no jwt" }, 403),
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
expected: "timeout",
|
||||
fetch: async () => {
|
||||
const err = new Error("The operation was aborted");
|
||||
err.name = "TimeoutError";
|
||||
throw err;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed",
|
||||
expected: "malformed",
|
||||
fetch: async () => jsonResponse({ data: { hitLimit: true } }),
|
||||
},
|
||||
{
|
||||
name: "http",
|
||||
expected: "http",
|
||||
fetch: async () => jsonResponse({ error: "boom" }, 500),
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
closeDatabase();
|
||||
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(c.fetch));
|
||||
assert.equal(result.endorserOutcome, c.expected, c.name);
|
||||
assert.equal(result.partnerOutcome, c.expected, c.name);
|
||||
assert.equal(result.completed, false, c.name);
|
||||
assert.equal(result.consumed, false, c.name);
|
||||
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA);
|
||||
assert.equal(unused?.jwt, JWT_LA, c.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes the exact selected JWT row, not another day's unused JWT", async () => {
|
||||
await seedBatch("America/Los_Angeles", [
|
||||
jwtInput(1, DAY_LA, JWT_LA),
|
||||
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND),
|
||||
]);
|
||||
const today = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA);
|
||||
const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_AUCKLAND);
|
||||
assert.ok(today);
|
||||
assert.ok(other);
|
||||
const result = await runDailyAlertSearch(
|
||||
USER,
|
||||
NOW_SPLIT,
|
||||
cycleOpts(bothEmptyFetch().fetch)
|
||||
);
|
||||
assert.equal(result.consumed, true);
|
||||
assert.equal(result.jwtSequence, 1);
|
||||
const consumedRow = await alertAuthorizationDb.getJwtById(today.id);
|
||||
assert.equal(consumedRow?.status, ALERT_JWT_STATUS_CONSUMED);
|
||||
assert.ok(consumedRow?.consumedAt);
|
||||
const stillUnused = await alertAuthorizationDb.getJwtById(other.id);
|
||||
assert.equal(stillUnused?.status, ALERT_JWT_STATUS_UNUSED);
|
||||
assert.equal(stillUnused?.consumedAt, undefined);
|
||||
});
|
||||
|
||||
it("does not select or consume the same JWT after it has been consumed", async () => {
|
||||
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
const first = await runDailyAlertSearch(
|
||||
USER,
|
||||
NOW_SPLIT,
|
||||
cycleOpts(bothEmptyFetch().fetch)
|
||||
);
|
||||
assert.equal(first.consumed, true);
|
||||
const second = await runDailyAlertSearch(
|
||||
USER,
|
||||
NOW_SPLIT,
|
||||
cycleOpts(bothEmptyFetch().fetch)
|
||||
);
|
||||
assert.equal(second.jwtSequence, null);
|
||||
assert.equal(second.endorserOutcome, null);
|
||||
assert.equal(second.completed, false);
|
||||
assert.equal(second.consumed, false);
|
||||
assert.equal(await alertAuthorizationDb.countUnused(USER), 0);
|
||||
});
|
||||
|
||||
it("does not alter Phase 4B cursor rules: empty does not advance; success does", async () => {
|
||||
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
await runDailyAlertSearch(
|
||||
USER,
|
||||
NOW_SPLIT,
|
||||
cycleOpts(bothEmptyFetch().fetch)
|
||||
);
|
||||
const afterEmpty = await alertSearchCursorsDb.get(USER);
|
||||
assert.equal(afterEmpty, undefined);
|
||||
|
||||
closeDatabase();
|
||||
await seedBatch("America/Los_Angeles", [jwtInput(1, DAY_LA, JWT_LA)]);
|
||||
await runDailyAlertSearch(
|
||||
USER,
|
||||
NOW_SPLIT,
|
||||
cycleOpts(bothSuccessFetch().fetch)
|
||||
);
|
||||
const afterSuccess = await alertSearchCursorsDb.get(USER);
|
||||
assert.equal(afterSuccess?.endorserAfterId, ulid(10));
|
||||
assert.equal(afterSuccess?.partnerAfterAt, "2026-03-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { alertAuthorizationDb } from "../db/alertAuthorizationSqlite.js";
|
||||
import {
|
||||
calendarDayInTimeZone,
|
||||
isValidIanaTimeZone,
|
||||
} from "../services/alertAuthorization.js";
|
||||
import {
|
||||
runAlertSearchCycle,
|
||||
type AlertSearchCycleInput,
|
||||
} from "./cycle.js";
|
||||
import type { AlertSearchQueryOutcome } from "./types.js";
|
||||
|
||||
export class InvalidAlertAuthorizationTimezoneError extends Error {
|
||||
readonly timezone: string;
|
||||
|
||||
constructor(timezone: string) {
|
||||
super(
|
||||
`Alert authorization batch timezone is not a valid IANA time zone: ${timezone}`
|
||||
);
|
||||
this.name = "InvalidAlertAuthorizationTimezoneError";
|
||||
this.timezone = timezone;
|
||||
}
|
||||
}
|
||||
|
||||
/** Retrieval finished for the daily run (cursor advance still follows Phase 4B). */
|
||||
export function sourceCompletedDailyRun(
|
||||
outcome: AlertSearchQueryOutcome
|
||||
): boolean {
|
||||
return outcome === "success" || outcome === "empty";
|
||||
}
|
||||
|
||||
export type DailyAlertSearchCycleInput = Omit<AlertSearchCycleInput, "jwt">;
|
||||
|
||||
export type DailyAlertSearchResult = {
|
||||
userId: string;
|
||||
localDay: string | null;
|
||||
batchId: string | null;
|
||||
jwtSequence: number | null;
|
||||
endorserOutcome: AlertSearchQueryOutcome | null;
|
||||
partnerOutcome: AlertSearchQueryOutcome | null;
|
||||
completed: boolean;
|
||||
consumed: boolean;
|
||||
};
|
||||
|
||||
function noJwtResult(
|
||||
userId: string,
|
||||
localDay: string | null,
|
||||
batchId: string | null
|
||||
): DailyAlertSearchResult {
|
||||
return {
|
||||
userId,
|
||||
localDay,
|
||||
batchId,
|
||||
jwtSequence: null,
|
||||
endorserOutcome: null,
|
||||
partnerOutcome: null,
|
||||
completed: false,
|
||||
consumed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select today's unused delegated JWT (batch IANA timezone + stored day),
|
||||
* run the existing alertSearch cycle, and consume that JWT only when both
|
||||
* required sources completed (success or empty). Not invoked by the scheduler.
|
||||
*/
|
||||
export async function runDailyAlertSearch(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
cycleInput: DailyAlertSearchCycleInput = {}
|
||||
): Promise<DailyAlertSearchResult> {
|
||||
const batch = await alertAuthorizationDb.getLatestBatch(userId);
|
||||
if (batch === undefined) {
|
||||
return noJwtResult(userId, null, null);
|
||||
}
|
||||
if (!isValidIanaTimeZone(batch.timezone)) {
|
||||
throw new InvalidAlertAuthorizationTimezoneError(batch.timezone);
|
||||
}
|
||||
|
||||
const localDay = calendarDayInTimeZone(
|
||||
Math.floor(now.getTime() / 1000),
|
||||
batch.timezone
|
||||
);
|
||||
const selected = await alertAuthorizationDb.getUnusedForDay(userId, localDay);
|
||||
if (selected === undefined) {
|
||||
return noJwtResult(userId, localDay, batch.batchId);
|
||||
}
|
||||
|
||||
const cycle = await runAlertSearchCycle(userId, {
|
||||
...cycleInput,
|
||||
jwt: selected.jwt,
|
||||
});
|
||||
|
||||
const endorserOutcome = cycle.retrieved.endorser.outcome;
|
||||
const partnerOutcome = cycle.retrieved.partner.outcome;
|
||||
const completed =
|
||||
sourceCompletedDailyRun(endorserOutcome) &&
|
||||
sourceCompletedDailyRun(partnerOutcome);
|
||||
|
||||
let consumed = false;
|
||||
if (completed) {
|
||||
consumed = await alertAuthorizationDb.consumeUnusedJwt({
|
||||
id: selected.id,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
localDay,
|
||||
batchId: selected.batchId,
|
||||
jwtSequence: selected.sequence,
|
||||
endorserOutcome,
|
||||
partnerOutcome,
|
||||
completed,
|
||||
consumed,
|
||||
};
|
||||
}
|
||||
@@ -39,3 +39,12 @@ export {
|
||||
export type { CursorAdvanceResult, StoredAlertSearchCursors } from "./cursors.js";
|
||||
export { runAlertSearchCycle } from "./cycle.js";
|
||||
export type { AlertSearchCycleInput, AlertSearchCycleResult } from "./cycle.js";
|
||||
export {
|
||||
InvalidAlertAuthorizationTimezoneError,
|
||||
runDailyAlertSearch,
|
||||
sourceCompletedDailyRun,
|
||||
} from "./daily.js";
|
||||
export type {
|
||||
DailyAlertSearchCycleInput,
|
||||
DailyAlertSearchResult,
|
||||
} from "./daily.js";
|
||||
|
||||
@@ -232,4 +232,44 @@ export const alertAuthorizationDb = {
|
||||
.get(userId) as BatchDbRow | undefined;
|
||||
return row === undefined ? undefined : toBatchRecord(row);
|
||||
},
|
||||
|
||||
async getJwtById(
|
||||
id: string
|
||||
): Promise<AlertAuthorizationJwtRecord | undefined> {
|
||||
const row = getDatabase()
|
||||
.prepare(
|
||||
`
|
||||
SELECT ${JWT_COLUMNS} FROM alert_authorization_jwts
|
||||
WHERE id = ?
|
||||
`
|
||||
)
|
||||
.get(id) as JwtDbRow | undefined;
|
||||
return row === undefined ? undefined : toJwtRecord(row);
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark one unused JWT consumed. Matches the specific row, not "any unused for today".
|
||||
*/
|
||||
async consumeUnusedJwt(input: {
|
||||
id: string;
|
||||
userId: string;
|
||||
}): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = getDatabase()
|
||||
.prepare(
|
||||
`
|
||||
UPDATE alert_authorization_jwts
|
||||
SET status = ?, consumed_at = ?
|
||||
WHERE id = ? AND user_id = ? AND status = ?
|
||||
`
|
||||
)
|
||||
.run(
|
||||
ALERT_JWT_STATUS_CONSUMED,
|
||||
now,
|
||||
input.id,
|
||||
input.userId,
|
||||
ALERT_JWT_STATUS_UNUSED
|
||||
);
|
||||
return result.changes === 1;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,7 +44,8 @@ type JwtItemBody = {
|
||||
exp?: unknown;
|
||||
};
|
||||
|
||||
function isValidIanaTimeZone(timezone: string): boolean {
|
||||
export function isValidIanaTimeZone(timezone: string): boolean {
|
||||
if (timezone.length === 0) return false;
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
||||
return true;
|
||||
@@ -53,7 +54,7 @@ function isValidIanaTimeZone(timezone: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function calendarDayInTimeZone(epochSec: number, timeZone: string): string {
|
||||
export function calendarDayInTimeZone(epochSec: number, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
|
||||
Reference in New Issue
Block a user