@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 +21 -0
- package/README.md +220 -0
- package/dist/deadline.d.ts +4 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +41 -0
- package/dist/deadline.js.map +1 -0
- package/dist/firestore.d.ts +48 -0
- package/dist/firestore.d.ts.map +1 -0
- package/dist/firestore.js +296 -0
- package/dist/firestore.js.map +1 -0
- package/dist/github.d.ts +12 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js +275 -0
- package/dist/github.js.map +1 -0
- package/dist/http.d.ts +8 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +59 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +328 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +255 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres.d.ts +32 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +352 -0
- package/dist/postgres.js.map +1 -0
- package/dist/testing-server.d.ts +2 -0
- package/dist/testing-server.d.ts.map +1 -0
- package/dist/testing-server.js +46 -0
- package/dist/testing-server.js.map +1 -0
- package/dist/webhooks.d.ts +22 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +142 -0
- package/dist/webhooks.js.map +1 -0
- package/dist/zendesk.d.ts +12 -0
- package/dist/zendesk.d.ts.map +1 -0
- package/dist/zendesk.js +286 -0
- package/dist/zendesk.js.map +1 -0
- package/package.json +71 -0
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"}
|
package/dist/deadline.js
ADDED
|
@@ -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
|