WIP: API Notification Digest #6
+2
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) + ",",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user