§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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user