@snagset/server 0.1.16 → 0.1.19
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/LICENSE +661 -0
- package/README.md +9 -10
- package/dist/app.js +51 -47
- package/dist/app.js.map +1 -1
- package/dist/bin.js +31 -32
- package/dist/bin.js.map +1 -1
- package/dist/boot.js +21 -21
- package/dist/boot.js.map +1 -1
- package/dist/config.generated.js +5 -8
- package/dist/config.generated.js.map +1 -1
- package/dist/config.js +14 -14
- package/dist/config.js.map +1 -1
- package/dist/crypto.js +1 -1
- package/dist/crypto.js.map +1 -1
- package/dist/db/connect.js +1 -1
- package/dist/db/migrate.js +11 -11
- package/dist/db/migrations/0010_round_archive.sql +23 -0
- package/dist/db/rls.js +3 -3
- package/dist/db/rls.js.map +1 -1
- package/dist/domain.js +73 -5
- package/dist/domain.js.map +1 -1
- package/dist/explain.js +1 -1
- package/dist/instance/address.js +3 -3
- package/dist/instance/address.js.map +1 -1
- package/dist/instance/settings.js +1 -1
- package/dist/integrations/github.js +3 -3
- package/dist/integrations/github.js.map +1 -1
- package/dist/integrations/inbound.js +3 -3
- package/dist/integrations/inbound.js.map +1 -1
- package/dist/integrations/linear.js +1 -1
- package/dist/integrations/linear.js.map +1 -1
- package/dist/integrations/outbox.js +4 -4
- package/dist/integrations/outbox.js.map +1 -1
- package/dist/integrations/slack.js +2 -2
- package/dist/integrations/slack.js.map +1 -1
- package/dist/jobs/index.js +3 -3
- package/dist/jobs/reaper.js +11 -11
- package/dist/jobs/reaper.js.map +1 -1
- package/dist/jobs/set.js +3 -3
- package/dist/jobs/set.js.map +1 -1
- package/dist/jobs/soon.js +3 -3
- package/dist/keys.js +3 -3
- package/dist/keys.js.map +1 -1
- package/dist/listen.js +3 -3
- package/dist/listen.js.map +1 -1
- package/dist/mail/assigned.js +10 -10
- package/dist/mail/assigned.js.map +1 -1
- package/dist/mail/digest.js +3 -3
- package/dist/mail/digest.js.map +1 -1
- package/dist/mail/index.js +2 -2
- package/dist/mail/links.js +5 -5
- package/dist/mail/mailer.js +2 -2
- package/dist/mail/notify.js +22 -22
- package/dist/mail/notify.js.map +1 -1
- package/dist/mail/resend.js +1 -1
- package/dist/mail/resolve.js +1 -1
- package/dist/mail/resolve.js.map +1 -1
- package/dist/mail/smtp.js +1 -1
- package/dist/mail/unsubscribe.js +1 -1
- package/dist/net/publicFetch.js +1 -1
- package/dist/orgs.js +5 -5
- package/dist/orgs.js.map +1 -1
- package/dist/plan.js +1 -1
- package/dist/projects.js +4 -4
- package/dist/projects.js.map +1 -1
- package/dist/routes/account.js +1 -1
- package/dist/routes/account.js.map +1 -1
- package/dist/routes/activity.js +1 -1
- package/dist/routes/anchorReports.js +9 -9
- package/dist/routes/anchorReports.js.map +1 -1
- package/dist/routes/assignees.js +4 -4
- package/dist/routes/assignees.js.map +1 -1
- package/dist/routes/attachments.js +6 -6
- package/dist/routes/attachments.js.map +1 -1
- package/dist/routes/auth.js +4 -4
- package/dist/routes/bodyLimit.js +5 -5
- package/dist/routes/bodyLimit.js.map +1 -1
- package/dist/routes/commentEdit.js +5 -5
- package/dist/routes/commentEdit.js.map +1 -1
- package/dist/routes/comments.js +24 -24
- package/dist/routes/comments.js.map +1 -1
- package/dist/routes/cors.js +33 -15
- package/dist/routes/cors.js.map +1 -1
- package/dist/routes/instance.js +3 -3
- package/dist/routes/instance.js.map +1 -1
- package/dist/routes/integrations.js +14 -11
- package/dist/routes/integrations.js.map +1 -1
- package/dist/routes/invites.js +7 -7
- package/dist/routes/invites.js.map +1 -1
- package/dist/routes/mcpAuth.js +13 -13
- package/dist/routes/mcpAuth.js.map +1 -1
- package/dist/routes/mcpToken.js +4 -4
- package/dist/routes/mcpToken.js.map +1 -1
- package/dist/routes/mcpTokens.js +27 -14
- package/dist/routes/mcpTokens.js.map +1 -1
- package/dist/routes/members.js +9 -5
- package/dist/routes/members.js.map +1 -1
- package/dist/routes/owner.js +13 -13
- package/dist/routes/owner.js.map +1 -1
- package/dist/routes/reviewLinks.js +4 -4
- package/dist/routes/reviewLinks.js.map +1 -1
- package/dist/routes/rounds.js +131 -38
- package/dist/routes/rounds.js.map +1 -1
- package/dist/routes/sessions.js +190 -73
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/sites.js +9 -9
- package/dist/routes/sites.js.map +1 -1
- package/dist/routes/threads.js +298 -141
- package/dist/routes/threads.js.map +1 -1
- package/dist/routes/widget.js +5 -5
- package/dist/routes/widget.js.map +1 -1
- package/dist/serverless.js +4 -4
- package/dist/serverless.js.map +1 -1
- package/dist/session.js +8 -8
- package/dist/session.js.map +1 -1
- package/dist/storage/bucket.js +4 -4
- package/dist/storage/bucket.js.map +1 -1
- package/dist/storage/settings.js +4 -4
- package/dist/storage/settings.js.map +1 -1
- package/dist/studioReviewers.js +4 -4
- package/dist/vendor/adapters/outbound/fence.js +2 -2
- package/dist/vendor/adapters/outbound/sanitise.js +14 -14
- package/dist/vendor/mcp/bin.js +4 -4
- package/dist/vendor/mcp/client.js +3 -3
- package/dist/vendor/mcp/server.js +3 -3
- package/dist/vendor/mcp/source.js +13 -13
- package/dist/vendor/mcp/view.js +15 -15
- package/dist/vendor/types/brief.js +2 -2
- package/dist/vendor/types/index.js +1 -1
- package/dist/vendor/types/names.js +20 -18
- package/dist/vendor/widget/object.js +3 -3
- package/dist/vendor/widget/predicates.js +38 -39
- package/package.json +1 -5
- package/static/dashboard/assets/{CompositeItem-DP7FLsIz.js → CompositeItem-wqcRki5s.js} +1 -1
- package/static/dashboard/assets/{ConfirmDialog-B-mNmk6U.js → ConfirmDialog-BqK5eJxB.js} +1 -1
- package/static/dashboard/assets/{CopyButton-bsaLPvqU.js → CopyButton-Cc4nF6JB.js} +1 -1
- package/static/dashboard/assets/{FieldItemContext-Cz9anwiZ.js → FieldItemContext-DVLVe_H8.js} +1 -1
- package/static/dashboard/assets/{InstallTag-DDMHFPFi.js → InstallTag-DCRj1B-Z.js} +1 -1
- package/static/dashboard/assets/{SaveState-rgq6rRHH.js → SaveState-B5TIvzLh.js} +1 -1
- package/static/dashboard/assets/{_app-B--Q2p1D.js → _app-BOhhOcC-.js} +1 -1
- package/static/dashboard/assets/{_app-C5aeP_jI.js → _app-DdvP8iZf.js} +1 -1
- package/static/dashboard/assets/{activity-CmpMwfWp.js → activity-CaCW3Nql.js} +1 -1
- package/static/dashboard/assets/{arrow-left-BN3lUG8m.js → arrow-left-B5Z7SkRz.js} +1 -1
- package/static/dashboard/assets/{avatar-4OkpxHzf.js → avatar-CFgdRtiV.js} +1 -1
- package/static/dashboard/assets/{badge-BGUQxxes.js → badge-BIVx2feC.js} +1 -1
- package/static/dashboard/assets/{card-BX32udhB.js → card-HwCaU_bv.js} +1 -1
- package/static/dashboard/assets/{check-DjkY9Lhe.js → check-CMqBU0e3.js} +1 -1
- package/static/dashboard/assets/{chevron-right-w26JMtvi.js → chevron-right-CY_sUg3i.js} +1 -1
- package/static/dashboard/assets/{copy-BtOpNPn3.js → copy-Cn6p0txj.js} +1 -1
- package/static/dashboard/assets/{dialog-Ca351GqX.js → dialog-_GPHTnXl.js} +2 -2
- package/static/dashboard/assets/{dropdown-menu-BdM8D0_i.js → dropdown-menu-BEcLmKR6.js} +1 -1
- package/static/dashboard/assets/{image-off-DBc5pfc8.js → image-off-CyXpcTZd.js} +1 -1
- package/static/dashboard/assets/{index-Bvmz5Xph.js → index-BZAfm4kX.js} +14 -14
- package/static/dashboard/assets/index-BzWDSuyB.css +1 -0
- package/static/dashboard/assets/{invite-b1vVPR4n.js → invite-iirWwB3z.js} +1 -1
- package/static/dashboard/assets/{label-CeHVAC98.js → label-C9GdL6JP.js} +1 -1
- package/static/dashboard/assets/{layout-grid-BJUJgcuD.js → layout-grid-3hfjyzci.js} +1 -1
- package/static/dashboard/assets/me-Bgw9jS9F.js +1 -0
- package/static/dashboard/assets/{parts-BU_5BLga.js → parts-BOtvDu_v.js} +1 -1
- package/static/dashboard/assets/{plus-DW3meZh8.js → plus-CTKInfcq.js} +1 -1
- package/static/dashboard/assets/s._site-Bi7roN7x.js +1 -0
- package/static/dashboard/assets/s._site.comments-DZBq2FkM.js +54 -0
- package/static/dashboard/assets/s._site.reviewers-YfizOkhz.js +1 -0
- package/static/dashboard/assets/s._site.settings-Dbua1pYa.js +1 -0
- package/static/dashboard/assets/{setup-B5fdtoM-.js → setup-4E2PxysX.js} +1 -1
- package/static/dashboard/assets/{sign-in-D9Bhw619.js → sign-in-Nda93fWo.js} +1 -1
- package/static/dashboard/assets/{sign-up-CK-LWLzn.js → sign-up-kWIqmM8W.js} +1 -1
- package/static/dashboard/assets/{sites-CLB6RzU6.js → sites-DbhDznKA.js} +1 -1
- package/static/dashboard/assets/studio-MNEmZ3n7.js +1 -0
- package/static/dashboard/assets/{studio-CVFUbJQb.js → studio-ml7KCROJ.js} +1 -1
- package/static/dashboard/assets/{studio.index-CUtMjh5_.js → studio.index-CZXgB0jx.js} +1 -1
- package/static/dashboard/assets/studio.integrations-OprNYBrJ.js +1 -0
- package/static/dashboard/assets/{studio.members-BcVrAT7r.js → studio.members-DT93LEwF.js} +1 -1
- package/static/dashboard/assets/{sun-BzCBfLxA.js → sun--lA9pLT9.js} +1 -1
- package/static/dashboard/assets/{switch-Djsy8JA6.js → switch-BCNx3nDC.js} +1 -1
- package/static/dashboard/assets/{table-Cnpowiv-.js → table-B1e35-OI.js} +1 -1
- package/static/dashboard/assets/{useAriaLabelledBy-D35XdVIh.js → useAriaLabelledBy-BLgC8mE_.js} +1 -1
- package/static/dashboard/assets/{useBaseQuery-DztMyAqa.js → useBaseQuery-CZkDX1tZ.js} +1 -1
- package/static/dashboard/assets/useQuery-C0BVsxqZ.js +1 -0
- package/static/dashboard/assets/useRender-P5Wj_Sbt.js +1 -0
- package/static/dashboard/assets/{useValueChanged-DY7n1sYE.js → useValueChanged-CCRl5Bf5.js} +1 -1
- package/static/dashboard/assets/{users-CR2o4PsB.js → users-DCeMsa2g.js} +1 -1
- package/static/dashboard/assets/{welcome-DJ_8xHTE.js → welcome-Il9zYmvM.js} +1 -1
- package/static/dashboard/assets/{widget-signin-BOulktuR.js → widget-signin-BLNsOS5K.js} +1 -1
- package/static/dashboard/assets/{you-DJGezimL.js → you-B2lWBXCD.js} +1 -1
- package/static/dashboard/assets/{zod-4RphBaVj.js → zod-DG4VLz0r.js} +1 -1
- package/static/dashboard/index.html +2 -2
- package/static/widget/snag.capture.js +1 -1
- package/static/widget/snag.dock.js +2 -2
- package/static/widget/snag.panels.js +2 -2
- package/static/widget/snag.runtime.js +2 -2
- package/static/dashboard/assets/index-DAgnfv_E.css +0 -1
- package/static/dashboard/assets/me-zNn3-t75.js +0 -1
- package/static/dashboard/assets/s._site-DP3hLlXp.js +0 -1
- package/static/dashboard/assets/s._site.comments-CyIqVrl3.js +0 -54
- package/static/dashboard/assets/s._site.reviewers-muaVzTtT.js +0 -1
- package/static/dashboard/assets/s._site.settings-Bow6VdXW.js +0 -1
- package/static/dashboard/assets/studio-CuiXjnSt.js +0 -1
- package/static/dashboard/assets/studio.integrations-CiQ83f7Q.js +0 -1
- package/static/dashboard/assets/useQuery-DCEPkmeo.js +0 -1
- package/static/dashboard/assets/useRender-C2NyqaXd.js +0 -1
package/dist/routes/threads.js
CHANGED
|
@@ -13,13 +13,13 @@ import { queueIntegrationEvent } from "../integrations/outbox.js";
|
|
|
13
13
|
import { inOrg } from "../db/scope.js";
|
|
14
14
|
import { isStudioModerator } from "../studioReviewers.js";
|
|
15
15
|
/**
|
|
16
|
-
* Wire id ⇄ row id
|
|
16
|
+
* Wire id ⇄ row id, verbatim: *"id uuid PRIMARY KEY — client-minted
|
|
17
17
|
* UUIDv7; wire form 'thr_…'"*.
|
|
18
18
|
*
|
|
19
19
|
* The row is a bare uuid so it indexes and joins as one; the prefixed form is
|
|
20
20
|
* the identity everything OUTSIDE the database uses, because a bare uuid in a
|
|
21
21
|
* webhook payload, a GitHub issue marker or a support ticket tells you nothing
|
|
22
|
-
* about what you are looking at
|
|
22
|
+
* about what you are looking at.
|
|
23
23
|
*
|
|
24
24
|
* Both directions live here rather than at each call site. The widget mints
|
|
25
25
|
* the id, so the very first POST arrives prefixed, and a server that stores
|
|
@@ -34,10 +34,10 @@ function rowId(wire, prefix) {
|
|
|
34
34
|
}
|
|
35
35
|
const wireId_ = (row, prefix) => prefix + row;
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
37
|
+
* The envelope. `details` was missing from this file's copy while
|
|
38
38
|
* `comments.ts` had it, so every `field_not_permitted` here — including the
|
|
39
39
|
* long-standing refusal to move a thread between rounds — shipped without the
|
|
40
|
-
* `details.field`
|
|
40
|
+
* `details.field` registers for that code. A client cannot mark the
|
|
41
41
|
* offending input without it.
|
|
42
42
|
*/
|
|
43
43
|
function fail(c, status, code, message, details) {
|
|
@@ -45,8 +45,8 @@ function fail(c, status, code, message, details) {
|
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
47
|
* Every request runs inside a transaction with `app.org_id` set, which is what
|
|
48
|
-
* makes
|
|
49
|
-
* connecting role can bypass them
|
|
48
|
+
* makes the RLS policies do anything at all. It is set even where the
|
|
49
|
+
* connecting role can bypass them — the day someone points DATABASE_URL
|
|
50
50
|
* at a properly restricted role, every query here has to already be correct,
|
|
51
51
|
* and a `SET LOCAL` added later would be added under incident conditions.
|
|
52
52
|
*/
|
|
@@ -58,12 +58,12 @@ function fail(c, status, code, message, details) {
|
|
|
58
58
|
* added to each by hand after a link session's list was caught carrying a
|
|
59
59
|
* private reply: a rule answered in three places drifts.
|
|
60
60
|
*
|
|
61
|
-
* ── TEAM-ONLY
|
|
61
|
+
* ── TEAM-ONLY ──
|
|
62
62
|
*
|
|
63
63
|
* A `private` reply is for the studio's eyes and never reaches a reviewer
|
|
64
64
|
* session or an agent. The owner sees all of them.
|
|
65
65
|
*
|
|
66
|
-
* ── TOMBSTONES, FOR THE OWNER ONLY
|
|
66
|
+
* ── TOMBSTONES, FOR THE OWNER ONLY ──
|
|
67
67
|
*
|
|
68
68
|
* A deleted comment is a row with `body` NULL. Everyone else gets it filtered
|
|
69
69
|
* out, as before — the widget drops it and has nothing to render a hole with.
|
|
@@ -74,10 +74,10 @@ function fail(c, status, code, message, details) {
|
|
|
74
74
|
* on the studio's screen as a tombstone exactly until the next poll replaced
|
|
75
75
|
* the row, and then gone.
|
|
76
76
|
*
|
|
77
|
-
* The tombstone is
|
|
78
|
-
* NAME only — plus `createdAt`, which
|
|
77
|
+
* The tombstone is the shape — id, NULL body, deletedAt, the author's
|
|
78
|
+
* NAME only — plus `createdAt`, which leaves out and the studio needs:
|
|
79
79
|
* a hole is placed in time by when it was written. `seq` stays server-side
|
|
80
|
-
* for
|
|
80
|
+
* for the reason, and the author's id and kind go with the words.
|
|
81
81
|
*/
|
|
82
82
|
function commentsJson(tx, asOwner) {
|
|
83
83
|
return tx `COALESCE(
|
|
@@ -97,7 +97,7 @@ function commentsJson(tx, asOwner) {
|
|
|
97
97
|
'[]'::json)`;
|
|
98
98
|
}
|
|
99
99
|
export function threadRoutes(sql,
|
|
100
|
-
/** The 10-second cache
|
|
100
|
+
/** The 10-second cache, not a fresh SELECT per handler. Every
|
|
101
101
|
* route here starts by resolving `{uid}`, and on a database one round trip
|
|
102
102
|
* away that lookup was costing more than the work it introduced. */
|
|
103
103
|
projects,
|
|
@@ -106,8 +106,8 @@ projects,
|
|
|
106
106
|
* not need to know how storage is configured. */
|
|
107
107
|
features,
|
|
108
108
|
/**
|
|
109
|
-
* Is a mail transport configured
|
|
110
|
-
* and for the same reason: with no transport,
|
|
109
|
+
* Is a mail transport configured ? Same gate `commentRoutes` takes,
|
|
110
|
+
* and for the same reason: with no transport, the design wants notifications
|
|
111
111
|
* "off and say so, rather than silently queueing" — rows that would otherwise
|
|
112
112
|
* accumulate for the life of the install and arrive in one burst the day
|
|
113
113
|
* somebody configures Resend.
|
|
@@ -116,12 +116,12 @@ notifications = async () => false,
|
|
|
116
116
|
/** Where this instance lives (SNAGSET_BASE_URL) — the brief's screenshot links are absolute. */
|
|
117
117
|
baseUrl = async () => "http://localhost:8080",
|
|
118
118
|
/**
|
|
119
|
-
*
|
|
119
|
+
* The site's tracker connections — id, provider, and whether
|
|
120
120
|
* issues are made on every new thread. Injected so this file never opens
|
|
121
121
|
* a token; the worker does that.
|
|
122
122
|
*/
|
|
123
123
|
trackers = async () => [],
|
|
124
|
-
/**
|
|
124
|
+
/** How long an assignment waits before its email (SNAGSET_ASSIGN_MAIL_DELAY_S). */
|
|
125
125
|
assignDelayMs) {
|
|
126
126
|
const app = new Hono();
|
|
127
127
|
/** Resolve `{uid}`, and 404 rather than leak whether a uid exists. */
|
|
@@ -132,7 +132,7 @@ assignDelayMs) {
|
|
|
132
132
|
// request and, worse, ran AFTER the real one and overwrote
|
|
133
133
|
// `Access-Control-Allow-Headers` with a hand-typed `content-type` — harmless
|
|
134
134
|
// only because a preflight never reaches this far.
|
|
135
|
-
/**
|
|
135
|
+
/** What the widget reads at mount to know what this deployment can do. */
|
|
136
136
|
app.get("/api/v1/projects/:uid/config", async (c) => {
|
|
137
137
|
const p = await project(c.req.param("uid"));
|
|
138
138
|
if (!p)
|
|
@@ -140,7 +140,7 @@ assignDelayMs) {
|
|
|
140
140
|
c.header("x-snagset-config-version", String(p.config_version));
|
|
141
141
|
c.header("ETag", `"${p.config_etag}"`);
|
|
142
142
|
/**
|
|
143
|
-
* CACHEABLE, briefly —
|
|
143
|
+
* CACHEABLE, briefly — the decision makes this the stub's visibility check, fetched
|
|
144
144
|
* once per visitor per page-load-cluster rather than per page. 60 seconds:
|
|
145
145
|
* long enough that a browsing session costs one request, short enough that
|
|
146
146
|
* flipping the audience in the dashboard lands within a minute.
|
|
@@ -151,7 +151,7 @@ assignDelayMs) {
|
|
|
151
151
|
// full body to a stranger origin, and the split below would be theatre.
|
|
152
152
|
c.header("Vary", "Origin");
|
|
153
153
|
/**
|
|
154
|
-
*
|
|
154
|
+
* The ORIGIN-AWARE BODY, finally conformed to — the maintainer's
|
|
155
155
|
* question, verbatim: "the api gets the url that should be there and then
|
|
156
156
|
* just validates in the frontend?" Which is this design: the server never
|
|
157
157
|
* learns the page's URL (the browser sends only its origin, which CORS
|
|
@@ -161,14 +161,14 @@ assignDelayMs) {
|
|
|
161
161
|
* for it is printed in their page source.
|
|
162
162
|
*
|
|
163
163
|
* A stranger origin still gets a 200 with the public subset (writeMode
|
|
164
|
-
* included) rather than a 403: the fetch is also how
|
|
164
|
+
* included) rather than a 403: the fetch is also how the origin
|
|
165
165
|
* suggestion is born, and the stub on an archive.org mirror needs a
|
|
166
166
|
* readable "writable: false" to stay dormant — a CORS-omitted header would
|
|
167
167
|
* do that too, but this route is public-read so the suggestion flow works.
|
|
168
168
|
* `originAllowed` is absent (never false) when there is no Origin header:
|
|
169
169
|
* same-origin pages and curl are not strangers.
|
|
170
170
|
*/
|
|
171
|
-
//
|
|
171
|
+
// The switch, layer 6: a paused site is writable from
|
|
172
172
|
// nowhere, and the stub reads `writable` and stays dormant. Said before
|
|
173
173
|
// the origin verdict, because "switched off" is the more useful sentence.
|
|
174
174
|
const paused = p.status !== "active";
|
|
@@ -182,9 +182,9 @@ assignDelayMs) {
|
|
|
182
182
|
// mode is a complete product, not a degraded one.
|
|
183
183
|
features: await features(p.org_id),
|
|
184
184
|
/**
|
|
185
|
-
* EFFECTIVE, not the raw column —
|
|
185
|
+
* EFFECTIVE, not the raw column — turns this field into the thing
|
|
186
186
|
* that decides whether every visitor sees the toolbar, and the raw value
|
|
187
|
-
* lies during
|
|
187
|
+
* lies during the grace: a site 25 hours old still says `open` while
|
|
188
188
|
* every write is already refused. The session route learned this first
|
|
189
189
|
* (see effectiveWriteMode's own comment); the config route repeating the
|
|
190
190
|
* raw column would show a toolbar nobody can type into.
|
|
@@ -194,13 +194,15 @@ assignDelayMs) {
|
|
|
194
194
|
};
|
|
195
195
|
if (!writable)
|
|
196
196
|
return c.json({ ...base, reason: paused ? "project_paused" : "origin_not_allowed" });
|
|
197
|
-
// T10
|
|
197
|
+
// T10/: the round HISTORY rides the config the widget already fetches
|
|
198
198
|
// at mount, so the dock's selector costs no extra request. Behind the
|
|
199
|
-
// origin split with `keyPrefix
|
|
200
|
-
// on the project, which is not the same as public.
|
|
199
|
+
// origin split with `keyPrefix`: the least sensitive PRIVATE things
|
|
200
|
+
// on the project, which is not the same as public. An ARCHIVED round is
|
|
201
|
+
// left out: the studio put it away, and the selector is where it
|
|
202
|
+
// would otherwise come back for every reviewer.
|
|
201
203
|
const rounds = await inOrg(sql, p.org_id, (tx) => tx `
|
|
202
204
|
SELECT number, name, closed_at IS NOT NULL AS closed
|
|
203
|
-
FROM rounds WHERE project_id = ${p.id} AND deleted_at IS NULL
|
|
205
|
+
FROM rounds WHERE project_id = ${p.id} AND deleted_at IS NULL AND archived_at IS NULL
|
|
204
206
|
ORDER BY number DESC
|
|
205
207
|
`);
|
|
206
208
|
return c.json({
|
|
@@ -210,7 +212,7 @@ assignDelayMs) {
|
|
|
210
212
|
});
|
|
211
213
|
});
|
|
212
214
|
/**
|
|
213
|
-
*
|
|
215
|
+
* The list, endpoint 7, which the route table marks **W/D**.
|
|
214
216
|
*
|
|
215
217
|
* ONE endpoint, TWO audiences, and the credential decides the scope. That is
|
|
216
218
|
* the spec's design and not a shortcut: a dashboard is not a different kind
|
|
@@ -227,7 +229,7 @@ assignDelayMs) {
|
|
|
227
229
|
* cross-origin credentialed read is refused by the browser whatever this
|
|
228
230
|
* returns.
|
|
229
231
|
*
|
|
230
|
-
* `route` is the generated projection off the anchor
|
|
232
|
+
* `route` is the generated projection off the anchor, so filtering by
|
|
231
233
|
* it is an index scan rather than a JSONB walk on every row.
|
|
232
234
|
*/
|
|
233
235
|
app.get("/api/v1/projects/:uid/threads", async (c) => {
|
|
@@ -241,7 +243,7 @@ assignDelayMs) {
|
|
|
241
243
|
const owner = c.get("owner");
|
|
242
244
|
const asOwner = !!owner && owner.orgId === p.org_id;
|
|
243
245
|
/**
|
|
244
|
-
* An MCP token with `threads:read`
|
|
246
|
+
* An MCP token with `threads:read` reads with the studio's
|
|
245
247
|
* FILTERS but not the studio's PRIVACY, and the split is deliberate.
|
|
246
248
|
*
|
|
247
249
|
* Filters: an agent asked to "list the open snags on checkout" needs
|
|
@@ -249,10 +251,10 @@ assignDelayMs) {
|
|
|
249
251
|
* Denying those would make `list_threads` return the latest open round of
|
|
250
252
|
* the default environment and call it the project.
|
|
251
253
|
*
|
|
252
|
-
* Privacy: `private` threads and team-only replies
|
|
254
|
+
* Privacy: `private` threads and team-only replies stay OUT. The
|
|
253
255
|
* scope table grants "list and read threads, comments, anchor state" and
|
|
254
|
-
* says nothing about private, while
|
|
255
|
-
* minimalism* and
|
|
256
|
+
* says nothing about private, while the design is *scope
|
|
257
|
+
* minimalism* and separates `context:read` precisely because the
|
|
256
258
|
* personal-data-bearing half should not ride along by default. A team-only
|
|
257
259
|
* note is the studio talking about the client; an agent that reads one and
|
|
258
260
|
* quotes it into a pull request has published it.
|
|
@@ -268,8 +270,8 @@ assignDelayMs) {
|
|
|
268
270
|
// Better a sentence naming the missing scope than a confident short list.
|
|
269
271
|
if (mcp && !asAgent)
|
|
270
272
|
return scopeDenied(c, "threads:read");
|
|
271
|
-
//
|
|
272
|
-
// gated the same way (`asOwner ?
|
|
273
|
+
// The filters. `status` and `q` stay dashboard-only. `round` used to be
|
|
274
|
+
// gated the same way (`asOwner ? …: null`), which is the bug: a
|
|
273
275
|
// review-link session's `?round` was silently discarded and every reader
|
|
274
276
|
// without an owner cookie saw every round's pins forever, including ones
|
|
275
277
|
// long since closed. A reviewer session may now ask for a round exactly
|
|
@@ -279,7 +281,7 @@ assignDelayMs) {
|
|
|
279
281
|
const requestedRound = Number(c.req.query("round")) || null;
|
|
280
282
|
const q = asStudio ? (c.req.query("q") ?? "").trim() : "";
|
|
281
283
|
/**
|
|
282
|
-
* `?assignee=rev_…|unassigned` —
|
|
284
|
+
* `?assignee=rev_…|unassigned` — the triage filter ("what has
|
|
283
285
|
* nobody picked up"). `unassigned` is a word rather than an empty value
|
|
284
286
|
* because `assignee=` with nothing after it is indistinguishable from an
|
|
285
287
|
* absent parameter on the wire. Studio-only, like status and q: a filter
|
|
@@ -294,7 +296,7 @@ assignDelayMs) {
|
|
|
294
296
|
}
|
|
295
297
|
}
|
|
296
298
|
/**
|
|
297
|
-
* `?since=` —
|
|
299
|
+
* `?since=` — the delta poll, and the whole realtime story.
|
|
298
300
|
*
|
|
299
301
|
* *"`GET …/threads?since=<iso>` returns everything with `updated_at >
|
|
300
302
|
* since`, plus the `deleted[]` tombstone array. The client keeps
|
|
@@ -306,14 +308,14 @@ assignDelayMs) {
|
|
|
306
308
|
* forever in a background tab.
|
|
307
309
|
*/
|
|
308
310
|
/**
|
|
309
|
-
* `?key=SN-14` —
|
|
311
|
+
* `?key=SN-14` — the server-side permalink, and the thing that makes
|
|
310
312
|
* a shared link survive a round.
|
|
311
313
|
*
|
|
312
314
|
* `?snag_t=SN-14` resolves LOCALLY in the widget, against the list it
|
|
313
315
|
* already holds. That list is round-scoped: a non-owner reader defaults to
|
|
314
316
|
* the latest OPEN round. So a permalink to a thread from a closed round —
|
|
315
317
|
* which is most permalinks anyone bothers to share, since they share the
|
|
316
|
-
* ones that mattered — found nothing and showed F-
|
|
318
|
+
* ones that mattered — found nothing and showed F-the "we could not find
|
|
317
319
|
* that comment". The thread was there the whole time, one round back.
|
|
318
320
|
*
|
|
319
321
|
* An exact match on `UNIQUE (project_id, key)`, so at most one row, and it
|
|
@@ -321,7 +323,7 @@ assignDelayMs) {
|
|
|
321
323
|
* named a specific thread, which is a stronger statement of intent than
|
|
322
324
|
* any default. Privacy is not ignored — a private thread is absent for a
|
|
323
325
|
* non-owner, which reads as "no such key" rather than confirming it exists
|
|
324
|
-
* (
|
|
326
|
+
* (the rule for `GET /threads/{id}`).
|
|
325
327
|
*/
|
|
326
328
|
const key = c.req.query("key")?.trim().toUpperCase();
|
|
327
329
|
const sinceRaw = c.req.query("since");
|
|
@@ -336,7 +338,7 @@ assignDelayMs) {
|
|
|
336
338
|
/**
|
|
337
339
|
* ── WHY `visible` IS A COLUMN AND NOT A FILTER ──
|
|
338
340
|
*
|
|
339
|
-
*
|
|
341
|
+
* Acceptance A36: a thread the studio marks `private` must land
|
|
340
342
|
* in the caller's `deleted[]` so a cached copy is DROPPED. With the
|
|
341
343
|
* predicate in the WHERE clause the row simply stops being returned, which
|
|
342
344
|
* is indistinguishable from "nothing changed" — the client keeps showing a
|
|
@@ -345,7 +347,7 @@ assignDelayMs) {
|
|
|
345
347
|
*
|
|
346
348
|
* Only the two genuine VISIBILITY conditions are computed. Status, search,
|
|
347
349
|
* environment and round are FILTERS: status does not hide a thread, and a
|
|
348
|
-
* thread can never change environment or round (
|
|
350
|
+
* thread can never change environment or round (`environment_id` is
|
|
349
351
|
* set at creation and a thread never moves), so none of them can take a
|
|
350
352
|
* client from seeing a thread to not seeing it.
|
|
351
353
|
*
|
|
@@ -356,6 +358,29 @@ assignDelayMs) {
|
|
|
356
358
|
* explanation.
|
|
357
359
|
*/
|
|
358
360
|
const rows = await inOrg(sql, p.org_id, async (tx) => {
|
|
361
|
+
// Absent round query param: an OWNER still sees every round
|
|
362
|
+
// (unchanged), but a non-owner reader defaults to the latest OPEN
|
|
363
|
+
// round, so a fresh page load is -clean rather than showing a round
|
|
364
|
+
// that closed weeks ago. Not an archived or deleted one either:
|
|
365
|
+
// the same round a new thread would land in. The correlated subquery is
|
|
366
|
+
// cheap — one project's round table is a handful of rows.
|
|
367
|
+
//
|
|
368
|
+
// ON A POLL the predicate moves into `visible`, like privacy does: a
|
|
369
|
+
// thread the studio MOVED to another round has to come back as a
|
|
370
|
+
// tombstone for the list it left, and a WHERE clause would only make it
|
|
371
|
+
// vanish from the delta, so the old round's list kept showing it.
|
|
372
|
+
const inRound = key
|
|
373
|
+
? null
|
|
374
|
+
: requestedRound
|
|
375
|
+
? tx `rd.number = ${requestedRound}`
|
|
376
|
+
: asStudio
|
|
377
|
+
? null
|
|
378
|
+
: tx `rd.number = (
|
|
379
|
+
SELECT number FROM rounds
|
|
380
|
+
WHERE project_id = ${p.id} AND closed_at IS NULL
|
|
381
|
+
AND deleted_at IS NULL AND archived_at IS NULL
|
|
382
|
+
ORDER BY number DESC LIMIT 1
|
|
383
|
+
)`;
|
|
359
384
|
return tx `
|
|
360
385
|
SELECT t.id, t.key, t.status, t.version, t.anchor, t.anchor_state, t.context, t.round_number,
|
|
361
386
|
t.created_at, t.updated_at, t.resolved_at, t.comment_count_public,
|
|
@@ -383,7 +408,8 @@ assignDelayMs) {
|
|
|
383
408
|
-- VISIBLE, computed rather than filtered. See the note above
|
|
384
409
|
-- the query: a thread that becomes private must be TOMBSTONED,
|
|
385
410
|
-- and a WHERE clause can only make it vanish.
|
|
386
|
-
(t.deleted_at IS NULL AND ${asOwner ? tx `true` : tx `t.private = false`}
|
|
411
|
+
(t.deleted_at IS NULL AND ${asOwner ? tx `true` : tx `t.private = false`}
|
|
412
|
+
AND ${since && inRound ? inRound : tx `true`}) AS visible
|
|
387
413
|
FROM threads t
|
|
388
414
|
JOIN reviewers r ON r.id = t.created_by
|
|
389
415
|
-- The assignee, when there is one (05 section 6.4b). LEFT: most
|
|
@@ -392,7 +418,7 @@ assignDelayMs) {
|
|
|
392
418
|
JOIN environments e ON e.id = t.environment_id
|
|
393
419
|
-- Joined for the round filter, and the join is the fix for a real
|
|
394
420
|
-- trap: threads.round_number is the thread's position WITHIN its round
|
|
395
|
-
-- (
|
|
421
|
+
-- (-- the number on the pin), while rounds.number is which
|
|
396
422
|
-- round it is. Two different integers whose names differ by one word,
|
|
397
423
|
-- and filtering on the first is how round=1 returned the single thread
|
|
398
424
|
-- that happened to be first in its round.
|
|
@@ -422,23 +448,9 @@ assignDelayMs) {
|
|
|
422
448
|
: assigneeFilter === "unassigned"
|
|
423
449
|
? tx `AND t.assignee_id IS NULL`
|
|
424
450
|
: tx `AND t.assignee_id = ${assigneeFilter}::uuid`}
|
|
425
|
-
--
|
|
426
|
-
--
|
|
427
|
-
|
|
428
|
-
-- round that closed weeks ago. The ORDER BY number DESC LIMIT 1
|
|
429
|
-
-- correlated subquery is cheap here -- one project's round table
|
|
430
|
-
-- is a handful of rows, never a scan of threads.
|
|
431
|
-
${key
|
|
432
|
-
? tx ``
|
|
433
|
-
: requestedRound
|
|
434
|
-
? tx `AND rd.number = ${requestedRound}`
|
|
435
|
-
: asStudio
|
|
436
|
-
? tx ``
|
|
437
|
-
: tx `AND rd.number = (
|
|
438
|
-
SELECT number FROM rounds
|
|
439
|
-
WHERE project_id = ${p.id} AND closed_at IS NULL
|
|
440
|
-
ORDER BY number DESC LIMIT 1
|
|
441
|
-
)`}
|
|
451
|
+
-- The round, from inRound above: here on a full list, in the
|
|
452
|
+
-- visible column on a poll.
|
|
453
|
+
${inRound && !since ? tx `AND ${inRound}` : tx ``}
|
|
442
454
|
${q && !key
|
|
443
455
|
? tx `AND (t.key ILIKE ${"%" + q + "%"}
|
|
444
456
|
OR t.anchor_label ILIKE ${"%" + q + "%"}
|
|
@@ -479,11 +491,11 @@ assignDelayMs) {
|
|
|
479
491
|
* clocks, one comparison: if the app server ran even a second ahead of the
|
|
480
492
|
* database, every thread updated inside that second would be skipped on the
|
|
481
493
|
* next poll and never returned again — a permanently missing comment, on a
|
|
482
|
-
* skew nobody would think to look for.
|
|
494
|
+
* skew nobody would think to look for. The design says the client must never
|
|
483
495
|
* use its own clock; the same argument applies one layer down.
|
|
484
496
|
*/
|
|
485
497
|
const [clock] = await sql `SELECT now() AS now`;
|
|
486
|
-
//
|
|
498
|
+
// The tombstones. Ids only — the client already has these threads, and
|
|
487
499
|
// a body would be handing back the private content that made it invisible.
|
|
488
500
|
const visible = rows.filter((r) => r["visible"] !== false);
|
|
489
501
|
const deleted = rows
|
|
@@ -497,7 +509,89 @@ assignDelayMs) {
|
|
|
497
509
|
});
|
|
498
510
|
});
|
|
499
511
|
/**
|
|
500
|
-
*
|
|
512
|
+
* Move threads to another round, the studio's filing act, and the
|
|
513
|
+
* one exception to the "a thread never moves": automatic placement never
|
|
514
|
+
* moves a thread; only this explicit move does. Owner or admin, from the
|
|
515
|
+
* dashboard (cookie and CSRF pair, checked inline because the bearer
|
|
516
|
+
* middleware owns this path for the widget).
|
|
517
|
+
*
|
|
518
|
+
* `{ ids: ["thr_…"], round: <number> }`. The target may be open or closed
|
|
519
|
+
* — filing a stray comment into a finished round is a filing decision — but
|
|
520
|
+
* not archived or deleted. One transaction: the target round's row is
|
|
521
|
+
* locked, its `thread_seq` advanced by the number moving, and each thread
|
|
522
|
+
* takes the next per-round number in creation order. The old round keeps
|
|
523
|
+
* its gap (a number quoted in an email is never handed to another thread)
|
|
524
|
+
* and the project-wide key is untouched, so every permalink still works.
|
|
525
|
+
* `version` moves, so every open dashboard's poll drops the thread from
|
|
526
|
+
* the round it left (the list's visible column) and shows it in the new one.
|
|
527
|
+
* Threads already in the target, deleted, or not in this site are skipped.
|
|
528
|
+
* No `events` row: status and assignee changes write none either.
|
|
529
|
+
*/
|
|
530
|
+
app.post("/api/v1/projects/:uid/threads/move", async (c) => {
|
|
531
|
+
const p = await project(c.req.param("uid"));
|
|
532
|
+
if (!p)
|
|
533
|
+
return fail(c, 404, "project_not_found", "No project with that uid.");
|
|
534
|
+
const owner = c.get("owner");
|
|
535
|
+
if (!owner)
|
|
536
|
+
return fail(c, 401, "unauthorized", "Sign in to the dashboard first.");
|
|
537
|
+
if (owner.orgId !== p.org_id || (owner.role !== "owner" && owner.role !== "admin")) {
|
|
538
|
+
return fail(c, 403, "forbidden", "Only an owner or an admin can move comments between rounds.");
|
|
539
|
+
}
|
|
540
|
+
if (!csrfOk(c)) {
|
|
541
|
+
return fail(c, 403, "csrf_failed", "That request could not be verified. Reload the dashboard and try again.");
|
|
542
|
+
}
|
|
543
|
+
const body = (await c.req.json().catch(() => null));
|
|
544
|
+
const round = body?.round;
|
|
545
|
+
const ids = Array.isArray(body?.ids) ? body.ids.map((w) => rowId(w, ID_PREFIX.thread)) : [];
|
|
546
|
+
if (!ids.length || ids.length > 200 || ids.some((x) => !x)) {
|
|
547
|
+
return fail(c, 400, "validation_failed", "ids must be 1 to 200 thread ids (thr_…).");
|
|
548
|
+
}
|
|
549
|
+
if (!(typeof round === "number" && Number.isInteger(round) && round > 0)) {
|
|
550
|
+
return fail(c, 400, "validation_failed", "round must be a round number (1, 2, …).");
|
|
551
|
+
}
|
|
552
|
+
const out = await inOrg(sql, p.org_id, async (tx) => {
|
|
553
|
+
const [target] = await tx `
|
|
554
|
+
SELECT id, archived_at IS NOT NULL AS archived FROM rounds
|
|
555
|
+
WHERE project_id = ${p.id}::uuid AND number = ${round}::int AND deleted_at IS NULL
|
|
556
|
+
FOR UPDATE
|
|
557
|
+
`;
|
|
558
|
+
if (!target)
|
|
559
|
+
return "missing";
|
|
560
|
+
if (target.archived)
|
|
561
|
+
return "archived";
|
|
562
|
+
const moving = await tx `
|
|
563
|
+
SELECT id FROM threads
|
|
564
|
+
WHERE id = ANY(${ids}::uuid[]) AND project_id = ${p.id}::uuid
|
|
565
|
+
AND deleted_at IS NULL AND round_id <> ${target.id}::uuid
|
|
566
|
+
ORDER BY created_at, id
|
|
567
|
+
FOR UPDATE
|
|
568
|
+
`;
|
|
569
|
+
if (!moving.length)
|
|
570
|
+
return 0;
|
|
571
|
+
const [seq] = await tx `
|
|
572
|
+
UPDATE rounds SET thread_seq = thread_seq + ${moving.length}::int, updated_at = now()
|
|
573
|
+
WHERE id = ${target.id}::uuid
|
|
574
|
+
RETURNING thread_seq AS last
|
|
575
|
+
`;
|
|
576
|
+
const first = seq.last - moving.length;
|
|
577
|
+
await tx `
|
|
578
|
+
UPDATE threads t
|
|
579
|
+
SET round_id = ${target.id}::uuid, round_number = ${first}::int + o.n::int,
|
|
580
|
+
version = t.version + 1, updated_at = now()
|
|
581
|
+
FROM unnest(${moving.map((m) => m.id)}::uuid[]) WITH ORDINALITY AS o(id, n)
|
|
582
|
+
WHERE t.id = o.id
|
|
583
|
+
`;
|
|
584
|
+
return moving.length;
|
|
585
|
+
});
|
|
586
|
+
if (out === "missing")
|
|
587
|
+
return fail(c, 404, "round_not_found", `This site has no round ${round}.`);
|
|
588
|
+
if (out === "archived") {
|
|
589
|
+
return fail(c, 409, "round_archived", "That round is archived. Restore it in Settings to move comments into it.");
|
|
590
|
+
}
|
|
591
|
+
return c.json({ moved: out, round });
|
|
592
|
+
});
|
|
593
|
+
/**
|
|
594
|
+
* Create. The whole thing is one transaction because a thread
|
|
501
595
|
* without its first comment is not a snag, it is a pin nobody can read.
|
|
502
596
|
*/
|
|
503
597
|
app.post("/api/v1/projects/:uid/threads", async (c) => {
|
|
@@ -509,11 +603,11 @@ assignDelayMs) {
|
|
|
509
603
|
return fail(c, 401, "unauthorized", "Open your review link, or reload the page, and try again.");
|
|
510
604
|
}
|
|
511
605
|
if (p.status !== "active") {
|
|
512
|
-
//
|
|
606
|
+
// The switch is off — writes refused, reads work (layer 6).
|
|
513
607
|
return fail(c, 403, "project_paused", "This site's widget is switched off. Its owner can switch it back on.");
|
|
514
608
|
}
|
|
515
609
|
if (!session.perm.includes("thread:create")) {
|
|
516
|
-
//
|
|
610
|
+
// The matrix. A read-only session gets a sentence about permission,
|
|
517
611
|
// not about authentication — they ARE signed in.
|
|
518
612
|
return fail(c, 403, "forbidden", "This review link does not allow new comments.");
|
|
519
613
|
}
|
|
@@ -525,6 +619,16 @@ assignDelayMs) {
|
|
|
525
619
|
// the host put one on the script tag, and inventing one here would put a
|
|
526
620
|
// comment in an environment the dashboard is not looking at.
|
|
527
621
|
const envKey = typeof body.scope?.environment === "string" ? body.scope.environment : null;
|
|
622
|
+
// The round the reviewer is LOOKING AT, by number (the only round
|
|
623
|
+
// identity the widget holds). Rounds run side by side, so "the newest
|
|
624
|
+
// open one" stopped being the right answer the day a studio kept its own
|
|
625
|
+
// QA round going while the client's ran: a comment left while viewing
|
|
626
|
+
// Internal QA belongs to Internal QA.
|
|
627
|
+
const askedRound = body.scope?.round;
|
|
628
|
+
if (askedRound !== undefined && askedRound !== null && !(Number.isInteger(askedRound) && askedRound > 0)) {
|
|
629
|
+
return fail(c, 400, "validation_failed", "scope.round must be a round number (1, 2, …).");
|
|
630
|
+
}
|
|
631
|
+
const roundNo = typeof askedRound === "number" ? askedRound : null;
|
|
528
632
|
// A client-minted id that is not a UUID is a bug in the client, not
|
|
529
633
|
// something to paper over with a server-minted one: the widget's optimistic
|
|
530
634
|
// copy would then be a different snag from the stored one. Parsed out here,
|
|
@@ -535,8 +639,8 @@ assignDelayMs) {
|
|
|
535
639
|
}
|
|
536
640
|
const commentBody = typeof body.comment?.body === "string" && body.comment.body ? body.comment.body : null;
|
|
537
641
|
const commentId = rowId(body.comment?.id, ID_PREFIX.comment) ?? randomUUID();
|
|
538
|
-
// The attachments uploaded moments ago, to be BOUND by the write below
|
|
539
|
-
//
|
|
642
|
+
// The attachments uploaded moments ago, to be BOUND by the write below
|
|
643
|
+
// Until that runs they are `pending` rows with no owner: The
|
|
540
644
|
// CHECK forbids a bound attachment without exactly one of
|
|
541
645
|
// thread_id/comment_id, which is what makes an orphan detectable rather
|
|
542
646
|
// than merely unlikely. The bind is scoped to this project AND to rows
|
|
@@ -549,7 +653,7 @@ assignDelayMs) {
|
|
|
549
653
|
//
|
|
550
654
|
// This used to read `createdBy.holderSecret` off the request and mint a
|
|
551
655
|
// reviewer from it — a second, weaker answer to "who is this?" sitting
|
|
552
|
-
// beside the session that already answers it.
|
|
656
|
+
// beside the session that already answers it. The design is explicit that a
|
|
553
657
|
// session's identity is decided at exchange and never taken from a request,
|
|
554
658
|
// and leaving the body path in place would have meant a caller could author
|
|
555
659
|
// as anyone by presenting a secret they had seen once.
|
|
@@ -566,38 +670,69 @@ assignDelayMs) {
|
|
|
566
670
|
* work never was the cost. The *count* was.
|
|
567
671
|
*
|
|
568
672
|
* What survives is the ORDER, because the order is load-bearing —
|
|
569
|
-
*
|
|
673
|
+
* the allocation still row-locks the project before the insert
|
|
570
674
|
* reads it, still serialises creation per project, and still yields a
|
|
571
675
|
* gapless human id. A CTE does not relax that: data-modifying CTEs run
|
|
572
676
|
* exactly once, to completion, and the insert that reads pseq/rseq
|
|
573
677
|
* cannot begin before they have produced their rows.
|
|
574
678
|
*/
|
|
679
|
+
/*
|
|
680
|
+
* WHICH ROUND, in this order:
|
|
681
|
+
* 1. the one asked for, when it is open and not archived -- and a
|
|
682
|
+
* 409 when it is not, rather than a silent move elsewhere: the
|
|
683
|
+
* reviewer chose it, and a comment that lands somewhere else
|
|
684
|
+
* without a word is the bug parallel rounds exist to remove;
|
|
685
|
+
* 2. else the review link's pinned round, when it is open;
|
|
686
|
+
* 3. Else the newest open, unarchived round -- the original rule.
|
|
687
|
+
* Still the same one statement: the environment and both candidates
|
|
688
|
+
* come back together, and the choice is made below.
|
|
689
|
+
*/
|
|
575
690
|
const [ctx] = await tx `
|
|
576
691
|
SELECT
|
|
577
692
|
(SELECT id FROM environments
|
|
578
693
|
WHERE project_id = ${p.id} AND deleted_at IS NULL
|
|
579
694
|
AND ${envKey ? tx `key = ${envKey}` : tx `is_default`}
|
|
580
695
|
LIMIT 1) AS environment_id,
|
|
581
|
-
|
|
696
|
+
a.id AS asked_id,
|
|
697
|
+
(a.closed_at IS NULL AND a.archived_at IS NULL) AS asked_open,
|
|
698
|
+
COALESCE(
|
|
699
|
+
${session.lnk
|
|
700
|
+
? tx `(SELECT rd.id FROM review_links rl JOIN rounds rd ON rd.id = rl.round_id
|
|
701
|
+
WHERE rl.id = ${session.lnk}::uuid AND rd.project_id = ${p.id}
|
|
702
|
+
AND rd.deleted_at IS NULL AND rd.archived_at IS NULL AND rd.closed_at IS NULL)`
|
|
703
|
+
: tx `NULL::uuid`},
|
|
704
|
+
r.id
|
|
705
|
+
) AS round_id
|
|
582
706
|
FROM (SELECT 1) one
|
|
583
707
|
LEFT JOIN LATERAL (
|
|
584
708
|
SELECT id FROM rounds
|
|
585
|
-
WHERE project_id = ${p.id} AND deleted_at IS NULL AND closed_at IS NULL
|
|
709
|
+
WHERE project_id = ${p.id} AND deleted_at IS NULL AND closed_at IS NULL AND archived_at IS NULL
|
|
586
710
|
ORDER BY number DESC LIMIT 1
|
|
587
711
|
) r ON true
|
|
712
|
+
LEFT JOIN LATERAL (
|
|
713
|
+
SELECT id, closed_at, archived_at FROM rounds
|
|
714
|
+
WHERE project_id = ${p.id} AND deleted_at IS NULL AND number = ${roundNo ?? 0}::int
|
|
715
|
+
) a ON true
|
|
588
716
|
`;
|
|
589
717
|
// Kept as its own statement rather than folded into the write below,
|
|
590
718
|
// because these two are the only failures a caller can act on and
|
|
591
|
-
//
|
|
719
|
+
// the design gives them distinct codes. A single statement that inserted
|
|
592
720
|
// nothing would have to report "something was missing".
|
|
593
721
|
if (!ctx?.environment_id) {
|
|
594
722
|
throw new HttpError(400, "environment_not_found", envKey
|
|
595
723
|
? `This project has no environment called "${envKey}".`
|
|
596
724
|
: "This project has no default environment.");
|
|
597
725
|
}
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
if (!ctx.
|
|
726
|
+
// Still holds whichever round is chosen: a thread belongs to the
|
|
727
|
+
// round it was created in, and never moves.
|
|
728
|
+
if (roundNo !== null && !ctx.asked_id) {
|
|
729
|
+
throw new HttpError(409, "round_not_found", `This site has no round ${roundNo}. Reload to see its rounds.`);
|
|
730
|
+
}
|
|
731
|
+
if (roundNo !== null && !ctx.asked_open) {
|
|
732
|
+
throw new HttpError(409, "round_closed", `Round ${roundNo} is closed, so it takes no new comments. Pick an open round, or leave the round out.`);
|
|
733
|
+
}
|
|
734
|
+
const roundId = roundNo !== null ? ctx.asked_id : ctx.round_id;
|
|
735
|
+
if (!roundId)
|
|
601
736
|
throw new HttpError(409, "no_active_round", "This project has no open round.");
|
|
602
737
|
const counted = commentBody === null ? 0 : 1;
|
|
603
738
|
const [row] = await tx `
|
|
@@ -608,7 +743,7 @@ assignDelayMs) {
|
|
|
608
743
|
),
|
|
609
744
|
rseq AS (
|
|
610
745
|
UPDATE rounds SET thread_seq = thread_seq + 1
|
|
611
|
-
WHERE id = ${
|
|
746
|
+
WHERE id = ${roundId}::uuid
|
|
612
747
|
RETURNING thread_seq
|
|
613
748
|
),
|
|
614
749
|
ins AS (
|
|
@@ -617,7 +752,7 @@ assignDelayMs) {
|
|
|
617
752
|
comment_count, comment_count_public)
|
|
618
753
|
SELECT ${threadId}::uuid, ${p.org_id}::uuid, ${p.id}::uuid,
|
|
619
754
|
${p.key_prefix}::text || '-' || pseq.thread_seq::text,
|
|
620
|
-
${ctx.environment_id}::uuid, ${
|
|
755
|
+
${ctx.environment_id}::uuid, ${roundId}::uuid, rseq.thread_seq,
|
|
621
756
|
${tx.json(body.anchor)}::jsonb, ${tx.json(body.context)}::jsonb,
|
|
622
757
|
${reviewerId}::uuid, ${body.anchorState ?? "anchored"}::snag_anchor_state,
|
|
623
758
|
${counted}::int, ${counted}::int
|
|
@@ -642,7 +777,7 @@ assignDelayMs) {
|
|
|
642
777
|
),
|
|
643
778
|
-- The author's row: seen now, and the counters the assignee pool
|
|
644
779
|
-- reads (05 section 3.4, section 6.4b -- "everyone who has
|
|
645
|
-
-- commented" is thread_count > 0 OR comment_count > 0). Until
|
|
780
|
+
-- commented" is thread_count > 0 OR comment_count > 0). Until
|
|
646
781
|
-- nothing moved thread_count at all, so a client whose only act
|
|
647
782
|
-- was leaving a pin was invisible to the pool. ONE update: two
|
|
648
783
|
-- data-modifying CTEs touching the same row in one statement is
|
|
@@ -698,7 +833,7 @@ assignDelayMs) {
|
|
|
698
833
|
pageUrl: body.context?.url ?? "",
|
|
699
834
|
}, randomUUID());
|
|
700
835
|
}
|
|
701
|
-
//
|
|
836
|
+
// A site whose tracker makes an issue on every new thread. Same
|
|
702
837
|
// transaction as the thread, keyed on the thread, so a retried POST
|
|
703
838
|
// cannot make a second issue.
|
|
704
839
|
for (const link of await trackers(p.org_id, p.id)) {
|
|
@@ -723,7 +858,7 @@ assignDelayMs) {
|
|
|
723
858
|
if (e instanceof HttpError)
|
|
724
859
|
return fail(c, e.status, e.code, e.message);
|
|
725
860
|
/**
|
|
726
|
-
* A RETRY IS NOT AN ERROR —
|
|
861
|
+
* A RETRY IS NOT AN ERROR — the idempotency, by natural key.
|
|
727
862
|
*
|
|
728
863
|
* A double-tapped Send, or a reviewer on a train whose request landed
|
|
729
864
|
* while their connection dropped before the response did, sends the same
|
|
@@ -734,7 +869,7 @@ assignDelayMs) {
|
|
|
734
869
|
* connection is the moment they try again, or give up and stop using it.
|
|
735
870
|
*
|
|
736
871
|
* The id makes this decidable with no header and no client change: the
|
|
737
|
-
* widget already mints it
|
|
872
|
+
* widget already mints it, so a second POST carrying an id we
|
|
738
873
|
* already hold IS the same request. Returning what the first one created
|
|
739
874
|
* is the whole of the fix.
|
|
740
875
|
*
|
|
@@ -790,7 +925,7 @@ assignDelayMs) {
|
|
|
790
925
|
}
|
|
791
926
|
});
|
|
792
927
|
/**
|
|
793
|
-
*
|
|
928
|
+
* Status changes, endpoint 9, marked **W/D** like the list.
|
|
794
929
|
*
|
|
795
930
|
* IT SHIPPED WITH NO AUTHENTICATION CHECK AT ALL. Not a weak one — none: the
|
|
796
931
|
* handler read the uid, read the body and wrote. `bearerAuth` was mounted on
|
|
@@ -801,15 +936,15 @@ assignDelayMs) {
|
|
|
801
936
|
*
|
|
802
937
|
* Two credentials are legitimate here and they are checked separately: an
|
|
803
938
|
* OWNER of this project's org, and a reviewer session carrying
|
|
804
|
-
* `thread:resolve` (
|
|
805
|
-
* a client marking their own snag done is the loop
|
|
939
|
+
* `thread:resolve` (the matrix — it is in the default link grant, because
|
|
940
|
+
* a client marking their own snag done is the loop the decision is built around).
|
|
806
941
|
*/
|
|
807
942
|
/**
|
|
808
|
-
*
|
|
943
|
+
* Endpoint 8 — one thread — and the brief: the same row as
|
|
809
944
|
* `text/markdown` when the id ends in `.md`, for curl, an agent's shell or
|
|
810
945
|
* a ticket body. The privacy rule is the list's: a private thread is absent
|
|
811
946
|
* for a non-owner, which reads as "no such thread" rather than confirming
|
|
812
|
-
* one exists
|
|
947
|
+
* one exists, and team-only replies stay out of a client's copy.
|
|
813
948
|
*/
|
|
814
949
|
app.get("/api/v1/projects/:uid/threads/:id", async (c) => {
|
|
815
950
|
const p = await project(c.req.param("uid"));
|
|
@@ -880,10 +1015,10 @@ assignDelayMs) {
|
|
|
880
1015
|
const mcp = c.get("mcp");
|
|
881
1016
|
const asOwner = !!owner && owner.orgId === p.org_id;
|
|
882
1017
|
/**
|
|
883
|
-
* `mark_ready`
|
|
1018
|
+
* `mark_ready` lands here — and this is the whole of an agent's
|
|
884
1019
|
* write surface on a thread, `threads:write`.
|
|
885
1020
|
*
|
|
886
|
-
*
|
|
1021
|
+
* *"an agent can propose, never dispose"*. There is
|
|
887
1022
|
* deliberately no tool that resolves a thread, and the refusal below is
|
|
888
1023
|
* where that is actually true rather than merely absent from a tool list:
|
|
889
1024
|
* an agent PATCHing `status: 'resolved'` on this route is refused even
|
|
@@ -900,20 +1035,22 @@ assignDelayMs) {
|
|
|
900
1035
|
return fail(c, 403, "forbidden", "This review link does not allow changing status.");
|
|
901
1036
|
}
|
|
902
1037
|
const body = (await c.req.json().catch(() => null));
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
1038
|
+
// A thread never moves BY ACCIDENT. Refused explicitly rather than
|
|
1039
|
+
// ignored, because a caller that sends `round` and gets a 200 will believe
|
|
1040
|
+
// it moved. Moving is a studio act with its own route (the
|
|
1041
|
+
// move below), which renumbers inside the target round; a PATCH field
|
|
1042
|
+
// would be a second, quieter way to do it. The `field_not_permitted`
|
|
1043
|
+
// is the registered code.
|
|
907
1044
|
for (const field of ["round", "roundId", "roundNumber"]) {
|
|
908
1045
|
if (body && field in body) {
|
|
909
|
-
return fail(c, 422, "field_not_permitted", "A comment
|
|
1046
|
+
return fail(c, 422, "field_not_permitted", "A comment's round is not changed here. The studio moves comments between rounds from the dashboard.", { field });
|
|
910
1047
|
}
|
|
911
1048
|
}
|
|
912
1049
|
/**
|
|
913
|
-
* `private
|
|
1050
|
+
* `private`, and the write that makes `deleted[]` mean something.
|
|
914
1051
|
*
|
|
915
1052
|
* Studio-only, like the thread-level field it is: an owner always may, a
|
|
916
|
-
* reviewer session needs the link permission that names it.
|
|
1053
|
+
* reviewer session needs the link permission that names it. The design is
|
|
917
1054
|
* explicit that this is `403 field_not_permitted` with `details.field`
|
|
918
1055
|
* rather than a silently ignored field, because a caller who sends
|
|
919
1056
|
* `private: true` and gets a 200 will believe the thread is private.
|
|
@@ -925,14 +1062,14 @@ assignDelayMs) {
|
|
|
925
1062
|
});
|
|
926
1063
|
}
|
|
927
1064
|
if (wantsPrivate !== undefined && asAgent && !asOwner) {
|
|
928
|
-
//
|
|
1065
|
+
// An agent write is never laundered into a trust tier it did not
|
|
929
1066
|
// earn, and team-only is the studio's register.
|
|
930
1067
|
return fail(c, 403, "field_not_permitted", "An agent cannot make a thread team-only.", {
|
|
931
1068
|
field: "private",
|
|
932
1069
|
});
|
|
933
1070
|
}
|
|
934
1071
|
/**
|
|
935
|
-
* `assignee
|
|
1072
|
+
* `assignee`. A reviewer id from the pool, or null to
|
|
936
1073
|
* un-assign. Same register as `private`: the studio's own routing of
|
|
937
1074
|
* work, so an owner always may, a reviewer session needs the permission
|
|
938
1075
|
* that names it, and a refusal is `403 field_not_permitted` with
|
|
@@ -954,7 +1091,7 @@ assignDelayMs) {
|
|
|
954
1091
|
}
|
|
955
1092
|
}
|
|
956
1093
|
if (asAgent && !asOwner) {
|
|
957
|
-
//
|
|
1094
|
+
// Again: an agent proposes, never disposes
|
|
958
1095
|
// and handing a thread to a person is disposing of it.
|
|
959
1096
|
return fail(c, 403, "field_not_permitted", "An agent cannot assign a comment to someone.", {
|
|
960
1097
|
field: "assignee",
|
|
@@ -976,7 +1113,7 @@ assignDelayMs) {
|
|
|
976
1113
|
return fail(c, 400, "validation_failed", "status must be one of open, progress, review, resolved.");
|
|
977
1114
|
}
|
|
978
1115
|
/**
|
|
979
|
-
* F-26's expected version
|
|
1116
|
+
* F-26's expected version, the "no silent overwrite".
|
|
980
1117
|
*
|
|
981
1118
|
* `threads.version` has been incremented on every write since 0001 and
|
|
982
1119
|
* READ by nothing: the column existed, the guarantee did not. Two studio
|
|
@@ -987,7 +1124,7 @@ assignDelayMs) {
|
|
|
987
1124
|
* OPTIONAL, deliberately. A caller that sends no `version` is saying "I am
|
|
988
1125
|
* not editing a copy I read earlier" — the CLI, an agent acting on a fresh
|
|
989
1126
|
* read, a curl. Making it mandatory would break every existing caller to
|
|
990
|
-
* protect a case they are not in, and
|
|
1127
|
+
* protect a case they are not in, and the additive rule forbids
|
|
991
1128
|
* that. The dashboard and the widget send it because they ARE editing a
|
|
992
1129
|
* copy they read earlier, which is exactly the population F-26 is about.
|
|
993
1130
|
*/
|
|
@@ -1004,7 +1141,7 @@ assignDelayMs) {
|
|
|
1004
1141
|
return fail(c, 403, "forbidden", "An agent may mark a snag ready for review, but not resolve it. " +
|
|
1005
1142
|
"Whoever reported it decides when it is fixed — tell your user it is ready.");
|
|
1006
1143
|
}
|
|
1007
|
-
//
|
|
1144
|
+
// The stop signal, spent before the write rather than after it.
|
|
1008
1145
|
const spent = await checkWriteBudget(sql, mcp.tokenId);
|
|
1009
1146
|
if (spent !== null)
|
|
1010
1147
|
return budgetExhausted(c);
|
|
@@ -1030,10 +1167,10 @@ assignDelayMs) {
|
|
|
1030
1167
|
let conflict;
|
|
1031
1168
|
// Set by the transaction when the candidate assignee is not in the pool.
|
|
1032
1169
|
let badAssignee = false;
|
|
1033
|
-
// Read by the transaction when an assignee is being set
|
|
1170
|
+
// Read by the transaction when an assignee is being set.
|
|
1034
1171
|
let previousAssignee = null;
|
|
1035
1172
|
/**
|
|
1036
|
-
* The person pressing the button, as
|
|
1173
|
+
* The person pressing the button, as the assignment mail needs them:
|
|
1037
1174
|
* never mailed about handing a snag to themselves, and — when they ARE
|
|
1038
1175
|
* the assignee and move its status — the mail about it is dropped,
|
|
1039
1176
|
* because they have plainly seen it. An agent is nobody here: it cannot
|
|
@@ -1070,7 +1207,7 @@ assignDelayMs) {
|
|
|
1070
1207
|
}
|
|
1071
1208
|
}
|
|
1072
1209
|
/**
|
|
1073
|
-
* The candidate must be IN THE POOL —
|
|
1210
|
+
* The candidate must be IN THE POOL — the predicate, shared with
|
|
1074
1211
|
* the pool route — and in THIS project. Blocked and erased reviewers
|
|
1075
1212
|
* keep the assignments they have but take no new ones; a guest who
|
|
1076
1213
|
* never wrote is not assignable; a reviewer id from another project is
|
|
@@ -1087,7 +1224,7 @@ assignDelayMs) {
|
|
|
1087
1224
|
}
|
|
1088
1225
|
// Who held it BEFORE, under the row lock the UPDATE below would take
|
|
1089
1226
|
// anyway: re-sending the assignee a thread already has is not a new
|
|
1090
|
-
// assignment, and must not be a second mail
|
|
1227
|
+
// assignment, and must not be a second mail.
|
|
1091
1228
|
const [was] = await tx `
|
|
1092
1229
|
SELECT assignee_id FROM threads
|
|
1093
1230
|
WHERE id = ${threadRow}::uuid AND project_id = ${p.id}::uuid
|
|
@@ -1096,7 +1233,7 @@ assignDelayMs) {
|
|
|
1096
1233
|
previousAssignee = was?.assignee_id ?? null;
|
|
1097
1234
|
}
|
|
1098
1235
|
/**
|
|
1099
|
-
*
|
|
1236
|
+
* Consequence TWO of three, and it runs before the CTE rather than
|
|
1100
1237
|
* inside it.
|
|
1101
1238
|
*
|
|
1102
1239
|
* *"every comment on a thread becoming private is set `private = true` in
|
|
@@ -1185,7 +1322,7 @@ assignDelayMs) {
|
|
|
1185
1322
|
`;
|
|
1186
1323
|
/**
|
|
1187
1324
|
* `thread.resolved` — B2's second topic, queued in the SAME transaction
|
|
1188
|
-
* as the status change
|
|
1325
|
+
* as the status change, so a resolve that commits without its
|
|
1189
1326
|
* notification is impossible and a notification about a resolve that
|
|
1190
1327
|
* rolled back cannot exist.
|
|
1191
1328
|
*
|
|
@@ -1213,10 +1350,10 @@ assignDelayMs) {
|
|
|
1213
1350
|
});
|
|
1214
1351
|
}
|
|
1215
1352
|
/**
|
|
1216
|
-
*
|
|
1353
|
+
* "this snag is yours now", an hour from now and only if it is
|
|
1217
1354
|
* still true then (`mail/assigned.ts`). Queued here, in the transaction
|
|
1218
1355
|
* that assigned it, like every outbox row; only when it is a NEW holder
|
|
1219
|
-
* and only when mail is configured at all (
|
|
1356
|
+
* and only when mail is configured at all (off and say so,
|
|
1220
1357
|
* rather than rows that arrive in a burst the day somebody sets it up).
|
|
1221
1358
|
*/
|
|
1222
1359
|
if (assigneeRow && assigneeRow !== previousAssignee && actor && updated?.[0] && (await notifications())) {
|
|
@@ -1240,7 +1377,7 @@ assignDelayMs) {
|
|
|
1240
1377
|
if (status !== undefined && actor && updated?.[0]) {
|
|
1241
1378
|
await dropAssignedBy(tx, threadRow, actor);
|
|
1242
1379
|
}
|
|
1243
|
-
//
|
|
1380
|
+
// Our status is canonical, the tracker's follows — queued here, in
|
|
1244
1381
|
// the transaction that moved it, keyed on the version so every change
|
|
1245
1382
|
// is one row. The worker maps by state type: resolved closes as
|
|
1246
1383
|
// completed, anything else reopens; a thread with no issue is skipped.
|
|
@@ -1261,7 +1398,7 @@ assignDelayMs) {
|
|
|
1261
1398
|
});
|
|
1262
1399
|
/**
|
|
1263
1400
|
* F-26, on the wire. 409 and not 412: `If-Match` semantics would make this
|
|
1264
|
-
* a header concern, and
|
|
1401
|
+
* a header concern, and the design describes a product event ("someone else
|
|
1265
1402
|
* updated this while you were typing") that the client renders with **Post
|
|
1266
1403
|
* anyway** and **See what changed** — a conflict, not a failed
|
|
1267
1404
|
* precondition. `currentVersion` rides along so the refetch has something
|
|
@@ -1279,7 +1416,7 @@ assignDelayMs) {
|
|
|
1279
1416
|
if (!row)
|
|
1280
1417
|
return fail(c, 404, "thread_not_found", "No such thread in this project.");
|
|
1281
1418
|
/**
|
|
1282
|
-
*
|
|
1419
|
+
* Every agent write lands in `events` with the
|
|
1283
1420
|
* reviewer id and the token id.
|
|
1284
1421
|
*
|
|
1285
1422
|
* A SECOND transaction, and this is the one place that is not ideal: the
|
|
@@ -1316,12 +1453,12 @@ assignDelayMs) {
|
|
|
1316
1453
|
* 05 endpoint 10 — deleting a whole thread, project-scoped like the PATCH
|
|
1317
1454
|
* above. The studio, any thread; a reviewer, their own.
|
|
1318
1455
|
*
|
|
1319
|
-
*
|
|
1456
|
+
* The matrix gives a reviewer "own only", behind the link permission
|
|
1320
1457
|
* `thread:delete:own` — on by default since 0.1.14 for guests and new
|
|
1321
1458
|
* links, because the widget offered Delete on a reviewer's own snag and
|
|
1322
1459
|
* this route refused it. "Own" is narrower than "opened": every live
|
|
1323
1460
|
* comment in the thread must be theirs, so nobody's reply goes with it.
|
|
1324
|
-
* An agent is still refused (
|
|
1461
|
+
* An agent is still refused (an agent proposes, never
|
|
1325
1462
|
* disposes, and deleting is the most disposing act there is).
|
|
1326
1463
|
*
|
|
1327
1464
|
* ── SOFT, LIKE A SITE ──
|
|
@@ -1333,7 +1470,7 @@ assignDelayMs) {
|
|
|
1333
1470
|
* column, and an operator who deletes the wrong thread has a row to
|
|
1334
1471
|
* restore. `version` and `updated_at` move with it so a `?since=` poll —
|
|
1335
1472
|
* every other open dashboard, every reviewer's widget — gets the id in
|
|
1336
|
-
* `deleted[]` and drops its copy
|
|
1473
|
+
* `deleted[]` and drops its copy, rather than showing a thread that
|
|
1337
1474
|
* is gone until somebody reloads.
|
|
1338
1475
|
*
|
|
1339
1476
|
* ── THE COMMENTS ARE LEFT ALONE, DELIBERATELY ──
|
|
@@ -1343,7 +1480,7 @@ assignDelayMs) {
|
|
|
1343
1480
|
* thread's `deleted_at`; the two comment routes that address a comment by
|
|
1344
1481
|
* its own id (commentEdit.ts) answer 404 once its thread is deleted. So
|
|
1345
1482
|
* tombstoning them would hide nothing more today, and it would make the
|
|
1346
|
-
* delete irreversible —
|
|
1483
|
+
* delete irreversible — the tombstone NULLs the body, and a restored
|
|
1347
1484
|
* thread with every word gone is not a restore. The counters stay as they
|
|
1348
1485
|
* are for the same reason.
|
|
1349
1486
|
*
|
|
@@ -1361,7 +1498,7 @@ assignDelayMs) {
|
|
|
1361
1498
|
* with and never looks back at the thread. A tracker row would be
|
|
1362
1499
|
* dropped by its worker anyway ("the thread is gone"); dropping it here
|
|
1363
1500
|
* says so in one place. Rows already in flight are left to finish.
|
|
1364
|
-
* · Linked issues in GitHub, Linear, Jira or Slack are NOT touched.
|
|
1501
|
+
* · Linked issues in GitHub, Linear, Jira or Slack are NOT touched. The
|
|
1365
1502
|
* `thread.deleted` event is not built, and closing a developer's issue
|
|
1366
1503
|
* because a comment was moderated is not obviously what anyone wants.
|
|
1367
1504
|
* An inbound webhook for that issue finds no thread and is ignored.
|
|
@@ -1398,7 +1535,7 @@ assignDelayMs) {
|
|
|
1398
1535
|
if (!session.perm.includes("thread:delete:own")) {
|
|
1399
1536
|
return fail(c, 403, "forbidden", "Your review link does not allow deleting comments.");
|
|
1400
1537
|
}
|
|
1401
|
-
// A studio owner or admin signed in from the widget
|
|
1538
|
+
// A studio owner or admin signed in from the widget deletes
|
|
1402
1539
|
// any thread, as in the dashboard.
|
|
1403
1540
|
const moderator = await inOrg(sql, p.org_id, (tx) => isStudioModerator(tx, p.org_id, session.sub));
|
|
1404
1541
|
const [t] = moderator ? [{ all_mine: true, any: true }] : await inOrg(sql, p.org_id, (tx) => tx `
|
|
@@ -1420,7 +1557,7 @@ assignDelayMs) {
|
|
|
1420
1557
|
}
|
|
1421
1558
|
}
|
|
1422
1559
|
else {
|
|
1423
|
-
//
|
|
1560
|
+
// The matrix: member, admin and owner may; a viewer may not. Nobody
|
|
1424
1561
|
// can be GIVEN a viewer seat yet (members.ts wires owner and admin only),
|
|
1425
1562
|
// so this is the rule written down where it applies, before the day the
|
|
1426
1563
|
// seat exists and every route has to be audited for it.
|
|
@@ -1438,12 +1575,7 @@ assignDelayMs) {
|
|
|
1438
1575
|
if (!threadRow)
|
|
1439
1576
|
return fail(c, 404, "thread_not_found", "No such thread in this project.");
|
|
1440
1577
|
const found = await inOrg(sql, p.org_id, async (tx) => {
|
|
1441
|
-
const
|
|
1442
|
-
UPDATE threads
|
|
1443
|
-
SET deleted_at = now(), version = version + 1, updated_at = now()
|
|
1444
|
-
WHERE id = ${threadRow}::uuid AND project_id = ${p.id}::uuid AND deleted_at IS NULL
|
|
1445
|
-
RETURNING id
|
|
1446
|
-
`;
|
|
1578
|
+
const gone = await softDeleteThreads(tx, p.id, { thread: threadRow }, "the thread was deleted");
|
|
1447
1579
|
if (!gone) {
|
|
1448
1580
|
// Already deleted, or never here. Only the second is an error.
|
|
1449
1581
|
const [was] = await tx `
|
|
@@ -1451,19 +1583,6 @@ assignDelayMs) {
|
|
|
1451
1583
|
`;
|
|
1452
1584
|
return !!was;
|
|
1453
1585
|
}
|
|
1454
|
-
// The thread's own pictures and any bound to its comments: FOLDBACK
|
|
1455
|
-
// section A6 hangs an attachment off exactly one of the two.
|
|
1456
|
-
await tx `
|
|
1457
|
-
UPDATE attachments SET deleted_at = now()
|
|
1458
|
-
WHERE deleted_at IS NULL
|
|
1459
|
-
AND (thread_id = ${threadRow}::uuid
|
|
1460
|
-
OR comment_id IN (SELECT id FROM comments WHERE thread_id = ${threadRow}::uuid))
|
|
1461
|
-
`;
|
|
1462
|
-
await tx `
|
|
1463
|
-
UPDATE outbox
|
|
1464
|
-
SET status = 'dropped', last_error = 'the thread was deleted', completed_at = now()
|
|
1465
|
-
WHERE thread_id = ${threadRow}::uuid AND status = 'pending'
|
|
1466
|
-
`;
|
|
1467
1586
|
return true;
|
|
1468
1587
|
});
|
|
1469
1588
|
if (!found)
|
|
@@ -1472,6 +1591,44 @@ assignDelayMs) {
|
|
|
1472
1591
|
});
|
|
1473
1592
|
return app;
|
|
1474
1593
|
}
|
|
1594
|
+
/**
|
|
1595
|
+
* The thread DELETE's work, for one thread or for every live thread in a
|
|
1596
|
+
* round (the round delete) — one copy, so a round's threads go exactly the
|
|
1597
|
+
* way a single thread does and "what else goes with it" (the route's comment
|
|
1598
|
+
* above) is answered in one place. Inside the caller's transaction, which has
|
|
1599
|
+
* already set `app.org_id`. Returns how many threads it marked.
|
|
1600
|
+
*
|
|
1601
|
+
* `version` and `updated_at` move so a `?since=` poll tombstones each one;
|
|
1602
|
+
* attachments on the thread or its comments are marked; pending outbox rows
|
|
1603
|
+
* are dropped with `reason` in `last_error`. Comments are left alone, for the
|
|
1604
|
+
* reason given above.
|
|
1605
|
+
*/
|
|
1606
|
+
export async function softDeleteThreads(tx, projectId, which, reason) {
|
|
1607
|
+
const gone = await tx `
|
|
1608
|
+
UPDATE threads
|
|
1609
|
+
SET deleted_at = now(), version = version + 1, updated_at = now()
|
|
1610
|
+
WHERE project_id = ${projectId}::uuid AND deleted_at IS NULL
|
|
1611
|
+
AND ${"thread" in which ? tx `id = ${which.thread}::uuid` : tx `round_id = ${which.round}::uuid`}
|
|
1612
|
+
RETURNING id
|
|
1613
|
+
`;
|
|
1614
|
+
if (!gone.length)
|
|
1615
|
+
return 0;
|
|
1616
|
+
const ids = gone.map((g) => g.id);
|
|
1617
|
+
// The thread's own pictures and any bound to its comments:
|
|
1618
|
+
// section A6 hangs an attachment off exactly one of the two.
|
|
1619
|
+
await tx `
|
|
1620
|
+
UPDATE attachments SET deleted_at = now()
|
|
1621
|
+
WHERE deleted_at IS NULL
|
|
1622
|
+
AND (thread_id = ANY(${ids}::uuid[])
|
|
1623
|
+
OR comment_id IN (SELECT id FROM comments WHERE thread_id = ANY(${ids}::uuid[])))
|
|
1624
|
+
`;
|
|
1625
|
+
await tx `
|
|
1626
|
+
UPDATE outbox
|
|
1627
|
+
SET status = 'dropped', last_error = ${reason}, completed_at = now()
|
|
1628
|
+
WHERE thread_id = ANY(${ids}::uuid[]) AND status = 'pending'
|
|
1629
|
+
`;
|
|
1630
|
+
return ids.length;
|
|
1631
|
+
}
|
|
1475
1632
|
/**
|
|
1476
1633
|
* Fields declared and assigned, NOT constructor parameter properties.
|
|
1477
1634
|
*
|
|
@@ -1491,7 +1648,7 @@ class HttpError extends Error {
|
|
|
1491
1648
|
this.code = code;
|
|
1492
1649
|
}
|
|
1493
1650
|
}
|
|
1494
|
-
/** Rows →
|
|
1651
|
+
/** Rows → the `Thread`. The shape the widget already renders, unchanged. */
|
|
1495
1652
|
function shape(r) {
|
|
1496
1653
|
return {
|
|
1497
1654
|
id: wireId_(r.id, ID_PREFIX.thread),
|
|
@@ -1527,7 +1684,7 @@ function shape(r) {
|
|
|
1527
1684
|
isTeam: !!r.assignee_is_team,
|
|
1528
1685
|
}
|
|
1529
1686
|
: null,
|
|
1530
|
-
//
|
|
1687
|
+
// Where this thread lives elsewhere — the GitHub issue, later the
|
|
1531
1688
|
// Linear and Jira ones. Always an array, so a merge can clear it.
|
|
1532
1689
|
refs: r.refs ?? [],
|
|
1533
1690
|
createdAt: new Date(r.created_at).toISOString(),
|