fcc3c84d76
Ships the request side of joining a gated scope, completing the §22.8 pair
(S4 shipped the invite half). A user who knows a project/collection exists
asks to join it naming a desired role; the request fans out to that scope's
Owners across the subtree (the cross-collection inbox, §22.11), who accept
(writing the memberships row via memberships.grant) or decline. Built by
analogy to §28 contribution_requests + the S4 memberships surface.
Backend
- migration 032: join_requests (scope_type ∈ {project,collection}, scope_id,
requester, requested_role, message, status, granted_role); one-open-per
(scope, requester) partial unique index. Additive — no rebuild.
- api_join_requests.py: GET join-target / POST join-requests / POST
{id}/accept / {id}/decline under /api/scopes/{scope_type}/{scope_id}/.
Accept grants via memberships.grant; the request POST does not require the
scope be readable (that is how one joins a gated scope).
- notify: fan_out_join_request (subtree-Owner enumeration via
_scope_owner_user_ids), notify_join_decided, 3 render_summary cases.
- auth.effective_role_at_scope — scope-grain twin of effective_scope_role,
folding global → project for a project target.
- api_collections: viewer.can_request_join on the project + collection blocks.
Frontend
- api.js join verbs; JoinRequestModal; "Request to join" affordance in the
collection directory + catalog footer; JoinRequestRow in the inbox.
Tests: backend test_join_requests_vertical (11) + test_migration_032 (5);
frontend api.joinrequests + CollectionDirectory cases. 546 backend / 36
frontend green.
Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S6) and
SPEC.md §22.8 / §22.11. Closes the request-to-join item flagged open at
0.45.0; per-type surfaces (§22.4a items 1 & 3) remain the last S6 item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
59 lines
3.3 KiB
SQL
59 lines
3.3 KiB
SQL
-- §22.8 S6 — request-to-join a scope + the cross-collection inbox.
|
|
--
|
|
-- A gated project or collection is invisible to non-members (§22.5), so a user
|
|
-- who knows a scope exists can ask to join it: they name a desired role and the
|
|
-- request is recorded here, then fanned out to that scope's Owners *across the
|
|
-- subtree* (a collection request reaches the collection's Owners, its project's
|
|
-- Owners, and global Owners — the cross-collection inbox, §22.11). An Owner
|
|
-- accepts (which writes the `memberships` row via memberships.grant) or declines;
|
|
-- the requester is §15-notified of the decision either way.
|
|
--
|
|
-- This mirrors `contribution_requests` (migration 024) but at the scope grain
|
|
-- instead of the per-RFC grain: the target is a `(scope_type, scope_id)` pair
|
|
-- (matching the `memberships` scope vocabulary, minus 'global' — joining is for a
|
|
-- project or collection a user discovers, not the deployment), and accept grants
|
|
-- a scope role rather than minting an RFC invitation.
|
|
--
|
|
-- The request row is the persistent record; the inbox notification is the
|
|
-- owner-facing actionable surface keyed back to it via `notification_id`.
|
|
|
|
CREATE TABLE IF NOT EXISTS join_requests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
-- The target scope. 'global' is intentionally excluded: the deployment is
|
|
-- not a thing one "discovers and joins" (§22.8 names a project/collection).
|
|
scope_type TEXT NOT NULL
|
|
CHECK (scope_type IN ('project', 'collection')),
|
|
scope_id TEXT NOT NULL,
|
|
requester_user_id INTEGER NOT NULL
|
|
REFERENCES users(id) ON DELETE CASCADE,
|
|
-- The role the requester is asking for ({owner, contributor}, the §22.6
|
|
-- unified vocabulary). The accepting Owner may grant this or a narrower role.
|
|
requested_role TEXT NOT NULL
|
|
CHECK (requested_role IN ('owner', 'contributor')),
|
|
-- Optional free text — "who I am / why I want in". Bounded by the API layer.
|
|
message TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending'
|
|
CHECK (status IN ('pending', 'accepted', 'declined')),
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
decided_at TEXT,
|
|
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
-- The role actually granted on accept (may differ from requested_role if the
|
|
-- Owner narrowed it); NULL until accepted.
|
|
granted_role TEXT CHECK (granted_role IN ('owner', 'contributor')),
|
|
-- The owner-facing notification row that carries the Accept/Decline action.
|
|
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_join_requests_scope
|
|
ON join_requests(scope_type, scope_id, status);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_join_requests_requester
|
|
ON join_requests(requester_user_id, status);
|
|
|
|
-- At most one open (pending) request per (scope, requester): a second ask while
|
|
-- one is still pending is a 409, not a duplicate row. A decided request
|
|
-- (accepted/declined) does not block a fresh ask later.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_one_open
|
|
ON join_requests(scope_type, scope_id, requester_user_id)
|
|
WHERE status = 'pending';
|