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.
- package/README.md +171 -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/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/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 +16 -98
- package/dist/index.js.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 +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/submitResolver-DsfkrUHY.js.map +0 -1
- package/dist/server/FeedbackService.d.ts +0 -46
- package/dist/server/FeedbackService.d.ts.map +0 -1
package/dist/server.js
CHANGED
|
@@ -1,170 +1,15 @@
|
|
|
1
|
-
import { c } from "./chunks/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
if (
|
|
7
|
-
return
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
return { N: String(value) };
|
|
11
|
-
}
|
|
12
|
-
if (typeof value === "boolean") {
|
|
13
|
-
return { BOOL: value };
|
|
14
|
-
}
|
|
15
|
-
if (Array.isArray(value)) {
|
|
16
|
-
return { L: value.map(toAttr) };
|
|
17
|
-
}
|
|
18
|
-
if (typeof value === "object") {
|
|
19
|
-
const m = {};
|
|
20
|
-
for (const [k, v] of Object.entries(value)) {
|
|
21
|
-
m[k] = toAttr(v);
|
|
22
|
-
}
|
|
23
|
-
return { M: m };
|
|
24
|
-
}
|
|
25
|
-
return { S: String(value) };
|
|
26
|
-
}
|
|
27
|
-
function marshalItem(item) {
|
|
28
|
-
const result = {};
|
|
29
|
-
for (const [key, value] of Object.entries(item)) {
|
|
30
|
-
if (value !== void 0) {
|
|
31
|
-
result[key] = toAttr(value);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return result;
|
|
35
|
-
}
|
|
36
|
-
function fromAttr(attr) {
|
|
37
|
-
if (attr["S"] !== void 0) return attr["S"];
|
|
38
|
-
if (attr["N"] !== void 0) return Number(attr["N"]);
|
|
39
|
-
if (attr["BOOL"] !== void 0) return attr["BOOL"];
|
|
40
|
-
if (attr["NULL"]) return null;
|
|
41
|
-
if (attr["L"]) {
|
|
42
|
-
return attr["L"].map(fromAttr);
|
|
43
|
-
}
|
|
44
|
-
if (attr["M"]) {
|
|
45
|
-
const obj = {};
|
|
46
|
-
for (const [k, v] of Object.entries(attr["M"])) {
|
|
47
|
-
obj[k] = fromAttr(v);
|
|
48
|
-
}
|
|
49
|
-
return obj;
|
|
50
|
-
}
|
|
51
|
-
return void 0;
|
|
52
|
-
}
|
|
53
|
-
function unmarshalItem(item) {
|
|
54
|
-
const result = {};
|
|
55
|
-
for (const [key, attr] of Object.entries(item)) {
|
|
56
|
-
result[key] = fromAttr(attr);
|
|
57
|
-
}
|
|
58
|
-
return result;
|
|
1
|
+
import { c as createIngestReader } from "./chunks/ingest-transport-DAA_pJNb.js";
|
|
2
|
+
import { c } from "./chunks/submitResolver-wfr4kbEM.js";
|
|
3
|
+
const VALID_STATUSES = ["new", "triaged", "assigned", "in_progress", "fixed", "pending_retest", "verified", "closed", "deferred", "rejected", "duplicate", "reopened"];
|
|
4
|
+
function resolveReader(config) {
|
|
5
|
+
if (config.reader) return config.reader;
|
|
6
|
+
if (config.apiUrl && (config.tokenProvider || config.tokenUrl && config.clientId && config.clientSecret)) {
|
|
7
|
+
return createIngestReader(config);
|
|
8
|
+
}
|
|
9
|
+
return null;
|
|
59
10
|
}
|
|
60
|
-
class FeedbackService {
|
|
61
|
-
tableName;
|
|
62
|
-
region;
|
|
63
|
-
constructor(config) {
|
|
64
|
-
this.tableName = config.tableName;
|
|
65
|
-
this.region = config.region;
|
|
66
|
-
}
|
|
67
|
-
// ── put ────────────────────────────────────────────────────────
|
|
68
|
-
/**
|
|
69
|
-
* PutItem — writes a StoredFeedbackItem to DynamoDB.
|
|
70
|
-
* No-op when tableName is absent.
|
|
71
|
-
*
|
|
72
|
-
* @deprecated Direct-DynamoDB write — the pattern the central-storage principle removes. New
|
|
73
|
-
* writers use `createIngestTransport(...).submit(item)` (authenticated central ingest API).
|
|
74
|
-
*/
|
|
75
|
-
async put(item) {
|
|
76
|
-
if (!this.tableName) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
const dynamoPrefix = ["@aws", "-sdk/client-dynamo", "db"].join("");
|
|
80
|
-
const { DynamoDBClient, PutItemCommand } = await import(
|
|
81
|
-
/* @vite-ignore */
|
|
82
|
-
dynamoPrefix
|
|
83
|
-
);
|
|
84
|
-
const client = new DynamoDBClient({ region: this.region });
|
|
85
|
-
await client.send(
|
|
86
|
-
new PutItemCommand({
|
|
87
|
-
TableName: this.tableName,
|
|
88
|
-
Item: marshalItem(item)
|
|
89
|
-
})
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
// ── get ────────────────────────────────────────────────────────
|
|
93
|
-
/**
|
|
94
|
-
* GetItem — retrieves a StoredFeedbackItem by PK+SK.
|
|
95
|
-
* Returns null if the item does not exist.
|
|
96
|
-
*/
|
|
97
|
-
async get(pk, sk) {
|
|
98
|
-
if (!this.tableName) {
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
const dynamoPrefix = ["@aws", "-sdk/client-dynamo", "db"].join("");
|
|
102
|
-
const { DynamoDBClient, GetItemCommand } = await import(
|
|
103
|
-
/* @vite-ignore */
|
|
104
|
-
dynamoPrefix
|
|
105
|
-
);
|
|
106
|
-
const client = new DynamoDBClient({ region: this.region });
|
|
107
|
-
const response = await client.send(
|
|
108
|
-
new GetItemCommand({
|
|
109
|
-
TableName: this.tableName,
|
|
110
|
-
Key: {
|
|
111
|
-
PK: { S: pk },
|
|
112
|
-
SK: { S: sk }
|
|
113
|
-
}
|
|
114
|
-
})
|
|
115
|
-
);
|
|
116
|
-
if (!response.Item) {
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
return unmarshalItem(response.Item);
|
|
120
|
-
}
|
|
121
|
-
// ── list ───────────────────────────────────────────────────────
|
|
122
|
-
/**
|
|
123
|
-
* Query GSI1 with optional status filter.
|
|
124
|
-
* Returns [] when tableName is absent (Minimal level).
|
|
125
|
-
*/
|
|
126
|
-
async list(opts = {}) {
|
|
127
|
-
if (!this.tableName) {
|
|
128
|
-
return [];
|
|
129
|
-
}
|
|
130
|
-
const dynamoPrefix = ["@aws", "-sdk/client-dynamo", "db"].join("");
|
|
131
|
-
const { DynamoDBClient, QueryCommand } = await import(
|
|
132
|
-
/* @vite-ignore */
|
|
133
|
-
dynamoPrefix
|
|
134
|
-
);
|
|
135
|
-
const client = new DynamoDBClient({ region: this.region });
|
|
136
|
-
const limit = opts.limit ?? 50;
|
|
137
|
-
const gsi1pk = opts.status ? `STATUS#${opts.status}` : void 0;
|
|
138
|
-
const params = {
|
|
139
|
-
TableName: this.tableName,
|
|
140
|
-
IndexName: "GSI1",
|
|
141
|
-
KeyConditionExpression: "#pk = :pk",
|
|
142
|
-
ExpressionAttributeNames: {
|
|
143
|
-
"#pk": "GSI1PK"
|
|
144
|
-
},
|
|
145
|
-
ExpressionAttributeValues: {
|
|
146
|
-
":pk": { S: gsi1pk ?? "STATUS#open" }
|
|
147
|
-
},
|
|
148
|
-
Limit: limit,
|
|
149
|
-
ScanIndexForward: false
|
|
150
|
-
// Descending (highest priority first via inverted sort key)
|
|
151
|
-
};
|
|
152
|
-
if (opts.lastEvaluatedKey) {
|
|
153
|
-
params.ExclusiveStartKey = opts.lastEvaluatedKey;
|
|
154
|
-
}
|
|
155
|
-
const response = await client.send(new QueryCommand(params));
|
|
156
|
-
if (!response.Items || response.Items.length === 0) {
|
|
157
|
-
return [];
|
|
158
|
-
}
|
|
159
|
-
return response.Items.map(unmarshalItem);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
const VALID_STATUSES = ["open", "approved", "deferred", "resolved", "wontfix"];
|
|
163
11
|
function createListHandler(config) {
|
|
164
|
-
const
|
|
165
|
-
tableName: config.tableName ?? "",
|
|
166
|
-
region: config.region ?? "us-east-1"
|
|
167
|
-
});
|
|
12
|
+
const reader = resolveReader(config);
|
|
168
13
|
return async (req, res) => {
|
|
169
14
|
const errors = [];
|
|
170
15
|
const query = req.query ?? {};
|
|
@@ -191,15 +36,22 @@ function createListHandler(config) {
|
|
|
191
36
|
res.status(400).json({ errors });
|
|
192
37
|
return;
|
|
193
38
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
39
|
+
if (!reader) {
|
|
40
|
+
res.status(200).json({ items: [] });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const all = await reader.list();
|
|
44
|
+
let items = all;
|
|
45
|
+
if (status !== void 0) {
|
|
46
|
+
items = items.filter((it) => it.status === status);
|
|
47
|
+
}
|
|
48
|
+
if (limit !== void 0) {
|
|
49
|
+
items = items.slice(0, limit);
|
|
50
|
+
}
|
|
198
51
|
res.status(200).json({ items });
|
|
199
52
|
};
|
|
200
53
|
}
|
|
201
54
|
export {
|
|
202
|
-
FeedbackService,
|
|
203
55
|
c as createIntakeResolver,
|
|
204
56
|
createListHandler
|
|
205
57
|
};
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sources":["../src/server/FeedbackService.ts","../src/server/listHandler.ts"],"sourcesContent":["/**\n * FeedbackService — DynamoDB CRUD operations for feedback items.\n *\n * Server-only module — NOT imported by the client bundle.\n * Uses dynamic import for @aws-sdk/client-dynamodb to avoid hard client bundle dep\n * (same pattern as submitResolver.ts).\n *\n * When tableName is absent (Minimal level), put is a no-op and list returns [].\n */\n\nimport type { StoredFeedbackItem, FeedbackStatus } from '../core/types.js';\n\n// ─────────────────────────────────────────────────────────────────\n// Config\n// ─────────────────────────────────────────────────────────────────\n\nexport interface FeedbackServiceConfig {\n tableName: string;\n region: string;\n}\n\n// ─────────────────────────────────────────────────────────────────\n// ListOptions\n// ─────────────────────────────────────────────────────────────────\n\nexport interface ListOptions {\n status?: FeedbackStatus;\n limit?: number;\n lastEvaluatedKey?: Record<string, unknown>;\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Helpers\n// ─────────────────────────────────────────────────────────────────\n\n/** Marshal a JS value to a DynamoDB AttributeValue */\nfunction toAttr(value: unknown): Record<string, unknown> {\n if (value === null || value === undefined) {\n return { NULL: true };\n }\n if (typeof value === 'string') {\n return { S: value };\n }\n if (typeof value === 'number') {\n return { N: String(value) };\n }\n if (typeof value === 'boolean') {\n return { BOOL: value };\n }\n if (Array.isArray(value)) {\n return { L: value.map(toAttr) };\n }\n if (typeof value === 'object') {\n const m: Record<string, Record<string, unknown>> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n m[k] = toAttr(v);\n }\n return { M: m };\n }\n return { S: String(value) };\n}\n\n/** Marshal the entire StoredFeedbackItem to a DynamoDB Item map */\nfunction marshalItem(item: StoredFeedbackItem): Record<string, Record<string, unknown>> {\n const result: Record<string, Record<string, unknown>> = {};\n for (const [key, value] of Object.entries(item as unknown as Record<string, unknown>)) {\n if (value !== undefined) {\n result[key] = toAttr(value);\n }\n }\n return result;\n}\n\n/** Unmarshal a DynamoDB AttributeValue to a JS value */\nfunction fromAttr(attr: Record<string, unknown>): unknown {\n if (attr['S'] !== undefined) return attr['S'];\n if (attr['N'] !== undefined) return Number(attr['N']);\n if (attr['BOOL'] !== undefined) return attr['BOOL'];\n if (attr['NULL']) return null;\n if (attr['L']) {\n return (attr['L'] as Array<Record<string, unknown>>).map(fromAttr);\n }\n if (attr['M']) {\n const obj: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(attr['M'] as Record<string, Record<string, unknown>>)) {\n obj[k] = fromAttr(v);\n }\n return obj;\n }\n return undefined;\n}\n\n/** Unmarshal a DynamoDB Item map to StoredFeedbackItem */\nfunction unmarshalItem(item: Record<string, Record<string, unknown>>): StoredFeedbackItem {\n const result: Record<string, unknown> = {};\n for (const [key, attr] of Object.entries(item)) {\n result[key] = fromAttr(attr);\n }\n return result as unknown as StoredFeedbackItem;\n}\n\n// ─────────────────────────────────────────────────────────────────\n// FeedbackService\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * FeedbackService — direct-DynamoDB CRUD for feedback items.\n *\n * @deprecated FOR WRITES (`put`): the central-storage principle requires feedback to be written\n * through the authenticated central ingest API, not a bespoke direct-DynamoDB writer. Use\n * `createIngestTransport(...)` (src/core/ingest-transport.ts) → `transport.submit(item)` so the\n * consuming app holds a per-app JWT (no AWS principal) and the server stamps keys + enforces own-data.\n * `put` is retained only for the server that owns the table during migration, and is removed once all\n * writers are cut over to the ingest API. `get`/`list` remain valid server-side read operations until\n * the ingest API grows its own authenticated read path.\n */\nexport class FeedbackService {\n private readonly tableName: string;\n private readonly region: string;\n\n constructor(config: FeedbackServiceConfig) {\n this.tableName = config.tableName;\n this.region = config.region;\n }\n\n // ── put ────────────────────────────────────────────────────────\n\n /**\n * PutItem — writes a StoredFeedbackItem to DynamoDB.\n * No-op when tableName is absent.\n *\n * @deprecated Direct-DynamoDB write — the pattern the central-storage principle removes. New\n * writers use `createIngestTransport(...).submit(item)` (authenticated central ingest API).\n */\n async put(item: StoredFeedbackItem): Promise<void> {\n if (!this.tableName) {\n return;\n }\n\n // Dynamic import to avoid hard dependency on AWS SDK in client bundle\n const dynamoPrefix = ['@aws', '-sdk/client-dynamo', 'db'].join('');\n const { DynamoDBClient, PutItemCommand } = await import(/* @vite-ignore */ dynamoPrefix);\n\n const client = new DynamoDBClient({ region: this.region });\n\n await client.send(\n new PutItemCommand({\n TableName: this.tableName,\n Item: marshalItem(item),\n }),\n );\n }\n\n // ── get ────────────────────────────────────────────────────────\n\n /**\n * GetItem — retrieves a StoredFeedbackItem by PK+SK.\n * Returns null if the item does not exist.\n */\n async get(pk: string, sk: string): Promise<StoredFeedbackItem | null> {\n if (!this.tableName) {\n return null;\n }\n\n const dynamoPrefix = ['@aws', '-sdk/client-dynamo', 'db'].join('');\n const { DynamoDBClient, GetItemCommand } = await import(/* @vite-ignore */ dynamoPrefix);\n\n const client = new DynamoDBClient({ region: this.region });\n\n const response = await client.send(\n new GetItemCommand({\n TableName: this.tableName,\n Key: {\n PK: { S: pk },\n SK: { S: sk },\n },\n }),\n );\n\n if (!response.Item) {\n return null;\n }\n\n return unmarshalItem(response.Item as Record<string, Record<string, unknown>>);\n }\n\n // ── list ───────────────────────────────────────────────────────\n\n /**\n * Query GSI1 with optional status filter.\n * Returns [] when tableName is absent (Minimal level).\n */\n async list(opts: ListOptions = {}): Promise<StoredFeedbackItem[]> {\n if (!this.tableName) {\n return [];\n }\n\n const dynamoPrefix = ['@aws', '-sdk/client-dynamo', 'db'].join('');\n const { DynamoDBClient, QueryCommand } = await import(/* @vite-ignore */ dynamoPrefix);\n\n const client = new DynamoDBClient({ region: this.region });\n\n const limit = opts.limit ?? 50;\n const gsi1pk = opts.status ? `STATUS#${opts.status}` : undefined;\n\n // Build query params\n type QueryParams = {\n TableName: string;\n IndexName: string;\n KeyConditionExpression: string;\n ExpressionAttributeNames: Record<string, string>;\n ExpressionAttributeValues: Record<string, Record<string, unknown>>;\n Limit: number;\n ExclusiveStartKey?: Record<string, unknown>;\n ScanIndexForward: boolean;\n };\n\n const params: QueryParams = {\n TableName: this.tableName,\n IndexName: 'GSI1',\n KeyConditionExpression: '#pk = :pk',\n ExpressionAttributeNames: {\n '#pk': 'GSI1PK',\n },\n ExpressionAttributeValues: {\n ':pk': { S: gsi1pk ?? 'STATUS#open' },\n },\n Limit: limit,\n ScanIndexForward: false, // Descending (highest priority first via inverted sort key)\n };\n\n if (opts.lastEvaluatedKey) {\n params.ExclusiveStartKey = opts.lastEvaluatedKey;\n }\n\n const response = await client.send(new QueryCommand(params));\n\n if (!response.Items || response.Items.length === 0) {\n return [];\n }\n\n return (response.Items as Array<Record<string, Record<string, unknown>>>).map(unmarshalItem);\n }\n}\n","/**\n * listHandler — HTTP GET /api/feedback/list handler factory.\n *\n * Server-only module — NOT imported by the client bundle.\n *\n * HTTP contract:\n * GET /api/feedback/list\n * Query params: ?status=open&limit=20\n * → 200 { items: StoredFeedbackItem[]; nextKey?: string }\n * → 400 { errors: string[] }\n */\n\nimport type { FeedbackStatus, StoredFeedbackItem } from '../core/types.js';\nimport { FeedbackService } from './FeedbackService.js';\nimport type { ListOptions } from './FeedbackService.js';\nimport type { RequestHandler } from '../react/resolver/submitResolver.js';\n\n// ─────────────────────────────────────────────────────────────────\n// Config\n// ─────────────────────────────────────────────────────────────────\n\nexport interface ListHandlerConfig {\n /** DynamoDB table name — if absent, returns [] (Minimal level) */\n tableName?: string;\n /** AWS region for DynamoDB */\n region?: string;\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Valid status values\n// ─────────────────────────────────────────────────────────────────\n\nconst VALID_STATUSES: FeedbackStatus[] = ['open', 'approved', 'deferred', 'resolved', 'wontfix'];\n\n// ─────────────────────────────────────────────────────────────────\n// Handler Factory\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * Creates an Express/Lambda-compatible request handler for the list endpoint.\n *\n * Validates query params → queries DynamoDB GSI1 → returns paginated results.\n * Returns 400 for invalid status values.\n * Returns 200 with items array (empty [] when no tableName configured).\n */\nexport function createListHandler(config: ListHandlerConfig): RequestHandler {\n const service = new FeedbackService({\n tableName: config.tableName ?? '',\n region: config.region ?? 'us-east-1',\n });\n\n return async (req, res) => {\n const errors: string[] = [];\n\n // Extract query params from URL or req.body (depending on framework shape)\n // We parse from the req — in Express, query params live in req.query\n // but our RequestBody interface doesn't define query. Parse from body if present.\n const query = (req as unknown as { query?: Record<string, string> }).query ?? {};\n const rawStatus = query['status'];\n const rawLimit = query['limit'];\n\n // Validate status\n let status: FeedbackStatus | undefined;\n if (rawStatus !== undefined) {\n if (!VALID_STATUSES.includes(rawStatus as FeedbackStatus)) {\n errors.push(`status must be one of: ${VALID_STATUSES.join(', ')} (got: ${rawStatus})`);\n } else {\n status = rawStatus as FeedbackStatus;\n }\n }\n\n // Validate limit\n let limit: number | undefined;\n if (rawLimit !== undefined) {\n const parsed = parseInt(rawLimit, 10);\n if (isNaN(parsed) || parsed < 1 || parsed > 100) {\n errors.push('limit must be a number between 1 and 100');\n } else {\n limit = parsed;\n }\n }\n\n if (errors.length > 0) {\n res.status(400).json({ errors });\n return;\n }\n\n const opts: ListOptions = {};\n if (status !== undefined) opts.status = status;\n if (limit !== undefined) opts.limit = limit;\n\n const items: StoredFeedbackItem[] = await service.list(opts);\n\n res.status(200).json({ items });\n };\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server.js","sources":["../src/server/listHandler.ts"],"sourcesContent":["/**\n * listHandler — HTTP GET /api/feedback/list handler factory.\n *\n * Server-only module — NOT imported by the client bundle.\n *\n * Reads this app's feedback partition through the authenticated central ingest API\n * (`GET /records/feedback`, Bearer token, scope `assets/read`) via `createIngestReader`.\n * There is NO direct DynamoDB access here — the server returns the whole own-data partition\n * and status/limit filtering is applied CLIENT-SIDE in this handler.\n *\n * HTTP contract:\n * GET /api/feedback/list\n * Query params: ?status=open&limit=20\n * → 200 { items: StoredFeedbackItem[] }\n * → 400 { errors: string[] }\n *\n * NO AWS SDK imports in this file.\n */\n\nimport type { FeedbackStatus, StoredFeedbackItem } from '../core/types.js';\nimport { createIngestReader, type FeedbackReader, type IngestTransportConfig } from '../core/ingest-transport.js';\nimport type { RequestHandler } from '../react/resolver/submitResolver.js';\n\n// ─────────────────────────────────────────────────────────────────\n// Config\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * Config for the list handler. Supply the ingest credential (apiUrl + tokenUrl + clientId +\n * clientSecret, or a tokenProvider) so the handler can read via the central API. When no ingest\n * config is given, the handler runs at \"Minimal level\" and returns `[]` (no storage wired).\n *\n * For tests, an already-constructed `reader` may be injected directly.\n */\nexport interface ListHandlerConfig extends Partial<IngestTransportConfig> {\n /** Pre-constructed reader (tests / custom wiring). Takes precedence over ingest config. */\n reader?: FeedbackReader;\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Valid status values\n// ─────────────────────────────────────────────────────────────────\n\nconst VALID_STATUSES: FeedbackStatus[] = ['new', 'triaged', 'assigned', 'in_progress', 'fixed', 'pending_retest', 'verified', 'closed', 'deferred', 'rejected', 'duplicate', 'reopened'];\n\n// ─────────────────────────────────────────────────────────────────\n// Reader resolution\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * Resolve a reader from config. Returns null at \"Minimal level\" (no reader + no ingest config),\n * in which case the handler responds with an empty list.\n */\nfunction resolveReader(config: ListHandlerConfig): FeedbackReader | null {\n if (config.reader) return config.reader;\n // An ingest reader needs at minimum an apiUrl plus a token source.\n if (config.apiUrl && (config.tokenProvider || (config.tokenUrl && config.clientId && config.clientSecret))) {\n return createIngestReader(config as IngestTransportConfig);\n }\n return null;\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Handler Factory\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * Creates an Express/Lambda-compatible request handler for the list endpoint.\n *\n * Validates query params → reads the app's partition via the ingest API → applies client-side\n * status/limit filtering → returns the paginated result.\n * Returns 400 for invalid status/limit values.\n * Returns 200 with items array (empty [] when no ingest config is wired, or the partition is empty).\n */\nexport function createListHandler(config: ListHandlerConfig): RequestHandler {\n const reader = resolveReader(config);\n\n return async (req, res) => {\n const errors: string[] = [];\n\n // Extract query params (Express: req.query). Our RequestBody doesn't declare query, so read loosely.\n const query = (req as unknown as { query?: Record<string, string> }).query ?? {};\n const rawStatus = query['status'];\n const rawLimit = query['limit'];\n\n // Validate status\n let status: FeedbackStatus | undefined;\n if (rawStatus !== undefined) {\n if (!VALID_STATUSES.includes(rawStatus as FeedbackStatus)) {\n errors.push(`status must be one of: ${VALID_STATUSES.join(', ')} (got: ${rawStatus})`);\n } else {\n status = rawStatus as FeedbackStatus;\n }\n }\n\n // Validate limit\n let limit: number | undefined;\n if (rawLimit !== undefined) {\n const parsed = parseInt(rawLimit, 10);\n if (isNaN(parsed) || parsed < 1 || parsed > 100) {\n errors.push('limit must be a number between 1 and 100');\n } else {\n limit = parsed;\n }\n }\n\n if (errors.length > 0) {\n res.status(400).json({ errors });\n return;\n }\n\n // Minimal level — no reader wired → empty list (not an error).\n if (!reader) {\n res.status(200).json({ items: [] });\n return;\n }\n\n // Read the whole own-data partition, then filter client-side (no server-side query exists).\n const all: StoredFeedbackItem[] = await reader.list();\n\n let items = all;\n if (status !== undefined) {\n items = items.filter((it) => it.status === status);\n }\n if (limit !== undefined) {\n items = items.slice(0, limit);\n }\n\n res.status(200).json({ items });\n };\n}\n"],"names":[],"mappings":";;AA2CA,MAAM,iBAAmC,CAAC,OAAO,WAAW,YAAY,eAAe,SAAS,kBAAkB,YAAY,UAAU,YAAY,YAAY,aAAa,UAAU;AAUvL,SAAS,cAAc,QAAkD;AACvE,MAAI,OAAO,OAAQ,QAAO,OAAO;AAEjC,MAAI,OAAO,WAAW,OAAO,iBAAkB,OAAO,YAAY,OAAO,YAAY,OAAO,eAAgB;AAC1G,WAAO,mBAAmB,MAA+B;AAAA,EAC3D;AACA,SAAO;AACT;AAcO,SAAS,kBAAkB,QAA2C;AAC3E,QAAM,SAAS,cAAc,MAAM;AAEnC,SAAO,OAAO,KAAK,QAAQ;AACzB,UAAM,SAAmB,CAAA;AAGzB,UAAM,QAAS,IAAsD,SAAS,CAAA;AAC9E,UAAM,YAAY,MAAM,QAAQ;AAChC,UAAM,WAAW,MAAM,OAAO;AAG9B,QAAI;AACJ,QAAI,cAAc,QAAW;AAC3B,UAAI,CAAC,eAAe,SAAS,SAA2B,GAAG;AACzD,eAAO,KAAK,0BAA0B,eAAe,KAAK,IAAI,CAAC,UAAU,SAAS,GAAG;AAAA,MACvF,OAAO;AACL,iBAAS;AAAA,MACX;AAAA,IACF;AAGA,QAAI;AACJ,QAAI,aAAa,QAAW;AAC1B,YAAM,SAAS,SAAS,UAAU,EAAE;AACpC,UAAI,MAAM,MAAM,KAAK,SAAS,KAAK,SAAS,KAAK;AAC/C,eAAO,KAAK,0CAA0C;AAAA,MACxD,OAAO;AACL,gBAAQ;AAAA,MACV;AAAA,IACF;AAEA,QAAI,OAAO,SAAS,GAAG;AACrB,UAAI,OAAO,GAAG,EAAE,KAAK,EAAE,QAAQ;AAC/B;AAAA,IACF;AAGA,QAAI,CAAC,QAAQ;AACX,UAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,CAAA,GAAI;AAClC;AAAA,IACF;AAGA,UAAM,MAA4B,MAAM,OAAO,KAAA;AAE/C,QAAI,QAAQ;AACZ,QAAI,WAAW,QAAW;AACxB,cAAQ,MAAM,OAAO,CAAC,OAAO,GAAG,WAAW,MAAM;AAAA,IACnD;AACA,QAAI,UAAU,QAAW;AACvB,cAAQ,MAAM,MAAM,GAAG,KAAK;AAAA,IAC9B;AAEA,QAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO;AAAA,EAChC;AACF;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anton-bakker-feedback",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Zero-dependency business logic layer for feedback collection — types, capture, transport, config, i18n, observability",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,11 +46,23 @@
|
|
|
46
46
|
"publish:major": "npm run build && npm version major && npm publish"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
+
"@aws-sdk/client-cloudwatch": "^3.0.0",
|
|
50
|
+
"aws-sdk": ">=2.0.0",
|
|
51
|
+
"@opentelemetry/api": "^1.0.0",
|
|
49
52
|
"anton-bakker-tailwind-components": ">=0.7.0",
|
|
50
53
|
"react": "^18.0.0 || ^19.0.0",
|
|
51
54
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
52
55
|
},
|
|
53
56
|
"peerDependenciesMeta": {
|
|
57
|
+
"@aws-sdk/client-cloudwatch": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"aws-sdk": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@opentelemetry/api": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
54
66
|
"react": {
|
|
55
67
|
"optional": true
|
|
56
68
|
},
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"submitResolver-DsfkrUHY.js","sources":["../../src/react/resolver/submitResolver.ts"],"sourcesContent":["/**\n * submitResolver — CRUD resolver for the feedback intake submit path.\n *\n * Server-only module — NOT imported by the client bundle.\n * Tree-shakeable: the /react subpath export does NOT re-export this.\n *\n * Factory: createIntakeResolver(config: SubmitHandlerConfig): RequestHandler\n *\n * HTTP contract:\n * POST /api/feedback/submit\n * Content-Type: application/json\n * Body: SubmitPayload\n * → 201 { id: string; status: string }\n * → 400 { errors: string[] }\n * → 503 { retryable: true; error: string }\n *\n * OTel span: 'feedback.submit' with attributes:\n * app, feedbackId (after store), environment\n *\n * No Bedrock dependency — resolver calls submit handler only, not dialogue handler.\n */\n\nimport { withSpan } from '../../core/observability.js';\nimport type { FeedbackEnvironment, FeedbackType, FeedbackScope, FeedbackSize } from '../../core/types.js';\n\n// ─────────────────────────────────────────────────────────────────\n// Request / Response types\n// ─────────────────────────────────────────────────────────────────\n\n/** Minimal HTTP request shape (Lambda/Express compatible) */\nexport interface RequestBody {\n headers?: Record<string, string | string[] | undefined>;\n body?: unknown;\n}\n\n/** Minimal HTTP response shape (Lambda/Express compatible) */\nexport interface ResponseBody {\n statusCode: number;\n headers: Record<string, string>;\n body: string;\n}\n\n/** Express-compatible RequestHandler */\nexport type RequestHandler = (\n req: RequestBody,\n res: {\n status: (code: number) => { json: (body: unknown) => void };\n json: (body: unknown) => void;\n },\n) => Promise<void>;\n\n// ─────────────────────────────────────────────────────────────────\n// Submit Payload (what the client POSTs)\n// ─────────────────────────────────────────────────────────────────\n\nexport interface SubmitPayload {\n title: string;\n description?: string | undefined;\n type: FeedbackType;\n scope?: FeedbackScope | undefined;\n size?: FeedbackSize | undefined;\n screenshots?: Array<{ dataUrl: string; capturedAt: string }> | undefined;\n consoleErrors?: string[] | undefined;\n systemInfo?: {\n url?: string | undefined;\n userAgent?: string | undefined;\n viewport?: { width: number; height: number } | undefined;\n locale?: string | undefined;\n } | undefined;\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Config\n// ─────────────────────────────────────────────────────────────────\n\nexport interface SubmitHandlerConfig {\n /** Application name — used in OTel attributes and feedback ID */\n appName: string;\n /** Deployment environment */\n environment?: FeedbackEnvironment;\n /** DynamoDB table name — if absent, storage is skipped (Minimal level) */\n tableName?: string;\n /** AWS region for DynamoDB */\n awsRegion?: string;\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Validation\n// ─────────────────────────────────────────────────────────────────\n\nconst VALID_TYPES: FeedbackType[] = ['bug', 'feature', 'ux', 'other'];\n\nfunction validatePayload(body: unknown): { valid: true; payload: SubmitPayload } | { valid: false; errors: string[] } {\n if (!body || typeof body !== 'object' || Array.isArray(body)) {\n return { valid: false, errors: ['Request body must be a JSON object'] };\n }\n\n const obj = body as Record<string, unknown>;\n const errors: string[] = [];\n\n // title: required, string, min 1 char\n if (!obj['title'] || typeof obj['title'] !== 'string' || (obj['title'] as string).trim().length === 0) {\n errors.push('title is required and must be a non-empty string');\n } else if ((obj['title'] as string).trim().length < 1) {\n errors.push('title must be at least 1 character');\n }\n\n // type: required, must be valid\n if (!obj['type']) {\n errors.push('type is required');\n } else if (!VALID_TYPES.includes(obj['type'] as FeedbackType)) {\n errors.push(`type must be one of: ${VALID_TYPES.join(', ')} (got: ${String(obj['type'])})`);\n }\n\n // description: optional string\n if (obj['description'] !== undefined && typeof obj['description'] !== 'string') {\n errors.push('description must be a string if provided');\n }\n\n if (errors.length > 0) {\n return { valid: false, errors };\n }\n\n return {\n valid: true,\n payload: {\n title: (obj['title'] as string).trim(),\n ...(obj['description'] !== undefined && { description: obj['description'] as string }),\n type: obj['type'] as FeedbackType,\n ...(obj['scope'] !== undefined && { scope: obj['scope'] as FeedbackScope }),\n ...(obj['size'] !== undefined && { size: obj['size'] as FeedbackSize }),\n ...(obj['screenshots'] !== undefined && { screenshots: obj['screenshots'] as SubmitPayload['screenshots'] }),\n ...(obj['consoleErrors'] !== undefined && { consoleErrors: obj['consoleErrors'] as string[] }),\n ...(obj['systemInfo'] !== undefined && { systemInfo: obj['systemInfo'] as SubmitPayload['systemInfo'] }),\n },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────\n// DynamoDB store (minimal implementation)\n// ─────────────────────────────────────────────────────────────────\n\nasync function storeFeedback(\n payload: SubmitPayload,\n config: SubmitHandlerConfig,\n feedbackId: string,\n): Promise<void> {\n if (!config.tableName || !config.awsRegion) {\n // Minimal level — no storage\n return;\n }\n\n // Dynamic import to avoid hard dependency on AWS SDK in client bundle\n const dynamoPrefix = ['@aws', '-sdk/client-dynamo', 'db'].join('');\n const { DynamoDBClient, PutItemCommand } = await import(/* @vite-ignore */ dynamoPrefix);\n\n const client = new DynamoDBClient({ region: config.awsRegion });\n const now = new Date().toISOString();\n\n await client.send(\n new PutItemCommand({\n TableName: config.tableName,\n Item: {\n PK: { S: `PROJECT#${config.appName}` },\n SK: { S: `FB#${now}#${feedbackId}` },\n id: { S: feedbackId },\n appName: { S: config.appName },\n type: { S: payload.type },\n status: { S: 'open' },\n content: { S: payload.title },\n submittedAt: { S: now },\n lastModifiedAt: { S: now },\n environment: { S: config.environment ?? 'production' },\n },\n }),\n );\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Feedback ID generation\n// ─────────────────────────────────────────────────────────────────\n\nfunction generateId(): string {\n const date = new Date().toISOString().slice(0, 10);\n const uuid8 = crypto.randomUUID().replace(/-/g, '').slice(0, 8);\n return `FB-${date}-${uuid8}`;\n}\n\n// ─────────────────────────────────────────────────────────────────\n// Handler Factory\n// ─────────────────────────────────────────────────────────────────\n\n/**\n * Creates an Express/Lambda-compatible request handler for the submit endpoint.\n *\n * Validates payload → stores in DynamoDB (if configured) → returns 201 with ID.\n * Returns 503 with retryable:true when DynamoDB is unavailable.\n * No Bedrock dependency.\n */\nexport function createIntakeResolver(config: SubmitHandlerConfig): RequestHandler {\n return async (req, res) => {\n // ── Content-Type validation ───────────────────────────────────\n const contentType = (req.headers?.['content-type'] ?? '').toString();\n if (!contentType.includes('application/json')) {\n res.status(400).json({\n errors: ['Content-Type must be application/json'],\n });\n return;\n }\n\n // ── Body validation ───────────────────────────────────────────\n const validation = validatePayload(req.body);\n if (!validation.valid) {\n res.status(400).json({ errors: validation.errors });\n return;\n }\n\n const { payload } = validation;\n const feedbackId = generateId();\n\n // ── OTel span ─────────────────────────────────────────────────\n try {\n await withSpan(\n 'feedback.submit',\n {\n app: config.appName,\n environment: config.environment ?? 'production',\n },\n async (span) => {\n try {\n await storeFeedback(payload, config, feedbackId);\n\n // Attach feedbackId after successful store\n if (span) {\n (span as unknown as { setAttribute?: (k: string, v: string) => void })\n .setAttribute?.('feedbackId', feedbackId);\n }\n\n res.status(201).json({\n id: feedbackId,\n status: 'open',\n });\n } catch (storageError) {\n const isDynamoUnavailable =\n storageError instanceof Error &&\n (storageError.name === 'ServiceUnavailableException' ||\n storageError.name === 'ProvisionedThroughputExceededException' ||\n storageError.message.includes('ECONNREFUSED') ||\n storageError.message.includes('unavailable') ||\n storageError.message.includes('DynamoDB'));\n\n if (isDynamoUnavailable) {\n res.status(503).json({\n retryable: true,\n error: 'Storage service temporarily unavailable. Please try again.',\n });\n } else {\n res.status(503).json({\n retryable: true,\n error: 'Failed to store feedback. Please try again.',\n });\n }\n }\n },\n );\n } catch {\n res.status(503).json({\n retryable: true,\n error: 'Internal error. Please try again.',\n });\n }\n };\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { StoredFeedbackItem, FeedbackStatus } from '../core/types.js';
|
|
2
|
-
|
|
3
|
-
export interface FeedbackServiceConfig {
|
|
4
|
-
tableName: string;
|
|
5
|
-
region: string;
|
|
6
|
-
}
|
|
7
|
-
export interface ListOptions {
|
|
8
|
-
status?: FeedbackStatus;
|
|
9
|
-
limit?: number;
|
|
10
|
-
lastEvaluatedKey?: Record<string, unknown>;
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* FeedbackService — direct-DynamoDB CRUD for feedback items.
|
|
14
|
-
*
|
|
15
|
-
* @deprecated FOR WRITES (`put`): the central-storage principle requires feedback to be written
|
|
16
|
-
* through the authenticated central ingest API, not a bespoke direct-DynamoDB writer. Use
|
|
17
|
-
* `createIngestTransport(...)` (src/core/ingest-transport.ts) → `transport.submit(item)` so the
|
|
18
|
-
* consuming app holds a per-app JWT (no AWS principal) and the server stamps keys + enforces own-data.
|
|
19
|
-
* `put` is retained only for the server that owns the table during migration, and is removed once all
|
|
20
|
-
* writers are cut over to the ingest API. `get`/`list` remain valid server-side read operations until
|
|
21
|
-
* the ingest API grows its own authenticated read path.
|
|
22
|
-
*/
|
|
23
|
-
export declare class FeedbackService {
|
|
24
|
-
private readonly tableName;
|
|
25
|
-
private readonly region;
|
|
26
|
-
constructor(config: FeedbackServiceConfig);
|
|
27
|
-
/**
|
|
28
|
-
* PutItem — writes a StoredFeedbackItem to DynamoDB.
|
|
29
|
-
* No-op when tableName is absent.
|
|
30
|
-
*
|
|
31
|
-
* @deprecated Direct-DynamoDB write — the pattern the central-storage principle removes. New
|
|
32
|
-
* writers use `createIngestTransport(...).submit(item)` (authenticated central ingest API).
|
|
33
|
-
*/
|
|
34
|
-
put(item: StoredFeedbackItem): Promise<void>;
|
|
35
|
-
/**
|
|
36
|
-
* GetItem — retrieves a StoredFeedbackItem by PK+SK.
|
|
37
|
-
* Returns null if the item does not exist.
|
|
38
|
-
*/
|
|
39
|
-
get(pk: string, sk: string): Promise<StoredFeedbackItem | null>;
|
|
40
|
-
/**
|
|
41
|
-
* Query GSI1 with optional status filter.
|
|
42
|
-
* Returns [] when tableName is absent (Minimal level).
|
|
43
|
-
*/
|
|
44
|
-
list(opts?: ListOptions): Promise<StoredFeedbackItem[]>;
|
|
45
|
-
}
|
|
46
|
-
//# sourceMappingURL=FeedbackService.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"FeedbackService.d.ts","sourceRoot":"","sources":["../../src/server/FeedbackService.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAM3E,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB;AAMD,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC5C;AA4ED;;;;;;;;;;GAUG;AACH,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,MAAM,EAAE,qBAAqB;IAOzC;;;;;;OAMG;IACG,GAAG,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAqBlD;;;OAGG;IACG,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IA6BrE;;;OAGG;IACG,IAAI,CAAC,IAAI,GAAE,WAAgB,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;CAmDlE"}
|