@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.
- package/CHANGELOG.md +23 -0
- package/README.md +162 -7
- 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 +2 -1
- 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.js +9 -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 +159 -0
- package/dist/github/compose.js +312 -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 +9 -2
- 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 +2 -1
- 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 +8 -6
- package/src/client/sheet.tsx +78 -4
- package/src/client/styles.css +5 -0
- 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 +362 -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,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
|
-
|
|
4
|
-
|
|
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;
|
package/dist/server/handlers.js
CHANGED
|
@@ -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
|
-
/**
|
|
59
|
-
const
|
|
60
|
-
/**
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
257
|
-
return json(200, rows.map((r) => ({ id: r.id, status:
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
513
|
+
return json(200, { id, status: cur(r) });
|
|
482
514
|
}
|
|
483
|
-
/** Takes (or renews) the approval for `by`. A closed report is never taken,
|
|
484
|
-
*
|
|
485
|
-
*
|
|
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 =
|
|
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 === '
|
|
524
|
+
if (from === 'open' && holder && holder !== by)
|
|
492
525
|
return { claimedBy: holder };
|
|
493
|
-
const lasting = from
|
|
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 =
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
|
562
|
-
const holder =
|
|
563
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
const
|
|
574
|
-
if (member && TOLD.includes(status) && !
|
|
575
|
-
await news({ member, reportId: id, status, words: wordsFor(status, { owner }), meaning: meaningFor(status, { owner }), ended: END.includes(status), ...(status === 'not-
|
|
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
|
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
"suggest-a-change-check": "bin/check.mjs"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
|
-
".":
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/contract.d.mts",
|
|
13
|
+
"default": "./src/contract.mjs"
|
|
14
|
+
},
|
|
12
15
|
"./client": {
|
|
13
16
|
"types": "./dist/client/index.d.ts",
|
|
14
17
|
"import": "./dist/client/index.js"
|
|
@@ -17,6 +20,10 @@
|
|
|
17
20
|
"types": "./dist/server/index.d.ts",
|
|
18
21
|
"import": "./dist/server/index.js"
|
|
19
22
|
},
|
|
23
|
+
"./github": {
|
|
24
|
+
"types": "./dist/github/index.d.ts",
|
|
25
|
+
"import": "./dist/github/index.js"
|
|
26
|
+
},
|
|
20
27
|
"./check": "./src/check/index.mjs",
|
|
21
28
|
"./styles.css": "./src/client/styles.css",
|
|
22
29
|
"./package.json": "./package.json"
|
package/src/client/adapters.ts
CHANGED
|
@@ -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
|
-
|
|
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 }
|