@bongos/core 1.21.57 → 1.21.59
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 +91 -51
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +13 -2
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +2 -0
- package/docs/adr/0365-a-guild-engages-a-project-through-each-members-own-door.md +58 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +233 -6
- package/docs/api-reference.md +8 -5
- package/docs/copy-inventory.md +384 -376
- package/docs/copy-registry.json +514 -432
- package/docs/file-map.md +3 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +7 -1
- package/docs/page-readings.json +930 -919
- package/modules/hall-ui/public/goals-page.js +20 -1
- package/modules/hall-ui/public/task-lib.js +13 -0
- package/modules/hall-ui/public/work.js +22 -1
- package/modules/platform-identity/application-echo.js +11 -5
- package/modules/platform-identity/guild-engagements.js +255 -0
- package/modules/platform-identity/guilds.js +3 -1
- package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
- package/modules/platform-identity/routes/guilds.js +35 -1
- package/modules/platform-identity/routes/my-projects.js +27 -3
- package/modules/platform-identity/routes/projects.js +44 -0
- package/modules/platform-identity/routes/sso.js +21 -3
- package/modules/public-landing/public/assets/cosmos.css +7 -0
- package/modules/public-landing/public/guild.html +64 -2
- package/modules/public-landing/public/guild.probes.json +13 -3
- package/modules/public-landing/public/guild.states.json +10 -5
- package/modules/public-landing/public/projects.html +252 -16
- package/modules/public-landing/public/projects.probes.json +26 -1
- package/modules/public-landing/public/projects.states.json +4 -0
- package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
- package/modules/ui-design/kit/serve.js +54 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/applicant_profile_boundary.mjs +104 -1
- package/tests/application_echo.mjs +91 -5
- package/tests/focus_version_filter_default.mjs +47 -0
- package/tests/guild_engagement.mjs +881 -0
- package/tests/guild_engagement_db.mjs +240 -0
- package/tests/hall_mine_lens.mjs +1 -0
- package/tests/hub_engagement_cards.mjs +307 -0
- package/tests/hub_guild_page.mjs +94 -7
- package/tests/privacy_matrix.mjs +99 -9
- package/tests/project_invite_ui.mjs +187 -3
|
@@ -237,6 +237,21 @@
|
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// task 1004552 — the list opens on the project's focus version (the Roadmap
|
|
241
|
+
// star); no focus = '— any —'. This page keeps no URL filter state, so only a
|
|
242
|
+
// choice already made can win. A focus with no goal yet has no option here and
|
|
243
|
+
// is left alone rather than selecting a blank. Runs before the first render.
|
|
244
|
+
function applyFocusVersion(focusResp) {
|
|
245
|
+
const sel = $('#f-goal-version');
|
|
246
|
+
if (!sel) return;
|
|
247
|
+
const want = window.OTBTaskLib.focusVersionDefault({
|
|
248
|
+
focusId: focusResp && focusResp.focus_version_id,
|
|
249
|
+
optionValues: Array.from(sel.options || [], (o) => o.value),
|
|
250
|
+
current: sel.value,
|
|
251
|
+
});
|
|
252
|
+
if (want) sel.value = want;
|
|
253
|
+
}
|
|
254
|
+
|
|
240
255
|
// task 1003135 — the Owner dropdown. 'Goal owner' is goals.created_by (the
|
|
241
256
|
// fixed authority, migration 160), returned by GET /goals as a raw builder id;
|
|
242
257
|
// labels come from the directory the page already lazily caches (loadDirectory,
|
|
@@ -836,9 +851,13 @@
|
|
|
836
851
|
// task 1828 — "My goals" needs an identity; disable it when signed out.
|
|
837
852
|
const mineSel = $('#f-goal-mine');
|
|
838
853
|
if (mineSel && !state.me) { mineSel.disabled = true; mineSel.title = 'Sign in to filter to your goals'; }
|
|
839
|
-
const gres = await
|
|
854
|
+
const [gres, focusResp] = await Promise.all([
|
|
855
|
+
getJSON(`${API}/goals`),
|
|
856
|
+
getJSON(`${API}/versions/focus`).catch(() => null),
|
|
857
|
+
]);
|
|
840
858
|
state.goals = gres.goals || [];
|
|
841
859
|
populateVersionFilter();
|
|
860
|
+
applyFocusVersion(focusResp);
|
|
842
861
|
// task 1003135 — fire-and-forget: the Owner options fill when the (cached)
|
|
843
862
|
// directory resolves; boot never waits on a label lookup.
|
|
844
863
|
populateOwnerFilter().catch(() => {});
|
|
@@ -179,7 +179,20 @@
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// task 1004552 — the value a Version filter should OPEN on. The project's focus
|
|
183
|
+
// version (the Roadmap star, task 1004551) is the default; nothing is guessed when
|
|
184
|
+
// there is no focus (no "pick the version that is building" fallback). A URL filter
|
|
185
|
+
// (?f-version=) or a choice the builder already made wins, and a focus the select
|
|
186
|
+
// has no option for (e.g. no goal in that version yet) is left alone — selecting a
|
|
187
|
+
// value that is not on the list would show a blank control. Returns the value to
|
|
188
|
+
// set, or null for "leave the control as it is".
|
|
189
|
+
function focusVersionDefault({ focusId, optionValues, current, urlValue } = {}) {
|
|
190
|
+
if (!focusId || urlValue || current) return null;
|
|
191
|
+
return (optionValues || []).includes(focusId) ? focusId : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
182
194
|
const api = {
|
|
195
|
+
focusVersionDefault,
|
|
183
196
|
msOf, taskActivityMs, STALE_CHOICES, isStale,
|
|
184
197
|
priorityRank, touchesConflict, conflictPeers, conflictChipHtml, rankTagHtml, deHashRefs,
|
|
185
198
|
creditsPerMinute, buildPromptForTask, friendlyBatchReason,
|
|
@@ -514,6 +514,25 @@
|
|
|
514
514
|
return true;
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
// task 1004552 — the board opens on the project's focus version (the Roadmap
|
|
518
|
+
// star). A ?f-version= link or a choice already made wins; no focus = '— any —'.
|
|
519
|
+
// Sets the control directly (no change event), so the default never rides the
|
|
520
|
+
// URL and a copied link stays exactly what the builder chose. The claimable feed
|
|
521
|
+
// was fetched unfiltered, which is fine: matchesBoardFilters narrows it here.
|
|
522
|
+
function applyFocusVersion(focusResp) {
|
|
523
|
+
const sel = $('#f-version');
|
|
524
|
+
if (!sel) return false;
|
|
525
|
+
const want = window.OTBTaskLib.focusVersionDefault({
|
|
526
|
+
focusId: focusResp && focusResp.focus_version_id,
|
|
527
|
+
optionValues: Array.from(sel.options || [], (o) => o.value),
|
|
528
|
+
current: sel.value,
|
|
529
|
+
urlValue: new URLSearchParams(window.location.search).get('f-version'),
|
|
530
|
+
});
|
|
531
|
+
if (!want) return false;
|
|
532
|
+
sel.value = want;
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
|
|
517
536
|
function populateVersionFilter(versions) {
|
|
518
537
|
const sel = $('#f-version');
|
|
519
538
|
if (!sel) return;
|
|
@@ -972,13 +991,15 @@
|
|
|
972
991
|
// the friendly notice and hide the claim queue, but keep the in-flight list
|
|
973
992
|
// empty-but-honest (the gate also blocks active_claims, so say so).
|
|
974
993
|
try {
|
|
975
|
-
const [claimResp, versionsResp] = await Promise.all([
|
|
994
|
+
const [claimResp, versionsResp, focusResp] = await Promise.all([
|
|
976
995
|
getJSON(`${API}/tasks/claimable${serverFilterQuery()}`),
|
|
977
996
|
getJSON(`${API}/versions`).catch(() => ({ versions: [] })),
|
|
997
|
+
getJSON(`${API}/versions/focus`).catch(() => null),
|
|
978
998
|
]);
|
|
979
999
|
taskState.versions = versionsResp.versions || [];
|
|
980
1000
|
populateVersionFilter(taskState.versions);
|
|
981
1001
|
restoreUrlFilter('f-version');
|
|
1002
|
+
if (applyFocusVersion(focusResp)) renderBacklog();
|
|
982
1003
|
renderInFlight(claimResp.active_claims || []);
|
|
983
1004
|
taskState.all = claimResp.claimable || [];
|
|
984
1005
|
renderClaimable();
|
|
@@ -47,19 +47,25 @@ const APPLICATION_ECHO_WINDOW_DAYS = 30;
|
|
|
47
47
|
// is measured from the most recent ask — otherwise an applicant who re-asks
|
|
48
48
|
// watches their own card go quiet while their request is still open.
|
|
49
49
|
//
|
|
50
|
+
// `guildId` is the guild this application was made with (task 1002303, ADR 0365
|
|
51
|
+
// D1) — resolved by the relay through guild-engagements.js engagementFor, never
|
|
52
|
+
// taken from the request — or null for a solo application. It is written on
|
|
53
|
+
// EVERY upsert, null included, so the most recent application decides the label
|
|
54
|
+
// the reviewer sees: applying again on your own clears it.
|
|
55
|
+
//
|
|
50
56
|
// BEST-EFFORT BY CONTRACT: returns false instead of throwing when the account is
|
|
51
57
|
// a non-numeric principal (ADR 0171 D2). The caller must also swallow a DB error
|
|
52
58
|
// — the echo is a convenience on the applicant's own view, and losing it must
|
|
53
59
|
// never turn a filed application into a reported failure.
|
|
54
|
-
async function recordApplication({ githubId, clientId }, { pool = defaultPool } = {}) {
|
|
60
|
+
async function recordApplication({ githubId, clientId, guildId = null }, { pool = defaultPool } = {}) {
|
|
55
61
|
if (!isNumericGithubId(githubId)) return false;
|
|
56
62
|
const client = String(clientId || '');
|
|
57
63
|
if (!client) return false;
|
|
58
64
|
await pool.query(
|
|
59
|
-
`INSERT INTO platform_identity_application_echoes (github_id, client_id, applied_at)
|
|
60
|
-
VALUES ($1::bigint, $2, now())
|
|
61
|
-
ON CONFLICT (github_id, client_id) DO UPDATE SET applied_at = now()`,
|
|
62
|
-
[String(githubId), client],
|
|
65
|
+
`INSERT INTO platform_identity_application_echoes (github_id, client_id, applied_at, guild_id)
|
|
66
|
+
VALUES ($1::bigint, $2, now(), $3::bigint)
|
|
67
|
+
ON CONFLICT (github_id, client_id) DO UPDATE SET applied_at = now(), guild_id = EXCLUDED.guild_id`,
|
|
68
|
+
[String(githubId), client, guildId == null ? null : String(guildId)],
|
|
63
69
|
);
|
|
64
70
|
return true;
|
|
65
71
|
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// modules/platform-identity/guild-engagements.js — a guild engaging a project:
|
|
2
|
+
// "G is applying to P" and "P invited G", plus the guild label an application
|
|
3
|
+
// carries to the project's reviewer (task 1002303, R27; ADR 0336 D7, amended by
|
|
4
|
+
// ADR 0365).
|
|
5
|
+
//
|
|
6
|
+
// WHAT LIVES HERE, next to the data so no route can forget a rule:
|
|
7
|
+
//
|
|
8
|
+
// * A PROJECT STILL ADMITS PEOPLE, ONE AT A TIME (D7). An engagement admits
|
|
9
|
+
// nobody. A member who acts on one applies AS THEMSELVES through the
|
|
10
|
+
// existing relay (routes/my-projects.js), vouched and echoed; the echo then
|
|
11
|
+
// carries the guild as a label. Filing does not apply on the owner's behalf.
|
|
12
|
+
// * A PROJECT INVITING A GUILD WRITES NO INVITES (ADR 0365 D3, after ADR
|
|
13
|
+
// 0353). It records the `invited` engagement and hands back one hall link
|
|
14
|
+
// per member on the guild's PUBLIC roster at that moment — the people the
|
|
15
|
+
// inviter could see — so each invite is the project's own row, sent from
|
|
16
|
+
// its own hall. An unlisted guild cannot be invited: it answers like an
|
|
17
|
+
// unknown one.
|
|
18
|
+
// * THE NAMING RULE (ADR 0365 D4): a project is named only when it is on the
|
|
19
|
+
// map AND Public (namedProjectSql — the getRollupForBuilder rule, composed
|
|
20
|
+
// from the feed's own onMapSql). It gates both kinds, at filing AND at
|
|
21
|
+
// read. A private or stealth project's engagement is not shown at all,
|
|
22
|
+
// never shown anonymised.
|
|
23
|
+
// * ONE FRAGMENT DECIDES WHICH ENGAGEMENTS A MEMBER MAY SEE
|
|
24
|
+
// (memberEngagementsSql): their own guild's, inside the window, naming a
|
|
25
|
+
// project by the rule above, and an `invited` row only if they were already
|
|
26
|
+
// a member when it was filed. The inbox AND the join's guild check both use
|
|
27
|
+
// it, so the join can never accept a guild the inbox would not have shown —
|
|
28
|
+
// that difference would let a member probe whether a private project had
|
|
29
|
+
// invited their guild.
|
|
30
|
+
// * STATELESS AND STATUSLESS (ADR 0201 D1.3). No status, no filer, no
|
|
31
|
+
// withdraw: a row ages out read-time at ENGAGEMENT_WINDOW_DAYS, and
|
|
32
|
+
// re-filing restarts it.
|
|
33
|
+
//
|
|
34
|
+
// NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). Every read that touches
|
|
35
|
+
// platform_identity_accounts joins account-visibility.js
|
|
36
|
+
// (tests/effective_visibility_predicate.mjs). client_id never leaves this file.
|
|
37
|
+
//
|
|
38
|
+
// Its own file because platform-identity.js is at the size ratchet. Reaches
|
|
39
|
+
// core only through the doorway (ADR 0083).
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
const api = require('../../src/module-api');
|
|
43
|
+
const { accountVisibleSql } = require('./account-visibility');
|
|
44
|
+
const { onMapSql } = require('./projects-feed');
|
|
45
|
+
const { isNumericGithubId } = require('./platform-identity');
|
|
46
|
+
const { APPLICATION_ECHO_WINDOW_DAYS } = require('./application-echo');
|
|
47
|
+
const { hallInviteUrl } = require('./invite-notices');
|
|
48
|
+
const { ownerExistsSql, LIST_MAX } = require('./guilds');
|
|
49
|
+
|
|
50
|
+
const { pool: defaultPool } = api;
|
|
51
|
+
|
|
52
|
+
// How long an engagement stays visible. READ-TIME, like the application echo's
|
|
53
|
+
// window and for the same reason: no sweep to fall behind, no status to expire
|
|
54
|
+
// into. Exported so the routes, the tests and the docs quote one number.
|
|
55
|
+
const ENGAGEMENT_WINDOW_DAYS = 30;
|
|
56
|
+
|
|
57
|
+
// A project may be NAMED to a guild's members only when it is on the map and
|
|
58
|
+
// Public (ADR 0365 D4, the ADR 0171 D2 amendment). onMapSql validates the alias.
|
|
59
|
+
function namedProjectSql(alias) {
|
|
60
|
+
return `(${onMapSql(alias)} AND ${alias}.visibility = 'public')`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The FROM + WHERE every member-facing read shares (see the header). `member`
|
|
64
|
+
// and `windowDays` are placeholders, e.g. '$1' and '$2'. The client must still
|
|
65
|
+
// be an active registration: a dead client is not a project anyone can apply to.
|
|
66
|
+
function memberEngagementsSql(member, windowDays) {
|
|
67
|
+
return `
|
|
68
|
+
FROM platform_identity_guild_members m
|
|
69
|
+
JOIN platform_identity_guilds g ON g.id = m.guild_id
|
|
70
|
+
JOIN platform_identity_guild_engagements e ON e.guild_id = m.guild_id
|
|
71
|
+
JOIN platform_identity_sso_clients c ON c.client_id = e.client_id
|
|
72
|
+
JOIN platform_identity_projects p ON p.origin = c.origin
|
|
73
|
+
WHERE m.github_id = ${member}::bigint
|
|
74
|
+
AND e.created_at > now() - make_interval(days => ${windowDays}::int)
|
|
75
|
+
AND c.status = 'active' AND ${namedProjectSql('p')}
|
|
76
|
+
AND (e.kind = 'apply' OR m.joined_at <= e.created_at)`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The wire shape of one engagement. A literal whitelist: client_id, the guild's
|
|
80
|
+
// row id and the guild's visibility never cross.
|
|
81
|
+
function engagementView(r) {
|
|
82
|
+
return {
|
|
83
|
+
guild: { slug: r.slug, name: r.guild_name },
|
|
84
|
+
kind: r.kind,
|
|
85
|
+
project: { name: r.project_name, origin: r.project_origin },
|
|
86
|
+
created_at: r.created_at,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// fileEngagement — the guild's OWNER files "this guild is applying to P"
|
|
91
|
+
// (kind 'apply'). ONE statement: P is resolved from the origin the way the join
|
|
92
|
+
// relay resolves it (the oldest active registration at that exact origin), must
|
|
93
|
+
// pass the naming rule, and the owner test is re-asserted inside the INSERT, so
|
|
94
|
+
// the route's pre-check is a clearer error and never the only gate. Re-filing
|
|
95
|
+
// restarts the window. Returns the engagement view, or null when P is unknown,
|
|
96
|
+
// inactive or not named — one null, so the route's 404 cannot be an oracle.
|
|
97
|
+
async function fileEngagement({ guildId, githubId, origin }, { pool = defaultPool } = {}) {
|
|
98
|
+
const norm = String(origin || '').replace(/\/+$/, '');
|
|
99
|
+
if (!norm || !isNumericGithubId(githubId)) return null;
|
|
100
|
+
const { rows } = await pool.query(
|
|
101
|
+
`WITH target AS (
|
|
102
|
+
SELECT c.client_id, p.name, p.origin
|
|
103
|
+
FROM platform_identity_sso_clients c
|
|
104
|
+
JOIN platform_identity_projects p ON p.origin = c.origin
|
|
105
|
+
WHERE c.client_id = (SELECT c1.client_id FROM platform_identity_sso_clients c1
|
|
106
|
+
WHERE c1.origin = $3 AND c1.status = 'active'
|
|
107
|
+
ORDER BY c1.id ASC LIMIT 1)
|
|
108
|
+
AND ${namedProjectSql('p')}
|
|
109
|
+
),
|
|
110
|
+
filed AS (
|
|
111
|
+
INSERT INTO platform_identity_guild_engagements AS e (guild_id, client_id, kind)
|
|
112
|
+
SELECT g.id, target.client_id, 'apply'
|
|
113
|
+
FROM platform_identity_guilds g CROSS JOIN target
|
|
114
|
+
WHERE g.id = $1 AND ${ownerExistsSql('g.id', '$2')}
|
|
115
|
+
ON CONFLICT (guild_id, client_id, kind) DO UPDATE SET created_at = now()
|
|
116
|
+
RETURNING e.guild_id, e.kind, e.created_at
|
|
117
|
+
)
|
|
118
|
+
SELECT g.slug, g.name AS guild_name, filed.kind, filed.created_at,
|
|
119
|
+
target.name AS project_name, target.origin AS project_origin
|
|
120
|
+
FROM filed
|
|
121
|
+
JOIN platform_identity_guilds g ON g.id = filed.guild_id
|
|
122
|
+
CROSS JOIN target`,
|
|
123
|
+
[guildId, String(githubId), norm],
|
|
124
|
+
);
|
|
125
|
+
return rows[0] ? engagementView(rows[0]) : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// inviteGuild — a project invites a PUBLIC guild (ADR 0365 D3). The caller has
|
|
129
|
+
// already passed the project's inviter gate and holds the active client row.
|
|
130
|
+
// Returns { guild, invites } — one hall hand-off link per member on the guild's
|
|
131
|
+
// public roster at this moment — or null when the slug is unknown OR unlisted
|
|
132
|
+
// (one answer: an unlisted guild cannot be addressed).
|
|
133
|
+
//
|
|
134
|
+
// The `invited` row is written only while P passes the naming rule, re-checked
|
|
135
|
+
// in the INSERT: a private or stealth project's invite reaches the people it
|
|
136
|
+
// chose and nobody else, so it leaves no notice for the members it could not
|
|
137
|
+
// see — not even one that would surface if P went public later.
|
|
138
|
+
async function inviteGuild({ clientId, origin, slug }, { pool = defaultPool } = {}) {
|
|
139
|
+
const g = await pool.query(
|
|
140
|
+
`SELECT id, slug, name FROM platform_identity_guilds
|
|
141
|
+
WHERE lower(slug) = $1 AND visibility = 'public'`,
|
|
142
|
+
[slug],
|
|
143
|
+
);
|
|
144
|
+
const guild = g.rows[0];
|
|
145
|
+
if (!guild) return null;
|
|
146
|
+
await pool.query(
|
|
147
|
+
`INSERT INTO platform_identity_guild_engagements AS e (guild_id, client_id, kind)
|
|
148
|
+
SELECT $1::bigint, c.client_id, 'invited'
|
|
149
|
+
FROM platform_identity_sso_clients c
|
|
150
|
+
JOIN platform_identity_projects p ON p.origin = c.origin
|
|
151
|
+
WHERE c.client_id = $2 AND c.status = 'active' AND ${namedProjectSql('p')}
|
|
152
|
+
AND EXISTS (SELECT 1 FROM platform_identity_guilds pg
|
|
153
|
+
WHERE pg.id = $1 AND pg.visibility = 'public')
|
|
154
|
+
ON CONFLICT (guild_id, client_id, kind) DO UPDATE SET created_at = now()`,
|
|
155
|
+
[guild.id, clientId],
|
|
156
|
+
);
|
|
157
|
+
const roster = await publicRosterForInvite(guild.id, { pool });
|
|
158
|
+
const invites = roster
|
|
159
|
+
.map((m) => ({ handle: m.handle, display_name: m.display_name, invite_url: hallInviteUrl(origin, m.github_login) }))
|
|
160
|
+
.filter((m) => m.invite_url);
|
|
161
|
+
return { guild: { slug: guild.slug, name: guild.name }, invites };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The guild's PUBLIC roster, exactly as getPublicGuild reads it (shown_publicly
|
|
165
|
+
// AND accountVisibleSql, owner first, then by handle, capped at LIST_MAX) — the
|
|
166
|
+
// people an inviter can already see on the guild's page. github_login is read
|
|
167
|
+
// only to build each hall link (the hall's ?invite= prefill takes a login, which
|
|
168
|
+
// a public profile already shows); it is never returned on its own.
|
|
169
|
+
async function publicRosterForInvite(guildId, { pool }) {
|
|
170
|
+
const { rows } = await pool.query(
|
|
171
|
+
`SELECT pa.handle, pa.display_name, pa.github_login
|
|
172
|
+
FROM platform_identity_guild_members m
|
|
173
|
+
JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
|
|
174
|
+
WHERE m.guild_id = $1 AND m.shown_publicly AND ${accountVisibleSql('pa')}
|
|
175
|
+
ORDER BY (m.membership_kind = 'owner') DESC, lower(pa.handle)
|
|
176
|
+
LIMIT $2`,
|
|
177
|
+
[guildId, LIST_MAX],
|
|
178
|
+
);
|
|
179
|
+
return rows;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// listMyEngagements — the caller's OWN inbox: engagements of guilds they belong
|
|
183
|
+
// to that they may act on, newest first, capped at LIST_MAX. On top of the shared
|
|
184
|
+
// rule (memberEngagementsSql) it drops any project the caller already builds on,
|
|
185
|
+
// already holds a live invite notice for (the inviteExpiry window), or already
|
|
186
|
+
// has a live application echo for — the card would ask them to do what they
|
|
187
|
+
// have done. Own-scope only: the github_id is always the session's.
|
|
188
|
+
async function listMyEngagements({ githubId }, { pool = defaultPool } = {}) {
|
|
189
|
+
if (!isNumericGithubId(githubId)) return [];
|
|
190
|
+
const { rows } = await pool.query(
|
|
191
|
+
`SELECT g.slug, g.name AS guild_name, e.kind, e.created_at,
|
|
192
|
+
p.name AS project_name, p.origin AS project_origin
|
|
193
|
+
${memberEngagementsSql('$1', '$2')}
|
|
194
|
+
AND NOT EXISTS (SELECT 1 FROM platform_identity_project_memberships pm
|
|
195
|
+
WHERE pm.client_id = e.client_id AND pm.github_id = $1::bigint
|
|
196
|
+
AND (pm.membership_kind <> 'pending'
|
|
197
|
+
OR pm.joined_at > now() - make_interval(days => $3::int)))
|
|
198
|
+
AND NOT EXISTS (SELECT 1 FROM platform_identity_application_echoes ae
|
|
199
|
+
WHERE ae.github_id = $1::bigint AND ae.client_id = e.client_id
|
|
200
|
+
AND ae.applied_at > now() - make_interval(days => $4::int))
|
|
201
|
+
ORDER BY e.created_at DESC, g.slug, e.kind
|
|
202
|
+
LIMIT $5`,
|
|
203
|
+
[String(githubId), ENGAGEMENT_WINDOW_DAYS, api.inviteExpiry.INVITE_EXPIRY_DAYS,
|
|
204
|
+
APPLICATION_ECHO_WINDOW_DAYS, LIST_MAX],
|
|
205
|
+
);
|
|
206
|
+
return rows.map(engagementView);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// engagementFor — "may THIS member apply to THIS project WITH this guild?" The
|
|
210
|
+
// join relay's check before it relays anything (ADR 0365 D2): the guild must be
|
|
211
|
+
// one the caller belongs to, with an engagement of either kind for the project
|
|
212
|
+
// that the shared rule lets them see. Returns { id, slug, name } (the id is for
|
|
213
|
+
// the echo, never the wire) or null — left the guild, aged out, or never visible.
|
|
214
|
+
async function engagementFor({ githubId, slug, clientId }, { pool = defaultPool } = {}) {
|
|
215
|
+
if (!isNumericGithubId(githubId) || !slug || !clientId) return null;
|
|
216
|
+
const { rows } = await pool.query(
|
|
217
|
+
`SELECT g.id, g.slug, g.name
|
|
218
|
+
${memberEngagementsSql('$1', '$4')}
|
|
219
|
+
AND lower(g.slug) = $2 AND e.client_id = $3
|
|
220
|
+
LIMIT 1`,
|
|
221
|
+
[String(githubId), slug, String(clientId), ENGAGEMENT_WINDOW_DAYS],
|
|
222
|
+
);
|
|
223
|
+
return rows[0] || null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// echoGuildLabel — the guild an applicant's LIVE echo for this project carries,
|
|
227
|
+
// as the reviewer may see it: { slug, name } while the applicant is still a
|
|
228
|
+
// member (slug null for an unlisted guild, which has no public page to open),
|
|
229
|
+
// else null. Read by POST /sso/applicant-profile only AFTER its echo gate, so
|
|
230
|
+
// it says nothing about anyone who did not apply to the calling project. The
|
|
231
|
+
// most recent application decides: a later solo one wrote guild_id NULL.
|
|
232
|
+
async function echoGuildLabel({ githubId, clientId }, { pool = defaultPool } = {}) {
|
|
233
|
+
if (!isNumericGithubId(githubId) || !clientId) return null;
|
|
234
|
+
const { rows } = await pool.query(
|
|
235
|
+
`SELECT g.slug, g.name, g.visibility
|
|
236
|
+
FROM platform_identity_application_echoes ae
|
|
237
|
+
JOIN platform_identity_guilds g ON g.id = ae.guild_id
|
|
238
|
+
JOIN platform_identity_guild_members m ON m.guild_id = ae.guild_id AND m.github_id = ae.github_id
|
|
239
|
+
WHERE ae.github_id = $1::bigint AND ae.client_id = $2
|
|
240
|
+
AND ae.applied_at > now() - make_interval(days => $3::int)
|
|
241
|
+
LIMIT 1`,
|
|
242
|
+
[String(githubId), String(clientId), APPLICATION_ECHO_WINDOW_DAYS],
|
|
243
|
+
);
|
|
244
|
+
const r = rows[0];
|
|
245
|
+
return r ? { slug: r.visibility === 'public' ? r.slug : null, name: r.name } : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
ENGAGEMENT_WINDOW_DAYS,
|
|
250
|
+
fileEngagement,
|
|
251
|
+
inviteGuild,
|
|
252
|
+
listMyEngagements,
|
|
253
|
+
engagementFor,
|
|
254
|
+
echoGuildLabel,
|
|
255
|
+
};
|
|
@@ -80,7 +80,8 @@ const PERSON_COLUMNS = 'pa.handle, pa.display_name, pa.avatar_url';
|
|
|
80
80
|
|
|
81
81
|
// The owner test every owner-only statement re-asserts in its own WHERE, so a
|
|
82
82
|
// route's pre-check is a nicer error and never the only gate. $g/$a are the
|
|
83
|
-
// placeholders holding the guild id and the acting github_id.
|
|
83
|
+
// placeholders holding the guild id and the acting github_id. Exported for
|
|
84
|
+
// guild-engagements.js, whose owner-filed engagement asserts the same test.
|
|
84
85
|
function ownerExistsSql(g, a) {
|
|
85
86
|
return `EXISTS (SELECT 1 FROM platform_identity_guild_members o
|
|
86
87
|
WHERE o.guild_id = ${g} AND o.github_id = ${a}::bigint
|
|
@@ -686,6 +687,7 @@ module.exports = {
|
|
|
686
687
|
DESCRIPTION_MAX_CHARS,
|
|
687
688
|
INVITE_BATCH_MAX,
|
|
688
689
|
LIST_MAX,
|
|
690
|
+
ownerExistsSql,
|
|
689
691
|
normalizeGuildSlug,
|
|
690
692
|
lookupSlug,
|
|
691
693
|
createGuild,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- platform_identity_030_guild_engagements.sql — a guild's engagement with a
|
|
2
|
+
-- project, and the guild an application was made with (task 1002303, R27;
|
|
3
|
+
-- ADR 0336 D7, amended by ADR 0365).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT THIS IS. One row says "this guild is applying to this project" (kind
|
|
6
|
+
-- 'apply', filed by the guild's owner) or "this project invited this guild"
|
|
7
|
+
-- (kind 'invited', written when a project's inviter names a public guild).
|
|
8
|
+
-- Members read it in their own inbox and may apply AS THEMSELVES through the
|
|
9
|
+
-- existing relay; nobody reaches a project without their own act. And the
|
|
10
|
+
-- application echo gains the guild the member applied with, so the project's
|
|
11
|
+
-- reviewer can see the label on the live applicant-profile read.
|
|
12
|
+
--
|
|
13
|
+
-- THE SHAPE CARRIES THE RULES, so no route has to remember them:
|
|
14
|
+
-- * NO FILER COLUMN — for the reason the guild row keeps no creator: the
|
|
15
|
+
-- owner row is the authority, and a permanent "filed by" would tie a person
|
|
16
|
+
-- to an act after they have left the guild.
|
|
17
|
+
-- * NO STATUS COLUMN, AND ADDING ONE WOULD BE THE BUG (ADR 0201 D1.3). The
|
|
18
|
+
-- hub may not hold an admission fact about someone else's project; whether
|
|
19
|
+
-- anyone got in is the project's own access_requests row, and it becomes
|
|
20
|
+
-- visible here only as membership does.
|
|
21
|
+
-- * THE KEY IS (guild, project, kind), so re-filing cannot pile up rows: the
|
|
22
|
+
-- writer's upsert restarts created_at in place.
|
|
23
|
+
-- * THE 30-DAY WINDOW IS READ-TIME, not a column and not a sweep
|
|
24
|
+
-- (ENGAGEMENT_WINDOW_DAYS in ../guild-engagements.js). A stale row is
|
|
25
|
+
-- invisible rather than wrong.
|
|
26
|
+
-- * THE GUILD FK CASCADES, so a dissolved guild takes its engagements with
|
|
27
|
+
-- it (the ADR 0336 D2 no-tombstone reason). The echo's guild_id is SET NULL
|
|
28
|
+
-- instead: the application stands without its label.
|
|
29
|
+
-- * client_id is the registered sso_client, never a caller-supplied origin —
|
|
30
|
+
-- the writer resolves the origin against the registry, as the join relay does.
|
|
31
|
+
--
|
|
32
|
+
-- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank, credit or admission
|
|
33
|
+
-- path reads these rows; an engagement admits nobody anywhere.
|
|
34
|
+
--
|
|
35
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). CREATE and ADD COLUMN
|
|
36
|
+
-- only, so the previous release still runs against this schema. Idempotent.
|
|
37
|
+
|
|
38
|
+
BEGIN;
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS platform_identity_guild_engagements (
|
|
41
|
+
guild_id bigint NOT NULL REFERENCES platform_identity_guilds(id) ON DELETE CASCADE,
|
|
42
|
+
client_id text NOT NULL,
|
|
43
|
+
kind text NOT NULL CHECK (kind IN ('apply', 'invited')),
|
|
44
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
45
|
+
PRIMARY KEY (guild_id, client_id, kind)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
-- The project side: the member's join check and the per-project reads.
|
|
49
|
+
CREATE INDEX IF NOT EXISTS platform_identity_guild_engagements_client
|
|
50
|
+
ON platform_identity_guild_engagements (client_id);
|
|
51
|
+
|
|
52
|
+
COMMENT ON TABLE platform_identity_guild_engagements IS
|
|
53
|
+
'A guild''s engagement with a project (ADR 0336 D7, ADR 0365): apply (the guild owner filed "we are applying") or invited (a project''s inviter named this public guild). No filer column, as the guild row keeps no creator. No status column: the hub holds no admission fact about someone else''s project (ADR 0201 D1.3). The 30-day window is applied when read (ENGAGEMENT_WINDOW_DAYS); nothing expires the rows. Descriptive only, never authority (ADR 0016).';
|
|
54
|
+
|
|
55
|
+
COMMENT ON COLUMN platform_identity_guild_engagements.created_at IS
|
|
56
|
+
'When this engagement was most recently filed. Re-filing restarts it in place, so the 30-day read window is measured from the latest act.';
|
|
57
|
+
|
|
58
|
+
ALTER TABLE platform_identity_application_echoes
|
|
59
|
+
ADD COLUMN IF NOT EXISTS guild_id bigint REFERENCES platform_identity_guilds(id) ON DELETE SET NULL;
|
|
60
|
+
|
|
61
|
+
COMMENT ON COLUMN platform_identity_application_echoes.guild_id IS
|
|
62
|
+
'The guild this application was made with (ADR 0365 D1), or NULL for a solo application. Written by the join relay on every application, so the most recent one decides the label: applying again on your own clears it. SET NULL when the guild is dissolved. A label only, never a status.';
|
|
63
|
+
|
|
64
|
+
COMMIT;
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
// POST /guild-requests/:id/accept invitee {visibility (the card's), counted_in_totals?} or owner
|
|
26
26
|
// POST /guild-requests/:id/decline invitee or owner; SILENT 204
|
|
27
27
|
// DELETE /guild-requests/:id whoever opened it: cancel
|
|
28
|
+
// POST /guilds/:slug/engagements owner: "this guild is applying to P" {origin} (R27)
|
|
29
|
+
// GET /me/guild-engagements my inbox: engagements I may act on (R27)
|
|
28
30
|
//
|
|
29
31
|
// A GUILD IS NEVER AUTHORITY (ADR 0016 / ADR 0141). no-store everywhere: these
|
|
30
32
|
// payloads are privacy-filtered per viewer (ADR 0171 D5).
|
|
@@ -36,6 +38,7 @@ const express = require('express');
|
|
|
36
38
|
const api = require('../../../src/module-api');
|
|
37
39
|
const pi = require('../platform-identity');
|
|
38
40
|
const guilds = require('../guilds');
|
|
41
|
+
const engagements = require('../guild-engagements');
|
|
39
42
|
const { isAccountActive } = require('../account-visibility');
|
|
40
43
|
|
|
41
44
|
const { pool, requireBuilder, validateOrRespond, parsePagination } = api;
|
|
@@ -110,7 +113,7 @@ const COUNTED = { type: 'boolean' };
|
|
|
110
113
|
module.exports = function guildRoutes() {
|
|
111
114
|
const router = express.Router();
|
|
112
115
|
|
|
113
|
-
router.use(['/guilds', '/me/guilds', '/me/guild-requests', '/guild-requests'], (req, res, next) => {
|
|
116
|
+
router.use(['/guilds', '/me/guilds', '/me/guild-requests', '/me/guild-engagements', '/guild-requests'], (req, res, next) => {
|
|
114
117
|
res.set('Cache-Control', 'no-store');
|
|
115
118
|
next();
|
|
116
119
|
});
|
|
@@ -389,5 +392,36 @@ module.exports = function guildRoutes() {
|
|
|
389
392
|
res.json({ outcome: 'cancelled' });
|
|
390
393
|
}));
|
|
391
394
|
|
|
395
|
+
// rank: any-builder — owner: file "this guild is applying to P" (ADR 0365 D2,
|
|
396
|
+
// task 1002303). Each member then sees it in their own inbox and applies as
|
|
397
|
+
// themselves; filing applies for nobody, the owner included. P unknown,
|
|
398
|
+
// inactive, or not on the map and Public is ONE 404 — no oracle on a private
|
|
399
|
+
// project. The owner test is re-asserted inside the INSERT. Re-filing restarts
|
|
400
|
+
// the 30-day window.
|
|
401
|
+
router.post('/guilds/:slug/engagements', requireBuilder, handle('POST /guilds/:slug/engagements', 'guild_engagement_failed', async (req, res) => {
|
|
402
|
+
if (validateOrRespond(req, res, { origin: { required: true, type: 'string', maxLength: 512 } })) return;
|
|
403
|
+
const me = await actingAccount(req.builder);
|
|
404
|
+
if (me.fail) return res.fail(...me.fail);
|
|
405
|
+
const o = await ownerOf(me, req.params.slug);
|
|
406
|
+
if (o.fail) return res.fail(...o.fail);
|
|
407
|
+
const engagement = await engagements.fileEngagement({
|
|
408
|
+
guildId: o.guild.id, githubId: me.githubId, origin: req.body.origin,
|
|
409
|
+
}, { pool });
|
|
410
|
+
if (!engagement) return res.fail('unknown_project', 404);
|
|
411
|
+
res.json({ engagement });
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
// rank: any-builder — MY inbox of guild engagements (ADR 0365): "G is applying
|
|
415
|
+
// to P" and "P invited G", for guilds I belong to, inside 30 days, naming only
|
|
416
|
+
// projects on the map and Public, an invite only if I was a member when it was
|
|
417
|
+
// sent, and never a project I already build on, hold an invite to, or applied
|
|
418
|
+
// to. count is the array's own length; client_id never crosses.
|
|
419
|
+
router.get('/me/guild-engagements', requireBuilder, handle('GET /me/guild-engagements', 'guild_engagements_read_failed', async (req, res) => {
|
|
420
|
+
const me = await actingAccount(req.builder);
|
|
421
|
+
if (me.fail) return res.fail(...me.fail);
|
|
422
|
+
const list = await engagements.listMyEngagements({ githubId: me.githubId }, { pool });
|
|
423
|
+
res.json({ engagements: list, count: list.length });
|
|
424
|
+
}));
|
|
425
|
+
|
|
392
426
|
return router;
|
|
393
427
|
};
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// apart, plus `applied`: the projects it has
|
|
6
6
|
// asked to join and when (ADR 0201 D1.3)
|
|
7
7
|
// POST /my-projects/join — apply to a project: one click plus an optional
|
|
8
|
-
// note in the applicant's own words (task 1002330)
|
|
8
|
+
// note in the applicant's own words (task 1002330),
|
|
9
|
+
// and optionally WITH a guild that is engaging the
|
|
10
|
+
// project (`guild`, a slug; task 1002303, ADR 0365).
|
|
9
11
|
// Relayed; the project executes and decides —
|
|
10
12
|
// unless the platform's record says it is unpublished.
|
|
11
13
|
// POST /my-projects/:clientId/leave — self-service offboard (reflected to the hub)
|
|
@@ -25,6 +27,8 @@ const api = require('../../../src/module-api');
|
|
|
25
27
|
const pi = require('../platform-identity');
|
|
26
28
|
const projectStats = require('../project-stats');
|
|
27
29
|
const applicationEcho = require('../application-echo');
|
|
30
|
+
const guilds = require('../guilds');
|
|
31
|
+
const guildEngagements = require('../guild-engagements');
|
|
28
32
|
const hubKeys = require('../hub-keys');
|
|
29
33
|
const { isAccountActive } = require('../account-visibility');
|
|
30
34
|
const { projectPublishability } = require('../publish-gate');
|
|
@@ -219,12 +223,30 @@ module.exports = function myProjectsRoutes() {
|
|
|
219
223
|
if (validateOrRespond(req, res, {
|
|
220
224
|
origin: { required: true, type: 'string', maxLength: 512 },
|
|
221
225
|
note: { type: 'string', maxLength: APPLICANT_NOTE_MAX },
|
|
226
|
+
guild: { type: 'string', maxLength: 39 },
|
|
222
227
|
})) return;
|
|
223
228
|
try {
|
|
224
229
|
const client = await pi.getClientByOrigin(req.body.origin, { pool });
|
|
225
230
|
if (!client || client.status !== 'active' || !client.origin) {
|
|
226
231
|
return res.fail('unknown_project', 404);
|
|
227
232
|
}
|
|
233
|
+
// Applying WITH a guild (ADR 0365 D2). The guild must still resolve FOR THE
|
|
234
|
+
// CALLER — a guild they belong to, engaging this project inside the window,
|
|
235
|
+
// by the same rule their inbox shows it — BEFORE anything is relayed. A guild
|
|
236
|
+
// that no longer does (they left, it aged out, the project stopped being
|
|
237
|
+
// named) is a 409 with nothing relayed and nothing echoed: the applicant
|
|
238
|
+
// chose to apply with that guild, so the hub never files it without one.
|
|
239
|
+
let guildId = null;
|
|
240
|
+
if (req.body.guild != null) {
|
|
241
|
+
const slug = guilds.lookupSlug(req.body.guild);
|
|
242
|
+
const engaged = slug ? await guildEngagements.engagementFor({
|
|
243
|
+
githubId: req.builder.github_id, slug, clientId: client.client_id,
|
|
244
|
+
}, { pool }) : null;
|
|
245
|
+
if (!engaged) {
|
|
246
|
+
return res.fail('guild_engagement_gone', { status: 409, message: 'that guild is no longer engaging this project — apply on your own instead' });
|
|
247
|
+
}
|
|
248
|
+
guildId = engaged.id;
|
|
249
|
+
}
|
|
228
250
|
if (await isDarkMatter(client)) {
|
|
229
251
|
return res.json({ ok: true, status: 'not_published', project: { name: client.name, origin: client.origin },
|
|
230
252
|
message: RELAY_SENTENCE.not_published });
|
|
@@ -256,11 +278,13 @@ module.exports = function myProjectsRoutes() {
|
|
|
256
278
|
// covers the project's own already_pending 200, D1.4), and refreshes the date.
|
|
257
279
|
// Best-effort in both directions: a lost echo must never turn a filed
|
|
258
280
|
// application into a reported failure, which is the only reason this is
|
|
259
|
-
// allowed to swallow its error.
|
|
281
|
+
// allowed to swallow its error. The echo carries the guild this application
|
|
282
|
+
// was made with — NULL when none, so a later solo application clears the
|
|
283
|
+
// label the reviewer sees (ADR 0365 D1).
|
|
260
284
|
if (outcome.status === 'requested') {
|
|
261
285
|
try {
|
|
262
286
|
await applicationEcho.recordApplication(
|
|
263
|
-
{ githubId: req.builder.github_id, clientId: client.client_id }, { pool },
|
|
287
|
+
{ githubId: req.builder.github_id, clientId: client.client_id, guildId }, { pool },
|
|
264
288
|
);
|
|
265
289
|
} catch (e) {
|
|
266
290
|
api.logger('platform-identity').error(`join echo write failed (non-fatal): ${e && e.message}`);
|