@supersuit/suggest-a-change 0.1.12 → 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/CHANGELOG.md +22 -1
- package/README.md +163 -8
- package/dist/client/adapters.d.ts +27 -0
- package/dist/client/context.d.ts +61 -0
- package/dist/client/context.js +279 -0
- package/dist/client/detail.d.ts +2 -2
- package/dist/client/detail.js +12 -8
- package/dist/client/entry.js +10 -5
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/client/mine.js +4 -2
- package/dist/client/runtime.js +3 -0
- package/dist/client/seen.d.ts +12 -0
- package/dist/client/seen.js +30 -7
- package/dist/client/sheet.js +64 -4
- package/dist/client/words.d.ts +10 -1
- package/dist/client/words.js +11 -2
- package/dist/contract.d.mts +32 -22
- package/dist/contract.mjs +127 -37
- package/dist/github/api.d.ts +100 -0
- package/dist/github/api.js +164 -0
- package/dist/github/compose.d.ts +152 -0
- package/dist/github/compose.js +309 -0
- package/dist/github/filer.d.ts +72 -0
- package/dist/github/filer.js +89 -0
- package/dist/github/hosts.d.ts +41 -0
- package/dist/github/hosts.js +154 -0
- package/dist/github/index.d.ts +12 -0
- package/dist/github/index.js +9 -0
- package/dist/github/privacy.d.ts +34 -0
- package/dist/github/privacy.js +142 -0
- package/dist/github/relay.d.ts +159 -0
- package/dist/github/relay.js +734 -0
- package/dist/server/geo.d.ts +17 -0
- package/dist/server/geo.js +35 -0
- package/dist/server/handlers.d.ts +54 -3
- package/dist/server/handlers.js +143 -41
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +1 -0
- package/package.json +5 -1
- package/src/client/adapters.ts +18 -1
- package/src/client/context.ts +243 -0
- package/src/client/detail.tsx +13 -8
- package/src/client/entry.tsx +9 -5
- package/src/client/index.ts +2 -0
- package/src/client/mine.tsx +7 -3
- package/src/client/runtime.ts +3 -0
- package/src/client/seen.ts +27 -6
- package/src/client/sheet.tsx +78 -4
- package/src/client/styles.css +11 -2
- package/src/client/words.ts +15 -2
- package/src/contract.mjs +110 -37
- package/src/github/api.ts +169 -0
- package/src/github/compose.ts +354 -0
- package/src/github/filer.ts +96 -0
- package/src/github/hosts.ts +117 -0
- package/src/github/index.ts +15 -0
- package/src/github/privacy.ts +131 -0
- package/src/github/relay.ts +637 -0
- package/src/server/geo.ts +32 -0
- package/src/server/handlers.ts +158 -45
- package/src/server/index.ts +3 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ROUGHLY WHERE THE SENDER WAS, from the request the receiver got, never from a browser prompt. The receiver (the
|
|
2
|
+
// server handlers, or a suggestion relay) adds it as one context item; a place the phone claims is dropped. Hosts put
|
|
3
|
+
// it in headers already, so a geo hook is one line:
|
|
4
|
+
//
|
|
5
|
+
// Vercel: (req) => ({ city: decodeURIComponent(req.headers.get("x-vercel-ip-city") ?? ""), region: req.headers.get("x-vercel-ip-country-region") ?? "", country: req.headers.get("x-vercel-ip-country") ?? "" })
|
|
6
|
+
// Cloudflare: (req) => ({ city: req.headers.get("cf-ipcity") ?? "", region: req.headers.get("cf-region") ?? "", country: req.headers.get("cf-ipcountry") ?? "" })
|
|
7
|
+
// (cf-ipcity and cf-region need Cloudflare's "Add visitor location headers" managed transform; cf-ipcountry is always there)
|
|
8
|
+
|
|
9
|
+
export type Geo = { city?: string; region?: string; country?: string }
|
|
10
|
+
export type GeoHook = (req: Request) => Geo | null | undefined | Promise<Geo | null | undefined>
|
|
11
|
+
export const PLACE_LABEL = 'Roughly where they were'
|
|
12
|
+
export const PLACE_WHY = 'from their internet connection, never their exact location; some problems only happen in one region'
|
|
13
|
+
|
|
14
|
+
/** The context item for a place from the request, or null when there is nothing to say. */
|
|
15
|
+
export function placeItem(g: Geo | null | undefined): { key: string; label: string; value: string; why: string } | null {
|
|
16
|
+
if (!g) return null
|
|
17
|
+
const value = [g.city, g.region, g.country].map((x) => (typeof x === 'string' ? x.trim().slice(0, 100) : '')).filter(Boolean).join(', ')
|
|
18
|
+
return value ? { key: 'place', label: PLACE_LABEL, value, why: PLACE_WHY } : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The context with the phone's own `place` dropped and the receiver's added; unchanged when there is neither. */
|
|
22
|
+
export async function withPlace(ctx: unknown, req: Request, geo: GeoHook | undefined): Promise<unknown> {
|
|
23
|
+
const has = Boolean(ctx && typeof ctx === 'object' && !Array.isArray(ctx))
|
|
24
|
+
const base: Record<string, unknown> = has ? { ...(ctx as Record<string, unknown>) } : {}
|
|
25
|
+
const items = Array.isArray(base.items) ? (base.items as { key?: unknown }[]).filter((i) => i?.key !== 'place') : undefined
|
|
26
|
+
let place: ReturnType<typeof placeItem> = null
|
|
27
|
+
try { place = geo ? placeItem(await geo(req)) : null } catch { place = null }
|
|
28
|
+
if (!has && !place) return ctx
|
|
29
|
+
const next = [...(items ?? []), ...(place ? [place] : [])].slice(0, 40)
|
|
30
|
+
if (next.length || items) base.items = next
|
|
31
|
+
return base
|
|
32
|
+
}
|
package/src/server/handlers.ts
CHANGED
|
@@ -7,19 +7,24 @@
|
|
|
7
7
|
// a report is decided here from the signed-in member and the explicit maintainers list, never from the
|
|
8
8
|
// request. Nothing is uploaded through this server: `uploadUrl` issues a direct-to-storage link with the
|
|
9
9
|
// size and type fixed, and `finalize` checks what actually landed.
|
|
10
|
-
import { END, FLOW, STATUSES, TOLD, isAcceptedMedia, isReportId, mayMove, meaningFor, validateReport, wordsFor } from '../contract.mjs'
|
|
10
|
+
import { END, FLOW, LEGACY, STATUSES, TOLD, currentStatus, isAcceptedMedia, isReportId, mayMove, meaningFor, validContact, validIssue, validateReport, wordsFor } from '../contract.mjs'
|
|
11
|
+
import { fileSuggestion, privacyCatch, type AttachmentHost, type Fetch, type Person } from '../github/index.ts'
|
|
11
12
|
import { effectiveLimits, type Limits } from './rate.ts'
|
|
12
13
|
import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.ts'
|
|
13
14
|
import { json, notFound, signIn, cappedText } from './http.ts'
|
|
15
|
+
import { withPlace, type GeoHook } from './geo.ts'
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
export interface IssueRef { repo: string; number: number; url: string }
|
|
18
|
+
/** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac filed or closed the issue, written on that
|
|
19
|
+
* entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. `issue` is
|
|
20
|
+
* written on the move to open. `flags` are the kinds the privacy catch found (never the words), on a held entry; the
|
|
21
|
+
* member's own view never shows them. */
|
|
22
|
+
export interface TimelineEntry { status: string; by: string; at: string; step?: string; note?: string; holder?: string; issue?: IssueRef; flags?: string[] }
|
|
18
23
|
export interface FileState { pending?: { type: string; bytes: number; issuedAt?: number }; type?: string; bytes?: number; sha256?: string; at?: string }
|
|
19
24
|
export interface FollowUp { followId: string; words: string; at: string; by: string; voice: { file: string; ms: number }[] }
|
|
20
25
|
export interface StoredReport {
|
|
21
26
|
id: string
|
|
22
|
-
who: { member: string; role: string }
|
|
27
|
+
who: { member: string; role: string; contact?: { email?: string; phone?: string; handle?: string } }
|
|
23
28
|
timeline: TimelineEntry[]
|
|
24
29
|
receivedAt: string
|
|
25
30
|
/** Server-side state per attachment: the upload link that was issued, and what finalize verified. */
|
|
@@ -88,7 +93,22 @@ export interface TellEvent { type: 'submitted' | 'followed-up' | 'status' | 'set
|
|
|
88
93
|
export { TOLD }
|
|
89
94
|
/** What `tellSubmitter` is handed. `words` and `meaning` are the module's own words for the status (`wordsFor`/`meaningFor`),
|
|
90
95
|
* never the report's text, so a lock screen never shows what someone wrote. `note` is the one-line reason, on `not-now` only. */
|
|
91
|
-
export interface SubmitterNews { member: string; reportId: string; status: string; words: string; meaning: string; ended: boolean; note?: string }
|
|
96
|
+
export interface SubmitterNews { member: string; reportId: string; status: string; words: string; meaning: string; ended: boolean; note?: string; /** The issue it became, so a push can link it. */ issue?: IssueRef }
|
|
97
|
+
/** Filing straight from the app's server: each complete report becomes an issue, with no Mac in the loop. */
|
|
98
|
+
export interface GitHubFiling {
|
|
99
|
+
/** owner/name of the repo the issues go to. */
|
|
100
|
+
repo: string
|
|
101
|
+
/** A token that can open issues there (and push, when `attachments` is a branchHost with it). */
|
|
102
|
+
token: string
|
|
103
|
+
/** Where screenshots and recordings go. Without one they are listed as still being added. */
|
|
104
|
+
attachments?: AttachmentHost
|
|
105
|
+
/** Third parties the privacy catch looks for (anyone but the sender and the owner). */
|
|
106
|
+
people?: Person[]
|
|
107
|
+
/** The sender's name as the issue says it; without it the issue says "A member". Never their key or email. */
|
|
108
|
+
nameOf?(member: string): string | undefined | Promise<string | undefined>
|
|
109
|
+
/** For GitHub calls and for reading the stored files back through their read links. */
|
|
110
|
+
fetch?: Fetch
|
|
111
|
+
}
|
|
92
112
|
export interface ReportHandlerOptions {
|
|
93
113
|
store: HostedStore
|
|
94
114
|
/** The signed-in member, or null. Anything but `account` is ignored: the role is decided here. */
|
|
@@ -109,6 +129,17 @@ export interface ReportHandlerOptions {
|
|
|
109
129
|
* and never to a maintainer about their own report being made. A failure never fails the move. An app that genuinely
|
|
110
130
|
* has nobody to tell passes `{ none: "<reason>" }`; leaving it out throws at startup. */
|
|
111
131
|
tellSubmitter: ((news: SubmitterNews) => void | Promise<void>) | { none: string }
|
|
132
|
+
/** When given, a report is filed as an issue the moment it is complete: at submit when it has no files, else when its
|
|
133
|
+
* last file is verified. A privacy hit holds it for the owner instead; GitHub failing leaves it received for a Mac. */
|
|
134
|
+
github?: GitHubFiling
|
|
135
|
+
/** Roughly where the sender was, from the request (never a browser prompt). Added to the report's context as one item;
|
|
136
|
+
* a place the phone claims is dropped. On Vercel: `(req) => ({ city: decodeURIComponent(req.headers.get('x-vercel-ip-city') ?? ''),
|
|
137
|
+
* region: req.headers.get('x-vercel-ip-country-region') ?? '', country: req.headers.get('x-vercel-ip-country') ?? '' })`. */
|
|
138
|
+
geo?: GeoHook
|
|
139
|
+
/** How to reach a member, from what the app knows of them. The phone only says whether to share it (by sending
|
|
140
|
+
* `who.contact`, which it does while the person leaves it ticked); what it sent there is ignored. Without this, no
|
|
141
|
+
* report carries a contact. A contact of the wrong shape is dropped. */
|
|
142
|
+
contactOf?(member: string): { email?: string; phone?: string; handle?: string } | null | undefined | Promise<{ email?: string; phone?: string; handle?: string } | null | undefined>
|
|
112
143
|
now?: () => number
|
|
113
144
|
}
|
|
114
145
|
|
|
@@ -128,7 +159,9 @@ export const VOICE_BYTES_PER_SECOND = 200_000
|
|
|
128
159
|
export const INCOMPLETE_GRACE_MS = 60 * 60_000
|
|
129
160
|
const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
|
|
130
161
|
const FOLLOW = /^[A-Za-z0-9_-]{1,64}$/
|
|
131
|
-
const FORBIDDEN = ['timeline', 'status', 'plan', 'decision', 'files', 'receivedAt', 'followUps', 'claimedBy', 'settledBy']
|
|
162
|
+
const FORBIDDEN = ['timeline', 'status', 'plan', 'decision', 'issue', 'files', 'receivedAt', 'followUps', 'claimedBy', 'settledBy']
|
|
163
|
+
/** Every stored status that means closed, old ones included: what a store's atomic follow-up write refuses on. */
|
|
164
|
+
const CLOSED_STORED = [...END, ...Object.keys(LEGACY).filter((k) => END.includes(LEGACY[k as keyof typeof LEGACY]))]
|
|
132
165
|
|
|
133
166
|
const bad = (error: string) => json(400, { error })
|
|
134
167
|
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -146,10 +179,17 @@ async function readBody(req: Request, cap = BODY_CAP, optional = false): Promise
|
|
|
146
179
|
}
|
|
147
180
|
|
|
148
181
|
const iso = (ms: number) => new Date(ms).toISOString()
|
|
182
|
+
/** The status as stored (a store's precondition compares this one). */
|
|
149
183
|
const lastStatus = (r: StoredReport) => r.timeline[r.timeline.length - 1]?.status ?? 'received'
|
|
150
|
-
/**
|
|
151
|
-
const
|
|
152
|
-
/**
|
|
184
|
+
/** The status it means now: an old (0.1.x) status folded into the new one. Every decision below reads this. */
|
|
185
|
+
const cur = (r: StoredReport) => currentStatus(lastStatus(r))
|
|
186
|
+
/** Filed or closed: whoever holds the approval keeps it. */
|
|
187
|
+
const FILED = [...TOLD]
|
|
188
|
+
const issueOf = (r: StoredReport): IssueRef | undefined => {
|
|
189
|
+
for (let i = r.timeline.length - 1; i >= 0; i--) if (r.timeline[i].issue) return r.timeline[i].issue
|
|
190
|
+
return undefined
|
|
191
|
+
}
|
|
192
|
+
/** The maintainer named on the latest move that filed or closed it (or an old one that started or ended the work). */
|
|
153
193
|
const holderOf = (r: StoredReport): string | undefined => {
|
|
154
194
|
for (let i = r.timeline.length - 1; i >= 0; i--) if (r.timeline[i].holder) return r.timeline[i].holder
|
|
155
195
|
return undefined
|
|
@@ -207,6 +247,8 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
207
247
|
...(r.answers !== undefined ? { answers: r.answers } : {}),
|
|
208
248
|
...(r.app !== undefined ? { app: r.app } : {}),
|
|
209
249
|
where: r.where, diagnostics: r.diagnostics, media: r.media ?? [], voice: r.voice ?? [],
|
|
250
|
+
...(issueOf(r) ? { issue: issueOf(r) } : {}),
|
|
251
|
+
...(r.context !== undefined ? { context: r.context } : {}),
|
|
210
252
|
timeline: (r.timeline ?? []).map((t) => ({ status: t.status, by: t.by, at: t.at, ...(t.step ? { step: t.step } : {}), ...(t.note ? { note: t.note } : {}) })),
|
|
211
253
|
followUps: (r.followUps ?? []).map((f) => ({ followId: f.followId, words: f.words, at: f.at, by: f.by, voice: f.voice })),
|
|
212
254
|
})
|
|
@@ -263,9 +305,16 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
263
305
|
else return bad('diagnostics values are short text')
|
|
264
306
|
}
|
|
265
307
|
}
|
|
308
|
+
// How to reach the sender comes from the host, never the phone: the phone only says whether to share it.
|
|
309
|
+
const asked = isObj(raw.who) && raw.who.contact !== undefined
|
|
310
|
+
let contact: unknown
|
|
311
|
+
try { contact = asked && opts.contactOf ? await opts.contactOf(member) : undefined } catch { contact = undefined }
|
|
312
|
+
if (contact !== undefined && (contact === null || validContact(contact).length)) contact = undefined
|
|
313
|
+
const context = raw.context === undefined && !opts.geo ? undefined : await withPlace(raw.context, req, opts.geo)
|
|
266
314
|
const report: Record<string, unknown> = {
|
|
267
315
|
id: raw.id, kind: raw.kind, words, media, voice: v.voice,
|
|
268
|
-
who: { member, role: roleOf(member) },
|
|
316
|
+
who: { member, role: roleOf(member), ...(contact !== undefined ? { contact } : {}) },
|
|
317
|
+
...(context !== undefined ? { context } : {}),
|
|
269
318
|
...(answers ? { answers } : {}),
|
|
270
319
|
app: { ...(opts.app ?? {}) },
|
|
271
320
|
where, diagnostics,
|
|
@@ -275,7 +324,7 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
275
324
|
const id = report.id as string
|
|
276
325
|
const existing = await store.get(id)
|
|
277
326
|
// The same member sending the same id again is a repeat, even at the day's limit: it was stored.
|
|
278
|
-
if (existing?.who?.member === member) return json(200, { id, status:
|
|
327
|
+
if (existing?.who?.member === member) return json(200, { id, status: cur(existing) })
|
|
279
328
|
if ((await store.countToday(member)) >= limits.perPersonPerDay) return json(429, { error: 'you have reached today\'s limit of suggestions' })
|
|
280
329
|
const at = iso(now())
|
|
281
330
|
const made = await store.put(
|
|
@@ -287,10 +336,13 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
287
336
|
// Lost a race with the same member (a repeat), or the id belongs to someone else. Someone else's id is REFUSED
|
|
288
337
|
// (not re-issued, not dropped silently): nothing is created or overwritten, and the phone is told to start again.
|
|
289
338
|
const now2 = await store.get(id)
|
|
290
|
-
return now2?.who?.member === member ? json(200, { id, status:
|
|
339
|
+
return now2?.who?.member === member ? json(200, { id, status: cur(now2) }) : json(409, { error: 'that suggestion cannot use this id; start it again' })
|
|
291
340
|
}
|
|
292
341
|
await tell({ type: 'submitted', reportId: id, member })
|
|
293
|
-
|
|
342
|
+
// Nothing to wait for: with the github option it becomes an issue now.
|
|
343
|
+
if (opts.github && !fileNames(report as unknown as StoredReport).length) await fileNow(id)
|
|
344
|
+
const after = await store.get(id)
|
|
345
|
+
return json(200, { id, status: after ? cur(after) : 'received' })
|
|
294
346
|
}
|
|
295
347
|
|
|
296
348
|
async function mine(req: Request): Promise<Response> {
|
|
@@ -300,8 +352,8 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
300
352
|
const rows = (await store.listFor(member)).filter((r) => r.who?.member === member)
|
|
301
353
|
rows.sort((a, b) => (a.receivedAt < b.receivedAt ? 1 : -1))
|
|
302
354
|
// A declined report carries its one-line reason, which the member's own view already shows; no other note is listed.
|
|
303
|
-
const reason = (r: StoredReport) => { const t = r.timeline[r.timeline.length - 1]; return
|
|
304
|
-
return json(200, rows.map((r) => ({ id: r.id, status:
|
|
355
|
+
const reason = (r: StoredReport) => { const t = r.timeline[r.timeline.length - 1]; return cur(r) === 'not-planned' && t?.note ? { note: t.note } : {} }
|
|
356
|
+
return json(200, rows.map((r) => ({ id: r.id, status: cur(r), kind: r.kind, at: Date.parse(r.receivedAt), ...reason(r), ...(issueOf(r) ? { issue: issueOf(r) } : {}) })))
|
|
305
357
|
}
|
|
306
358
|
|
|
307
359
|
async function one(req: Request, id: string): Promise<Response> {
|
|
@@ -324,11 +376,11 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
324
376
|
// a maintainer speaks to the member through status notes.
|
|
325
377
|
const r = await readable(id, member, { ownerOnly: true }); if (!r) return notFound()
|
|
326
378
|
if ((r.followUps ?? []).some((f) => f.followId === followId)) return json(200, { id, followId }) // a repeat
|
|
327
|
-
if (END.includes(
|
|
379
|
+
if (END.includes(cur(r))) return json(409, { error: 'this suggestion is closed; start a new one' })
|
|
328
380
|
const v = voiceEntries(raw.voice, limits.followUpVoiceNotes, new Set(fileNames(r))); if ('error' in v) return bad(v.error)
|
|
329
381
|
// The caps are enforced again inside the store's atomic write: the read above is only a fast path.
|
|
330
382
|
const added = await store.addFollowUp(id, { followId, words, at: iso(now()), by: roleOf(member), voice: v.voice.map(({ file, ms }) => ({ file, ms })) },
|
|
331
|
-
{ max: limits.followUps, maxVoice: limits.followUps * limits.followUpVoiceNotes, voiceTotalMs: limits.voiceTotalMs, closed:
|
|
383
|
+
{ max: limits.followUps, maxVoice: limits.followUps * limits.followUpVoiceNotes, voiceTotalMs: limits.voiceTotalMs, closed: CLOSED_STORED })
|
|
332
384
|
if (added === 'missing') return notFound()
|
|
333
385
|
if (added === 'closed') return json(409, { error: 'this suggestion is closed; start a new one' })
|
|
334
386
|
if (added === 'full') return bad('this report has all the follow-ups or voice it can take')
|
|
@@ -388,6 +440,7 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
388
440
|
const verified: FileState = { type: real.type as string, bytes: real.bytes, sha256: real.sha256, at: iso(now()) }
|
|
389
441
|
await store.setFile(id, file, verified)
|
|
390
442
|
await store.reopen(id) // verified after a Mac collected the report: it is offered again with this file
|
|
443
|
+
if (opts.github) await fileNow(id) // its last file: with the github option it becomes an issue now
|
|
391
444
|
return json(200, { file, type: verified.type, bytes: verified.bytes, sha256: verified.sha256 })
|
|
392
445
|
}
|
|
393
446
|
|
|
@@ -406,7 +459,7 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
406
459
|
let settled = 0
|
|
407
460
|
for (const r of batch) {
|
|
408
461
|
if (out.length >= limits.agentPage) break
|
|
409
|
-
if (END.includes(
|
|
462
|
+
if (END.includes(cur(r)) && holderOf(r) !== by) {
|
|
410
463
|
await store.claim(r.id, by, LEASE_MS)
|
|
411
464
|
if ((await store.settle(r.id, by, reportRevision(r))) === 'settled') settled++
|
|
412
465
|
continue
|
|
@@ -419,9 +472,9 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
419
472
|
if (!settled || batch.length < limits.agentPage * 3) break
|
|
420
473
|
}
|
|
421
474
|
// `revision` is what this Mac is being shown; it goes back with the settle so a report that grew in between is not settled.
|
|
422
|
-
// `heldBy` names another maintainer whose Mac
|
|
475
|
+
// `heldBy` names another maintainer whose Mac filed the issue, so this Mac leaves it to that one.
|
|
423
476
|
return json(200, { reports: out.map((r) => {
|
|
424
|
-
const holder =
|
|
477
|
+
const holder = cur(r) === 'open' ? holderOf(r) : undefined
|
|
425
478
|
return { ...r, revision: reportRevision(r), ...(holder && holder !== by ? { heldBy: holder } : {}) }
|
|
426
479
|
}) })
|
|
427
480
|
}
|
|
@@ -448,22 +501,22 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
448
501
|
const settled = await store.settle(id, by, revision)
|
|
449
502
|
if (settled === 'unclaimed') return json(409, { error: 'this report was not claimed by this Mac' })
|
|
450
503
|
if (settled === 'stale') return json(409, { error: 'this report changed after it was listed; list again', stale: true })
|
|
451
|
-
|
|
452
|
-
if (moves) await store.setStatus(id, { status: 'with-freeda', by: FLOW['with-freeda'].actor, at: iso(now()) }, lastStatus(r))
|
|
504
|
+
// Collecting is not filing: the report stays received until the Mac files the issue or holds it.
|
|
453
505
|
await store.moveCollected(id)
|
|
454
506
|
await tell({ type: 'settled', reportId: id })
|
|
455
|
-
return json(200, { id, status:
|
|
507
|
+
return json(200, { id, status: cur(r) })
|
|
456
508
|
}
|
|
457
509
|
|
|
458
|
-
/** Takes (or renews) the approval for `by`. A closed report is never taken,
|
|
459
|
-
*
|
|
460
|
-
*
|
|
510
|
+
/** Takes (or renews) the approval for `by`. A closed report is never taken, except to reopen it by the Mac that
|
|
511
|
+
* filed it (or by anyone when nobody is named); otherwise it answers who closed it. An open issue stays with the Mac
|
|
512
|
+
* that filed it. Taken for filing or closing, or on an open issue, the lease is HELD_MS, so it does not lapse; before
|
|
513
|
+
* that, a week. Writes nothing when refused. */
|
|
461
514
|
async function takeApproval(r: StoredReport, by: string, to?: string): Promise<{ ok: true } | { closed: string; claimedBy?: string } | { claimedBy: string }> {
|
|
462
|
-
const from =
|
|
515
|
+
const from = cur(r)
|
|
463
516
|
const holder = holderOf(r)
|
|
464
|
-
if (END.includes(from)) return { closed: from, ...(holder ? { claimedBy: holder } : {}) }
|
|
465
|
-
if (from === '
|
|
466
|
-
const lasting = from
|
|
517
|
+
if (END.includes(from) && !(to === 'open' && (!holder || holder === by))) return { closed: from, ...(holder ? { claimedBy: holder } : {}) }
|
|
518
|
+
if (from === 'open' && holder && holder !== by) return { claimedBy: holder }
|
|
519
|
+
const lasting = FILED.includes(from) || (to !== undefined && FILED.includes(to))
|
|
467
520
|
return store.claimApproval(r.id, by, lasting ? HELD_MS : APPROVAL_LEASE_MS)
|
|
468
521
|
}
|
|
469
522
|
|
|
@@ -495,34 +548,94 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
495
548
|
const step = got.body.step === undefined ? undefined : str(got.body.step, 200)
|
|
496
549
|
const note = got.body.note === undefined ? undefined : str(got.body.note, 200)
|
|
497
550
|
if (step === null || note === null) return bad('step and note are short text')
|
|
551
|
+
const rawIssue = got.body.issue
|
|
552
|
+
if (rawIssue !== undefined && validIssue(rawIssue).length) return bad(`the issue is not valid: ${validIssue(rawIssue).join('; ')}`)
|
|
553
|
+
const given = rawIssue as IssueRef | undefined
|
|
498
554
|
const r = isReportId(id) ? await store.get(id) : null
|
|
499
555
|
if (!r) return notFound()
|
|
500
|
-
const from =
|
|
556
|
+
const from = cur(r)
|
|
501
557
|
if (from === status) return json(200, { id, status }) // a repeat
|
|
502
558
|
if (!mayMove(from, status)) return json(409, { error: `a report cannot go from ${from} to ${status}` })
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
559
|
+
const issue = given ? { repo: given.repo, number: given.number, url: given.url } : undefined
|
|
560
|
+
if (status === 'open' && !issue && !issueOf(r)) return bad('a move to open names the issue it became: { repo, number, url }')
|
|
561
|
+
// Once it is held for the owner, the Mac that holds the approval decides what happens next. Leaving held,
|
|
562
|
+
// filing and closing need the claim (taken here if nobody holds it).
|
|
563
|
+
const needsApproval = from === 'held' || FILED.includes(status)
|
|
506
564
|
if (needsApproval) {
|
|
507
565
|
const c = await takeApproval(r, by, status)
|
|
508
566
|
if (!('ok' in c)) return json(409, { error: 'another Mac holds the approval for this report', ...(c.claimedBy ? { claimedBy: c.claimedBy } : {}) })
|
|
509
567
|
}
|
|
510
|
-
// The maintainer is named on a move that
|
|
511
|
-
const holder =
|
|
512
|
-
|
|
568
|
+
// The maintainer is named on a move that files or closes it, so the hold is known from the report itself.
|
|
569
|
+
const holder = FILED.includes(status) ? { holder: by } : {}
|
|
570
|
+
// Declined before it was ever filed is the owner's decision, not GitHub's.
|
|
571
|
+
const actor = status === 'not-planned' && from !== 'open' ? 'owner' : FLOW[status as keyof typeof FLOW].actor
|
|
572
|
+
const moved = await store.setStatus(id, { status, by: actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}), ...holder, ...(issue ? { issue } : {}) }, lastStatus(r))
|
|
513
573
|
if (!moved) {
|
|
514
574
|
// The lasting lease was taken for a move that did not happen: bring it back to a week.
|
|
515
|
-
if (needsApproval && from
|
|
575
|
+
if (needsApproval && !FILED.includes(from) && FILED.includes(status)) await store.claimApproval(id, by, APPROVAL_LEASE_MS)
|
|
516
576
|
return json(409, { error: 'the report changed while this was sent; read it again' })
|
|
517
577
|
}
|
|
518
|
-
|
|
578
|
+
// A maintainer is not told that the issue they just filed from their own report exists; a close is still news.
|
|
579
|
+
await moved_(r, status, { by, note, issue: issue ?? issueOf(r) })
|
|
580
|
+
return json(200, { id, status })
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** After a move landed: the ids-only tell, and the person who suggested it on a telling move. */
|
|
584
|
+
async function moved_(r: StoredReport, status: string, { by, note, issue }: { by?: string; note?: string; issue?: IssueRef }) {
|
|
519
585
|
const member = r.who?.member
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
if (member && TOLD.includes(status) && !
|
|
523
|
-
await news({ member, reportId: id, status, words: wordsFor(status, { owner }), meaning: meaningFor(status, { owner }), ended: END.includes(status), ...(status === 'not-
|
|
586
|
+
await tell({ type: 'status', reportId: r.id, member, status })
|
|
587
|
+
const ownFiling = status === 'open' && member === by
|
|
588
|
+
if (member && TOLD.includes(status) && !ownFiling) {
|
|
589
|
+
await news({ member, reportId: r.id, status, words: wordsFor(status, { owner }), meaning: meaningFor(status, { owner, issue }), ended: END.includes(status), ...(status === 'not-planned' && note ? { note } : {}), ...(issue ? { issue } : {}) })
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** FILING FROM THE APP'S SERVER (the github option). Runs once a report is complete. One caller files it: the approval
|
|
594
|
+
* is taken atomically under a one-time name, the report is read again under it, and a second caller finds it taken
|
|
595
|
+
* or already filed. A privacy hit holds it for the owner (the kinds on the entry, never the words); GitHub failing
|
|
596
|
+
* leaves it received and the approval free, so a maintainer's Mac can file it. Never fails the person's request. */
|
|
597
|
+
async function fileNow(id: string): Promise<void> {
|
|
598
|
+
const g = opts.github
|
|
599
|
+
if (!g) return
|
|
600
|
+
const first = await store.get(id)
|
|
601
|
+
if (!first || cur(first) !== 'received' || !complete(first)) return
|
|
602
|
+
const lock = `@app:${Math.random().toString(36).slice(2, 10)}`
|
|
603
|
+
const got = await store.claimApproval(id, lock, HELD_MS)
|
|
604
|
+
if (!('ok' in got)) return
|
|
605
|
+
try {
|
|
606
|
+
const r = await store.get(id)
|
|
607
|
+
if (!r || cur(r) !== 'received' || !complete(r)) return
|
|
608
|
+
const report = { id: r.id, kind: r.kind as string, words: (r.words as string) ?? '', who: r.who, voice: (r.voice as { file: string; ms: number }[]) ?? [], media: (r.media as { file: string; type: string; bytes: number }[]) ?? [], where: r.where as Record<string, string>, diagnostics: r.diagnostics as Record<string, string>, followUps: r.followUps ?? [], ...(r.context ? { context: r.context as never } : {}) }
|
|
609
|
+
const { hits } = privacyCatch(report, { people: g.people ?? [], secrets: [r.who.member, ...maintainers] })
|
|
610
|
+
if (hits.length) {
|
|
611
|
+
const flags = [...new Set(hits.map((h) => h.kind))]
|
|
612
|
+
await store.setStatus(id, { status: 'held', by: FLOW.held.actor, at: iso(now()), flags }, lastStatus(r))
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
const f = g.fetch ?? globalThis.fetch
|
|
616
|
+
const files = []
|
|
617
|
+
if (g.attachments) {
|
|
618
|
+
for (const name of fileNames(r)) {
|
|
619
|
+
const v = r.files?.[name]
|
|
620
|
+
if (!v?.sha256) continue
|
|
621
|
+
const link = await store.readUrl({ reportId: id, file: name, ttlMs: READ_TTL_MS })
|
|
622
|
+
const res = await f(link.url)
|
|
623
|
+
if (!res.ok) continue // left as still being added
|
|
624
|
+
files.push({ name, type: String(v.type), bytes: new Uint8Array(await res.arrayBuffer()) })
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const filerName = (await g.nameOf?.(r.who.member)) || undefined
|
|
628
|
+
const filed = await fileSuggestion({ repo: g.repo, token: g.token, fetch: f }, {
|
|
629
|
+
report,
|
|
630
|
+
compose: { appName: opts.app?.name, ...(typeof opts.app?.label === 'string' && opts.app.label ? { appLabel: opts.app.label } : {}), appVersion: typeof report.diagnostics?.appVersion === 'string' ? report.diagnostics.appVersion : undefined, filerName, filerRole: r.who.role },
|
|
631
|
+
files,
|
|
632
|
+
...(g.attachments ? { attachments: g.attachments } : {}),
|
|
633
|
+
})
|
|
634
|
+
const issue = { repo: g.repo, number: filed.number, url: filed.url }
|
|
635
|
+
if (await store.setStatus(id, { status: 'open', by: FLOW.open.actor, at: iso(now()), issue }, lastStatus(r))) await moved_(r, 'open', { issue })
|
|
636
|
+
} catch { /* GitHub or storage failed: it stays received for a Mac to file */ } finally {
|
|
637
|
+
await store.releaseApproval(id, lock)
|
|
524
638
|
}
|
|
525
|
-
return json(200, { id, status })
|
|
526
639
|
}
|
|
527
640
|
|
|
528
641
|
return { submit, uploadUrl, finalize, mine, one, append, agentList, agentFile, agentSettle, agentClaimApproval, agentReleaseApproval, agentStatus }
|
package/src/server/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
|
|
2
2
|
export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts'
|
|
3
|
-
export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts'
|
|
3
|
+
export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions, GitHubFiling, IssueRef } from './handlers.ts'
|
|
4
4
|
export { json, signIn, notFound, cappedText, peek } from './http.ts'
|
|
5
|
+
export { placeItem, withPlace, PLACE_LABEL, PLACE_WHY } from './geo.ts'
|
|
6
|
+
export type { Geo, GeoHook } from './geo.ts'
|
|
5
7
|
export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts'
|
|
6
8
|
export { effectiveLimits } from './rate.ts'
|
|
7
9
|
export type { Limits } from './rate.ts'
|