@ciromaciel/contract 1.3.0 → 1.4.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 +8 -0
- package/package.json +3 -2
- package/src/feedback.js +417 -0
package/README.md
CHANGED
|
@@ -141,6 +141,14 @@ import { OUTBOX_SQL, envelope, enqueueEvent, deliverDue, releaseHeld, retryDeliv
|
|
|
141
141
|
|
|
142
142
|
The delivery queue every product shares. Each event is a row in `outbox` before it leaves, one per subscribed endpoint, signed with `./webhooks`. A 2xx is `sent`; 401 and 403 are `held` (probed hourly, released at once by `releaseHeld` when the secret changes); 410 is `failed`; anything else retries after 1 min, 5 min, 30 min, 2 h and 12 h. `OUTBOX_SQL` is the schema to put in the product's first migration. The product supplies a D1-shaped `db` and `decryptSecret(ciphertext)`.
|
|
143
143
|
|
|
144
|
+
### `@ciromaciel/contract/feedback`
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { createFeedback, FEEDBACK_SQL } from '@ciromaciel/contract/feedback'
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
"Sugerir ou relatar", the corner button of every panel (`FeedbackWidget` in `@ciromaciel/dashboard-kit`), on the worker side. `createFeedback({ product, prefix, error, requirePerson, locate, fields })` returns `handleRoutes` (`GET`/`POST /api/feedback`, behind the login), `handleHooks` (the Tasks' signed answers at `/feedback/tasks` and the build job's news at `/feedback/build`) and `runCron` (resend what the Tasks did not take; resend and then report what the triage never took). A request carries `<prefix>-<6 hex>` (`DK-1A2B3C`) and its thread is `<product>:<ref>`. `FEEDBACK_SQL` is the table; the product adds its own columns (`deck_id`) and names them in `locate().columns`. Only a person sends, and `locate` names only the account's own things. Env: `FEEDBACK_URL`, `FEEDBACK_SECRET`, `TASKS_WEBHOOK_SECRET`, `BUILD_SECRET`.
|
|
151
|
+
|
|
144
152
|
## Links
|
|
145
153
|
|
|
146
154
|
- [Website](https://ciromaciel.click)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ciromaciel/contract",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A single response envelope, a closed catalogue of error codes, and helpers that make answering outside it impossible.",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"./plan": "./src/plan/index.js",
|
|
17
17
|
"./webhooks": "./src/webhooks.js",
|
|
18
18
|
"./token": "./src/token.js",
|
|
19
|
-
"./outbox": "./src/outbox.js"
|
|
19
|
+
"./outbox": "./src/outbox.js",
|
|
20
|
+
"./feedback": "./src/feedback.js"
|
|
20
21
|
},
|
|
21
22
|
"scripts": {
|
|
22
23
|
"test": "bun test tests/"
|
package/src/feedback.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @ciromaciel/contract/feedback — "Sugerir ou relatar", the same in every product
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// import { createFeedback, FEEDBACK_SQL } from '@ciromaciel/contract/feedback'
|
|
6
|
+
//
|
|
7
|
+
// const feedback = createFeedback({
|
|
8
|
+
// product: 'decks', prefix: 'DK',
|
|
9
|
+
// error: (code, message, details) => new ContractError(code, message, details),
|
|
10
|
+
// requirePerson: auth => requirePerson(auth, ContractError, CODES, 'Mandar uma sugestão'),
|
|
11
|
+
// locate: async ({ db, auth, body }) => ({ columns: { deck_id: … }, where: { deckTitle: … } }),
|
|
12
|
+
// fields: row => ({ deckId: row.deck_id }),
|
|
13
|
+
// })
|
|
14
|
+
//
|
|
15
|
+
// The panel's corner button (dashboard-kit FeedbackWidget) is the door to the improvement agent
|
|
16
|
+
// (manager/docs/2026-10-11-AGENTE-DE-MELHORIAS.html). Blogs was the pilot, 11/10/2026; this module
|
|
17
|
+
// is its routes/feedback.js with the product's pieces passed in, so the second product is a config
|
|
18
|
+
// and not a copy.
|
|
19
|
+
//
|
|
20
|
+
// POST /api/feedback { kind: problem|idea, happens, wish?, context, …the product's ids }
|
|
21
|
+
// GET /api/feedback the account's own, newest first, with where each one is
|
|
22
|
+
// POST /feedback/tasks the Tasks' signed request.created / request.answered (the way back)
|
|
23
|
+
// POST /feedback/build the build job's signed news: building, review (with the preview), live
|
|
24
|
+
//
|
|
25
|
+
// The person writes only what happens and what they would like. WHERE comes from the panel
|
|
26
|
+
// (`context`: the screen, the address, the browser, the window) and from the product (`locate`:
|
|
27
|
+
// the names of its own things, only when they are the account's): the agent starts knowing the
|
|
28
|
+
// place, not asking.
|
|
29
|
+
//
|
|
30
|
+
// The row is the record. When FEEDBACK_URL and FEEDBACK_SECRET are set (the Tasks' source
|
|
31
|
+
// "Melhorias dos produtos"), a signed `feedback.created` goes there after the answer; a failed
|
|
32
|
+
// send is tried again by the product's cron (`forwardPending`), and a feedback the triage never
|
|
33
|
+
// took is sent once more and then reported stuck (`watchTriage`). Only a person sends.
|
|
34
|
+
//
|
|
35
|
+
// ENV the product's worker reads: FEEDBACK_URL, FEEDBACK_SECRET (the source), TASKS_WEBHOOK_SECRET
|
|
36
|
+
// (the Tasks' outgoing webhook, the way back), BUILD_SECRET (the build job's news).
|
|
37
|
+
|
|
38
|
+
import { CODES } from './codes.js'
|
|
39
|
+
import { collection, created, ok, page } from './responses.js'
|
|
40
|
+
import { envelope, sendSigned } from './outbox.js'
|
|
41
|
+
import { verifyWebhook } from './webhooks.js'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The table every product keeps. A product adds its own columns for its things (Blogs: blog_id,
|
|
45
|
+
* post_id; Decks: deck_id) in its migration, and names them in `locate().columns`.
|
|
46
|
+
*/
|
|
47
|
+
export const FEEDBACK_SQL = `
|
|
48
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
account_id TEXT NOT NULL,
|
|
51
|
+
number INTEGER NOT NULL,
|
|
52
|
+
kind TEXT NOT NULL,
|
|
53
|
+
happens TEXT NOT NULL,
|
|
54
|
+
wish TEXT NOT NULL DEFAULT '',
|
|
55
|
+
context TEXT NOT NULL DEFAULT '{}',
|
|
56
|
+
reporter_name TEXT NOT NULL DEFAULT '',
|
|
57
|
+
reporter_email TEXT NOT NULL DEFAULT '',
|
|
58
|
+
status TEXT NOT NULL DEFAULT 'received',
|
|
59
|
+
status_note TEXT NOT NULL DEFAULT '',
|
|
60
|
+
forwarded_at INTEGER,
|
|
61
|
+
forward_status INTEGER,
|
|
62
|
+
retries INTEGER NOT NULL DEFAULT 0,
|
|
63
|
+
retried_at INTEGER,
|
|
64
|
+
stuck_at INTEGER,
|
|
65
|
+
created_at INTEGER NOT NULL,
|
|
66
|
+
updated_at INTEGER NOT NULL,
|
|
67
|
+
UNIQUE (account_id, number)
|
|
68
|
+
);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_account ON feedback (account_id, created_at DESC);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_forward ON feedback (forwarded_at, created_at);
|
|
71
|
+
`
|
|
72
|
+
|
|
73
|
+
export const FEEDBACK_KINDS = ['problem', 'idea']
|
|
74
|
+
|
|
75
|
+
export const FEEDBACK_STATUS_LABELS = {
|
|
76
|
+
received: 'Recebido',
|
|
77
|
+
triaged: 'Em análise',
|
|
78
|
+
approved: 'Aprovado',
|
|
79
|
+
building: 'Em construção',
|
|
80
|
+
review: 'Em revisão',
|
|
81
|
+
live: 'No ar',
|
|
82
|
+
declined: 'Não vamos fazer',
|
|
83
|
+
needs_info: 'Precisa de mais detalhes',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How long a feedback may sit in "Recebido" after the Tasks took it before the next step. */
|
|
87
|
+
export const STUCK_AFTER = 15 * 60
|
|
88
|
+
|
|
89
|
+
/** Requests about a feedback that are not its triage: their creation moves nothing. */
|
|
90
|
+
const FOLLOW_UP = / · (Revisar|A construção parou|A sugestão travou)\b/
|
|
91
|
+
|
|
92
|
+
/** The Ciro's choice in the Tasks, as the status the person sees. */
|
|
93
|
+
const OPTION_STATUS = { 'Aprovar e construir': 'approved', 'Pedir mais detalhes à pessoa': 'needs_info', 'Não vamos fazer': 'declined' }
|
|
94
|
+
|
|
95
|
+
/** The choices that start the build job: the first approval builds; "Tentar de novo" answers a build that stopped. */
|
|
96
|
+
const OPTION_JOB = { 'Aprovar e construir': 'build', 'Tentar de novo': 'build' }
|
|
97
|
+
const RESEND_OPTION = 'Mandar de novo ao agente'
|
|
98
|
+
|
|
99
|
+
/** The review's native answers (Tasks: kind review), as the job they start. */
|
|
100
|
+
const REVIEW_JOB = { approved: 'ship', changes: 'revise', refused: 'discard' }
|
|
101
|
+
|
|
102
|
+
const TRIAGE_PHASE = 'Triagem'
|
|
103
|
+
const BUILD_STATUSES = ['building', 'review', 'live', 'approved', 'declined']
|
|
104
|
+
const MAX_TEXT = 4000
|
|
105
|
+
const CONTEXT_KEYS = { screen: 80, section: 40, path: 500, url: 1000, viewport: 20, browser: 300, language: 20, panelVersion: 40, timezone: 60 }
|
|
106
|
+
const COLUMN = /^[a-z][a-z0-9_]{0,40}$/
|
|
107
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
108
|
+
|
|
109
|
+
const text = (value, max) =>
|
|
110
|
+
String(value ?? '')
|
|
111
|
+
.replace(/\r\n?/g, '\n')
|
|
112
|
+
.trim()
|
|
113
|
+
.slice(0, max)
|
|
114
|
+
const parse = (value, fallback) => {
|
|
115
|
+
try {
|
|
116
|
+
return value ? JSON.parse(value) : fallback
|
|
117
|
+
} catch {
|
|
118
|
+
return fallback
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const isOk = status => status >= 200 && status < 300
|
|
122
|
+
export const isUuid = value => typeof value === 'string' && UUID.test(value)
|
|
123
|
+
|
|
124
|
+
/** What the panel says about where it happened, kept to the known keys and sizes. */
|
|
125
|
+
export function cleanContext(value) {
|
|
126
|
+
const input = value && typeof value === 'object' ? value : {}
|
|
127
|
+
return Object.fromEntries(
|
|
128
|
+
Object.entries(CONTEXT_KEYS)
|
|
129
|
+
.map(([key, max]) => [key, text(input[key], max)])
|
|
130
|
+
.filter(([, v]) => v)
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The feedback of one product.
|
|
136
|
+
*
|
|
137
|
+
* product 'blogs' — the event's product, the thread's key (`blogs:BL-…`), the machines' groups
|
|
138
|
+
* prefix 'BL' — the code a request carries across systems: BL- and the first 6 hex of the id
|
|
139
|
+
* error (code, message, details) => Error the product's worker turns into its failure()
|
|
140
|
+
* requirePerson auth => void, throws when it is not a person (a key or an agent never sends)
|
|
141
|
+
* locate async ({ db, auth, body }) => { columns, where }: the product's columns of the row
|
|
142
|
+
* and the names added to the context; only what is the account's (never another's name)
|
|
143
|
+
* fields row => object: the product's ids in the API and in the events ({ blogId, postId })
|
|
144
|
+
* userAgent of the signed sends
|
|
145
|
+
* fetch for the tests; the global one by default
|
|
146
|
+
*/
|
|
147
|
+
export function createFeedback({ product, prefix, error, requirePerson, locate = async () => ({}), fields = () => ({}), userAgent = `ciromaciel-${product}/1.0`, fetch: fetchImpl }) {
|
|
148
|
+
if (!/^[a-z][a-z0-9-]{0,30}$/.test(String(product))) throw new Error('createFeedback: product is a lowercase key, like "decks".')
|
|
149
|
+
if (!/^[A-Z]{2,4}$/.test(String(prefix))) throw new Error('createFeedback: prefix is 2 to 4 capital letters, like "DK".')
|
|
150
|
+
const fail = (code, message, details) => error(code, message, details)
|
|
151
|
+
|
|
152
|
+
const refOf = id => `${prefix}-${String(id).replace(/-/g, '').slice(0, 6).toUpperCase()}`
|
|
153
|
+
const REF = new RegExp(`^${prefix}-([0-9A-F]{6})\\b`)
|
|
154
|
+
const WORK_KEY = new RegExp(`^${product}:(${prefix}-[0-9A-F]{6})$`)
|
|
155
|
+
const workKeyOf = id => `${product}:${refOf(id)}`
|
|
156
|
+
const byRef = (db, hex) => db.prepare('SELECT * FROM feedback WHERE id LIKE ?').bind(`${hex.toLowerCase()}%`).first()
|
|
157
|
+
|
|
158
|
+
function serialize(row) {
|
|
159
|
+
return {
|
|
160
|
+
id: row.id,
|
|
161
|
+
number: row.number,
|
|
162
|
+
ref: refOf(row.id),
|
|
163
|
+
kind: row.kind,
|
|
164
|
+
happens: row.happens,
|
|
165
|
+
wish: row.wish,
|
|
166
|
+
...fields(row),
|
|
167
|
+
context: parse(row.context, {}),
|
|
168
|
+
status: row.status,
|
|
169
|
+
statusLabel: FEEDBACK_STATUS_LABELS[row.status] ?? row.status,
|
|
170
|
+
statusNote: row.status_note,
|
|
171
|
+
createdAt: row.created_at,
|
|
172
|
+
updatedAt: row.updated_at,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The event the Tasks receives: everything the triage needs, in one piece. A resend is another event id. */
|
|
177
|
+
function eventOf(row, type = 'feedback.created', attempt = 0, extra = {}, eventId = null) {
|
|
178
|
+
return {
|
|
179
|
+
eventId: eventId ?? `${row.id}.${type}${attempt ? `.retry${attempt}` : ''}`,
|
|
180
|
+
body: envelope({
|
|
181
|
+
type,
|
|
182
|
+
at: row.created_at,
|
|
183
|
+
channel: 'panel',
|
|
184
|
+
person: { email: row.reporter_email || null, name: row.reporter_name || null },
|
|
185
|
+
properties: { product, id: row.id, ref: refOf(row.id), number: row.number, accountId: row.account_id, kind: row.kind, happens: row.happens, wish: row.wish, ...fields(row), ...parse(row.context, {}), ...extra },
|
|
186
|
+
}),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const isWired = env => Boolean(env.FEEDBACK_URL && env.FEEDBACK_SECRET)
|
|
191
|
+
|
|
192
|
+
/** One signed event to the Tasks' source; the HTTP status (0 when it did not answer). */
|
|
193
|
+
async function send(env, event) {
|
|
194
|
+
try {
|
|
195
|
+
const answer = await sendSigned({ url: env.FEEDBACK_URL, secret_ciphertext: env.FEEDBACK_SECRET }, event, { decryptSecret: async secret => secret, userAgent, ...(fetchImpl ? { fetch: fetchImpl } : {}) })
|
|
196
|
+
return answer?.status ?? 0
|
|
197
|
+
} catch {
|
|
198
|
+
return 0
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function forward(env, db, row, now) {
|
|
203
|
+
if (!isWired(env)) return false
|
|
204
|
+
const status = await send(env, eventOf(row))
|
|
205
|
+
await db
|
|
206
|
+
.prepare('UPDATE feedback SET forwarded_at = ?, forward_status = ? WHERE id = ?')
|
|
207
|
+
.bind(isOk(status) ? now : null, status, row.id)
|
|
208
|
+
.run()
|
|
209
|
+
return isOk(status)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Each status a feedback reaches, once (`feedback.status`): the Tasks' boards count the funnel from it. */
|
|
213
|
+
async function announceStatus(env, row, status, now) {
|
|
214
|
+
if (!isWired(env) || status === row.status) return
|
|
215
|
+
await send(env, eventOf({ ...row, created_at: now }, 'feedback.status', 0, { status, statusLabel: FEEDBACK_STATUS_LABELS[status] ?? status }, `${row.id}.feedback.status.${status}`))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function resend(env, db, row, now) {
|
|
219
|
+
const attempt = (row.retries ?? 0) + 1
|
|
220
|
+
const status = await send(env, eventOf(row, 'feedback.created', attempt))
|
|
221
|
+
if (isOk(status)) await db.prepare('UPDATE feedback SET retries = ?, retried_at = ?, stuck_at = NULL WHERE id = ?').bind(attempt, now, row.id).run()
|
|
222
|
+
return isOk(status)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The cron's watch over the triage: taken by the Tasks and still "Recebido", after 15 minutes
|
|
227
|
+
* it goes to the agent once more; 15 minutes after that, `feedback.stuck`, which a rule in the
|
|
228
|
+
* Tasks turns into a request to the Ciro ("A sugestão travou").
|
|
229
|
+
*/
|
|
230
|
+
async function watchTriage(env, db, now) {
|
|
231
|
+
if (!isWired(env)) return { resent: 0, stuck: 0 }
|
|
232
|
+
const { results } = await db
|
|
233
|
+
.prepare("SELECT * FROM feedback WHERE status = 'received' AND forwarded_at IS NOT NULL AND stuck_at IS NULL AND created_at > ? ORDER BY created_at LIMIT 20")
|
|
234
|
+
.bind(now - 3 * 86_400)
|
|
235
|
+
.all()
|
|
236
|
+
let resent = 0
|
|
237
|
+
let stuck = 0
|
|
238
|
+
for (const row of results) {
|
|
239
|
+
if (!row.retries && row.forwarded_at <= now - STUCK_AFTER) {
|
|
240
|
+
if (await resend(env, db, row, now)) resent++
|
|
241
|
+
} else if (row.retries && row.retried_at <= now - STUCK_AFTER) {
|
|
242
|
+
if (isOk(await send(env, eventOf(row, 'feedback.stuck', row.retries)))) {
|
|
243
|
+
await db.prepare('UPDATE feedback SET stuck_at = ? WHERE id = ?').bind(now, row.id).run()
|
|
244
|
+
stuck++
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { resent, stuck }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The cron's second chance: rows of the last 3 days the Tasks did not take yet. */
|
|
252
|
+
async function forwardPending(env, db, now) {
|
|
253
|
+
if (!isWired(env)) return 0
|
|
254
|
+
const { results } = await db
|
|
255
|
+
.prepare('SELECT * FROM feedback WHERE forwarded_at IS NULL AND created_at > ? ORDER BY created_at LIMIT 20')
|
|
256
|
+
.bind(now - 3 * 86_400)
|
|
257
|
+
.all()
|
|
258
|
+
let sent = 0
|
|
259
|
+
for (const row of results) if (await forward(env, db, row, now)) sent++
|
|
260
|
+
return sent
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Both cron chores at once, for the product's scheduled(). */
|
|
264
|
+
async function runCron(env, db, now) {
|
|
265
|
+
return { sent: await forwardPending(env, db, now), ...(await watchTriage(env, db, now)) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Starts the build job on the Tasks' machines: a signed `feedback.job` to the source, whose rule
|
|
270
|
+
* "Construir melhorias · <product>" runs the product's improvement job. `group` keeps two jobs of
|
|
271
|
+
* one group from running at once: the ships share one (two deploys never overlap), each
|
|
272
|
+
* improvement's build, revise and discard share another.
|
|
273
|
+
*/
|
|
274
|
+
async function startJob(env, job, row, props) {
|
|
275
|
+
if (!isWired(env)) return 0
|
|
276
|
+
const feedback = { id: row.id, ref: refOf(row.id), number: row.number, kind: row.kind, happens: row.happens, wish: row.wish, context: parse(row.context, {}) }
|
|
277
|
+
const group = job === 'ship' ? `${product}-deploy` : `${product}-${feedback.ref.toLowerCase()}`
|
|
278
|
+
const payload = { feedback, workKey: workKeyOf(row.id), requestId: String(props.requestId ?? ''), note: String(props.note ?? '').slice(0, 2000), group }
|
|
279
|
+
const event = {
|
|
280
|
+
eventId: `${row.id}.feedback.job.${job}.${payload.requestId || Date.now()}`,
|
|
281
|
+
body: envelope({ type: 'feedback.job', at: Math.floor(Date.now() / 1000), channel: 'panel', person: { email: null, name: null }, properties: { product, ref: feedback.ref, job, ...payload } }),
|
|
282
|
+
}
|
|
283
|
+
return send(env, event)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function readSigned(request, secret, maxBytes, now) {
|
|
287
|
+
const body = await request.text()
|
|
288
|
+
if (body.length > maxBytes) throw fail(CODES.PAYLOAD_TOO_LARGE, 'Evento grande demais.')
|
|
289
|
+
const check = await verifyWebhook([secret], { id: request.headers.get('x-ccm-id'), timestamp: request.headers.get('x-ccm-timestamp'), signature: request.headers.get('x-ccm-signature'), body }, now)
|
|
290
|
+
if (!check.ok) throw fail(CODES.UNAUTHORIZED, 'Assinatura inválida.')
|
|
291
|
+
return parse(body, {})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* POST /feedback/tasks — public, signed with TASKS_WEBHOOK_SECRET. A request whose thread key
|
|
296
|
+
* (or, before the threads, whose title code) names a feedback moves it: created in the triage →
|
|
297
|
+
* Em análise; answered → the option the Ciro chose; refused → Não vamos fazer. Any other request
|
|
298
|
+
* of the Ciro's Tasks is acknowledged and ignored.
|
|
299
|
+
*/
|
|
300
|
+
async function handleTasksHook({ request, env, db, pathname, method, now }) {
|
|
301
|
+
if (pathname !== '/feedback/tasks' || method !== 'POST') return null
|
|
302
|
+
if (!env.TASKS_WEBHOOK_SECRET) return null
|
|
303
|
+
const event = await readSigned(request, env.TASKS_WEBHOOK_SECRET, 64 * 1024, now)
|
|
304
|
+
const props = event?.data?.properties ?? {}
|
|
305
|
+
const fromWork = WORK_KEY.exec(String(props.workKey ?? ''))
|
|
306
|
+
const match = fromWork ? REF.exec(fromWork[1]) : REF.exec(String(props.title ?? ''))
|
|
307
|
+
if (!match || !['request.created', 'request.answered'].includes(event.type)) return ok({ accepted: 0 })
|
|
308
|
+
const row = await byRef(db, match[1])
|
|
309
|
+
if (!row) return ok({ accepted: 0 })
|
|
310
|
+
let status = row.status
|
|
311
|
+
let note = ''
|
|
312
|
+
let job = null
|
|
313
|
+
const isTriage = props.workKey ? props.phase === TRIAGE_PHASE : !FOLLOW_UP.test(String(props.title))
|
|
314
|
+
if (event.type === 'request.created' && row.status === 'received' && isTriage) status = 'triaged'
|
|
315
|
+
if (event.type === 'request.answered' && props.outcome === 'answered' && props.option === RESEND_OPTION) {
|
|
316
|
+
const sent = await resend(env, db, row, now)
|
|
317
|
+
return ok({ accepted: 1, status: row.status, resent: sent })
|
|
318
|
+
}
|
|
319
|
+
if (event.type === 'request.answered' && props.kind === 'review' && props.decision) {
|
|
320
|
+
// The review, answered natively: approve ships, changes rebuilds on the same branch with
|
|
321
|
+
// the note (meant for whoever redoes it, not for the person), refuse discards.
|
|
322
|
+
job = REVIEW_JOB[props.decision] ?? null
|
|
323
|
+
if (props.decision === 'refused') status = 'declined'
|
|
324
|
+
note = props.decision === 'changes' ? '' : String(props.note ?? '').slice(0, 500)
|
|
325
|
+
} else if (event.type === 'request.answered') {
|
|
326
|
+
if (props.outcome === 'refused') status = 'declined'
|
|
327
|
+
else if (props.outcome === 'answered') {
|
|
328
|
+
status = OPTION_STATUS[props.option] ?? status
|
|
329
|
+
job = OPTION_JOB[props.option] ?? null
|
|
330
|
+
}
|
|
331
|
+
note = String(props.note ?? '').slice(0, 500)
|
|
332
|
+
}
|
|
333
|
+
// "Pôr no ar" keeps "Em revisão" until the deploy is over: the job itself says "No ar".
|
|
334
|
+
const keepsNote = event.type !== 'request.answered' || props.decision === 'changes'
|
|
335
|
+
const isChanged = status !== row.status || (note && !keepsNote)
|
|
336
|
+
if (isChanged) {
|
|
337
|
+
await db
|
|
338
|
+
.prepare('UPDATE feedback SET status = ?, status_note = ?, updated_at = ? WHERE id = ?')
|
|
339
|
+
.bind(status, keepsNote ? row.status_note : note, now, row.id)
|
|
340
|
+
.run()
|
|
341
|
+
await announceStatus(env, row, status, now)
|
|
342
|
+
}
|
|
343
|
+
const dispatched = job ? await startJob(env, job, row, props) : null
|
|
344
|
+
if (!isChanged && !job) return ok({ accepted: 0 })
|
|
345
|
+
return ok({ accepted: 1, status, ...(job ? { job, dispatched } : {}) })
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** POST /feedback/build — public, signed with BUILD_SECRET: { ref, status, note? }, the job's news. */
|
|
349
|
+
async function handleBuildHook({ request, env, db, pathname, method, now }) {
|
|
350
|
+
if (pathname !== '/feedback/build' || method !== 'POST') return null
|
|
351
|
+
if (!env.BUILD_SECRET) return null
|
|
352
|
+
const input = await readSigned(request, env.BUILD_SECRET, 16 * 1024, now)
|
|
353
|
+
const match = REF.exec(String(input.ref ?? ''))
|
|
354
|
+
if (!match) throw fail(CODES.VALIDATION_ERROR, `Falta o código (${prefix}-…).`, { field: 'ref' })
|
|
355
|
+
if (!BUILD_STATUSES.includes(input.status)) throw fail(CODES.VALIDATION_ERROR, `O status é ${BUILD_STATUSES.join(', ')}.`, { field: 'status' })
|
|
356
|
+
const row = await byRef(db, match[1])
|
|
357
|
+
if (!row) return ok({ accepted: 0 })
|
|
358
|
+
await db
|
|
359
|
+
.prepare('UPDATE feedback SET status = ?, status_note = ?, updated_at = ? WHERE id = ?')
|
|
360
|
+
.bind(input.status, text(input.note, 500), now, row.id)
|
|
361
|
+
.run()
|
|
362
|
+
await announceStatus(env, row, input.status, now)
|
|
363
|
+
return ok({ accepted: 1, status: input.status })
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The open hooks, in one call for the product's public chain. */
|
|
367
|
+
async function handleHooks(context) {
|
|
368
|
+
return (await handleTasksHook(context)) ?? (await handleBuildHook(context))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** GET and POST /api/feedback, behind the product's login. */
|
|
372
|
+
async function handleRoutes(context) {
|
|
373
|
+
const { request, env, db, auth, pathname, method, now, ctx } = context
|
|
374
|
+
if (pathname !== '/api/feedback') return null
|
|
375
|
+
|
|
376
|
+
if (method === 'GET') {
|
|
377
|
+
const { results } = await db.prepare('SELECT * FROM feedback WHERE account_id = ? ORDER BY created_at DESC LIMIT 50').bind(auth.accountId).all()
|
|
378
|
+
return collection(results.map(serialize), page({ limit: 50, offset: 0, total: results.length }))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (method === 'POST') {
|
|
382
|
+
requirePerson(auth)
|
|
383
|
+
const body = await readBody(request)
|
|
384
|
+
const kind = String(body.kind ?? '')
|
|
385
|
+
if (!FEEDBACK_KINDS.includes(kind)) throw fail(CODES.VALIDATION_ERROR, 'Escolha se é um problema ou uma melhoria.', { field: 'kind' })
|
|
386
|
+
const happens = text(body.happens, MAX_TEXT)
|
|
387
|
+
if (happens.length < 5) throw fail(CODES.VALIDATION_ERROR, kind === 'problem' ? 'Conte o que aconteceu.' : 'Conte a sua ideia.', { field: 'happens' })
|
|
388
|
+
const wish = text(body.wish, MAX_TEXT)
|
|
389
|
+
const located = (await locate({ db, auth, body })) ?? {}
|
|
390
|
+
const columns = Object.entries(located.columns ?? {}).filter(([name]) => COLUMN.test(name))
|
|
391
|
+
const where = { ...cleanContext(body.context), ...(located.where ?? {}) }
|
|
392
|
+
const id = crypto.randomUUID()
|
|
393
|
+
const number = ((await db.prepare('SELECT MAX(number) AS n FROM feedback WHERE account_id = ?').bind(auth.accountId).first())?.n ?? 0) + 1
|
|
394
|
+
const names = ['id', 'account_id', 'number', ...columns.map(([name]) => name), 'kind', 'happens', 'wish', 'context', 'reporter_name', 'reporter_email', 'created_at', 'updated_at']
|
|
395
|
+
const values = [id, auth.accountId, number, ...columns.map(([, value]) => value ?? null), kind, happens, wish, JSON.stringify(where), text(auth.name, 120), text(auth.email, 200), now, now]
|
|
396
|
+
await db
|
|
397
|
+
.prepare(`INSERT INTO feedback (${names.join(', ')}) VALUES (${names.map(() => '?').join(', ')})`)
|
|
398
|
+
.bind(...values)
|
|
399
|
+
.run()
|
|
400
|
+
const row = await db.prepare('SELECT * FROM feedback WHERE id = ?').bind(id).first()
|
|
401
|
+
// To the Tasks after the answer: the person never waits for the triage's door.
|
|
402
|
+
if (ctx?.waitUntil) ctx.waitUntil(forward(env, db, row, now).catch(() => {}))
|
|
403
|
+
return created(serialize(row))
|
|
404
|
+
}
|
|
405
|
+
return null
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function readBody(request) {
|
|
409
|
+
const raw = await request.text()
|
|
410
|
+
if (raw.length > 64 * 1024) throw fail(CODES.PAYLOAD_TOO_LARGE, 'Texto grande demais.')
|
|
411
|
+
const body = parse(raw, null)
|
|
412
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw fail(CODES.VALIDATION_ERROR, 'O corpo é um objeto JSON.')
|
|
413
|
+
return body
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { product, prefix, refOf, workKeyOf, serialize, handleRoutes, handleHooks, handleTasksHook, handleBuildHook, forwardPending, watchTriage, runCron }
|
|
417
|
+
}
|