151 lines
4.3 KiB
TypeScript
151 lines
4.3 KiB
TypeScript
import {
|
|
alertSearchCursorsDb,
|
|
smsAlertSearchCursorsDb,
|
|
type AlertSearchCursorsStore,
|
|
} from "../db/alertSearchCursorsSqlite.js";
|
|
import { isAlertSearchCursorUlid } from "./params.js";
|
|
import type {
|
|
AlertSearchSourceResult,
|
|
RetrieveAlertSearchResult,
|
|
} from "./retrieve.js";
|
|
import type {
|
|
AlertSearchChannel,
|
|
EndorserAlertSearchData,
|
|
PartnerAlertSearchData,
|
|
} from "./types.js";
|
|
|
|
export type StoredAlertSearchCursors = {
|
|
endorserAfterId?: string;
|
|
partnerAfterDate?: string;
|
|
};
|
|
|
|
export type CursorAdvanceResult = {
|
|
endorserAdvanced: boolean;
|
|
partnerAdvanced: boolean;
|
|
endorserAfterId: string | null;
|
|
partnerAfterAt: string | null;
|
|
};
|
|
|
|
const CURSOR_STORES: Record<AlertSearchChannel, AlertSearchCursorsStore> = {
|
|
fcm: alertSearchCursorsDb,
|
|
sms: smsAlertSearchCursorsDb,
|
|
};
|
|
|
|
const COMPLETE_OUTCOMES = new Set(["success"]);
|
|
|
|
function isCompleteOutcome(outcome: string): boolean {
|
|
return COMPLETE_OUTCOMES.has(outcome);
|
|
}
|
|
|
|
/** MAX ULID across Endorser alertSearch identifier fields. */
|
|
export function maxEndorserAlertSearchUlid(
|
|
data: EndorserAlertSearchData
|
|
): string | undefined {
|
|
const ids: string[] = [];
|
|
for (const row of data.claims) {
|
|
if (isAlertSearchCursorUlid(row.id)) ids.push(row.id);
|
|
}
|
|
for (const row of data.personalPlanContributions) {
|
|
if (isAlertSearchCursorUlid(row.id)) ids.push(row.id);
|
|
}
|
|
for (const row of data.trackedPlanClaims) {
|
|
if (isAlertSearchCursorUlid(row.id)) ids.push(row.id);
|
|
}
|
|
for (const row of data.trackedPlanUpdates) {
|
|
if (isAlertSearchCursorUlid(row.jwtId)) ids.push(row.jwtId);
|
|
}
|
|
for (const row of data.plansNearby) {
|
|
if (isAlertSearchCursorUlid(row.jwtId)) ids.push(row.jwtId);
|
|
}
|
|
if (ids.length === 0) return undefined;
|
|
ids.sort();
|
|
return ids[ids.length - 1];
|
|
}
|
|
|
|
/** MAX profilesNearby.updatedAt (ISO). Not rowId. */
|
|
export function maxPartnerUpdatedAt(
|
|
data: PartnerAlertSearchData
|
|
): string | undefined {
|
|
let best: string | undefined;
|
|
let bestMs = Number.NEGATIVE_INFINITY;
|
|
for (const row of data.profilesNearby) {
|
|
if (typeof row.updatedAt !== "string" || row.updatedAt.length === 0) {
|
|
continue;
|
|
}
|
|
const ms = Date.parse(row.updatedAt);
|
|
if (Number.isNaN(ms)) continue;
|
|
if (ms > bestMs) {
|
|
bestMs = ms;
|
|
best = row.updatedAt;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
export function nextEndorserCursorFromResult(
|
|
endorser: AlertSearchSourceResult<EndorserAlertSearchData>
|
|
): string | undefined {
|
|
if (!isCompleteOutcome(endorser.outcome)) return undefined;
|
|
return maxEndorserAlertSearchUlid(endorser.data);
|
|
}
|
|
|
|
export function nextPartnerCursorFromResult(
|
|
partner: AlertSearchSourceResult<PartnerAlertSearchData>
|
|
): string | undefined {
|
|
if (!isCompleteOutcome(partner.outcome)) return undefined;
|
|
return maxPartnerUpdatedAt(partner.data);
|
|
}
|
|
|
|
/**
|
|
* Load stored cursors for a retrieval query.
|
|
* Omits missing values so first run sends no afterId / afterDate / "0".
|
|
*/
|
|
export async function loadAlertSearchCursors(
|
|
userId: string,
|
|
channel: AlertSearchChannel = "fcm"
|
|
): Promise<StoredAlertSearchCursors> {
|
|
const row = await CURSOR_STORES[channel].get(userId);
|
|
const out: StoredAlertSearchCursors = {};
|
|
if (row?.endorserAfterId && isAlertSearchCursorUlid(row.endorserAfterId)) {
|
|
out.endorserAfterId = row.endorserAfterId;
|
|
}
|
|
if (row?.partnerAfterAt && row.partnerAfterAt.length > 0) {
|
|
out.partnerAfterDate = row.partnerAfterAt;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Advance each cursor independently after retrieveAlertSearch.
|
|
* Does not persist on empty, pagination, or error outcomes.
|
|
*/
|
|
export async function advanceAlertSearchCursors(
|
|
userId: string,
|
|
result: RetrieveAlertSearchResult,
|
|
channel: AlertSearchChannel = "fcm"
|
|
): Promise<CursorAdvanceResult> {
|
|
const store = CURSOR_STORES[channel];
|
|
let endorserAdvanced = false;
|
|
let partnerAdvanced = false;
|
|
|
|
const nextEndorser = nextEndorserCursorFromResult(result.endorser);
|
|
if (nextEndorser !== undefined) {
|
|
await store.setEndorserAfterId(userId, nextEndorser);
|
|
endorserAdvanced = true;
|
|
}
|
|
|
|
const nextPartner = nextPartnerCursorFromResult(result.partner);
|
|
if (nextPartner !== undefined) {
|
|
await store.setPartnerAfterAt(userId, nextPartner);
|
|
partnerAdvanced = true;
|
|
}
|
|
|
|
const stored = await store.get(userId);
|
|
return {
|
|
endorserAdvanced,
|
|
partnerAdvanced,
|
|
endorserAfterId: stored?.endorserAfterId ?? null,
|
|
partnerAfterAt: stored?.partnerAfterAt ?? null,
|
|
};
|
|
}
|