WIP: API Notification Digest #6

Draft
jose wants to merge 5 commits from api-notification-digest into master
19 changed files with 1278 additions and 109 deletions
+2 -1
View File
@@ -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",
+83
View File
@@ -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 <deviceId>
*
* 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);
+318
View File
@@ -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<string>();
const seenObjects = new WeakSet<object>();
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"
);
}
}
+42 -16
View File
@@ -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,
};
+16 -2
View File
@@ -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 {
+3 -1
View File
@@ -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,
+44 -8
View File
@@ -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;
+51 -30
View File
@@ -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<T>(
existing: T[],
incoming: T[],
keyOf: (row: T) => string | undefined
): T[] {
const seen = new Set<string>();
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<string, unknown>, ["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<string, unknown>, ["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<T>(
existing: T[],
incoming: T[],
keyOf: (match: T) => string | undefined
): T[] {
const seen = new Set<string>();
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 &&
+1
View File
@@ -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
*/
+20 -16
View File
@@ -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[];
+30
View File
@@ -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=<this> on a tap
...(content.cursorParam !== undefined
? { c: content.cursorParam }
: {}),
},
};
}
+6 -16
View File
@@ -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=<this> 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) + ",",
+419
View File
@@ -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> = {}
): CombinedAlertSearchData {
return {
...emptyEndorserData(),
...emptyPartnerData(),
...partial,
};
}
function jwt(
id: string,
claimType?: string,
extra: Partial<AlertSearchClaimRecord> = {}
): 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<string>(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);
});
});
+65 -6
View File
@@ -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 };
+35 -10
View File
@@ -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 () => {
+115
View File
@@ -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);
});
});
+5 -1
View File
@@ -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 },
+2 -2
View File
@@ -64,8 +64,8 @@ function digest(overrides: Partial<AlertSearchDigest> = {}): AlertSearchDigest {
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
profilesSimilar: 0,
plansSimilar: 0,
profilesSimilar: 0,
plansSimilar: 0,
};
return {
completed: true,
@@ -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);
});
});