Files
rfc-app/backend/app/api_join_requests.py
T
Ben Stull fcc3c84d76 §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>
2026-06-06 02:11:16 -07:00

312 lines
12 KiB
Python

"""§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 joining is
by invite (an Owner grants directly — `api_memberships.py`) *or* by request: a
user who knows a scope exists asks to join it, naming a desired role. This module
is the request side:
* ``GET /api/scopes/{scope_type}/{scope_id}/join-target`` — what the join
form needs (the scope's name, the viewer's eligibility + whether they already
have a pending ask + their current role).
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests`` — submit the ask
(desired role + optional message); lands a row + one §15 notification per
Owner across the scope's subtree (the cross-collection inbox, §22.11).
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/accept`` —
Owner: accept, which writes the `memberships` row via ``memberships.grant``
(the §22.8 "accepting writes the membership row"), then notifies the requester.
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/decline`` —
Owner: decline; the request closes and the requester is notified.
Mirrors ``api_contributions.py`` (the per-RFC contribute-request flow) but at the
scope grain: the target is a ``(scope_type, scope_id)`` pair drawn from the
``memberships`` scope vocabulary (minus ``global`` — a deployment isn't a thing
one discovers and joins), and accept grants a scope role rather than minting an
RFC invitation.
The request POST deliberately does **not** require the scope be *readable*: the
whole point of request-to-join is to ask into a *gated* scope you were told about
but cannot see (§22.8). It is gated only on "you're signed in, granted, and not
already a member". Accept/decline are gated on Owner reach over the scope
(``auth.can_invite_at_project`` / ``auth.can_invite_at_collection``).
"""
from __future__ import annotations
import sqlite3
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import (
auth,
collections as collections_mod,
db,
memberships as memberships_mod,
notify,
)
_MESSAGE_MAX = 4000
class JoinRequestBody(BaseModel):
role: str
message: str | None = Field(default=None, max_length=_MESSAGE_MAX)
class DecideBody(BaseModel):
# On accept, the Owner may grant a role narrower than the one requested; a
# missing value grants exactly the requested role.
role: str | None = None
def _project_name(project_id: str) -> str | None:
row = db.conn().execute(
"SELECT name FROM projects WHERE id = ?", (project_id,)
).fetchone()
return row["name"] if row and row["name"] else None
def _resolve_scope(scope_type: str, scope_id: str) -> dict[str, Any]:
"""Resolve a `(scope_type, scope_id)` target to its display facts, or 404 if
it doesn't exist. Returns `{project_id, scope_name, project_name}`. The
`scope_type` itself must be one of the join-able scopes."""
if scope_type == "project":
row = db.conn().execute(
"SELECT id, name FROM projects WHERE id = ?", (scope_id,)
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
name = row["name"] or scope_id
return {"project_id": scope_id, "scope_name": name, "project_name": name}
if scope_type == "collection":
col = collections_mod.get_collection(scope_id)
if col is None:
raise HTTPException(404, "Not found")
pid = col["project_id"]
return {
"project_id": pid,
"scope_name": col.get("name") or scope_id,
"project_name": _project_name(pid),
}
raise HTTPException(404, "Not found")
def _require_join_owner(viewer, scope_type: str, scope_id: str) -> None:
"""The accept/decline gate: an Owner whose reach covers the scope (§22.8 'the
scope's Owners across the subtree'). Reuses the S4 invite gates."""
ok = (
auth.can_invite_at_collection(viewer, scope_id)
if scope_type == "collection"
else auth.can_invite_at_project(viewer, scope_id)
)
if not ok:
raise HTTPException(403, "Only an Owner of this scope can act on join requests")
def _require_request(scope_type: str, scope_id: str, request_id: int):
row = db.conn().execute(
"""
SELECT id, scope_type, scope_id, requester_user_id, requested_role,
message, status
FROM join_requests
WHERE id = ? AND scope_type = ? AND scope_id = ?
""",
(request_id, scope_type, scope_id),
).fetchone()
if row is None:
raise HTTPException(404, "Join request not found")
return row
def make_router() -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# GET — what the join form needs to render + gate itself.
# ---------------------------------------------------------------
@router.get("/api/scopes/{scope_type}/{scope_id}/join-target")
async def join_target(scope_type: str, scope_id: str, request: Request) -> dict[str, Any]:
facts = _resolve_scope(scope_type, scope_id)
viewer = auth.current_user(request)
eligible = True
reason: str | None = None
already_requested = False
current_role = auth.effective_role_at_scope(viewer, scope_type, scope_id)
if viewer is None:
eligible, reason = False, "Sign in to request to join."
elif viewer.permission_state != "granted":
eligible, reason = False, "Your beta access request is in review."
elif current_role is not None:
eligible, reason = False, f"You already hold {('Owner' if current_role == 'owner' else 'RFC Contributor')} here."
else:
already_requested = bool(
db.conn().execute(
"""
SELECT 1 FROM join_requests
WHERE scope_type = ? AND scope_id = ? AND requester_user_id = ?
AND status = 'pending' LIMIT 1
""",
(scope_type, scope_id, viewer.user_id),
).fetchone()
)
return {
"scope_type": scope_type,
"scope_id": scope_id,
"name": facts["scope_name"],
"project_id": facts["project_id"],
"eligible": eligible and not already_requested,
"reason": reason,
"already_requested": already_requested,
"current_role": current_role,
}
# ---------------------------------------------------------------
# POST — submit a request to join.
# ---------------------------------------------------------------
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests")
async def create_join_request(
scope_type: str, scope_id: str, body: JoinRequestBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
facts = _resolve_scope(scope_type, scope_id)
role = (body.role or "").strip().lower()
if role not in memberships_mod.VALID_ROLES:
raise HTTPException(422, f"invalid role {body.role!r}")
# Already a member of the scope (at this or a broader grain)? Then there
# is nothing to request — a clear 409 rather than a useless self-request.
if auth.effective_role_at_scope(viewer, scope_type, scope_id) is not None:
raise HTTPException(409, "You already hold a role in this scope.")
message = (body.message or "").strip() or None
try:
cur = db.conn().execute(
"""
INSERT INTO join_requests
(scope_type, scope_id, requester_user_id, requested_role, message)
VALUES (?, ?, ?, ?, ?)
""",
(scope_type, scope_id, viewer.user_id, role, message),
)
except sqlite3.IntegrityError:
# The partial unique index — one open request per (scope, user).
raise HTTPException(409, "You already have a pending request to join this scope.")
request_id = cur.lastrowid
# One actionable notification per Owner across the subtree; stamp the
# first onto the row as the inbox-action handle (any Owner may act).
notif_ids = notify.fan_out_join_request(
scope_type=scope_type,
scope_id=scope_id,
scope_name=facts["scope_name"],
project_id=facts["project_id"],
project_name=facts["project_name"],
requester_user_id=viewer.user_id,
request_id=request_id,
requested_role=role,
message=message,
)
if notif_ids:
db.conn().execute(
"UPDATE join_requests SET notification_id = ? WHERE id = ?",
(notif_ids[0], request_id),
)
return {"id": request_id, "scope_type": scope_type, "scope_id": scope_id, "status": "pending"}
# ---------------------------------------------------------------
# POST — Owner accepts → write the membership row.
# ---------------------------------------------------------------
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/accept")
async def accept_join_request(
scope_type: str, scope_id: str, request_id: int, body: DecideBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
facts = _resolve_scope(scope_type, scope_id)
_require_join_owner(viewer, scope_type, scope_id)
req = _require_request(scope_type, scope_id, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
# The Owner may narrow the requested role on accept; default to what was
# asked for. (Both are within the Owner's grant reach at this scope.)
granted_role = (body.role or req["requested_role"] or "").strip().lower()
if granted_role not in memberships_mod.VALID_ROLES:
raise HTTPException(422, f"invalid role {body.role!r}")
memberships_mod.grant(
scope_type=scope_type,
scope_id=scope_id,
user_id=req["requester_user_id"],
role=granted_role,
granted_by=viewer.user_id,
)
db.conn().execute(
"""
UPDATE join_requests
SET status = 'accepted', decided_at = datetime('now'),
decided_by_user_id = ?, granted_role = ?
WHERE id = ?
""",
(viewer.user_id, granted_role, request_id),
)
notify.notify_join_decided(
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
scope_type=scope_type,
scope_id=scope_id,
scope_name=facts["scope_name"],
granted_role=granted_role,
accepted=True,
)
return {"ok": True, "status": "accepted", "granted_role": granted_role}
# ---------------------------------------------------------------
# POST — Owner declines.
# ---------------------------------------------------------------
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/decline")
async def decline_join_request(
scope_type: str, scope_id: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
facts = _resolve_scope(scope_type, scope_id)
_require_join_owner(viewer, scope_type, scope_id)
req = _require_request(scope_type, scope_id, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
db.conn().execute(
"""
UPDATE join_requests
SET status = 'declined', decided_at = datetime('now'),
decided_by_user_id = ?
WHERE id = ?
""",
(viewer.user_id, request_id),
)
notify.notify_join_decided(
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
scope_type=scope_type,
scope_id=scope_id,
scope_name=facts["scope_name"],
granted_role=None,
accepted=False,
)
return {"ok": True, "status": "declined"}
return router