@artymclabin/qa-review 0.3.7
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 +174 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/dist/client/QAReviewOverlay.d.ts +59 -0
- package/dist/client/QAReviewOverlay.js +858 -0
- package/dist/client/device.d.ts +28 -0
- package/dist/client/device.js +57 -0
- package/dist/client/fingerprint.d.ts +19 -0
- package/dist/client/fingerprint.js +40 -0
- package/dist/client/highlight.d.ts +17 -0
- package/dist/client/highlight.js +77 -0
- package/dist/client/index.d.ts +12 -0
- package/dist/client/index.js +11 -0
- package/dist/client/journey.d.ts +89 -0
- package/dist/client/journey.js +134 -0
- package/dist/client/preview.d.ts +30 -0
- package/dist/client/preview.js +116 -0
- package/dist/client/revisit.d.ts +28 -0
- package/dist/client/revisit.js +27 -0
- package/dist/client/store.d.ts +100 -0
- package/dist/client/store.js +234 -0
- package/dist/client/styles.d.ts +3 -0
- package/dist/client/styles.js +211 -0
- package/dist/client/types.d.ts +87 -0
- package/dist/client/types.js +7 -0
- package/dist/server/handlers.d.ts +31 -0
- package/dist/server/handlers.js +186 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +3 -0
- package/dist/server/storage.d.ts +78 -0
- package/dist/server/storage.js +245 -0
- package/dist/shared/codename.d.ts +16 -0
- package/dist/shared/codename.js +55 -0
- package/package.json +61 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export type QAReviewDevice = "💻" | "📱" | "💻📱";
|
|
2
|
+
export interface QAReviewItem {
|
|
3
|
+
/** Stable id, used as the verdict-ledger key. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** Short label shown on the review card. */
|
|
6
|
+
title: string;
|
|
7
|
+
/** Optional multi-line detail ("\n" renders as paragraph breaks). */
|
|
8
|
+
sub?: string;
|
|
9
|
+
/**
|
|
10
|
+
* CSS selector for the element to spotlight on the real page (e.g.
|
|
11
|
+
* `[data-qa="hero"]`). OMIT for an off-DOM "task item": it renders as a
|
|
12
|
+
* centered card (no spotlight) - for visit-this-page checks and decisions
|
|
13
|
+
* that have no single on-page anchor.
|
|
14
|
+
*/
|
|
15
|
+
selector?: string;
|
|
16
|
+
/** Optional action link (task items): a button opening the URL in a new tab. */
|
|
17
|
+
action?: {
|
|
18
|
+
/** Button label. Default "Open". */
|
|
19
|
+
label?: string;
|
|
20
|
+
href: string;
|
|
21
|
+
};
|
|
22
|
+
/** Which viewport(s) this item is about (legacy display emoji). */
|
|
23
|
+
device?: QAReviewDevice;
|
|
24
|
+
/**
|
|
25
|
+
* Devices whose sign-off is REQUIRED for this item to count as approved
|
|
26
|
+
* (device-split approvals). Default: ["pc"]. The item stays pending until
|
|
27
|
+
* every listed device is approved; rejection is whole-item.
|
|
28
|
+
*/
|
|
29
|
+
devices?: Array<"pc" | "mobile">;
|
|
30
|
+
/**
|
|
31
|
+
* Words/phrases INSIDE the anchored element to sub-highlight (secondary
|
|
32
|
+
* mark on top of the spotlight). Replaces "where to look" prose - keep the
|
|
33
|
+
* question terse and let the highlight point.
|
|
34
|
+
*/
|
|
35
|
+
highlightWords?: string[];
|
|
36
|
+
/** Optional grouping label. */
|
|
37
|
+
section?: string;
|
|
38
|
+
/**
|
|
39
|
+
* When the reviewed element has design VARIATIONS to choose from, the overlay
|
|
40
|
+
* renders 1..count buttons that live-swap the element on the page via
|
|
41
|
+
* `onSelect` (dynamic preview) and records the chosen variant in the result.
|
|
42
|
+
*/
|
|
43
|
+
variations?: {
|
|
44
|
+
count: number;
|
|
45
|
+
/** Current live variant (1-based) for button highlighting. */
|
|
46
|
+
current: number;
|
|
47
|
+
/** Apply a variant on the real page (updates the page's own state). */
|
|
48
|
+
onSelect: (variant: number) => void;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Off-DOM "task item" = no selector: centered card, no spotlight. */
|
|
52
|
+
export declare function isTaskItem(item: Pick<QAReviewItem, "selector">): boolean;
|
|
53
|
+
export type QAVerdict = "approve" | "reject";
|
|
54
|
+
export interface QAResult {
|
|
55
|
+
id: string;
|
|
56
|
+
title: string;
|
|
57
|
+
verdict: QAVerdict;
|
|
58
|
+
note?: string;
|
|
59
|
+
/** Chosen variant (1-based) when the item had variations. */
|
|
60
|
+
variant?: number;
|
|
61
|
+
}
|
|
62
|
+
/** Session-snapshot payload POSTed to the submit endpoint. */
|
|
63
|
+
export interface QASubmission {
|
|
64
|
+
/** What is being reviewed (e.g. "example-site:/pricing"). */
|
|
65
|
+
target: string;
|
|
66
|
+
/** Free-text reviewer name/label (server-side auth may override). */
|
|
67
|
+
reviewer?: string;
|
|
68
|
+
approved: number;
|
|
69
|
+
rejected: number;
|
|
70
|
+
total: number;
|
|
71
|
+
results: Array<QAResult | {
|
|
72
|
+
id: string;
|
|
73
|
+
title: string;
|
|
74
|
+
verdict: "skipped";
|
|
75
|
+
}>;
|
|
76
|
+
}
|
|
77
|
+
/** Theme hooks for the overlay chrome. Any CSS color value (vars included). */
|
|
78
|
+
export interface QATheme {
|
|
79
|
+
/** Brand accent (spotlight ring, approve button, headings). */
|
|
80
|
+
accent: string;
|
|
81
|
+
/** Text color used ON accent-filled surfaces (usually the darkest bg). */
|
|
82
|
+
accentContrast: string;
|
|
83
|
+
/** Panel/card background. */
|
|
84
|
+
panelBg: string;
|
|
85
|
+
/** Input (textarea) background. */
|
|
86
|
+
inputBg: string;
|
|
87
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Shared types for the on-page gamified QA review overlay.
|
|
2
|
+
// A page supplies an array of QAReviewItem; <QAReviewOverlay> turns them into
|
|
3
|
+
// an onboarding-style spotlight walkthrough with approve/reject stepping.
|
|
4
|
+
/** Off-DOM "task item" = no selector: centered card, no spotlight. */
|
|
5
|
+
export function isTaskItem(item) {
|
|
6
|
+
return !item.selector;
|
|
7
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type QAReviewStorage } from "./storage.js";
|
|
2
|
+
/** Result of a successful authorization. */
|
|
3
|
+
export interface QAAuthUser {
|
|
4
|
+
/** Persisted as the session reviewer (overrides any client-sent value). */
|
|
5
|
+
reviewer?: string;
|
|
6
|
+
/** Returned by the access probe for UI display. */
|
|
7
|
+
displayName?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface QAReviewHandlerOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Install scope - one database can serve many sites/apps; every row is
|
|
12
|
+
* scoped to this value (e.g. "example-site").
|
|
13
|
+
*/
|
|
14
|
+
site: string;
|
|
15
|
+
/**
|
|
16
|
+
* Auth gate, BYO: return a user to allow, null/undefined to reject (401).
|
|
17
|
+
* Runs on EVERY endpoint. For an unauthenticated local tool use
|
|
18
|
+
* `async () => ({})`.
|
|
19
|
+
*/
|
|
20
|
+
authorize: (req: Request) => Promise<QAAuthUser | null | undefined>;
|
|
21
|
+
/** Storage adapter. Default: Postgres via QA_REVIEW_DATABASE_URL. */
|
|
22
|
+
storage?: QAReviewStorage;
|
|
23
|
+
}
|
|
24
|
+
export interface QAReviewHandlers {
|
|
25
|
+
stateGET: (req: Request) => Promise<Response>;
|
|
26
|
+
statePOST: (req: Request) => Promise<Response>;
|
|
27
|
+
submitPOST: (req: Request) => Promise<Response>;
|
|
28
|
+
sessionsGET: (req: Request) => Promise<Response>;
|
|
29
|
+
accessGET: (req: Request) => Promise<Response>;
|
|
30
|
+
}
|
|
31
|
+
export declare function createQAReviewHandlers(opts: QAReviewHandlerOptions): QAReviewHandlers;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Framework-agnostic handler factory for the QA review endpoints. Every
|
|
2
|
+
// handler is a plain `(req: Request) => Promise<Response>` (Web standard), so
|
|
3
|
+
// they mount 1:1 in Next.js route handlers, Remix, Hono, or any Node server
|
|
4
|
+
// that speaks fetch primitives.
|
|
5
|
+
//
|
|
6
|
+
// Endpoint semantics (the durable verdict ledger):
|
|
7
|
+
// state GET ?target=... -> { ok, verdicts: {itemId: {verdict,note,variant}} }
|
|
8
|
+
// state POST { target, itemId, verdict, ... } -> merge-upsert one item
|
|
9
|
+
// state POST { target, itemId, verdict: null } -> invalidate that ONE item
|
|
10
|
+
// (the only way to make an approved item reappear). With a
|
|
11
|
+
// `revisitReason` the row is KEPT (prior verdict + note + fingerprint
|
|
12
|
+
// preserved, reason recorded) so the card can tell the reviewer WHY it
|
|
13
|
+
// is back; without a reason the row is deleted (undo).
|
|
14
|
+
// 🚨 Deliberately NO reset-all operation: the ledger is never wiped in bulk.
|
|
15
|
+
// submit POST { target, results, ... } -> insert one session snapshot
|
|
16
|
+
// sessions GET ?target=&limit= -> list recent session summaries
|
|
17
|
+
// access GET -> { ok, displayName? } (auth probe)
|
|
18
|
+
import { createPostgresStorage, } from "./storage.js";
|
|
19
|
+
import { codenameFor } from "../shared/codename.js";
|
|
20
|
+
/** Hard sanity caps so a bad client cannot bloat the tables. */
|
|
21
|
+
const MAX_KEY = 200;
|
|
22
|
+
const MAX_TITLE = 500;
|
|
23
|
+
const MAX_TEXT = 4000;
|
|
24
|
+
const MAX_RESULTS = 200;
|
|
25
|
+
const json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
26
|
+
status,
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
});
|
|
29
|
+
export function createQAReviewHandlers(opts) {
|
|
30
|
+
const storage = opts.storage ?? createPostgresStorage();
|
|
31
|
+
const site = opts.site;
|
|
32
|
+
const guard = async (req) => {
|
|
33
|
+
try {
|
|
34
|
+
return (await opts.authorize(req)) ?? null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null; // fail closed
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
async stateGET(req) {
|
|
42
|
+
const user = await guard(req);
|
|
43
|
+
if (!user)
|
|
44
|
+
return json({ ok: false }, 401);
|
|
45
|
+
const target = new URL(req.url).searchParams.get("target") || "";
|
|
46
|
+
if (!target)
|
|
47
|
+
return json({ ok: false, error: "Missing target." }, 400);
|
|
48
|
+
try {
|
|
49
|
+
const verdicts = await storage.getState(site, target);
|
|
50
|
+
// Attach the deterministic codename per item so agents/humans can
|
|
51
|
+
// reference items by name ("I'm QA-ing red-apple").
|
|
52
|
+
for (const [itemId, v] of Object.entries(verdicts)) {
|
|
53
|
+
v.codename = codenameFor(target, itemId);
|
|
54
|
+
}
|
|
55
|
+
return json({ ok: true, verdicts });
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
console.error("[qa-review/state] read failed:", e);
|
|
59
|
+
return json({ ok: false, error: "Database read failed." }, 500);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
async statePOST(req) {
|
|
63
|
+
const user = await guard(req);
|
|
64
|
+
if (!user)
|
|
65
|
+
return json({ ok: false }, 401);
|
|
66
|
+
let body;
|
|
67
|
+
try {
|
|
68
|
+
body = await req.json();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return json({ ok: false, error: "Invalid JSON." }, 400);
|
|
72
|
+
}
|
|
73
|
+
const target = typeof body.target === "string" ? body.target.slice(0, MAX_KEY) : "";
|
|
74
|
+
const itemId = typeof body.itemId === "string" ? body.itemId.slice(0, MAX_KEY) : "";
|
|
75
|
+
if (!target || !itemId) {
|
|
76
|
+
return json({ ok: false, error: "Missing target or itemId." }, 400);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
// verdict === null -> invalidate this single item. A revisitReason
|
|
80
|
+
// keeps the row with re-queue context (0.3.3); no reason = plain
|
|
81
|
+
// delete (undo semantics).
|
|
82
|
+
if (body.verdict === null) {
|
|
83
|
+
const reason = typeof body.revisitReason === "string" ? body.revisitReason.trim().slice(0, 500) : "";
|
|
84
|
+
if (reason) {
|
|
85
|
+
await storage.invalidateState(site, target, itemId, reason);
|
|
86
|
+
return json({ ok: true, invalidated: itemId, revisitReason: reason });
|
|
87
|
+
}
|
|
88
|
+
await storage.deleteState(site, target, itemId);
|
|
89
|
+
return json({ ok: true, deleted: itemId });
|
|
90
|
+
}
|
|
91
|
+
const patch = {};
|
|
92
|
+
if (body.verdict !== undefined) {
|
|
93
|
+
if (body.verdict !== "approve" && body.verdict !== "reject") {
|
|
94
|
+
return json({ ok: false, error: "Invalid verdict." }, 400);
|
|
95
|
+
}
|
|
96
|
+
patch.verdict = body.verdict;
|
|
97
|
+
}
|
|
98
|
+
if (body.note !== undefined)
|
|
99
|
+
patch.note = String(body.note).slice(0, MAX_TEXT) || null;
|
|
100
|
+
if (body.variant !== undefined)
|
|
101
|
+
patch.variant = typeof body.variant === "number" ? body.variant : null;
|
|
102
|
+
if (body.fp !== undefined)
|
|
103
|
+
patch.fp = String(body.fp).slice(0, 128) || null;
|
|
104
|
+
if (body.approvedDevices !== undefined) {
|
|
105
|
+
patch.approvedDevices = Array.isArray(body.approvedDevices)
|
|
106
|
+
? body.approvedDevices.filter((d) => d === "pc" || d === "mobile")
|
|
107
|
+
: null;
|
|
108
|
+
}
|
|
109
|
+
await storage.upsertState(site, target, itemId, patch);
|
|
110
|
+
return json({ ok: true });
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
console.error("[qa-review/state] write failed:", e);
|
|
114
|
+
return json({ ok: false, error: "Database write failed." }, 500);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
async submitPOST(req) {
|
|
118
|
+
const user = await guard(req);
|
|
119
|
+
if (!user)
|
|
120
|
+
return json({ ok: false, error: "Not authorized." }, 401);
|
|
121
|
+
let body;
|
|
122
|
+
try {
|
|
123
|
+
body = await req.json();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return json({ ok: false, error: "Invalid JSON body." }, 400);
|
|
127
|
+
}
|
|
128
|
+
if (!body || typeof body.target !== "string" || !body.target || !Array.isArray(body.results)) {
|
|
129
|
+
return json({ ok: false, error: "Missing target or results." }, 400);
|
|
130
|
+
}
|
|
131
|
+
if (body.results.length > MAX_RESULTS) {
|
|
132
|
+
return json({ ok: false, error: "Too many results." }, 400);
|
|
133
|
+
}
|
|
134
|
+
// Normalize/validate per-item rows; never trust client-side shapes blindly.
|
|
135
|
+
const results = body.results.map((raw) => {
|
|
136
|
+
const r = (raw ?? {});
|
|
137
|
+
return {
|
|
138
|
+
id: String(r.id ?? "").slice(0, MAX_KEY),
|
|
139
|
+
title: String(r.title ?? "").slice(0, MAX_TITLE),
|
|
140
|
+
verdict: r.verdict === "approve" || r.verdict === "reject" ? r.verdict : "skipped",
|
|
141
|
+
note: typeof r.note === "string" && r.note ? r.note.slice(0, MAX_TEXT) : undefined,
|
|
142
|
+
variant: typeof r.variant === "number" ? r.variant : undefined,
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
const { id } = await storage.insertSession(site, {
|
|
147
|
+
target: body.target.slice(0, MAX_KEY),
|
|
148
|
+
// Reviewer from the VERIFIED auth result wins over the client payload.
|
|
149
|
+
reviewer: user.reviewer ?? (typeof body.reviewer === "string" ? body.reviewer.slice(0, MAX_TITLE) : null),
|
|
150
|
+
approved: results.filter((r) => r.verdict === "approve").length,
|
|
151
|
+
rejected: results.filter((r) => r.verdict === "reject").length,
|
|
152
|
+
total: results.length,
|
|
153
|
+
results,
|
|
154
|
+
});
|
|
155
|
+
return json({ ok: true, reviewId: id });
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
console.error("[qa-review/submit] DB write failed:", e);
|
|
159
|
+
return json({ ok: false, error: "Database write failed - use Copy JSON as fallback." }, 500);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
async sessionsGET(req) {
|
|
163
|
+
const user = await guard(req);
|
|
164
|
+
if (!user)
|
|
165
|
+
return json({ ok: false, sessions: [] }, 401);
|
|
166
|
+
const url = new URL(req.url);
|
|
167
|
+
const target = url.searchParams.get("target") || undefined;
|
|
168
|
+
const limitRaw = Number(url.searchParams.get("limit"));
|
|
169
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 500) : 100;
|
|
170
|
+
try {
|
|
171
|
+
const sessions = await storage.listSessions(site, target, limit);
|
|
172
|
+
return json({ ok: true, sessions });
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
console.error("[qa-review/sessions] read failed:", e);
|
|
176
|
+
return json({ ok: false, error: "Read failed.", sessions: [] }, 500);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
async accessGET(req) {
|
|
180
|
+
const user = await guard(req);
|
|
181
|
+
if (!user)
|
|
182
|
+
return json({ ok: false }, 401);
|
|
183
|
+
return json({ ok: true, displayName: user.displayName });
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createQAReviewHandlers, type QAReviewHandlers, type QAReviewHandlerOptions, type QAAuthUser, } from "./handlers.js";
|
|
2
|
+
export { createPostgresStorage, type PostgresStorageOptions, type QAReviewStorage, type StoredVerdict, type StoredVerdictMap, type VerdictPatch, type NewSession, type SessionResultRow, type SessionSummary, } from "./storage.js";
|
|
3
|
+
export { codenameFor, findByCodename, formatQARef, type CodenameEntry, } from "../shared/codename.js";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface StoredVerdict {
|
|
2
|
+
verdict?: string;
|
|
3
|
+
note?: string;
|
|
4
|
+
variant?: number;
|
|
5
|
+
/** Content fingerprint at verdict time (NOT-ALTERED poka-yoke). */
|
|
6
|
+
fp?: string;
|
|
7
|
+
/** Per-device approvals ("pc"/"mobile") for device-split items. */
|
|
8
|
+
approvedDevices?: string[];
|
|
9
|
+
/** Deterministic two-word codename (computed, not stored). */
|
|
10
|
+
codename?: string;
|
|
11
|
+
/** Why the item was re-queued (set by the invalidation call). */
|
|
12
|
+
revisitReason?: string;
|
|
13
|
+
/** Verdict that was in effect before the invalidation. */
|
|
14
|
+
prevVerdict?: string;
|
|
15
|
+
}
|
|
16
|
+
/** item id -> stored verdict. */
|
|
17
|
+
export type StoredVerdictMap = Record<string, StoredVerdict>;
|
|
18
|
+
export interface VerdictPatch {
|
|
19
|
+
verdict?: string;
|
|
20
|
+
note?: string | null;
|
|
21
|
+
variant?: number | null;
|
|
22
|
+
fp?: string | null;
|
|
23
|
+
approvedDevices?: string[] | null;
|
|
24
|
+
}
|
|
25
|
+
export interface SessionResultRow {
|
|
26
|
+
id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
verdict: "approve" | "reject" | "skipped";
|
|
29
|
+
note?: string;
|
|
30
|
+
variant?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface NewSession {
|
|
33
|
+
target: string;
|
|
34
|
+
reviewer: string | null;
|
|
35
|
+
approved: number;
|
|
36
|
+
rejected: number;
|
|
37
|
+
total: number;
|
|
38
|
+
results: SessionResultRow[];
|
|
39
|
+
}
|
|
40
|
+
export interface SessionSummary {
|
|
41
|
+
id: string;
|
|
42
|
+
site: string;
|
|
43
|
+
target: string;
|
|
44
|
+
reviewer: string | null;
|
|
45
|
+
approved: number;
|
|
46
|
+
rejected: number;
|
|
47
|
+
total: number;
|
|
48
|
+
createdAt: string;
|
|
49
|
+
}
|
|
50
|
+
/** Pluggable storage contract (the Postgres adapter is the shipped default). */
|
|
51
|
+
export interface QAReviewStorage {
|
|
52
|
+
getState(site: string, target: string): Promise<StoredVerdictMap>;
|
|
53
|
+
upsertState(site: string, target: string, itemId: string, patch: VerdictPatch): Promise<void>;
|
|
54
|
+
deleteState(site: string, target: string, itemId: string): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Invalidate WITH context (0.3.3): clear the verdict + device approvals so
|
|
57
|
+
* the item re-queues, but KEEP the row - prior verdict moves to
|
|
58
|
+
* prev_verdict, the note and fingerprint stay, and revisit_reason records
|
|
59
|
+
* why it is back. (Plain deleteState remains the no-context undo.)
|
|
60
|
+
*/
|
|
61
|
+
invalidateState(site: string, target: string, itemId: string, revisitReason: string): Promise<void>;
|
|
62
|
+
insertSession(site: string, session: NewSession): Promise<{
|
|
63
|
+
id: string;
|
|
64
|
+
}>;
|
|
65
|
+
listSessions(site: string, target?: string, limit?: number): Promise<SessionSummary[]>;
|
|
66
|
+
}
|
|
67
|
+
export interface PostgresStorageOptions {
|
|
68
|
+
/** Postgres connection string. Default: process.env.QA_REVIEW_DATABASE_URL. */
|
|
69
|
+
databaseUrl?: string;
|
|
70
|
+
/** Max pool size (serverless-friendly default: 1). */
|
|
71
|
+
max?: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Postgres-backed storage. Lazily connects and provisions its schema exactly
|
|
75
|
+
* once per process (transaction + advisory lock, so parallel cold starts on
|
|
76
|
+
* serverless cannot race each other).
|
|
77
|
+
*/
|
|
78
|
+
export declare function createPostgresStorage(opts?: PostgresStorageOptions): QAReviewStorage;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Storage layer for the QA review server handlers.
|
|
2
|
+
//
|
|
3
|
+
// The Postgres adapter SELF-PROVISIONS its own tables on first use via a tiny
|
|
4
|
+
// versioned migration runner (drizzle-style `__migrations` bookkeeping table,
|
|
5
|
+
// all names prefixed `qa_review_`), so consumers never hand-write migrations.
|
|
6
|
+
// A `site` scope column lets ONE database serve many installs.
|
|
7
|
+
import postgres from "postgres";
|
|
8
|
+
/* ------------------------- self-provisioning DDL --------------------------- */
|
|
9
|
+
// Append-only list; NEVER edit an applied entry - add a new one. Applied ids
|
|
10
|
+
// are tracked in qa_review_migrations (advisory-locked, concurrency-safe).
|
|
11
|
+
const MIGRATIONS = [
|
|
12
|
+
{
|
|
13
|
+
id: 1,
|
|
14
|
+
ddl: `
|
|
15
|
+
CREATE TABLE IF NOT EXISTS qa_review_state (
|
|
16
|
+
site text NOT NULL,
|
|
17
|
+
target text NOT NULL,
|
|
18
|
+
item_id text NOT NULL,
|
|
19
|
+
verdict text,
|
|
20
|
+
note text,
|
|
21
|
+
variant integer,
|
|
22
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
23
|
+
PRIMARY KEY (site, target, item_id)
|
|
24
|
+
);
|
|
25
|
+
CREATE TABLE IF NOT EXISTS qa_review_sessions (
|
|
26
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
27
|
+
site text NOT NULL,
|
|
28
|
+
target text NOT NULL,
|
|
29
|
+
reviewer text,
|
|
30
|
+
approved integer NOT NULL DEFAULT 0,
|
|
31
|
+
rejected integer NOT NULL DEFAULT 0,
|
|
32
|
+
total integer NOT NULL DEFAULT 0,
|
|
33
|
+
results jsonb NOT NULL,
|
|
34
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS qa_review_sessions_site_target_idx
|
|
37
|
+
ON qa_review_sessions (site, target);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS qa_review_sessions_created_idx
|
|
39
|
+
ON qa_review_sessions (created_at);
|
|
40
|
+
`,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 2,
|
|
44
|
+
// 0.3.0: NOT-ALTERED fingerprints + device-split approvals.
|
|
45
|
+
ddl: `
|
|
46
|
+
ALTER TABLE qa_review_state ADD COLUMN IF NOT EXISTS fp text;
|
|
47
|
+
ALTER TABLE qa_review_state ADD COLUMN IF NOT EXISTS approved_pc boolean;
|
|
48
|
+
ALTER TABLE qa_review_state ADD COLUMN IF NOT EXISTS approved_mobile boolean;
|
|
49
|
+
`,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 3,
|
|
53
|
+
// 0.3.3: re-queue context (revisit reason + prior verdict).
|
|
54
|
+
ddl: `
|
|
55
|
+
ALTER TABLE qa_review_state ADD COLUMN IF NOT EXISTS revisit_reason text;
|
|
56
|
+
ALTER TABLE qa_review_state ADD COLUMN IF NOT EXISTS prev_verdict text;
|
|
57
|
+
`,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
/** Arbitrary but stable advisory-lock key for provisioning. */
|
|
61
|
+
const PROVISION_LOCK_KEY = 727_001_337;
|
|
62
|
+
function sslSetting(url) {
|
|
63
|
+
if (process.env.NODE_ENV === "production")
|
|
64
|
+
return "require";
|
|
65
|
+
try {
|
|
66
|
+
const host = new URL(url).hostname;
|
|
67
|
+
const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
68
|
+
return isLocal ? undefined : "require";
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Postgres-backed storage. Lazily connects and provisions its schema exactly
|
|
76
|
+
* once per process (transaction + advisory lock, so parallel cold starts on
|
|
77
|
+
* serverless cannot race each other).
|
|
78
|
+
*/
|
|
79
|
+
export function createPostgresStorage(opts = {}) {
|
|
80
|
+
let sql = null;
|
|
81
|
+
let provisioned = null;
|
|
82
|
+
function connect() {
|
|
83
|
+
if (sql)
|
|
84
|
+
return sql;
|
|
85
|
+
const url = opts.databaseUrl ?? process.env.QA_REVIEW_DATABASE_URL;
|
|
86
|
+
if (!url) {
|
|
87
|
+
throw new Error("qa-review: no database URL. Pass databaseUrl or set QA_REVIEW_DATABASE_URL.");
|
|
88
|
+
}
|
|
89
|
+
sql = postgres(url, {
|
|
90
|
+
max: opts.max ?? 1,
|
|
91
|
+
prepare: false,
|
|
92
|
+
connect_timeout: 10,
|
|
93
|
+
ssl: sslSetting(url),
|
|
94
|
+
});
|
|
95
|
+
return sql;
|
|
96
|
+
}
|
|
97
|
+
function ensureProvisioned() {
|
|
98
|
+
if (provisioned)
|
|
99
|
+
return provisioned;
|
|
100
|
+
provisioned = (async () => {
|
|
101
|
+
const db = connect();
|
|
102
|
+
await db.begin(async (tx) => {
|
|
103
|
+
await tx `SELECT pg_advisory_xact_lock(${PROVISION_LOCK_KEY})`;
|
|
104
|
+
await tx `
|
|
105
|
+
CREATE TABLE IF NOT EXISTS qa_review_migrations (
|
|
106
|
+
id integer PRIMARY KEY,
|
|
107
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
108
|
+
)
|
|
109
|
+
`;
|
|
110
|
+
const applied = await tx `SELECT id FROM qa_review_migrations`;
|
|
111
|
+
const done = new Set(applied.map((r) => r.id));
|
|
112
|
+
for (const m of MIGRATIONS) {
|
|
113
|
+
if (done.has(m.id))
|
|
114
|
+
continue;
|
|
115
|
+
await tx.unsafe(m.ddl);
|
|
116
|
+
await tx `INSERT INTO qa_review_migrations (id) VALUES (${m.id})`;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
})();
|
|
120
|
+
// Allow a retry on transient failure instead of caching the rejection.
|
|
121
|
+
provisioned.catch(() => {
|
|
122
|
+
provisioned = null;
|
|
123
|
+
});
|
|
124
|
+
return provisioned;
|
|
125
|
+
}
|
|
126
|
+
async function ready() {
|
|
127
|
+
await ensureProvisioned();
|
|
128
|
+
return connect();
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
async getState(site, target) {
|
|
132
|
+
const db = await ready();
|
|
133
|
+
const rows = await db `
|
|
134
|
+
SELECT item_id, verdict, note, variant, fp, approved_pc, approved_mobile, revisit_reason, prev_verdict
|
|
135
|
+
FROM qa_review_state
|
|
136
|
+
WHERE site = ${site} AND target = ${target}
|
|
137
|
+
`;
|
|
138
|
+
const map = {};
|
|
139
|
+
for (const r of rows) {
|
|
140
|
+
const devices = [
|
|
141
|
+
...(r.approved_pc ? ["pc"] : []),
|
|
142
|
+
...(r.approved_mobile ? ["mobile"] : []),
|
|
143
|
+
];
|
|
144
|
+
map[r.item_id] = {
|
|
145
|
+
verdict: r.verdict ?? undefined,
|
|
146
|
+
note: r.note ?? undefined,
|
|
147
|
+
variant: r.variant ?? undefined,
|
|
148
|
+
fp: r.fp ?? undefined,
|
|
149
|
+
approvedDevices: devices.length ? devices : undefined,
|
|
150
|
+
revisitReason: r.revisit_reason ?? undefined,
|
|
151
|
+
prevVerdict: r.prev_verdict ?? undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return map;
|
|
155
|
+
},
|
|
156
|
+
async upsertState(site, target, itemId, patch) {
|
|
157
|
+
const db = await ready();
|
|
158
|
+
// Merge-upsert: only the provided fields overwrite; absent fields keep
|
|
159
|
+
// their stored value (COALESCE on excluded values via conditional set).
|
|
160
|
+
const verdict = patch.verdict === undefined ? null : patch.verdict;
|
|
161
|
+
const hasVerdict = patch.verdict !== undefined;
|
|
162
|
+
const note = patch.note === undefined ? null : patch.note;
|
|
163
|
+
const hasNote = patch.note !== undefined;
|
|
164
|
+
const variant = patch.variant === undefined ? null : patch.variant;
|
|
165
|
+
const hasVariant = patch.variant !== undefined;
|
|
166
|
+
const fp = patch.fp === undefined ? null : patch.fp;
|
|
167
|
+
const hasFp = patch.fp !== undefined;
|
|
168
|
+
const hasDevices = patch.approvedDevices !== undefined;
|
|
169
|
+
const approvedPc = hasDevices ? (patch.approvedDevices?.includes("pc") ?? false) : null;
|
|
170
|
+
const approvedMobile = hasDevices ? (patch.approvedDevices?.includes("mobile") ?? false) : null;
|
|
171
|
+
await db `
|
|
172
|
+
INSERT INTO qa_review_state (site, target, item_id, verdict, note, variant, fp, approved_pc, approved_mobile, updated_at)
|
|
173
|
+
VALUES (${site}, ${target}, ${itemId}, ${verdict}, ${note}, ${variant}, ${fp}, ${approvedPc}, ${approvedMobile}, now())
|
|
174
|
+
ON CONFLICT (site, target, item_id) DO UPDATE SET
|
|
175
|
+
verdict = CASE WHEN ${hasVerdict} THEN EXCLUDED.verdict ELSE qa_review_state.verdict END,
|
|
176
|
+
note = CASE WHEN ${hasNote} THEN EXCLUDED.note ELSE qa_review_state.note END,
|
|
177
|
+
variant = CASE WHEN ${hasVariant} THEN EXCLUDED.variant ELSE qa_review_state.variant END,
|
|
178
|
+
fp = CASE WHEN ${hasFp} THEN EXCLUDED.fp ELSE qa_review_state.fp END,
|
|
179
|
+
approved_pc = CASE WHEN ${hasDevices} THEN EXCLUDED.approved_pc ELSE qa_review_state.approved_pc END,
|
|
180
|
+
approved_mobile = CASE WHEN ${hasDevices} THEN EXCLUDED.approved_mobile ELSE qa_review_state.approved_mobile END,
|
|
181
|
+
revisit_reason = CASE WHEN ${hasVerdict} THEN NULL ELSE qa_review_state.revisit_reason END,
|
|
182
|
+
prev_verdict = CASE WHEN ${hasVerdict} THEN NULL ELSE qa_review_state.prev_verdict END,
|
|
183
|
+
updated_at = now()
|
|
184
|
+
`;
|
|
185
|
+
},
|
|
186
|
+
async deleteState(site, target, itemId) {
|
|
187
|
+
const db = await ready();
|
|
188
|
+
await db `
|
|
189
|
+
DELETE FROM qa_review_state
|
|
190
|
+
WHERE site = ${site} AND target = ${target} AND item_id = ${itemId}
|
|
191
|
+
`;
|
|
192
|
+
},
|
|
193
|
+
async invalidateState(site, target, itemId, revisitReason) {
|
|
194
|
+
const db = await ready();
|
|
195
|
+
// Keep the row: verdict -> prev_verdict, approvals cleared, note + fp
|
|
196
|
+
// retained (the fingerprint anchors the NOT-ALTERED comparison), reason
|
|
197
|
+
// recorded. Upsert so a reason can also be attached to a never-reviewed
|
|
198
|
+
// item ("reassess this" guidance).
|
|
199
|
+
await db `
|
|
200
|
+
INSERT INTO qa_review_state (site, target, item_id, revisit_reason, updated_at)
|
|
201
|
+
VALUES (${site}, ${target}, ${itemId}, ${revisitReason}, now())
|
|
202
|
+
ON CONFLICT (site, target, item_id) DO UPDATE SET
|
|
203
|
+
prev_verdict = COALESCE(qa_review_state.verdict, qa_review_state.prev_verdict),
|
|
204
|
+
verdict = NULL,
|
|
205
|
+
approved_pc = NULL,
|
|
206
|
+
approved_mobile = NULL,
|
|
207
|
+
revisit_reason = ${revisitReason},
|
|
208
|
+
updated_at = now()
|
|
209
|
+
`;
|
|
210
|
+
},
|
|
211
|
+
async insertSession(site, session) {
|
|
212
|
+
const db = await ready();
|
|
213
|
+
const [row] = await db `
|
|
214
|
+
INSERT INTO qa_review_sessions (site, target, reviewer, approved, rejected, total, results)
|
|
215
|
+
VALUES (
|
|
216
|
+
${site}, ${session.target}, ${session.reviewer},
|
|
217
|
+
${session.approved}, ${session.rejected}, ${session.total},
|
|
218
|
+
${db.json(session.results)}
|
|
219
|
+
)
|
|
220
|
+
RETURNING id
|
|
221
|
+
`;
|
|
222
|
+
return { id: row.id };
|
|
223
|
+
},
|
|
224
|
+
async listSessions(site, target, limit = 100) {
|
|
225
|
+
const db = await ready();
|
|
226
|
+
const rows = await db `
|
|
227
|
+
SELECT id, site, target, reviewer, approved, rejected, total, created_at
|
|
228
|
+
FROM qa_review_sessions
|
|
229
|
+
WHERE site = ${site} ${target ? db `AND target = ${target}` : db ``}
|
|
230
|
+
ORDER BY created_at DESC
|
|
231
|
+
LIMIT ${limit}
|
|
232
|
+
`;
|
|
233
|
+
return rows.map((r) => ({
|
|
234
|
+
id: r.id,
|
|
235
|
+
site: r.site,
|
|
236
|
+
target: r.target,
|
|
237
|
+
reviewer: r.reviewer,
|
|
238
|
+
approved: r.approved,
|
|
239
|
+
rejected: r.rejected,
|
|
240
|
+
total: r.total,
|
|
241
|
+
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at),
|
|
242
|
+
}));
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** FNV-1a 32-bit (deterministic across platforms/sessions). */
|
|
2
|
+
export declare function fnv1a(str: string): number;
|
|
3
|
+
/** Stable two-word codename for a (target, itemId) pair, e.g. "red-apple". */
|
|
4
|
+
export declare function codenameFor(target: string, itemId: string): string;
|
|
5
|
+
export interface CodenameEntry {
|
|
6
|
+
target: string;
|
|
7
|
+
itemId: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a spoken/written codename ("red-apple", "red apple", "Red Apple")
|
|
11
|
+
* back to items. Returns ALL matches (codename space is 4096; collisions are
|
|
12
|
+
* possible across many items).
|
|
13
|
+
*/
|
|
14
|
+
export declare function findByCodename<T extends CodenameEntry>(codename: string, entries: readonly T[]): T[];
|
|
15
|
+
/** Canonical copy-reference line for an item ("Copy ref" button payload). */
|
|
16
|
+
export declare function formatQARef(target: string, itemId: string, title: string): string;
|