@bongos/core 1.19.237 → 1.19.239
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 +53 -38
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0177-goal-membership-kind.md +96 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -5
- package/docs/api-reference.md +2 -2
- package/docs/canonical-permissions.md +3 -1
- package/docs/file-map.md +1 -1
- package/migrations/core_220_goal_members_membership_kind.sql +100 -0
- package/modules/discord/board-broadcast.js +118 -11
- package/modules/discord/db.js +44 -0
- package/modules/discord/migrations/discord_001_board_messages.sql +50 -0
- package/modules/discord/module.json +1 -0
- package/modules/discord/routes/discord.js +6 -0
- package/modules/discord/webhook.js +53 -8
- package/modules/government/board.js +35 -0
- package/modules/hall-ui/public/goals-page.js +15 -13
- package/modules/hall-ui/public/goals.css +2 -2
- package/modules/lifecycle/db.js +40 -32
- package/modules/lifecycle/goal-authz.js +10 -7
- package/modules/lifecycle/routes/done-when.js +3 -3
- package/modules/lifecycle/routes/goals.js +43 -36
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +68 -0
- package/src/module-api.js +5 -1
- package/tests/criterion_authoring_gate.mjs +4 -4
- package/tests/discord_board_broadcast.mjs +168 -0
- package/tests/goal_conflicts.mjs +3 -3
- package/tests/{goal_member_roles.mjs → goal_member_kinds.mjs} +89 -80
- package/tests/goal_membership_requests.mjs +10 -10
- package/tests/goal_routes.mjs +8 -8
- package/tests/goal_tier.mjs +10 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// tests/goal_membership_requests.mjs — private goals: the visibility flag + the
|
|
2
2
|
// invite / join-request queue db layer (task 1951 / ADR 0112). DB-free: the
|
|
3
|
-
// SQL-aware fake pool (the
|
|
3
|
+
// SQL-aware fake pool (the goal_member_kinds.mjs precedent) pins each helper's
|
|
4
4
|
// SQL contract + return shaping. The route authorization gates are task 1952's
|
|
5
5
|
// tests; this file pins the query layer under them.
|
|
6
6
|
//
|
|
@@ -139,7 +139,7 @@ await test('resolveMembershipRequest: a lost race (nothing pending) → null', a
|
|
|
139
139
|
// without a membership.
|
|
140
140
|
await test('acceptMembershipRequestAndAddMember: one atomic CTE; returns { request, member }', async () => {
|
|
141
141
|
const REQ = { ...REQUEST_ROW, status: 'accepted' };
|
|
142
|
-
const MEMBER = { goal_id: 1, builder_id: 2,
|
|
142
|
+
const MEMBER = { goal_id: 1, builder_id: 2, membership_kind: 'member', joined_at: 'T2' };
|
|
143
143
|
const pool = fakePool((s) => (/WITH resolved AS/.test(s) ? { rows: [{ request: REQ, member: MEMBER }] } : null));
|
|
144
144
|
const out = await acceptMembershipRequestAndAddMember({ id: 10, resolvedBy: 5 }, { pool });
|
|
145
145
|
assert.deepEqual(out, { request: REQ, member: MEMBER });
|
|
@@ -179,7 +179,7 @@ await test('listInboxFor: shapes {invitations, joinRequests, myPendingRequests}'
|
|
|
179
179
|
await test('listInboxFor: joinRequests gates on owner (created_by) OR manager (lead)', async () => {
|
|
180
180
|
const pool = fakePool(() => ({ rows: [] }));
|
|
181
181
|
await listInboxFor(95, { pool });
|
|
182
|
-
const jq = find(pool.queries, /r\.kind = 'request'.*g\.created_by = \$1.*
|
|
182
|
+
const jq = find(pool.queries, /r\.kind = 'request'.*g\.created_by = \$1.*membership_kind = 'lead'/);
|
|
183
183
|
assert.ok(jq, 'a join-request only reaches an owner or a manager of the goal');
|
|
184
184
|
// and it must not leak: the same $1 builder id feeds every bucket.
|
|
185
185
|
for (const q of pool.queries) assert.deepEqual(q.params, [95]);
|
|
@@ -207,30 +207,30 @@ await test('listPendingRequestsForGoal: pending both-kinds for one goal', async
|
|
|
207
207
|
// stamped 'rejected' on someone who is in fact a member.
|
|
208
208
|
// =========================================================================
|
|
209
209
|
|
|
210
|
-
const ADD_MEMBER_ROW = { goal_id: 7, builder_id: 42,
|
|
210
|
+
const ADD_MEMBER_ROW = { goal_id: 7, builder_id: 42, membership_kind: 'member', joined_at: 'T0' };
|
|
211
211
|
const addMemberPool = () => fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [ADD_MEMBER_ROW] } : null));
|
|
212
212
|
|
|
213
213
|
// join-cancels-request: the joiner arrives under their own steam.
|
|
214
214
|
await test('addGoalMember: a join cancels the joiner\'s pending ask in ONE statement', async () => {
|
|
215
215
|
const pool = addMemberPool();
|
|
216
|
-
const out = await addGoalMember({ goalId: 7, builderId: 42,
|
|
216
|
+
const out = await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'member', resolvedBy: 42 }, { pool });
|
|
217
217
|
assert.deepEqual(out, ADD_MEMBER_ROW, 'still returns the member row — the route response shape is unchanged');
|
|
218
218
|
assert.equal(pool.queries.length, 1, 'ONE statement: the cancel commits with the membership write or not at all');
|
|
219
219
|
const q = find(pool.queries, /^WITH added AS/);
|
|
220
|
-
assert.ok(/INSERT INTO goal_members[\s\S]*ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET
|
|
221
|
-
'the membership upsert is the first arm,
|
|
220
|
+
assert.ok(/INSERT INTO goal_members[\s\S]*ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET membership_kind = goal_members\.membership_kind/.test(norm(q.sql)),
|
|
221
|
+
'the membership upsert is the first arm, membership_kind still preserved (task 1002689)');
|
|
222
222
|
assert.ok(/UPDATE goal_membership_requests[\s\S]*SET status = 'cancelled', resolved_by = \$4/.test(norm(q.sql)),
|
|
223
223
|
'the second arm closes the ask as cancelled, attributed to the actor');
|
|
224
224
|
assert.ok(/WHERE goal_id = \$1 AND builder_id = \$2 AND status = 'pending'/.test(norm(q.sql)),
|
|
225
225
|
'scoped to THIS goal+builder, and only a pending row');
|
|
226
|
-
assert.deepEqual(q.params, [7, 42, 'member', 42], 'goal, builder,
|
|
226
|
+
assert.deepEqual(q.params, [7, 42, 'member', 42], 'goal, builder, membership_kind, resolvedBy — in that order');
|
|
227
227
|
});
|
|
228
228
|
|
|
229
229
|
// add-cancels-request: an owner/manager/Archon admits them directly. Same
|
|
230
230
|
// statement; resolvedBy is the ADDER, so the audit trail names who admitted them.
|
|
231
231
|
await test('addGoalMember: a direct member-add cancels the target\'s pending ask, attributed to the adder', async () => {
|
|
232
232
|
const pool = addMemberPool();
|
|
233
|
-
await addGoalMember({ goalId: 7, builderId: 42,
|
|
233
|
+
await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'lead', resolvedBy: 99 }, { pool });
|
|
234
234
|
const q = find(pool.queries, /^WITH added AS/);
|
|
235
235
|
assert.deepEqual(q.params, [7, 42, 'lead', 99],
|
|
236
236
|
'resolved_by is the ADDER (99), not the target (42) — who admitted them is the audit fact');
|
|
@@ -425,7 +425,7 @@ await test('routes: invitation-respond declines before the goal-state gate', ()
|
|
|
425
425
|
// authorizeJoinRequestResponse — owner|manager|Archon resolves a join-request.
|
|
426
426
|
const OWNER_GOAL = { id: 5, status: 'open', created_by: '1', visibility: 'private' };
|
|
427
427
|
const REQUEST = { id: 8, goal_id: '5', builder_id: '2', kind: 'request', status: 'pending' };
|
|
428
|
-
const mgr = (bid) => ({ goal_id: 5, builder_id: bid,
|
|
428
|
+
const mgr = (bid) => ({ goal_id: 5, builder_id: bid, membership_kind: 'lead' });
|
|
429
429
|
await test('authorizeJoinRequestResponse: non-authority actor → 403 (before any request lookup — no leak)', () => {
|
|
430
430
|
const d = authorizeJoinRequestResponse({ goal: OWNER_GOAL, actorId: 7, actorRank: 'metic', actorMembership: null, request: REQUEST, goalId: 5 });
|
|
431
431
|
assert.equal(d.status, 403); assert.equal(d.body.error, 'not_owner_or_manager');
|
package/tests/goal_routes.mjs
CHANGED
|
@@ -42,10 +42,10 @@ const POST_GOALS_SCHEMA = {
|
|
|
42
42
|
scope_modules: { type: 'array', maxItems: 64, itemsType: 'string' },
|
|
43
43
|
succeeds_goal_id: { type: 'integer', min: 1 }, // BV1.R64 rescope carry-forward
|
|
44
44
|
};
|
|
45
|
-
// /members validates only `
|
|
45
|
+
// /members validates only `membership_kind` declaratively; builder_id is coerced in-handler
|
|
46
46
|
// (accepts a number OR a numeric string — ids round-trip through JSON as strings).
|
|
47
47
|
const POST_MEMBERS_SCHEMA = {
|
|
48
|
-
|
|
48
|
+
membership_kind: { type: 'string' },
|
|
49
49
|
};
|
|
50
50
|
// POST /goals/:id/scope (BV1.R64 ask-to-widen) — `add` is the module list; the
|
|
51
51
|
// empty-array, unknown-key, and protected-module checks are in-handler.
|
|
@@ -122,15 +122,15 @@ await test('POST /goals/:id/scope: a non-string add item is rejected', () => {
|
|
|
122
122
|
// ---- POST /goals/:id/members schema ----
|
|
123
123
|
// builder_id is coerced via Number() + Number.isInteger in-handler (a number or a
|
|
124
124
|
// numeric string passes; a non-numeric string → 400 bad_builder_id), verified
|
|
125
|
-
// live against Postgres. Here we pin the declarative half (
|
|
126
|
-
await test('POST /members:
|
|
125
|
+
// live against Postgres. Here we pin the declarative half (membership_kind).
|
|
126
|
+
await test('POST /members: membership_kind optional, valid when present', () => {
|
|
127
127
|
assert.equal(validate({}, POST_MEMBERS_SCHEMA), null);
|
|
128
|
-
assert.equal(validate({
|
|
128
|
+
assert.equal(validate({ membership_kind: 'lead' }, POST_MEMBERS_SCHEMA), null);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
await test('POST /members: non-string
|
|
132
|
-
const r = validate({
|
|
133
|
-
assert.ok(r && r.details.some((d) => d.field === '
|
|
131
|
+
await test('POST /members: non-string membership_kind → wrong_type', () => {
|
|
132
|
+
const r = validate({ membership_kind: 42 }, POST_MEMBERS_SCHEMA);
|
|
133
|
+
assert.ok(r && r.details.some((d) => d.field === 'membership_kind' && d.reason === 'wrong_type'));
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
await test('builder_id coercion contract: Number() accepts int + numeric string, rejects junk', () => {
|
package/tests/goal_tier.mjs
CHANGED
|
@@ -49,7 +49,7 @@ const GOAL_ROW = {
|
|
|
49
49
|
scope_modules: ['gds-core'], status: 'open', created_by: 42,
|
|
50
50
|
succeeded_by_goal_id: null, sort_order: 0, created_at: 'T0', updated_at: 'T0',
|
|
51
51
|
};
|
|
52
|
-
const MEMBER_ROW = { goal_id: 7, builder_id: 42,
|
|
52
|
+
const MEMBER_ROW = { goal_id: 7, builder_id: 42, membership_kind: 'member', joined_at: 'T0' };
|
|
53
53
|
|
|
54
54
|
// =========================================================================
|
|
55
55
|
// createGoal
|
|
@@ -76,7 +76,7 @@ await test('createGoal: the creator becomes the first lead in the same statement
|
|
|
76
76
|
const pool = fakePool((s) => (/INSERT INTO goals/.test(s) ? { rows: [GOAL_ROW] } : null));
|
|
77
77
|
await createGoal({ versionId: 'V1', title: 'g', createdBy: 42 }, { pool });
|
|
78
78
|
const ins = find(pool.queries, /INSERT INTO goals/);
|
|
79
|
-
assert.match(ins.sql, /INSERT INTO goal_members \(goal_id, builder_id,
|
|
79
|
+
assert.match(ins.sql, /INSERT INTO goal_members \(goal_id, builder_id, membership_kind\)/,
|
|
80
80
|
'the member insert is part of the createGoal statement');
|
|
81
81
|
assert.match(ins.sql, /'lead'/, 'the creator is inserted as lead, not member');
|
|
82
82
|
assert.match(ins.sql, /WHERE \$6::bigint IS NOT NULL/,
|
|
@@ -157,19 +157,19 @@ await test('setGoalStatus: unknown goal returns null', async () => {
|
|
|
157
157
|
// task 1002888 wrapped the bare upsert in a CTE that also cancels any pending
|
|
158
158
|
// membership request, so the statement now STARTS with `WITH added AS (` — the
|
|
159
159
|
// INSERT is the first arm. The pending-ask cancellation itself is pinned in
|
|
160
|
-
// tests/goal_membership_requests.mjs; here we only re-anchor the
|
|
161
|
-
await test('addGoalMember: upsert PRESERVES the existing
|
|
162
|
-
const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [{ ...MEMBER_ROW,
|
|
163
|
-
const out = await addGoalMember({ goalId: 7, builderId: 42,
|
|
164
|
-
assert.equal(out.
|
|
160
|
+
// tests/goal_membership_requests.mjs; here we only re-anchor the membership contract.
|
|
161
|
+
await test('addGoalMember: upsert PRESERVES the existing membership_kind (re-join cannot demote a lead)', async () => {
|
|
162
|
+
const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [{ ...MEMBER_ROW, membership_kind: 'lead' }] } : null));
|
|
163
|
+
const out = await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'member' }, { pool });
|
|
164
|
+
assert.equal(out.membership_kind, 'lead', 'an existing lead row survives a membership_kind:member re-add');
|
|
165
165
|
const ins = find(pool.queries, /^WITH added AS/);
|
|
166
166
|
assert.deepEqual(ins.params, [7, 42, 'member', null], 'resolvedBy defaults to null when no actor is supplied');
|
|
167
167
|
assert.match(ins.sql, /INSERT INTO goal_members/, 'the membership write is the first CTE arm');
|
|
168
|
-
assert.match(ins.sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET
|
|
169
|
-
'conflict keeps the stored
|
|
168
|
+
assert.match(ins.sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET membership_kind = goal_members\.membership_kind/,
|
|
169
|
+
'conflict keeps the stored membership_kind — changes go through PATCH /goals/:id/members/:builderId only (task 1002689)');
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
-
await test('addGoalMember:
|
|
172
|
+
await test('addGoalMember: membershipKind defaults to member', async () => {
|
|
173
173
|
const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [MEMBER_ROW] } : null));
|
|
174
174
|
await addGoalMember({ goalId: 7, builderId: 42 }, { pool });
|
|
175
175
|
assert.equal(find(pool.queries, /^WITH added AS/).params[2], 'member');
|