loopable / web /src /customer-grid /recordCommentsApi.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
ea7b176 verified
Raw
History Blame Contribute Delete
3.54 kB
import {
API_V1,
CREDENTIALS,
UNAUTHORIZED_EVENT,
signal,
} from "../apiContract";
import type { SurfaceScope } from "./apiBridge";
/**
* ⭐ WAVE 19 (owner item 12) — EVERY VERB CARRIES ITS SURFACE.
*
* The three URLs were hardcoded `/customers/{pid}/comments`, so opening a PRODUCT record and
* typing a comment asked the customer endpoint about a CRC32 hash of a SKU code. The honest
* failures read "that customer is not in your book" on a screen showing a product — the bug the
* owner reported. The dishonest failure is the one that made this worth a wave: a product pid
* that happens to collide with a real partner id answers 200, and the comment lands on somebody
* else's customer.
*
* `?scope=` rather than a path segment, because that is what the surrounding API already speaks
* for reads (`/workspace?scope=`) and what `scopeKey` does for writes — one vocabulary, not two.
*
* ⚠ THE PATH STAYS `/customers/…` and that is deliberate, not an oversight. It is the shipped
* URL, pinned by `verify_api.py`'s E1a section, and the scope is now carried explicitly beside
* it — so renaming the segment would buy a nicer noun in exchange for churning another session's
* gate mid-wave. Booked as a cosmetic debt in the wave doc; the WALL is `?scope=`, not the noun.
*/
function commentsUrl(scope: SurfaceScope, pid: number, suffix = ""): string {
return `${API_V1}/customers/${encodeURIComponent(String(pid))}/comments${suffix}` +
`?scope=${encodeURIComponent(scope)}`;
}
export interface RecordComment {
id: string;
body: string;
authorKey: string;
author: string;
createdAt: string;
}
export class RecordCommentsError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "RecordCommentsError";
this.status = status;
}
}
async function result<T>(response: Response): Promise<T> {
let body: unknown = null;
try {
body = await response.json();
} catch {
// The status still gives the user an honest fallback below.
}
if (response.status === 401) signal(UNAUTHORIZED_EVENT);
if (!response.ok) {
const shaped = body as { error?: { message?: string } } | null;
throw new RecordCommentsError(
shaped?.error?.message || `The server answered ${response.status}.`,
response.status
);
}
return body as T;
}
export async function fetchRecordComments(
scope: SurfaceScope,
pid: number,
abortSignal?: AbortSignal
): Promise<RecordComment[]> {
const response = await fetch(commentsUrl(scope, pid), {
credentials: CREDENTIALS,
signal: abortSignal,
});
const body = await result<{ comments?: RecordComment[] }>(response);
return Array.isArray(body.comments) ? body.comments : [];
}
export async function postRecordComment(
scope: SurfaceScope,
pid: number,
body: string
): Promise<RecordComment> {
const response = await fetch(commentsUrl(scope, pid), {
method: "POST",
credentials: CREDENTIALS,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
});
const payload = await result<{ comment: RecordComment }>(response);
return payload.comment;
}
export async function deleteRecordComment(
scope: SurfaceScope,
pid: number,
commentId: string
): Promise<void> {
const response = await fetch(
commentsUrl(scope, pid, `/${encodeURIComponent(commentId)}`),
{
method: "DELETE",
credentials: CREDENTIALS,
}
);
await result<{ ok: boolean }>(response);
}