anton-bakker-feedback 0.4.3 → 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.
- package/README.md +171 -0
- package/dist/chunks/{i18n-Cba0xHU-.js → i18n-Cn2yFMbd.js} +4 -2
- package/dist/chunks/i18n-Cn2yFMbd.js.map +1 -0
- package/dist/chunks/ingest-transport-DAA_pJNb.js +245 -0
- package/dist/chunks/ingest-transport-DAA_pJNb.js.map +1 -0
- package/dist/chunks/normalisers-Cbd-i4Jh.js +97 -0
- package/dist/chunks/normalisers-Cbd-i4Jh.js.map +1 -0
- package/dist/chunks/{observability-CfwHKObh.js → observability-DUK725zv.js} +3 -3
- package/dist/chunks/{observability-CfwHKObh.js.map → observability-DUK725zv.js.map} +1 -1
- package/dist/chunks/{submitResolver-DsfkrUHY.js → submitResolver-wfr4kbEM.js} +60 -39
- package/dist/chunks/submitResolver-wfr4kbEM.js.map +1 -0
- package/dist/core/config.d.ts +11 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/ingest-transport.d.ts +97 -0
- package/dist/core/ingest-transport.d.ts.map +1 -1
- package/dist/core/normalisers.d.ts +29 -0
- package/dist/core/normalisers.d.ts.map +1 -0
- package/dist/core/observability.d.ts.map +1 -1
- package/dist/core/request-headers.d.ts +22 -0
- package/dist/core/request-headers.d.ts.map +1 -0
- package/dist/core/state-metrics.d.ts +19 -0
- package/dist/core/state-metrics.d.ts.map +1 -0
- package/dist/core/transforms.d.ts.map +1 -1
- package/dist/core/types.d.ts +93 -3
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.js +17 -99
- package/dist/index.js.map +1 -1
- package/dist/react/FeedbackConfirm.d.ts.map +1 -1
- package/dist/react/FeedbackDialogue.d.ts.map +1 -1
- package/dist/react/admin/ActivityTimeline.d.ts +9 -0
- package/dist/react/admin/ActivityTimeline.d.ts.map +1 -0
- package/dist/react/admin/AnnotationComposer.d.ts +30 -0
- package/dist/react/admin/AnnotationComposer.d.ts.map +1 -0
- package/dist/react/admin/FeedbackListPage.d.ts.map +1 -1
- package/dist/react/admin/FeedbackUpdatePage.d.ts +6 -0
- package/dist/react/admin/FeedbackUpdatePage.d.ts.map +1 -1
- package/dist/react/admin/LifecycleControls.d.ts +28 -0
- package/dist/react/admin/LifecycleControls.d.ts.map +1 -0
- package/dist/react/admin/__tests__/ActivityTimeline.test.d.ts +2 -0
- package/dist/react/admin/__tests__/ActivityTimeline.test.d.ts.map +1 -0
- package/dist/react/admin/__tests__/AnnotationComposer.test.d.ts +2 -0
- package/dist/react/admin/__tests__/AnnotationComposer.test.d.ts.map +1 -0
- package/dist/react/admin/__tests__/LifecycleControls.test.d.ts +2 -0
- package/dist/react/admin/__tests__/LifecycleControls.test.d.ts.map +1 -0
- package/dist/react/admin/hooks/can.d.ts +47 -0
- package/dist/react/admin/hooks/can.d.ts.map +1 -0
- package/dist/react/admin/hooks/useStatusTransition.d.ts +16 -2
- package/dist/react/admin/hooks/useStatusTransition.d.ts.map +1 -1
- package/dist/react/admin/index.d.ts +14 -1
- package/dist/react/admin/index.d.ts.map +1 -1
- package/dist/react/admin/index.js +312 -29
- package/dist/react/admin/index.js.map +1 -1
- package/dist/react/admin/types.d.ts +13 -0
- package/dist/react/admin/types.d.ts.map +1 -1
- package/dist/react/resolver/submitResolver.d.ts +10 -7
- package/dist/react/resolver/submitResolver.d.ts.map +1 -1
- package/dist/react.js +26 -9
- package/dist/react.js.map +1 -1
- package/dist/server/index.d.ts +4 -5
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/listHandler.d.ts +15 -8
- package/dist/server/listHandler.d.ts.map +1 -1
- package/dist/server.js +22 -170
- package/dist/server.js.map +1 -1
- package/package.json +13 -1
- package/dist/chunks/i18n-Cba0xHU-.js.map +0 -1
- package/dist/chunks/submitResolver-DsfkrUHY.js.map +0 -1
- package/dist/server/FeedbackService.d.ts +0 -46
- 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
|
|
@@ -44,7 +44,9 @@ function resolveConfig(config) {
|
|
|
44
44
|
transport: {
|
|
45
45
|
submitUrl: config.transport.submitUrl,
|
|
46
46
|
dialogueUrl: config.transport.dialogueUrl,
|
|
47
|
-
screenshotUploadUrl: config.transport.screenshotUploadUrl
|
|
47
|
+
screenshotUploadUrl: config.transport.screenshotUploadUrl,
|
|
48
|
+
getAuthToken: config.transport.getAuthToken,
|
|
49
|
+
headers: config.transport.headers
|
|
48
50
|
},
|
|
49
51
|
dialogue: {
|
|
50
52
|
maxTurns: config.dialogue?.maxTurns ?? 3,
|
|
@@ -241,4 +243,4 @@ export {
|
|
|
241
243
|
t,
|
|
242
244
|
validateConfig as v
|
|
243
245
|
};
|
|
244
|
-
//# sourceMappingURL=i18n-
|
|
246
|
+
//# sourceMappingURL=i18n-Cn2yFMbd.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n-Cn2yFMbd.js","sources":["../../src/core/config.ts","../../src/core/locales/en.ts","../../src/core/locales/nl.ts","../../src/core/i18n.ts"],"sourcesContent":["/**\n * Configuration schema and validation for the anton-bakker-feedback package.\n */\n\nimport type { FeedbackEnvironment } from './types.js';\n\n// ─────────────────────────────────────────────────────────────────\n// Config Interface\n// ─────────────────────────────────────────────────────────────────\n\n/** Full configuration object for the feedback package */\nexport interface FeedbackConfig {\n /** Required — alphanumeric, hyphens, underscores only */\n appName: string;\n version?: string;\n /** Defaults to 'production' if absent */\n environment?: FeedbackEnvironment;\n\n transport: {\n /** POST endpoint — app provides its own route */\n submitUrl: string;\n /** If absent → dialogue disabled, form shown */\n dialogueUrl?: string;\n /** If absent → screenshots disabled */\n screenshotUploadUrl?: string;\n /**\n * Optional per-request auth token provider. When set, its resolved value is\n * attached to submit/dialogue requests as `Authorization: Bearer <token>`.\n * Lets the host (which owns auth) identify the submitter server-side instead\n * of the request being anonymous. May be sync or async.\n */\n getAuthToken?: () => string | null | undefined | Promise<string | null | undefined>;\n /** Optional static extra headers merged into submit/dialogue requests. */\n headers?: Record<string, string>;\n };\n\n dialogue?: {\n /** Default 3; min 1, max 10 */\n maxTurns?: number;\n /** Default 30_000 */\n timeoutMs?: number;\n };\n\n screenshots?: {\n /** Default true if screenshotUploadUrl set */\n enabled?: boolean;\n /** Default 5_242_880 (5 MB) */\n maxSizeBytes?: number;\n };\n\n ui?: {\n /** Default 'bottom-right' */\n position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';\n /** Default 'auto' */\n theme?: 'light' | 'dark' | 'auto';\n };\n\n observability?: {\n /** Default true */\n enabled?: boolean;\n /** Default 'Feedback/Service' */\n namespace?: string;\n /** Default ['x-trace-id', 'x-request-id'] */\n traceIdHeaders?: string[];\n };\n\n i18n?: {\n /** Default 'en' */\n locale?: string;\n customTranslations?: Record<string, Record<string, string>>;\n };\n\n signOff?: {\n /** Default 7; 0 = manual only */\n autoConfirmDays?: number;\n /** Default 3 */\n reminderDays?: number;\n };\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Resolved Config — all fields have values, no undefined\n// ─────────────────────────────────────────────────────────────────\n\n/** Fully-resolved config with all defaults applied — no undefined fields */\nexport interface ResolvedFeedbackConfig {\n appName: string;\n version: string;\n environment: FeedbackEnvironment;\n\n transport: {\n submitUrl: string;\n dialogueUrl: string | undefined;\n screenshotUploadUrl: string | undefined;\n getAuthToken: (() => string | null | undefined | Promise<string | null | undefined>) | undefined;\n headers: Record<string, string> | undefined;\n };\n\n dialogue: {\n maxTurns: number;\n timeoutMs: number;\n };\n\n screenshots: {\n enabled: boolean;\n maxSizeBytes: number;\n };\n\n ui: {\n position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';\n theme: 'light' | 'dark' | 'auto';\n };\n\n observability: {\n enabled: boolean;\n namespace: string;\n traceIdHeaders: string[];\n };\n\n i18n: {\n locale: string;\n customTranslations: Record<string, Record<string, string>>;\n };\n\n signOff: {\n autoConfirmDays: number;\n reminderDays: number;\n };\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Validation\n// ─────────────────────────────────────────────────────────────────\n\nconst APP_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;\nconst URL_PATTERN = /^https?:\\/\\/.+/;\n\n/** Validation result with structured errors */\nexport interface ConfigValidationResult {\n valid: boolean;\n errors: string[];\n}\n\n/**\n * Validates a FeedbackConfig and returns structured errors.\n * Returns { valid: false, errors: [...] } with actionable messages.\n */\nexport function validateConfig(config: Partial<FeedbackConfig>): ConfigValidationResult {\n const errors: string[] = [];\n\n // Validate appName\n if (!config.appName || config.appName.trim() === '') {\n errors.push('appName is required and must not be empty');\n } else if (!APP_NAME_PATTERN.test(config.appName)) {\n errors.push(\n `appName \"${config.appName}\" is invalid — must match /^[a-zA-Z0-9_-]+$/ (no spaces, slashes, or special characters)`,\n );\n }\n\n // Validate transport.submitUrl\n if (!config.transport?.submitUrl || config.transport.submitUrl.trim() === '') {\n errors.push('transport.submitUrl is required and must not be empty');\n } else if (!URL_PATTERN.test(config.transport.submitUrl)) {\n errors.push(\n `transport.submitUrl \"${config.transport.submitUrl}\" must be a valid URL starting with http:// or https://`,\n );\n }\n\n // Validate dialogue.maxTurns\n if (config.dialogue?.maxTurns !== undefined) {\n const turns = config.dialogue.maxTurns;\n if (!Number.isInteger(turns) || turns < 1 || turns > 10) {\n errors.push(\n `dialogue.maxTurns must be an integer between 1 and 10 (got ${turns})`,\n );\n }\n }\n\n // Validate screenshots.maxSizeBytes\n if (config.screenshots?.maxSizeBytes !== undefined) {\n const size = config.screenshots.maxSizeBytes;\n if (!Number.isFinite(size) || size <= 0) {\n errors.push(`screenshots.maxSizeBytes must be a positive number (got ${size})`);\n }\n }\n\n return {\n valid: errors.length === 0,\n errors,\n };\n}\n\n/**\n * Merges user-supplied partial config with defaults.\n * Returns a fully-resolved config with no undefined fields.\n */\nexport function resolveConfig(config: FeedbackConfig): ResolvedFeedbackConfig {\n const screenshotsEnabled =\n config.screenshots?.enabled !== undefined\n ? config.screenshots.enabled\n : !!config.transport.screenshotUploadUrl;\n\n return {\n appName: config.appName,\n version: config.version ?? '0.0.0',\n environment: config.environment ?? 'production',\n\n transport: {\n submitUrl: config.transport.submitUrl,\n dialogueUrl: config.transport.dialogueUrl,\n screenshotUploadUrl: config.transport.screenshotUploadUrl,\n getAuthToken: config.transport.getAuthToken,\n headers: config.transport.headers,\n },\n\n dialogue: {\n maxTurns: config.dialogue?.maxTurns ?? 3,\n timeoutMs: config.dialogue?.timeoutMs ?? 30_000,\n },\n\n screenshots: {\n enabled: screenshotsEnabled,\n maxSizeBytes: config.screenshots?.maxSizeBytes ?? 5_242_880,\n },\n\n ui: {\n position: config.ui?.position ?? 'bottom-right',\n theme: config.ui?.theme ?? 'auto',\n },\n\n observability: {\n enabled: config.observability?.enabled ?? true,\n namespace: config.observability?.namespace ?? 'Feedback/Service',\n traceIdHeaders: config.observability?.traceIdHeaders ?? [\n 'x-trace-id',\n 'x-request-id',\n ],\n },\n\n i18n: {\n locale: config.i18n?.locale ?? 'en',\n customTranslations: config.i18n?.customTranslations ?? {},\n },\n\n signOff: {\n autoConfirmDays: config.signOff?.autoConfirmDays ?? 7,\n reminderDays: config.signOff?.reminderDays ?? 3,\n },\n };\n}\n","/**\n * English (en) translations for the feedback package.\n * Key format: feedback.{component}.{element}\n */\nexport const en: Record<string, string> = {\n // Button labels\n 'feedback.button.open': 'Feedback',\n 'feedback.button.close': 'Close',\n 'feedback.button.screenshot': 'Add screenshot',\n 'feedback.button.removeScreenshot': 'Remove screenshot',\n 'feedback.button.submit': 'Submit feedback',\n\n // Form labels\n 'feedback.form.contentLabel': 'What would you like to share?',\n 'feedback.form.contentPlaceholder': 'Describe the issue or suggestion in detail...',\n 'feedback.form.typeLabel': 'Type',\n 'feedback.form.typePlaceholder': 'Select a type',\n\n // Confirm (review) form\n 'feedback.confirm.heading': 'Review your feedback',\n 'feedback.confirm.titleLabel': 'Title',\n 'feedback.confirm.descriptionLabel': 'Description',\n 'feedback.confirm.typeLabel': 'Type',\n 'feedback.confirm.scopeLabel': 'Scope',\n 'feedback.confirm.sizeLabel': 'Size',\n 'feedback.confirm.duplicateWarning': 'Similar feedback already exists:',\n 'feedback.confirm.scopePlaceholder': 'Select scope (optional)',\n 'feedback.confirm.scope.isolated': 'Isolated',\n 'feedback.confirm.scope.crossCutting': 'Cross-cutting',\n 'feedback.confirm.scope.systemic': 'Systemic',\n 'feedback.confirm.sizePlaceholder': 'Select size (optional)',\n 'feedback.confirm.size.trivial': 'Trivial',\n 'feedback.confirm.size.small': 'Small',\n 'feedback.confirm.size.medium': 'Medium',\n 'feedback.confirm.size.large': 'Large',\n\n // Dialogue states\n 'feedback.dialogue.opening': 'Thank you for your feedback. I have a few questions to help us understand better.',\n 'feedback.dialogue.thinking': 'Thinking...',\n 'feedback.dialogue.error': 'Sorry, something went wrong. Please try again.',\n 'feedback.dialogue.complete': 'Thank you! Your feedback has been recorded.',\n 'feedback.dialogue.turnPrompt': 'Your response:',\n\n // Feedback types\n 'feedback.types.bug': 'Bug report',\n 'feedback.types.feature': 'Feature request',\n 'feedback.types.ux': 'UX improvement',\n 'feedback.types.other': 'Other',\n\n // Status messages\n 'feedback.status.submitting': 'Submitting your feedback...',\n 'feedback.status.success': 'Feedback submitted successfully!',\n 'feedback.status.error': 'Failed to submit feedback. Please try again.',\n 'feedback.status.retry': 'Retry',\n 'feedback.status.duplicate': 'This feedback has already been submitted.',\n\n // Toast messages\n 'feedback.toast.screenshotAdded': 'Screenshot attached',\n 'feedback.toast.screenshotRemoved': 'Screenshot removed',\n 'feedback.toast.screenshotError': 'Failed to capture screenshot',\n\n // Sign-off messages\n 'feedback.signoff.confirmLabel': 'Mark as resolved',\n 'feedback.signoff.reopenLabel': 'Reopen',\n 'feedback.signoff.autoConfirmNotice':\n 'This issue will be marked resolved in {{days}} days unless you reopen it.',\n\n // Correction messages\n 'feedback.correction.editLabel': 'Edit',\n 'feedback.correction.reasonLabel': 'Reason for change',\n 'feedback.correction.saveLabel': 'Save changes',\n};\n","/**\n * Dutch (nl) translations for the feedback package.\n * Key format: feedback.{component}.{element}\n */\nexport const nl: Record<string, string> = {\n // Button labels\n 'feedback.button.open': 'Feedback',\n 'feedback.button.close': 'Sluiten',\n 'feedback.button.screenshot': 'Screenshot toevoegen',\n 'feedback.button.removeScreenshot': 'Screenshot verwijderen',\n 'feedback.button.submit': 'Feedback versturen',\n\n // Form labels\n 'feedback.form.contentLabel': 'Wat wilt u delen?',\n 'feedback.form.contentPlaceholder': 'Beschrijf het probleem of de suggestie in detail...',\n 'feedback.form.typeLabel': 'Type',\n 'feedback.form.typePlaceholder': 'Selecteer een type',\n\n // Confirm (review) form\n 'feedback.confirm.heading': 'Controleer uw feedback',\n 'feedback.confirm.titleLabel': 'Titel',\n 'feedback.confirm.descriptionLabel': 'Omschrijving',\n 'feedback.confirm.typeLabel': 'Type',\n 'feedback.confirm.scopeLabel': 'Reikwijdte',\n 'feedback.confirm.sizeLabel': 'Omvang',\n 'feedback.confirm.duplicateWarning': 'Er bestaat al vergelijkbare feedback:',\n 'feedback.confirm.scopePlaceholder': 'Selecteer reikwijdte (optioneel)',\n 'feedback.confirm.scope.isolated': 'Geïsoleerd',\n 'feedback.confirm.scope.crossCutting': 'Overkoepelend',\n 'feedback.confirm.scope.systemic': 'Systemisch',\n 'feedback.confirm.sizePlaceholder': 'Selecteer omvang (optioneel)',\n 'feedback.confirm.size.trivial': 'Triviaal',\n 'feedback.confirm.size.small': 'Klein',\n 'feedback.confirm.size.medium': 'Middel',\n 'feedback.confirm.size.large': 'Groot',\n\n // Dialogue states\n 'feedback.dialogue.opening': 'Bedankt voor uw feedback. Ik heb een paar vragen om dit beter te begrijpen.',\n 'feedback.dialogue.thinking': 'Aan het nadenken...',\n 'feedback.dialogue.error': 'Er is iets misgegaan. Probeer het opnieuw.',\n 'feedback.dialogue.complete': 'Bedankt! Uw feedback is geregistreerd.',\n 'feedback.dialogue.turnPrompt': 'Uw antwoord:',\n\n // Feedback types\n 'feedback.types.bug': 'Foutmelding',\n 'feedback.types.feature': 'Functiewens',\n 'feedback.types.ux': 'UX-verbetering',\n 'feedback.types.other': 'Overig',\n\n // Status messages\n 'feedback.status.submitting': 'Feedback verzenden...',\n 'feedback.status.success': 'Feedback succesvol verzonden!',\n 'feedback.status.error': 'Verzenden mislukt. Probeer het opnieuw.',\n 'feedback.status.retry': 'Opnieuw proberen',\n 'feedback.status.duplicate': 'Deze feedback is al ingediend.',\n\n // Toast messages\n 'feedback.toast.screenshotAdded': 'Screenshot bijgevoegd',\n 'feedback.toast.screenshotRemoved': 'Screenshot verwijderd',\n 'feedback.toast.screenshotError': 'Screenshot maken mislukt',\n\n // Sign-off messages\n 'feedback.signoff.confirmLabel': 'Markeer als opgelost',\n 'feedback.signoff.reopenLabel': 'Heropenen',\n 'feedback.signoff.autoConfirmNotice':\n 'Dit probleem wordt als opgelost gemarkeerd over {{days}} dagen, tenzij u het heropent.',\n\n // Correction messages\n 'feedback.correction.editLabel': 'Bewerken',\n 'feedback.correction.reasonLabel': 'Reden voor wijziging',\n 'feedback.correction.saveLabel': 'Wijzigingen opslaan',\n};\n","/**\n * Internationalisation utilities for the feedback package.\n *\n * t() falls back gracefully:\n * - Unknown locale → English\n * - Unknown key → returns the key itself (never undefined)\n *\n * Template interpolation: {{param}} → replaced with params values\n */\n\nimport { en } from './locales/en.js';\nimport { nl } from './locales/nl.js';\n\n// ─────────────────────────────────────────────────────────────────\n// Constants\n// ─────────────────────────────────────────────────────────────────\n\n/** Supported locale codes */\nexport const SUPPORTED_LOCALES: readonly string[] = ['en', 'nl'] as const;\n\n/** Default locale when unknown locale is requested */\nexport const DEFAULT_LOCALE = 'en';\n\n/** Built-in translations keyed by locale */\nconst BUILT_IN_TRANSLATIONS: Record<string, Record<string, string>> = {\n en,\n nl,\n};\n\n// ─────────────────────────────────────────────────────────────────\n// Translation Function\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * Looks up a translation key.\n *\n * Fallback chain:\n * 1. customTranslations[locale][key]\n * 2. BUILT_IN_TRANSLATIONS[locale][key]\n * 3. BUILT_IN_TRANSLATIONS['en'][key]\n * 4. key itself (never returns undefined or throws)\n *\n * Template interpolation: {{days}} → params.days (if params provided)\n *\n * @param key - Dot-notation key e.g. 'feedback.button.open'\n * @param locale - BCP 47 locale code e.g. 'en', 'nl'\n * @param customTranslations - Optional custom translations (overrides built-in)\n * @param params - Optional template parameters e.g. { days: 7 }\n */\nexport function t(\n key: string,\n locale: string,\n customTranslations?: Record<string, Record<string, string>>,\n params?: Record<string, string | number>,\n): string {\n // 1. Check custom translations for this locale\n const customForLocale = customTranslations?.[locale];\n if (customForLocale?.[key] !== undefined) {\n return interpolate(customForLocale[key], params);\n }\n\n // 2. Check custom translations for English fallback\n const customEn = customTranslations?.['en'];\n if (locale !== 'en' && customEn?.[key] !== undefined) {\n // Only use English custom if locale not found\n }\n\n // 3. Check built-in translations for this locale\n const builtInForLocale = BUILT_IN_TRANSLATIONS[locale];\n if (builtInForLocale?.[key] !== undefined) {\n return interpolate(builtInForLocale[key] as string, params);\n }\n\n // 4. Fall back to English built-in\n const englishBuiltIn = BUILT_IN_TRANSLATIONS['en'];\n if (englishBuiltIn?.[key] !== undefined) {\n return interpolate(englishBuiltIn[key] as string, params);\n }\n\n // 5. Fall back to English custom (if provided)\n if (customEn?.[key] !== undefined) {\n return interpolate(customEn[key] as string, params);\n }\n\n // 6. Return the key itself — never undefined\n return key;\n}\n\n/**\n * Replaces {{param}} placeholders with values from params.\n */\nfunction interpolate(\n template: string,\n params?: Record<string, string | number>,\n): string {\n if (!params) return template;\n return template.replace(/\\{\\{(\\w+)\\}\\}/g, (_match, name: string) => {\n const value = params[name];\n return value !== undefined ? String(value) : `{{${name}}}`;\n });\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Locale Detection\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * Returns the locale to use.\n * If preferredLocale is a supported locale → return it.\n * Otherwise → return DEFAULT_LOCALE ('en').\n *\n * @param preferredLocale - BCP 47 locale code or undefined\n */\nexport function detectLocale(preferredLocale?: string): string {\n if (preferredLocale && SUPPORTED_LOCALES.includes(preferredLocale)) {\n return preferredLocale;\n }\n return DEFAULT_LOCALE;\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -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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"}
|