§22 S6: request-to-join + cross-collection inbox (§22.8) — v0.46.0

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>
This commit is contained in:
Ben Stull
2026-06-06 02:11:16 -07:00
parent e86fc65643
commit fcc3c84d76
18 changed files with 1379 additions and 6 deletions
+20 -1
View File
@@ -10,6 +10,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { listRFCs, listProposals, getCollection } from '../api'
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
import JoinRequestModal from './JoinRequestModal.jsx'
const STATE_CHIPS = [
{ id: 'super-draft', label: 'Super-draft' },
@@ -31,6 +32,10 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
// collection's caps load; we fall back to "any authenticated viewer" so the
// common case doesn't flicker, then refine.
const [canContribute, setCanContribute] = useState(null)
// §22.8: when the viewer can't contribute here but holds no role reaching the
// collection, offer "Request to join" in the footer.
const [canRequestJoin, setCanRequestJoin] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
// §22.4a: the type-driven entry noun ("RFC" | "Spec" | "Feature") for this
// collection, read from the API. Defaults to the generic "RFC" until loaded.
const [entryNoun, setEntryNoun] = useState('RFC')
@@ -48,9 +53,11 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
setCanContribute(null)
setCanRequestJoin(false)
getCollection(pid, cid)
.then(c => {
setCanContribute(!!c?.viewer?.can_contribute)
setCanRequestJoin(!!c?.viewer?.can_request_join)
setEntryNoun(c?.entry_noun || 'RFC')
})
.catch(() => setCanContribute(false))
@@ -182,8 +189,13 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
// §22 S4: the propose control is offered only when the viewer may
// contribute to *this* collection (the scope-role gate); a granted
// viewer with no contribute right in this collection sees nothing.
mayPropose && (
mayPropose ? (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New {entryNoun}</button>
) : (
// §22.8: signed in but no contribute right here — offer to join.
canRequestJoin && (
<button className="btn-propose" onClick={() => setJoinOpen(true)}>Request to join</button>
)
)
) : (
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
@@ -191,6 +203,13 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
</a>
)}
</div>
{joinOpen && (
<JoinRequestModal
scopeType="collection"
scopeId={cid}
onClose={() => setJoinOpen(false)}
/>
)}
</aside>
)
}