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>
312 lines
12 KiB
Python
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
|