Add an alertSearch retrieval layer so notify-api can query Endorser and Partner with a delegated JWT.
Keep Endorser and Partner cursors independent, paginate with the real beforeId/beforeDate contract, and leave scheduling and notification delivery for later.
This commit is contained in:
@@ -3,6 +3,11 @@ PORT=3003
|
|||||||
|
|
||||||
# Endorser API base URL (default: https://api.endorser.ch)
|
# Endorser API base URL (default: https://api.endorser.ch)
|
||||||
# ENDORSER_URL=https://api.endorser.ch
|
# ENDORSER_URL=https://api.endorser.ch
|
||||||
|
# DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||||
|
|
||||||
|
# Partner API base URL (default: https://partner-api.endorser.ch)
|
||||||
|
# PARTNER_URL=https://partner-api.endorser.ch
|
||||||
|
# DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
|
||||||
|
|
||||||
# Firebase Admin: inline service account JSON (one line).
|
# Firebase Admin: inline service account JSON (one line).
|
||||||
# If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS).
|
# If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS).
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
## [0.1.6] - 2026.08.27
|
||||||
|
### Added
|
||||||
|
- AlertSearch retrieval layer for Endorser and Partner GET endpoints (pagination, independent cursors; not scheduled yet)
|
||||||
|
|
||||||
|
|
||||||
## [0.1.5] - 2026.08.26
|
## [0.1.5] - 2026.08.26
|
||||||
### Added
|
### Added
|
||||||
- `PUT /notifications/alert-authorization` stores a 100-JWT delegated alert-authorization batch per user (SQLite), authenticated with the current user JWT
|
- `PUT /notifications/alert-authorization` stores a 100-JWT delegated alert-authorization batch per user (SQLite), authenticated with the current user JWT
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ cp .env.example .env
|
|||||||
Edit .env — set `FIREBASE_SERVICE_ACCOUNT_JSON`.
|
Edit .env — set `FIREBASE_SERVICE_ACCOUNT_JSON`.
|
||||||
Here is one way to generate the contents: `cat your-downloaded-key.json | jq -c .`
|
Here is one way to generate the contents: `cat your-downloaded-key.json | jq -c .`
|
||||||
|
|
||||||
Optionally set `ENDORSER_URL` if you are not using the default production Endorser API (`https://api.endorser.ch`).
|
Optionally set `ENDORSER_URL` / `PARTNER_URL` if you are not using the production Endorser (`https://api.endorser.ch`) and Partner (`https://partner-api.endorser.ch`) hosts.
|
||||||
|
|
||||||
Optionally set `NOTIFY_DATA_DIR` if you want the SQLite database somewhere other than `./data`.
|
Optionally set `NOTIFY_DATA_DIR` if you want the SQLite database somewhere other than `./data`.
|
||||||
|
|
||||||
@@ -20,6 +20,10 @@ pnpm install
|
|||||||
pnpm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
The server starts on `http://localhost:3003` (or the port in `PORT`). Hot-reloads on file changes.
|
The server starts on `http://localhost:3003` (or the port in `PORT`). Hot-reloads on file changes.
|
||||||
|
|
||||||
On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the SQLite file `notify.sqlite` with the required schema.
|
On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the SQLite file `notify.sqlite` with the required schema.
|
||||||
@@ -60,6 +64,15 @@ Authorization: Bearer <current-user-JWT>
|
|||||||
|
|
||||||
`timezone` is the IANA zone used when minting the 100 validity windows; it is stored as batch metadata, not live device-timezone tracking. A successful call replaces that user's previous **unused** JWTs atomically. Passkey (`did:peer`) identities cannot mint this batch and receive `DELEGATED_JWT_UNSUPPORTED_IDENTITY`.
|
`timezone` is the IANA zone used when minting the 100 validity windows; it is stored as batch metadata, not live device-timezone tracking. A successful call replaces that user's previous **unused** JWTs atomically. Passkey (`did:peer`) identities cannot mint this batch and receive `DELEGATED_JWT_UNSUPPORTED_IDENTITY`.
|
||||||
|
|
||||||
|
### Alert search retrieval
|
||||||
|
|
||||||
|
`retrieveAlertSearch` (not yet wired to the scheduler) GETs:
|
||||||
|
|
||||||
|
- `{ENDORSER_URL}/api/v2/report/alertSearch`
|
||||||
|
- `{PARTNER_URL}/api/partner/alertSearch`
|
||||||
|
|
||||||
|
The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endorserAfterId` / `partnerAfterId` ULIDs, or omit them on first run. Nearby search uses the alertSearch bbox (`minLocLat`, `maxLocLat`, `minLocLon`, `maxLocLon`).
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
### Database location
|
### Database location
|
||||||
@@ -131,5 +144,6 @@ Required environment variables:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. |
|
| `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. |
|
||||||
| `PORT` | HTTP port (default: `3003`). |
|
| `PORT` | HTTP port (default: `3003`). |
|
||||||
| `ENDORSER_URL` | Endorser API base URL used for auth checks on register, refresh, and alert-authorization (default: `https://api.endorser.ch`). |
|
| `ENDORSER_URL` | Endorser API base URL used for auth checks and alertSearch (default: `https://api.endorser.ch`). |
|
||||||
|
| `PARTNER_URL` | Partner API base URL used for alertSearch (default: `https://partner-api.endorser.ch`). |
|
||||||
| `NOTIFY_DATA_DIR` | Directory for the SQLite database file `notify.sqlite` (default: `./data`). |
|
| `NOTIFY_DATA_DIR` | Directory for the SQLite database file `notify.sqlite` (default: `./data`). |
|
||||||
|
|||||||
+3
-2
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "notification-wakeup-service",
|
"name": "notification-wakeup-service",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.4.0",
|
"packageManager": "pnpm@11.4.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "tsx src/index.ts",
|
"start": "tsx src/index.ts",
|
||||||
"build": "tsc"
|
"build": "tsc",
|
||||||
|
"test": "tsx --test src/alertSearch/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@peculiar/asn1-ecc": "^2.7.0",
|
"@peculiar/asn1-ecc": "^2.7.0",
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { errorMessage } from "../util/formatElapsed.js";
|
||||||
|
import { maskToken } from "../util/maskToken.js";
|
||||||
|
import {
|
||||||
|
alertSearchUrl,
|
||||||
|
buildAlertSearchQuery,
|
||||||
|
type AlertSearchQueryInput,
|
||||||
|
} from "./params.js";
|
||||||
|
import {
|
||||||
|
ALERT_SEARCH_PAGE_SIZE,
|
||||||
|
type AlertSearchFailureReason,
|
||||||
|
type AlertSearchSource,
|
||||||
|
type EndorserAlertSearchData,
|
||||||
|
type EndorserAlertSearchResponse,
|
||||||
|
type PartnerAlertSearchData,
|
||||||
|
type PartnerAlertSearchResponse,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type FetchLike = (
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
export type AlertSearchHttpConfig = {
|
||||||
|
fetch?: FetchLike;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlertSearchHttpOk<T> = {
|
||||||
|
ok: true;
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlertSearchHttpErr = {
|
||||||
|
ok: false;
|
||||||
|
reason: AlertSearchFailureReason;
|
||||||
|
message: string;
|
||||||
|
status?: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlertSearchHttpResult<T> = AlertSearchHttpOk<T> | AlertSearchHttpErr;
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
export function emptyEndorserData(): EndorserAlertSearchData {
|
||||||
|
return {
|
||||||
|
claims: [],
|
||||||
|
personalPlanContributions: [],
|
||||||
|
trackedPlanUpdates: [],
|
||||||
|
trackedPlanClaims: [],
|
||||||
|
plansNearby: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyPartnerData(): PartnerAlertSearchData {
|
||||||
|
return { profilesNearby: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyHttpStatus(status: number): AlertSearchFailureReason {
|
||||||
|
if (status === 400 || status === 401 || status === 403) return "auth";
|
||||||
|
return "http";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(err: unknown): boolean {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === "AbortError" || err.name === "TimeoutError") return true;
|
||||||
|
if (err.message.toLowerCase().includes("aborted")) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson(
|
||||||
|
url: string,
|
||||||
|
jwt: string,
|
||||||
|
source: AlertSearchSource,
|
||||||
|
config: AlertSearchHttpConfig
|
||||||
|
): Promise<AlertSearchHttpResult<unknown>> {
|
||||||
|
const fetchImpl = config.fetch ?? fetch;
|
||||||
|
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const reason: AlertSearchFailureReason = isAbortError(err)
|
||||||
|
? "timeout"
|
||||||
|
: "network";
|
||||||
|
console.error(
|
||||||
|
"[AlertSearch]",
|
||||||
|
source,
|
||||||
|
reason,
|
||||||
|
url,
|
||||||
|
"jwt suffix=" + maskToken(jwt) + ":",
|
||||||
|
errorMessage(err)
|
||||||
|
);
|
||||||
|
return { ok: false, reason, message: errorMessage(err), url };
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
if (!response.ok) {
|
||||||
|
const reason = classifyHttpStatus(response.status);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason,
|
||||||
|
message: `HTTP ${response.status}`,
|
||||||
|
status: response.status,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "malformed",
|
||||||
|
message: errorMessage(err),
|
||||||
|
status: response.status,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const reason = classifyHttpStatus(response.status);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason,
|
||||||
|
message: `HTTP ${response.status}`,
|
||||||
|
status: response.status,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, status: response.status, body: parsed, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObjectArray(value: unknown): Record<string, unknown>[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const out: Record<string, unknown>[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) return undefined;
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEndorserAlertSearchResponse(
|
||||||
|
body: unknown
|
||||||
|
): EndorserAlertSearchResponse | undefined {
|
||||||
|
if (!isRecord(body) || !isRecord(body.data)) return undefined;
|
||||||
|
const data = body.data;
|
||||||
|
const claims = asObjectArray(data.claims);
|
||||||
|
const personalPlanContributions = asObjectArray(
|
||||||
|
data.personalPlanContributions
|
||||||
|
);
|
||||||
|
const trackedPlanUpdates = asObjectArray(data.trackedPlanUpdates);
|
||||||
|
const trackedPlanClaims = asObjectArray(data.trackedPlanClaims);
|
||||||
|
const plansNearby = asObjectArray(data.plansNearby);
|
||||||
|
if (
|
||||||
|
claims === undefined ||
|
||||||
|
personalPlanContributions === undefined ||
|
||||||
|
trackedPlanUpdates === undefined ||
|
||||||
|
trackedPlanClaims === undefined ||
|
||||||
|
plansNearby === undefined
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed: EndorserAlertSearchResponse = {
|
||||||
|
data: {
|
||||||
|
claims: claims as EndorserAlertSearchResponse["data"]["claims"],
|
||||||
|
personalPlanContributions:
|
||||||
|
personalPlanContributions as EndorserAlertSearchResponse["data"]["personalPlanContributions"],
|
||||||
|
trackedPlanUpdates:
|
||||||
|
trackedPlanUpdates as EndorserAlertSearchResponse["data"]["trackedPlanUpdates"],
|
||||||
|
trackedPlanClaims:
|
||||||
|
trackedPlanClaims as EndorserAlertSearchResponse["data"]["trackedPlanClaims"],
|
||||||
|
plansNearby:
|
||||||
|
plansNearby as EndorserAlertSearchResponse["data"]["plansNearby"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (typeof body.userMessage === "string") {
|
||||||
|
parsed.userMessage = body.userMessage;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const parsed: PartnerAlertSearchResponse = {
|
||||||
|
data: {
|
||||||
|
profilesNearby:
|
||||||
|
profilesNearby as PartnerAlertSearchResponse["data"]["profilesNearby"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (typeof body.userMessage === "string") {
|
||||||
|
parsed.userMessage = body.userMessage;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEndorserAlertSearchPage(input: {
|
||||||
|
baseUrl: string;
|
||||||
|
jwt: string;
|
||||||
|
query: AlertSearchQueryInput;
|
||||||
|
config?: AlertSearchHttpConfig;
|
||||||
|
}): Promise<AlertSearchHttpResult<EndorserAlertSearchResponse>> {
|
||||||
|
const url = alertSearchUrl(
|
||||||
|
input.baseUrl,
|
||||||
|
"/api/v2/report/alertSearch",
|
||||||
|
buildAlertSearchQuery(input.query)
|
||||||
|
);
|
||||||
|
const raw = await getJson(url, input.jwt, "endorser", input.config ?? {});
|
||||||
|
if (!raw.ok) return raw;
|
||||||
|
const parsed = parseEndorserAlertSearchResponse(raw.body);
|
||||||
|
if (parsed === undefined) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "malformed",
|
||||||
|
message: "Endorser alertSearch response is missing required data buckets.",
|
||||||
|
status: raw.status,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, status: raw.status, body: parsed, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPartnerAlertSearchPage(input: {
|
||||||
|
baseUrl: string;
|
||||||
|
jwt: string;
|
||||||
|
query: AlertSearchQueryInput;
|
||||||
|
config?: AlertSearchHttpConfig;
|
||||||
|
}): Promise<AlertSearchHttpResult<PartnerAlertSearchResponse>> {
|
||||||
|
const url = alertSearchUrl(
|
||||||
|
input.baseUrl,
|
||||||
|
"/api/partner/alertSearch",
|
||||||
|
buildAlertSearchQuery(input.query)
|
||||||
|
);
|
||||||
|
const raw = await getJson(url, input.jwt, "partner", input.config ?? {});
|
||||||
|
if (!raw.ok) return raw;
|
||||||
|
const parsed = parsePartnerAlertSearchResponse(raw.body);
|
||||||
|
if (parsed === undefined) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "malformed",
|
||||||
|
message: "Partner alertSearch response is missing profilesNearby.",
|
||||||
|
status: raw.status,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, status: raw.status, body: parsed, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endorserBucketHitPageLimit(
|
||||||
|
data: EndorserAlertSearchData
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
data.claims.length >= ALERT_SEARCH_PAGE_SIZE ||
|
||||||
|
data.personalPlanContributions.length >= ALERT_SEARCH_PAGE_SIZE ||
|
||||||
|
data.trackedPlanUpdates.length >= ALERT_SEARCH_PAGE_SIZE ||
|
||||||
|
data.trackedPlanClaims.length >= ALERT_SEARCH_PAGE_SIZE ||
|
||||||
|
data.plansNearby.length >= ALERT_SEARCH_PAGE_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partnerBucketHitPageLimit(
|
||||||
|
data: PartnerAlertSearchData
|
||||||
|
): boolean {
|
||||||
|
return data.profilesNearby.length >= ALERT_SEARCH_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minEndorserBeforeId(
|
||||||
|
data: EndorserAlertSearchData
|
||||||
|
): string | undefined {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const takeJwtIdBuckets: Array<{ length: number; jwtIds: Array<unknown> }> = [
|
||||||
|
{
|
||||||
|
length: data.claims.length,
|
||||||
|
jwtIds: data.claims.map((r) => r.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
length: data.personalPlanContributions.length,
|
||||||
|
jwtIds: data.personalPlanContributions.map((r) => r.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
length: data.trackedPlanClaims.length,
|
||||||
|
jwtIds: data.trackedPlanClaims.map((r) => r.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
length: data.trackedPlanUpdates.length,
|
||||||
|
jwtIds: data.trackedPlanUpdates.map((r) => r.jwtId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
length: data.plansNearby.length,
|
||||||
|
jwtIds: data.plansNearby.map((r) => r.jwtId),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const bucket of takeJwtIdBuckets) {
|
||||||
|
if (bucket.length < ALERT_SEARCH_PAGE_SIZE) continue;
|
||||||
|
for (const id of bucket.jwtIds) {
|
||||||
|
if (typeof id === "string" && id.length > 0) ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.length === 0) return undefined;
|
||||||
|
ids.sort();
|
||||||
|
return ids[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minPartnerBeforeDate(
|
||||||
|
data: PartnerAlertSearchData
|
||||||
|
): string | undefined {
|
||||||
|
if (data.profilesNearby.length < ALERT_SEARCH_PAGE_SIZE) return undefined;
|
||||||
|
const dates = data.profilesNearby
|
||||||
|
.map((p) => p.updatedAt)
|
||||||
|
.filter((d): d is string => typeof d === "string" && d.length > 0);
|
||||||
|
if (dates.length === 0) return undefined;
|
||||||
|
dates.sort();
|
||||||
|
return dates[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export {
|
||||||
|
ALERT_SEARCH_PAGE_SIZE,
|
||||||
|
ENDORSER_ALERT_SEARCH_PATH,
|
||||||
|
PARTNER_ALERT_SEARCH_PATH,
|
||||||
|
} from "./types.js";
|
||||||
|
export type {
|
||||||
|
AlertSearchCursorUlid,
|
||||||
|
AlertSearchFailureReason,
|
||||||
|
AlertSearchLocationBBox,
|
||||||
|
AlertSearchQueryOutcome,
|
||||||
|
CombinedAlertSearchData,
|
||||||
|
EndorserAlertSearchData,
|
||||||
|
EndorserAlertSearchResponse,
|
||||||
|
PartnerAlertSearchData,
|
||||||
|
PartnerAlertSearchResponse,
|
||||||
|
} from "./types.js";
|
||||||
|
export {
|
||||||
|
alertSearchUrl,
|
||||||
|
buildAlertSearchQuery,
|
||||||
|
isAlertSearchCursorUlid,
|
||||||
|
isCompleteLocationBBox,
|
||||||
|
} from "./params.js";
|
||||||
|
export {
|
||||||
|
fetchEndorserAlertSearchPage,
|
||||||
|
fetchPartnerAlertSearchPage,
|
||||||
|
parseEndorserAlertSearchResponse,
|
||||||
|
parsePartnerAlertSearchResponse,
|
||||||
|
} from "./client.js";
|
||||||
|
export { retrieveAlertSearch } from "./retrieve.js";
|
||||||
|
export type { RetrieveAlertSearchInput, RetrieveAlertSearchResult } from "./retrieve.js";
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
alertSearchUrl,
|
||||||
|
buildAlertSearchQuery,
|
||||||
|
isAlertSearchCursorUlid,
|
||||||
|
} from "./params.js";
|
||||||
|
import {
|
||||||
|
ENDORSER_ALERT_SEARCH_PATH,
|
||||||
|
PARTNER_ALERT_SEARCH_PATH,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const LOCATION = {
|
||||||
|
minLocLat: 40.7,
|
||||||
|
maxLocLat: 40.8,
|
||||||
|
minLocLon: -74.1,
|
||||||
|
maxLocLon: -74.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("alertSearch request construction", () => {
|
||||||
|
it("omits afterId on first run (does not send 0 or empty)", () => {
|
||||||
|
const query = buildAlertSearchQuery({});
|
||||||
|
assert.equal(query.has("afterId"), false);
|
||||||
|
assert.equal(query.has("beforeId"), false);
|
||||||
|
const endorser = alertSearchUrl(
|
||||||
|
"https://api.endorser.ch",
|
||||||
|
ENDORSER_ALERT_SEARCH_PATH,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
const partner = alertSearchUrl(
|
||||||
|
"https://partner-api.endorser.ch",
|
||||||
|
PARTNER_ALERT_SEARCH_PATH,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
assert.equal(endorser, "https://api.endorser.ch/api/v2/report/alertSearch");
|
||||||
|
assert.equal(
|
||||||
|
partner,
|
||||||
|
"https://partner-api.endorser.ch/api/partner/alertSearch"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-ULID afterId including 0", () => {
|
||||||
|
assert.equal(isAlertSearchCursorUlid("0"), false);
|
||||||
|
assert.equal(isAlertSearchCursorUlid(""), false);
|
||||||
|
const query = buildAlertSearchQuery({ afterId: "0" });
|
||||||
|
assert.equal(query.has("afterId"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes a subsequent afterId ULID", () => {
|
||||||
|
const afterId = "01H00000000000000000000001";
|
||||||
|
const query = buildAlertSearchQuery({ afterId });
|
||||||
|
assert.equal(query.get("afterId"), afterId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds independent Endorser vs Partner URLs and cursors", () => {
|
||||||
|
const endorserAfter = "01H0000000000000000000000A";
|
||||||
|
const partnerAfter = "01H0000000000000000000000B";
|
||||||
|
const endorser = alertSearchUrl(
|
||||||
|
"https://api.endorser.ch",
|
||||||
|
ENDORSER_ALERT_SEARCH_PATH,
|
||||||
|
buildAlertSearchQuery({ afterId: endorserAfter })
|
||||||
|
);
|
||||||
|
const partner = alertSearchUrl(
|
||||||
|
"https://partner-api.endorser.ch",
|
||||||
|
PARTNER_ALERT_SEARCH_PATH,
|
||||||
|
buildAlertSearchQuery({ afterId: partnerAfter })
|
||||||
|
);
|
||||||
|
assert.match(endorser, /afterId=01H0000000000000000000000A/);
|
||||||
|
assert.match(partner, /afterId=01H0000000000000000000000B/);
|
||||||
|
assert.equal(endorser.includes("0000000B"), false);
|
||||||
|
assert.equal(partner.includes("0000000A"), false);
|
||||||
|
assert.equal(endorser.includes("/api/v2/report/alertSearch"), true);
|
||||||
|
assert.equal(partner.includes("/api/partner/alertSearch"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattens location bbox for GET query params", () => {
|
||||||
|
const query = buildAlertSearchQuery({ location: LOCATION });
|
||||||
|
assert.equal(query.get("minLocLat"), "40.7");
|
||||||
|
assert.equal(query.get("maxLocLat"), "40.8");
|
||||||
|
assert.equal(query.get("minLocLon"), "-74.1");
|
||||||
|
assert.equal(query.get("maxLocLon"), "-74");
|
||||||
|
assert.equal(query.has("location"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("JSON-encodes planHandleIds", () => {
|
||||||
|
const query = buildAlertSearchQuery({
|
||||||
|
planHandleIds: ["plan-1", "plan-2"],
|
||||||
|
});
|
||||||
|
assert.equal(query.get("planHandleIds"), JSON.stringify(["plan-1", "plan-2"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { AlertSearchCursorUlid, AlertSearchLocationBBox } from "./types.js";
|
||||||
|
|
||||||
|
/** Crockford ULID, same regex as endorser-ch alert-search.service.js */
|
||||||
|
const ULID_REGEX = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
||||||
|
|
||||||
|
export function isAlertSearchCursorUlid(
|
||||||
|
value: unknown
|
||||||
|
): value is AlertSearchCursorUlid {
|
||||||
|
return typeof value === "string" && ULID_REGEX.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only a complete bbox is sent. Partial boxes are omitted rather than guessing.
|
||||||
|
* Shape matches endorser-ch `alertSearchParamsFromRequest` (not the app's
|
||||||
|
* eastLong/westLong BoundingBox).
|
||||||
|
*/
|
||||||
|
export function isCompleteLocationBBox(
|
||||||
|
value: unknown
|
||||||
|
): value is AlertSearchLocationBBox {
|
||||||
|
if (value === null || typeof value !== "object") return false;
|
||||||
|
const box = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof box.minLocLat === "number" &&
|
||||||
|
Number.isFinite(box.minLocLat) &&
|
||||||
|
typeof box.maxLocLat === "number" &&
|
||||||
|
Number.isFinite(box.maxLocLat) &&
|
||||||
|
typeof box.minLocLon === "number" &&
|
||||||
|
Number.isFinite(box.minLocLon) &&
|
||||||
|
typeof box.maxLocLon === "number" &&
|
||||||
|
Number.isFinite(box.maxLocLon)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertSearchQueryInput = {
|
||||||
|
afterId?: string;
|
||||||
|
beforeId?: string;
|
||||||
|
afterDate?: string;
|
||||||
|
beforeDate?: string;
|
||||||
|
location?: AlertSearchLocationBBox;
|
||||||
|
planHandleIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET query params for alertSearch.
|
||||||
|
* Omits afterId/beforeId unless they are valid ULIDs (never sends "0").
|
||||||
|
* Location is flattened to minLocLat/maxLocLat/minLocLon/maxLocLon for GET.
|
||||||
|
*/
|
||||||
|
export function buildAlertSearchQuery(
|
||||||
|
input: AlertSearchQueryInput
|
||||||
|
): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (isAlertSearchCursorUlid(input.afterId)) {
|
||||||
|
params.set("afterId", input.afterId);
|
||||||
|
}
|
||||||
|
if (isAlertSearchCursorUlid(input.beforeId)) {
|
||||||
|
params.set("beforeId", input.beforeId);
|
||||||
|
}
|
||||||
|
if (typeof input.afterDate === "string" && input.afterDate.length > 0) {
|
||||||
|
params.set("afterDate", input.afterDate);
|
||||||
|
}
|
||||||
|
if (typeof input.beforeDate === "string" && input.beforeDate.length > 0) {
|
||||||
|
params.set("beforeDate", input.beforeDate);
|
||||||
|
}
|
||||||
|
if (input.location !== undefined && isCompleteLocationBBox(input.location)) {
|
||||||
|
params.set("minLocLat", String(input.location.minLocLat));
|
||||||
|
params.set("maxLocLat", String(input.location.maxLocLat));
|
||||||
|
params.set("minLocLon", String(input.location.minLocLon));
|
||||||
|
params.set("maxLocLon", String(input.location.maxLocLon));
|
||||||
|
}
|
||||||
|
if (input.planHandleIds !== undefined && input.planHandleIds.length > 0) {
|
||||||
|
params.set("planHandleIds", JSON.stringify(input.planHandleIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertSearchUrl(baseUrl: string, path: string, query: URLSearchParams): string {
|
||||||
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
|
const qs = query.toString();
|
||||||
|
return qs.length > 0 ? `${base}${path}?${qs}` : `${base}${path}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
fetchEndorserAlertSearchPage,
|
||||||
|
fetchPartnerAlertSearchPage,
|
||||||
|
type FetchLike,
|
||||||
|
} from "./client.js";
|
||||||
|
import { retrieveAlertSearch } from "./retrieve.js";
|
||||||
|
import { ALERT_SEARCH_PAGE_SIZE } from "./types.js";
|
||||||
|
|
||||||
|
const JWT = "delegated.jwt.token";
|
||||||
|
const ENDORSER_BASE = "https://api.endorser.ch";
|
||||||
|
const PARTNER_BASE = "https://partner-api.endorser.ch";
|
||||||
|
const AFTER_E = "01H0000000000000000000000A";
|
||||||
|
const AFTER_P = "01H0000000000000000000000B";
|
||||||
|
|
||||||
|
function emptyEndorserBody(overrides?: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
claims: [],
|
||||||
|
personalPlanContributions: [],
|
||||||
|
trackedPlanUpdates: [],
|
||||||
|
trackedPlanClaims: [],
|
||||||
|
plansNearby: [],
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPartnerBody(overrides?: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
profilesNearby: [],
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureFetch(
|
||||||
|
handler: (url: string, init?: RequestInit) => Response | Promise<Response>
|
||||||
|
): { fetch: FetchLike; urls: string[]; auths: string[] } {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const auths: string[] = [];
|
||||||
|
const fetchImpl: FetchLike = async (input, init) => {
|
||||||
|
urls.push(input);
|
||||||
|
auths.push(new Headers(init?.headers).get("Authorization") ?? "");
|
||||||
|
assert.equal(init?.method, "GET");
|
||||||
|
return handler(input, init);
|
||||||
|
};
|
||||||
|
return { fetch: fetchImpl, urls, auths };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ulid(n: number): string {
|
||||||
|
return `01H${String(n).padStart(23, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("alertSearch HTTP pages", () => {
|
||||||
|
it("sends the delegated JWT as Bearer on Endorser GET", async () => {
|
||||||
|
const cap = captureFetch(() => jsonResponse(emptyEndorserBody()));
|
||||||
|
await fetchEndorserAlertSearchPage({
|
||||||
|
baseUrl: ENDORSER_BASE,
|
||||||
|
jwt: JWT,
|
||||||
|
query: {},
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
assert.equal(cap.auths[0], "Bearer delegated.jwt.token");
|
||||||
|
assert.equal(cap.urls[0], `${ENDORSER_BASE}/api/v2/report/alertSearch`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the delegated JWT as Bearer on Partner GET", async () => {
|
||||||
|
const cap = captureFetch(() => jsonResponse(emptyPartnerBody()));
|
||||||
|
await fetchPartnerAlertSearchPage({
|
||||||
|
baseUrl: PARTNER_BASE,
|
||||||
|
jwt: JWT,
|
||||||
|
query: {},
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
assert.equal(cap.auths[0], "Bearer delegated.jwt.token");
|
||||||
|
assert.equal(cap.urls[0], `${PARTNER_BASE}/api/partner/alertSearch`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies auth failure", async () => {
|
||||||
|
const cap = captureFetch(() =>
|
||||||
|
jsonResponse({ error: "Request must include a valid Authorization JWT" }, 400)
|
||||||
|
);
|
||||||
|
const result = await fetchEndorserAlertSearchPage({
|
||||||
|
baseUrl: ENDORSER_BASE,
|
||||||
|
jwt: JWT,
|
||||||
|
query: {},
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (!result.ok) assert.equal(result.reason, "auth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies network failure", async () => {
|
||||||
|
const fetchImpl: FetchLike = async () => {
|
||||||
|
throw new TypeError("fetch failed");
|
||||||
|
};
|
||||||
|
const result = await fetchPartnerAlertSearchPage({
|
||||||
|
baseUrl: PARTNER_BASE,
|
||||||
|
jwt: JWT,
|
||||||
|
query: {},
|
||||||
|
config: { fetch: fetchImpl },
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (!result.ok) assert.equal(result.reason, "network");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies malformed Endorser envelope", async () => {
|
||||||
|
const cap = captureFetch(() => jsonResponse({ data: { hitLimit: true } }));
|
||||||
|
const result = await fetchEndorserAlertSearchPage({
|
||||||
|
baseUrl: ENDORSER_BASE,
|
||||||
|
jwt: JWT,
|
||||||
|
query: {},
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (!result.ok) assert.equal(result.reason, "malformed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies timeout", async () => {
|
||||||
|
const fetchImpl: FetchLike = async () => {
|
||||||
|
const err = new Error("The operation was aborted");
|
||||||
|
err.name = "TimeoutError";
|
||||||
|
throw err;
|
||||||
|
};
|
||||||
|
const result = await fetchEndorserAlertSearchPage({
|
||||||
|
baseUrl: ENDORSER_BASE,
|
||||||
|
jwt: JWT,
|
||||||
|
query: {},
|
||||||
|
config: { fetch: fetchImpl },
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (!result.ok) assert.equal(result.reason, "timeout");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("retrieveAlertSearch", () => {
|
||||||
|
it("first run omits afterId on both hosts", async () => {
|
||||||
|
const cap = captureFetch((url) => {
|
||||||
|
if (url.includes("/api/partner/")) {
|
||||||
|
return jsonResponse(emptyPartnerBody());
|
||||||
|
}
|
||||||
|
return jsonResponse(emptyEndorserBody());
|
||||||
|
});
|
||||||
|
const result = await retrieveAlertSearch({
|
||||||
|
jwt: JWT,
|
||||||
|
endorserBaseUrl: ENDORSER_BASE,
|
||||||
|
partnerBaseUrl: PARTNER_BASE,
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
assert.equal(result.endorser.outcome, "empty");
|
||||||
|
assert.equal(result.partner.outcome, "empty");
|
||||||
|
assert.equal(result.empty, true);
|
||||||
|
assert.equal(cap.urls.length, 2);
|
||||||
|
for (const url of cap.urls) {
|
||||||
|
assert.equal(url.includes("afterId="), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes independent afterId cursors", async () => {
|
||||||
|
const cap = captureFetch((url) => {
|
||||||
|
if (url.includes("/api/partner/")) {
|
||||||
|
return jsonResponse(emptyPartnerBody());
|
||||||
|
}
|
||||||
|
return jsonResponse(emptyEndorserBody());
|
||||||
|
});
|
||||||
|
await retrieveAlertSearch({
|
||||||
|
jwt: JWT,
|
||||||
|
endorserAfterId: AFTER_E,
|
||||||
|
partnerAfterId: AFTER_P,
|
||||||
|
endorserBaseUrl: ENDORSER_BASE,
|
||||||
|
partnerBaseUrl: PARTNER_BASE,
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
const endorserUrl = cap.urls.find((u) => u.includes("/api/v2/report/")) ?? "";
|
||||||
|
const partnerUrl = cap.urls.find((u) => u.includes("/api/partner/")) ?? "";
|
||||||
|
assert.equal(endorserUrl.includes(AFTER_E), true);
|
||||||
|
assert.equal(endorserUrl.includes(AFTER_P), false);
|
||||||
|
assert.equal(partnerUrl.includes(AFTER_P), true);
|
||||||
|
assert.equal(partnerUrl.includes(AFTER_E), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paginates Endorser with beforeId from the limited bucket", async () => {
|
||||||
|
const page1Ids = Array.from({ length: ALERT_SEARCH_PAGE_SIZE }, (_, i) =>
|
||||||
|
ulid(100 + i)
|
||||||
|
);
|
||||||
|
const minId = [...page1Ids].sort()[0];
|
||||||
|
let endorserPages = 0;
|
||||||
|
const cap = captureFetch((url) => {
|
||||||
|
if (url.includes("/api/partner/")) {
|
||||||
|
return jsonResponse(emptyPartnerBody());
|
||||||
|
}
|
||||||
|
endorserPages += 1;
|
||||||
|
if (endorserPages === 1) {
|
||||||
|
assert.equal(url.includes("beforeId="), false);
|
||||||
|
return jsonResponse(
|
||||||
|
emptyEndorserBody({
|
||||||
|
claims: page1Ids.map((id) => ({
|
||||||
|
id,
|
||||||
|
issuedAt: "2026-01-01T00:00:00Z",
|
||||||
|
issuer: "did:ethr:0x1",
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(url.includes(`beforeId=${minId}`), true);
|
||||||
|
return jsonResponse(
|
||||||
|
emptyEndorserBody({
|
||||||
|
claims: [
|
||||||
|
{
|
||||||
|
id: ulid(1),
|
||||||
|
issuedAt: "2026-01-01T00:00:00Z",
|
||||||
|
issuer: "did:ethr:0x1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const result = await retrieveAlertSearch({
|
||||||
|
jwt: JWT,
|
||||||
|
endorserBaseUrl: ENDORSER_BASE,
|
||||||
|
partnerBaseUrl: PARTNER_BASE,
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
assert.equal(result.endorser.outcome, "success");
|
||||||
|
assert.equal(result.endorser.pageCount, 2);
|
||||||
|
assert.equal(result.data.claims.length, ALERT_SEARCH_PAGE_SIZE + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paginates Partner with beforeDate independently", async () => {
|
||||||
|
const times = Array.from(
|
||||||
|
{ length: ALERT_SEARCH_PAGE_SIZE },
|
||||||
|
(_, i) => `2026-01-01T00:${String(i).padStart(2, "0")}:00.000Z`
|
||||||
|
);
|
||||||
|
const minTime = [...times].sort()[0];
|
||||||
|
let partnerPages = 0;
|
||||||
|
const cap = captureFetch((url) => {
|
||||||
|
if (url.includes("/api/v2/report/")) {
|
||||||
|
return jsonResponse(emptyEndorserBody());
|
||||||
|
}
|
||||||
|
partnerPages += 1;
|
||||||
|
if (partnerPages === 1) {
|
||||||
|
assert.equal(url.includes("beforeDate="), false);
|
||||||
|
return jsonResponse(
|
||||||
|
emptyPartnerBody({
|
||||||
|
profilesNearby: times.map((updatedAt, i) => ({
|
||||||
|
issuerDid: `did:ethr:0x${i}`,
|
||||||
|
description: "p",
|
||||||
|
updatedAt,
|
||||||
|
rowId: i,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(decodeURIComponent(url).includes(`beforeDate=${minTime}`), true);
|
||||||
|
return jsonResponse(emptyPartnerBody());
|
||||||
|
});
|
||||||
|
const result = await retrieveAlertSearch({
|
||||||
|
jwt: JWT,
|
||||||
|
endorserBaseUrl: ENDORSER_BASE,
|
||||||
|
partnerBaseUrl: PARTNER_BASE,
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
assert.equal(result.partner.pageCount, 2);
|
||||||
|
assert.equal(result.partner.outcome, "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns success with empty buckets", async () => {
|
||||||
|
const cap = captureFetch((url) => {
|
||||||
|
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
||||||
|
return jsonResponse(emptyEndorserBody());
|
||||||
|
});
|
||||||
|
const result = await retrieveAlertSearch({
|
||||||
|
jwt: JWT,
|
||||||
|
endorserBaseUrl: ENDORSER_BASE,
|
||||||
|
partnerBaseUrl: PARTNER_BASE,
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
assert.equal(result.empty, true);
|
||||||
|
assert.equal(result.endorser.outcome, "empty");
|
||||||
|
assert.equal(result.partner.outcome, "empty");
|
||||||
|
assert.ok("claims" in result.data);
|
||||||
|
assert.ok("profilesNearby" in result.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves all six result categories", async () => {
|
||||||
|
const cap = captureFetch((url) => {
|
||||||
|
if (url.includes("/api/partner/")) {
|
||||||
|
return jsonResponse(
|
||||||
|
emptyPartnerBody({
|
||||||
|
profilesNearby: [
|
||||||
|
{ issuerDid: "did:ethr:0x2", description: "near", extra: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return jsonResponse({
|
||||||
|
data: {
|
||||||
|
claims: [{ id: ulid(1), issuedAt: "t", issuer: "did:ethr:0x1", extra: 1 }],
|
||||||
|
personalPlanContributions: [
|
||||||
|
{ id: ulid(2), issuedAt: "t", issuer: "did:ethr:0x1", claim: "{}" },
|
||||||
|
],
|
||||||
|
trackedPlanUpdates: [{ handleId: "h", jwtId: ulid(3) }],
|
||||||
|
trackedPlanClaims: [
|
||||||
|
{ id: ulid(4), issuedAt: "t", issuer: "did:ethr:0x1" },
|
||||||
|
],
|
||||||
|
plansNearby: [{ handleId: "p", jwtId: ulid(5), locLat: 1 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const result = await retrieveAlertSearch({
|
||||||
|
jwt: JWT,
|
||||||
|
endorserBaseUrl: ENDORSER_BASE,
|
||||||
|
partnerBaseUrl: PARTNER_BASE,
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
assert.equal(result.endorser.outcome, "success");
|
||||||
|
assert.equal(result.partner.outcome, "success");
|
||||||
|
assert.equal(result.data.claims[0]?.extra, 1);
|
||||||
|
assert.equal(result.data.profilesNearby[0]?.extra, true);
|
||||||
|
assert.equal(result.data.personalPlanContributions.length, 1);
|
||||||
|
assert.equal(result.data.trackedPlanUpdates.length, 1);
|
||||||
|
assert.equal(result.data.trackedPlanClaims.length, 1);
|
||||||
|
assert.equal(result.data.plansNearby.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies truncation userMessage as incomplete pagination", async () => {
|
||||||
|
const cap = captureFetch((url) => {
|
||||||
|
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
||||||
|
return jsonResponse({
|
||||||
|
data: {
|
||||||
|
claims: [],
|
||||||
|
personalPlanContributions: [],
|
||||||
|
trackedPlanUpdates: [],
|
||||||
|
trackedPlanClaims: [],
|
||||||
|
plansNearby: [],
|
||||||
|
},
|
||||||
|
userMessage:
|
||||||
|
"Some data was not available in this search. Check the detail screens for the full set of data.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const result = await retrieveAlertSearch({
|
||||||
|
jwt: JWT,
|
||||||
|
endorserBaseUrl: ENDORSER_BASE,
|
||||||
|
partnerBaseUrl: PARTNER_BASE,
|
||||||
|
config: { fetch: cap.fetch },
|
||||||
|
});
|
||||||
|
assert.equal(result.endorser.outcome, "pagination");
|
||||||
|
assert.equal(result.endorser.reason, "pagination");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_ENDORSER_API_SERVER,
|
||||||
|
DEFAULT_PARTNER_API_SERVER,
|
||||||
|
ENDORSER_URL,
|
||||||
|
PARTNER_URL,
|
||||||
|
} from "../env.js";
|
||||||
|
import {
|
||||||
|
emptyEndorserData,
|
||||||
|
emptyPartnerData,
|
||||||
|
endorserBucketHitPageLimit,
|
||||||
|
fetchEndorserAlertSearchPage,
|
||||||
|
fetchPartnerAlertSearchPage,
|
||||||
|
minEndorserBeforeId,
|
||||||
|
minPartnerBeforeDate,
|
||||||
|
partnerBucketHitPageLimit,
|
||||||
|
type AlertSearchHttpConfig,
|
||||||
|
} from "./client.js";
|
||||||
|
import type { AlertSearchQueryInput } from "./params.js";
|
||||||
|
import type {
|
||||||
|
AlertSearchFailureReason,
|
||||||
|
AlertSearchLocationBBox,
|
||||||
|
AlertSearchQueryOutcome,
|
||||||
|
CombinedAlertSearchData,
|
||||||
|
EndorserAlertSearchData,
|
||||||
|
PartnerAlertSearchData,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_PAGES = 20;
|
||||||
|
|
||||||
|
const TRUNCATION_HINT = "Some data was not available in this search";
|
||||||
|
|
||||||
|
export type AlertSearchSourceResult<TData> = {
|
||||||
|
outcome: AlertSearchQueryOutcome;
|
||||||
|
reason?: AlertSearchFailureReason;
|
||||||
|
message?: string;
|
||||||
|
userMessage?: string;
|
||||||
|
pageCount: number;
|
||||||
|
data: TData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RetrieveAlertSearchInput = {
|
||||||
|
/** Delegated notification JWT for this scheduled day. Not the setup session JWT. */
|
||||||
|
jwt: string;
|
||||||
|
/** Endorser cursor from a previous successful daily run. Omit on first run. */
|
||||||
|
endorserAfterId?: string;
|
||||||
|
/** Partner cursor from a previous successful daily run. Independent of Endorser. */
|
||||||
|
partnerAfterId?: string;
|
||||||
|
/** User-selected nearby-search bbox (alertSearch minLoc* / maxLoc* shape). */
|
||||||
|
location?: AlertSearchLocationBBox;
|
||||||
|
planHandleIds?: string[];
|
||||||
|
endorserBaseUrl?: string;
|
||||||
|
partnerBaseUrl?: string;
|
||||||
|
maxPages?: number;
|
||||||
|
config?: AlertSearchHttpConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RetrieveAlertSearchResult = {
|
||||||
|
data: CombinedAlertSearchData;
|
||||||
|
empty: boolean;
|
||||||
|
endorser: AlertSearchSourceResult<EndorserAlertSearchData>;
|
||||||
|
partner: AlertSearchSourceResult<PartnerAlertSearchData>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function recordKey(row: Record<string, unknown>, keys: string[]): string | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key];
|
||||||
|
if (typeof value === "string" && value.length > 0) return `${key}:${value}`;
|
||||||
|
if (typeof value === "number") return `${key}:${value}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRows<T extends Record<string, unknown>>(
|
||||||
|
existing: T[],
|
||||||
|
incoming: T[],
|
||||||
|
keys: string[]
|
||||||
|
): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const row of [...existing, ...incoming]) {
|
||||||
|
const key = recordKey(row, keys);
|
||||||
|
if (key !== undefined) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEndorser(
|
||||||
|
acc: EndorserAlertSearchData,
|
||||||
|
page: EndorserAlertSearchData
|
||||||
|
): EndorserAlertSearchData {
|
||||||
|
return {
|
||||||
|
claims: mergeRows(acc.claims, page.claims, ["id"]),
|
||||||
|
personalPlanContributions: mergeRows(
|
||||||
|
acc.personalPlanContributions,
|
||||||
|
page.personalPlanContributions,
|
||||||
|
["id"]
|
||||||
|
),
|
||||||
|
trackedPlanUpdates: mergeRows(acc.trackedPlanUpdates, page.trackedPlanUpdates, [
|
||||||
|
"jwtId",
|
||||||
|
"handleId",
|
||||||
|
]),
|
||||||
|
trackedPlanClaims: mergeRows(acc.trackedPlanClaims, page.trackedPlanClaims, [
|
||||||
|
"id",
|
||||||
|
]),
|
||||||
|
plansNearby: mergeRows(acc.plansNearby, page.plansNearby, ["jwtId", "handleId"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePartner(
|
||||||
|
acc: PartnerAlertSearchData,
|
||||||
|
page: PartnerAlertSearchData
|
||||||
|
): PartnerAlertSearchData {
|
||||||
|
return {
|
||||||
|
profilesNearby: mergeRows(acc.profilesNearby, page.profilesNearby, [
|
||||||
|
"rowId",
|
||||||
|
"issuerDid",
|
||||||
|
"updatedAt",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function endorserEmpty(data: EndorserAlertSearchData): boolean {
|
||||||
|
return (
|
||||||
|
data.claims.length === 0 &&
|
||||||
|
data.personalPlanContributions.length === 0 &&
|
||||||
|
data.trackedPlanUpdates.length === 0 &&
|
||||||
|
data.trackedPlanClaims.length === 0 &&
|
||||||
|
data.plansNearby.length === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function partnerEmpty(data: PartnerAlertSearchData): boolean {
|
||||||
|
return data.profilesNearby.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retrieveEndorserPages(input: {
|
||||||
|
jwt: string;
|
||||||
|
afterId?: string;
|
||||||
|
location?: AlertSearchLocationBBox;
|
||||||
|
planHandleIds?: string[];
|
||||||
|
baseUrl: string;
|
||||||
|
maxPages: number;
|
||||||
|
config?: AlertSearchHttpConfig;
|
||||||
|
}): Promise<AlertSearchSourceResult<EndorserAlertSearchData>> {
|
||||||
|
let data = emptyEndorserData();
|
||||||
|
let beforeId: string | undefined;
|
||||||
|
let userMessage: string | undefined;
|
||||||
|
let pageCount = 0;
|
||||||
|
|
||||||
|
for (let page = 0; page < input.maxPages; page++) {
|
||||||
|
const query: AlertSearchQueryInput = {
|
||||||
|
afterId: input.afterId,
|
||||||
|
beforeId,
|
||||||
|
location: input.location,
|
||||||
|
planHandleIds: input.planHandleIds,
|
||||||
|
};
|
||||||
|
const result = await fetchEndorserAlertSearchPage({
|
||||||
|
baseUrl: input.baseUrl,
|
||||||
|
jwt: input.jwt,
|
||||||
|
query,
|
||||||
|
config: input.config,
|
||||||
|
});
|
||||||
|
pageCount += 1;
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
outcome: result.reason,
|
||||||
|
reason: result.reason,
|
||||||
|
message: result.message,
|
||||||
|
userMessage,
|
||||||
|
pageCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result.body.userMessage) {
|
||||||
|
userMessage = userMessage
|
||||||
|
? `${userMessage} ${result.body.userMessage}`
|
||||||
|
: result.body.userMessage;
|
||||||
|
}
|
||||||
|
if (result.body.userMessage?.includes(TRUNCATION_HINT)) {
|
||||||
|
data = mergeEndorser(data, result.body.data);
|
||||||
|
return {
|
||||||
|
outcome: "pagination",
|
||||||
|
reason: "pagination",
|
||||||
|
message: result.body.userMessage,
|
||||||
|
userMessage,
|
||||||
|
pageCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
data = mergeEndorser(data, result.body.data);
|
||||||
|
if (!endorserBucketHitPageLimit(result.body.data)) {
|
||||||
|
return {
|
||||||
|
outcome: endorserEmpty(data) ? "empty" : "success",
|
||||||
|
userMessage,
|
||||||
|
pageCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const nextBefore = minEndorserBeforeId(result.body.data);
|
||||||
|
if (nextBefore === undefined) {
|
||||||
|
return {
|
||||||
|
outcome: "pagination",
|
||||||
|
reason: "pagination",
|
||||||
|
message:
|
||||||
|
"Endorser page was at the server row limit but no beforeId ULID could be derived.",
|
||||||
|
userMessage,
|
||||||
|
pageCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
beforeId = nextBefore;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
outcome: "pagination",
|
||||||
|
reason: "pagination",
|
||||||
|
message: `Endorser alertSearch stopped after ${input.maxPages} pages.`,
|
||||||
|
userMessage,
|
||||||
|
pageCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retrievePartnerPages(input: {
|
||||||
|
jwt: string;
|
||||||
|
afterId?: string;
|
||||||
|
location?: AlertSearchLocationBBox;
|
||||||
|
baseUrl: string;
|
||||||
|
maxPages: number;
|
||||||
|
config?: AlertSearchHttpConfig;
|
||||||
|
}): Promise<AlertSearchSourceResult<PartnerAlertSearchData>> {
|
||||||
|
let data = emptyPartnerData();
|
||||||
|
let beforeDate: string | undefined;
|
||||||
|
let userMessage: string | undefined;
|
||||||
|
let pageCount = 0;
|
||||||
|
|
||||||
|
for (let page = 0; page < input.maxPages; page++) {
|
||||||
|
const query: AlertSearchQueryInput = {
|
||||||
|
afterId: input.afterId,
|
||||||
|
beforeDate,
|
||||||
|
location: input.location,
|
||||||
|
};
|
||||||
|
const result = await fetchPartnerAlertSearchPage({
|
||||||
|
baseUrl: input.baseUrl,
|
||||||
|
jwt: input.jwt,
|
||||||
|
query,
|
||||||
|
config: input.config,
|
||||||
|
});
|
||||||
|
pageCount += 1;
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
outcome: result.reason,
|
||||||
|
reason: result.reason,
|
||||||
|
message: result.message,
|
||||||
|
userMessage,
|
||||||
|
pageCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result.body.userMessage) {
|
||||||
|
userMessage = userMessage
|
||||||
|
? `${userMessage} ${result.body.userMessage}`
|
||||||
|
: result.body.userMessage;
|
||||||
|
}
|
||||||
|
data = mergePartner(data, result.body.data);
|
||||||
|
if (!partnerBucketHitPageLimit(result.body.data)) {
|
||||||
|
return {
|
||||||
|
outcome: partnerEmpty(data) ? "empty" : "success",
|
||||||
|
userMessage,
|
||||||
|
pageCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const nextBefore = minPartnerBeforeDate(result.body.data);
|
||||||
|
if (nextBefore === undefined) {
|
||||||
|
return {
|
||||||
|
outcome: "pagination",
|
||||||
|
reason: "pagination",
|
||||||
|
message:
|
||||||
|
"Partner page was at the server row limit but no beforeDate could be derived.",
|
||||||
|
userMessage,
|
||||||
|
pageCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
beforeDate = nextBefore;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
outcome: "pagination",
|
||||||
|
reason: "pagination",
|
||||||
|
message: `Partner alertSearch stopped after ${input.maxPages} pages.`,
|
||||||
|
userMessage,
|
||||||
|
pageCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query Endorser and Partner alertSearch independently with the same delegated JWT.
|
||||||
|
* Does not persist cursors; the caller supplies afterId values.
|
||||||
|
*/
|
||||||
|
export async function retrieveAlertSearch(
|
||||||
|
input: RetrieveAlertSearchInput
|
||||||
|
): Promise<RetrieveAlertSearchResult> {
|
||||||
|
const endorserBaseUrl =
|
||||||
|
input.endorserBaseUrl ?? ENDORSER_URL ?? DEFAULT_ENDORSER_API_SERVER;
|
||||||
|
const partnerBaseUrl =
|
||||||
|
input.partnerBaseUrl ?? PARTNER_URL ?? DEFAULT_PARTNER_API_SERVER;
|
||||||
|
const maxPages = input.maxPages ?? DEFAULT_MAX_PAGES;
|
||||||
|
|
||||||
|
const [endorser, partner] = await Promise.all([
|
||||||
|
retrieveEndorserPages({
|
||||||
|
jwt: input.jwt,
|
||||||
|
afterId: input.endorserAfterId,
|
||||||
|
location: input.location,
|
||||||
|
planHandleIds: input.planHandleIds,
|
||||||
|
baseUrl: endorserBaseUrl,
|
||||||
|
maxPages,
|
||||||
|
config: input.config,
|
||||||
|
}),
|
||||||
|
retrievePartnerPages({
|
||||||
|
jwt: input.jwt,
|
||||||
|
afterId: input.partnerAfterId,
|
||||||
|
location: input.location,
|
||||||
|
baseUrl: partnerBaseUrl,
|
||||||
|
maxPages,
|
||||||
|
config: input.config,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data: CombinedAlertSearchData = {
|
||||||
|
...endorser.data,
|
||||||
|
...partner.data,
|
||||||
|
};
|
||||||
|
const empty = endorserEmpty(endorser.data) && partnerEmpty(partner.data);
|
||||||
|
return { data, empty, endorser, partner };
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Raw alertSearch contract types, aligned with endorser-ch and the app
|
||||||
|
* `interfaces/alertSearch` module. These are API envelopes, not a digest model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Server-issued ULID on a stored JWT/plan record. Not an auth or delegated JWT. */
|
||||||
|
export type AlertSearchCursorUlid = string;
|
||||||
|
|
||||||
|
export type AlertSearchLocationBBox = {
|
||||||
|
minLocLat: number;
|
||||||
|
maxLocLat: number;
|
||||||
|
minLocLon: number;
|
||||||
|
maxLocLon: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlertSearchClaimRecord = {
|
||||||
|
id: AlertSearchCursorUlid;
|
||||||
|
issuedAt: string;
|
||||||
|
issuer: string;
|
||||||
|
subject?: string;
|
||||||
|
claimType?: string;
|
||||||
|
handleId?: string;
|
||||||
|
fromEntity?: string;
|
||||||
|
toEntity?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlertSearchJwtWithClaimRecord = AlertSearchClaimRecord & {
|
||||||
|
claim?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlertSearchPlanRecord = {
|
||||||
|
handleId: string;
|
||||||
|
jwtId: AlertSearchCursorUlid;
|
||||||
|
issuerDid?: string;
|
||||||
|
agentDid?: string;
|
||||||
|
fulfillsLinkConfirmed?: boolean | number;
|
||||||
|
fulfillsPlanClaimId?: string;
|
||||||
|
fulfillsPlanHandleId?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
endTime?: string;
|
||||||
|
startTime?: string;
|
||||||
|
locLat?: number;
|
||||||
|
locLon?: number;
|
||||||
|
resultDescription?: string;
|
||||||
|
resultIdentifier?: string;
|
||||||
|
url?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlertSearchProfileRecord = {
|
||||||
|
rowId?: number;
|
||||||
|
issuerDid: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
description: string;
|
||||||
|
locLat?: number;
|
||||||
|
locLon?: number;
|
||||||
|
locLat2?: number;
|
||||||
|
locLon2?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EndorserAlertSearchData = {
|
||||||
|
claims: AlertSearchClaimRecord[];
|
||||||
|
personalPlanContributions: AlertSearchJwtWithClaimRecord[];
|
||||||
|
trackedPlanUpdates: AlertSearchPlanRecord[];
|
||||||
|
trackedPlanClaims: AlertSearchJwtWithClaimRecord[];
|
||||||
|
plansNearby: AlertSearchPlanRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PartnerAlertSearchData = {
|
||||||
|
profilesNearby: AlertSearchProfileRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CombinedAlertSearchData = EndorserAlertSearchData &
|
||||||
|
PartnerAlertSearchData;
|
||||||
|
|
||||||
|
export type EndorserAlertSearchResponse = {
|
||||||
|
data: EndorserAlertSearchData;
|
||||||
|
userMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PartnerAlertSearchResponse = {
|
||||||
|
data: PartnerAlertSearchData;
|
||||||
|
userMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENDORSER_ALERT_SEARCH_PATH = "/api/v2/report/alertSearch";
|
||||||
|
export const PARTNER_ALERT_SEARCH_PATH = "/api/partner/alertSearch";
|
||||||
|
|
||||||
|
/** Matches endorser-ch / partner DEFAULT_LIMIT. Not a JSON envelope field. */
|
||||||
|
export const ALERT_SEARCH_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export type AlertSearchFailureReason =
|
||||||
|
| "auth"
|
||||||
|
| "timeout"
|
||||||
|
| "network"
|
||||||
|
| "malformed"
|
||||||
|
| "pagination"
|
||||||
|
| "http";
|
||||||
|
|
||||||
|
export type AlertSearchSource = "endorser" | "partner";
|
||||||
|
|
||||||
|
export type AlertSearchQueryOutcome =
|
||||||
|
| "success"
|
||||||
|
| "empty"
|
||||||
|
| AlertSearchFailureReason;
|
||||||
+15
-1
@@ -2,6 +2,20 @@ import { config } from "dotenv";
|
|||||||
|
|
||||||
config();
|
config();
|
||||||
|
|
||||||
|
/** Production Endorser host (same default as the app DEFAULT_ENDORSER_API_SERVER). */
|
||||||
|
export const DEFAULT_ENDORSER_API_SERVER = "https://api.endorser.ch";
|
||||||
|
|
||||||
|
/** Production Partner host (same default as the app DEFAULT_PARTNER_API_SERVER). */
|
||||||
|
export const DEFAULT_PARTNER_API_SERVER = "https://partner-api.endorser.ch";
|
||||||
|
|
||||||
/** Base URL for the Endorser API server. */
|
/** Base URL for the Endorser API server. */
|
||||||
export const ENDORSER_URL =
|
export const ENDORSER_URL =
|
||||||
process.env.ENDORSER_URL ?? "https://api.endorser.ch";
|
process.env.ENDORSER_URL ??
|
||||||
|
process.env.DEFAULT_ENDORSER_API_SERVER ??
|
||||||
|
DEFAULT_ENDORSER_API_SERVER;
|
||||||
|
|
||||||
|
/** Base URL for the Partner API server. Separate from Endorser. */
|
||||||
|
export const PARTNER_URL =
|
||||||
|
process.env.PARTNER_URL ??
|
||||||
|
process.env.DEFAULT_PARTNER_API_SERVER ??
|
||||||
|
DEFAULT_PARTNER_API_SERVER;
|
||||||
|
|||||||
+1
-1
@@ -14,5 +14,5 @@
|
|||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user