"""§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