anton-bakker-feedback 0.4.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +171 -0
  2. package/dist/chunks/ingest-transport-DAA_pJNb.js +245 -0
  3. package/dist/chunks/ingest-transport-DAA_pJNb.js.map +1 -0
  4. package/dist/chunks/normalisers-Cbd-i4Jh.js +97 -0
  5. package/dist/chunks/normalisers-Cbd-i4Jh.js.map +1 -0
  6. package/dist/chunks/{observability-CfwHKObh.js → observability-DUK725zv.js} +3 -3
  7. package/dist/chunks/{observability-CfwHKObh.js.map → observability-DUK725zv.js.map} +1 -1
  8. package/dist/chunks/{submitResolver-DsfkrUHY.js → submitResolver-wfr4kbEM.js} +60 -39
  9. package/dist/chunks/submitResolver-wfr4kbEM.js.map +1 -0
  10. package/dist/core/index.d.ts +1 -0
  11. package/dist/core/index.d.ts.map +1 -1
  12. package/dist/core/ingest-transport.d.ts +97 -0
  13. package/dist/core/ingest-transport.d.ts.map +1 -1
  14. package/dist/core/normalisers.d.ts +29 -0
  15. package/dist/core/normalisers.d.ts.map +1 -0
  16. package/dist/core/observability.d.ts.map +1 -1
  17. package/dist/core/state-metrics.d.ts +19 -0
  18. package/dist/core/state-metrics.d.ts.map +1 -0
  19. package/dist/core/transforms.d.ts.map +1 -1
  20. package/dist/core/types.d.ts +93 -3
  21. package/dist/core/types.d.ts.map +1 -1
  22. package/dist/index.js +16 -98
  23. package/dist/index.js.map +1 -1
  24. package/dist/react/admin/ActivityTimeline.d.ts +9 -0
  25. package/dist/react/admin/ActivityTimeline.d.ts.map +1 -0
  26. package/dist/react/admin/AnnotationComposer.d.ts +30 -0
  27. package/dist/react/admin/AnnotationComposer.d.ts.map +1 -0
  28. package/dist/react/admin/FeedbackListPage.d.ts.map +1 -1
  29. package/dist/react/admin/FeedbackUpdatePage.d.ts +6 -0
  30. package/dist/react/admin/FeedbackUpdatePage.d.ts.map +1 -1
  31. package/dist/react/admin/LifecycleControls.d.ts +28 -0
  32. package/dist/react/admin/LifecycleControls.d.ts.map +1 -0
  33. package/dist/react/admin/__tests__/ActivityTimeline.test.d.ts +2 -0
  34. package/dist/react/admin/__tests__/ActivityTimeline.test.d.ts.map +1 -0
  35. package/dist/react/admin/__tests__/AnnotationComposer.test.d.ts +2 -0
  36. package/dist/react/admin/__tests__/AnnotationComposer.test.d.ts.map +1 -0
  37. package/dist/react/admin/__tests__/LifecycleControls.test.d.ts +2 -0
  38. package/dist/react/admin/__tests__/LifecycleControls.test.d.ts.map +1 -0
  39. package/dist/react/admin/hooks/can.d.ts +47 -0
  40. package/dist/react/admin/hooks/can.d.ts.map +1 -0
  41. package/dist/react/admin/hooks/useStatusTransition.d.ts +16 -2
  42. package/dist/react/admin/hooks/useStatusTransition.d.ts.map +1 -1
  43. package/dist/react/admin/index.d.ts +14 -1
  44. package/dist/react/admin/index.d.ts.map +1 -1
  45. package/dist/react/admin/index.js +312 -29
  46. package/dist/react/admin/index.js.map +1 -1
  47. package/dist/react/admin/types.d.ts +13 -0
  48. package/dist/react/admin/types.d.ts.map +1 -1
  49. package/dist/react/resolver/submitResolver.d.ts +10 -7
  50. package/dist/react/resolver/submitResolver.d.ts.map +1 -1
  51. package/dist/react.js +1 -1
  52. package/dist/server/index.d.ts +4 -5
  53. package/dist/server/index.d.ts.map +1 -1
  54. package/dist/server/listHandler.d.ts +15 -8
  55. package/dist/server/listHandler.d.ts.map +1 -1
  56. package/dist/server.js +22 -170
  57. package/dist/server.js.map +1 -1
  58. package/package.json +13 -1
  59. package/dist/chunks/submitResolver-DsfkrUHY.js.map +0 -1
  60. package/dist/server/FeedbackService.d.ts +0 -46
  61. package/dist/server/FeedbackService.d.ts.map +0 -1
package/README.md CHANGED
@@ -95,6 +95,177 @@ export function AdminFeedbackRoute({ items, loading }) {
95
95
 
96
96
  Components handle loading and empty-state UI internally. Data fetch errors should be caught in your `fetchItem`/`updateItem` implementations and surfaced via your consumer-level error boundary.
97
97
 
98
+ ## Migrating to v1.0.0
99
+
100
+ Version 1.0.0 introduces three breaking changes that require consumer updates. This is a **MAJOR** semver bump because these changes can cause TypeScript compilation errors or runtime behavior differences in existing code.
101
+
102
+ ### Breaking Change 1: FeedbackStatus enum widened from 5 to 12 states
103
+
104
+ The `FeedbackStatus` type has been expanded from 5 legacy states to a 12-state QA lifecycle. Consumers with exhaustive `switch` statements or status-specific logic must handle the new states.
105
+
106
+ **Legacy → New Status Mapping (via `normaliseLegacyStatus`):**
107
+
108
+ | Legacy Status | New Status | Notes |
109
+ |--------------|------------|-------|
110
+ | `open` | `new` | Initial triage state |
111
+ | `approved` | `triaged` | Accepted for work |
112
+ | `deferred` | `deferred` | Unchanged |
113
+ | `resolved` | `closed` | Terminal state |
114
+ | `wontfix` | `rejected` | Terminal rejection |
115
+
116
+ **New 12-state lifecycle:**
117
+ - `new` — initial submission
118
+ - `triaged` — approved for work
119
+ - `assigned` — assigned to a team member
120
+ - `in_progress` — actively being worked on
121
+ - `fixed` — fix committed
122
+ - `pending_retest` — awaiting QA verification
123
+ - `verified` — fix confirmed
124
+ - `closed` — work complete
125
+ - `deferred` — postponed
126
+ - `rejected` — will not fix
127
+ - `duplicate` — merged into another item
128
+ - `reopened` — re-opened after closure
129
+
130
+ **Migration:**
131
+ ```typescript
132
+ // Before (exhaustive switch over 5 states)
133
+ switch (item.status) {
134
+ case 'open': return 'New';
135
+ case 'approved': return 'Approved';
136
+ case 'deferred': return 'Deferred';
137
+ case 'resolved': return 'Resolved';
138
+ case 'wontfix': return 'Rejected';
139
+ }
140
+
141
+ // After (handle all 12 states or use a default)
142
+ switch (item.status) {
143
+ case 'new': return 'New';
144
+ case 'triaged': return 'Triaged';
145
+ case 'assigned': return 'Assigned';
146
+ case 'in_progress': return 'In Progress';
147
+ case 'fixed': return 'Fixed';
148
+ case 'pending_retest': return 'Pending Retest';
149
+ case 'verified': return 'Verified';
150
+ case 'closed': return 'Closed';
151
+ case 'deferred': return 'Deferred';
152
+ case 'rejected': return 'Rejected';
153
+ case 'duplicate': return 'Duplicate';
154
+ case 'reopened': return 'Reopened';
155
+ default: return 'Unknown';
156
+ }
157
+
158
+ // Or normalize legacy data at read-time
159
+ import { normaliseLegacyStatus } from 'anton-bakker-feedback';
160
+ const normalised = normaliseLegacyStatus(item.status);
161
+ ```
162
+
163
+ ### Breaking Change 2: Whole-array `corrections[]` PATCH removed
164
+
165
+ The old `FeedbackUpdatePage` component internally performed a whole-array `corrections[]` PATCH, which was racy and overwrote concurrent updates. This operation is **removed**. Use atomic `appendActivity` and `transitionStatus` instead.
166
+
167
+ **Before (racy PATCH — DO NOT USE):**
168
+ ```typescript
169
+ // Old approach: fetch item, mutate corrections[], PATCH entire array back
170
+ const item = await fetchItem(id);
171
+ item.corrections.push({
172
+ field: 'status',
173
+ before: 'new',
174
+ after: 'triaged',
175
+ correctedBy: currentUser.userId,
176
+ correctedAt: new Date().toISOString(),
177
+ });
178
+ await updateItem(id, { corrections: item.corrections }); // ⚠️ Race condition
179
+ ```
180
+
181
+ **After (atomic append-only via annotate client):**
182
+ ```typescript
183
+ import { createIngestAnnotateClient } from 'anton-bakker-feedback';
184
+
185
+ const annotateClient = createIngestAnnotateClient({
186
+ apiUrl: process.env.FEEDBACK_INGEST_URL!,
187
+ tokenUrl: process.env.FEEDBACK_TOKEN_URL!,
188
+ clientId: process.env.FEEDBACK_CLIENT_ID!,
189
+ clientSecret: await resolveSecret('feedback/ingest/myapp'),
190
+ });
191
+
192
+ // Append a comment/note/question activity entry
193
+ const result = await annotateClient.appendActivity(feedbackId, {
194
+ id: crypto.randomUUID(),
195
+ actor: { userId: currentUser.userId, displayName: currentUser.name },
196
+ occurredAt: new Date().toISOString(),
197
+ recordedAt: new Date().toISOString(),
198
+ source: 'myapp',
199
+ kind: 'comment',
200
+ body: 'Investigating this issue',
201
+ schemaVersion: 1,
202
+ });
203
+
204
+ // Or transition status atomically with optimistic concurrency control
205
+ const transitionResult = await annotateClient.transitionStatus(
206
+ feedbackId,
207
+ 'triaged',
208
+ {
209
+ currentStatus: 'new', // Optimistic lock — server rejects if stale
210
+ reason: 'Approved for sprint 42',
211
+ assignee: 'user-123',
212
+ }
213
+ );
214
+ ```
215
+
216
+ ### Breaking Change 3: `FeedbackUpdatePage` deprecated
217
+
218
+ `FeedbackUpdatePage` is deprecated and will be removed in v2.0.0. Migrate to `FeedbackDetailPage` with injected `appendActivity`, `transitionStatus`, and capability guard functions.
219
+
220
+ **New `FeedbackDetailPage` prop interface:**
221
+ ```typescript
222
+ import type {
223
+ StoredFeedbackItem,
224
+ ActivityEntry,
225
+ FeedbackStatus,
226
+ AnnotateResult,
227
+ TransitionStatusOptions,
228
+ } from 'anton-bakker-feedback';
229
+ import type { FeedbackAnnotateClient } from 'anton-bakker-feedback';
230
+ import { can, CAPABILITIES } from 'anton-bakker-feedback/react/admin';
231
+
232
+ interface FeedbackDetailPageProps {
233
+ feedbackId: string;
234
+ fetchItem: (id: string) => Promise<StoredFeedbackItem>;
235
+ appendActivity: (feedbackId: string, entry: ActivityEntry) => Promise<AnnotateResult>;
236
+ transitionStatus: (
237
+ feedbackId: string,
238
+ toStatus: FeedbackStatus,
239
+ opts?: TransitionStatusOptions
240
+ ) => Promise<AnnotateResult>;
241
+ currentUser: { userId: string; name?: string };
242
+ capabilities?: { canEdit?: boolean; canChangeStatus?: boolean; canAssign?: boolean };
243
+ can?: typeof can; // Capability guard function
244
+ onNavigate: (path: string) => void;
245
+ getScreenshotUrl?: (s3Key: string) => Promise<string>;
246
+ className?: string;
247
+ }
248
+
249
+ // Example usage
250
+ <FeedbackDetailPage
251
+ feedbackId={id}
252
+ fetchItem={fetchItem}
253
+ appendActivity={annotateClient.appendActivity}
254
+ transitionStatus={annotateClient.transitionStatus}
255
+ currentUser={{ userId: 'user-123', name: 'Alice' }}
256
+ capabilities={{ canEdit: true, canChangeStatus: true }}
257
+ can={can}
258
+ onNavigate={router.push}
259
+ />
260
+ ```
261
+
262
+ **Why this is a MAJOR bump:**
263
+ - TypeScript will error on exhaustive switches over the old 5-state enum
264
+ - Removing the `corrections[]` PATCH removes a previously accessible public behavior
265
+ - Consumers must update imports and prop interfaces
266
+
267
+ For a complete list of new exports and deprecations, see [CHANGELOG.md](./CHANGELOG.md).
268
+
98
269
  ## Development
99
270
 
100
271
  ```bash
@@ -0,0 +1,245 @@
1
+ const FEEDBACK_COLLECTION = "feedback";
2
+ const INGEST_WRITE_SCOPE = "assets/write";
3
+ const INGEST_READ_SCOPE = "assets/read";
4
+ function assertHttps(url, label) {
5
+ let u;
6
+ try {
7
+ u = new URL(url);
8
+ } catch {
9
+ throw new Error(`ingest-transport: ${label} is not a valid URL: ${url}`);
10
+ }
11
+ if (u.protocol !== "https:") {
12
+ throw new Error(`ingest-transport: ${label} must be https:// (got ${u.protocol}//) — refusing plaintext`);
13
+ }
14
+ }
15
+ const SERVER_OWNED_FIELDS = [
16
+ "PK",
17
+ "SK",
18
+ "GSI1PK",
19
+ "GSI1SK",
20
+ "GSI2PK",
21
+ "GSI2SK"
22
+ ];
23
+ function toContent(item) {
24
+ const content = { ...item };
25
+ for (const f of SERVER_OWNED_FIELDS) delete content[f];
26
+ return content;
27
+ }
28
+ function createIngestTransport(config) {
29
+ const collection = config.collection ?? FEEDBACK_COLLECTION;
30
+ const refreshSkewMs = (config.refreshSkewSeconds ?? 60) * 1e3;
31
+ const doFetch = resolveFetch(config);
32
+ validateTransportConfig(config);
33
+ const getToken = createTokenGetter(config, doFetch, refreshSkewMs, INGEST_WRITE_SCOPE);
34
+ return {
35
+ async submit(item) {
36
+ try {
37
+ const token = await getToken();
38
+ const base = config.apiUrl.replace(/\/$/, "");
39
+ const res = await doFetch(`${base}/records/${encodeURIComponent(collection)}`, {
40
+ method: "POST",
41
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json" },
42
+ body: JSON.stringify({ item: toContent(item) })
43
+ });
44
+ const data = await res.json().catch(() => ({}));
45
+ if (res.status === 200 && data.success === true) {
46
+ return { success: true, feedbackId: data.ids?.[0] ?? item.id };
47
+ }
48
+ const retryable = res.status >= 500;
49
+ const msg = data.error ?? data.errors?.join("; ") ?? `HTTP ${res.status}`;
50
+ return { success: false, feedbackId: item.id, error: `ingest write failed: ${msg}`, retryable };
51
+ } catch (err) {
52
+ return { success: false, feedbackId: item.id, error: err.message, retryable: true };
53
+ }
54
+ }
55
+ };
56
+ }
57
+ function toBase64(s) {
58
+ if (typeof btoa === "function") return btoa(s);
59
+ const B = globalThis.Buffer;
60
+ if (B) return B.from(s).toString("base64");
61
+ throw new Error("ingest-transport: no base64 encoder available");
62
+ }
63
+ function resolveFetch(config) {
64
+ const doFetch = config.fetchImpl ?? globalThis.fetch;
65
+ if (typeof doFetch !== "function") {
66
+ throw new Error("ingest-transport: no fetch implementation available");
67
+ }
68
+ return doFetch;
69
+ }
70
+ function validateTransportConfig(config) {
71
+ assertHttps(config.apiUrl, "apiUrl");
72
+ if (!config.tokenProvider) {
73
+ if (!config.tokenUrl || !config.clientId || !config.clientSecret) {
74
+ throw new Error("ingest-transport: provide tokenProvider, OR tokenUrl + clientId + clientSecret");
75
+ }
76
+ assertHttps(config.tokenUrl, "tokenUrl");
77
+ }
78
+ }
79
+ function createTokenGetter(config, doFetch, refreshSkewMs, scope) {
80
+ let cachedToken = null;
81
+ let cachedExpiryMs = 0;
82
+ async function mintToken() {
83
+ const basic = toBase64(`${config.clientId}:${config.clientSecret}`);
84
+ const res = await doFetch(config.tokenUrl, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${basic}` },
87
+ body: `grant_type=client_credentials&scope=${encodeURIComponent(scope)}`
88
+ });
89
+ const data = await res.json();
90
+ if (!res.ok || !data.access_token) {
91
+ throw new Error(`token mint failed (${res.status}): ${data.error ?? ""} ${data.error_description ?? ""}`.trim());
92
+ }
93
+ cachedToken = data.access_token;
94
+ cachedExpiryMs = Date.now() + Math.max(0, (data.expires_in ?? 3600) * 1e3 - refreshSkewMs);
95
+ return cachedToken;
96
+ }
97
+ return async function getToken() {
98
+ if (config.tokenProvider) return config.tokenProvider();
99
+ if (cachedToken && Date.now() < cachedExpiryMs) return cachedToken;
100
+ return mintToken();
101
+ };
102
+ }
103
+ function normaliseReadBody(data) {
104
+ if (Array.isArray(data)) return data;
105
+ if (data && typeof data === "object") {
106
+ const obj = data;
107
+ if (Array.isArray(obj["items"])) return obj["items"];
108
+ if (Array.isArray(obj["records"])) return obj["records"];
109
+ }
110
+ return [];
111
+ }
112
+ function createIngestReader(config) {
113
+ const collection = config.collection ?? FEEDBACK_COLLECTION;
114
+ const refreshSkewMs = (config.refreshSkewSeconds ?? 60) * 1e3;
115
+ const doFetch = resolveFetch(config);
116
+ validateTransportConfig(config);
117
+ const getToken = createTokenGetter(config, doFetch, refreshSkewMs, INGEST_READ_SCOPE);
118
+ return {
119
+ async list() {
120
+ const token = await getToken();
121
+ const base = config.apiUrl.replace(/\/$/, "");
122
+ const res = await doFetch(`${base}/records/${encodeURIComponent(collection)}`, {
123
+ method: "GET",
124
+ headers: { authorization: `Bearer ${token}`, accept: "application/json" }
125
+ });
126
+ const data = await res.json().catch(() => void 0);
127
+ if (!res.ok) {
128
+ const err = data ?? {};
129
+ const msg = err.error ?? err.errors?.join("; ") ?? `HTTP ${res.status}`;
130
+ throw new Error(`ingest read failed (${res.status}): ${msg}`);
131
+ }
132
+ return normaliseReadBody(data);
133
+ }
134
+ };
135
+ }
136
+ function createIngestAnnotateClient(config) {
137
+ const collection = config.collection ?? FEEDBACK_COLLECTION;
138
+ const refreshSkewMs = (config.refreshSkewSeconds ?? 60) * 1e3;
139
+ const doFetch = resolveFetch(config);
140
+ validateTransportConfig(config);
141
+ const getToken = createTokenGetter(config, doFetch, refreshSkewMs, INGEST_WRITE_SCOPE);
142
+ return {
143
+ async appendActivity(feedbackId, entry) {
144
+ try {
145
+ const token = await getToken();
146
+ const base = config.apiUrl.replace(/\/$/, "");
147
+ const url = `${base}/records/${encodeURIComponent(collection)}/${encodeURIComponent(feedbackId)}/activity`;
148
+ const res = await doFetch(url, {
149
+ method: "POST",
150
+ headers: {
151
+ authorization: `Bearer ${token}`,
152
+ "content-type": "application/json",
153
+ accept: "application/json"
154
+ },
155
+ body: JSON.stringify({ entry })
156
+ });
157
+ const data = await res.json().catch(() => ({}));
158
+ if (res.status === 200 && data.success === true) {
159
+ return { success: true, feedbackId };
160
+ }
161
+ let conflictReason;
162
+ if (res.status === 409) conflictReason = "stale_status";
163
+ else if (res.status === 403) conflictReason = "missing_capability";
164
+ else if (res.status === 404) conflictReason = "not_found";
165
+ const retryable = res.status >= 500;
166
+ const msg = data.error ?? data.errors?.join("; ") ?? `HTTP ${res.status}`;
167
+ const result = {
168
+ success: false,
169
+ feedbackId,
170
+ error: `appendActivity failed: ${msg}`,
171
+ retryable
172
+ };
173
+ if (conflictReason !== void 0) {
174
+ result.conflictReason = conflictReason;
175
+ }
176
+ return result;
177
+ } catch (err) {
178
+ return {
179
+ success: false,
180
+ feedbackId,
181
+ error: err.message,
182
+ retryable: true
183
+ };
184
+ }
185
+ },
186
+ async transitionStatus(feedbackId, toStatus, opts) {
187
+ try {
188
+ const token = await getToken();
189
+ const base = config.apiUrl.replace(/\/$/, "");
190
+ const url = `${base}/records/${encodeURIComponent(collection)}/${encodeURIComponent(feedbackId)}/status`;
191
+ const body = { toStatus };
192
+ if (opts?.currentStatus !== void 0) body.currentStatus = opts.currentStatus;
193
+ if (opts?.reason !== void 0) body.reason = opts.reason;
194
+ if (opts?.assignee !== void 0) body.assignee = opts.assignee;
195
+ if (opts?.duplicateOf !== void 0) body.duplicateOf = opts.duplicateOf;
196
+ if (opts?.duplicateReporters !== void 0) body.duplicateReporters = opts.duplicateReporters;
197
+ const res = await doFetch(url, {
198
+ method: "PATCH",
199
+ headers: {
200
+ authorization: `Bearer ${token}`,
201
+ "content-type": "application/json",
202
+ accept: "application/json"
203
+ },
204
+ body: JSON.stringify(body)
205
+ });
206
+ const data = await res.json().catch(() => ({}));
207
+ if (res.status === 200 && data.success === true) {
208
+ return { success: true, feedbackId };
209
+ }
210
+ let conflictReason;
211
+ if (res.status === 409) conflictReason = "stale_status";
212
+ else if (res.status === 403) conflictReason = "missing_capability";
213
+ else if (res.status === 404) conflictReason = "not_found";
214
+ const retryable = res.status >= 500;
215
+ const msg = data.error ?? data.errors?.join("; ") ?? `HTTP ${res.status}`;
216
+ const result = {
217
+ success: false,
218
+ feedbackId,
219
+ error: `transitionStatus failed: ${msg}`,
220
+ retryable
221
+ };
222
+ if (conflictReason !== void 0) {
223
+ result.conflictReason = conflictReason;
224
+ }
225
+ return result;
226
+ } catch (err) {
227
+ return {
228
+ success: false,
229
+ feedbackId,
230
+ error: err.message,
231
+ retryable: true
232
+ };
233
+ }
234
+ }
235
+ };
236
+ }
237
+ export {
238
+ FEEDBACK_COLLECTION as F,
239
+ INGEST_READ_SCOPE as I,
240
+ createIngestTransport as a,
241
+ INGEST_WRITE_SCOPE as b,
242
+ createIngestReader as c,
243
+ createIngestAnnotateClient as d
244
+ };
245
+ //# sourceMappingURL=ingest-transport-DAA_pJNb.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ingest-transport-DAA_pJNb.js","sources":["../../src/core/ingest-transport.ts"],"sourcesContent":["/**\n * ingest-transport.ts — central-ingest implementation of FeedbackSubmitTransport.\n *\n * The central-storage principle: a consuming application reaches central feedback storage ONLY through\n * an authenticated API — it mints a short-lived OAuth2 client-credentials JWT and POSTs the feedback\n * CONTENT to the generic `/records/{collection}` ingest endpoint. The server attributes every write to\n * the verified token identity (its `client_id` claim) and stamps the canonical keys (PK/SK/GSI); the\n * application holds no AWS principal and sends no partition/identity fields. This replaces the bespoke\n * direct-DynamoDB writer (`FeedbackService.put`) — the pattern the principle removes.\n *\n * This transport is the client half of that contract, matching:\n * - the deployed ingest API (`POST /records/feedback`, body `{ item }`, response `{ success, ids }`),\n * - ralph's `ingest-client.mjs` (same route + shape),\n * - deploy-engine's `ingest-verify` (same token mint + write).\n *\n * The READ half (`createIngestReader`) is the symmetric client for `GET /records/feedback` (scope\n * `assets/read`): the server derives the app from the verified JWT and returns only that app's own-data\n * partition. There is no server-side query/filter — status/limit/paging are applied by the caller.\n *\n * Zero runtime dependencies — uses the built-in `fetch` (keeps the package browser-safe and SDK-free).\n * The caller supplies the app-client credential (or a token provider); the SECRET is resolved by the\n * host out-of-band (SSM/password manager) and never embedded here. HTTPS is enforced — a token or\n * record sent over plaintext is a compromise.\n *\n * NO AWS SDK imports in this file.\n */\n\nimport type {\n StoredFeedbackItem,\n SubmitResult,\n ActivityEntry,\n FeedbackStatus,\n Reporter,\n} from './types.js';\nimport type { FeedbackSubmitTransport } from './transport.js';\n\n// ─────────────────────────────────────────────────────────────────\n// Config\n// ─────────────────────────────────────────────────────────────────\n\n/** The ingest collection feedback writes to (the deployed registry maps it to `ralph-feedback`). */\nexport const FEEDBACK_COLLECTION = 'feedback';\n/** OAuth2 scope required to write via the ingest API. */\nexport const INGEST_WRITE_SCOPE = 'assets/write';\n/** OAuth2 scope required to read via the ingest API. */\nexport const INGEST_READ_SCOPE = 'assets/read';\n\n/** Minimal fetch surface (so the package needs no DOM lib types to compile). */\nexport type FetchLike = (\n url: string,\n init?: { method?: string; headers?: Record<string, string>; body?: string },\n) => Promise<{ status: number; ok: boolean; json: () => Promise<unknown> }>;\n\n/**\n * How the transport obtains a bearer token. Either:\n * - supply `clientId` + `clientSecret` + `tokenUrl` and let the transport mint/refresh it, OR\n * - supply a `tokenProvider()` (e.g. the host already manages the token) — takes precedence.\n */\nexport interface IngestTransportConfig {\n /** Ingest API base URL (HTTPS), e.g. https://xxxx.execute-api.<region>.amazonaws.com */\n apiUrl: string;\n /** OAuth2 token endpoint (HTTPS) — required unless `tokenProvider` is given. */\n tokenUrl?: string;\n /** App-client id (the app identity the server attributes writes to). Required unless `tokenProvider`. */\n clientId?: string;\n /** App-client secret. SHOULD come from the host's secret store; required unless `tokenProvider`. */\n clientSecret?: string;\n /** Supply a token directly (overrides client-credentials minting). */\n tokenProvider?: () => Promise<string>;\n /** Collection name (default 'feedback'). */\n collection?: string;\n /** Seconds before expiry to proactively re-mint (default 60). */\n refreshSkewSeconds?: number;\n /** Injectable fetch (tests). Defaults to global fetch. */\n fetchImpl?: FetchLike;\n}\n\nfunction assertHttps(url: string, label: string): void {\n let u: URL;\n try {\n u = new URL(url);\n } catch {\n throw new Error(`ingest-transport: ${label} is not a valid URL: ${url}`);\n }\n if (u.protocol !== 'https:') {\n throw new Error(`ingest-transport: ${label} must be https:// (got ${u.protocol}//) — refusing plaintext`);\n }\n}\n\n/** Identity/partition + local-only fields the server owns — stripped before sending the content. */\nconst SERVER_OWNED_FIELDS: readonly string[] = [\n 'PK', 'SK', 'GSI1PK', 'GSI1SK', 'GSI2PK', 'GSI2SK',\n];\n\n/** Strip the fields the server stamps from the verified identity (never sent/trusted from the client). */\nfunction toContent(item: StoredFeedbackItem): Record<string, unknown> {\n const content: Record<string, unknown> = { ...(item as unknown as Record<string, unknown>) };\n for (const f of SERVER_OWNED_FIELDS) delete content[f];\n return content;\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Transport\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * Create a FeedbackSubmitTransport that writes to the central ingest API.\n *\n * Usage (host app wires the credential from its secret store):\n * const transport = createIngestTransport({\n * apiUrl: process.env.FEEDBACK_INGEST_URL!,\n * tokenUrl: process.env.FEEDBACK_TOKEN_URL!,\n * clientId: process.env.FEEDBACK_CLIENT_ID!,\n * clientSecret: await resolveSecret('feedback/ingest/<app>'),\n * });\n * await transport.submit(item); // → { success, feedbackId }\n */\nexport function createIngestTransport(config: IngestTransportConfig): FeedbackSubmitTransport {\n const collection = config.collection ?? FEEDBACK_COLLECTION;\n const refreshSkewMs = (config.refreshSkewSeconds ?? 60) * 1000;\n const doFetch = resolveFetch(config);\n\n validateTransportConfig(config);\n\n const getToken = createTokenGetter(config, doFetch, refreshSkewMs, INGEST_WRITE_SCOPE);\n\n return {\n async submit(item: StoredFeedbackItem): Promise<SubmitResult> {\n try {\n const token = await getToken();\n const base = config.apiUrl.replace(/\\/$/, '');\n const res = await doFetch(`${base}/records/${encodeURIComponent(collection)}`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify({ item: toContent(item) }),\n });\n const data = (await res.json().catch(() => ({}))) as { success?: boolean; ids?: string[]; error?: string; errors?: string[] };\n if (res.status === 200 && data.success === true) {\n return { success: true, feedbackId: data.ids?.[0] ?? item.id };\n }\n // Explicit failure — never a silent drop. 5xx is retryable; 4xx (validation/auth) is not.\n const retryable = res.status >= 500;\n const msg = data.error ?? data.errors?.join('; ') ?? `HTTP ${res.status}`;\n return { success: false, feedbackId: item.id, error: `ingest write failed: ${msg}`, retryable };\n } catch (err) {\n // Transport/network failure — retryable (the caller may buffer + retry).\n return { success: false, feedbackId: item.id, error: (err as Error).message, retryable: true };\n }\n },\n };\n}\n\n/** Base64 encode a string in both Node and browser (zero-dep). */\nfunction toBase64(s: string): string {\n if (typeof btoa === 'function') return btoa(s);\n // Node fallback — Buffer exists; typed loosely to avoid a hard @types/node coupling in the browser build.\n const B = (globalThis as unknown as { Buffer?: { from: (x: string) => { toString: (enc: string) => string } } }).Buffer;\n if (B) return B.from(s).toString('base64');\n throw new Error('ingest-transport: no base64 encoder available');\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Shared token / config helpers (used by both the write transport and the reader)\n// ─────────────────────────────────────────────────────────────────\n\n/** Resolve the fetch implementation (injected for tests, else global). */\nfunction resolveFetch(config: { fetchImpl?: FetchLike }): FetchLike {\n const doFetch = config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);\n if (typeof doFetch !== 'function') {\n throw new Error('ingest-transport: no fetch implementation available');\n }\n return doFetch;\n}\n\n/** Validate credential/URL config shared by the write transport and the reader. */\nfunction validateTransportConfig(config: IngestTransportConfig): void {\n assertHttps(config.apiUrl, 'apiUrl');\n if (!config.tokenProvider) {\n if (!config.tokenUrl || !config.clientId || !config.clientSecret) {\n throw new Error('ingest-transport: provide tokenProvider, OR tokenUrl + clientId + clientSecret');\n }\n assertHttps(config.tokenUrl, 'tokenUrl');\n }\n}\n\n/**\n * Build a cached token getter for a given OAuth2 scope. When a `tokenProvider` is supplied it takes\n * precedence (no minting); otherwise a client-credentials token is minted for `scope` and cached\n * until just before expiry.\n */\nfunction createTokenGetter(\n config: IngestTransportConfig,\n doFetch: FetchLike,\n refreshSkewMs: number,\n scope: string,\n): () => Promise<string> {\n let cachedToken: string | null = null;\n let cachedExpiryMs = 0;\n\n async function mintToken(): Promise<string> {\n const basic = toBase64(`${config.clientId}:${config.clientSecret}`);\n const res = await doFetch(config.tokenUrl as string, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded', authorization: `Basic ${basic}` },\n body: `grant_type=client_credentials&scope=${encodeURIComponent(scope)}`,\n });\n const data = (await res.json()) as { access_token?: string; expires_in?: number; error?: string; error_description?: string };\n if (!res.ok || !data.access_token) {\n throw new Error(`token mint failed (${res.status}): ${data.error ?? ''} ${data.error_description ?? ''}`.trim());\n }\n cachedToken = data.access_token;\n cachedExpiryMs = Date.now() + Math.max(0, (data.expires_in ?? 3600) * 1000 - refreshSkewMs);\n return cachedToken;\n }\n\n return async function getToken(): Promise<string> {\n if (config.tokenProvider) return config.tokenProvider();\n if (cachedToken && Date.now() < cachedExpiryMs) return cachedToken;\n return mintToken();\n };\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Reader — authenticated READ/LIST half of the central-storage contract\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * The read half of the central-storage contract. `list()` mints/reuses a short-lived\n * client-credentials JWT (scope `assets/read`), GETs `/records/{collection}` with a Bearer token,\n * and returns the app's OWN-DATA partition as an array. The server derives the app from the verified\n * token identity and returns only that app's items — there is NO server-side GSI query or SK range,\n * so any status/limit/paging filtering is the caller's responsibility (applied client-side).\n *\n * Zero AWS SDK — `fetch`-only, mirroring `createIngestTransport`.\n */\nexport interface FeedbackReader {\n /** Read this app's own feedback partition. Returns an array (empty when the partition is empty). */\n list(): Promise<StoredFeedbackItem[]>;\n}\n\n/** Normalise the ingest read response to an array. Accepts `[...]` or `{ items: [...] }` / `{ records: [...] }`. */\nfunction normaliseReadBody(data: unknown): StoredFeedbackItem[] {\n if (Array.isArray(data)) return data as StoredFeedbackItem[];\n if (data && typeof data === 'object') {\n const obj = data as Record<string, unknown>;\n if (Array.isArray(obj['items'])) return obj['items'] as StoredFeedbackItem[];\n if (Array.isArray(obj['records'])) return obj['records'] as StoredFeedbackItem[];\n }\n return [];\n}\n\n/**\n * Create a reader that fetches this app's feedback partition via the central ingest API.\n *\n * Usage (host app wires the credential from its secret store):\n * const reader = createIngestReader({\n * apiUrl: process.env.FEEDBACK_INGEST_URL!,\n * tokenUrl: process.env.FEEDBACK_TOKEN_URL!,\n * clientId: process.env.FEEDBACK_CLIENT_ID!,\n * clientSecret: await resolveSecret('ralph/ingest/feedback'),\n * });\n * const items = await reader.list(); // → StoredFeedbackItem[]\n */\nexport function createIngestReader(config: IngestTransportConfig): FeedbackReader {\n const collection = config.collection ?? FEEDBACK_COLLECTION;\n const refreshSkewMs = (config.refreshSkewSeconds ?? 60) * 1000;\n const doFetch = resolveFetch(config);\n\n validateTransportConfig(config);\n\n const getToken = createTokenGetter(config, doFetch, refreshSkewMs, INGEST_READ_SCOPE);\n\n return {\n async list(): Promise<StoredFeedbackItem[]> {\n const token = await getToken();\n const base = config.apiUrl.replace(/\\/$/, '');\n const res = await doFetch(`${base}/records/${encodeURIComponent(collection)}`, {\n method: 'GET',\n headers: { authorization: `Bearer ${token}`, accept: 'application/json' },\n });\n const data = await res.json().catch(() => undefined);\n if (!res.ok) {\n const err = (data ?? {}) as { error?: string; errors?: string[] };\n const msg = err.error ?? err.errors?.join('; ') ?? `HTTP ${res.status}`;\n throw new Error(`ingest read failed (${res.status}): ${msg}`);\n }\n return normaliseReadBody(data);\n },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Annotate — atomic activity append and status transition\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * Result of an annotate or transition operation.\n * Mirrors the existing SubmitResult shape with the addition of conflictReason\n * for classifying non-retryable server rejections.\n */\nexport interface AnnotateResult {\n success: boolean;\n feedbackId: string;\n error?: string;\n retryable?: boolean;\n /** Specific reason for a server rejection (HTTP 409/403/404). */\n conflictReason?: 'stale_status' | 'missing_capability' | 'not_found';\n}\n\n/**\n * Options for transitionStatus operation.\n * Supports optimistic concurrency control (currentStatus), duplicate merging\n * (duplicateOf + duplicateReporters), and metadata (reason, assignee).\n */\nexport interface TransitionStatusOptions {\n /** Expected current status for optimistic concurrency control. Server returns HTTP 409 if stale. */\n currentStatus?: FeedbackStatus;\n /** Human-readable reason for the transition. */\n reason?: string;\n /** User ID to assign the item to. */\n assignee?: string;\n /** Feedback ID this item is a duplicate of (when transitioning to 'duplicate'). */\n duplicateOf?: string;\n /** Reporter list from the duplicate item to merge onto the canonical item atomically. */\n duplicateReporters?: Reporter[];\n}\n\n/**\n * The annotate/transition half of the central-storage contract.\n * Provides atomic operations for appending activity and transitioning status.\n */\nexport interface FeedbackAnnotateClient {\n /**\n * Atomically append an activity entry to a feedback item.\n * Maps to POST /records/feedback/{feedbackId}/activity\n */\n appendActivity(feedbackId: string, entry: ActivityEntry): Promise<AnnotateResult>;\n\n /**\n * Atomically transition a feedback item's status with optimistic concurrency control.\n * Maps to PATCH /records/feedback/{feedbackId}/status\n */\n transitionStatus(\n feedbackId: string,\n toStatus: FeedbackStatus,\n opts?: TransitionStatusOptions,\n ): Promise<AnnotateResult>;\n}\n\n/**\n * Create a FeedbackAnnotateClient that writes to the central ingest API.\n * Reuses the same config validation and token management as createIngestTransport.\n *\n * Error mapping (all returned as AnnotateResult, never thrown):\n * - HTTP 409 → conflictReason='stale_status', retryable=false\n * - HTTP 403 → conflictReason='missing_capability', retryable=false\n * - HTTP 404 → conflictReason='not_found', retryable=false\n * - HTTP 5xx → retryable=true\n * - fetch throws → retryable=true\n *\n * Usage:\n * const annotateClient = createIngestAnnotateClient({\n * apiUrl: process.env.FEEDBACK_INGEST_URL!,\n * tokenUrl: process.env.FEEDBACK_TOKEN_URL!,\n * clientId: process.env.FEEDBACK_CLIENT_ID!,\n * clientSecret: await resolveSecret('feedback/ingest/<app>'),\n * });\n * await annotateClient.appendActivity(feedbackId, activityEntry);\n * await annotateClient.transitionStatus(feedbackId, 'triaged', { currentStatus: 'new' });\n */\nexport function createIngestAnnotateClient(config: IngestTransportConfig): FeedbackAnnotateClient {\n const collection = config.collection ?? FEEDBACK_COLLECTION;\n const refreshSkewMs = (config.refreshSkewSeconds ?? 60) * 1000;\n const doFetch = resolveFetch(config);\n\n validateTransportConfig(config);\n\n const getToken = createTokenGetter(config, doFetch, refreshSkewMs, INGEST_WRITE_SCOPE);\n\n return {\n async appendActivity(feedbackId: string, entry: ActivityEntry): Promise<AnnotateResult> {\n try {\n const token = await getToken();\n const base = config.apiUrl.replace(/\\/$/, '');\n const url = `${base}/records/${encodeURIComponent(collection)}/${encodeURIComponent(feedbackId)}/activity`;\n\n const res = await doFetch(url, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`,\n 'content-type': 'application/json',\n accept: 'application/json',\n },\n body: JSON.stringify({ entry }),\n });\n\n const data = (await res.json().catch(() => ({}))) as {\n success?: boolean;\n error?: string;\n errors?: string[];\n };\n\n if (res.status === 200 && data.success === true) {\n return { success: true, feedbackId };\n }\n\n // Map HTTP status codes to conflict reasons\n let conflictReason: 'stale_status' | 'missing_capability' | 'not_found' | undefined;\n if (res.status === 409) conflictReason = 'stale_status';\n else if (res.status === 403) conflictReason = 'missing_capability';\n else if (res.status === 404) conflictReason = 'not_found';\n\n const retryable = res.status >= 500;\n const msg = data.error ?? data.errors?.join('; ') ?? `HTTP ${res.status}`;\n const result: AnnotateResult = {\n success: false,\n feedbackId,\n error: `appendActivity failed: ${msg}`,\n retryable,\n };\n if (conflictReason !== undefined) {\n result.conflictReason = conflictReason;\n }\n return result;\n } catch (err) {\n // Network/transport failure — retryable\n return {\n success: false,\n feedbackId,\n error: (err as Error).message,\n retryable: true,\n };\n }\n },\n\n async transitionStatus(\n feedbackId: string,\n toStatus: FeedbackStatus,\n opts?: TransitionStatusOptions,\n ): Promise<AnnotateResult> {\n try {\n const token = await getToken();\n const base = config.apiUrl.replace(/\\/$/, '');\n const url = `${base}/records/${encodeURIComponent(collection)}/${encodeURIComponent(feedbackId)}/status`;\n\n // Build the request body with toStatus and all optional fields\n const body: Record<string, unknown> = { toStatus };\n if (opts?.currentStatus !== undefined) body.currentStatus = opts.currentStatus;\n if (opts?.reason !== undefined) body.reason = opts.reason;\n if (opts?.assignee !== undefined) body.assignee = opts.assignee;\n if (opts?.duplicateOf !== undefined) body.duplicateOf = opts.duplicateOf;\n if (opts?.duplicateReporters !== undefined) body.duplicateReporters = opts.duplicateReporters;\n\n const res = await doFetch(url, {\n method: 'PATCH',\n headers: {\n authorization: `Bearer ${token}`,\n 'content-type': 'application/json',\n accept: 'application/json',\n },\n body: JSON.stringify(body),\n });\n\n const data = (await res.json().catch(() => ({}))) as {\n success?: boolean;\n error?: string;\n errors?: string[];\n };\n\n if (res.status === 200 && data.success === true) {\n return { success: true, feedbackId };\n }\n\n // Map HTTP status codes to conflict reasons\n let conflictReason: 'stale_status' | 'missing_capability' | 'not_found' | undefined;\n if (res.status === 409) conflictReason = 'stale_status';\n else if (res.status === 403) conflictReason = 'missing_capability';\n else if (res.status === 404) conflictReason = 'not_found';\n\n const retryable = res.status >= 500;\n const msg = data.error ?? data.errors?.join('; ') ?? `HTTP ${res.status}`;\n const result: AnnotateResult = {\n success: false,\n feedbackId,\n error: `transitionStatus failed: ${msg}`,\n retryable,\n };\n if (conflictReason !== undefined) {\n result.conflictReason = conflictReason;\n }\n return result;\n } catch (err) {\n // Network/transport failure — retryable\n return {\n success: false,\n feedbackId,\n error: (err as Error).message,\n retryable: true,\n };\n }\n },\n };\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,97 @@
1
+ const NORMALISER_SCHEMA_VERSION = 1;
2
+ const LEGACY_STATUS_MAP = {
3
+ open: "new",
4
+ approved: "triaged",
5
+ deferred: "deferred",
6
+ resolved: "closed",
7
+ wontfix: "rejected"
8
+ };
9
+ const NEW_STATUS_VALUES = /* @__PURE__ */ new Set([
10
+ "new",
11
+ "triaged",
12
+ "assigned",
13
+ "in_progress",
14
+ "fixed",
15
+ "pending_retest",
16
+ "verified",
17
+ "closed",
18
+ "deferred",
19
+ "rejected",
20
+ "duplicate",
21
+ "reopened"
22
+ ]);
23
+ function normaliseLegacyStatus(status) {
24
+ if (NEW_STATUS_VALUES.has(status)) return status;
25
+ const mapped = LEGACY_STATUS_MAP[status];
26
+ return mapped ?? "new";
27
+ }
28
+ function stableActivityId(sourceKind, index, occurredAt) {
29
+ return `legacy:${sourceKind}:${index}:${occurredAt}`;
30
+ }
31
+ function dialogueToActivity(entry, index) {
32
+ const kind = entry.role === "assistant" ? "answer" : "comment";
33
+ return {
34
+ id: stableActivityId("dialogue", index, entry.timestamp),
35
+ actor: { userId: entry.role, displayName: entry.role },
36
+ occurredAt: entry.timestamp,
37
+ recordedAt: entry.timestamp,
38
+ source: "legacy-normaliser",
39
+ kind,
40
+ body: entry.content,
41
+ schemaVersion: NORMALISER_SCHEMA_VERSION
42
+ };
43
+ }
44
+ function correctionToActivity(entry, index) {
45
+ const isStatus = entry.field === "status";
46
+ const kind = isStatus ? "status_change" : "classification_change";
47
+ const before = isStatus ? normaliseLegacyStatus(entry.before) : entry.before;
48
+ const after = isStatus ? normaliseLegacyStatus(entry.after) : entry.after;
49
+ return {
50
+ id: stableActivityId("correction", index, entry.correctedAt),
51
+ actor: { userId: entry.correctedBy, displayName: entry.correctedBy },
52
+ occurredAt: entry.correctedAt,
53
+ recordedAt: entry.correctedAt,
54
+ source: "legacy-normaliser",
55
+ kind,
56
+ before,
57
+ after,
58
+ ...entry.reason !== void 0 ? { reason: entry.reason } : {},
59
+ schemaVersion: NORMALISER_SCHEMA_VERSION
60
+ };
61
+ }
62
+ function normaliseActivityArrays(item) {
63
+ const existing = item.activity ?? [];
64
+ const existingIds = new Set(existing.map((e) => e.id));
65
+ const synthesised = [
66
+ ...(item.dialogueHistory ?? []).map((d, i) => dialogueToActivity(d, i)),
67
+ ...(item.corrections ?? []).map((c, i) => correctionToActivity(c, i))
68
+ ];
69
+ const toAppend = synthesised.filter((e) => !existingIds.has(e.id));
70
+ if (toAppend.length === 0) {
71
+ return item.activity ? item : { ...item, activity: existing };
72
+ }
73
+ const merged = [...existing, ...toAppend].sort(
74
+ (a, b) => new Date(a.occurredAt).getTime() - new Date(b.occurredAt).getTime()
75
+ );
76
+ return { ...item, activity: merged };
77
+ }
78
+ function normaliseReportContent(item) {
79
+ const hasActual = Boolean(item.report?.actual);
80
+ const content = item.content?.trim();
81
+ if (hasActual || !content) return item;
82
+ const report = { ...item.report ?? {}, actual: item.content };
83
+ return { ...item, report };
84
+ }
85
+ function normaliseStoredItem(item) {
86
+ const status = normaliseLegacyStatus(item.status);
87
+ const withStatus = status === item.status ? item : { ...item, status };
88
+ const withActivity = normaliseActivityArrays(withStatus);
89
+ return normaliseReportContent(withActivity);
90
+ }
91
+ export {
92
+ normaliseLegacyStatus as a,
93
+ normaliseReportContent as b,
94
+ normaliseStoredItem as c,
95
+ normaliseActivityArrays as n
96
+ };
97
+ //# sourceMappingURL=normalisers-Cbd-i4Jh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalisers-Cbd-i4Jh.js","sources":["../../src/core/normalisers.ts"],"sourcesContent":["/**\n * normalisers.ts — read-time legacy normalisers (core layer, ZERO framework deps).\n *\n * Location: src/core/normalisers.ts\n *\n * Purpose: reconcile legacy stored rows into the current canonical model WITHOUT ever\n * rewriting stored data. Pure, idempotent, side-effect-free functions. Consumed by\n * server code, the admin UI, and future CLIs — so it lives in the zero-dependency core\n * layer (imports only ./types.js).\n *\n * Contract (design/feedback-annotations/02-lifecycle-and-guard/DESIGN.md §WP-1):\n * - normaliseLegacyStatus: map the 5 legacy status values onto the 12-state QA lifecycle;\n * new values pass through unchanged.\n * - normaliseActivityArrays: merge dialogueHistory + corrections into activity[] (Tolerant\n * Reader), idempotent by a deterministic per-entry id.\n * - normaliseReportContent: content-only legacy item → report.actual = content fallback.\n * - normaliseStoredItem: compose all three; never mutate the input (returns a new object).\n */\n\nimport type {\n StoredFeedbackItem,\n FeedbackStatus,\n LegacyFeedbackStatus,\n ActivityEntry,\n ActivityKind,\n DialogueEntry,\n CorrectionEntry,\n ReportFields,\n} from './types.js';\n\n/** Schema version stamped on activity entries synthesised by the normaliser. */\nconst NORMALISER_SCHEMA_VERSION = 1;\n\n/**\n * Legacy 5-state → 12-state QA lifecycle mapping (design §WP-1).\n * open → new, approved → triaged, deferred → deferred, resolved → closed, wontfix → rejected\n */\nconst LEGACY_STATUS_MAP: Record<LegacyFeedbackStatus, FeedbackStatus> = {\n open: 'new',\n approved: 'triaged',\n deferred: 'deferred',\n resolved: 'closed',\n wontfix: 'rejected',\n};\n\n/** The set of valid new-lifecycle status values (for pass-through detection). */\nconst NEW_STATUS_VALUES: ReadonlySet<string> = new Set<FeedbackStatus>([\n 'new', 'triaged', 'assigned', 'in_progress', 'fixed', 'pending_retest',\n 'verified', 'closed', 'deferred', 'rejected', 'duplicate', 'reopened',\n]);\n\n/**\n * Map a legacy status value to the new QA lifecycle. New values pass through unchanged.\n * Unknown values fall back to 'new' (Tolerant Reader — never throw on stored data).\n *\n * @param status - a legacy or new status value\n * @returns the equivalent new-lifecycle FeedbackStatus\n */\nexport function normaliseLegacyStatus(status: LegacyFeedbackStatus | FeedbackStatus): FeedbackStatus {\n if (NEW_STATUS_VALUES.has(status)) return status as FeedbackStatus;\n const mapped = LEGACY_STATUS_MAP[status as LegacyFeedbackStatus];\n return mapped ?? 'new';\n}\n\n/**\n * Deterministic per-entry id for a synthesised activity entry. Stable across repeated\n * normalisation (idempotency): same source field + position → same id, so re-merging\n * never duplicates an entry.\n */\nfunction stableActivityId(sourceKind: string, index: number, occurredAt: string): string {\n return `legacy:${sourceKind}:${index}:${occurredAt}`;\n}\n\n/** Convert a legacy DialogueEntry to an ActivityEntry (user → comment, assistant → answer). */\nfunction dialogueToActivity(entry: DialogueEntry, index: number): ActivityEntry {\n const kind: ActivityKind = entry.role === 'assistant' ? 'answer' : 'comment';\n return {\n id: stableActivityId('dialogue', index, entry.timestamp),\n actor: { userId: entry.role, displayName: entry.role },\n occurredAt: entry.timestamp,\n recordedAt: entry.timestamp,\n source: 'legacy-normaliser',\n kind,\n body: entry.content,\n schemaVersion: NORMALISER_SCHEMA_VERSION,\n };\n}\n\n/**\n * Convert a legacy CorrectionEntry to an ActivityEntry. A correction to the `status` field\n * becomes a `status_change` entry (so deriveStateDurations can compute MTTR/time-in-state\n * from activity[] alone); any other field becomes a `classification_change`. The new status\n * value is normalised onto the current lifecycle.\n */\nfunction correctionToActivity(entry: CorrectionEntry, index: number): ActivityEntry {\n const isStatus = entry.field === 'status';\n const kind: ActivityKind = isStatus ? 'status_change' : 'classification_change';\n const before = isStatus\n ? normaliseLegacyStatus(entry.before as LegacyFeedbackStatus | FeedbackStatus)\n : entry.before;\n const after = isStatus\n ? normaliseLegacyStatus(entry.after as LegacyFeedbackStatus | FeedbackStatus)\n : entry.after;\n return {\n id: stableActivityId('correction', index, entry.correctedAt),\n actor: { userId: entry.correctedBy, displayName: entry.correctedBy },\n occurredAt: entry.correctedAt,\n recordedAt: entry.correctedAt,\n source: 'legacy-normaliser',\n kind,\n before,\n after,\n ...(entry.reason !== undefined ? { reason: entry.reason } : {}),\n schemaVersion: NORMALISER_SCHEMA_VERSION,\n };\n}\n\n/**\n * Merge legacy dialogueHistory + corrections into activity[]. Idempotent: entries already\n * present (by stable id) are not re-appended, so applying twice equals applying once.\n * Returns a new object; never mutates the input. Stored data is never rewritten.\n */\nexport function normaliseActivityArrays(item: StoredFeedbackItem): StoredFeedbackItem {\n const existing = item.activity ?? [];\n const existingIds = new Set(existing.map((e) => e.id));\n\n const synthesised: ActivityEntry[] = [\n ...(item.dialogueHistory ?? []).map((d, i) => dialogueToActivity(d, i)),\n ...(item.corrections ?? []).map((c, i) => correctionToActivity(c, i)),\n ];\n\n const toAppend = synthesised.filter((e) => !existingIds.has(e.id));\n if (toAppend.length === 0) {\n // Nothing new to merge — return input shape unchanged (but still a defined activity[]).\n return item.activity ? item : { ...item, activity: existing };\n }\n\n const merged = [...existing, ...toAppend].sort(\n (a, b) => new Date(a.occurredAt).getTime() - new Date(b.occurredAt).getTime(),\n );\n return { ...item, activity: merged };\n}\n\n/**\n * Tolerant-Reader fallback for the structured report: if report.actual is not set and the\n * legacy `content` blob is non-empty, set report.actual = content. The content field is\n * always preserved. Returns a new object; never mutates the input.\n */\nexport function normaliseReportContent(item: StoredFeedbackItem): StoredFeedbackItem {\n const hasActual = Boolean(item.report?.actual);\n const content = item.content?.trim();\n if (hasActual || !content) return item;\n\n const report: ReportFields = { ...(item.report ?? {}), actual: item.content };\n return { ...item, report };\n}\n\n/**\n * Apply all read-time normalisers in sequence: legacy status → activity merge → report\n * content fallback. Pure and idempotent; returns a new object and never mutates the input.\n * Rendering/consumer code should call this before reading status or any optional field.\n */\nexport function normaliseStoredItem(item: StoredFeedbackItem): StoredFeedbackItem {\n const status = normaliseLegacyStatus(item.status as LegacyFeedbackStatus | FeedbackStatus);\n const withStatus: StoredFeedbackItem = status === item.status ? item : { ...item, status };\n const withActivity = normaliseActivityArrays(withStatus);\n return normaliseReportContent(withActivity);\n}\n"],"names":[],"mappings":"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"}
@@ -65,10 +65,10 @@ function emitMetric(metricName, value, unit, dimensions) {
65
65
  return;
66
66
  }
67
67
  try {
68
- const cwPrefix = ["@aw", "s-sdk/clien", "t-cloudwatch"].join("");
68
+ const cloudwatchModuleId = "@aws-sdk/client-cloudwatch";
69
69
  const { CloudWatchClient, PutMetricDataCommand } = await import(
70
70
  /* @vite-ignore */
71
- cwPrefix
71
+ cloudwatchModuleId
72
72
  );
73
73
  const client = new CloudWatchClient({ region: process.env["AWS_REGION"] });
74
74
  await client.send(
@@ -99,4 +99,4 @@ export {
99
99
  recordDialogueExchange as r,
100
100
  withSpan as w
101
101
  };
102
- //# sourceMappingURL=observability-CfwHKObh.js.map
102
+ //# sourceMappingURL=observability-DUK725zv.js.map