§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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.45.0",
"version": "0.46.0",
"type": "module",
"scripts": {
"dev": "vite",
+46
View File
@@ -0,0 +1,46 @@
// §22.8 — the request-to-join API client builds scope-keyed URLs and methods.
import { describe, it, expect, vi, afterEach } from 'vitest'
import { joinTarget, requestJoin, acceptJoinRequest, declineJoinRequest } from './api.js'
function mockFetch() {
const fn = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true }),
}))
global.fetch = fn
return fn
}
afterEach(() => { vi.restoreAllMocks() })
describe('join-request api URLs', () => {
it('joinTarget GETs the scope join-target', async () => {
const f = mockFetch()
await joinTarget('collection', 'features')
expect(f).toHaveBeenCalledWith('/api/scopes/collection/features/join-target')
})
it('requestJoin POSTs the desired role + message', async () => {
const f = mockFetch()
await requestJoin('project', 'ohm', { role: 'contributor', message: 'hi' })
expect(f.mock.calls[0][0]).toBe('/api/scopes/project/ohm/join-requests')
const opts = f.mock.calls[0][1]
expect(opts.method).toBe('POST')
expect(JSON.parse(opts.body)).toEqual({ role: 'contributor', message: 'hi' })
})
it('acceptJoinRequest POSTs the accept route with an optional role override', async () => {
const f = mockFetch()
await acceptJoinRequest('collection', 'features', 7, 'contributor')
expect(f.mock.calls[0][0]).toBe('/api/scopes/collection/features/join-requests/7/accept')
expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({ role: 'contributor' })
})
it('declineJoinRequest POSTs the decline route', async () => {
const f = mockFetch()
await declineJoinRequest('collection', 'features', 7)
expect(f.mock.calls[0][0]).toBe('/api/scopes/collection/features/join-requests/7/decline')
expect(f.mock.calls[0][1].method).toBe('POST')
})
})
+40
View File
@@ -367,6 +367,46 @@ export async function declineContributionRequest(slug, requestId) {
))
}
// §22.8: request-to-join a scope + the cross-collection inbox.
// `joinTarget` feeds the request modal (scope name, the viewer's eligibility);
// `requestJoin` submits the ask (desired role + optional message); accept/decline
// are the scope Owner's inbox actions (accept writes the membership row).
export async function joinTarget(scopeType, scopeId) {
return jsonOrThrow(await fetch(
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-target`,
))
}
export async function requestJoin(scopeType, scopeId, { role, message }) {
const res = await fetch(
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role, message: message || null }),
},
)
return jsonOrThrow(res)
}
export async function acceptJoinRequest(scopeType, scopeId, requestId, role) {
return jsonOrThrow(await fetch(
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/accept`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: role || null }),
},
))
}
export async function declineJoinRequest(scopeType, scopeId, requestId) {
return jsonOrThrow(await fetch(
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/decline`,
{ method: 'POST' },
))
}
export async function mergeProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res)
+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>
)
}
@@ -20,6 +20,7 @@ import { collectionHome } from '../lib/entryPaths'
import { entryNoun } from './ProjectLayout.jsx'
import CreateCollectionModal from './CreateCollectionModal.jsx'
import ScopeMembersModal from './ScopeMembersModal.jsx'
import JoinRequestModal from './JoinRequestModal.jsx'
export default function CollectionDirectory({ projectId }) {
const [cols, setCols] = useState(null)
@@ -27,6 +28,7 @@ export default function CollectionDirectory({ projectId }) {
const [version, setVersion] = useState(0)
const [createOpen, setCreateOpen] = useState(false)
const [membersOpen, setMembersOpen] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
useEffect(() => {
let live = true
@@ -46,6 +48,7 @@ export default function CollectionDirectory({ projectId }) {
const canCreate = !!viewer?.can_create_collection
const canInvite = !!viewer?.can_invite
const canRequestJoin = !!viewer?.can_request_join
return (
<main className="chrome-pane">
@@ -56,6 +59,9 @@ export default function CollectionDirectory({ projectId }) {
{canInvite && (
<button className="btn-link" onClick={() => setMembersOpen(true)}>Members</button>
)}
{canRequestJoin && (
<button className="btn-link" onClick={() => setJoinOpen(true)}>Request to join</button>
)}
{canCreate && cols.length > 0 && (
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New collection</button>
)}
@@ -101,6 +107,13 @@ export default function CollectionDirectory({ projectId }) {
onClose={() => setMembersOpen(false)}
/>
)}
{joinOpen && (
<JoinRequestModal
scopeType="project"
scopeId={projectId}
onClose={() => setJoinOpen(false)}
/>
)}
</main>
)
}
@@ -4,15 +4,20 @@ import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
let mockItems = []
let mockViewer = null
vi.mock('../api', () => ({
listCollections: vi.fn(async () => ({ items: mockItems })),
listCollections: vi.fn(async () => ({ items: mockItems, viewer: mockViewer })),
// JoinRequestModal (rendered behind the affordance) pulls these in.
joinTarget: vi.fn(async () => ({ eligible: true, name: 'Ohm', already_requested: false })),
requestJoin: vi.fn(async () => ({ status: 'pending' })),
}))
import CollectionDirectory from './CollectionDirectory.jsx'
beforeEach(() => { mockItems = [] })
beforeEach(() => { mockItems = []; mockViewer = null })
function renderDir(items) {
function renderDir(items, viewer = null) {
mockItems = items
mockViewer = viewer
return render(
<MemoryRouter initialEntries={["/p/ohm/"]}>
<Routes>
@@ -45,4 +50,27 @@ describe('CollectionDirectory', () => {
renderDir([])
await waitFor(() => expect(screen.getByText('No collections yet.')).toBeInTheDocument())
})
it('offers "Request to join" when the viewer holds no role (§22.8)', async () => {
renderDir(
[
{ id: 'a', name: 'A', type: 'document' },
{ id: 'b', name: 'B', type: 'document' },
],
{ can_request_join: true },
)
await waitFor(() => expect(screen.getByText('Request to join')).toBeInTheDocument())
})
it('hides "Request to join" from a member', async () => {
renderDir(
[
{ id: 'a', name: 'A', type: 'document' },
{ id: 'b', name: 'B', type: 'document' },
],
{ role: 'owner', can_request_join: false },
)
await waitFor(() => expect(screen.getByText('A')).toBeInTheDocument())
expect(screen.queryByText('Request to join')).not.toBeInTheDocument()
})
})
+67
View File
@@ -12,7 +12,9 @@ import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
acceptContributionRequest,
acceptJoinRequest,
declineContributionRequest,
declineJoinRequest,
listNotifications,
markNotificationRead,
markNotificationsReadByFilter,
@@ -228,11 +230,76 @@ function ContributionRequestRow({ item, onMarkRead }) {
)
}
// §22.8: the cross-collection inbox row a request to join a scope, surfaced
// to that scope's Owners across the subtree. Mirrors ContributionRequestRow:
// the requester's role + message inline, an Accept/Decline pair that writes (or
// refuses) the membership grant.
function JoinRequestRow({ item, onMarkRead }) {
const unread = !item.read_at
const x = item.extras || {}
const [outcome, setOutcome] = useState(null) // 'accepted' | 'declined'
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
async function act(accept) {
if (busy || outcome) return
setBusy(true)
setError(null)
try {
if (accept) await acceptJoinRequest(x.scope_type, x.scope_id, x.request_id)
else await declineJoinRequest(x.scope_type, x.scope_id, x.request_id)
setOutcome(accept ? 'accepted' : 'declined')
await onMarkRead(item)
} catch (err) {
setError(err.message || 'Action failed.')
} finally {
setBusy(false)
}
}
const roleLabel = x.requested_role === 'owner' ? 'Owner' : 'RFC Contributor'
return (
<li className={`inbox-row inbox-row-action ${unread ? 'unread' : 'read'}`}>
<div className="inbox-row-main">
<span className="inbox-unread-dot" aria-hidden />
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
<span className="inbox-summary">{item.summary}</span>
<span className="inbox-when">{formatWhen(item.created_at)}</span>
</div>
<div className="inbox-request-detail">
<p><strong>Requested role:</strong> {roleLabel}</p>
{x.message && <p><strong>Message:</strong> {x.message}</p>}
</div>
{error && <p className="field-error">{error}</p>}
{outcome ? (
<p className="inbox-request-outcome muted">
{outcome === 'accepted'
? `Accepted — they're now a member as ${roleLabel}.`
: 'Declined.'}
</p>
) : (
<div className="inbox-request-actions">
<button type="button" className="btn-primary" disabled={busy || !x.request_id} onClick={() => act(true)}>
Accept
</button>
<button type="button" className="btn-secondary" disabled={busy || !x.request_id} onClick={() => act(false)}>
Decline
</button>
</div>
)}
</li>
)
}
function InboxRow({ item, onClick, onMarkRead, onClose }) {
const pid = useProjectId()
if (item.event_kind === 'contribution_request_on_pending_rfc') {
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
}
if (item.event_kind === 'join_request_on_scope') {
return <JoinRequestRow item={item} onMarkRead={onMarkRead} />
}
const unread = !item.read_at
const target = deepLink(item, pid)
const handle = async () => {
@@ -0,0 +1,114 @@
// JoinRequestModal.jsx §22.8: request-to-join a scope.
//
// A gated project or collection is invisible to non-members, so a user who
// knows it exists asks to join naming a desired role ({owner, contributor})
// and an optional message. The request is recorded and fanned out to the
// scope's Owners across the subtree (the cross-collection inbox), who accept
// (writing the membership row) or decline.
//
// Sibling of ScopeMembersModal (the Owner's grant surface) and the per-RFC
// ContributeRequestForm (the per-entry ask). Opens from the "Request to join"
// affordance the directory / collection view shows when the viewer's
// `can_request_join` flag is set.
import { useEffect, useState } from 'react'
import { joinTarget, requestJoin } from '../api'
const ROLE_OPTIONS = [
{ value: 'contributor', label: 'RFC Contributor — propose entries in the scope' },
{ value: 'owner', label: 'Owner — administer the scope and its membership' },
]
export default function JoinRequestModal({ scopeType, scopeId, scopeName, onClose }) {
const [target, setTarget] = useState(null)
const [role, setRole] = useState('contributor')
const [message, setMessage] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [done, setDone] = useState(false)
useEffect(() => {
let live = true
joinTarget(scopeType, scopeId)
.then(t => { if (live) setTarget(t) })
.catch(e => { if (live) setError(e.message || 'Could not load this scope.') })
return () => { live = false }
}, [scopeType, scopeId])
const label = (target && target.name) || scopeName || scopeId
const where = scopeType === 'collection' ? 'collection' : 'project'
async function handleSubmit(e) {
e.preventDefault()
if (submitting) return
setSubmitting(true)
setError(null)
try {
await requestJoin(scopeType, scopeId, { role, message: message.trim() || null })
setDone(true)
} catch (err) {
setError(err.message || 'Failed to send the request.')
} finally {
setSubmitting(false)
}
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal" style={{ maxWidth: 520 }}>
<div className="modal-header">
<h2>Request to join</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
{done ? (
<p>
Your request to join {where} <strong>{label}</strong> has been sent to
its Owners. You'll get an inbox notification when it's decided.
</p>
) : target && !target.eligible ? (
<p style={{ color: '#666' }}>
{target.already_requested
? `You already have a pending request to join ${where} ${label}.`
: (target.reason || 'You cannot request to join this scope.')}
</p>
) : (
<form onSubmit={handleSubmit} className="invitations-form">
<p style={{ marginTop: 0, color: '#666' }}>
Ask the Owners of {where} <strong>{label}</strong> for a role. An{' '}
<strong>RFC Contributor</strong> may propose entries; an{' '}
<strong>Owner</strong> administers the scope.
</p>
<label htmlFor="join-role">Role</label>
<select id="join-role" value={role} onChange={e => setRole(e.target.value)}>
{ROLE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<label htmlFor="join-message" style={{ marginTop: 10 }}>
Message <span style={{ color: '#999' }}>(optional)</span>
</label>
<textarea
id="join-message"
value={message}
onChange={e => setMessage(e.target.value)}
rows={3}
placeholder="Who you are and why you'd like to join."
maxLength={4000}
/>
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Sending…' : 'Send request'}
</button>
{error && <span style={{ color: '#c33' }}>{error}</span>}
</div>
</form>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn-link" onClick={onClose}>
{done ? 'Close' : 'Cancel'}
</button>
</div>
</div>
</div>
)
}