@supersuit/suggest-a-change 0.1.13 → 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.
Files changed (61) hide show
  1. package/CHANGELOG.md +18 -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 +152 -0
  22. package/dist/github/compose.js +309 -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 +5 -1
  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 +354 -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,17 @@
1
+ export type Geo = {
2
+ city?: string;
3
+ region?: string;
4
+ country?: string;
5
+ };
6
+ export type GeoHook = (req: Request) => Geo | null | undefined | Promise<Geo | null | undefined>;
7
+ export declare const PLACE_LABEL = "Roughly where they were";
8
+ export declare const PLACE_WHY = "from their internet connection, never their exact location; some problems only happen in one region";
9
+ /** The context item for a place from the request, or null when there is nothing to say. */
10
+ export declare function placeItem(g: Geo | null | undefined): {
11
+ key: string;
12
+ label: string;
13
+ value: string;
14
+ why: string;
15
+ } | null;
16
+ /** The context with the phone's own `place` dropped and the receiver's added; unchanged when there is neither. */
17
+ export declare function withPlace(ctx: unknown, req: Request, geo: GeoHook | undefined): Promise<unknown>;
@@ -0,0 +1,35 @@
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
+ export const PLACE_LABEL = 'Roughly where they were';
9
+ export const PLACE_WHY = 'from their internet connection, never their exact location; some problems only happen in one region';
10
+ /** The context item for a place from the request, or null when there is nothing to say. */
11
+ export function placeItem(g) {
12
+ if (!g)
13
+ return null;
14
+ const value = [g.city, g.region, g.country].map((x) => (typeof x === 'string' ? x.trim().slice(0, 100) : '')).filter(Boolean).join(', ');
15
+ return value ? { key: 'place', label: PLACE_LABEL, value, why: PLACE_WHY } : null;
16
+ }
17
+ /** The context with the phone's own `place` dropped and the receiver's added; unchanged when there is neither. */
18
+ export async function withPlace(ctx, req, geo) {
19
+ const has = Boolean(ctx && typeof ctx === 'object' && !Array.isArray(ctx));
20
+ const base = has ? { ...ctx } : {};
21
+ const items = Array.isArray(base.items) ? base.items.filter((i) => i?.key !== 'place') : undefined;
22
+ let place = null;
23
+ try {
24
+ place = geo ? placeItem(await geo(req)) : null;
25
+ }
26
+ catch {
27
+ place = null;
28
+ }
29
+ if (!has && !place)
30
+ return ctx;
31
+ const next = [...(items ?? []), ...(place ? [place] : [])].slice(0, 40);
32
+ if (next.length || items)
33
+ base.items = next;
34
+ return base;
35
+ }
@@ -1,7 +1,16 @@
1
1
  import { TOLD } from '../contract.mjs';
2
+ import { type AttachmentHost, type Fetch, type Person } from '../github/index.ts';
2
3
  import { type Limits } from './rate.ts';
3
- /** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
4
- * or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
4
+ import { type GeoHook } from './geo.ts';
5
+ export interface IssueRef {
6
+ repo: string;
7
+ number: number;
8
+ url: string;
9
+ }
10
+ /** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac filed or closed the issue, written on that
11
+ * entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. `issue` is
12
+ * written on the move to open. `flags` are the kinds the privacy catch found (never the words), on a held entry; the
13
+ * member's own view never shows them. */
5
14
  export interface TimelineEntry {
6
15
  status: string;
7
16
  by: string;
@@ -9,6 +18,8 @@ export interface TimelineEntry {
9
18
  step?: string;
10
19
  note?: string;
11
20
  holder?: string;
21
+ issue?: IssueRef;
22
+ flags?: string[];
12
23
  }
13
24
  export interface FileState {
14
25
  pending?: {
@@ -36,6 +47,11 @@ export interface StoredReport {
36
47
  who: {
37
48
  member: string;
38
49
  role: string;
50
+ contact?: {
51
+ email?: string;
52
+ phone?: string;
53
+ handle?: string;
54
+ };
39
55
  };
40
56
  timeline: TimelineEntry[];
41
57
  receivedAt: string;
@@ -149,7 +165,23 @@ export interface SubmitterNews {
149
165
  words: string;
150
166
  meaning: string;
151
167
  ended: boolean;
152
- note?: string;
168
+ note?: string; /** The issue it became, so a push can link it. */
169
+ issue?: IssueRef;
170
+ }
171
+ /** Filing straight from the app's server: each complete report becomes an issue, with no Mac in the loop. */
172
+ export interface GitHubFiling {
173
+ /** owner/name of the repo the issues go to. */
174
+ repo: string;
175
+ /** A token that can open issues there (and push, when `attachments` is a branchHost with it). */
176
+ token: string;
177
+ /** Where screenshots and recordings go. Without one they are listed as still being added. */
178
+ attachments?: AttachmentHost;
179
+ /** Third parties the privacy catch looks for (anyone but the sender and the owner). */
180
+ people?: Person[];
181
+ /** The sender's name as the issue says it; without it the issue says "A member". Never their key or email. */
182
+ nameOf?(member: string): string | undefined | Promise<string | undefined>;
183
+ /** For GitHub calls and for reading the stored files back through their read links. */
184
+ fetch?: Fetch;
153
185
  }
154
186
  export interface ReportHandlerOptions {
155
187
  store: HostedStore;
@@ -175,6 +207,25 @@ export interface ReportHandlerOptions {
175
207
  tellSubmitter: ((news: SubmitterNews) => void | Promise<void>) | {
176
208
  none: string;
177
209
  };
210
+ /** When given, a report is filed as an issue the moment it is complete: at submit when it has no files, else when its
211
+ * last file is verified. A privacy hit holds it for the owner instead; GitHub failing leaves it received for a Mac. */
212
+ github?: GitHubFiling;
213
+ /** Roughly where the sender was, from the request (never a browser prompt). Added to the report's context as one item;
214
+ * a place the phone claims is dropped. On Vercel: `(req) => ({ city: decodeURIComponent(req.headers.get('x-vercel-ip-city') ?? ''),
215
+ * region: req.headers.get('x-vercel-ip-country-region') ?? '', country: req.headers.get('x-vercel-ip-country') ?? '' })`. */
216
+ geo?: GeoHook;
217
+ /** How to reach a member, from what the app knows of them. The phone only says whether to share it (by sending
218
+ * `who.contact`, which it does while the person leaves it ticked); what it sent there is ignored. Without this, no
219
+ * report carries a contact. A contact of the wrong shape is dropped. */
220
+ contactOf?(member: string): {
221
+ email?: string;
222
+ phone?: string;
223
+ handle?: string;
224
+ } | null | undefined | Promise<{
225
+ email?: string;
226
+ phone?: string;
227
+ handle?: string;
228
+ } | null | undefined>;
178
229
  now?: () => number;
179
230
  }
180
231
  export declare const LEASE_MS: number;
@@ -7,10 +7,12 @@
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 } from '../github/index.js';
11
12
  import { effectiveLimits } from './rate.js';
12
13
  import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.js';
13
14
  import { json, notFound, signIn, cappedText } from './http.js';
15
+ import { withPlace } from './geo.js';
14
16
  /** What a collector saw of a report: its verified files and its follow-ups. A store computes this from its own copy,
15
17
  * inside `settle`, and settles only when it equals what the collector says it collected. */
16
18
  export function reportRevision(r) {
@@ -35,7 +37,9 @@ export const VOICE_BYTES_PER_SECOND = 200_000;
35
37
  export const INCOMPLETE_GRACE_MS = 60 * 60_000;
36
38
  const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
37
39
  const FOLLOW = /^[A-Za-z0-9_-]{1,64}$/;
38
- const FORBIDDEN = ['timeline', 'status', 'plan', 'decision', 'files', 'receivedAt', 'followUps', 'claimedBy', 'settledBy'];
40
+ const FORBIDDEN = ['timeline', 'status', 'plan', 'decision', 'issue', 'files', 'receivedAt', 'followUps', 'claimedBy', 'settledBy'];
41
+ /** Every stored status that means closed, old ones included: what a store's atomic follow-up write refuses on. */
42
+ const CLOSED_STORED = [...END, ...Object.keys(LEGACY).filter((k) => END.includes(LEGACY[k]))];
39
43
  const bad = (error) => json(400, { error });
40
44
  const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
41
45
  /** Reads a JSON object body, refusing past `BODY_CAP` by the header and again by counting what arrives. */
@@ -54,10 +58,19 @@ async function readBody(req, cap = BODY_CAP, optional = false) {
54
58
  }
55
59
  }
56
60
  const iso = (ms) => new Date(ms).toISOString();
61
+ /** The status as stored (a store's precondition compares this one). */
57
62
  const lastStatus = (r) => r.timeline[r.timeline.length - 1]?.status ?? 'received';
58
- /** Past asking the owner: the work has started or ended, and whoever holds the approval keeps it. */
59
- const PAST_ASKING = ['being-made', ...END];
60
- /** The maintainer named on the latest move that started or ended the work; none on a report from before 0.1.4. */
63
+ /** The status it means now: an old (0.1.x) status folded into the new one. Every decision below reads this. */
64
+ const cur = (r) => currentStatus(lastStatus(r));
65
+ /** Filed or closed: whoever holds the approval keeps it. */
66
+ const FILED = [...TOLD];
67
+ const issueOf = (r) => {
68
+ for (let i = r.timeline.length - 1; i >= 0; i--)
69
+ if (r.timeline[i].issue)
70
+ return r.timeline[i].issue;
71
+ return undefined;
72
+ };
73
+ /** The maintainer named on the latest move that filed or closed it (or an old one that started or ended the work). */
61
74
  const holderOf = (r) => {
62
75
  for (let i = r.timeline.length - 1; i >= 0; i--)
63
76
  if (r.timeline[i].holder)
@@ -124,6 +137,8 @@ export function createReportHandlers(opts) {
124
137
  ...(r.answers !== undefined ? { answers: r.answers } : {}),
125
138
  ...(r.app !== undefined ? { app: r.app } : {}),
126
139
  where: r.where, diagnostics: r.diagnostics, media: r.media ?? [], voice: r.voice ?? [],
140
+ ...(issueOf(r) ? { issue: issueOf(r) } : {}),
141
+ ...(r.context !== undefined ? { context: r.context } : {}),
127
142
  timeline: (r.timeline ?? []).map((t) => ({ status: t.status, by: t.by, at: t.at, ...(t.step ? { step: t.step } : {}), ...(t.note ? { note: t.note } : {}) })),
128
143
  followUps: (r.followUps ?? []).map((f) => ({ followId: f.followId, words: f.words, at: f.at, by: f.by, voice: f.voice })),
129
144
  });
@@ -212,9 +227,22 @@ export function createReportHandlers(opts) {
212
227
  return bad('diagnostics values are short text');
213
228
  }
214
229
  }
230
+ // How to reach the sender comes from the host, never the phone: the phone only says whether to share it.
231
+ const asked = isObj(raw.who) && raw.who.contact !== undefined;
232
+ let contact;
233
+ try {
234
+ contact = asked && opts.contactOf ? await opts.contactOf(member) : undefined;
235
+ }
236
+ catch {
237
+ contact = undefined;
238
+ }
239
+ if (contact !== undefined && (contact === null || validContact(contact).length))
240
+ contact = undefined;
241
+ const context = raw.context === undefined && !opts.geo ? undefined : await withPlace(raw.context, req, opts.geo);
215
242
  const report = {
216
243
  id: raw.id, kind: raw.kind, words, media, voice: v.voice,
217
- who: { member, role: roleOf(member) },
244
+ who: { member, role: roleOf(member), ...(contact !== undefined ? { contact } : {}) },
245
+ ...(context !== undefined ? { context } : {}),
218
246
  ...(answers ? { answers } : {}),
219
247
  app: { ...(opts.app ?? {}) },
220
248
  where, diagnostics,
@@ -226,7 +254,7 @@ export function createReportHandlers(opts) {
226
254
  const existing = await store.get(id);
227
255
  // The same member sending the same id again is a repeat, even at the day's limit: it was stored.
228
256
  if (existing?.who?.member === member)
229
- return json(200, { id, status: lastStatus(existing) });
257
+ return json(200, { id, status: cur(existing) });
230
258
  if ((await store.countToday(member)) >= limits.perPersonPerDay)
231
259
  return json(429, { error: 'you have reached today\'s limit of suggestions' });
232
260
  const at = iso(now());
@@ -237,10 +265,14 @@ export function createReportHandlers(opts) {
237
265
  // Lost a race with the same member (a repeat), or the id belongs to someone else. Someone else's id is REFUSED
238
266
  // (not re-issued, not dropped silently): nothing is created or overwritten, and the phone is told to start again.
239
267
  const now2 = await store.get(id);
240
- return now2?.who?.member === member ? json(200, { id, status: lastStatus(now2) }) : json(409, { error: 'that suggestion cannot use this id; start it again' });
268
+ return now2?.who?.member === member ? json(200, { id, status: cur(now2) }) : json(409, { error: 'that suggestion cannot use this id; start it again' });
241
269
  }
242
270
  await tell({ type: 'submitted', reportId: id, member });
243
- return json(200, { id, status: 'received' });
271
+ // Nothing to wait for: with the github option it becomes an issue now.
272
+ if (opts.github && !fileNames(report).length)
273
+ await fileNow(id);
274
+ const after = await store.get(id);
275
+ return json(200, { id, status: after ? cur(after) : 'received' });
244
276
  }
245
277
  async function mine(req) {
246
278
  const m = wrongMethod(req, 'GET');
@@ -253,8 +285,8 @@ export function createReportHandlers(opts) {
253
285
  const rows = (await store.listFor(member)).filter((r) => r.who?.member === member);
254
286
  rows.sort((a, b) => (a.receivedAt < b.receivedAt ? 1 : -1));
255
287
  // A declined report carries its one-line reason, which the member's own view already shows; no other note is listed.
256
- const reason = (r) => { const t = r.timeline[r.timeline.length - 1]; return t?.status === 'not-now' && t.note ? { note: t.note } : {}; };
257
- return json(200, rows.map((r) => ({ id: r.id, status: lastStatus(r), kind: r.kind, at: Date.parse(r.receivedAt), ...reason(r) })));
288
+ const reason = (r) => { const t = r.timeline[r.timeline.length - 1]; return cur(r) === 'not-planned' && t?.note ? { note: t.note } : {}; };
289
+ 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) } : {}) })));
258
290
  }
259
291
  async function one(req, id) {
260
292
  const m = wrongMethod(req, 'GET');
@@ -290,13 +322,13 @@ export function createReportHandlers(opts) {
290
322
  return notFound();
291
323
  if ((r.followUps ?? []).some((f) => f.followId === followId))
292
324
  return json(200, { id, followId }); // a repeat
293
- if (END.includes(lastStatus(r)))
325
+ if (END.includes(cur(r)))
294
326
  return json(409, { error: 'this suggestion is closed; start a new one' });
295
327
  const v = voiceEntries(raw.voice, limits.followUpVoiceNotes, new Set(fileNames(r)));
296
328
  if ('error' in v)
297
329
  return bad(v.error);
298
330
  // The caps are enforced again inside the store's atomic write: the read above is only a fast path.
299
- const added = await store.addFollowUp(id, { followId, words, at: iso(now()), by: roleOf(member), voice: v.voice.map(({ file, ms }) => ({ file, ms })) }, { max: limits.followUps, maxVoice: limits.followUps * limits.followUpVoiceNotes, voiceTotalMs: limits.voiceTotalMs, closed: [...END] });
331
+ const added = await store.addFollowUp(id, { followId, words, at: iso(now()), by: roleOf(member), voice: v.voice.map(({ file, ms }) => ({ file, ms })) }, { max: limits.followUps, maxVoice: limits.followUps * limits.followUpVoiceNotes, voiceTotalMs: limits.voiceTotalMs, closed: CLOSED_STORED });
300
332
  if (added === 'missing')
301
333
  return notFound();
302
334
  if (added === 'closed')
@@ -394,6 +426,8 @@ export function createReportHandlers(opts) {
394
426
  const verified = { type: real.type, bytes: real.bytes, sha256: real.sha256, at: iso(now()) };
395
427
  await store.setFile(id, file, verified);
396
428
  await store.reopen(id); // verified after a Mac collected the report: it is offered again with this file
429
+ if (opts.github)
430
+ await fileNow(id); // its last file: with the github option it becomes an issue now
397
431
  return json(200, { file, type: verified.type, bytes: verified.bytes, sha256: verified.sha256 });
398
432
  }
399
433
  const complete = (r) => fileNames(r).every((f) => r.files?.[f]?.sha256);
@@ -415,7 +449,7 @@ export function createReportHandlers(opts) {
415
449
  for (const r of batch) {
416
450
  if (out.length >= limits.agentPage)
417
451
  break;
418
- if (END.includes(lastStatus(r)) && holderOf(r) !== by) {
452
+ if (END.includes(cur(r)) && holderOf(r) !== by) {
419
453
  await store.claim(r.id, by, LEASE_MS);
420
454
  if ((await store.settle(r.id, by, reportRevision(r))) === 'settled')
421
455
  settled++;
@@ -431,9 +465,9 @@ export function createReportHandlers(opts) {
431
465
  break;
432
466
  }
433
467
  // `revision` is what this Mac is being shown; it goes back with the settle so a report that grew in between is not settled.
434
- // `heldBy` names another maintainer whose Mac is making the report, so this Mac files it as taken.
468
+ // `heldBy` names another maintainer whose Mac filed the issue, so this Mac leaves it to that one.
435
469
  return json(200, { reports: out.map((r) => {
436
- const holder = lastStatus(r) === 'being-made' ? holderOf(r) : undefined;
470
+ const holder = cur(r) === 'open' ? holderOf(r) : undefined;
437
471
  return { ...r, revision: reportRevision(r), ...(holder && holder !== by ? { heldBy: holder } : {}) };
438
472
  }) });
439
473
  }
@@ -473,24 +507,23 @@ export function createReportHandlers(opts) {
473
507
  return json(409, { error: 'this report was not claimed by this Mac' });
474
508
  if (settled === 'stale')
475
509
  return json(409, { error: 'this report changed after it was listed; list again', stale: true });
476
- const moves = mayMove(lastStatus(r), 'with-freeda');
477
- if (moves)
478
- await store.setStatus(id, { status: 'with-freeda', by: FLOW['with-freeda'].actor, at: iso(now()) }, lastStatus(r));
510
+ // Collecting is not filing: the report stays received until the Mac files the issue or holds it.
479
511
  await store.moveCollected(id);
480
512
  await tell({ type: 'settled', reportId: id });
481
- return json(200, { id, status: moves ? 'with-freeda' : lastStatus(r) });
513
+ return json(200, { id, status: cur(r) });
482
514
  }
483
- /** Takes (or renews) the approval for `by`. A closed report is never taken, by anyone: it answers who closed it. A
484
- * report being made stays with the Mac that started it. Taken for a move that starts or ends the work, or on a
485
- * report already being made, the lease is HELD_MS, so it does not lapse; before that, a week. Writes nothing when refused. */
515
+ /** Takes (or renews) the approval for `by`. A closed report is never taken, except to reopen it by the Mac that
516
+ * filed it (or by anyone when nobody is named); otherwise it answers who closed it. An open issue stays with the Mac
517
+ * that filed it. Taken for filing or closing, or on an open issue, the lease is HELD_MS, so it does not lapse; before
518
+ * that, a week. Writes nothing when refused. */
486
519
  async function takeApproval(r, by, to) {
487
- const from = lastStatus(r);
520
+ const from = cur(r);
488
521
  const holder = holderOf(r);
489
- if (END.includes(from))
522
+ if (END.includes(from) && !(to === 'open' && (!holder || holder === by)))
490
523
  return { closed: from, ...(holder ? { claimedBy: holder } : {}) };
491
- if (from === 'being-made' && holder && holder !== by)
524
+ if (from === 'open' && holder && holder !== by)
492
525
  return { claimedBy: holder };
493
- const lasting = from === 'being-made' || (to !== undefined && PAST_ASKING.includes(to));
526
+ const lasting = FILED.includes(from) || (to !== undefined && FILED.includes(to));
494
527
  return store.claimApproval(r.id, by, lasting ? HELD_MS : APPROVAL_LEASE_MS);
495
528
  }
496
529
  async function agentClaimApproval(req, id) {
@@ -542,39 +575,108 @@ export function createReportHandlers(opts) {
542
575
  const note = got.body.note === undefined ? undefined : str(got.body.note, 200);
543
576
  if (step === null || note === null)
544
577
  return bad('step and note are short text');
578
+ const rawIssue = got.body.issue;
579
+ if (rawIssue !== undefined && validIssue(rawIssue).length)
580
+ return bad(`the issue is not valid: ${validIssue(rawIssue).join('; ')}`);
581
+ const given = rawIssue;
545
582
  const r = isReportId(id) ? await store.get(id) : null;
546
583
  if (!r)
547
584
  return notFound();
548
- const from = lastStatus(r);
585
+ const from = cur(r);
549
586
  if (from === status)
550
587
  return json(200, { id, status }); // a repeat
551
588
  if (!mayMove(from, status))
552
589
  return json(409, { error: `a report cannot go from ${from} to ${status}` });
553
- // Once the owner is being asked, the Mac that holds the approval decides what happens next. Leaving
554
- // waiting-on-owner, starting the work and ending it need the claim (taken here if nobody holds it).
555
- const needsApproval = from === 'waiting-on-owner' || PAST_ASKING.includes(status);
590
+ const issue = given ? { repo: given.repo, number: given.number, url: given.url } : undefined;
591
+ if (status === 'open' && !issue && !issueOf(r))
592
+ return bad('a move to open names the issue it became: { repo, number, url }');
593
+ // Once it is held for the owner, the Mac that holds the approval decides what happens next. Leaving held,
594
+ // filing and closing need the claim (taken here if nobody holds it).
595
+ const needsApproval = from === 'held' || FILED.includes(status);
556
596
  if (needsApproval) {
557
597
  const c = await takeApproval(r, by, status);
558
598
  if (!('ok' in c))
559
599
  return json(409, { error: 'another Mac holds the approval for this report', ...(c.claimedBy ? { claimedBy: c.claimedBy } : {}) });
560
600
  }
561
- // The maintainer is named on a move that starts or ends the work, so the hold is known from the report itself.
562
- const holder = PAST_ASKING.includes(status) ? { holder: by } : {};
563
- const moved = await store.setStatus(id, { status, by: FLOW[status].actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}), ...holder }, from);
601
+ // The maintainer is named on a move that files or closes it, so the hold is known from the report itself.
602
+ const holder = FILED.includes(status) ? { holder: by } : {};
603
+ // Declined before it was ever filed is the owner's decision, not GitHub's.
604
+ const actor = status === 'not-planned' && from !== 'open' ? 'owner' : FLOW[status].actor;
605
+ const moved = await store.setStatus(id, { status, by: actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}), ...holder, ...(issue ? { issue } : {}) }, lastStatus(r));
564
606
  if (!moved) {
565
607
  // The lasting lease was taken for a move that did not happen: bring it back to a week.
566
- if (needsApproval && from !== 'being-made' && PAST_ASKING.includes(status))
608
+ if (needsApproval && !FILED.includes(from) && FILED.includes(status))
567
609
  await store.claimApproval(id, by, APPROVAL_LEASE_MS);
568
610
  return json(409, { error: 'the report changed while this was sent; read it again' });
569
611
  }
570
- await tell({ type: 'status', reportId: id, member: r.who?.member, status });
612
+ // A maintainer is not told that the issue they just filed from their own report exists; a close is still news.
613
+ await moved_(r, status, { by, note, issue: issue ?? issueOf(r) });
614
+ return json(200, { id, status });
615
+ }
616
+ /** After a move landed: the ids-only tell, and the person who suggested it on a telling move. */
617
+ async function moved_(r, status, { by, note, issue }) {
571
618
  const member = r.who?.member;
572
- // A maintainer is not told that the work they just started on their own report has started; an end is still news.
573
- const ownStart = status === 'being-made' && member === by;
574
- if (member && TOLD.includes(status) && !ownStart) {
575
- await news({ member, reportId: id, status, words: wordsFor(status, { owner }), meaning: meaningFor(status, { owner }), ended: END.includes(status), ...(status === 'not-now' && note ? { note } : {}) });
619
+ await tell({ type: 'status', reportId: r.id, member, status });
620
+ const ownFiling = status === 'open' && member === by;
621
+ if (member && TOLD.includes(status) && !ownFiling) {
622
+ 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 } : {}) });
623
+ }
624
+ }
625
+ /** FILING FROM THE APP'S SERVER (the github option). Runs once a report is complete. One caller files it: the approval
626
+ * is taken atomically under a one-time name, the report is read again under it, and a second caller finds it taken
627
+ * or already filed. A privacy hit holds it for the owner (the kinds on the entry, never the words); GitHub failing
628
+ * leaves it received and the approval free, so a maintainer's Mac can file it. Never fails the person's request. */
629
+ async function fileNow(id) {
630
+ const g = opts.github;
631
+ if (!g)
632
+ return;
633
+ const first = await store.get(id);
634
+ if (!first || cur(first) !== 'received' || !complete(first))
635
+ return;
636
+ const lock = `@app:${Math.random().toString(36).slice(2, 10)}`;
637
+ const got = await store.claimApproval(id, lock, HELD_MS);
638
+ if (!('ok' in got))
639
+ return;
640
+ try {
641
+ const r = await store.get(id);
642
+ if (!r || cur(r) !== 'received' || !complete(r))
643
+ return;
644
+ const report = { id: r.id, kind: r.kind, words: r.words ?? '', who: r.who, voice: r.voice ?? [], media: r.media ?? [], where: r.where, diagnostics: r.diagnostics, followUps: r.followUps ?? [], ...(r.context ? { context: r.context } : {}) };
645
+ const { hits } = privacyCatch(report, { people: g.people ?? [], secrets: [r.who.member, ...maintainers] });
646
+ if (hits.length) {
647
+ const flags = [...new Set(hits.map((h) => h.kind))];
648
+ await store.setStatus(id, { status: 'held', by: FLOW.held.actor, at: iso(now()), flags }, lastStatus(r));
649
+ return;
650
+ }
651
+ const f = g.fetch ?? globalThis.fetch;
652
+ const files = [];
653
+ if (g.attachments) {
654
+ for (const name of fileNames(r)) {
655
+ const v = r.files?.[name];
656
+ if (!v?.sha256)
657
+ continue;
658
+ const link = await store.readUrl({ reportId: id, file: name, ttlMs: READ_TTL_MS });
659
+ const res = await f(link.url);
660
+ if (!res.ok)
661
+ continue; // left as still being added
662
+ files.push({ name, type: String(v.type), bytes: new Uint8Array(await res.arrayBuffer()) });
663
+ }
664
+ }
665
+ const filerName = (await g.nameOf?.(r.who.member)) || undefined;
666
+ const filed = await fileSuggestion({ repo: g.repo, token: g.token, fetch: f }, {
667
+ report,
668
+ 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 },
669
+ files,
670
+ ...(g.attachments ? { attachments: g.attachments } : {}),
671
+ });
672
+ const issue = { repo: g.repo, number: filed.number, url: filed.url };
673
+ if (await store.setStatus(id, { status: 'open', by: FLOW.open.actor, at: iso(now()), issue }, lastStatus(r)))
674
+ await moved_(r, 'open', { issue });
675
+ }
676
+ catch { /* GitHub or storage failed: it stays received for a Mac to file */ }
677
+ finally {
678
+ await store.releaseApproval(id, lock);
576
679
  }
577
- return json(200, { id, status });
578
680
  }
579
681
  return { submit, uploadUrl, finalize, mine, one, append, agentList, agentFile, agentSettle, agentClaimApproval, agentReleaseApproval, agentStatus };
580
682
  }
@@ -1,6 +1,8 @@
1
1
  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';
2
- export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts';
2
+ export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions, GitHubFiling, IssueRef } from './handlers.ts';
3
3
  export { json, signIn, notFound, cappedText, peek } from './http.ts';
4
+ export { placeItem, withPlace, PLACE_LABEL, PLACE_WHY } from './geo.ts';
5
+ export type { Geo, GeoHook } from './geo.ts';
4
6
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts';
5
7
  export { effectiveLimits } from './rate.ts';
6
8
  export type { Limits } from './rate.ts';
@@ -1,6 +1,7 @@
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.js';
3
3
  export { json, signIn, notFound, cappedText, peek } from './http.js';
4
+ export { placeItem, withPlace, PLACE_LABEL, PLACE_WHY } from './geo.js';
4
5
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.js';
5
6
  export { effectiveLimits } from './rate.js';
6
7
  export { runStoreConformance, checks as conformanceChecks } from './conformance.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "description": "The report format and flow behind a Suggest a change button: what a report is, who acts at each step, the screens, and the check every app runs. The contract has zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,10 @@
17
17
  "types": "./dist/server/index.d.ts",
18
18
  "import": "./dist/server/index.js"
19
19
  },
20
+ "./github": {
21
+ "types": "./dist/github/index.d.ts",
22
+ "import": "./dist/github/index.js"
23
+ },
20
24
  "./check": "./src/check/index.mjs",
21
25
  "./styles.css": "./src/client/styles.css",
22
26
  "./package.json": "./package.json"
@@ -7,10 +7,15 @@ import type { Store } from './idb.ts'
7
7
  import type { StoreAdapter } from './reports.ts'
8
8
  import type { startTake } from './recorder.ts'
9
9
  import type { Words } from './words.ts'
10
+ import type { Destination } from '../github/compose.ts'
11
+ import type { AppContextItem } from './context.ts'
10
12
 
11
13
  export type Role = 'operator' | 'member' | 'maintainer'
12
14
  export type Kind = 'change' | 'broken' | 'feedback'
13
- export type Identity = { account: string; role: Role }
15
+ /** `name` (or `username`) is how the person is called in the app; it goes in the context, and the account never does. */
16
+ export type Identity = { account: string; role: Role; name?: string; username?: string }
17
+ /** How whoever fixes it can reach the sender: what the app already knows. */
18
+ export type Contact = { email?: string; phone?: string; handle?: string }
14
19
 
15
20
  export type HeardUpload = (key: string, blob: Blob, ms: number) => Promise<{ audio: string; text: string } | { refused: string }>
16
21
 
@@ -75,4 +80,16 @@ export interface Adapters {
75
80
  startTake?: typeof startTake
76
81
  /** False when the receiving side cannot take a voice note added to a report afterwards (the detail view then says so). */
77
82
  appendVoice?: boolean
83
+ /** Where a sent report files as a GitHub issue. With it, the sheet lists exactly what goes and where above Send
84
+ * (the same list the issue is written from); null or absent keeps the one-line note. */
85
+ destination?(): Promise<SheetDestination | null>
86
+ /** Anything app-specific that would help fix it, each with why it is shared (an item without a why is dropped). Or
87
+ * `{ items?, precise? }`, where `precise: true` also asks the browser for the person's location (its permission prompt). */
88
+ context?: (() => Promise<AppContextItem[]>) | { items?(): Promise<AppContextItem[]>; precise?: boolean }
89
+ /** How to reach the sender, from what the app already knows. Shared by default to a private repo, off for a public one. */
90
+ contact?(): Promise<Contact | null>
78
91
  }
92
+
93
+ /** The issue's destination as the sheet shows it: the repo, whether it is public, the GitHub login it files from,
94
+ * whether files can reach it yet, and (optionally) the sender's name as the issue will say it. */
95
+ export interface SheetDestination extends Destination { name?: string }