diff --git a/package.json b/package.json index a286b72..e225075 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "sms:send": "tsx scripts/sms-send.ts", "sms:smoke": "tsx scripts/sms-smoke.ts", "twilio:whoami": "tsx scripts/twilio-whoami.ts", - "sms:blocks": "tsx scripts/sms-blocks.ts" + "sms:blocks": "tsx scripts/sms-blocks.ts", + "alert-search:tap": "tsx scripts/alert-search-tap.ts" }, "dependencies": { "@peculiar/asn1-ecc": "^2.7.0", diff --git a/scripts/alert-search-tap.ts b/scripts/alert-search-tap.ts new file mode 100644 index 0000000..6470ac2 --- /dev/null +++ b/scripts/alert-search-tap.ts @@ -0,0 +1,83 @@ +/** + * Send one visible AlertSearch FCM notification to one registered device. + * Does not start the server, run the daily scheduler, consume a JWT, or + * advance a cursor. + * + * pnpm alert-search:tap + * pnpm alert-search:tap -- --device-id + * + * Uses FIREBASE_SERVICE_ACCOUNT_JSON (or ADC) and the local notification + * database (NOTIFY_DATA_DIR, default ./data). Omitting --device-id lists + * registrations and sends nothing. + */ +import "../src/env.js"; +import { db } from "../src/db/fcmTokensSqlite.js"; +import { sendAlertSearchPushToDevice } from "../src/services/pushService.js"; +import { maskToken } from "../src/util/maskToken.js"; + +const TITLE = "AlertSearch tap test"; +const BODY = "Tap this notification."; + +function deviceIdArg(argv: string[]): string | undefined { + const inline = argv.find((arg) => arg.startsWith("--device-id=")); + if (inline !== undefined) { + const value = inline.slice("--device-id=".length).trim(); + return value.length > 0 ? value : undefined; + } + const index = argv.indexOf("--device-id"); + if (index === -1) return undefined; + const value = argv[index + 1]?.trim(); + return value !== undefined && value.length > 0 ? value : undefined; +} + +function maskUserId(userId: string): string { + if (userId.length <= 16) return "…"; + return userId.slice(0, 12) + "…" + userId.slice(-4); +} + +const rows = await db.getAll(); +const deviceId = deviceIdArg(process.argv.slice(2)); + +if (deviceId === undefined) { + console.log("Pass --device-id to send one notification. Registered devices:"); + if (rows.length === 0) { + console.log("(none)"); + } + for (const row of rows) { + console.log( + [ + row.platform, + row.deviceId, + "token …" + maskToken(row.fcmToken), + maskUserId(row.userId), + ].join(" ") + ); + } + process.exit(rows.length === 0 ? 1 : 0); +} + +const matches = rows.filter((row) => row.deviceId === deviceId); +if (matches.length !== 1) { + console.error( + `Expected one registration for device ${deviceId}, found ${matches.length}.` + ); + process.exit(1); +} + +const row = matches[0]; +const status = await sendAlertSearchPushToDevice(row.fcmToken, { + title: TITLE, + body: BODY, +}); +console.log( + JSON.stringify({ + status, + deviceId: row.deviceId, + platform: row.platform, + tokenSuffix: maskToken(row.fcmToken), + title: TITLE, + body: BODY, + dataType: "alert_search", + }) +); +process.exit(status === "sent" ? 0 : 1); diff --git a/src/alertSearch/categories.ts b/src/alertSearch/categories.ts new file mode 100644 index 0000000..64c2d9b --- /dev/null +++ b/src/alertSearch/categories.ts @@ -0,0 +1,318 @@ +import type { + AlertSearchClaimRecord, + AlertSearchJwtWithClaimRecord, + AlertSearchPlanRecord, + AlertSearchProfileRecord, + CombinedAlertSearchData, +} from "./types.js"; + +/** + * Claim `@type` values Endorser stores on a jwt row (`claim.service.js`). + * `fromEntity` / `toEntity` are written only for GiveAction and Offer, and the + * plan-contribution queries filter to those two types. Any other claimType is + * kept as a broader category rather than called an offer or a give. + */ +export const GIVE_CLAIM_TYPE = "GiveAction"; +export const OFFER_CLAIM_TYPE = "Offer"; + +export const ENDORSER_CLAIM_TYPES = [ + "AgreeAction", + "Confirmation", + "Emoji", + "GiveAction", + "JoinAction", + "Offer", + "Organization", + "PlanAction", + "Project", + "RegisterAction", + "Tenure", + "VoteAction", +] as const; + +export type ClaimUpdateKind = "give" | "offer" | "other"; + +/** + * Notification lines, most specific relationship first. A record that the APIs + * place in more than one list is counted only in the first category that + * claims it (see `countAlertSearchUpdates`). + */ +export const ALERT_SEARCH_UPDATE_CATEGORIES = [ + "offerToOwnedProject", + "giveToOwnedProject", + "otherContributionToOwnedProject", + "offerToStarredProject", + "giveToStarredProject", + "otherClaimOnStarredProject", + "starredProjectUpdate", + "offerToYou", + "giveToYou", + "otherClaimToYou", + "offerFromYou", + "giveFromYou", + "otherClaimFromYou", + "projectNearby", + "profileNearby", + "projectSimilar", + "profileSimilar", +] as const; + +export type AlertSearchUpdateCategory = + (typeof ALERT_SEARCH_UPDATE_CATEGORIES)[number]; + +export type AlertSearchUpdateCounts = { + [K in AlertSearchUpdateCategory]: number; +}; + +export function claimUpdateKind(claimType: unknown): ClaimUpdateKind { + if (claimType === GIVE_CLAIM_TYPE) return "give"; + if (claimType === OFFER_CLAIM_TYPE) return "offer"; + return "other"; +} + +function emptyCounts(): AlertSearchUpdateCounts { + return { + offerToOwnedProject: 0, + giveToOwnedProject: 0, + otherContributionToOwnedProject: 0, + offerToStarredProject: 0, + giveToStarredProject: 0, + otherClaimOnStarredProject: 0, + starredProjectUpdate: 0, + offerToYou: 0, + giveToYou: 0, + otherClaimToYou: 0, + offerFromYou: 0, + giveFromYou: 0, + otherClaimFromYou: 0, + projectNearby: 0, + profileNearby: 0, + projectSimilar: 0, + profileSimilar: 0, + }; +} + +function idPart(value: unknown): string | undefined { + if (typeof value === "string" && value.length > 0) return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return undefined; +} + +function ownedProjectCategory(kind: ClaimUpdateKind): AlertSearchUpdateCategory { + if (kind === "offer") return "offerToOwnedProject"; + if (kind === "give") return "giveToOwnedProject"; + return "otherContributionToOwnedProject"; +} + +function starredClaimCategory(kind: ClaimUpdateKind): AlertSearchUpdateCategory { + if (kind === "offer") return "offerToStarredProject"; + if (kind === "give") return "giveToStarredProject"; + return "otherClaimOnStarredProject"; +} + +function claimSideCategory( + kind: ClaimUpdateKind, + side: "to" | "from" +): AlertSearchUpdateCategory { + if (side === "to") { + if (kind === "offer") return "offerToYou"; + if (kind === "give") return "giveToYou"; + return "otherClaimToYou"; + } + if (kind === "offer") return "offerFromYou"; + if (kind === "give") return "giveFromYou"; + return "otherClaimFromYou"; +} + +/** + * `jwtsWithDidAfterId` returns a row only when `toEntity` or `fromEntity` is + * the requester and the issuer is not. `toEntity` is the recipient; `fromEntity` + * is the giver or offerer. There is no third side. + */ +function claimAboutYouCategory( + row: AlertSearchClaimRecord, + userId: string | undefined +): AlertSearchUpdateCategory { + const kind = claimUpdateKind(row.claimType); + if (userId !== undefined && row.toEntity === userId) { + return claimSideCategory(kind, "to"); + } + return claimSideCategory(kind, "from"); +} + +function planKeys(plan: AlertSearchPlanRecord): string[] { + const keys: string[] = []; + const handleId = idPart(plan.handleId); + const jwtId = idPart(plan.jwtId); + if (handleId !== undefined) keys.push(`plan:${handleId}`); + if (jwtId !== undefined) keys.push(`planJwt:${jwtId}`); + return keys; +} + +function profileKeys(profile: AlertSearchProfileRecord): string[] { + const keys: string[] = []; + const rowId = idPart(profile.rowId); + const issuerDid = idPart(profile.issuerDid); + if (rowId !== undefined) keys.push(`profile:${rowId}`); + if (issuerDid !== undefined) keys.push(`profileDid:${issuerDid}`); + return keys; +} + +function jwtKey(row: AlertSearchClaimRecord): string | undefined { + const id = idPart(row.id); + return id === undefined ? undefined : `jwt:${id}`; +} + +/** + * Counts each underlying record once. The same jwt id can be returned in + * `personalPlanContributions` and `trackedPlanClaims` when the recipient owns + * and also tracks that project, and the same plan or profile can sit in a + * tracked/nearby list and a semantic-match list. Ownership beats tracking, + * tracking beats a personal claim, a tracked project update beats nearby, and + * nearby beats a semantic match. + */ +export function countAlertSearchUpdates( + records: CombinedAlertSearchData, + userId?: string +): AlertSearchUpdateCounts { + const counts = emptyCounts(); + const seenKeys = new Set(); + const seenObjects = new WeakSet(); + + const take = ( + row: object, + keys: string[], + category: AlertSearchUpdateCategory + ): void => { + if (seenObjects.has(row)) return; + if (keys.some((key) => seenKeys.has(key))) return; + seenObjects.add(row); + for (const key of keys) seenKeys.add(key); + counts[category] += 1; + }; + + const takeClaim = ( + row: AlertSearchJwtWithClaimRecord | AlertSearchClaimRecord, + category: AlertSearchUpdateCategory + ): void => { + const key = jwtKey(row); + take(row, key === undefined ? [] : [key], category); + }; + + for (const row of records.personalPlanContributions) { + takeClaim(row, ownedProjectCategory(claimUpdateKind(row.claimType))); + } + for (const row of records.trackedPlanClaims) { + takeClaim(row, starredClaimCategory(claimUpdateKind(row.claimType))); + } + for (const row of records.claims) { + takeClaim(row, claimAboutYouCategory(row, userId)); + } + for (const row of records.trackedPlanUpdates) { + take(row, planKeys(row), "starredProjectUpdate"); + } + for (const row of records.plansNearby) { + take(row, planKeys(row), "projectNearby"); + } + for (const row of records.plansSimilar) { + take(row.plan, planKeys(row.plan), "projectSimilar"); + } + for (const row of records.profilesNearby) { + take(row, profileKeys(row), "profileNearby"); + } + for (const row of records.profilesSimilar) { + take(row.profile, profileKeys(row.profile), "profileSimilar"); + } + + return counts; +} + +export function totalAlertSearchUpdates(counts: AlertSearchUpdateCounts): number { + return ALERT_SEARCH_UPDATE_CATEGORIES.reduce( + (sum, category) => sum + counts[category], + 0 + ); +} + +function line(count: number, singular: string, plural: string): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +/** One category fragment, without the "You have" prefix. */ +export function formatAlertSearchUpdateLine( + category: AlertSearchUpdateCategory, + count: number +): string { + switch (category) { + case "offerToOwnedProject": + return line( + count, + "new offer to a project you own", + "new offers to projects you own" + ); + case "giveToOwnedProject": + return line( + count, + "new give to a project you own", + "new gives to projects you own" + ); + case "otherContributionToOwnedProject": + return line( + count, + "new contribution to a project you own", + "new contributions to projects you own" + ); + case "offerToStarredProject": + return line( + count, + "new offer to a starred project", + "new offers to your starred projects" + ); + case "giveToStarredProject": + return line( + count, + "new give to a starred project", + "new gives to your starred projects" + ); + case "otherClaimOnStarredProject": + return line( + count, + "new claim on a starred project", + "new claims on your starred projects" + ); + case "starredProjectUpdate": + return line( + count, + "new project update to a starred project", + "new project updates to your starred projects" + ); + case "offerToYou": + return line(count, "new offer to you", "new offers to you"); + case "giveToYou": + return line(count, "new give to you", "new gives to you"); + case "otherClaimToYou": + return line(count, "new claim to you", "new claims to you"); + case "offerFromYou": + return line(count, "new offer from you", "new offers from you"); + case "giveFromYou": + return line(count, "new give from you", "new gives from you"); + case "otherClaimFromYou": + return line(count, "new claim from you", "new claims from you"); + case "projectNearby": + return line(count, "new project near you", "new projects near you"); + case "profileNearby": + return line(count, "new profile near you", "new profiles near you"); + case "projectSimilar": + return line( + count, + "new project similar to your profile", + "new projects similar to your profile" + ); + case "profileSimilar": + return line( + count, + "new profile similar to yours", + "new profiles similar to yours" + ); + } +} diff --git a/src/alertSearch/client.ts b/src/alertSearch/client.ts index e298027..a688924 100644 --- a/src/alertSearch/client.ts +++ b/src/alertSearch/client.ts @@ -8,6 +8,8 @@ import { import { type AlertSearchFailureReason, type AlertSearchSource, + type AlertSearchSimilarPlan, + type AlertSearchSimilarProfile, type EndorserAlertSearchData, type EndorserAlertSearchResponse, type PartnerAlertSearchData, @@ -244,32 +246,55 @@ function copyCursorFields( } } +/** + * Missing similar lists are an older partner response and mean none. + * A present value that is not an array, or an element without its nested + * record, is malformed so those matches are not dropped. + */ +function parseSimilarProfiles( + value: unknown +): AlertSearchSimilarProfile[] | undefined { + if (value === undefined) return []; + const items = asObjectArray(value); + if (items === undefined) return undefined; + for (const item of items) { + if (!isRecord(item.profile)) return undefined; + } + return items as AlertSearchSimilarProfile[]; +} + +function parseSimilarPlans( + value: unknown +): AlertSearchSimilarPlan[] | undefined { + if (value === undefined) return []; + const items = asObjectArray(value); + if (items === undefined) return undefined; + for (const item of items) { + if (!isRecord(item.plan)) return undefined; + } + return items as AlertSearchSimilarPlan[]; +} + export function parsePartnerAlertSearchResponse( body: unknown ): PartnerAlertSearchResponse | undefined { if (!isRecord(body) || !isRecord(body.data)) return undefined; const profilesNearby = asObjectArray(body.data.profilesNearby); - if (profilesNearby === undefined) return undefined; - // optional: servers before the similarity matches send neither list - const profilesSimilar = - body.data.profilesSimilar === undefined - ? [] - : asObjectArray(body.data.profilesSimilar); - const plansSimilar = - body.data.plansSimilar === undefined - ? [] - : asObjectArray(body.data.plansSimilar); - if (profilesSimilar === undefined || plansSimilar === undefined) { + const profilesSimilar = parseSimilarProfiles(body.data.profilesSimilar); + const plansSimilar = parseSimilarPlans(body.data.plansSimilar); + if ( + profilesNearby === undefined || + profilesSimilar === undefined || + plansSimilar === undefined + ) { return undefined; } const parsed: PartnerAlertSearchResponse = { data: { profilesNearby: profilesNearby as PartnerAlertSearchResponse["data"]["profilesNearby"], - profilesSimilar: - profilesSimilar as PartnerAlertSearchResponse["data"]["profilesSimilar"], - plansSimilar: - plansSimilar as PartnerAlertSearchResponse["data"]["plansSimilar"], + profilesSimilar, + plansSimilar, }, }; copyCursorFields(body, parsed); @@ -320,7 +345,8 @@ export async function fetchPartnerAlertSearchPage(input: { return { ok: false, reason: "malformed", - message: "Partner alertSearch response is missing profilesNearby or has malformed similar lists.", + message: + "Partner alertSearch response is missing profilesNearby or has invalid profilesSimilar or plansSimilar.", status: raw.status, url, }; diff --git a/src/alertSearch/digest.ts b/src/alertSearch/digest.ts index 9046b40..9b844d1 100644 --- a/src/alertSearch/digest.ts +++ b/src/alertSearch/digest.ts @@ -1,3 +1,7 @@ +import { + countAlertSearchUpdates, + totalAlertSearchUpdates, +} from "./categories.js"; import type { AlertSearchSourceResult, RetrieveAlertSearchResult, @@ -49,6 +53,10 @@ export type AlertSearchDigestSourceStatus = { export type AlertSearchDigest = { completed: boolean; hasUpdates: boolean; + /** + * Records counted once across lists. FCM's heading and the SMS sentence both + * use this number. `counts` stays the raw length of each API list. + */ totalCount: number; counts: AlertSearchDigestCounts; records: CombinedAlertSearchData; @@ -68,7 +76,12 @@ function copyEndorserRecords( }; } -function copyPartnerRecords(data: PartnerAlertSearchData): PartnerAlertSearchData { +function copyPartnerRecords( + data: PartnerAlertSearchData +): Pick< + CombinedAlertSearchData, + "profilesNearby" | "profilesSimilar" | "plansSimilar" +> { return { profilesNearby: [...data.profilesNearby], profilesSimilar: [...data.profilesSimilar], @@ -104,6 +117,7 @@ export function countAlertSearchDigestRecords( }; } +/** Sum of raw list lengths. Not the number announced in FCM or SMS. */ export function totalAlertSearchDigestCount(counts: AlertSearchDigestCounts): number { return ALERT_SEARCH_DIGEST_BUCKETS.reduce( (sum, bucket) => sum + counts[bucket], @@ -127,7 +141,7 @@ export function buildAlertSearchDigest( ...copyPartnerRecords(retrieved.partner.data), }; const counts = countAlertSearchDigestRecords(records); - const totalCount = totalAlertSearchDigestCount(counts); + const totalCount = totalAlertSearchUpdates(countAlertSearchUpdates(records)); const hasUpdates = completed && totalCount > 0; return { diff --git a/src/alertSearch/index.ts b/src/alertSearch/index.ts index b965e53..9430f62 100644 --- a/src/alertSearch/index.ts +++ b/src/alertSearch/index.ts @@ -9,6 +9,8 @@ export type { AlertSearchFailureReason, AlertSearchLocationBBox, AlertSearchQueryOutcome, + AlertSearchSimilarPlan, + AlertSearchSimilarProfile, CombinedAlertSearchData, EndorserAlertSearchData, EndorserAlertSearchResponse, @@ -58,8 +60,8 @@ export type { } from "./scheduler.js"; export { ALERT_SEARCH_FCM_TYPE, - ALERT_SEARCH_NOTIFICATION_TITLE, alertSearchNotificationBody, + alertSearchNotificationTitle, buildAlertSearchNotificationContent, deliverAlertSearchNotification, isAlertSearchNotificationEligible, diff --git a/src/alertSearch/notify.ts b/src/alertSearch/notify.ts index 5f642be..afdafc7 100644 --- a/src/alertSearch/notify.ts +++ b/src/alertSearch/notify.ts @@ -1,12 +1,18 @@ import { db } from "../db/fcmTokensSqlite.js"; import { errorMessage } from "../util/formatElapsed.js"; +import { + ALERT_SEARCH_UPDATE_CATEGORIES, + countAlertSearchUpdates, + formatAlertSearchUpdateLine, + totalAlertSearchUpdates, +} from "./categories.js"; import { alertSearchLinkCursorParam, type DailyAlertSearchResult, } from "./daily.js"; +import type { CombinedAlertSearchData } from "./types.js"; import { log } from "../util/log.js"; -export const ALERT_SEARCH_NOTIFICATION_TITLE = "TimeSafari"; export const ALERT_SEARCH_FCM_TYPE = "alert_search"; export type AlertSearchNotificationContent = { @@ -33,18 +39,47 @@ export type AlertSearchNotifyResult = { failed: number; }; -export function alertSearchNotificationBody(totalCount: number): string { - const noun = totalCount === 1 ? "update" : "updates"; - return `You have ${totalCount} new ${noun}.`; +/** Deduplicated total. Singular only when that total is exactly 1. */ +export function alertSearchNotificationTitle(total: number): string { + const noun = total === 1 ? "update" : "updates"; + return `You have ${total} new ${noun}`; +} + +function alertSearchUpdateLines( + records: CombinedAlertSearchData, + userId?: string +): string[] { + const counts = countAlertSearchUpdates(records, userId); + return ALERT_SEARCH_UPDATE_CATEGORIES.flatMap((category) => { + const count = counts[category]; + if (count <= 0) return []; + return [formatAlertSearchUpdateLine(category, count)]; + }); +} + +/** + * One `- ` line per nonzero category, in display order. The title carries the + * total. An empty string means nothing to show; eligibility still refuses to + * send that digest. + */ +export function alertSearchNotificationBody( + records: CombinedAlertSearchData, + userId?: string +): string { + return alertSearchUpdateLines(records, userId) + .map((line) => `- ${line}`) + .join("\n"); } export function buildAlertSearchNotificationContent( - totalCount: number, + records: CombinedAlertSearchData, + userId?: string, cursorParam?: string ): AlertSearchNotificationContent { + const total = totalAlertSearchUpdates(countAlertSearchUpdates(records, userId)); return { - title: ALERT_SEARCH_NOTIFICATION_TITLE, - body: alertSearchNotificationBody(totalCount), + title: alertSearchNotificationTitle(total), + body: alertSearchNotificationBody(records, userId), type: ALERT_SEARCH_FCM_TYPE, ...(cursorParam !== undefined ? { cursorParam } : {}), }; @@ -107,7 +142,8 @@ export async function deliverAlertSearchNotification( } const content = buildAlertSearchNotificationContent( - digest.totalCount, + digest.records, + result.userId, alertSearchLinkCursorParam(result.startCursors) ); const listTokens = deps.listTokens ?? defaultListTokens; diff --git a/src/alertSearch/retrieve.ts b/src/alertSearch/retrieve.ts index d2d2ad5..b538aac 100644 --- a/src/alertSearch/retrieve.ts +++ b/src/alertSearch/retrieve.ts @@ -16,7 +16,11 @@ import type { AlertSearchCursor, AlertSearchFailureReason, AlertSearchLocationBBox, + AlertSearchPlanRecord, + AlertSearchProfileRecord, AlertSearchQueryOutcome, + AlertSearchSimilarPlan, + AlertSearchSimilarProfile, CombinedAlertSearchData, EndorserAlertSearchData, PartnerAlertSearchData, @@ -111,6 +115,44 @@ function mergeEndorser( }; } +function mergeBy( + existing: T[], + incoming: T[], + keyOf: (row: T) => string | undefined +): T[] { + const seen = new Set(); + const out: T[] = []; + for (const row of [...existing, ...incoming]) { + const key = keyOf(row); + if (key !== undefined) { + if (seen.has(key)) continue; + seen.add(key); + } + out.push(row); + } + return out; +} + +function profileMergeKey(profile: AlertSearchProfileRecord): string | undefined { + if (typeof profile.rowId === "number" || typeof profile.rowId === "string") { + return `rowId:${profile.rowId}`; + } + if (typeof profile.issuerDid === "string" && profile.issuerDid.length > 0) { + return `issuerDid:${profile.issuerDid}`; + } + return undefined; +} + +function planMergeKey(plan: AlertSearchPlanRecord): string | undefined { + if (typeof plan.handleId === "string" && plan.handleId.length > 0) { + return `handleId:${plan.handleId}`; + } + if (typeof plan.jwtId === "string" && plan.jwtId.length > 0) { + return `jwtId:${plan.jwtId}`; + } + return undefined; +} + function mergePartner( acc: PartnerAlertSearchData, page: PartnerAlertSearchData @@ -121,41 +163,20 @@ function mergePartner( "issuerDid", "updatedAt", ]), - profilesSimilar: mergeNested(acc.profilesSimilar, page.profilesSimilar, (m) => - m.profile === null || typeof m.profile !== "object" - ? undefined - : recordKey(m.profile as Record, ["rowId", "issuerDid"]) + profilesSimilar: mergeBy( + acc.profilesSimilar, + page.profilesSimilar, + (row: AlertSearchSimilarProfile) => profileMergeKey(row.profile) ), - plansSimilar: mergeNested(acc.plansSimilar, page.plansSimilar, (m) => - m.plan === null || typeof m.plan !== "object" - ? undefined - : recordKey(m.plan as Record, ["handleId"]) + // plansSimilar is the whole main-DB window on every forward page. + plansSimilar: mergeBy( + acc.plansSimilar, + page.plansSimilar, + (row: AlertSearchSimilarPlan) => planMergeKey(row.plan) ), }; } -/** - * Merge similarity matches, keyed by the record inside each. A later page can - * repeat a subject whose vector changed again inside the window. - */ -function mergeNested( - existing: T[], - incoming: T[], - keyOf: (match: T) => string | undefined -): T[] { - const seen = new Set(); - const out: T[] = []; - for (const match of [...existing, ...incoming]) { - const key = keyOf(match); - if (key !== undefined) { - if (seen.has(key)) continue; - seen.add(key); - } - out.push(match); - } - return out; -} - function endorserEmpty(data: EndorserAlertSearchData): boolean { return ( data.claims.length === 0 && diff --git a/src/alertSearch/smsNotify.ts b/src/alertSearch/smsNotify.ts index 7ebc714..733a5e6 100644 --- a/src/alertSearch/smsNotify.ts +++ b/src/alertSearch/smsNotify.ts @@ -52,6 +52,7 @@ export type AlertSearchSmsNotifyResult = { }; /** + * @param totalCount `digest.totalCount`: the same deduped total the FCM heading uses * @param cursorParam the run's start cursors (`alertSearchLinkCursorParam`), * so the app can tell whether it shows the window this text counted */ diff --git a/src/alertSearch/types.ts b/src/alertSearch/types.ts index 484006b..d2b85b0 100644 --- a/src/alertSearch/types.ts +++ b/src/alertSearch/types.ts @@ -78,6 +78,26 @@ export type AlertSearchProfileRecord = { [key: string]: unknown; }; +/** + * Partner `profilesSimilar` element: a profile whose embedding was stamped in + * the cursor window and is at least `alertMinimumSimilarity` from the + * requester's profile. Best first; not paged on its own. + */ +export type AlertSearchSimilarProfile = { + similarity?: number; + profile: AlertSearchProfileRecord; +}; + +/** + * Partner `plansSimilar` element: a plan whose embedding was stamped in the + * cursor window and is close to the requester's profile. Best first. On a + * forward cursor the whole plan window is repeated on every profiles page. + */ +export type AlertSearchSimilarPlan = { + similarity?: number; + plan: AlertSearchPlanRecord; +}; + export type EndorserAlertSearchData = { claims: AlertSearchClaimRecord[]; personalPlanContributions: AlertSearchJwtWithClaimRecord[]; @@ -86,22 +106,6 @@ export type EndorserAlertSearchData = { plansNearby: AlertSearchPlanRecord[]; }; -/** - * A profile or plan close in meaning to the requester's profile, from partner - * `profilesSimilar` / `plansSimilar` (best first, at most 50, not paged). - */ -export type AlertSearchSimilarProfile = { - similarity: number; - profile: AlertSearchProfileRecord; - [key: string]: unknown; -}; - -export type AlertSearchSimilarPlan = { - similarity: number; - plan: AlertSearchPlanRecord; - [key: string]: unknown; -}; - /** The similar lists are empty from partner servers that predate them. */ export type PartnerAlertSearchData = { profilesNearby: AlertSearchProfileRecord[]; diff --git a/src/services/alertSearchFcmPayload.ts b/src/services/alertSearchFcmPayload.ts new file mode 100644 index 0000000..08d7a11 --- /dev/null +++ b/src/services/alertSearchFcmPayload.ts @@ -0,0 +1,30 @@ +import { withEnvLabel } from "../env.js"; + +export const ALERT_SEARCH_FCM_TYPE = "alert_search"; + +export type AlertSearchFcmMessage = { + token: string; + notification: { title: string; body: string }; + data: { type: typeof ALERT_SEARCH_FCM_TYPE; c?: string }; +}; + +/** The FCM message a tap on the daily New Activity notification carries. */ +export function buildAlertSearchFcmMessage( + fcmToken: string, + content: { title: string; body: string; cursorParam?: string } +): AlertSearchFcmMessage { + return { + token: fcmToken, + notification: { + title: content.title, + body: withEnvLabel(content.body), + }, + data: { + type: ALERT_SEARCH_FCM_TYPE, + // the app opens /new-activity?c= on a tap + ...(content.cursorParam !== undefined + ? { c: content.cursorParam } + : {}), + }, + }; +} diff --git a/src/services/pushService.ts b/src/services/pushService.ts index 51a488b..890bb95 100644 --- a/src/services/pushService.ts +++ b/src/services/pushService.ts @@ -1,10 +1,13 @@ import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js"; import { maskToken } from "../util/maskToken.js"; +import { + ALERT_SEARCH_FCM_TYPE, + buildAlertSearchFcmMessage, +} from "./alertSearchFcmPayload.js"; import { messaging } from "./firebase.js"; import { log } from "../util/log.js"; -import { withEnvLabel } from "../env.js"; -export const ALERT_SEARCH_FCM_TYPE = "alert_search"; +export { ALERT_SEARCH_FCM_TYPE }; /** * User-visible AlertSearch digest push. @@ -18,20 +21,7 @@ export async function sendAlertSearchPushToDevice( log.info("[AlertSearchPush] Send attempt, token suffix:", suffix); try { - await messaging.send({ - token: fcmToken, - notification: { - title: content.title, - body: withEnvLabel(content.body), - }, - data: { - type: ALERT_SEARCH_FCM_TYPE, - // the app opens /new-activity?c= on a tap - ...(content.cursorParam !== undefined - ? { c: content.cursorParam } - : {}), - }, - }); + await messaging.send(buildAlertSearchFcmMessage(fcmToken, content)); log.info( "[AlertSearchPush] Send completed in", formatElapsedMs(Date.now() - sendStarted) + ",", diff --git a/test/alertSearch/categories.test.ts b/test/alertSearch/categories.test.ts new file mode 100644 index 0000000..1ed204f --- /dev/null +++ b/test/alertSearch/categories.test.ts @@ -0,0 +1,419 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { emptyEndorserData, emptyPartnerData } from "../../src/alertSearch/client.js"; +import { + ALERT_SEARCH_UPDATE_CATEGORIES, + ENDORSER_CLAIM_TYPES, + claimUpdateKind, + countAlertSearchUpdates, + formatAlertSearchUpdateLine, + totalAlertSearchUpdates, +} from "../../src/alertSearch/categories.js"; +import { + ALERT_SEARCH_FCM_TYPE, + alertSearchNotificationBody, + alertSearchNotificationTitle, + buildAlertSearchNotificationContent, + isAlertSearchNotificationEligible, +} from "../../src/alertSearch/notify.js"; +import type { AlertSearchDigest } from "../../src/alertSearch/digest.js"; +import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js"; +import type { + AlertSearchClaimRecord, + AlertSearchPlanRecord, + AlertSearchProfileRecord, + CombinedAlertSearchData, +} from "../../src/alertSearch/types.js"; + +const USER = "did:ethr:0xuser"; +const OTHER = "did:ethr:0xother"; + +function records( + partial: Partial = {} +): CombinedAlertSearchData { + return { + ...emptyEndorserData(), + ...emptyPartnerData(), + ...partial, + }; +} + +function jwt( + id: string, + claimType?: string, + extra: Partial = {} +): AlertSearchClaimRecord { + return { + id, + issuedAt: "2026-01-01T00:00:00Z", + issuer: OTHER, + ...extra, + ...(claimType === undefined ? {} : { claimType }), + }; +} + +function plan(handleId: string): AlertSearchPlanRecord { + return { handleId, jwtId: `jwt-${handleId}`, name: handleId }; +} + +function profile(rowId: number, issuerDid = `did:ethr:0xp${rowId}`): AlertSearchProfileRecord { + return { rowId, issuerDid, description: `profile-${rowId}` }; +} + +function onlyCategory( + data: CombinedAlertSearchData, + userId?: string +): string { + const counts = countAlertSearchUpdates(data, userId); + const nonzero = ALERT_SEARCH_UPDATE_CATEGORIES.filter( + (category) => counts[category] > 0 + ); + assert.equal(nonzero.length, 1, nonzero.join(",")); + return nonzero[0]; +} + +function bulletNumbers(body: string): number[] { + return body + .split("\n") + .map((line) => { + const match = /^- (\d+) /.exec(line); + assert.ok(match, line); + return Number(match[1]); + }); +} + +describe("alertSearch update classification", () => { + it("classifies every Endorser claim type from claimType, not from other fields", () => { + const extras = ["TradeAction", "DonateAction", "OrganizationRole", "giveaction", "Offer "]; + for (const claimType of [...ENDORSER_CLAIM_TYPES, ...extras]) { + const kind = claimUpdateKind(claimType); + if (claimType === "GiveAction") assert.equal(kind, "give"); + else if (claimType === "Offer") assert.equal(kind, "offer"); + else assert.equal(kind, "other", claimType); + + const owned = onlyCategory( + records({ + personalPlanContributions: [ + { + ...jwt("owned", claimType), + claim: JSON.stringify({ "@type": "Offer" }), + offer: true, + }, + ], + }), + USER + ); + const starred = onlyCategory( + records({ + trackedPlanClaims: [jwt("starred", claimType, { fromEntity: "offer" })], + }), + USER + ); + const toYou = onlyCategory( + records({ + claims: [jwt("to-you", claimType, { toEntity: USER, fromEntity: OTHER })], + }), + USER + ); + + if (claimType === "GiveAction") { + assert.equal(owned, "giveToOwnedProject"); + assert.equal(starred, "giveToStarredProject"); + assert.equal(toYou, "giveToYou"); + } else if (claimType === "Offer") { + assert.equal(owned, "offerToOwnedProject"); + assert.equal(starred, "offerToStarredProject"); + assert.equal(toYou, "offerToYou"); + } else { + assert.equal(owned, "otherContributionToOwnedProject", claimType); + assert.equal(starred, "otherClaimOnStarredProject", claimType); + assert.equal(toYou, "otherClaimToYou", claimType); + } + } + }); + + it("uses fromEntity when the claims row is not addressed to the requester", () => { + assert.equal( + onlyCategory( + records({ + claims: [jwt("from-give", "GiveAction", { fromEntity: USER, toEntity: "plan-1" })], + }), + USER + ), + "giveFromYou" + ); + assert.equal( + onlyCategory( + records({ + claims: [jwt("from-offer", "Offer", { fromEntity: USER })], + }), + USER + ), + "offerFromYou" + ); + assert.equal( + onlyCategory( + records({ + claims: [jwt("from-other", "PlanAction", { fromEntity: USER })], + }), + USER + ), + "otherClaimFromYou" + ); + assert.equal( + onlyCategory( + records({ + claims: [jwt("both", "GiveAction", { toEntity: USER, fromEntity: USER })], + }), + USER + ), + "giveToYou" + ); + }); + + it("classifies a claims row as to the requester or from the requester", () => { + const names = new Set(ALERT_SEARCH_UPDATE_CATEGORIES); + assert.equal(names.has("offerInvolvingYou"), false); + assert.equal(names.has("giveInvolvingYou"), false); + assert.equal(names.has("otherClaimInvolvingYou"), false); + assert.equal( + onlyCategory( + records({ + claims: [jwt("to-offer", "Offer", { toEntity: USER, fromEntity: OTHER })], + }), + USER + ), + "offerToYou" + ); + assert.equal( + onlyCategory( + records({ + claims: [jwt("from-give", "GiveAction", { fromEntity: USER, toEntity: "plan-1" })], + }), + USER + ), + "giveFromYou" + ); + }); + + it("counts one record once when the APIs return it in more than one list", () => { + const sharedGive = jwt("same-jwt", "GiveAction", { toEntity: USER }); + const starredCopy = jwt("same-jwt", "Offer", { toEntity: "plan-star" }); + const nearbyPlan = plan("shared-plan"); + const similarPlan = { ...plan("shared-plan"), description: "same project" }; + const nearbyProfile = profile(7, "did:ethr:0xshared"); + const data = records({ + personalPlanContributions: [sharedGive], + trackedPlanClaims: [starredCopy], + claims: [jwt("same-jwt", "GiveAction", { toEntity: USER })], + trackedPlanUpdates: [nearbyPlan], + plansNearby: [{ ...nearbyPlan, locLat: 1 }], + plansSimilar: [{ similarity: 0.8, plan: similarPlan }], + profilesNearby: [nearbyProfile], + profilesSimilar: [ + { similarity: 0.4, profile: { ...profile(7, "did:ethr:0xshared"), description: "again" } }, + ], + }); + const counts = countAlertSearchUpdates(data, USER); + assert.equal(counts.giveToOwnedProject, 1); + assert.equal(counts.offerToStarredProject, 0); + assert.equal(counts.giveToYou, 0); + assert.equal(counts.starredProjectUpdate, 1); + assert.equal(counts.projectNearby, 0); + assert.equal(counts.projectSimilar, 0); + assert.equal(counts.profileNearby, 1); + assert.equal(counts.profileSimilar, 0); + assert.equal(totalAlertSearchUpdates(counts), 3); + const content = buildAlertSearchNotificationContent(data, USER); + assert.equal(content.title, "You have 3 new updates"); + assert.equal( + content.body, + [ + "- 1 new give to a project you own", + "- 1 new project update to a starred project", + "- 1 new profile near you", + ].join("\n") + ); + assert.equal(content.body.includes("You have"), false); + assert.equal( + bulletNumbers(content.body).reduce((sum, n) => sum + n, 0), + 3 + ); + }); +}); + +describe("alertSearch notification body", () => { + it("puts a total of 1 in the title and the single category in the body", () => { + const data = records({ + personalPlanContributions: [jwt("g1", "GiveAction")], + }); + const content = buildAlertSearchNotificationContent(data, USER); + assert.equal(content.title, "You have 1 new update"); + assert.equal(content.title, alertSearchNotificationTitle(1)); + assert.equal(content.body, "- 1 new give to a project you own"); + assert.equal(content.body.includes("You have"), false); + }); + + it("uses updates in the title when the only category is plural", () => { + const content = buildAlertSearchNotificationContent( + records({ + trackedPlanClaims: [jwt("o1", "Offer"), jwt("o2", "Offer")], + }), + USER + ); + assert.equal(content.title, "You have 2 new updates"); + assert.equal(content.body, "- 2 new offers to your starred projects"); + assert.equal(content.body.includes("\n"), false); + }); + + it("lists each nonzero category once, with the title total equal to the lines", () => { + const content = buildAlertSearchNotificationContent( + records({ + personalPlanContributions: [jwt("g1", "GiveAction")], + trackedPlanClaims: [jwt("o1", "Offer"), jwt("o2", "Offer")], + trackedPlanUpdates: [plan("star-1"), plan("star-2")], + plansNearby: [], + profilesNearby: [], + profilesSimilar: [], + plansSimilar: [], + }), + USER + ); + assert.equal(content.title, "You have 5 new updates"); + assert.equal( + content.body, + [ + "- 1 new give to a project you own", + "- 2 new offers to your starred projects", + "- 2 new project updates to your starred projects", + ].join("\n") + ); + const stated = Number(/You have (\d+) /.exec(content.title)?.[1]); + assert.equal( + stated, + bulletNumbers(content.body).reduce((sum, n) => sum + n, 0) + ); + assert.equal(content.body.includes("You have"), false); + assert.equal(content.body.includes("profile"), false); + assert.equal(content.body.includes("near you"), false); + }); + + it("phrases every category in the singular and the plural and omits zeros", () => { + const plural: Record<(typeof ALERT_SEARCH_UPDATE_CATEGORIES)[number], string> = { + offerToOwnedProject: "2 new offers to projects you own", + giveToOwnedProject: "2 new gives to projects you own", + otherContributionToOwnedProject: "2 new contributions to projects you own", + offerToStarredProject: "2 new offers to your starred projects", + giveToStarredProject: "2 new gives to your starred projects", + otherClaimOnStarredProject: "2 new claims on your starred projects", + starredProjectUpdate: "2 new project updates to your starred projects", + offerToYou: "2 new offers to you", + giveToYou: "2 new gives to you", + otherClaimToYou: "2 new claims to you", + offerFromYou: "2 new offers from you", + giveFromYou: "2 new gives from you", + otherClaimFromYou: "2 new claims from you", + projectNearby: "2 new projects near you", + profileNearby: "2 new profiles near you", + projectSimilar: "2 new projects similar to your profile", + profileSimilar: "2 new profiles similar to yours", + }; + const data = records({ + personalPlanContributions: [ + jwt("own-offer", "Offer"), + jwt("own-give", "GiveAction"), + jwt("own-other", "PlanAction"), + ], + trackedPlanClaims: [ + jwt("star-offer", "Offer"), + jwt("star-give", "GiveAction"), + jwt("star-other", "AgreeAction"), + ], + trackedPlanUpdates: [plan("starred")], + claims: [ + jwt("to-offer", "Offer", { toEntity: USER }), + jwt("to-give", "GiveAction", { toEntity: USER }), + jwt("to-other", "JoinAction", { toEntity: USER }), + jwt("from-offer", "Offer", { fromEntity: USER, toEntity: "plan-9" }), + jwt("from-give", "GiveAction", { fromEntity: USER, toEntity: "plan-9" }), + jwt("from-other", "Project", { fromEntity: USER }), + ], + plansNearby: [plan("nearby")], + plansSimilar: [{ similarity: 0.7, plan: plan("similar-plan") }], + profilesNearby: [profile(1)], + profilesSimilar: [{ similarity: 0.7, profile: profile(2) }], + }); + const counts = countAlertSearchUpdates(data, USER); + const content = buildAlertSearchNotificationContent(data, USER); + const body = content.body; + const lines = body.split("\n"); + assert.equal(lines.length, ALERT_SEARCH_UPDATE_CATEGORIES.length); + ALERT_SEARCH_UPDATE_CATEGORIES.forEach((category, index) => { + assert.equal(counts[category], 1, category); + assert.equal(lines[index], `- ${formatAlertSearchUpdateLine(category, 1)}`); + assert.equal(formatAlertSearchUpdateLine(category, 2), plural[category]); + assert.notEqual( + formatAlertSearchUpdateLine(category, 1), + formatAlertSearchUpdateLine(category, 2) + ); + }); + const stated = Number(/You have (\d+) /.exec(content.title)?.[1]); + assert.equal(stated, totalAlertSearchUpdates(counts)); + assert.equal(stated, lines.length); + assert.equal(content.title, alertSearchNotificationTitle(stated)); + assert.equal(content.type, ALERT_SEARCH_FCM_TYPE); + assert.equal(content.type, "alert_search"); + assert.equal(content.body, body); + assert.equal(content.body.includes(USER), false); + assert.equal(content.body.includes("\n- 0 "), false); + }); + + it("builds an empty body when there are no records, and that digest is not sent", () => { + const content = buildAlertSearchNotificationContent(records(), USER); + assert.equal(content.title, "You have 0 new updates"); + assert.equal(content.body, ""); + const digest: AlertSearchDigest = { + completed: true, + hasUpdates: false, + totalCount: 0, + counts: { + claims: 0, + personalPlanContributions: 0, + trackedPlanUpdates: 0, + trackedPlanClaims: 0, + plansNearby: 0, + profilesNearby: 0, + profilesSimilar: 0, + plansSimilar: 0, + }, + records: records(), + endorser: { outcome: "empty", completed: true }, + partner: { outcome: "empty", completed: true }, + }; + const daily: DailyAlertSearchResult = { + userId: USER, + utcDay: "2026-08-14", + batchId: "batch-1", + jwtSequence: 1, + endorserOutcome: "empty", + partnerOutcome: "empty", + completed: true, + consumed: true, + digest, + }; + assert.equal(isAlertSearchNotificationEligible(daily), false); + const incomplete: DailyAlertSearchResult = { + ...daily, + completed: false, + consumed: false, + digest: { + ...digest, + completed: false, + hasUpdates: false, + totalCount: 1, + partner: { outcome: "auth", completed: false, reason: "auth" }, + }, + }; + assert.equal(isAlertSearchNotificationEligible(incomplete), false); + }); +}); diff --git a/test/alertSearch/digest.test.ts b/test/alertSearch/digest.test.ts index 587aea0..945e00a 100644 --- a/test/alertSearch/digest.test.ts +++ b/test/alertSearch/digest.test.ts @@ -4,7 +4,16 @@ import { ALERT_SEARCH_DIGEST_BUCKETS, alertSearchDigestDebugSummary, buildAlertSearchDigest, + totalAlertSearchDigestCount, } from "../../src/alertSearch/digest.js"; +import { + alertSearchNotificationBody, + buildAlertSearchNotificationContent, +} from "../../src/alertSearch/notify.js"; +import { + ALERT_SEARCH_SMS_LINK, + alertSearchSmsBody, +} from "../../src/alertSearch/smsNotify.js"; import { emptyEndorserData, emptyPartnerData } from "../../src/alertSearch/client.js"; import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "../../src/alertSearch/retrieve.js"; import type { @@ -86,11 +95,12 @@ function populatedEndorser(): EndorserAlertSearchData { } describe("buildAlertSearchDigest", () => { - it("preserves all eight buckets and their records when populated", () => { + it("preserves every bucket and its records when populated", () => { const endorserData = populatedEndorser(); const partnerData = { + ...emptyPartnerData(), profilesNearby: [profile(1), profile(2)], - profilesSimilar: [{ similarity: 0.9, profile: profile(3) }], + profilesSimilar: [{ similarity: 0.91, profile: profile(3) }], plansSimilar: [{ similarity: 0.8, plan: plan(50) }], }; const digest = buildAlertSearchDigest( @@ -119,6 +129,8 @@ describe("buildAlertSearchDigest", () => { assert.deepEqual(digest.records.trackedPlanClaims, endorserData.trackedPlanClaims); assert.deepEqual(digest.records.plansNearby, endorserData.plansNearby); assert.deepEqual(digest.records.profilesNearby, partnerData.profilesNearby); + assert.deepEqual(digest.records.profilesSimilar, partnerData.profilesSimilar); + assert.deepEqual(digest.records.plansSimilar, partnerData.plansSimilar); assert.equal(digest.records.claims[0], endorserData.claims[0]); assert.notEqual(digest.records.claims, endorserData.claims); }); @@ -150,7 +162,10 @@ describe("buildAlertSearchDigest", () => { claims: [claim(1), claim(2), claim(3)], trackedPlanUpdates: [plan(20)], }), - partnerResult("success", { ...emptyPartnerData(), profilesNearby: [profile(1), profile(2)] }) + partnerResult("success", { + ...emptyPartnerData(), + profilesNearby: [profile(1), profile(2)], + }) ) ); assert.equal(digest.completed, true); @@ -209,7 +224,10 @@ describe("buildAlertSearchDigest", () => { reason: "network", message: "fetch failed", }), - partnerResult("success", { ...emptyPartnerData(), profilesNearby: [profile(1)] }) + partnerResult("success", { + ...emptyPartnerData(), + profilesNearby: [profile(1)], + }) ) ); assert.equal(digest.completed, false); @@ -241,11 +259,14 @@ describe("buildAlertSearchDigest", () => { } }); - it("sums counts across all six buckets", () => { + it("sums counts across every bucket", () => { const digest = buildAlertSearchDigest( retrieved( endorserResult("success", populatedEndorser()), - partnerResult("success", { ...emptyPartnerData(), profilesNearby: [profile(1), profile(2)] }) + partnerResult("success", { + ...emptyPartnerData(), + profilesNearby: [profile(1), profile(2)], + }) ) ); const sum = ALERT_SEARCH_DIGEST_BUCKETS.reduce( @@ -256,6 +277,44 @@ describe("buildAlertSearchDigest", () => { assert.equal(digest.totalCount, 10); }); + it("announces one deduped total in the digest, the FCM heading, and the SMS sentence", () => { + const shared = { ...jwtClaim(4), claimType: "GiveAction" as const }; + const digest = buildAlertSearchDigest( + retrieved( + endorserResult("success", { + ...emptyEndorserData(), + personalPlanContributions: [shared], + trackedPlanClaims: [{ ...shared }], + }), + partnerResult("empty") + ) + ); + const raw = totalAlertSearchDigestCount(digest.counts); + assert.equal(digest.counts.personalPlanContributions, 1); + assert.equal(digest.counts.trackedPlanClaims, 1); + assert.equal(raw, 2); + assert.equal(digest.totalCount, 1); + assert.equal(digest.hasUpdates, true); + const content = buildAlertSearchNotificationContent( + digest.records, + "did:ethr:0xuser" + ); + assert.equal(content.title, "You have 1 new update"); + assert.equal(content.body, "- 1 new give to a project you own"); + assert.equal( + alertSearchNotificationBody(digest.records, "did:ethr:0xuser"), + content.body + ); + assert.equal( + Number(/You have (\d+) /.exec(content.title)?.[1]), + digest.totalCount + ); + assert.equal( + alertSearchSmsBody(digest.totalCount), + `Gift Economies: you have 1 new update. ${ALERT_SEARCH_SMS_LINK} Reply STOP to end.` + ); + }); + it("preserves underlying records rather than reducing them to counts", () => { const extra = { claimType: "AgreeAction", subject: "did:ethr:0xabc" }; const row = { ...claim(7), ...extra }; diff --git a/test/alertSearch/notify.test.ts b/test/alertSearch/notify.test.ts index a005347..92f5c11 100644 --- a/test/alertSearch/notify.test.ts +++ b/test/alertSearch/notify.test.ts @@ -9,7 +9,6 @@ import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js"; import type { AlertSearchDigest } from "../../src/alertSearch/digest.js"; import { ALERT_SEARCH_FCM_TYPE, - ALERT_SEARCH_NOTIFICATION_TITLE, alertSearchNotificationBody, buildAlertSearchNotificationContent, deliverAlertSearchNotification, @@ -70,7 +69,8 @@ function updatesDigest(totalCount: number): AlertSearchDigest { claims: Array.from({ length: totalCount }, (_, i) => ({ id: `01H${String(i).padStart(23, "0")}`, issuedAt: "2026-01-01T00:00:00Z", - issuer: USER, + issuer: "did:ethr:0xother", + toEntity: USER, })), personalPlanContributions: [], trackedPlanUpdates: [], @@ -152,16 +152,24 @@ describe("alertSearch notification gate", () => { ); }); - 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."); + it("keeps the title and FCM type and summarizes records without dumping them", () => { + const digest = updatesDigest(7); + const content = buildAlertSearchNotificationContent(digest.records, USER); + assert.equal(content.title, "You have 7 new updates"); + assert.equal(content.body, "- 7 new claims to you"); 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 update."); + assert.equal( + alertSearchNotificationBody(digest.records, USER), + content.body + ); assert.equal(content.cursorParam, undefined); - assert.equal(buildAlertSearchNotificationContent(7, "5.2-5").cursorParam, "5.2-5"); + assert.equal( + buildAlertSearchNotificationContent(digest.records, USER, "5.2-5") + .cursorParam, + "5.2-5" + ); }); }); @@ -207,7 +215,7 @@ describe("deliverAlertSearchNotification", () => { send: async (token, content) => { sent.push(token); bodies.push(content.body); - assert.equal(content.title, "TimeSafari"); + assert.equal(content.title, "You have 7 new updates"); assert.equal(content.type, "alert_search"); return "sent"; }, @@ -215,7 +223,24 @@ describe("deliverAlertSearchNotification", () => { 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."); + assert.equal(bodies[0], "- 7 new claims to you"); + assert.equal(bodies[0].includes("You have"), false); + }); + + it("does not send a completed digest whose deduped total is zero", async () => { + let sends = 0; + const result = await deliverAlertSearchNotification( + daily({ digest: emptyDigest(), consumed: true }), + { + send: async () => { + sends += 1; + return "sent"; + }, + } + ); + assert.equal(result.eligible, false); + assert.equal(result.sent, 0); + assert.equal(sends, 0); }); it("does not send when ineligible", async () => { diff --git a/test/alertSearch/retrieve.test.ts b/test/alertSearch/retrieve.test.ts index 149037c..0ab644b 100644 --- a/test/alertSearch/retrieve.test.ts +++ b/test/alertSearch/retrieve.test.ts @@ -301,6 +301,69 @@ describe("retrieveAlertSearch", () => { assert.equal(result.data.plansNearby.length, 1); }); + it("keeps partner semantic matches and treats a missing list as empty", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse({ + ...emptyPartnerBody({ + profilesSimilar: [ + { + similarity: 0.77, + profile: { issuerDid: "did:ethr:0xsim", description: "alike", rowId: 4 }, + }, + ], + plansSimilar: [ + { + similarity: 0.66, + plan: { handleId: "plan-sim", jwtId: ulid(8), name: "Alike" }, + }, + ], + }), + nextAfterCursor: "4-8", + }); + } + return jsonResponse({ ...emptyEndorserBody(), nextAfterCursor: "8" }); + }); + const result = await retrieveAlertSearch({ + jwt: JWT, + endorserAfterCursor: "1", + partnerAfterCursor: "1-1", + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, + config: { fetch: cap.fetch }, + }); + assert.equal(result.partner.outcome, "success"); + assert.equal(result.empty, false); + assert.equal(result.data.profilesSimilar.length, 1); + assert.equal(result.data.profilesSimilar[0]?.similarity, 0.77); + assert.equal(result.data.profilesSimilar[0]?.profile.description, "alike"); + assert.equal(result.data.plansSimilar.length, 1); + assert.equal(result.data.plansSimilar[0]?.plan.handleId, "plan-sim"); + assert.equal(result.endorser.nextAfterCursor, "8"); + assert.equal(result.partner.nextAfterCursor, "4-8"); + }); + + it("rejects a partner page whose similar list is not the contract shape", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse({ + ...emptyPartnerBody({ profilesSimilar: [{ similarity: 1 }] }), + nextAfterCursor: "1-1", + }); + } + return jsonResponse({ ...emptyEndorserBody(), nextAfterCursor: "1" }); + }); + const result = await retrieveAlertSearch({ + jwt: JWT, + partnerAfterCursor: "0-1", + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, + config: { fetch: cap.fetch }, + }); + assert.equal(result.partner.outcome, "malformed"); + assert.equal(result.partner.nextAfterCursor, undefined); + }); + it("classifies truncation userMessage as incomplete pagination", async () => { const cap = captureFetch((url) => { if (url.includes("/api/partner/")) return jsonResponse({ ...emptyPartnerBody(), nextAfterCursor: "0-0" }); @@ -523,4 +586,56 @@ describe("retrieveAlertSearch cursor path", () => { assert.equal(result.endorser.outcome, "http"); assert.equal(result.endorser.nextAfterCursor, undefined); }); + + it("dedupes plansSimilar repeated on each forward page and keeps distinct profile matches", async () => { + const plan = { handleId: "plan-sim", jwtId: ulid(9), name: "Alike" }; + let partnerPages = 0; + const cap = captureFetch((url) => { + if (!url.includes("/api/partner/")) { + return jsonResponse({ ...emptyEndorserBody(), nextAfterCursor: "9", hasMore: false }); + } + partnerPages += 1; + const after = param(url, "afterCursor"); + if (partnerPages === 1) { + assert.equal(after, "1-10"); + return jsonResponse({ + ...emptyPartnerBody({ + profilesNearby: [{ issuerDid: "did:ethr:0x1", description: "a", rowId: 1 }], + profilesSimilar: [ + { similarity: 0.9, profile: { issuerDid: "did:ethr:0xs", description: "s", rowId: 2 } }, + ], + plansSimilar: [{ similarity: 0.9, plan }], + }), + nextAfterCursor: "2-20", + hasMore: true, + }); + } + assert.equal(after, "2-20"); + return jsonResponse({ + ...emptyPartnerBody({ + profilesSimilar: [ + { similarity: 0.9, profile: { issuerDid: "did:ethr:0xs", description: "s", rowId: 2 } }, + { similarity: 0.5, profile: { issuerDid: "did:ethr:0xt", description: "t", rowId: 3 } }, + ], + plansSimilar: [{ similarity: 0.9, plan: { ...plan } }], + }), + nextAfterCursor: "3-20", + hasMore: false, + }); + }); + const result = await retrieveAlertSearch({ + jwt: JWT, + endorserAfterCursor: "8", + partnerAfterCursor: "1-10", + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, + config: { fetch: cap.fetch }, + }); + assert.equal(partnerPages, 2); + assert.equal(result.partner.outcome, "success"); + assert.equal(result.partner.nextAfterCursor, "3-20"); + assert.equal(result.data.plansSimilar.length, 1); + assert.equal(result.data.profilesSimilar.length, 2); + assert.equal(result.data.profilesNearby.length, 1); + }); }); diff --git a/test/alertSearch/smsChannel.test.ts b/test/alertSearch/smsChannel.test.ts index fc66b0a..979e752 100644 --- a/test/alertSearch/smsChannel.test.ts +++ b/test/alertSearch/smsChannel.test.ts @@ -51,7 +51,11 @@ function retrieved(endorserCursor: string, partnerCursor: string): RetrieveAlert outcome: "success", pageCount: 1, nextAfterCursor: partnerCursor, - data: { profilesNearby: [{ rowId: "r1" } as never], profilesSimilar: [], plansSimilar: [] }, + data: { + profilesNearby: [{ rowId: "r1" } as never], + profilesSimilar: [], + plansSimilar: [], + }, }; return { data: { ...endorser.data, ...partner.data }, diff --git a/test/alertSearch/smsNotify.test.ts b/test/alertSearch/smsNotify.test.ts index ce2845b..8a3c4a2 100644 --- a/test/alertSearch/smsNotify.test.ts +++ b/test/alertSearch/smsNotify.test.ts @@ -64,8 +64,8 @@ function digest(overrides: Partial = {}): AlertSearchDigest { trackedPlanClaims: 0, plansNearby: 0, profilesNearby: 0, - profilesSimilar: 0, - plansSimilar: 0, + profilesSimilar: 0, + plansSimilar: 0, }; return { completed: true, diff --git a/test/services/alertSearchFcmPayload.test.ts b/test/services/alertSearchFcmPayload.test.ts new file mode 100644 index 0000000..7fd0625 --- /dev/null +++ b/test/services/alertSearchFcmPayload.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + ALERT_SEARCH_FCM_TYPE, + buildAlertSearchFcmMessage, +} from "../../src/services/alertSearchFcmPayload.js"; + +describe("buildAlertSearchFcmMessage", () => { + it("sends a visible notification whose data type is alert_search", () => { + const message = buildAlertSearchFcmMessage("device-token", { + title: "AlertSearch tap test", + body: "Tap this notification.", + }); + assert.equal(message.token, "device-token"); + assert.equal(message.notification.title, "AlertSearch tap test"); + assert.equal(message.notification.body, "Tap this notification."); + assert.equal(message.data.type, "alert_search"); + assert.equal(message.data.type, ALERT_SEARCH_FCM_TYPE); + assert.equal(Object.keys(message.data).length, 1); + }); +});