@bongos/core 1.19.238 → 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 +40 -30
- 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/migrations/core_220_goal_members_membership_kind.sql +100 -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 +3 -1
- package/tests/criterion_authoring_gate.mjs +4 -4
- 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
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');
|