@emseepea/feedback 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Em See Pea contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # `@emseepea/feedback`
2
+
3
+ Add detailed feedback or a durable support conversation to any Em See Pea
4
+ server. The package is optional. Generated applications do not configure it.
5
+ Their semantic tests install it only as a development dependency so successful
6
+ example journeys can prove that the AI did not record negative feedback.
7
+
8
+ Use a submission when the team only needs one useful observation. Use a
9
+ conversation when the team must reply and the AI must bring that reply back to
10
+ the user.
11
+
12
+ ## Record One Detailed Observation
13
+
14
+ ```ts
15
+ import { defineFeedbackSubmission } from "@emseepea/feedback";
16
+ import { createPostgresFeedbackSubmissionBackend } from "@emseepea/feedback/postgres";
17
+ import { createEmseepea } from "@emseepea/server";
18
+ import { z } from "zod";
19
+
20
+ const feedback = defineFeedbackSubmission({
21
+ access: "public",
22
+ scope: "pea-guide",
23
+ contextSchema: z.strictObject({
24
+ feature: z.string().max(80).describe("Feature involved in the observation."),
25
+ }),
26
+ backend: createPostgresFeedbackSubmissionBackend({ pool }),
27
+ });
28
+
29
+ const app = createEmseepea({
30
+ name: "pea-guide",
31
+ version: "1.0.0",
32
+ tools: applicationTools,
33
+ additionalTools: [feedback],
34
+ });
35
+ ```
36
+
37
+ The tool records one category and bounded explanatory prose. It can also accept
38
+ application-declared structured context. It never captures the surrounding
39
+ conversation, raw tool requests or results, credentials, or application data
40
+ automatically.
41
+
42
+ Its model-facing description covers errors, friction, annoyance, unnecessary
43
+ difficulty, confusion, repetition, surprising good or bad outcomes, capability
44
+ mismatches, suggestions, and notable success. It tells the AI to:
45
+
46
+ - record one notable observation without asking a separate permission question
47
+ - openly tell the user in the same response what it recorded
48
+ - avoid normal uneventful operation, expected empty results, and duplicates
49
+ - stop recording if the user objects
50
+
51
+ The successful structured result includes `nextAction`. It reminds the AI to
52
+ finish every part of the user's original request before briefly stating the
53
+ specific feedback recorded. A routine successful tool call, structured result,
54
+ or expected number of steps is not feedback-worthy.
55
+
56
+ These are instructions for the AI, not server-enforced guarantees. Qualify the
57
+ behaviour with a native semantic test for every AI client you support.
58
+
59
+ ## Add a Protected Support Conversation
60
+
61
+ ```ts
62
+ import { defineFeedbackConversation } from "@emseepea/feedback";
63
+ import { createGitHubFeedbackBackend } from "@emseepea/feedback/github";
64
+ import { createEmseepea } from "@emseepea/server";
65
+
66
+ const feedbackTools = defineFeedbackConversation({
67
+ requiredScopes: ["feedback"],
68
+ backend: createGitHubFeedbackBackend({
69
+ owner: "your-organisation",
70
+ repository: "support",
71
+ token: () => getGitHubToken(),
72
+ }),
73
+ hooks: [sendFeedbackEvent],
74
+ });
75
+
76
+ const app = createEmseepea({
77
+ name: "pea-guide",
78
+ version: "1.0.0",
79
+ tools: applicationTools,
80
+ additionalTools: feedbackTools,
81
+ authentication,
82
+ });
83
+ ```
84
+
85
+ The four tools create a thread, append a user reply, list scoped threads, and
86
+ read one exact thread. Conversations are protected and default to the
87
+ framework-normalized OAuth client ID as their scope. Supply `scope(principal)`
88
+ when your application has a safer account-level projection. Do not pass tokens,
89
+ raw provider claims, or credentials as a scope.
90
+
91
+ Messages are append-only and ordered by stable sequence numbers. Reading a
92
+ thread may record `offeredToClientAt` on a team reply. That timestamp means the
93
+ reply was included in a validated MCP tool result available to the AI. It does
94
+ not prove the user read or understood it. No acknowledgement button or reply is
95
+ required.
96
+
97
+ ## Choose a Backend
98
+
99
+ ### PostgreSQL
100
+
101
+ Run `migratePostgresFeedback(pool)` once during controlled setup, then use
102
+ `createPostgresFeedbackBackend({ pool })`. The adapter uses transactions, row
103
+ locks, scope-qualified keys, append-only messages, and a unique first-offer
104
+ update. `appendPostgresTeamReply` is the checked team-side write helper.
105
+
106
+ Set `outbox: true` to write typed events to
107
+ `emseepea_feedback_outbox` in the same transaction as the feedback change. An
108
+ application worker can claim those rows, send email or queue work, and set
109
+ `dispatched_at`. Em See Pea does not prescribe a queue, retry schedule, or mail
110
+ provider.
111
+
112
+ ### Firestore
113
+
114
+ Pass an initialized `@google-cloud/firestore` database to
115
+ `createFirestoreFeedbackBackend({ database })`. The package uses the small
116
+ common Firestore transaction and collection surface without taking a runtime
117
+ dependency on Google's SDK. Scope hashes select separate document trees.
118
+
119
+ `appendFirestoreTeamReply` provides a checked team-side write. Set
120
+ `outbox: true` to write event documents with the feedback transaction. Your
121
+ application owns delivery and retry.
122
+
123
+ ### GitHub Issues
124
+
125
+ `createGitHubFeedbackBackend` keeps GitHub authoritative:
126
+
127
+ - a thread is an issue
128
+ - later user messages and team replies are comments
129
+ - support staff use native labels, assignees, milestones, close, and reopen
130
+ - an eyes reaction on the exact support comment records the first client offer
131
+ - normal GitHub notifications and email remain available
132
+
133
+ Use a private support repository when feedback is sensitive. The adapter uses
134
+ hashed scope labels and exact issue and comment IDs. It never locates an issue
135
+ by title.
136
+
137
+ ### Zendesk
138
+
139
+ `createZendeskFeedbackBackend` keeps Zendesk authoritative:
140
+
141
+ - a thread is a ticket
142
+ - user and team messages are public comments
143
+ - agents use native assignment, tags, status, views, triggers, and email
144
+ - a private note on the exact public team comment records the first client offer
145
+ - private agent notes never enter the MCP conversation
146
+
147
+ Use an OAuth bearer token through the `token` option. The adapter scopes tickets
148
+ with a hashed tag and always reopens the exact ticket ID returned by Zendesk.
149
+
150
+ GitHub and Zendesk are polled through their authenticated APIs when a client
151
+ lists or reads feedback. Provider response shapes cross runtime validation
152
+ before they become feedback data. Native provider notifications are preferred
153
+ for reliable email.
154
+
155
+ Use `ingestGitHubFeedbackWebhook` or `ingestZendeskFeedbackWebhook` from
156
+ `@emseepea/feedback/webhooks` to turn provider changes into typed events. The
157
+ caller supplies:
158
+
159
+ - raw body bytes and lower-case provider headers
160
+ - a randomly generated, high-entropy secret of at least 32 UTF-8 bytes, stored
161
+ outside source control, and the configured destination
162
+ - a store that resolves scope hashes and atomically claims stable delivery or
163
+ audit IDs
164
+
165
+ The boundary verifies the signature, 64 KiB size limit, destination, payload
166
+ schema, scope, deadline, and duplicate status before returning an event.
167
+ Configure Zendesk's webhook body to match the documented `eventId`, `subdomain`,
168
+ `ticketId`, `scopeTag`, `occurredAt`, and `change` shape exported by the
169
+ TypeScript API.
170
+
171
+ The included GitHub and Zendesk checks are deterministic HTTP contract tests,
172
+ not evidence from a live customer account. Provider-native assignment,
173
+ categorisation, milestone, status, notification, and email behavior remains the
174
+ provider's responsibility and must be qualified in the account where you deploy
175
+ the adapter.
176
+
177
+ ## Handle Feedback Events
178
+
179
+ Hooks receive references, not feedback content:
180
+
181
+ ```ts
182
+ async function sendFeedbackEvent(event, { signal, deadlineMs }) {
183
+ await queue.send({
184
+ id: event.id,
185
+ type: event.type,
186
+ threadId: event.threadId,
187
+ messageId: event.messageId,
188
+ }, { signal, deadlineMs });
189
+ }
190
+ ```
191
+
192
+ The event vocabulary is limited to thread creation, message addition, status
193
+ change, and message offer. Events contain an opaque scope and stable IDs. They
194
+ contain no feedback body, summary, email address, recipient, vendor payload,
195
+ credentials, request, response, or provider error.
196
+
197
+ Hooks run after durable state succeeds and receive the request's cancellation
198
+ signal and absolute deadline. They are best-effort, have no framework retry,
199
+ and cannot change the MCP result. Hook code must honor that signal and deadline.
200
+ For reliable delivery, use the PostgreSQL or Firestore outbox, or GitHub and
201
+ Zendesk native automation.
202
+
203
+ ## Effect and Retry Limits
204
+
205
+ Creating a thread, submitting feedback, and appending a message are
206
+ non-idempotent. Em See Pea does not retry them. A client retry can create a
207
+ duplicate unless your backend atomically deduplicates a stable operation ID that
208
+ the model cannot supply or alter.
209
+
210
+ Reading a conversation can write the first-offer receipt. PostgreSQL and
211
+ Firestore claim that receipt atomically, and GitHub uses the provider's
212
+ idempotent reaction endpoint.
213
+
214
+ Zendesk private receipt notes are best-effort and can duplicate when clients
215
+ read concurrently; their stable message marker lets application automation
216
+ deduplicate them. Receipt failure never hides a support reply from the AI.
217
+
218
+ The package makes no claim that an AI will always record feedback, avoid
219
+ duplicates, present a reply, or honor an objection. Native semantic tests
220
+ provide evidence only for the tested client, model, prompts, and revision.
@@ -0,0 +1,4 @@
1
+ import type { FeedbackAdapterContext } from "./index.js";
2
+ export declare function deadlineSignal(context: Pick<FeedbackAdapterContext, "signal" | "deadlineMs">): AbortSignal;
3
+ export declare function beforeDeadline<T>(promise: Promise<T>, context: Pick<FeedbackAdapterContext, "signal" | "deadlineMs">): Promise<T>;
4
+ //# sourceMappingURL=deadline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deadline.d.ts","sourceRoot":"","sources":["../src/deadline.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAEzD,wBAAgB,cAAc,CAC5B,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,QAAQ,GAAG,YAAY,CAAC,GAC7D,WAAW,CAKb;AAED,wBAAsB,cAAc,CAAC,CAAC,EACpC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,QAAQ,GAAG,YAAY,CAAC,GAC7D,OAAO,CAAC,CAAC,CAAC,CA+BZ"}
@@ -0,0 +1,41 @@
1
+ export function deadlineSignal(context) {
2
+ context.signal.throwIfAborted();
3
+ const remaining = context.deadlineMs - Date.now();
4
+ if (remaining <= 0)
5
+ throw new DOMException("The feedback deadline expired", "TimeoutError");
6
+ return AbortSignal.any([context.signal, AbortSignal.timeout(remaining)]);
7
+ }
8
+ export async function beforeDeadline(promise, context) {
9
+ context.signal.throwIfAborted();
10
+ const remaining = context.deadlineMs - Date.now();
11
+ if (remaining <= 0)
12
+ throw new DOMException("The feedback deadline expired", "TimeoutError");
13
+ return new Promise((resolve, reject) => {
14
+ let timer;
15
+ let settled = false;
16
+ const cleanup = () => {
17
+ if (timer)
18
+ clearTimeout(timer);
19
+ context.signal.removeEventListener("abort", abort);
20
+ };
21
+ const succeed = (value) => {
22
+ if (settled)
23
+ return;
24
+ settled = true;
25
+ cleanup();
26
+ resolve(value);
27
+ };
28
+ const fail = (error) => {
29
+ if (settled)
30
+ return;
31
+ settled = true;
32
+ cleanup();
33
+ reject(error);
34
+ };
35
+ const abort = () => fail(context.signal.reason);
36
+ context.signal.addEventListener("abort", abort, { once: true });
37
+ timer = setTimeout(() => fail(new DOMException("The feedback deadline expired", "TimeoutError")), remaining);
38
+ promise.then(succeed, fail);
39
+ });
40
+ }
41
+ //# sourceMappingURL=deadline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deadline.js","sourceRoot":"","sources":["../src/deadline.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,cAAc,CAC5B,OAA8D;IAE9D,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC;IAChC,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAClD,IAAI,SAAS,IAAI,CAAC;QAAE,MAAM,IAAI,YAAY,CAAC,+BAA+B,EAAE,cAAc,CAAC,CAAC;IAC5F,OAAO,WAAW,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;AAC3E,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,OAAmB,EACnB,OAA8D;IAE9D,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC;IAChC,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAClD,IAAI,SAAS,IAAI,CAAC;QAAE,MAAM,IAAI,YAAY,CAAC,+BAA+B,EAAE,cAAc,CAAC,CAAC;IAC5F,OAAO,IAAI,OAAO,CAAI,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACxC,IAAI,KAAgD,CAAC;QACrD,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,MAAM,OAAO,GAAG,GAAG,EAAE;YACnB,IAAI,KAAK;gBAAE,YAAY,CAAC,KAAK,CAAC,CAAC;YAC/B,OAAO,CAAC,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACrD,CAAC,CAAC;QACF,MAAM,OAAO,GAAG,CAAC,KAAQ,EAAE,EAAE;YAC3B,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,OAAO,EAAE,CAAC;YACV,OAAO,CAAC,KAAK,CAAC,CAAC;QACjB,CAAC,CAAC;QACF,MAAM,IAAI,GAAG,CAAC,KAAc,EAAE,EAAE;YAC9B,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,OAAO,EAAE,CAAC;YACV,MAAM,CAAC,KAAK,CAAC,CAAC;QAChB,CAAC,CAAC;QACF,MAAM,KAAK,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAChD,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;QAChE,KAAK,GAAG,UAAU,CAChB,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,YAAY,CAAC,+BAA+B,EAAE,cAAc,CAAC,CAAC,EAC7E,SAAS,CACV,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IAC9B,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,48 @@
1
+ import type { FeedbackConversationBackend, FeedbackSubmissionBackend } from "./index.js";
2
+ interface FirestoreDocumentSnapshot {
3
+ readonly id: string;
4
+ readonly exists: boolean;
5
+ data(): unknown;
6
+ }
7
+ interface FirestoreQuerySnapshot {
8
+ readonly docs: readonly FirestoreDocumentSnapshot[];
9
+ }
10
+ interface FirestoreQuery {
11
+ orderBy(field: string, direction?: "asc" | "desc"): FirestoreQuery;
12
+ startAfter(value: unknown): FirestoreQuery;
13
+ limit(value: number): FirestoreQuery;
14
+ }
15
+ interface FirestoreDocumentReference {
16
+ readonly id: string;
17
+ collection(path: string): FirestoreCollectionReference;
18
+ }
19
+ interface FirestoreCollectionReference extends FirestoreQuery {
20
+ doc(id?: string): FirestoreDocumentReference;
21
+ }
22
+ interface FirestoreTransaction {
23
+ get(reference: FirestoreDocumentReference | FirestoreQuery): Promise<FirestoreDocumentSnapshot | FirestoreQuerySnapshot>;
24
+ set(reference: FirestoreDocumentReference, data: Readonly<Record<string, unknown>>): void;
25
+ update(reference: FirestoreDocumentReference, data: Readonly<Record<string, unknown>>): void;
26
+ }
27
+ export interface FirestoreFeedbackDatabase {
28
+ collection(path: string): FirestoreCollectionReference;
29
+ runTransaction<T>(work: (transaction: FirestoreTransaction) => Promise<T>): Promise<T>;
30
+ }
31
+ export interface FirestoreFeedbackOptions {
32
+ readonly database: FirestoreFeedbackDatabase;
33
+ readonly rootCollection?: string;
34
+ readonly outbox?: boolean;
35
+ }
36
+ export declare function createFirestoreFeedbackSubmissionBackend<Context = undefined>(options: FirestoreFeedbackOptions): FeedbackSubmissionBackend<Context>;
37
+ export declare function createFirestoreFeedbackBackend(options: FirestoreFeedbackOptions): FeedbackConversationBackend;
38
+ export declare function appendFirestoreTeamReply(options: FirestoreFeedbackOptions, command: Readonly<{
39
+ scope: string;
40
+ threadId: string;
41
+ message: string;
42
+ status?: string;
43
+ }>, context: Readonly<{
44
+ signal: AbortSignal;
45
+ deadlineMs: number;
46
+ }>): Promise<void>;
47
+ export {};
48
+ //# sourceMappingURL=firestore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"firestore.d.ts","sourceRoot":"","sources":["../src/firestore.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAGV,2BAA2B,EAC3B,yBAAyB,EAC1B,MAAM,YAAY,CAAC;AAGpB,UAAU,yBAAyB;IACjC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,IAAI,IAAI,OAAO,CAAC;CACjB;AAED,UAAU,sBAAsB;IAC9B,QAAQ,CAAC,IAAI,EAAE,SAAS,yBAAyB,EAAE,CAAC;CACrD;AAED,UAAU,cAAc;IACtB,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,cAAc,CAAC;IACnE,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,CAAC;IAC3C,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,CAAC;CACtC;AAED,UAAU,0BAA0B;IAClC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,4BAA4B,CAAC;CACxD;AAED,UAAU,4BAA6B,SAAQ,cAAc;IAC3D,GAAG,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,0BAA0B,CAAC;CAC9C;AAED,UAAU,oBAAoB;IAC5B,GAAG,CAAC,SAAS,EAAE,0BAA0B,GAAG,cAAc,GAAG,OAAO,CAAC,yBAAyB,GAAG,sBAAsB,CAAC,CAAC;IACzH,GAAG,CAAC,SAAS,EAAE,0BAA0B,EAAE,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAC1F,MAAM,CAAC,SAAS,EAAE,0BAA0B,EAAE,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;CAC9F;AAED,MAAM,WAAW,yBAAyB;IACxC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,4BAA4B,CAAC;IACvD,cAAc,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,oBAAoB,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CACxF;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,QAAQ,EAAE,yBAAyB,CAAC;IAC7C,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAgB,wCAAwC,CAAC,OAAO,GAAG,SAAS,EAC1E,OAAO,EAAE,wBAAwB,GAChC,yBAAyB,CAAC,OAAO,CAAC,CAoBpC;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,wBAAwB,GAChC,2BAA2B,CA6J7B;AAED,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,wBAAwB,EACjC,OAAO,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC,EACF,OAAO,EAAE,QAAQ,CAAC;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,GAC7D,OAAO,CAAC,IAAI,CAAC,CAsCf"}
@@ -0,0 +1,296 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { feedbackConversationSchema } from "./index.js";
4
+ import { beforeDeadline, deadlineSignal } from "./deadline.js";
5
+ export function createFirestoreFeedbackSubmissionBackend(options) {
6
+ return {
7
+ async submit(command, context) {
8
+ context.signal.throwIfAborted();
9
+ const id = randomUUID();
10
+ const recordedAt = new Date().toISOString();
11
+ const events = [event("feedback.message.added", id, id, "user", recordedAt)];
12
+ await runTransaction(options, context, async (transaction) => {
13
+ context.signal.throwIfAborted();
14
+ transaction.set(scopeRoot(options, context.scope).collection("submissions").doc(id), {
15
+ observation: command.observation,
16
+ detail: command.detail,
17
+ context: JSON.parse(JSON.stringify(command.context ?? null)),
18
+ recordedAt,
19
+ });
20
+ if (options.outbox)
21
+ writeOutbox(transaction, options, context.scope, events);
22
+ });
23
+ return { id, recordedAt, events };
24
+ },
25
+ };
26
+ }
27
+ export function createFirestoreFeedbackBackend(options) {
28
+ return {
29
+ async createThread(command, context) {
30
+ const threadId = randomUUID();
31
+ const messageId = randomUUID();
32
+ const now = new Date().toISOString();
33
+ const thread = {
34
+ id: threadId,
35
+ subject: command.subject,
36
+ status: "open",
37
+ createdAt: now,
38
+ updatedAt: now,
39
+ pageKey: pageKey(now, threadId),
40
+ latestSequence: 1,
41
+ };
42
+ const message = {
43
+ id: messageId,
44
+ threadId,
45
+ sequence: 1,
46
+ author: "user",
47
+ kind: "comment",
48
+ body: command.message,
49
+ createdAt: now,
50
+ };
51
+ const events = [
52
+ event("feedback.thread.created", threadId, undefined, "user", now),
53
+ event("feedback.message.added", threadId, messageId, "user", now),
54
+ ];
55
+ await runTransaction(options, context, async (transaction) => {
56
+ context.signal.throwIfAborted();
57
+ const reference = threads(options, context.scope).doc(threadId);
58
+ transaction.set(reference, thread);
59
+ transaction.set(reference.collection("messages").doc(messageId), message);
60
+ if (options.outbox)
61
+ writeOutbox(transaction, options, context.scope, events);
62
+ });
63
+ return { conversation: { ...publicThread(thread), messages: [message] }, events };
64
+ },
65
+ async appendMessage(command, context) {
66
+ return runTransaction(options, context, async (transaction) => {
67
+ context.signal.throwIfAborted();
68
+ const reference = threads(options, context.scope).doc(command.threadId);
69
+ const snapshot = document(await transaction.get(reference));
70
+ if (!snapshot.exists)
71
+ throw new Error("Feedback thread not found");
72
+ const thread = threadDocument.parse(snapshot.data());
73
+ const now = new Date().toISOString();
74
+ const message = {
75
+ id: randomUUID(),
76
+ threadId: command.threadId,
77
+ sequence: thread.latestSequence + 1,
78
+ author: "user",
79
+ kind: "comment",
80
+ body: command.message,
81
+ createdAt: now,
82
+ };
83
+ transaction.set(reference.collection("messages").doc(message.id), message);
84
+ transaction.update(reference, {
85
+ updatedAt: now,
86
+ pageKey: pageKey(now, command.threadId),
87
+ latestSequence: message.sequence,
88
+ });
89
+ const events = [event("feedback.message.added", command.threadId, message.id, "user", now)];
90
+ if (options.outbox)
91
+ writeOutbox(transaction, options, context.scope, events);
92
+ return { message, events };
93
+ });
94
+ },
95
+ async listThreads({ cursor, limit }, context) {
96
+ context.signal.throwIfAborted();
97
+ let query = threads(options, context.scope).orderBy("pageKey", "desc");
98
+ if (cursor)
99
+ query = query.startAfter(decodeCursor(cursor));
100
+ query = query.limit(limit + 1);
101
+ const snapshot = querySnapshot(await runTransaction(options, context, (transaction) => transaction.get(query)));
102
+ const rows = snapshot.docs.slice(0, limit + 1).map((item) => threadDocument.parse(item.data()));
103
+ const visible = rows.slice(0, limit);
104
+ const last = visible.at(-1);
105
+ return {
106
+ page: {
107
+ threads: visible.map(publicThread),
108
+ nextCursor: rows.length > limit && last ? encodeCursor(last.pageKey) : undefined,
109
+ },
110
+ };
111
+ },
112
+ async getThread(query, context) {
113
+ const conversation = await runTransaction(options, context, async (transaction) => {
114
+ context.signal.throwIfAborted();
115
+ const reference = threads(options, context.scope).doc(query.threadId);
116
+ const threadSnapshot = document(await transaction.get(reference));
117
+ if (!threadSnapshot.exists)
118
+ throw new Error("Feedback thread not found");
119
+ const thread = threadDocument.parse(threadSnapshot.data());
120
+ const messagesSnapshot = querySnapshot(await transaction.get(reference.collection("messages").orderBy("sequence", "asc").limit(200)));
121
+ const messages = messagesSnapshot.docs.map((item) => messageDocument.parse(item.data()));
122
+ return { ...publicThread(thread), messages };
123
+ });
124
+ feedbackConversationSchema.parse(conversation);
125
+ const pending = conversation.messages.filter((message) => message.author === "team" && !message.offeredToClientAt);
126
+ if (pending.length === 0)
127
+ return { conversation, events: [] };
128
+ try {
129
+ const receipt = await runTransaction(options, context, async (transaction) => {
130
+ const reference = threads(options, context.scope).doc(query.threadId);
131
+ const timestamps = new Map();
132
+ const unclaimed = [];
133
+ for (const message of pending) {
134
+ const messageReference = reference.collection("messages").doc(message.id);
135
+ const snapshot = document(await transaction.get(messageReference));
136
+ if (!snapshot.exists)
137
+ throw new Error("Feedback message not found");
138
+ const current = messageDocument.parse(snapshot.data());
139
+ if (current.offeredToClientAt) {
140
+ timestamps.set(message.id, current.offeredToClientAt);
141
+ continue;
142
+ }
143
+ unclaimed.push(message);
144
+ }
145
+ const offeredAt = new Date().toISOString();
146
+ const events = [];
147
+ for (const message of unclaimed) {
148
+ const messageReference = reference.collection("messages").doc(message.id);
149
+ transaction.update(messageReference, { offeredToClientAt: offeredAt });
150
+ timestamps.set(message.id, offeredAt);
151
+ events.push(event("feedback.message.offered-to-client", query.threadId, message.id, "team", offeredAt));
152
+ }
153
+ if (options.outbox)
154
+ writeOutbox(transaction, options, context.scope, events);
155
+ return { timestamps, events };
156
+ });
157
+ return {
158
+ conversation: {
159
+ ...conversation,
160
+ messages: conversation.messages.map((message) => receipt.timestamps.has(message.id)
161
+ ? { ...message, offeredToClientAt: receipt.timestamps.get(message.id) }
162
+ : message),
163
+ },
164
+ events: receipt.events,
165
+ };
166
+ }
167
+ catch {
168
+ return { conversation, events: [] };
169
+ }
170
+ },
171
+ };
172
+ }
173
+ export async function appendFirestoreTeamReply(options, command, context) {
174
+ const scope = z.string().min(1).max(240).parse(command.scope);
175
+ const body = z.string().min(1).max(4_000).parse(command.message);
176
+ await runTransaction(options, { ...context, scope }, async (transaction) => {
177
+ context.signal.throwIfAborted();
178
+ const reference = threads(options, scope).doc(command.threadId);
179
+ const snapshot = document(await transaction.get(reference));
180
+ if (!snapshot.exists)
181
+ throw new Error("Feedback thread not found");
182
+ const thread = threadDocument.parse(snapshot.data());
183
+ const now = new Date().toISOString();
184
+ const message = {
185
+ id: randomUUID(),
186
+ threadId: command.threadId,
187
+ sequence: thread.latestSequence + 1,
188
+ author: "team",
189
+ kind: "comment",
190
+ body,
191
+ createdAt: now,
192
+ };
193
+ transaction.set(reference.collection("messages").doc(message.id), message);
194
+ transaction.update(reference, {
195
+ status: command.status ?? thread.status,
196
+ updatedAt: now,
197
+ pageKey: pageKey(now, command.threadId),
198
+ latestSequence: message.sequence,
199
+ });
200
+ const events = [event("feedback.message.added", command.threadId, message.id, "team", now)];
201
+ if (command.status && command.status !== thread.status) {
202
+ events.push(event("feedback.status.changed", command.threadId, undefined, "team", now));
203
+ }
204
+ if (options.outbox)
205
+ writeOutbox(transaction, options, scope, events);
206
+ });
207
+ }
208
+ async function runTransaction(options, context, work) {
209
+ return beforeDeadline(options.database.runTransaction(async (transaction) => {
210
+ deadlineSignal(context);
211
+ const result = await work(transaction);
212
+ deadlineSignal(context);
213
+ return result;
214
+ }), context);
215
+ }
216
+ const threadDocument = z.object({
217
+ id: z.string(),
218
+ subject: z.string(),
219
+ status: z.string(),
220
+ createdAt: z.string(),
221
+ updatedAt: z.string(),
222
+ pageKey: z.string(),
223
+ latestSequence: z.number().int().positive(),
224
+ });
225
+ const messageDocument = z.object({
226
+ id: z.string(),
227
+ threadId: z.string(),
228
+ sequence: z.number().int().positive(),
229
+ author: z.enum(["user", "team", "system"]),
230
+ kind: z.enum(["comment", "question", "status"]),
231
+ body: z.string(),
232
+ createdAt: z.string(),
233
+ offeredToClientAt: z.string().optional(),
234
+ });
235
+ function scopeRoot(options, scope) {
236
+ return options.database.collection(options.rootCollection ?? "emseepeaFeedbackScopes").doc(scopeHash(scope));
237
+ }
238
+ function threads(options, scope) {
239
+ return scopeRoot(options, scope).collection("threads");
240
+ }
241
+ function publicThread(thread) {
242
+ return {
243
+ id: thread.id,
244
+ subject: thread.subject,
245
+ status: thread.status,
246
+ createdAt: thread.createdAt,
247
+ updatedAt: thread.updatedAt,
248
+ };
249
+ }
250
+ function writeOutbox(transaction, options, scope, events) {
251
+ const outbox = scopeRoot(options, scope).collection("outbox");
252
+ for (const item of events) {
253
+ transaction.set(outbox.doc(scopeHash(item.id)), Object.fromEntries(Object.entries({ ...item, scope }).filter(([, value]) => value !== undefined)));
254
+ }
255
+ }
256
+ function document(value) {
257
+ if ("docs" in value)
258
+ throw new Error("Firestore returned a query snapshot for a document");
259
+ return value;
260
+ }
261
+ function querySnapshot(value) {
262
+ if (!("docs" in value))
263
+ throw new Error("Firestore returned a document snapshot for a query");
264
+ return value;
265
+ }
266
+ function scopeHash(value) {
267
+ return createHash("sha256").update(value).digest("hex");
268
+ }
269
+ function pageKey(updatedAt, id) {
270
+ return `${updatedAt}|${id}`;
271
+ }
272
+ function encodeCursor(value) {
273
+ return Buffer.from(value).toString("base64url");
274
+ }
275
+ function decodeCursor(value) {
276
+ try {
277
+ const decoded = Buffer.from(value, "base64url").toString("utf8");
278
+ if (!/^\d{4}-\d{2}-\d{2}T.+\|.+$/.test(decoded))
279
+ throw new Error();
280
+ return decoded;
281
+ }
282
+ catch {
283
+ throw new Error("Invalid Firestore feedback cursor");
284
+ }
285
+ }
286
+ function event(type, threadId, messageId, author, occurredAt) {
287
+ return {
288
+ id: messageId ? `${type}:${messageId}` : `${type}:${threadId}:${occurredAt}`,
289
+ type,
290
+ occurredAt,
291
+ threadId,
292
+ messageId,
293
+ author,
294
+ };
295
+ }
296
+ //# sourceMappingURL=firestore.js.map