File size: 3,536 Bytes
5351cc8
 
 
 
 
 
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5351cc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
5351cc8
 
 
ea7b176
5351cc8
 
 
 
 
 
 
 
ea7b176
5351cc8
 
 
ea7b176
5351cc8
 
 
 
 
 
 
 
 
ea7b176
 
 
 
 
5351cc8
ea7b176
5351cc8
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
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);
}