@bongos/core 1.19.315 → 1.19.317
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/.bongos-core.json +104 -29
- package/.claude/skills/merge-mode/SKILL.md +16 -0
- package/.claude/skills/planning-session/SKILL.md +10 -0
- package/modules/lifecycle/db-analytics.js +719 -0
- package/modules/lifecycle/db-claim-reads.js +165 -0
- package/modules/lifecycle/db-claims.js +934 -0
- package/modules/lifecycle/db-deps-criteria.js +441 -0
- package/modules/lifecycle/db-goals.js +763 -0
- package/modules/lifecycle/db-grade.js +233 -0
- package/modules/lifecycle/db-overrides.js +181 -0
- package/modules/lifecycle/db-rank-authz.js +446 -0
- package/modules/lifecycle/db-shared.js +115 -0
- package/modules/lifecycle/db-ship.js +994 -0
- package/modules/lifecycle/db-tasks.js +810 -0
- package/modules/lifecycle/db-versions.js +118 -0
- package/modules/lifecycle/db.js +167 -5564
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/coreB-carve-proof.sh +18 -9
- package/scripts/gds/fitness.js +35 -27
- package/scripts/gds/identity-strip.js +75 -0
- package/scripts/gds/preflight.js +25 -2
- package/scripts/gds/provision.js +7 -3
- package/scripts/gds/skill-preflight.js +336 -0
- package/src/module-api.js +5 -1
- package/tests/auto_satisfy_criteria.mjs +3 -2
- package/tests/dependencies_migration.mjs +3 -2
- package/tests/fitness.mjs +43 -0
- package/tests/goal_abuse_matrix.mjs +3 -2
- package/tests/goal_advisory.mjs +3 -2
- package/tests/government_claim_eligibility.mjs +3 -1
- package/tests/government_rank_sync.mjs +9 -2
- package/tests/helpers.mjs +19 -0
- package/tests/migration_allocation.mjs +3 -1
- package/tests/publish_reconciler.mjs +3 -1
- package/tests/rank_gate.mjs +3 -1
- package/tests/reward_gate.mjs +3 -1
- package/tests/skill_preflight.mjs +251 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
// modules/lifecycle/db-goals.js — goals, goal membership, membership requests + goal scope/visibility/successor.
|
|
2
|
+
//
|
|
3
|
+
// Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
|
|
4
|
+
// de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
|
|
5
|
+
// original db.js lines 657-1373 unchanged. db.js remains the facade that
|
|
6
|
+
// re-exports every name, so every existing db call site is unchanged.
|
|
7
|
+
//
|
|
8
|
+
// THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
|
|
9
|
+
// doorway ../../src/module-api.js — never a core internal, never a sibling module.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const api = require('../../src/module-api');
|
|
14
|
+
const { pool } = api;
|
|
15
|
+
const doneWhen = require('./done-when.js');
|
|
16
|
+
const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
17
|
+
|
|
18
|
+
// -------------------------------------------------------------------------
|
|
19
|
+
// Goals (ADR 0086 §2 / BV1.R56 — task 1510)
|
|
20
|
+
//
|
|
21
|
+
// The shared, module-scoped tier between versions and criteria. Membership
|
|
22
|
+
// (goal_members) is what later grants bounded task authoring within the goal's
|
|
23
|
+
// `scope_modules` wall (R57 adds routes/goals.js; R58 the /status rollup). These
|
|
24
|
+
// are the pure SQL helpers + their unit tests only — NO route references a goal
|
|
25
|
+
// yet, so OTB behaves identically.
|
|
26
|
+
//
|
|
27
|
+
// Each accepts the #538 deps.pool seam (honored ONLY under NODE_ENV==='test')
|
|
28
|
+
// so the suite can drive them with a SQL-aware fake — same posture as
|
|
29
|
+
// abandonTask / createOverrideRequest.
|
|
30
|
+
// -------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
// Shared column list so every goal read/return shapes a row identically.
|
|
33
|
+
|
|
34
|
+
const GOAL_COLS =
|
|
35
|
+
'id, version_id, title, description, scope_modules, status, visibility, created_by, succeeded_by_goal_id, sort_order, created_at, updated_at';
|
|
36
|
+
|
|
37
|
+
// Shared column list for a goal_membership_requests row (task 1951 / ADR 0112) —
|
|
38
|
+
// the invite / join-request queue behind private goals. Every helper below shapes
|
|
39
|
+
// a row identically, the GOAL_COLS precedent.
|
|
40
|
+
const MEMBERSHIP_REQUEST_COLS =
|
|
41
|
+
'id, goal_id, builder_id, kind, status, note, created_by, resolved_by, created_at, resolved_at, updated_at';
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
// The creator is the goal's OWNER (created_by — the fixed authority, task 1735)
|
|
45
|
+
// and lands in the member list as a 'lead' (rendered "manager" in the hall): the
|
|
46
|
+
// member-insert CTE rides in the same statement so goal + owner-membership land
|
|
47
|
+
// atomically. createdBy NULL (system-attributed, e.g. the migration-160 default
|
|
48
|
+
// goals) inserts no member — those goals have no owner; Archons act as one.
|
|
49
|
+
|
|
50
|
+
async function createGoal(
|
|
51
|
+
{ versionId, title, description = null, scopeModules = [], status = 'open', createdBy = null },
|
|
52
|
+
deps = {}
|
|
53
|
+
) {
|
|
54
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
55
|
+
const { rows } = await activePool.query(
|
|
56
|
+
`WITH next_order AS (
|
|
57
|
+
SELECT COALESCE(MAX(sort_order) + 1, 0) AS n FROM goals WHERE version_id = $1
|
|
58
|
+
), g AS (
|
|
59
|
+
INSERT INTO goals (version_id, title, description, scope_modules, status, created_by, sort_order)
|
|
60
|
+
SELECT $1, $2, $3, $4, $5, $6, next_order.n FROM next_order
|
|
61
|
+
RETURNING ${GOAL_COLS}
|
|
62
|
+
), first_lead AS (
|
|
63
|
+
INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
64
|
+
SELECT g.id, $6::bigint, 'lead' FROM g WHERE $6::bigint IS NOT NULL
|
|
65
|
+
)
|
|
66
|
+
SELECT * FROM g`,
|
|
67
|
+
[versionId, title, description, scopeModules, status, createdBy]
|
|
68
|
+
);
|
|
69
|
+
return rows[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async function getGoal(id, deps = {}) {
|
|
74
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
75
|
+
const { rows } = await activePool.query(
|
|
76
|
+
`SELECT ${GOAL_COLS} FROM goals WHERE id = $1`,
|
|
77
|
+
[id]
|
|
78
|
+
);
|
|
79
|
+
return rows[0] ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// getGoalDependencies (task 1761) — the prerequisite goals THIS goal is blocked by:
|
|
83
|
+
// the goal->goal edges in the polymorphic `dependencies` table (migration 163),
|
|
84
|
+
// read as "from is blocked until to is done". Each carries its satisfied state — a
|
|
85
|
+
// goal dependency is satisfied when the target goal is 'achieved' (migration 163's
|
|
86
|
+
// done-ness for a goal). The hall (goals-page + roadmap) shows these and greys/locks
|
|
87
|
+
// an open goal with any unsatisfied dep. Scope is goal->goal only (a goal depending
|
|
88
|
+
// on a task/criterion is not a modeled pattern). deps.pool test seam.
|
|
89
|
+
async function getGoalDependencies(goalId, deps = {}) {
|
|
90
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
91
|
+
const { rows } = await activePool.query(
|
|
92
|
+
`SELECT g.id, g.title, g.status, g.version_id,
|
|
93
|
+
(g.status = 'achieved') AS satisfied
|
|
94
|
+
FROM dependencies d
|
|
95
|
+
JOIN goals g ON g.id = d.to_id
|
|
96
|
+
WHERE d.from_kind = 'goal' AND d.from_id = $1 AND d.to_kind = 'goal'
|
|
97
|
+
ORDER BY g.id`,
|
|
98
|
+
[goalId]
|
|
99
|
+
);
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// listGoals — optional version_id / status filters (NULL = no filter), ordered
|
|
104
|
+
// by the owner's WITHIN-version priority (sort_order, task 1760), falling back
|
|
105
|
+
// to created_at/id so a fresh instance and prod agree on row order pre-reorder.
|
|
106
|
+
|
|
107
|
+
// `limit` is optional and defaults to unbounded — the goal board reads every goal.
|
|
108
|
+
// A caller that only needs the first N (the orphan advisory wants 5 candidates)
|
|
109
|
+
// passes it so Postgres does the truncating instead of shipping the whole version's
|
|
110
|
+
// goals to be discarded in JS (task 1002539).
|
|
111
|
+
async function listGoals({ versionId = null, status = null, limit = null } = {}, deps = {}) {
|
|
112
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
113
|
+
const { rows } = await activePool.query(
|
|
114
|
+
`SELECT ${GOAL_COLS}
|
|
115
|
+
FROM goals
|
|
116
|
+
WHERE ($1::text IS NULL OR version_id = $1)
|
|
117
|
+
AND ($2::text IS NULL OR status = $2)
|
|
118
|
+
ORDER BY version_id, sort_order, created_at, id
|
|
119
|
+
LIMIT $3::int`,
|
|
120
|
+
[versionId, status, limit]
|
|
121
|
+
);
|
|
122
|
+
return rows;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// reorderGoals — Archon drag-to-reorder (task 1760). orderedIds is the new
|
|
126
|
+
// relative order for a SCOPED SUBSET of versionId's goals — the caller (hall
|
|
127
|
+
// UI) only ever renders a status-filtered slice (roadmap excludes archived;
|
|
128
|
+
// the Goals page defaults to open-only), so demanding the version's COMPLETE
|
|
129
|
+
// goal set here would fail-closed on every version that has ever archived a
|
|
130
|
+
// goal (the goal the client never rendered, and so could never round-trip).
|
|
131
|
+
// Every id must still belong to THIS version, appear once (fail-closed on a
|
|
132
|
+
// cross-version id or a duplicate — SET_MISMATCH), and be non-empty; any
|
|
133
|
+
// goal in the version NOT named in orderedIds (e.g. an archived one hidden
|
|
134
|
+
// from the view that dragged) keeps its RELATIVE order, appended after the
|
|
135
|
+
// reordered slice — reordering a subset never disturbs a goal the client
|
|
136
|
+
// didn't even see. Returns the reordered rows (the touched subset only).
|
|
137
|
+
|
|
138
|
+
async function reorderGoals({ versionId, orderedIds }, deps = {}) {
|
|
139
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
140
|
+
const { rows: existing } = await activePool.query(
|
|
141
|
+
`SELECT id FROM goals WHERE version_id = $1 ORDER BY sort_order, created_at, id`,
|
|
142
|
+
[versionId]
|
|
143
|
+
);
|
|
144
|
+
const existingIds = existing.map((r) => String(r.id));
|
|
145
|
+
const existingSet = new Set(existingIds);
|
|
146
|
+
const wantedIds = orderedIds.map(String);
|
|
147
|
+
if (wantedIds.length === 0 || new Set(wantedIds).size !== wantedIds.length || !wantedIds.every((id) => existingSet.has(id))) {
|
|
148
|
+
const err = new Error('ordered_ids must be a duplicate-free list of this version\'s goal ids');
|
|
149
|
+
err.code = 'SET_MISMATCH';
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
const wantedSet = new Set(wantedIds);
|
|
153
|
+
const remainder = existingIds.filter((id) => !wantedSet.has(id));
|
|
154
|
+
const finalOrder = [...orderedIds, ...remainder.map(Number)];
|
|
155
|
+
const { rows } = await activePool.query(
|
|
156
|
+
`UPDATE goals g
|
|
157
|
+
SET sort_order = data.ord, updated_at = now()
|
|
158
|
+
FROM (SELECT unnest($1::bigint[]) AS id, unnest($2::int[]) AS ord) AS data
|
|
159
|
+
WHERE g.id = data.id
|
|
160
|
+
RETURNING ${GOAL_COLS.split(', ').map((c) => `g.${c}`).join(', ')}`,
|
|
161
|
+
[finalOrder, finalOrder.map((_, i) => i)]
|
|
162
|
+
);
|
|
163
|
+
const byId = new Map(rows.map((r) => [String(r.id), r]));
|
|
164
|
+
return orderedIds.map((id) => byId.get(String(id))).filter(Boolean);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// setGoalStatus — the open -> achieved -> archived lifecycle move (R63/R64 wire
|
|
168
|
+
// the auto-achieve + archive/reopen on top). Bumps updated_at. Returns null if
|
|
169
|
+
// the goal id is unknown.
|
|
170
|
+
|
|
171
|
+
async function setGoalStatus({ goalId, status }, deps = {}) {
|
|
172
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
173
|
+
const { rows } = await activePool.query(
|
|
174
|
+
`UPDATE goals
|
|
175
|
+
SET status = $2, updated_at = now()
|
|
176
|
+
WHERE id = $1
|
|
177
|
+
RETURNING ${GOAL_COLS}`,
|
|
178
|
+
[goalId, status]
|
|
179
|
+
);
|
|
180
|
+
return rows[0] ?? null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// achieveGoalIfComplete — the auto-achieve half of the criterion-confirm flow
|
|
184
|
+
// (ADR 0086 §6 / BV1.R63). Called after a criterion is confirmed (satisfied=true):
|
|
185
|
+
// if that criterion's goal is still `open` AND every criterion under the goal is
|
|
186
|
+
// now satisfied (with ≥1), flip the goal open→achieved. Conservative + idempotent —
|
|
187
|
+
// it never touches an achieved/archived goal (reopen/archive is the R64 lifecycle
|
|
188
|
+
// move), and an empty goal (no criteria) is never auto-achieved. Returns
|
|
189
|
+
// { achieved, goal }: `goal` is the (possibly updated) row, or null for an unknown
|
|
190
|
+
// id. Honours the #538 deps.pool test seam (NODE_ENV==='test' only), like its
|
|
191
|
+
// siblings, so the flip logic is unit-testable with a fake client.
|
|
192
|
+
|
|
193
|
+
async function achieveGoalIfComplete({ goalId }, deps = {}) {
|
|
194
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
195
|
+
const goal = await getGoal(goalId, deps);
|
|
196
|
+
// Only an OPEN goal can auto-achieve. An already achieved/archived goal is left
|
|
197
|
+
// exactly as-is — re-confirming a criterion must not churn its status.
|
|
198
|
+
if (!goal || goal.status !== 'open') return { achieved: false, goal: goal ?? null };
|
|
199
|
+
const { rows } = await activePool.query(
|
|
200
|
+
`SELECT count(*)::int AS total,
|
|
201
|
+
count(*) FILTER (WHERE NOT satisfied)::int AS unsatisfied
|
|
202
|
+
FROM done_when_criteria
|
|
203
|
+
WHERE goal_id = $1`,
|
|
204
|
+
[goalId]
|
|
205
|
+
);
|
|
206
|
+
const total = rows[0]?.total ?? 0;
|
|
207
|
+
const unsatisfied = rows[0]?.unsatisfied ?? 0;
|
|
208
|
+
// Need ≥1 criterion and zero unsatisfied — confirming the LAST one is the
|
|
209
|
+
// trigger. An empty goal stays open (nothing could complete it).
|
|
210
|
+
if (total === 0 || unsatisfied > 0) return { achieved: false, goal };
|
|
211
|
+
const updated = await setGoalStatus({ goalId, status: 'achieved' }, deps);
|
|
212
|
+
return { achieved: true, goal: updated };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// addGoalMember — join. Upserts on the (goal_id, builder_id) PK so a re-join is
|
|
216
|
+
// not a duplicate-key error, and the conflict arm PRESERVES the stored
|
|
217
|
+
// membership_kind (task 1002689): overwriting it let POST /goals/:id/join (always
|
|
218
|
+
// 'member') and a POST /goals/:id/members re-add silently demote a lead —
|
|
219
|
+
// including the owner's row — bypassing authorizeMembershipKindChange's
|
|
220
|
+
// owner-only-demotion guards. Changes go through PATCH
|
|
221
|
+
// /goals/:id/members/:builderId only (the acceptMembershipRequestAndAddMember
|
|
222
|
+
// precedent). membershipKind defaults to 'member'.
|
|
223
|
+
|
|
224
|
+
// THE COLUMN IS `membership_kind` (task 1003013, ADR 0177 — ADR 0174 reserves the
|
|
225
|
+
// other word for the three crafts). The legacy column still EXISTS for one
|
|
226
|
+
// release, kept in lockstep by core_220's trigger so a rolled-back deploy still
|
|
227
|
+
// reads a schema it understands: write the new name only, step 2 drops the old.
|
|
228
|
+
//
|
|
229
|
+
// ARRIVING AS A MEMBER CLOSES ANY PENDING ASK (task 1002888 / idea 1000688).
|
|
230
|
+
// The membership write used to be a bare upsert that never looked at
|
|
231
|
+
// goal_membership_requests, so a builder's pending row SURVIVED their arrival by
|
|
232
|
+
// any other route — the goal flips to public and they self-join, or an Archon
|
|
233
|
+
// adds them directly. The stale row lingered in the owner/manager inbox, a later
|
|
234
|
+
// approve resolved a request for an existing member, and a later deny stamped
|
|
235
|
+
// 'rejected' on someone who is in fact a member. Harmless to access control,
|
|
236
|
+
// wrong in the audit trail.
|
|
237
|
+
//
|
|
238
|
+
// BOTH kinds are cancelled, not just 'request'. The partial-unique index
|
|
239
|
+
// (core_188: one pending row per goal+builder, either direction) means there is
|
|
240
|
+
// at most ONE pending row to close, and a stale pending INVITE is the same bug
|
|
241
|
+
// mirrored — the invitee could later decline an invitation to a goal they are
|
|
242
|
+
// already in, stamping 'rejected' on a member. 'cancelled' is the right terminal
|
|
243
|
+
// state (core_188: "withdrawn/rescinded"); 'accepted' stays reserved for an
|
|
244
|
+
// accept/approve flow that actually ran, so the inbox history never claims a
|
|
245
|
+
// decision nobody made. resolvedBy is the ACTOR — the joiner themselves on
|
|
246
|
+
// /join, the adding owner/manager/Archon on /members.
|
|
247
|
+
//
|
|
248
|
+
// One CTE, not two queries — the acceptMembershipRequestAndAddMember precedent:
|
|
249
|
+
// the cancel commits with the membership write or not at all, so an error
|
|
250
|
+
// between them can't leave the pair half-applied. The two arms are independent
|
|
251
|
+
// (Postgres runs a data-modifying CTE to completion whether or not the primary
|
|
252
|
+
// query reads it), and both see the same snapshot.
|
|
253
|
+
|
|
254
|
+
async function addGoalMember({ goalId, builderId, membershipKind = 'member', resolvedBy = null }, deps = {}) {
|
|
255
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
256
|
+
const { rows } = await activePool.query(
|
|
257
|
+
`WITH added AS (
|
|
258
|
+
INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
259
|
+
VALUES ($1, $2, $3)
|
|
260
|
+
ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = goal_members.membership_kind
|
|
261
|
+
RETURNING goal_id, builder_id, membership_kind, joined_at
|
|
262
|
+
), cancelled AS (
|
|
263
|
+
UPDATE goal_membership_requests
|
|
264
|
+
SET status = 'cancelled', resolved_by = $4, resolved_at = now(), updated_at = now()
|
|
265
|
+
WHERE goal_id = $1 AND builder_id = $2 AND status = 'pending'
|
|
266
|
+
RETURNING id
|
|
267
|
+
)
|
|
268
|
+
SELECT goal_id, builder_id, membership_kind, joined_at FROM added`,
|
|
269
|
+
[goalId, builderId, membershipKind, resolvedBy]
|
|
270
|
+
);
|
|
271
|
+
return rows[0];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// removeGoalMember — leave. Returns { removed } so the (later) route can 404 a
|
|
275
|
+
// non-member leave without a second lookup.
|
|
276
|
+
|
|
277
|
+
async function removeGoalMember({ goalId, builderId }, deps = {}) {
|
|
278
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
279
|
+
const { rowCount } = await activePool.query(
|
|
280
|
+
`DELETE FROM goal_members WHERE goal_id = $1 AND builder_id = $2`,
|
|
281
|
+
[goalId, builderId]
|
|
282
|
+
);
|
|
283
|
+
return { removed: rowCount > 0 };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async function listGoalMembers(goalId, deps = {}) {
|
|
288
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
289
|
+
// Builder identity rides along (task 1734: the hall Goals page renders member
|
|
290
|
+
// names + lead badges) — additive columns, so every existing consumer that
|
|
291
|
+
// reads only {goal_id, builder_id, membership_kind} is untouched.
|
|
292
|
+
const { rows } = await activePool.query(
|
|
293
|
+
`SELECT gm.goal_id, gm.builder_id, gm.membership_kind, gm.joined_at,
|
|
294
|
+
b.github_login, b.display_name, b.avatar_url, b.rank
|
|
295
|
+
FROM goal_members gm
|
|
296
|
+
LEFT JOIN builders b ON b.id = gm.builder_id
|
|
297
|
+
WHERE gm.goal_id = $1
|
|
298
|
+
ORDER BY gm.joined_at, gm.builder_id`,
|
|
299
|
+
[goalId]
|
|
300
|
+
);
|
|
301
|
+
return rows;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// listCriteriaForGoal — the done_when_criteria re-parented under a goal (R56
|
|
305
|
+
// backfilled every criterion under its version's default goal). Powers the
|
|
306
|
+
// "linked criteria" half of GET /goals/:id; projection matches done-when.js's
|
|
307
|
+
// listCriteria so the hall renders goal + version criteria identically.
|
|
308
|
+
|
|
309
|
+
async function listCriteriaForGoal(goalId, deps = {}) {
|
|
310
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
311
|
+
// task 1002164: carry the linked-task counts so the caller can derive the
|
|
312
|
+
// "met — pending review" state the hall renders. The counts are aggregated
|
|
313
|
+
// ONCE, over only this goal's criteria (the `counts` CTE narrows on the `crit`
|
|
314
|
+
// CTE before grouping), rather than per-row correlated subqueries — one pass
|
|
315
|
+
// over the idx_task_criteria_criterion range instead of 2N lookups. The
|
|
316
|
+
// criterion columns are still listed explicitly so the projection this read
|
|
317
|
+
// already feeds (the version-criteria view, the task-create suggester) is
|
|
318
|
+
// unchanged apart from the two additive count columns.
|
|
319
|
+
const { rows } = await activePool.query(
|
|
320
|
+
`WITH crit AS (
|
|
321
|
+
SELECT id, version_id, criterion_id, criterion_md, satisfied, sort_order, goal_id
|
|
322
|
+
FROM done_when_criteria
|
|
323
|
+
WHERE goal_id = $1
|
|
324
|
+
),
|
|
325
|
+
counts AS (
|
|
326
|
+
SELECT tc.criterion_id,
|
|
327
|
+
count(*)::int AS linked_tasks,
|
|
328
|
+
-- task 1002651 (migration 017's family): abandoned is a terminal
|
|
329
|
+
-- state (deliberately cut), not pending work — it must not hold a
|
|
330
|
+
-- criterion's "met — pending review" flip hostage forever.
|
|
331
|
+
count(*) FILTER (WHERE t.status NOT IN ('shipped', 'abandoned'))::int AS unshipped_tasks
|
|
332
|
+
FROM task_criteria tc
|
|
333
|
+
JOIN tasks t ON t.id = tc.task_id
|
|
334
|
+
WHERE tc.criterion_id IN (SELECT id FROM crit)
|
|
335
|
+
GROUP BY tc.criterion_id
|
|
336
|
+
)
|
|
337
|
+
SELECT c.id, c.version_id, c.criterion_id, c.criterion_md, c.satisfied,
|
|
338
|
+
c.sort_order, c.goal_id,
|
|
339
|
+
COALESCE(n.linked_tasks, 0) AS linked_tasks,
|
|
340
|
+
COALESCE(n.unshipped_tasks, 0) AS unshipped_tasks
|
|
341
|
+
FROM crit c
|
|
342
|
+
LEFT JOIN counts n ON n.criterion_id = c.id
|
|
343
|
+
ORDER BY sort_order, id`,
|
|
344
|
+
[goalId]
|
|
345
|
+
);
|
|
346
|
+
return rows.map((r) => ({
|
|
347
|
+
...r,
|
|
348
|
+
pending_review: doneWhen.isPendingReview({
|
|
349
|
+
satisfied: r.satisfied, linked: r.linked_tasks, unshipped: r.unshipped_tasks,
|
|
350
|
+
}),
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// isGoalMember — is this builder a member of this goal? The membership predicate
|
|
355
|
+
// behind the bounded-authority gates (BV1.R64 ask-to-widen; the R60 authoring
|
|
356
|
+
// gate reuses the same idea). Returns a plain boolean.
|
|
357
|
+
|
|
358
|
+
async function isGoalMember({ goalId, builderId }, deps = {}) {
|
|
359
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
360
|
+
const { rows } = await activePool.query(
|
|
361
|
+
`SELECT 1 FROM goal_members WHERE goal_id = $1 AND builder_id = $2 LIMIT 1`,
|
|
362
|
+
[goalId, builderId]
|
|
363
|
+
);
|
|
364
|
+
return rows.length > 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// getGoalMember — one membership row incl. membership_kind, or null. The
|
|
368
|
+
// lead-authority read behind PATCH /goals/:id/members/:builderId (task 1735): the
|
|
369
|
+
// route needs the actor's and the target's membership_kind, not just the
|
|
370
|
+
// isGoalMember boolean.
|
|
371
|
+
|
|
372
|
+
async function getGoalMember({ goalId, builderId }, deps = {}) {
|
|
373
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
374
|
+
const { rows } = await activePool.query(
|
|
375
|
+
`SELECT goal_id, builder_id, membership_kind, joined_at
|
|
376
|
+
FROM goal_members
|
|
377
|
+
WHERE goal_id = $1 AND builder_id = $2`,
|
|
378
|
+
[goalId, builderId]
|
|
379
|
+
);
|
|
380
|
+
return rows[0] ?? null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// setGoalMembershipKind — apply a membership change with the owner guard IN the statement
|
|
384
|
+
// (task 1735): the goal OWNER (goals.created_by) is the fixed authority, so a
|
|
385
|
+
// demotion never applies to the owner's row even if two requests race past the
|
|
386
|
+
// route's pure gate (the attachParent "re-check atomically" posture). Returns
|
|
387
|
+
// the updated row, or null when nothing changed — no such member, or the guard
|
|
388
|
+
// blocked an owner demotion — which the route answers as a conflict.
|
|
389
|
+
|
|
390
|
+
async function setGoalMembershipKind({ goalId, builderId, membershipKind }, deps = {}) {
|
|
391
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
392
|
+
const { rows } = await activePool.query(
|
|
393
|
+
`UPDATE goal_members gm
|
|
394
|
+
SET membership_kind = $3
|
|
395
|
+
WHERE gm.goal_id = $1 AND gm.builder_id = $2
|
|
396
|
+
AND ($3 = 'lead'
|
|
397
|
+
OR gm.builder_id IS DISTINCT FROM
|
|
398
|
+
(SELECT g.created_by FROM goals g WHERE g.id = $1))
|
|
399
|
+
RETURNING goal_id, builder_id, membership_kind, joined_at`,
|
|
400
|
+
[goalId, builderId, membershipKind]
|
|
401
|
+
);
|
|
402
|
+
return rows[0] ?? null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// transferGoalOwnership — reassign the goal's owner (task 1735): created_by
|
|
406
|
+
// moves to the new owner and the new owner's membership row is upserted to
|
|
407
|
+
// 'lead' (manager) in the SAME statement, so the goal can never point at an
|
|
408
|
+
// owner who isn't in its member list. The OLD owner keeps their lead row —
|
|
409
|
+
// "drops to manager" — exactly the handoff semantics the route promises. The
|
|
410
|
+
// route gates WHO may call this (owner | Archon, target Metic+); this helper
|
|
411
|
+
// just performs the move. Returns the updated goal row, or null for unknown id.
|
|
412
|
+
|
|
413
|
+
async function transferGoalOwnership({ goalId, newOwnerId }, deps = {}) {
|
|
414
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
415
|
+
const { rows } = await activePool.query(
|
|
416
|
+
`WITH g AS (
|
|
417
|
+
UPDATE goals
|
|
418
|
+
SET created_by = $2, updated_at = now()
|
|
419
|
+
WHERE id = $1
|
|
420
|
+
RETURNING ${GOAL_COLS}
|
|
421
|
+
), new_owner_member AS (
|
|
422
|
+
INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
423
|
+
SELECT g.id, $2, 'lead' FROM g
|
|
424
|
+
ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = 'lead'
|
|
425
|
+
)
|
|
426
|
+
SELECT * FROM g`,
|
|
427
|
+
[goalId, newOwnerId]
|
|
428
|
+
);
|
|
429
|
+
return rows[0] ?? null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// listActiveClaimsForTasks — which of these tasks hold an active claim right
|
|
433
|
+
// now (claims.released_at IS NULL — the claims-table convention; there is no
|
|
434
|
+
// status column). Feeds the conflict detector's claim-aware severity
|
|
435
|
+
// (goal-conflicts.js, task 1736). Holder identity rides along so the hall can
|
|
436
|
+
// name who to talk to.
|
|
437
|
+
|
|
438
|
+
async function listActiveClaimsForTasks(taskIds, deps = {}) {
|
|
439
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
440
|
+
const ids = (taskIds || []).map(Number).filter(Number.isFinite);
|
|
441
|
+
if (ids.length === 0) return [];
|
|
442
|
+
const { rows } = await activePool.query(
|
|
443
|
+
`SELECT c.id AS claim_id, c.task_id, c.builder_id, c.claimed_at,
|
|
444
|
+
b.github_login
|
|
445
|
+
FROM claims c
|
|
446
|
+
LEFT JOIN builders b ON b.id = c.builder_id
|
|
447
|
+
WHERE c.task_id = ANY($1::bigint[]) AND c.released_at IS NULL
|
|
448
|
+
ORDER BY c.claimed_at, c.id`,
|
|
449
|
+
[ids]
|
|
450
|
+
);
|
|
451
|
+
return rows;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// requestClaimRelease — the flag-for-release resolution action (task 1736): a
|
|
455
|
+
// goal owner/manager asks the holder of a task's ACTIVE claim to wrap up or
|
|
456
|
+
// release. A soft nudge, not an eviction — nothing about the claim's validity
|
|
457
|
+
// changes; the flag surfaces on the holder's own claim reads
|
|
458
|
+
// (getActiveClaimsForBuilder → /me). Idempotent: re-flagging refreshes
|
|
459
|
+
// requester/time/note. Returns the flagged claim row, or null when the task
|
|
460
|
+
// holds no active claim (the route answers 409).
|
|
461
|
+
|
|
462
|
+
async function requestClaimRelease({ taskId, requestedBy, note = null }, deps = {}) {
|
|
463
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
464
|
+
const { rows } = await activePool.query(
|
|
465
|
+
`UPDATE claims
|
|
466
|
+
SET release_requested_by = $2,
|
|
467
|
+
release_requested_at = now(),
|
|
468
|
+
release_requested_note = $3
|
|
469
|
+
WHERE task_id = $1 AND released_at IS NULL
|
|
470
|
+
RETURNING id AS claim_id, task_id, builder_id,
|
|
471
|
+
release_requested_by, release_requested_at, release_requested_note`,
|
|
472
|
+
[taskId, requestedBy, note]
|
|
473
|
+
);
|
|
474
|
+
return rows[0] ?? null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// rescopeTaskTouches — the rescope resolution action (task 1736): a goal
|
|
478
|
+
// owner/manager rewrites a task's DECLARED touches[] at PLANNING time, with the
|
|
479
|
+
// ADR 0049 advisory semantics (git merge stays the authoritative collision
|
|
480
|
+
// detector; this only reshapes the planning-time conflict view). This is NOT
|
|
481
|
+
// the mid-claim drift-expand removed in task 879 — the route refuses a task
|
|
482
|
+
// with an active claim, so touches are never rewritten under a working session.
|
|
483
|
+
//
|
|
484
|
+
// The ADR 0084 floor stays honest: the rank floor was auto-derived from touches
|
|
485
|
+
// at CREATE, so rewriting touches re-derives it and RATCHETS requires_rank UP
|
|
486
|
+
// in the same UPDATE if the new blast radius demands more (the
|
|
487
|
+
// updateTaskSecuritySensitive precedent — never lowered; an Archon can PATCH it
|
|
488
|
+
// down explicitly). The caller passes the task row's current facts
|
|
489
|
+
// (securitySensitive, currentRequiresRank) it already fetched for containment.
|
|
490
|
+
// Returns { id, touches, requires_rank }, or null for an unknown task.
|
|
491
|
+
|
|
492
|
+
async function rescopeTaskTouches({ taskId, touches, securitySensitive = false, currentRequiresRank = 'xenos' }, deps = {}) {
|
|
493
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
494
|
+
const cleaned = Array.from(new Set((touches || []).map((p) => String(p || '').trim()).filter(Boolean)));
|
|
495
|
+
const floor = deriveRequiredRank(cleaned, securitySensitive === true);
|
|
496
|
+
const effectiveRank = highestRank(currentRequiresRank ?? 'xenos', floor);
|
|
497
|
+
const { rows } = await activePool.query(
|
|
498
|
+
`UPDATE tasks SET touches = $2, requires_rank = $3, updated_at = now()
|
|
499
|
+
WHERE id = $1
|
|
500
|
+
RETURNING id, touches, requires_rank`,
|
|
501
|
+
[taskId, cleaned, effectiveRank]
|
|
502
|
+
);
|
|
503
|
+
return rows[0] ?? null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// addGoalScopeModules — the "ask to widen" mutation (ADR 0086 §4 / BV1.R64). Adds
|
|
507
|
+
// module key(s) to a goal's scope_modules, set-union + deduped (existing keys and
|
|
508
|
+
// repeats collapse), so widening is idempotent. The route — not this helper — is
|
|
509
|
+
// where the wall is enforced: scope is NEVER self-granted, so a protected module
|
|
510
|
+
// can only be added by an Archon, and only a member/Archon may widen at all. This
|
|
511
|
+
// helper just performs the append once that gate has passed. Returns the updated
|
|
512
|
+
// goal row, or null for an unknown id.
|
|
513
|
+
|
|
514
|
+
async function addGoalScopeModules({ goalId, modules }, deps = {}) {
|
|
515
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
516
|
+
const { rows } = await activePool.query(
|
|
517
|
+
`UPDATE goals
|
|
518
|
+
SET scope_modules = COALESCE((
|
|
519
|
+
SELECT array_agg(DISTINCT m ORDER BY m)
|
|
520
|
+
FROM unnest(scope_modules || $2::text[]) AS m
|
|
521
|
+
), '{}'),
|
|
522
|
+
updated_at = now()
|
|
523
|
+
WHERE id = $1
|
|
524
|
+
RETURNING ${GOAL_COLS}`,
|
|
525
|
+
[goalId, modules]
|
|
526
|
+
);
|
|
527
|
+
return rows[0] ?? null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// setGoalSuccessor — the rescope carry-forward link (ADR 0086 §6 / BV1.R64). Stamps
|
|
531
|
+
// an (achieved) goal's succeeded_by_goal_id to point at the new-version goal that
|
|
532
|
+
// continues it, so the version→version lineage is queryable. Bumps updated_at.
|
|
533
|
+
// Returns the updated row, or null if the goal id is unknown.
|
|
534
|
+
|
|
535
|
+
async function setGoalSuccessor({ goalId, successorId }, deps = {}) {
|
|
536
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
537
|
+
const { rows } = await activePool.query(
|
|
538
|
+
`UPDATE goals
|
|
539
|
+
SET succeeded_by_goal_id = $2, updated_at = now()
|
|
540
|
+
WHERE id = $1
|
|
541
|
+
RETURNING ${GOAL_COLS}`,
|
|
542
|
+
[goalId, successorId]
|
|
543
|
+
);
|
|
544
|
+
return rows[0] ?? null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// -------------------------------------------------------------------------
|
|
548
|
+
// Private goals: visibility + the invite / join-request queue (task 1951 / ADR
|
|
549
|
+
// 0112). The query layer only — the routes (task 1952) hold the authorization
|
|
550
|
+
// wall (owner|manager|Archon for invites/approvals; the invitee for accepting).
|
|
551
|
+
// -------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
// setGoalVisibility — flip a goal public|private (ADR 0112). 'public' is the
|
|
554
|
+
// migration default = today's open-join; 'private' means non-members can't
|
|
555
|
+
// self-join (they are invited or request+approved). Bumps updated_at. Returns
|
|
556
|
+
// the updated goal row, or null for an unknown id. The route gates WHO may call
|
|
557
|
+
// this (authorizeGoalManagement: owner|manager|Archon); this helper just writes.
|
|
558
|
+
async function setGoalVisibility({ goalId, visibility }, deps = {}) {
|
|
559
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
560
|
+
const { rows } = await activePool.query(
|
|
561
|
+
`UPDATE goals
|
|
562
|
+
SET visibility = $2, updated_at = now()
|
|
563
|
+
WHERE id = $1
|
|
564
|
+
RETURNING ${GOAL_COLS}`,
|
|
565
|
+
[goalId, visibility]
|
|
566
|
+
);
|
|
567
|
+
return rows[0] ?? null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// createMembershipRequest — open a pending invite (kind='invite', a manager asks
|
|
571
|
+
// builder_id in) or join-request (kind='request', the builder asks to join). The
|
|
572
|
+
// partial-unique index (one pending row per goal+builder) means a duplicate ask
|
|
573
|
+
// raises 23505; we surface it as a typed PENDING_EXISTS the route maps to 409 (the
|
|
574
|
+
// access_requests precedent). createdBy is who opened it. Returns the new row.
|
|
575
|
+
async function createMembershipRequest({ goalId, builderId, kind, createdBy = null, note = null }, deps = {}) {
|
|
576
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
577
|
+
try {
|
|
578
|
+
const { rows } = await activePool.query(
|
|
579
|
+
`INSERT INTO goal_membership_requests (goal_id, builder_id, kind, created_by, note)
|
|
580
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
581
|
+
RETURNING ${MEMBERSHIP_REQUEST_COLS}`,
|
|
582
|
+
[goalId, builderId, kind, createdBy, note]
|
|
583
|
+
);
|
|
584
|
+
return rows[0];
|
|
585
|
+
} catch (err) {
|
|
586
|
+
if (err && err.code === '23505') {
|
|
587
|
+
const e = new Error('a pending membership request already exists for this builder on this goal');
|
|
588
|
+
e.code = 'PENDING_EXISTS';
|
|
589
|
+
throw e;
|
|
590
|
+
}
|
|
591
|
+
throw err;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// getMembershipRequest — one request row by id, or null. The route reads it to
|
|
596
|
+
// check kind + goal_id + status + builder_id before authorizing a resolve.
|
|
597
|
+
async function getMembershipRequest(id, deps = {}) {
|
|
598
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
599
|
+
const { rows } = await activePool.query(
|
|
600
|
+
`SELECT ${MEMBERSHIP_REQUEST_COLS} FROM goal_membership_requests WHERE id = $1`,
|
|
601
|
+
[id]
|
|
602
|
+
);
|
|
603
|
+
return rows[0] ?? null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// resolveMembershipRequest — close a pending request. status is one of
|
|
607
|
+
// 'accepted' | 'rejected' | 'cancelled'. The WHERE status='pending' guard makes
|
|
608
|
+
// it atomic + idempotent: a lost race (already resolved / gone) returns null, so
|
|
609
|
+
// the route never double-adds a member off a re-submitted resolve. resolvedBy is
|
|
610
|
+
// who closed it. The membership add itself is the route's job (addGoalMember on
|
|
611
|
+
// accept/approve). Returns the updated row, or null when nothing was pending.
|
|
612
|
+
async function resolveMembershipRequest({ id, status, resolvedBy = null }, deps = {}) {
|
|
613
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
614
|
+
const { rows } = await activePool.query(
|
|
615
|
+
`UPDATE goal_membership_requests
|
|
616
|
+
SET status = $2, resolved_by = $3, resolved_at = now(), updated_at = now()
|
|
617
|
+
WHERE id = $1 AND status = 'pending'
|
|
618
|
+
RETURNING ${MEMBERSHIP_REQUEST_COLS}`,
|
|
619
|
+
[id, status, resolvedBy]
|
|
620
|
+
);
|
|
621
|
+
return rows[0] ?? null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// acceptMembershipRequestAndAddMember — the ACCEPT/APPROVE half done ATOMICALLY
|
|
625
|
+
// (task 1952 review fix). Resolving a request to 'accepted' and adding the
|
|
626
|
+
// member used to be two separate queries: an error between them left an
|
|
627
|
+
// 'accepted' row with no membership — unrecoverable, since the row is no longer
|
|
628
|
+
// pending and can't be re-accepted. One CTE makes them a single statement: the
|
|
629
|
+
// member INSERT feeds off the SAME UPDATE (the createGoal / transferGoalOwnership
|
|
630
|
+
// atomic-CTE precedent), so either both land or neither does. The UPDATE's
|
|
631
|
+
// WHERE status='pending' keeps it idempotent — a lost race (already resolved /
|
|
632
|
+
// gone) resolves nothing, adds nobody, and returns null. On the (near-impossible,
|
|
633
|
+
// since the create routes reject an existing member) membership conflict the
|
|
634
|
+
// existing membership_kind is PRESERVED, so an approve never silently demotes a
|
|
635
|
+
// manager.
|
|
636
|
+
// Returns { request, member } or null.
|
|
637
|
+
async function acceptMembershipRequestAndAddMember({ id, resolvedBy = null }, deps = {}) {
|
|
638
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
639
|
+
const { rows } = await activePool.query(
|
|
640
|
+
`WITH resolved AS (
|
|
641
|
+
UPDATE goal_membership_requests
|
|
642
|
+
SET status = 'accepted', resolved_by = $2, resolved_at = now(), updated_at = now()
|
|
643
|
+
WHERE id = $1 AND status = 'pending'
|
|
644
|
+
RETURNING ${MEMBERSHIP_REQUEST_COLS}
|
|
645
|
+
), added AS (
|
|
646
|
+
INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
647
|
+
SELECT goal_id, builder_id, 'member' FROM resolved
|
|
648
|
+
ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = goal_members.membership_kind
|
|
649
|
+
RETURNING goal_id, builder_id, membership_kind, joined_at
|
|
650
|
+
)
|
|
651
|
+
SELECT (SELECT to_jsonb(r) FROM resolved r) AS request,
|
|
652
|
+
(SELECT to_jsonb(a) FROM added a) AS member`,
|
|
653
|
+
[id, resolvedBy]
|
|
654
|
+
);
|
|
655
|
+
const request = rows[0] && rows[0].request;
|
|
656
|
+
if (!request) return null; // nothing was pending — no resolve, no member added
|
|
657
|
+
return { request, member: rows[0].member };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// listInboxFor — the home-page inbox (task 1954) for one builder, three buckets:
|
|
661
|
+
// • invitations — pending invites addressed to ME (accept/decline), with the
|
|
662
|
+
// goal + who invited me.
|
|
663
|
+
// • joinRequests — pending join-requests on goals I OWN or MANAGE
|
|
664
|
+
// (goals.created_by = me OR a goal_members 'lead' row for me),
|
|
665
|
+
// with the goal + the asker's identity + their note (approve/deny).
|
|
666
|
+
// • myPendingRequests — my own outstanding join-requests (so I can see/withdraw them).
|
|
667
|
+
// Requests only ever exist on private goals someone made private, so an
|
|
668
|
+
// owner/manager always exists — a request never lands in nobody's inbox. Archons
|
|
669
|
+
// can still approve any request via the route; the inbox stays scoped to what a
|
|
670
|
+
// builder owns/manages so it doesn't drown an Archon in every goal's queue.
|
|
671
|
+
async function listInboxFor(builderId, deps = {}) {
|
|
672
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
673
|
+
const [invitations, joinRequests, myPendingRequests] = await Promise.all([
|
|
674
|
+
activePool.query(
|
|
675
|
+
`SELECT r.id, r.goal_id, r.kind, r.status, r.note, r.created_at,
|
|
676
|
+
g.title AS goal_title, g.version_id, g.visibility,
|
|
677
|
+
ib.github_login AS invited_by_login, ib.display_name AS invited_by_name,
|
|
678
|
+
ib.avatar_url AS invited_by_avatar
|
|
679
|
+
FROM goal_membership_requests r
|
|
680
|
+
JOIN goals g ON g.id = r.goal_id
|
|
681
|
+
LEFT JOIN builders ib ON ib.id = r.created_by
|
|
682
|
+
WHERE r.kind = 'invite' AND r.status = 'pending' AND r.builder_id = $1
|
|
683
|
+
ORDER BY r.created_at, r.id`,
|
|
684
|
+
[builderId]
|
|
685
|
+
),
|
|
686
|
+
activePool.query(
|
|
687
|
+
`SELECT r.id, r.goal_id, r.builder_id, r.kind, r.status, r.note, r.created_at,
|
|
688
|
+
g.title AS goal_title, g.version_id, g.visibility,
|
|
689
|
+
b.github_login, b.display_name, b.avatar_url, b.rank
|
|
690
|
+
FROM goal_membership_requests r
|
|
691
|
+
JOIN goals g ON g.id = r.goal_id
|
|
692
|
+
LEFT JOIN builders b ON b.id = r.builder_id
|
|
693
|
+
WHERE r.kind = 'request' AND r.status = 'pending'
|
|
694
|
+
AND (g.created_by = $1
|
|
695
|
+
OR EXISTS (SELECT 1 FROM goal_members gm
|
|
696
|
+
WHERE gm.goal_id = g.id AND gm.builder_id = $1 AND gm.membership_kind = 'lead'))
|
|
697
|
+
ORDER BY r.created_at, r.id`,
|
|
698
|
+
[builderId]
|
|
699
|
+
),
|
|
700
|
+
activePool.query(
|
|
701
|
+
`SELECT r.id, r.goal_id, r.status, r.note, r.created_at,
|
|
702
|
+
g.title AS goal_title, g.version_id, g.visibility
|
|
703
|
+
FROM goal_membership_requests r
|
|
704
|
+
JOIN goals g ON g.id = r.goal_id
|
|
705
|
+
WHERE r.kind = 'request' AND r.status = 'pending' AND r.builder_id = $1
|
|
706
|
+
ORDER BY r.created_at, r.id`,
|
|
707
|
+
[builderId]
|
|
708
|
+
),
|
|
709
|
+
]);
|
|
710
|
+
return {
|
|
711
|
+
invitations: invitations.rows,
|
|
712
|
+
joinRequests: joinRequests.rows,
|
|
713
|
+
myPendingRequests: myPendingRequests.rows,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// listPendingRequestsForGoal — the goals-page manager view (task 1953): every
|
|
718
|
+
// pending row on a goal, BOTH kinds (invites the goal has outstanding + join-
|
|
719
|
+
// requests awaiting approval), with the counterpart builder's identity. Ordered
|
|
720
|
+
// kind-then-time so invites and requests group predictably.
|
|
721
|
+
async function listPendingRequestsForGoal(goalId, deps = {}) {
|
|
722
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
723
|
+
const { rows } = await activePool.query(
|
|
724
|
+
`SELECT r.id, r.goal_id, r.builder_id, r.kind, r.status, r.note, r.created_at,
|
|
725
|
+
b.github_login, b.display_name, b.avatar_url, b.rank
|
|
726
|
+
FROM goal_membership_requests r
|
|
727
|
+
LEFT JOIN builders b ON b.id = r.builder_id
|
|
728
|
+
WHERE r.goal_id = $1 AND r.status = 'pending'
|
|
729
|
+
ORDER BY r.kind, r.created_at, r.id`,
|
|
730
|
+
[goalId]
|
|
731
|
+
);
|
|
732
|
+
return rows;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
module.exports = {
|
|
736
|
+
acceptMembershipRequestAndAddMember,
|
|
737
|
+
achieveGoalIfComplete,
|
|
738
|
+
addGoalMember,
|
|
739
|
+
addGoalScopeModules,
|
|
740
|
+
createGoal,
|
|
741
|
+
createMembershipRequest,
|
|
742
|
+
getGoal,
|
|
743
|
+
getGoalDependencies,
|
|
744
|
+
getGoalMember,
|
|
745
|
+
getMembershipRequest,
|
|
746
|
+
isGoalMember,
|
|
747
|
+
listActiveClaimsForTasks,
|
|
748
|
+
listCriteriaForGoal,
|
|
749
|
+
listGoalMembers,
|
|
750
|
+
listGoals,
|
|
751
|
+
listInboxFor,
|
|
752
|
+
listPendingRequestsForGoal,
|
|
753
|
+
removeGoalMember,
|
|
754
|
+
reorderGoals,
|
|
755
|
+
requestClaimRelease,
|
|
756
|
+
rescopeTaskTouches,
|
|
757
|
+
resolveMembershipRequest,
|
|
758
|
+
setGoalMembershipKind,
|
|
759
|
+
setGoalStatus,
|
|
760
|
+
setGoalSuccessor,
|
|
761
|
+
setGoalVisibility,
|
|
762
|
+
transferGoalOwnership,
|
|
763
|
+
};
|