@supersuit/suggest-a-change 0.1.13 → 0.2.1

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +162 -7
  3. package/dist/client/adapters.d.ts +27 -0
  4. package/dist/client/context.d.ts +61 -0
  5. package/dist/client/context.js +279 -0
  6. package/dist/client/detail.d.ts +2 -2
  7. package/dist/client/detail.js +12 -8
  8. package/dist/client/entry.js +2 -1
  9. package/dist/client/index.d.ts +2 -0
  10. package/dist/client/index.js +1 -0
  11. package/dist/client/mine.js +4 -2
  12. package/dist/client/runtime.js +3 -0
  13. package/dist/client/seen.js +9 -7
  14. package/dist/client/sheet.js +64 -4
  15. package/dist/client/words.d.ts +10 -1
  16. package/dist/client/words.js +11 -2
  17. package/dist/contract.d.mts +32 -22
  18. package/dist/contract.mjs +127 -37
  19. package/dist/github/api.d.ts +100 -0
  20. package/dist/github/api.js +164 -0
  21. package/dist/github/compose.d.ts +159 -0
  22. package/dist/github/compose.js +312 -0
  23. package/dist/github/filer.d.ts +72 -0
  24. package/dist/github/filer.js +89 -0
  25. package/dist/github/hosts.d.ts +41 -0
  26. package/dist/github/hosts.js +154 -0
  27. package/dist/github/index.d.ts +12 -0
  28. package/dist/github/index.js +9 -0
  29. package/dist/github/privacy.d.ts +34 -0
  30. package/dist/github/privacy.js +142 -0
  31. package/dist/github/relay.d.ts +159 -0
  32. package/dist/github/relay.js +734 -0
  33. package/dist/server/geo.d.ts +17 -0
  34. package/dist/server/geo.js +35 -0
  35. package/dist/server/handlers.d.ts +54 -3
  36. package/dist/server/handlers.js +143 -41
  37. package/dist/server/index.d.ts +3 -1
  38. package/dist/server/index.js +1 -0
  39. package/package.json +9 -2
  40. package/src/client/adapters.ts +18 -1
  41. package/src/client/context.ts +243 -0
  42. package/src/client/detail.tsx +13 -8
  43. package/src/client/entry.tsx +2 -1
  44. package/src/client/index.ts +2 -0
  45. package/src/client/mine.tsx +7 -3
  46. package/src/client/runtime.ts +3 -0
  47. package/src/client/seen.ts +8 -6
  48. package/src/client/sheet.tsx +78 -4
  49. package/src/client/styles.css +5 -0
  50. package/src/client/words.ts +15 -2
  51. package/src/contract.mjs +110 -37
  52. package/src/github/api.ts +169 -0
  53. package/src/github/compose.ts +362 -0
  54. package/src/github/filer.ts +96 -0
  55. package/src/github/hosts.ts +117 -0
  56. package/src/github/index.ts +15 -0
  57. package/src/github/privacy.ts +131 -0
  58. package/src/github/relay.ts +637 -0
  59. package/src/server/geo.ts +32 -0
  60. package/src/server/handlers.ts +158 -45
  61. 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
+ }
@@ -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
- /** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
16
- * or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
17
- export interface TimelineEntry { status: string; by: string; at: string; step?: string; note?: string; holder?: string }
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
- /** Past asking the owner: the work has started or ended, and whoever holds the approval keeps it. */
151
- const PAST_ASKING = ['being-made', ...END]
152
- /** The maintainer named on the latest move that started or ended the work; none on a report from before 0.1.4. */
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: lastStatus(existing) })
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: lastStatus(now2) }) : json(409, { error: 'that suggestion cannot use this id; start it again' })
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
- return json(200, { id, status: 'received' })
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 t?.status === 'not-now' && t.note ? { note: t.note } : {} }
304
- return json(200, rows.map((r) => ({ id: r.id, status: lastStatus(r), kind: r.kind, at: Date.parse(r.receivedAt), ...reason(r) })))
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(lastStatus(r))) return json(409, { error: 'this suggestion is closed; start a new one' })
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: [...END] })
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(lastStatus(r)) && holderOf(r) !== by) {
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 is making the report, so this Mac files it as taken.
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 = lastStatus(r) === 'being-made' ? holderOf(r) : undefined
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
- const moves = mayMove(lastStatus(r), 'with-freeda')
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: moves ? 'with-freeda' : lastStatus(r) })
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, by anyone: it answers who closed it. A
459
- * report being made stays with the Mac that started it. Taken for a move that starts or ends the work, or on a
460
- * report already being made, the lease is HELD_MS, so it does not lapse; before that, a week. Writes nothing when refused. */
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 = lastStatus(r)
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 === 'being-made' && holder && holder !== by) return { claimedBy: holder }
466
- const lasting = from === 'being-made' || (to !== undefined && PAST_ASKING.includes(to))
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 = lastStatus(r)
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
- // Once the owner is being asked, the Mac that holds the approval decides what happens next. Leaving
504
- // waiting-on-owner, starting the work and ending it need the claim (taken here if nobody holds it).
505
- const needsApproval = from === 'waiting-on-owner' || PAST_ASKING.includes(status)
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 starts or ends the work, so the hold is known from the report itself.
511
- const holder = PAST_ASKING.includes(status) ? { holder: by } : {}
512
- const moved = await store.setStatus(id, { status, by: FLOW[status as keyof typeof FLOW].actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}), ...holder }, from)
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 !== 'being-made' && PAST_ASKING.includes(status)) await store.claimApproval(id, by, APPROVAL_LEASE_MS)
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
- await tell({ type: 'status', reportId: id, member: r.who?.member, status })
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
- // A maintainer is not told that the work they just started on their own report has started; an end is still news.
521
- const ownStart = status === 'being-made' && member === by
522
- if (member && TOLD.includes(status) && !ownStart) {
523
- await news({ member, reportId: id, status, words: wordsFor(status, { owner }), meaning: meaningFor(status, { owner }), ended: END.includes(status), ...(status === 'not-now' && note ? { note } : {}) })
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 }
@@ -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'