diff --git a/CHANGELOG.md b/CHANGELOG.md index 80caffe..8273f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6d8651a..2e9bc4d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,9 @@ Authorization: Bearer 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 diff --git a/package.json b/package.json index 89f18c8..42bd605 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/alertSearch/daily.test.ts b/src/alertSearch/daily.test.ts new file mode 100644 index 0000000..309b0ca --- /dev/null +++ b/src/alertSearch/daily.test.ts @@ -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) { + return { + data: { + claims: [], + personalPlanContributions: [], + trackedPlanUpdates: [], + trackedPlanClaims: [], + plansNearby: [], + ...overrides, + }, + }; +} + +function emptyPartnerBody(overrides?: Record) { + 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 +): { 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"); + }); +}); diff --git a/src/alertSearch/daily.ts b/src/alertSearch/daily.ts new file mode 100644 index 0000000..dcf6b6a --- /dev/null +++ b/src/alertSearch/daily.ts @@ -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; + +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 { + 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, + }; +} diff --git a/src/alertSearch/index.ts b/src/alertSearch/index.ts index 1ac80db..31aabef 100644 --- a/src/alertSearch/index.ts +++ b/src/alertSearch/index.ts @@ -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"; diff --git a/src/db/alertAuthorizationSqlite.ts b/src/db/alertAuthorizationSqlite.ts index 7bb2f14..7d4d69c 100644 --- a/src/db/alertAuthorizationSqlite.ts +++ b/src/db/alertAuthorizationSqlite.ts @@ -232,4 +232,44 @@ export const alertAuthorizationDb = { .get(userId) as BatchDbRow | undefined; return row === undefined ? undefined : toBatchRecord(row); }, + + async getJwtById( + id: string + ): Promise { + 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 { + 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; + }, }; diff --git a/src/services/alertAuthorization.ts b/src/services/alertAuthorization.ts index 8f3bbb1..912471d 100644 --- a/src/services/alertAuthorization.ts +++ b/src/services/alertAuthorization.ts @@ -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",