@bongos/core 1.19.1025 → 1.19.1027
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 +88 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +40 -0
- package/clients/bongos-client/index.cjs +40 -0
- package/clients/bongos-client/index.d.ts +63 -0
- package/clients/bongos-client/index.mjs +40 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +4 -2
- package/docs/api/openapi.json +1361 -151
- package/docs/api-reference.md +32 -4
- package/docs/copy-inventory.md +6 -6
- package/docs/copy-registry.json +10 -20
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/platform-identity/community-leaderboard.js +235 -0
- package/modules/platform-identity/connections.js +1 -0
- package/modules/platform-identity/guilds.js +705 -0
- package/modules/platform-identity/migrations/platform_identity_025_guilds.sql +100 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/read-rate-limit.js +62 -0
- package/modules/platform-identity/routes/community-leaderboard.js +69 -0
- package/modules/platform-identity/routes/community-search.js +4 -31
- package/modules/platform-identity/routes/guilds-public.js +57 -0
- package/modules/platform-identity/routes/guilds.js +393 -0
- package/modules/platform-identity/routes/projects.js +1 -1
- package/modules/platform-identity/terms-acceptance.js +14 -5
- package/modules/public-landing/public/terms.html +3 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +10 -0
- package/src/module-api.js +1 -1
- package/tests/community_leaderboard_db.mjs +202 -0
- package/tests/community_leaderboard_route.mjs +414 -0
- package/tests/goal_map_page.mjs +8 -0
- package/tests/guilds_api.mjs +521 -0
- package/tests/guilds_db.mjs +343 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/terms_acceptance.mjs +74 -28
- package/tests/terms_grandfather_db.mjs +2 -2
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
// modules/platform-identity/guilds.js — the guild domain: create, membership,
|
|
2
|
+
// invites and join requests, and the public/unlisted read (task 1002300, R24;
|
|
3
|
+
// ADR 0336 D1–D4 + D6, amended by ADR 0337 D4.4).
|
|
4
|
+
//
|
|
5
|
+
// WHAT LIVES HERE, next to the data so no route can forget a rule:
|
|
6
|
+
//
|
|
7
|
+
// * TWO CONSENTS TO JOIN, NO SELF-JOIN (D3). An owner invites and the invitee
|
|
8
|
+
// accepts, or a builder asks a PUBLIC guild and the owner accepts. An
|
|
9
|
+
// unlisted guild takes invites only, because a stranger cannot address it.
|
|
10
|
+
// * THE CONNECTIONS LIFECYCLE, VERBATIM (D3, ADR 0202 D2) — its exported
|
|
11
|
+
// constants, so the two features cannot drift: a decline is silent and a
|
|
12
|
+
// re-ask of the same kind inside the cooldown is SWALLOWED with the answer a
|
|
13
|
+
// real one gets; a pending row expires read-time and the write path resolves
|
|
14
|
+
// it in the same statement that inserts the next one; accepting resolves the
|
|
15
|
+
// row and inserts the membership in ONE statement.
|
|
16
|
+
// * CONSENT IS TO WHAT THE CARD SHOWED (D4 §3). shown_publicly is set by the
|
|
17
|
+
// guild's visibility at the moment of joining; an invite accept carries the
|
|
18
|
+
// visibility its card displayed and is refused if the guild changed since.
|
|
19
|
+
// A visibility change flips only the owner's own flag.
|
|
20
|
+
// * ONE OWNER (D2), held by a partial unique index. Transfer is two statements
|
|
21
|
+
// in one transaction — demote, then promote — because that index is checked
|
|
22
|
+
// row by row and cannot be deferred. The last member leaving dissolves.
|
|
23
|
+
// * THE PUBLIC ROSTER IS shown_publicly AND accountVisibleSql (D4): a private
|
|
24
|
+
// account is never on it, whatever its flag. Members read each other through
|
|
25
|
+
// the own-scope roster, which floors on consent alone (joining is consent to
|
|
26
|
+
// be seen by the guild, never by the world). Every count is the length of a
|
|
27
|
+
// list the reader may see.
|
|
28
|
+
// * counted_in_totals (ADR 0337 D4.4) is stored and switchable here; the
|
|
29
|
+
// totals that read it are task 1002302's.
|
|
30
|
+
//
|
|
31
|
+
// NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). A guild grants no rank,
|
|
32
|
+
// credits, admission or access to any project.
|
|
33
|
+
//
|
|
34
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const api = require('../../src/module-api');
|
|
38
|
+
const {
|
|
39
|
+
accountVisibleSql, accountActiveSql, isAccountVisible, isAccountActive,
|
|
40
|
+
} = require('./account-visibility');
|
|
41
|
+
const conn = require('./connections');
|
|
42
|
+
const { normalizeHandle } = require('./platform-identity');
|
|
43
|
+
|
|
44
|
+
const { pool: defaultPool } = api;
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// The constants ADR 0336 named. Exported so a route, a test and a doc read the
|
|
48
|
+
// same number.
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
// Mirrors the migration's CHECK. No default anywhere: the creator chooses.
|
|
52
|
+
const GUILD_VISIBILITIES = ['public', 'unlisted'];
|
|
53
|
+
|
|
54
|
+
// Words the /g namespace uses (D6). A slug may never shadow them.
|
|
55
|
+
const GUILD_RESERVED_SLUGS = ['me', 'new', 'edit', 'settings', 'admin', 'api'];
|
|
56
|
+
|
|
57
|
+
// D6: the handle charset with a two-character floor. The ceiling (39) is the
|
|
58
|
+
// charset's own, and the migration's CHECK holds both.
|
|
59
|
+
const SLUG_MIN_CHARS = 2;
|
|
60
|
+
|
|
61
|
+
// ADR 0336 Deferred: guild names are user-written public text, so R24 caps them.
|
|
62
|
+
// Mirrored by the migration's CHECKs.
|
|
63
|
+
const NAME_MAX_CHARS = 80;
|
|
64
|
+
const DESCRIPTION_MAX_CHARS = 500;
|
|
65
|
+
|
|
66
|
+
// D3: a batch invite is a loop of ordinary invites, never a new kind of
|
|
67
|
+
// membership, and it is bounded.
|
|
68
|
+
const INVITE_BATCH_MAX = 25;
|
|
69
|
+
|
|
70
|
+
// The largest roster or queue one read returns.
|
|
71
|
+
const LIST_MAX = 500;
|
|
72
|
+
|
|
73
|
+
// The connections lifecycle's own numbers (D3), imported rather than restated.
|
|
74
|
+
const { REQUEST_EXPIRY_DAYS, DECLINE_COOLDOWN_DAYS } = conn;
|
|
75
|
+
|
|
76
|
+
// What a person in a guild list carries (ADR 0336 R25 row): handle, display
|
|
77
|
+
// name, avatar. github_id and github_login never leave this module.
|
|
78
|
+
const PERSON_COLUMNS = 'pa.handle, pa.display_name, pa.avatar_url';
|
|
79
|
+
|
|
80
|
+
// The owner test every owner-only statement re-asserts in its own WHERE, so a
|
|
81
|
+
// route's pre-check is a nicer error and never the only gate. $g/$a are the
|
|
82
|
+
// placeholders holding the guild id and the acting github_id.
|
|
83
|
+
function ownerExistsSql(g, a) {
|
|
84
|
+
return `EXISTS (SELECT 1 FROM platform_identity_guild_members o
|
|
85
|
+
WHERE o.guild_id = ${g} AND o.github_id = ${a}::bigint
|
|
86
|
+
AND o.membership_kind = 'owner')`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Slugs and addressing
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
// A slug offered at creation → { slug } or { error }. Canonical form only.
|
|
94
|
+
function normalizeGuildSlug(raw) {
|
|
95
|
+
const slug = normalizeHandle(raw);
|
|
96
|
+
if (!slug || slug.length < SLUG_MIN_CHARS) return { error: 'slug_invalid' };
|
|
97
|
+
if (GUILD_RESERVED_SLUGS.includes(slug)) return { error: 'slug_reserved' };
|
|
98
|
+
return { slug };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A slug from a URL → its canonical form, or null. A reserved word simply never
|
|
102
|
+
// matches a row, so lookups need no separate refusal.
|
|
103
|
+
function lookupSlug(raw) {
|
|
104
|
+
return normalizeHandle(raw);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Resolve a handle to an account THIS viewer may invite: publicly visible, or a
|
|
108
|
+
// private account the viewer is already connected to (ADR 0202 D4 §2 — the
|
|
109
|
+
// rule routes/connections.js visibleTarget applies, composed from the same
|
|
110
|
+
// shared predicates). Unknown, provisional and private-stranger are one null.
|
|
111
|
+
async function addressableAccount(viewerGithubId, handle, { pool = defaultPool } = {}) {
|
|
112
|
+
const acct = await conn.findAccountByHandle(handle, { pool });
|
|
113
|
+
if (!acct) return null;
|
|
114
|
+
if (isAccountVisible(acct)) return acct;
|
|
115
|
+
if (!isAccountActive(acct)) return null;
|
|
116
|
+
return (await conn.isConnectedTo(viewerGithubId, acct.github_id, { pool })) ? acct : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Guild CRUD
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
// Create a guild and its owner row in ONE statement, so a guild never exists
|
|
124
|
+
// without its owner. The owner's shown_publicly follows the visibility they
|
|
125
|
+
// chose. Returns { guild } or { taken: true } when the slug is in use.
|
|
126
|
+
async function createGuild({ githubId, slug, name, description = null, visibility }, { pool = defaultPool } = {}) {
|
|
127
|
+
try {
|
|
128
|
+
const { rows } = await pool.query(
|
|
129
|
+
`WITH g AS (
|
|
130
|
+
INSERT INTO platform_identity_guilds (slug, name, description, visibility)
|
|
131
|
+
VALUES ($1, $2, $3, $4)
|
|
132
|
+
RETURNING id, slug, name, description, visibility, created_at
|
|
133
|
+
),
|
|
134
|
+
owner_row AS (
|
|
135
|
+
INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly)
|
|
136
|
+
SELECT g.id, $5::bigint, 'owner', g.visibility = 'public' FROM g
|
|
137
|
+
RETURNING guild_id
|
|
138
|
+
)
|
|
139
|
+
SELECT g.slug, g.name, g.description, g.visibility, g.created_at
|
|
140
|
+
FROM g JOIN owner_row o ON o.guild_id = g.id`,
|
|
141
|
+
[slug, name, description, visibility, githubId],
|
|
142
|
+
);
|
|
143
|
+
return { guild: rows[0] };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// 23505 here can only be the case-insensitive slug index.
|
|
146
|
+
if (err && err.code === '23505') return { taken: true };
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The caller's own membership of one guild, with the guild's details, or null.
|
|
152
|
+
// The own-scope door to an UNLISTED guild: a non-member gets null, the same
|
|
153
|
+
// answer as a guild that does not exist.
|
|
154
|
+
async function getMembership(slug, githubId, { pool = defaultPool } = {}) {
|
|
155
|
+
const { rows } = await pool.query(
|
|
156
|
+
`SELECT g.id, g.slug, g.name, g.description, g.visibility, g.created_at,
|
|
157
|
+
m.membership_kind, m.shown_publicly, m.counted_in_totals, m.joined_at
|
|
158
|
+
FROM platform_identity_guilds g
|
|
159
|
+
JOIN platform_identity_guild_members m ON m.guild_id = g.id AND m.github_id = $2::bigint
|
|
160
|
+
WHERE lower(g.slug) = $1`,
|
|
161
|
+
[slug, githubId],
|
|
162
|
+
);
|
|
163
|
+
return rows[0] || null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Edit name, description or visibility — the owner only, re-asserted here. A
|
|
167
|
+
// visibility CHANGE flips the owner's own shown_publicly to match and nobody
|
|
168
|
+
// else's (D4 §3: consent given to a small audience is not consent to a large
|
|
169
|
+
// one). `description` undefined leaves it; null or '' clears it.
|
|
170
|
+
async function updateGuild({ guildId, githubId, name, description, visibility }, { pool = defaultPool } = {}) {
|
|
171
|
+
const { rows } = await pool.query(
|
|
172
|
+
`WITH own AS (
|
|
173
|
+
SELECT g.id, g.visibility AS old_visibility
|
|
174
|
+
FROM platform_identity_guilds g
|
|
175
|
+
WHERE g.id = $1 AND ${ownerExistsSql('g.id', '$2')}
|
|
176
|
+
),
|
|
177
|
+
upd AS (
|
|
178
|
+
UPDATE platform_identity_guilds g
|
|
179
|
+
SET name = COALESCE($3::text, g.name),
|
|
180
|
+
description = CASE WHEN $4::boolean THEN $5::text ELSE g.description END,
|
|
181
|
+
visibility = COALESCE($6::text, g.visibility),
|
|
182
|
+
updated_at = now()
|
|
183
|
+
FROM own
|
|
184
|
+
WHERE g.id = own.id
|
|
185
|
+
RETURNING g.id, g.slug, g.name, g.description, g.visibility, own.old_visibility
|
|
186
|
+
),
|
|
187
|
+
flip AS (
|
|
188
|
+
UPDATE platform_identity_guild_members m
|
|
189
|
+
SET shown_publicly = (upd.visibility = 'public')
|
|
190
|
+
FROM upd
|
|
191
|
+
WHERE m.guild_id = upd.id AND m.github_id = $2::bigint
|
|
192
|
+
AND upd.visibility <> upd.old_visibility
|
|
193
|
+
RETURNING m.guild_id
|
|
194
|
+
)
|
|
195
|
+
SELECT slug, name, description, visibility FROM upd`,
|
|
196
|
+
[guildId, githubId, name ?? null, description !== undefined, description || null, visibility ?? null],
|
|
197
|
+
);
|
|
198
|
+
return rows[0] || null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Dissolve — the owner only. Hard delete; the FKs cascade to every membership
|
|
202
|
+
// and open request (D2).
|
|
203
|
+
async function dissolveGuild({ guildId, githubId }, { pool = defaultPool } = {}) {
|
|
204
|
+
const { rowCount } = await pool.query(
|
|
205
|
+
`DELETE FROM platform_identity_guilds g
|
|
206
|
+
WHERE g.id = $1 AND ${ownerExistsSql('g.id', '$2')}
|
|
207
|
+
RETURNING g.id`,
|
|
208
|
+
[guildId, githubId],
|
|
209
|
+
);
|
|
210
|
+
return rowCount > 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Membership
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
// The caller's guilds, newest first. Own data, so no account join.
|
|
218
|
+
async function listMyGuilds({ githubId, limit = LIST_MAX, offset = 0 }, { pool = defaultPool } = {}) {
|
|
219
|
+
const { rows } = await pool.query(
|
|
220
|
+
`SELECT g.slug, g.name, g.visibility,
|
|
221
|
+
m.membership_kind, m.shown_publicly, m.counted_in_totals, m.joined_at
|
|
222
|
+
FROM platform_identity_guild_members m
|
|
223
|
+
JOIN platform_identity_guilds g ON g.id = m.guild_id
|
|
224
|
+
WHERE m.github_id = $1::bigint
|
|
225
|
+
ORDER BY m.joined_at DESC, g.id DESC
|
|
226
|
+
LIMIT $2 OFFSET $3`,
|
|
227
|
+
[githubId, Math.min(Number(limit) || LIST_MAX, LIST_MAX), Math.max(0, Number(offset) || 0)],
|
|
228
|
+
);
|
|
229
|
+
return rows;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The members' roster — the OWN-SCOPE read a member reaches their guild through
|
|
233
|
+
// (D4). Floors on consent alone: a private member is visible to fellow members,
|
|
234
|
+
// because joining is consent to be seen by the guild. Neither shown_publicly nor
|
|
235
|
+
// counted_in_totals of anyone else is returned — both are that member's own call.
|
|
236
|
+
async function listMemberRoster({ guildId, limit = LIST_MAX, offset = 0 }, { pool = defaultPool } = {}) {
|
|
237
|
+
const { rows } = await pool.query(
|
|
238
|
+
`SELECT ${PERSON_COLUMNS}, m.membership_kind
|
|
239
|
+
FROM platform_identity_guild_members m
|
|
240
|
+
JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
|
|
241
|
+
WHERE m.guild_id = $1 AND ${accountActiveSql('pa')}
|
|
242
|
+
ORDER BY (m.membership_kind = 'owner') DESC, lower(pa.handle), pa.github_id
|
|
243
|
+
LIMIT $2 OFFSET $3`,
|
|
244
|
+
[guildId, Math.min(Number(limit) || LIST_MAX, LIST_MAX), Math.max(0, Number(offset) || 0)],
|
|
245
|
+
);
|
|
246
|
+
return rows;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// A member's own switches: shown_publicly (D4) and counted_in_totals (ADR 0337
|
|
250
|
+
// D4.4). Each is optional; null leaves it. Keyed on the session's github_id, so
|
|
251
|
+
// nobody can flip anybody else's. Returns the new flags, or null if not a member.
|
|
252
|
+
async function setMembershipFlags({ slug, githubId, shownPublicly = null, countedInTotals = null }, { pool = defaultPool } = {}) {
|
|
253
|
+
const { rows } = await pool.query(
|
|
254
|
+
`UPDATE platform_identity_guild_members m
|
|
255
|
+
SET shown_publicly = COALESCE($3::boolean, m.shown_publicly),
|
|
256
|
+
counted_in_totals = COALESCE($4::boolean, m.counted_in_totals)
|
|
257
|
+
FROM platform_identity_guilds g
|
|
258
|
+
WHERE g.id = m.guild_id AND lower(g.slug) = $1 AND m.github_id = $2::bigint
|
|
259
|
+
RETURNING m.shown_publicly, m.counted_in_totals`,
|
|
260
|
+
[slug, githubId, shownPublicly, countedInTotals],
|
|
261
|
+
);
|
|
262
|
+
return rows[0] || null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Leave. A member leaves; the owner must transfer first, unless they are the
|
|
266
|
+
// last member, in which case leaving DISSOLVES the guild (D2). One statement.
|
|
267
|
+
// `mine` locks the caller's row, so a leave racing a transfer that is promoting
|
|
268
|
+
// them waits and re-reads it as 'owner' (must_transfer) instead of deleting the
|
|
269
|
+
// new owner from a stale snapshot; left_row re-checks the kind for the same
|
|
270
|
+
// reason. The accepted request row that admitted them goes too: kept, it would
|
|
271
|
+
// be a record that they were ever here (the D2 no-tombstone reason).
|
|
272
|
+
// Returns 'left' | 'dissolved' | 'must_transfer' | null (not a member).
|
|
273
|
+
async function leaveGuild({ guildId, githubId }, { pool = defaultPool } = {}) {
|
|
274
|
+
const { rows } = await pool.query(
|
|
275
|
+
`WITH mine AS (
|
|
276
|
+
SELECT membership_kind FROM platform_identity_guild_members
|
|
277
|
+
WHERE guild_id = $1 AND github_id = $2::bigint
|
|
278
|
+
FOR UPDATE
|
|
279
|
+
),
|
|
280
|
+
others AS (
|
|
281
|
+
SELECT EXISTS (SELECT 1 FROM platform_identity_guild_members
|
|
282
|
+
WHERE guild_id = $1 AND github_id <> $2::bigint) AS has_others
|
|
283
|
+
),
|
|
284
|
+
left_row AS (
|
|
285
|
+
DELETE FROM platform_identity_guild_members m
|
|
286
|
+
USING mine
|
|
287
|
+
WHERE m.guild_id = $1 AND m.github_id = $2::bigint
|
|
288
|
+
AND m.membership_kind = 'member' AND mine.membership_kind = 'member'
|
|
289
|
+
RETURNING 'left'::text AS outcome
|
|
290
|
+
),
|
|
291
|
+
purged AS (
|
|
292
|
+
DELETE FROM platform_identity_guild_requests r
|
|
293
|
+
WHERE r.guild_id = $1 AND r.github_id = $2::bigint AND r.status = 'accepted'
|
|
294
|
+
AND EXISTS (SELECT 1 FROM left_row)
|
|
295
|
+
RETURNING r.id
|
|
296
|
+
),
|
|
297
|
+
dissolved AS (
|
|
298
|
+
DELETE FROM platform_identity_guilds g
|
|
299
|
+
USING mine, others
|
|
300
|
+
WHERE g.id = $1 AND mine.membership_kind = 'owner' AND NOT others.has_others
|
|
301
|
+
RETURNING 'dissolved'::text AS outcome
|
|
302
|
+
)
|
|
303
|
+
SELECT outcome FROM left_row
|
|
304
|
+
UNION ALL SELECT outcome FROM dissolved
|
|
305
|
+
UNION ALL SELECT 'must_transfer'::text FROM mine, others
|
|
306
|
+
WHERE mine.membership_kind = 'owner' AND others.has_others`,
|
|
307
|
+
[guildId, githubId],
|
|
308
|
+
);
|
|
309
|
+
return rows[0] ? rows[0].outcome : null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Remove a member — the owner only, and never the owner themselves (they leave
|
|
313
|
+
// or dissolve). Hard delete (D2), taking the accepted request row with it, as
|
|
314
|
+
// leaveGuild does. The member is named by current handle, resolved on the same
|
|
315
|
+
// consent floor as the members' roster the owner picked them from.
|
|
316
|
+
async function removeMember({ guildId, ownerGithubId, handle }, { pool = defaultPool } = {}) {
|
|
317
|
+
const { rows } = await pool.query(
|
|
318
|
+
`WITH gone AS (
|
|
319
|
+
DELETE FROM platform_identity_guild_members m
|
|
320
|
+
USING platform_identity_accounts pa
|
|
321
|
+
WHERE m.guild_id = $1 AND m.membership_kind = 'member'
|
|
322
|
+
AND pa.github_id = m.github_id AND lower(pa.handle) = $3
|
|
323
|
+
AND ${accountActiveSql('pa')}
|
|
324
|
+
AND ${ownerExistsSql('$1', '$2')}
|
|
325
|
+
RETURNING m.guild_id, m.github_id
|
|
326
|
+
),
|
|
327
|
+
purged AS (
|
|
328
|
+
DELETE FROM platform_identity_guild_requests r
|
|
329
|
+
USING gone
|
|
330
|
+
WHERE r.guild_id = gone.guild_id AND r.github_id = gone.github_id AND r.status = 'accepted'
|
|
331
|
+
RETURNING r.id
|
|
332
|
+
)
|
|
333
|
+
SELECT count(*)::int AS removed FROM gone`,
|
|
334
|
+
[guildId, ownerGithubId, handle],
|
|
335
|
+
);
|
|
336
|
+
return !!rows[0] && rows[0].removed > 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
class TransferAborted extends Error {}
|
|
340
|
+
|
|
341
|
+
// Transfer ownership to an existing member: TWO statements in ONE transaction,
|
|
342
|
+
// demote then promote (D2). A single UPDATE swapping both rows would pass or fail
|
|
343
|
+
// on the order Postgres visits them, because the one-owner index is checked row
|
|
344
|
+
// by row and a partial index cannot be deferred. The target row is locked first
|
|
345
|
+
// so it cannot leave between the two. Returns 'transferred' | 'not_member' | 'not_owner'.
|
|
346
|
+
async function transferOwnership({ guildId, ownerGithubId, handle }, { pool = defaultPool } = {}) {
|
|
347
|
+
try {
|
|
348
|
+
return await api.withTx(async (client) => {
|
|
349
|
+
const target = await client.query(
|
|
350
|
+
`SELECT m.github_id FROM platform_identity_guild_members m
|
|
351
|
+
JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
|
|
352
|
+
WHERE m.guild_id = $1 AND m.membership_kind = 'member'
|
|
353
|
+
AND lower(pa.handle) = $2 AND ${accountActiveSql('pa')}
|
|
354
|
+
FOR UPDATE OF m`,
|
|
355
|
+
[guildId, handle],
|
|
356
|
+
);
|
|
357
|
+
if (!target.rows[0]) return 'not_member';
|
|
358
|
+
const demoted = await client.query(
|
|
359
|
+
`UPDATE platform_identity_guild_members SET membership_kind = 'member'
|
|
360
|
+
WHERE guild_id = $1 AND github_id = $2::bigint AND membership_kind = 'owner'
|
|
361
|
+
RETURNING github_id`,
|
|
362
|
+
[guildId, ownerGithubId],
|
|
363
|
+
);
|
|
364
|
+
if (!demoted.rowCount) return 'not_owner';
|
|
365
|
+
const promoted = await client.query(
|
|
366
|
+
`UPDATE platform_identity_guild_members SET membership_kind = 'owner'
|
|
367
|
+
WHERE guild_id = $1 AND github_id = $2::bigint AND membership_kind = 'member'
|
|
368
|
+
RETURNING github_id`,
|
|
369
|
+
[guildId, target.rows[0].github_id],
|
|
370
|
+
);
|
|
371
|
+
// Unreachable while the row lock holds, but a guild must never be left
|
|
372
|
+
// ownerless: throwing rolls the demotion back.
|
|
373
|
+
if (!promoted.rowCount) throw new TransferAborted();
|
|
374
|
+
return 'transferred';
|
|
375
|
+
}, { pool });
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (err instanceof TransferAborted) return 'not_member';
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Invites and join requests
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
// ONE statement for both directions, the connections requestConnection shape:
|
|
387
|
+
// `expired` resolves a timed-out pending row of this pair and is referenced
|
|
388
|
+
// through `guard`, so it is drained BEFORE the INSERT (an unreferenced
|
|
389
|
+
// data-modifying CTE runs after it and would hit the open-row index); the
|
|
390
|
+
// cooldown NOT EXISTS inserts nothing inside the window, which is how a
|
|
391
|
+
// swallowed re-ask writes nothing while answering yes. `gate` is a static
|
|
392
|
+
// fragment naming who may open this kind of row.
|
|
393
|
+
function openRequestSql(gate) {
|
|
394
|
+
return `
|
|
395
|
+
WITH expired AS (
|
|
396
|
+
UPDATE platform_identity_guild_requests r
|
|
397
|
+
SET status = 'expired', resolved_at = now()
|
|
398
|
+
WHERE r.guild_id = $1 AND r.github_id = $2::bigint AND r.status = 'pending'
|
|
399
|
+
AND r.created_at <= now() - make_interval(days => $4::int)
|
|
400
|
+
RETURNING r.id
|
|
401
|
+
),
|
|
402
|
+
guard AS (SELECT count(*)::int AS expired_count FROM expired)
|
|
403
|
+
INSERT INTO platform_identity_guild_requests (guild_id, github_id, kind, counted_in_totals)
|
|
404
|
+
SELECT $1::bigint, $2::bigint, $3::text, $6::boolean
|
|
405
|
+
FROM guard
|
|
406
|
+
WHERE ${gate}
|
|
407
|
+
AND NOT EXISTS (SELECT 1 FROM platform_identity_guild_members m
|
|
408
|
+
WHERE m.guild_id = $1 AND m.github_id = $2::bigint)
|
|
409
|
+
AND NOT EXISTS (SELECT 1 FROM platform_identity_guild_requests r
|
|
410
|
+
WHERE r.guild_id = $1 AND r.github_id = $2::bigint AND r.kind = $3::text
|
|
411
|
+
AND r.status = 'rejected'
|
|
412
|
+
AND r.resolved_at > now() - make_interval(days => $5::int))
|
|
413
|
+
RETURNING id`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Run an open-request insert → 'created' | 'swallowed' | 'already_open'. The
|
|
417
|
+
// route must answer 'created' and 'swallowed' identically.
|
|
418
|
+
async function runOpenRequest(sql, params, pool) {
|
|
419
|
+
try {
|
|
420
|
+
const { rowCount } = await pool.query(sql, params);
|
|
421
|
+
return rowCount > 0 ? 'created' : 'swallowed';
|
|
422
|
+
} catch (err) {
|
|
423
|
+
// The open-row index: an invite or a request is already pending for this
|
|
424
|
+
// (guild, person). Safe to name — it is in the caller's own queue already.
|
|
425
|
+
if (err && err.code === '23505') return 'already_open';
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function isMember(guildId, githubId, pool) {
|
|
431
|
+
const { rowCount } = await pool.query(
|
|
432
|
+
`SELECT 1 FROM platform_identity_guild_members WHERE guild_id = $1 AND github_id = $2::bigint`,
|
|
433
|
+
[guildId, githubId],
|
|
434
|
+
);
|
|
435
|
+
return rowCount > 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// A builder asks to join. Only a PUBLIC guild takes requests (D3/D4), so an
|
|
439
|
+
// unlisted slug and an unknown one are the same 'not_found'. Joining a public
|
|
440
|
+
// guild is consent to be shown there, so the request needs no card check; the
|
|
441
|
+
// requester's counted_in_totals choice rides the row until the owner accepts.
|
|
442
|
+
// Returns { outcome: 'created' | 'swallowed' | 'already_open' | 'already_member' | 'not_found' }.
|
|
443
|
+
async function requestToJoin({ slug, githubId, countedInTotals = true }, { pool = defaultPool } = {}) {
|
|
444
|
+
const { rows } = await pool.query(
|
|
445
|
+
`SELECT id FROM platform_identity_guilds WHERE lower(slug) = $1 AND visibility = 'public'`,
|
|
446
|
+
[slug],
|
|
447
|
+
);
|
|
448
|
+
const guild = rows[0];
|
|
449
|
+
if (!guild) return { outcome: 'not_found' };
|
|
450
|
+
if (await isMember(guild.id, githubId, pool)) return { outcome: 'already_member' };
|
|
451
|
+
const gate = `EXISTS (SELECT 1 FROM platform_identity_guilds g WHERE g.id = $1 AND g.visibility = 'public')`;
|
|
452
|
+
const outcome = await runOpenRequest(openRequestSql(gate), [
|
|
453
|
+
guild.id, githubId, 'request', REQUEST_EXPIRY_DAYS, DECLINE_COOLDOWN_DAYS, countedInTotals !== false,
|
|
454
|
+
], pool);
|
|
455
|
+
return { outcome };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// The owner invites up to INVITE_BATCH_MAX handles, each an ordinary individual
|
|
459
|
+
// invite (D3). Per handle: 'invited' (written OR swallowed by a decline
|
|
460
|
+
// cooldown — deliberately one answer), 'already_open', 'already_member', or
|
|
461
|
+
// 'not_found' (unknown, provisional, or a private account the owner is not
|
|
462
|
+
// connected to — one answer for all three).
|
|
463
|
+
async function inviteHandles({ guildId, ownerGithubId, handles }, { pool = defaultPool } = {}) {
|
|
464
|
+
const seen = new Set();
|
|
465
|
+
const results = [];
|
|
466
|
+
const sql = openRequestSql(ownerExistsSql('$1', '$7'));
|
|
467
|
+
for (const raw of handles.slice(0, INVITE_BATCH_MAX)) {
|
|
468
|
+
const handle = String(raw || '').trim().toLowerCase();
|
|
469
|
+
if (!handle || seen.has(handle)) continue;
|
|
470
|
+
seen.add(handle);
|
|
471
|
+
const target = await addressableAccount(ownerGithubId, handle, { pool });
|
|
472
|
+
if (!target) { results.push({ handle, outcome: 'not_found' }); continue; }
|
|
473
|
+
if (String(target.github_id) === String(ownerGithubId) || await isMember(guildId, target.github_id, pool)) {
|
|
474
|
+
results.push({ handle, outcome: 'already_member' });
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const out = await runOpenRequest(sql, [
|
|
478
|
+
guildId, target.github_id, 'invite', REQUEST_EXPIRY_DAYS, DECLINE_COOLDOWN_DAYS, true, ownerGithubId,
|
|
479
|
+
], pool);
|
|
480
|
+
results.push({ handle, outcome: out === 'already_open' ? 'already_open' : 'invited' });
|
|
481
|
+
}
|
|
482
|
+
return results;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Accept an invite (the invitee) or a join request (the guild's owner).
|
|
486
|
+
//
|
|
487
|
+
// The invitee's accept carries the visibility their card showed; if the guild's
|
|
488
|
+
// visibility has changed since, it is refused ('visibility_changed') so the
|
|
489
|
+
// card can re-render for a fresh decision (D4 §3). They may also set their
|
|
490
|
+
// counted_in_totals there (ADR 0337 D4.4). An owner accepting a request cannot
|
|
491
|
+
// set the requester's switch: that comes from the request row.
|
|
492
|
+
//
|
|
493
|
+
// The accept itself is ONE statement: resolve the row and insert the membership,
|
|
494
|
+
// every condition re-asserted in its WHERE. shown_publicly is the guild's
|
|
495
|
+
// visibility at this moment. Returns { outcome: 'accepted' | 'not_found' |
|
|
496
|
+
// 'card_required' | 'visibility_changed', visibility? }.
|
|
497
|
+
async function acceptRequest({ id, githubId, cardVisibility = null, countedInTotals = null }, { pool = defaultPool } = {}) {
|
|
498
|
+
const pre = await pool.query(
|
|
499
|
+
`SELECT r.kind, r.github_id::text AS github_id, g.visibility,
|
|
500
|
+
${ownerExistsSql('r.guild_id', '$2')} AS actor_is_owner
|
|
501
|
+
FROM platform_identity_guild_requests r
|
|
502
|
+
JOIN platform_identity_guilds g ON g.id = r.guild_id
|
|
503
|
+
WHERE r.id = $1 AND r.status = 'pending'
|
|
504
|
+
AND r.created_at > now() - make_interval(days => $3::int)`,
|
|
505
|
+
[id, githubId, REQUEST_EXPIRY_DAYS],
|
|
506
|
+
);
|
|
507
|
+
const row = pre.rows[0];
|
|
508
|
+
const asInvitee = !!row && row.kind === 'invite' && row.github_id === String(githubId);
|
|
509
|
+
const asOwner = !!row && row.kind === 'request' && row.actor_is_owner === true;
|
|
510
|
+
if (!asInvitee && !asOwner) return { outcome: 'not_found' };
|
|
511
|
+
if (asInvitee) {
|
|
512
|
+
if (!cardVisibility) return { outcome: 'card_required' };
|
|
513
|
+
if (cardVisibility !== row.visibility) return { outcome: 'visibility_changed', visibility: row.visibility };
|
|
514
|
+
}
|
|
515
|
+
const { rows } = await pool.query(
|
|
516
|
+
`WITH acc AS (
|
|
517
|
+
UPDATE platform_identity_guild_requests r
|
|
518
|
+
SET status = 'accepted', resolved_at = now()
|
|
519
|
+
FROM platform_identity_guilds g
|
|
520
|
+
WHERE r.id = $1 AND g.id = r.guild_id AND r.status = 'pending'
|
|
521
|
+
AND r.created_at > now() - make_interval(days => $3::int)
|
|
522
|
+
AND ((r.kind = 'invite' AND r.github_id = $2::bigint AND g.visibility = $4::text)
|
|
523
|
+
OR (r.kind = 'request' AND ${ownerExistsSql('r.guild_id', '$2')}
|
|
524
|
+
AND EXISTS (SELECT 1 FROM platform_identity_accounts pa
|
|
525
|
+
WHERE pa.github_id = r.github_id AND ${accountActiveSql('pa')})))
|
|
526
|
+
RETURNING r.guild_id, r.github_id, r.kind, r.counted_in_totals, g.visibility
|
|
527
|
+
),
|
|
528
|
+
joined AS (
|
|
529
|
+
INSERT INTO platform_identity_guild_members
|
|
530
|
+
(guild_id, github_id, membership_kind, shown_publicly, counted_in_totals)
|
|
531
|
+
SELECT guild_id, github_id, 'member', visibility = 'public',
|
|
532
|
+
CASE WHEN kind = 'invite' THEN COALESCE($5::boolean, counted_in_totals)
|
|
533
|
+
ELSE counted_in_totals END
|
|
534
|
+
FROM acc
|
|
535
|
+
ON CONFLICT (guild_id, github_id) DO NOTHING
|
|
536
|
+
RETURNING guild_id
|
|
537
|
+
)
|
|
538
|
+
SELECT (SELECT count(*)::int FROM acc) AS accepted`,
|
|
539
|
+
[id, githubId, REQUEST_EXPIRY_DAYS, asInvitee ? cardVisibility : null, asInvitee ? countedInTotals : null],
|
|
540
|
+
);
|
|
541
|
+
return { outcome: rows[0] && rows[0].accepted > 0 ? 'accepted' : 'not_found' };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Decline — the invitee an invite, the owner a request. SILENT: the other side
|
|
545
|
+
// is never told, and the kept 'rejected' row IS the cooldown.
|
|
546
|
+
async function declineRequest({ id, githubId }, { pool = defaultPool } = {}) {
|
|
547
|
+
const { rowCount } = await pool.query(
|
|
548
|
+
`UPDATE platform_identity_guild_requests r
|
|
549
|
+
SET status = 'rejected', resolved_at = now()
|
|
550
|
+
WHERE r.id = $1 AND r.status = 'pending'
|
|
551
|
+
AND r.created_at > now() - make_interval(days => $3::int)
|
|
552
|
+
AND ((r.kind = 'invite' AND r.github_id = $2::bigint)
|
|
553
|
+
OR (r.kind = 'request' AND ${ownerExistsSql('r.guild_id', '$2')}))
|
|
554
|
+
RETURNING r.id`,
|
|
555
|
+
[id, githubId, REQUEST_EXPIRY_DAYS],
|
|
556
|
+
);
|
|
557
|
+
return rowCount > 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Cancel — whoever opened the row: the requester their request, the owner an
|
|
561
|
+
// invite. A cancel never arms a cooldown, so the other side cannot use it to
|
|
562
|
+
// dodge a decline.
|
|
563
|
+
async function cancelRequest({ id, githubId }, { pool = defaultPool } = {}) {
|
|
564
|
+
const { rowCount } = await pool.query(
|
|
565
|
+
`UPDATE platform_identity_guild_requests r
|
|
566
|
+
SET status = 'cancelled', resolved_at = now()
|
|
567
|
+
WHERE r.id = $1 AND r.status = 'pending'
|
|
568
|
+
AND ((r.kind = 'request' AND r.github_id = $2::bigint)
|
|
569
|
+
OR (r.kind = 'invite' AND ${ownerExistsSql('r.guild_id', '$2')}))
|
|
570
|
+
RETURNING r.id`,
|
|
571
|
+
[id, githubId],
|
|
572
|
+
);
|
|
573
|
+
return rowCount > 0;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Split a two-direction queue: the incoming side keeps seen_at (and gets it
|
|
577
|
+
// stamped); the outgoing side never sees it — a decline is silent, and so is a
|
|
578
|
+
// read (the connections listRequests rule).
|
|
579
|
+
function splitQueue(rows, incomingKind) {
|
|
580
|
+
const incoming = rows.filter((r) => r.kind === incomingKind).map(({ kind, ...r }) => r);
|
|
581
|
+
const outgoing = rows.filter((r) => r.kind !== incomingKind).map(({ kind, seen_at, ...r }) => r);
|
|
582
|
+
return { incoming, outgoing, unseen: incoming.filter((r) => !r.seen_at).map((r) => r.id) };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// The caller's own queue: invites addressed to them (each carrying the card —
|
|
586
|
+
// the guild's name, description and CURRENT visibility, which the accept must
|
|
587
|
+
// echo back) and the join requests they sent. Each direction has its own cap.
|
|
588
|
+
async function listMyRequests({ githubId }, { pool = defaultPool } = {}) {
|
|
589
|
+
const one = (kind) => `
|
|
590
|
+
SELECT r.id::text AS id, r.kind, r.created_at, r.seen_at,
|
|
591
|
+
g.slug, g.name, g.description, g.visibility
|
|
592
|
+
FROM platform_identity_guild_requests r
|
|
593
|
+
JOIN platform_identity_guilds g ON g.id = r.guild_id
|
|
594
|
+
WHERE r.github_id = $1::bigint AND r.kind = '${kind}' AND r.status = 'pending'
|
|
595
|
+
AND r.created_at > now() - make_interval(days => $2::int)
|
|
596
|
+
ORDER BY r.created_at DESC, r.id DESC
|
|
597
|
+
LIMIT $3`;
|
|
598
|
+
const { rows } = await pool.query(
|
|
599
|
+
`(${one('invite')}) UNION ALL (${one('request')})`,
|
|
600
|
+
[githubId, REQUEST_EXPIRY_DAYS, LIST_MAX],
|
|
601
|
+
);
|
|
602
|
+
const { incoming, outgoing, unseen } = splitQueue(rows, 'invite');
|
|
603
|
+
if (unseen.length) {
|
|
604
|
+
await pool.query(
|
|
605
|
+
`UPDATE platform_identity_guild_requests SET seen_at = now()
|
|
606
|
+
WHERE id = ANY($1::bigint[]) AND github_id = $2::bigint AND kind = 'invite' AND seen_at IS NULL`,
|
|
607
|
+
[unseen, githubId],
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
return { incoming, outgoing };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// The owner's queue for one guild: join requests to decide, and invites still
|
|
614
|
+
// out. A requester floors on consent alone — they addressed the guild, and a
|
|
615
|
+
// request must name who is asking or it cannot be decided. An INVITEE consented
|
|
616
|
+
// to nothing yet, so they are listed only if THIS owner could address them
|
|
617
|
+
// (public, or connected — the addressableAccount rule): after a transfer, a
|
|
618
|
+
// private account the previous owner invited is not revealed to the new one.
|
|
619
|
+
// Owner re-asserted in the statement.
|
|
620
|
+
async function listGuildRequests({ guildId, ownerGithubId }, { pool = defaultPool } = {}) {
|
|
621
|
+
const one = (kind, extra = '') => `
|
|
622
|
+
SELECT r.id::text AS id, r.kind, r.created_at, r.seen_at, ${PERSON_COLUMNS}
|
|
623
|
+
FROM platform_identity_guild_requests r
|
|
624
|
+
JOIN platform_identity_accounts pa ON pa.github_id = r.github_id
|
|
625
|
+
WHERE r.guild_id = $1 AND r.kind = '${kind}' AND r.status = 'pending'
|
|
626
|
+
AND r.created_at > now() - make_interval(days => $3::int)
|
|
627
|
+
AND ${ownerExistsSql('$1', '$2')}
|
|
628
|
+
AND ${accountActiveSql('pa')}${extra}
|
|
629
|
+
ORDER BY r.created_at DESC, r.id DESC
|
|
630
|
+
LIMIT $4`;
|
|
631
|
+
const addressable = `
|
|
632
|
+
AND (${accountVisibleSql('pa')} OR pa.github_id IN (${conn.neighboursSql('$2')}))`;
|
|
633
|
+
const { rows } = await pool.query(
|
|
634
|
+
`(${one('request')}) UNION ALL (${one('invite', addressable)})`,
|
|
635
|
+
[guildId, ownerGithubId, REQUEST_EXPIRY_DAYS, LIST_MAX],
|
|
636
|
+
);
|
|
637
|
+
const { incoming, outgoing, unseen } = splitQueue(rows, 'request');
|
|
638
|
+
if (unseen.length) {
|
|
639
|
+
await pool.query(
|
|
640
|
+
`UPDATE platform_identity_guild_requests SET seen_at = now()
|
|
641
|
+
WHERE id = ANY($1::bigint[]) AND guild_id = $2 AND kind = 'request' AND seen_at IS NULL`,
|
|
642
|
+
[unseen, guildId],
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
return { incoming, outgoing };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
// The public read (D4)
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
// A PUBLIC guild's page data, or null. An unlisted guild answers null to
|
|
653
|
+
// everyone, its own members included — the read takes no viewer, so it cannot
|
|
654
|
+
// vary by one. The roster is shown_publicly AND accountVisibleSql, ordered only
|
|
655
|
+
// by what it publishes (ADR 0255), capped at LIST_MAX; `size` is the length of
|
|
656
|
+
// the list returned, never a total beside it (D4: there is no "+N more").
|
|
657
|
+
async function getPublicGuild(slug, { pool = defaultPool } = {}) {
|
|
658
|
+
const g = await pool.query(
|
|
659
|
+
`SELECT id, slug, name, description, created_at
|
|
660
|
+
FROM platform_identity_guilds
|
|
661
|
+
WHERE lower(slug) = $1 AND visibility = 'public'`,
|
|
662
|
+
[slug],
|
|
663
|
+
);
|
|
664
|
+
const guild = g.rows[0];
|
|
665
|
+
if (!guild) return null;
|
|
666
|
+
const { rows } = await pool.query(
|
|
667
|
+
`SELECT ${PERSON_COLUMNS}, m.membership_kind
|
|
668
|
+
FROM platform_identity_guild_members m
|
|
669
|
+
JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
|
|
670
|
+
WHERE m.guild_id = $1 AND m.shown_publicly AND ${accountVisibleSql('pa')}
|
|
671
|
+
ORDER BY (m.membership_kind = 'owner') DESC, lower(pa.handle)
|
|
672
|
+
LIMIT $2`,
|
|
673
|
+
[guild.id, LIST_MAX],
|
|
674
|
+
);
|
|
675
|
+
const { id, ...shown } = guild;
|
|
676
|
+
return { guild: shown, members: rows, size: rows.length };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
module.exports = {
|
|
680
|
+
GUILD_VISIBILITIES,
|
|
681
|
+
NAME_MAX_CHARS,
|
|
682
|
+
DESCRIPTION_MAX_CHARS,
|
|
683
|
+
INVITE_BATCH_MAX,
|
|
684
|
+
LIST_MAX,
|
|
685
|
+
normalizeGuildSlug,
|
|
686
|
+
lookupSlug,
|
|
687
|
+
createGuild,
|
|
688
|
+
getMembership,
|
|
689
|
+
updateGuild,
|
|
690
|
+
dissolveGuild,
|
|
691
|
+
listMyGuilds,
|
|
692
|
+
listMemberRoster,
|
|
693
|
+
setMembershipFlags,
|
|
694
|
+
leaveGuild,
|
|
695
|
+
removeMember,
|
|
696
|
+
transferOwnership,
|
|
697
|
+
requestToJoin,
|
|
698
|
+
inviteHandles,
|
|
699
|
+
acceptRequest,
|
|
700
|
+
declineRequest,
|
|
701
|
+
cancelRequest,
|
|
702
|
+
listMyRequests,
|
|
703
|
+
listGuildRequests,
|
|
704
|
+
getPublicGuild,
|
|
705
|
+
};
|