@bongos/core 1.19.513 → 1.19.515
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 +62 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +20 -0
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0182-project-visibility-two-axes.md +2 -0
- package/docs/api/openapi.json +351 -4
- package/docs/api-reference.md +12 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +12 -0
- package/modules/government/catalog.js +6 -0
- package/modules/government/migrations/government_009_mingle_manage.sql +55 -0
- package/modules/lifecycle/migrations/lifecycle_007_mingle.sql +134 -0
- package/modules/lifecycle/mingle.js +337 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/mingle.js +116 -0
- package/modules/platform-identity/platform-identity.js +24 -20
- package/modules/platform-identity/projects-feed.js +142 -0
- package/modules/platform-identity/routes/projects.js +27 -8
- package/modules/platform-identity/tests/platform-identity.mjs +9 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_seed.mjs +35 -0
- package/tests/mingle.mjs +370 -0
- package/tests/projects_hub.mjs +5 -1
- package/tests/visible_projects_feed.mjs +320 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// modules/lifecycle/mingle.js — mingle: two switches, cross-craft, declinable
|
|
2
|
+
// without penalty (task 1003177 / C07, goal 1000075; criterion
|
|
3
|
+
// collab-mingle-two-switches, ADR 0187 §5).
|
|
4
|
+
//
|
|
5
|
+
// THE DECISION. Mingle introduces two builders who do NOT share a craft. The
|
|
6
|
+
// owner's reasoning (ADR 0187 §5) is the goal's founding premise — the person you
|
|
7
|
+
// need is often not another one of you — and it is also what keeps mingle doing
|
|
8
|
+
// something the craft-matched Collab request path does not already do.
|
|
9
|
+
// Same-craft pairing was considered and REJECTED as duplicating
|
|
10
|
+
// collab-page-matches-my-craft; do not "improve" the matcher by adding it back.
|
|
11
|
+
//
|
|
12
|
+
// TWO SWITCHES, AND ONE IS NOT CONSENT. A pair happens only when the PROJECT has
|
|
13
|
+
// enabled mingle AND the individual has opted in. Auto-pairing a person with a
|
|
14
|
+
// stranger a few times a week is a real-world social commitment, so the project
|
|
15
|
+
// enabling it for everyone is not the person agreeing to it, and a person opting
|
|
16
|
+
// in on an instance that never turned it on is not the project offering it. Both
|
|
17
|
+
// are read on every pairing, and both FAIL CLOSED: absent is "no", never
|
|
18
|
+
// "hasn't said no yet" (the ADR 0171 posture, inherited).
|
|
19
|
+
//
|
|
20
|
+
// DECLINING IS FREE, AND THAT IS A PROPERTY OF THE READ, NOT A PROMISE.
|
|
21
|
+
// "Without penalty" means declining affects no rank, no karma, no streak and no
|
|
22
|
+
// future matching weight — and the criterion insists that be verifiable rather
|
|
23
|
+
// than stated. Two things make it so, and both are structural:
|
|
24
|
+
//
|
|
25
|
+
// 1. `decline()` writes ONE row in ONE table — the pair it is settling. It
|
|
26
|
+
// touches no credit, karma, rank or streak surface, and there is no counter
|
|
27
|
+
// anywhere to touch (see lifecycle_007_mingle.sql, which says the same thing
|
|
28
|
+
// about the schema).
|
|
29
|
+
// 2. `eligibleBuilders()` — the matcher's ONLY input about who may be paired —
|
|
30
|
+
// never reads lifecycle_mingle_pairs at all. A decline therefore cannot make
|
|
31
|
+
// the decliner less likely to be paired again, because the read that decides
|
|
32
|
+
// that has no way to see it. The one thing pair history does gate is a second
|
|
33
|
+
// OPEN proposal for the same two people, which is the unique index's job and
|
|
34
|
+
// is about not double-introducing, not about declines.
|
|
35
|
+
//
|
|
36
|
+
// tests/mingle.mjs drives both against a recording pool and fails if either stops
|
|
37
|
+
// being true.
|
|
38
|
+
//
|
|
39
|
+
// THE CRAFT VOCABULARY IS NOT REDEFINED HERE. help-requests.js declares itself the
|
|
40
|
+
// one place that knows how a craft maps to `builders.preferred_disciplines`
|
|
41
|
+
// ('builder' the craft is 'engineer' the discipline), so this file consumes
|
|
42
|
+
// `craftMapSql` / `craftForDiscipline` from it rather than re-deriving. Two
|
|
43
|
+
// spellings of the same craft would silently match nobody.
|
|
44
|
+
//
|
|
45
|
+
// Reaches core only through the doorway (ADR 0083). The core tables it reads
|
|
46
|
+
// (builders) are read-only here.
|
|
47
|
+
|
|
48
|
+
'use strict';
|
|
49
|
+
|
|
50
|
+
const api = require('../../src/module-api');
|
|
51
|
+
const { pool } = api;
|
|
52
|
+
const { craftMapSql } = require('./help-requests');
|
|
53
|
+
|
|
54
|
+
// The PROJECT switch. A module-owned knob in the project's own store
|
|
55
|
+
// (project_settings, migration core_231) — deliberately NOT a second settings
|
|
56
|
+
// table, which is the thing src/bongos/project-settings.js exists to prevent, and
|
|
57
|
+
// deliberately not registered in that file's core SETTING_KEYS: the key is
|
|
58
|
+
// namespaced to this surface, and a module's knob is the module's to name.
|
|
59
|
+
const MINGLE_ENABLED_KEY = 'mingle.enabled';
|
|
60
|
+
|
|
61
|
+
const MAX_ROWS = 100;
|
|
62
|
+
|
|
63
|
+
// THE MATCHING RULE, RECORDED. The criterion asks for cross-craft to be "chosen
|
|
64
|
+
// deliberately and recorded", so the rule is data a reader can fetch (the route
|
|
65
|
+
// serves this verbatim) rather than a shape you have to infer from the SQL — the
|
|
66
|
+
// same reason closeness-weights.json exists.
|
|
67
|
+
const MATCHING = Object.freeze({
|
|
68
|
+
rule: 'cross_craft',
|
|
69
|
+
description:
|
|
70
|
+
'A pair is two builders whose crafts do not overlap at all — an engineer with '
|
|
71
|
+
+ 'an artist or an ideator, never two of the same. The premise is that the '
|
|
72
|
+
+ 'person you need is often not another one of you.',
|
|
73
|
+
// Stated because it is the road not taken, and an unexplained absence reads as
|
|
74
|
+
// an oversight to the next person holding the matcher.
|
|
75
|
+
rejected: 'same_craft — it duplicates the craft-matched Collab request path (criterion collab-page-matches-my-craft).',
|
|
76
|
+
// DISJOINT, not merely "different". A builder who is both an artist and an
|
|
77
|
+
// engineer shares a craft with every engineer, so pairing them would put two of
|
|
78
|
+
// the same in a room and call it cross-craft.
|
|
79
|
+
overlap: 'none',
|
|
80
|
+
consent: 'Both switches required: the project enables mingle AND the builder opts in. Either off means no pair.',
|
|
81
|
+
decline: 'Terminal and free — no rank, karma, streak or matching effect. The eligibility read never sees pair history.',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function matchingModel() { return MATCHING; }
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Switch 1 — the project
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
// TRUE only for an explicit, recognised yes. Everything else — absent, blank,
|
|
91
|
+
// 'maybe', a value that was valid before someone tightened this — is NO.
|
|
92
|
+
//
|
|
93
|
+
// This is the one place mingle deliberately DIVERGES from the rot timer's posture
|
|
94
|
+
// in src/bongos/project-settings.js. That file reports a bad value on the screen
|
|
95
|
+
// of the person who can fix it, because all of its callers are read surfaces. This
|
|
96
|
+
// is a consent gate: an unreadable switch must not open, so it fails closed the
|
|
97
|
+
// way ADR 0171's consent state does. One enforces, one reports.
|
|
98
|
+
function parseEnabled(raw) {
|
|
99
|
+
if (raw == null) return false;
|
|
100
|
+
return ['true', '1', 'on', 'yes'].includes(String(raw).trim().toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function projectEnabled(deps = {}) {
|
|
104
|
+
const get = deps.getSetting || api.projectSettings.get;
|
|
105
|
+
let row;
|
|
106
|
+
try {
|
|
107
|
+
row = await get(MINGLE_ENABLED_KEY);
|
|
108
|
+
} catch (_) {
|
|
109
|
+
// FAIL CLOSED. An unreadable switch is not permission.
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return parseEnabled(row && row.value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function setProjectEnabled(enabled, builderId = null, deps = {}) {
|
|
116
|
+
const set = deps.setSetting || api.projectSettings.set;
|
|
117
|
+
await set(MINGLE_ENABLED_KEY, enabled ? 'true' : 'false', builderId);
|
|
118
|
+
return { key: MINGLE_ENABLED_KEY, enabled: !!enabled };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Switch 2 — the builder
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
// null = never offered / never answered. Deliberately distinct from false, which
|
|
126
|
+
// is a deliberate withdrawal — a surface that cannot tell them apart cannot ask
|
|
127
|
+
// the right question.
|
|
128
|
+
async function optInState(builderId, deps = {}) {
|
|
129
|
+
const activePool = deps.pool || pool;
|
|
130
|
+
const { rows } = await activePool.query(
|
|
131
|
+
'SELECT opted_in, updated_at FROM lifecycle_mingle_optin WHERE builder_id = $1',
|
|
132
|
+
[builderId]
|
|
133
|
+
);
|
|
134
|
+
if (!rows[0]) return { opted_in: null, updated_at: null };
|
|
135
|
+
return { opted_in: rows[0].opted_in === true, updated_at: rows[0].updated_at };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function setOptIn(builderId, optedIn, deps = {}) {
|
|
139
|
+
const activePool = deps.pool || pool;
|
|
140
|
+
const { rows } = await activePool.query(
|
|
141
|
+
`INSERT INTO lifecycle_mingle_optin (builder_id, opted_in, updated_at)
|
|
142
|
+
VALUES ($1, $2, now())
|
|
143
|
+
ON CONFLICT (builder_id) DO UPDATE
|
|
144
|
+
SET opted_in = EXCLUDED.opted_in, updated_at = now()
|
|
145
|
+
RETURNING builder_id, opted_in, updated_at`,
|
|
146
|
+
[builderId, !!optedIn]
|
|
147
|
+
);
|
|
148
|
+
return rows[0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// BOTH switches, resolved together, for one builder. The surface that asks "can I
|
|
152
|
+
// mingle" gets back which half is off, because "no" with no reason is a dead end
|
|
153
|
+
// for the person who could turn their own half on.
|
|
154
|
+
async function mingleStateFor(builderId, deps = {}) {
|
|
155
|
+
const [project, mine] = await Promise.all([projectEnabled(deps), optInState(builderId, deps)]);
|
|
156
|
+
return {
|
|
157
|
+
enabled: project && mine.opted_in === true,
|
|
158
|
+
project_enabled: project,
|
|
159
|
+
opted_in: mine.opted_in,
|
|
160
|
+
opted_in_at: mine.updated_at,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// The matcher
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
// Everyone the project may introduce right now: opted in, active, and carrying at
|
|
169
|
+
// least one craft.
|
|
170
|
+
//
|
|
171
|
+
// IT DOES NOT JOIN lifecycle_mingle_pairs, AND MUST NOT. This read is the whole of
|
|
172
|
+
// "who may be paired", so the moment it can see a decline, declining has a
|
|
173
|
+
// matching cost and the criterion is broken. See the header.
|
|
174
|
+
//
|
|
175
|
+
// A builder with no `preferred_disciplines` produces no rows through the unnest and
|
|
176
|
+
// is therefore absent — the correct fail-closed answer, and the same one
|
|
177
|
+
// help-requests gives: belonging to a craft is a claim about yourself the system
|
|
178
|
+
// should not assume on your behalf.
|
|
179
|
+
async function eligibleBuilders(deps = {}) {
|
|
180
|
+
const activePool = deps.pool || pool;
|
|
181
|
+
const { rows } = await activePool.query(
|
|
182
|
+
`SELECT b.id::text AS builder_id,
|
|
183
|
+
array_agg(DISTINCT ${craftMapSql('d')}) FILTER (WHERE ${craftMapSql('d')} IS NOT NULL) AS crafts
|
|
184
|
+
FROM lifecycle_mingle_optin o
|
|
185
|
+
JOIN builders b ON b.id = o.builder_id
|
|
186
|
+
CROSS JOIN LATERAL unnest(b.preferred_disciplines) AS d
|
|
187
|
+
WHERE o.opted_in = true
|
|
188
|
+
AND b.status = 'active'
|
|
189
|
+
GROUP BY b.id
|
|
190
|
+
ORDER BY b.id`
|
|
191
|
+
);
|
|
192
|
+
return rows
|
|
193
|
+
.map((r) => ({ builder_id: String(r.builder_id), crafts: (r.crafts || []).filter(Boolean).sort() }))
|
|
194
|
+
.filter((b) => b.crafts.length > 0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// PURE. Is this pair legal? Disjoint craft sets — see MATCHING.overlap.
|
|
198
|
+
function isCrossCraft(craftsA, craftsB) {
|
|
199
|
+
const a = new Set(craftsA || []);
|
|
200
|
+
if (!a.size || !(craftsB || []).length) return false;
|
|
201
|
+
return !(craftsB || []).some((c) => a.has(c));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// PURE. Given eligible builders, produce cross-craft pairs — each builder in at
|
|
205
|
+
// most one pair. Greedy over a stable order so the same input always yields the
|
|
206
|
+
// same pairing (a matcher that shuffles cannot be tested, and cannot be explained
|
|
207
|
+
// to the person it introduced).
|
|
208
|
+
//
|
|
209
|
+
// `excludePairs` is the set of "lo:hi" keys that already hold an OPEN proposal. It
|
|
210
|
+
// is NOT decline history: settled pairs are absent from it and may be introduced
|
|
211
|
+
// again, because a decline that permanently separated two people would be a
|
|
212
|
+
// consequence, and declining is free.
|
|
213
|
+
function planPairs(eligible, { excludePairs = new Set(), limit = MAX_ROWS } = {}) {
|
|
214
|
+
const taken = new Set();
|
|
215
|
+
const out = [];
|
|
216
|
+
const list = [...eligible].sort((a, b) => String(a.builder_id).localeCompare(String(b.builder_id)));
|
|
217
|
+
for (let i = 0; i < list.length && out.length < limit; i++) {
|
|
218
|
+
const a = list[i];
|
|
219
|
+
if (taken.has(a.builder_id)) continue;
|
|
220
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
221
|
+
const b = list[j];
|
|
222
|
+
if (taken.has(b.builder_id)) continue;
|
|
223
|
+
if (!isCrossCraft(a.crafts, b.crafts)) continue;
|
|
224
|
+
const pair = orderPair(a, b);
|
|
225
|
+
if (excludePairs.has(pairKey(pair.lo.builder_id, pair.hi.builder_id))) continue;
|
|
226
|
+
taken.add(a.builder_id); taken.add(b.builder_id);
|
|
227
|
+
out.push({
|
|
228
|
+
builder_lo: pair.lo.builder_id,
|
|
229
|
+
builder_hi: pair.hi.builder_id,
|
|
230
|
+
// One representative craft each. The sets are disjoint, so any pick
|
|
231
|
+
// differs — recording them is what lets a pair be audited later even if
|
|
232
|
+
// someone changes their disciplines afterwards.
|
|
233
|
+
craft_lo: pair.lo.crafts[0],
|
|
234
|
+
craft_hi: pair.hi.crafts[0],
|
|
235
|
+
});
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function orderPair(a, b) {
|
|
243
|
+
return String(a.builder_id).padStart(24, '0') <= String(b.builder_id).padStart(24, '0')
|
|
244
|
+
? { lo: a, hi: b } : { lo: b, hi: a };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function pairKey(lo, hi) { return `${lo}:${hi}`; }
|
|
248
|
+
|
|
249
|
+
async function openPairKeys(deps = {}) {
|
|
250
|
+
const activePool = deps.pool || pool;
|
|
251
|
+
const { rows } = await activePool.query(
|
|
252
|
+
`SELECT builder_lo::text AS lo, builder_hi::text AS hi
|
|
253
|
+
FROM lifecycle_mingle_pairs WHERE status = 'proposed'`
|
|
254
|
+
);
|
|
255
|
+
return new Set(rows.map((r) => pairKey(String(r.lo), String(r.hi))));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Introduce everyone who can be introduced, and write the pairs. Refuses outright
|
|
259
|
+
// when the PROJECT switch is off — the per-builder switch is already enforced by
|
|
260
|
+
// eligibleBuilders, which only ever returns people who opted in.
|
|
261
|
+
async function runMingleRound({ limit = MAX_ROWS } = {}, deps = {}) {
|
|
262
|
+
if (!(await projectEnabled(deps))) return { paired: [], skipped: 'project_disabled' };
|
|
263
|
+
const activePool = deps.pool || pool;
|
|
264
|
+
const [eligible, open] = await Promise.all([eligibleBuilders(deps), openPairKeys(deps)]);
|
|
265
|
+
const planned = planPairs(eligible, { excludePairs: open, limit });
|
|
266
|
+
if (!planned.length) return { paired: [], skipped: null };
|
|
267
|
+
|
|
268
|
+
// ONE insert for the whole round, not one per pair. The round is bounded to
|
|
269
|
+
// MAX_ROWS and runs rarely, so this is not a hot path — but a loop of awaited
|
|
270
|
+
// INSERTs is also a partial write when the tenth throws, and unnest() makes the
|
|
271
|
+
// round atomic for free. ON CONFLICT DO NOTHING lets the open-pair unique index
|
|
272
|
+
// drop a pair another round introduced a moment ago rather than failing all of
|
|
273
|
+
// them; RETURNING is therefore what was actually written, never what was planned.
|
|
274
|
+
const { rows } = await activePool.query(
|
|
275
|
+
`INSERT INTO lifecycle_mingle_pairs (builder_lo, builder_hi, craft_lo, craft_hi)
|
|
276
|
+
SELECT * FROM unnest($1::bigint[], $2::bigint[], $3::text[], $4::text[])
|
|
277
|
+
ON CONFLICT DO NOTHING
|
|
278
|
+
RETURNING id, builder_lo::text AS builder_lo, builder_hi::text AS builder_hi,
|
|
279
|
+
craft_lo, craft_hi, status, created_at`,
|
|
280
|
+
[
|
|
281
|
+
planned.map((p) => p.builder_lo),
|
|
282
|
+
planned.map((p) => p.builder_hi),
|
|
283
|
+
planned.map((p) => p.craft_lo),
|
|
284
|
+
planned.map((p) => p.craft_hi),
|
|
285
|
+
]
|
|
286
|
+
);
|
|
287
|
+
return { paired: rows, skipped: null };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The open introductions for one builder, either side of the pair.
|
|
291
|
+
async function openPairsFor(builderId, deps = {}) {
|
|
292
|
+
const activePool = deps.pool || pool;
|
|
293
|
+
const { rows } = await activePool.query(
|
|
294
|
+
`SELECT p.id, p.builder_lo::text AS builder_lo, p.builder_hi::text AS builder_hi,
|
|
295
|
+
p.craft_lo, p.craft_hi, p.status, p.created_at,
|
|
296
|
+
lo.github_login AS lo_login, lo.display_name AS lo_name,
|
|
297
|
+
hi.github_login AS hi_login, hi.display_name AS hi_name
|
|
298
|
+
FROM lifecycle_mingle_pairs p
|
|
299
|
+
JOIN builders lo ON lo.id = p.builder_lo
|
|
300
|
+
JOIN builders hi ON hi.id = p.builder_hi
|
|
301
|
+
WHERE p.status = 'proposed'
|
|
302
|
+
AND (p.builder_lo = $1 OR p.builder_hi = $1)
|
|
303
|
+
ORDER BY p.created_at DESC`,
|
|
304
|
+
[builderId]
|
|
305
|
+
);
|
|
306
|
+
return rows;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Accept or decline an introduction you are part of.
|
|
310
|
+
//
|
|
311
|
+
// ONE ROW, ONE TABLE. There is nothing else to write, and that absence IS the
|
|
312
|
+
// "without penalty" guarantee — see the header and the migration. Do not add a
|
|
313
|
+
// counter, a cooldown, or a karma/credit call here.
|
|
314
|
+
async function decidePair(pairId, builderId, decision, deps = {}) {
|
|
315
|
+
const activePool = deps.pool || pool;
|
|
316
|
+
if (!['accepted', 'declined'].includes(decision)) return { error: 'bad_decision' };
|
|
317
|
+
const { rows } = await activePool.query(
|
|
318
|
+
`UPDATE lifecycle_mingle_pairs
|
|
319
|
+
SET status = $3, decided_by = $2, decided_at = now()
|
|
320
|
+
WHERE id = $1
|
|
321
|
+
AND status = 'proposed'
|
|
322
|
+
AND (builder_lo = $2 OR builder_hi = $2)
|
|
323
|
+
RETURNING id, builder_lo::text AS builder_lo, builder_hi::text AS builder_hi,
|
|
324
|
+
craft_lo, craft_hi, status, decided_by::text AS decided_by, decided_at`,
|
|
325
|
+
[pairId, builderId, decision]
|
|
326
|
+
);
|
|
327
|
+
if (!rows[0]) return { error: 'not_found' };
|
|
328
|
+
return { pair: rows[0] };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = {
|
|
332
|
+
MINGLE_ENABLED_KEY, MAX_ROWS, MATCHING, matchingModel,
|
|
333
|
+
parseEnabled, projectEnabled, setProjectEnabled,
|
|
334
|
+
optInState, setOptIn, mingleStateFor,
|
|
335
|
+
eligibleBuilders, isCrossCraft, planPairs, pairKey, openPairKeys,
|
|
336
|
+
runMingleRound, openPairsFor, decidePair,
|
|
337
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// modules/lifecycle/routes/mingle.js — the two switches, the introductions, and
|
|
2
|
+
// the decline (task 1003177 / C07, goal 1000075; criterion
|
|
3
|
+
// collab-mingle-two-switches, ADR 0187 §5).
|
|
4
|
+
//
|
|
5
|
+
// FIVE ROUTES, AND THE SHAPE IS THE POINT:
|
|
6
|
+
// GET /mingle — where I stand: BOTH switches, my open
|
|
7
|
+
// introductions, and the matching rule itself.
|
|
8
|
+
// PUT /mingle/opt-in — MY half of consent. Own-scoped, any-builder.
|
|
9
|
+
// PUT /mingle/project — the PROJECT's half. `mingle.manage`.
|
|
10
|
+
// POST /mingle/round — run a pairing round. `mingle.manage`.
|
|
11
|
+
// POST /mingle/pairs/:id/decide — accept or decline an introduction I am in.
|
|
12
|
+
//
|
|
13
|
+
// WHY GET /mingle ANSWERS WITH BOTH SWITCHES RATHER THAN ONE BOOLEAN. "You cannot
|
|
14
|
+
// mingle" with no reason is a dead end for the one person who could fix half of
|
|
15
|
+
// it. A builder who has opted in on an instance that never enabled mingle should
|
|
16
|
+
// see that it is the project's half that is off, and not be told to change a
|
|
17
|
+
// setting they already set.
|
|
18
|
+
//
|
|
19
|
+
// THE OPERATOR GATE IS AN ATOM, NOT A RANK. `requireRank` in a modules/*/routes
|
|
20
|
+
// gate is what BV1.R105 exists to remove, and tests/government_require_permission
|
|
21
|
+
// fails the build on a new one — the two files still spelling it that way are
|
|
22
|
+
// documented holdouts, not a pattern to copy. So this mints `mingle.manage`
|
|
23
|
+
// (modules/government/catalog.js) and gates on it.
|
|
24
|
+
//
|
|
25
|
+
// FLOOR: metic, by the `discord.channels.manage` analogue — configuring a
|
|
26
|
+
// project-wide social surface — and not archon. What makes that safe is the OTHER
|
|
27
|
+
// switch: enabling mingle only OFFERS it, and can never pair a builder who has not
|
|
28
|
+
// personally opted in, so this gate cannot put anyone in a room with a stranger.
|
|
29
|
+
//
|
|
30
|
+
// THE DECLINE ROUTE COSTS THE DECLINER NOTHING, and there is deliberately nothing
|
|
31
|
+
// here that could make it cost: no counter to bump, no karma or credit call, no
|
|
32
|
+
// cooldown to write. See mingle.js and lifecycle_007_mingle.sql, which say the
|
|
33
|
+
// same thing about the read and the schema.
|
|
34
|
+
//
|
|
35
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
36
|
+
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
const express = require('express');
|
|
40
|
+
const api = require('../../../src/module-api');
|
|
41
|
+
const mingle = require('../mingle');
|
|
42
|
+
|
|
43
|
+
const { asyncHandler, validateOrRespond, parseId } = api;
|
|
44
|
+
|
|
45
|
+
module.exports = function buildMingleRouter() {
|
|
46
|
+
const router = express.Router();
|
|
47
|
+
|
|
48
|
+
// rank: any-builder, and OWN-SCOPED — your own state, never someone else's.
|
|
49
|
+
// Who else has opted in to being introduced is a fact about them.
|
|
50
|
+
router.get('/mingle', api.requireBuilder, asyncHandler('GET /mingle', async (req, res) => {
|
|
51
|
+
const [state, pairs] = await Promise.all([
|
|
52
|
+
mingle.mingleStateFor(req.builder.id),
|
|
53
|
+
mingle.openPairsFor(req.builder.id),
|
|
54
|
+
]);
|
|
55
|
+
return res.json({
|
|
56
|
+
mingle: state,
|
|
57
|
+
open_pairs: pairs,
|
|
58
|
+
// Served VERBATIM from the module. The criterion asks for cross-craft to be
|
|
59
|
+
// chosen deliberately and RECORDED; this is where it is read, and a
|
|
60
|
+
// restatement here could drift from the matcher that actually runs.
|
|
61
|
+
matching: mingle.matchingModel(),
|
|
62
|
+
});
|
|
63
|
+
}, { errorCode: 'mingle_read_failed' }));
|
|
64
|
+
|
|
65
|
+
// rank: any-builder, OWN-SCOPED. Keyed by the SESSION, never a body field —
|
|
66
|
+
// consent you can give on someone else's behalf is not consent.
|
|
67
|
+
router.put('/mingle/opt-in', api.requireBuilder, asyncHandler('PUT /mingle/opt-in', async (req, res) => {
|
|
68
|
+
if (validateOrRespond(req, res, {
|
|
69
|
+
opted_in: { required: true, type: 'boolean' },
|
|
70
|
+
})) return;
|
|
71
|
+
const row = await mingle.setOptIn(req.builder.id, req.body.opted_in === true);
|
|
72
|
+
return res.json({ opted_in: row.opted_in, updated_at: row.updated_at });
|
|
73
|
+
}, { errorCode: 'mingle_optin_failed' }));
|
|
74
|
+
|
|
75
|
+
// perm: mingle.manage (floor metic) — see the header. Offering the feature to
|
|
76
|
+
// the project is an operator act; it still cannot pair anyone who has not
|
|
77
|
+
// opted themselves in.
|
|
78
|
+
router.put('/mingle/project', api.requireBuilder, api.requirePermission('mingle.manage'), asyncHandler('PUT /mingle/project', async (req, res) => {
|
|
79
|
+
if (validateOrRespond(req, res, {
|
|
80
|
+
enabled: { required: true, type: 'boolean' },
|
|
81
|
+
})) return;
|
|
82
|
+
const out = await mingle.setProjectEnabled(req.body.enabled === true, req.builder.id);
|
|
83
|
+
return res.json(out);
|
|
84
|
+
}, { errorCode: 'mingle_project_switch_failed' }));
|
|
85
|
+
|
|
86
|
+
// perm: mingle.manage — running a round writes introductions for other people,
|
|
87
|
+
// so it is an operator action rather than something a builder does to themselves.
|
|
88
|
+
// Refuses on its own when the project switch is off (runMingleRound checks).
|
|
89
|
+
router.post('/mingle/round', api.requireBuilder, api.requirePermission('mingle.manage'), asyncHandler('POST /mingle/round', async (req, res) => {
|
|
90
|
+
if (validateOrRespond(req, res, {
|
|
91
|
+
limit: { type: 'integer', min: 1, max: mingle.MAX_ROWS },
|
|
92
|
+
})) return;
|
|
93
|
+
const out = await mingle.runMingleRound({ limit: req.body.limit || mingle.MAX_ROWS });
|
|
94
|
+
return res.json({ paired: out.paired, count: out.paired.length, skipped: out.skipped });
|
|
95
|
+
}, { errorCode: 'mingle_round_failed' }));
|
|
96
|
+
|
|
97
|
+
// rank: any-builder. The UPDATE itself requires the caller to be one of the two
|
|
98
|
+
// people in the pair, so a builder cannot answer an introduction they are not
|
|
99
|
+
// part of — enforced in SQL, not by a check here that a later caller could skip.
|
|
100
|
+
router.post('/mingle/pairs/:id/decide', api.requireBuilder, asyncHandler('POST /mingle/pairs/:id/decide', async (req, res) => {
|
|
101
|
+
const pairId = parseId(req, res, { param: 'id', code: 'bad_pair_id', positiveInt: true });
|
|
102
|
+
if (pairId === null) return;
|
|
103
|
+
if (validateOrRespond(req, res, {
|
|
104
|
+
decision: { required: true, type: 'string', enum: ['accepted', 'declined'] },
|
|
105
|
+
})) return;
|
|
106
|
+
|
|
107
|
+
const out = await mingle.decidePair(pairId, req.builder.id, req.body.decision);
|
|
108
|
+
if (out.error === 'bad_decision') return res.fail(400, 'bad_decision', 'decision must be accepted or declined');
|
|
109
|
+
// ONE answer for "no such pair", "already settled" and "not yours". Telling
|
|
110
|
+
// them apart would let a caller probe which pair ids exist and who is in them.
|
|
111
|
+
if (out.error) return res.fail(404, 'pair_not_found', 'no open introduction with that id for you');
|
|
112
|
+
return res.json({ pair: out.pair });
|
|
113
|
+
}, { errorCode: 'mingle_decide_failed' }));
|
|
114
|
+
|
|
115
|
+
return router;
|
|
116
|
+
};
|
|
@@ -313,6 +313,13 @@ const {
|
|
|
313
313
|
publishGatePort,
|
|
314
314
|
} = require('./publish-gate');
|
|
315
315
|
|
|
316
|
+
// The public projects feed — who is on the map, and what each row may say (ADR 0182
|
|
317
|
+
// D1/D2a/D3; R06, task 1002325). Its own file for the publish-gate.js reason: this
|
|
318
|
+
// file is at the size ratchet. Re-exported below, so the surface is unchanged.
|
|
319
|
+
const {
|
|
320
|
+
ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects,
|
|
321
|
+
} = require('./projects-feed');
|
|
322
|
+
|
|
316
323
|
// Upsert a catalog row (keyed by origin). Called when a client registers (so every
|
|
317
324
|
// federated project is catalogued, unfeatured) and by the Archon create/curate route.
|
|
318
325
|
// Never touches `featured` on update — that is Archon-only via setProjectFeatured.
|
|
@@ -360,21 +367,9 @@ async function getProject(id, { pool = defaultPool } = {}) {
|
|
|
360
367
|
return { ...rows[0], ...projectPublishability(rows[0]) };
|
|
361
368
|
}
|
|
362
369
|
|
|
363
|
-
// The PUBLIC
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// card labels it "Listed" for exactly that reason (task 1002778). updated_at may
|
|
367
|
-
// sort but is never projected: feature toggles and client re-registration bump
|
|
368
|
-
// it, so as a public "last updated" it would lie.
|
|
369
|
-
async function listFeaturedProjects({ pool = defaultPool } = {}) {
|
|
370
|
-
const { rows } = await pool.query(
|
|
371
|
-
`SELECT origin, name, tagline, art_url, type, template, created_at
|
|
372
|
-
FROM platform_identity_projects
|
|
373
|
-
WHERE featured = true AND status = 'active'
|
|
374
|
-
ORDER BY updated_at DESC`,
|
|
375
|
-
);
|
|
376
|
-
return rows;
|
|
377
|
-
}
|
|
370
|
+
// The PUBLIC projects feed lives in projects-feed.js (R06, task 1002325) — the
|
|
371
|
+
// predicate, the orb mapping and the stealth redaction are one concern, and each is
|
|
372
|
+
// only safe given the other two.
|
|
378
373
|
|
|
379
374
|
// The curation list — every active project, its featured flag, its stored door, and the
|
|
380
375
|
// DERIVED publish verdict. `publishable` is computed in SQL from publishableSql() so this
|
|
@@ -779,10 +774,15 @@ function isNumericGithubId(githubId) {
|
|
|
779
774
|
// table, the disciplines union computed separately (never a pre-GROUP-BY unnest
|
|
780
775
|
// fan-out — bug task 1002911). Per-project rows join the module-internal
|
|
781
776
|
// client_id → sso_clients.origin → projects.origin two-hop; a project is NAMED
|
|
782
|
-
// only when its catalog row is publicly listed today (featured + active
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
777
|
+
// only when its catalog row is publicly listed today (featured + active).
|
|
778
|
+
// Everything else is counted in more_projects, never named ("the aggregate is
|
|
779
|
+
// not the leak, the name is").
|
|
780
|
+
//
|
|
781
|
+
// Deliberately NARROWER than the public feed since R06 (task 1002325), and left so
|
|
782
|
+
// rather than widened here: a STEALTH project is on the map ANONYMOUSLY (ADR 0182
|
|
783
|
+
// D2a), so naming it in a builder's profile would be the leak the feed exists to
|
|
784
|
+
// prevent, and how much of a record may be NAMED is ADR 0171 D2's question, not
|
|
785
|
+
// R06's. Narrower under-names, which is safe. The widening is its own task.
|
|
786
786
|
// client_id never appears in the returned shape.
|
|
787
787
|
async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
788
788
|
if (!isNumericGithubId(githubId)) return null;
|
|
@@ -1436,9 +1436,13 @@ module.exports = {
|
|
|
1436
1436
|
PROJECT_MATERIALS,
|
|
1437
1437
|
PROJECT_SHAPES,
|
|
1438
1438
|
getProject,
|
|
1439
|
-
|
|
1439
|
+
listPublicProjects,
|
|
1440
1440
|
listAllProjects,
|
|
1441
1441
|
setProjectFeatured,
|
|
1442
|
+
ORB_BY_STATE,
|
|
1443
|
+
orbForState,
|
|
1444
|
+
isStealthProject,
|
|
1445
|
+
feedShapeFor,
|
|
1442
1446
|
// memberships
|
|
1443
1447
|
MEMBERSHIP_KINDS,
|
|
1444
1448
|
membershipKindFromLegacy,
|