diff --git a/CHANGELOG.md b/CHANGELOG.md index 463ac15..e0eea8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,52 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.46.0 — 2026-06-06 + +**Minor (non-breaking) — §22 three-tier refactor, slice S6 (remainder): +*request-to-join + the cross-collection inbox (§22.8).* S4 shipped the invite +half of joining a gated scope (an Owner grants a role directly); this ships the +other half — a user who knows a scope exists asks to join it, naming a desired +role, and the request is fanned out to that scope's Owners across the subtree +(the cross-collection inbox, §22.11), who accept (writing the `memberships` row) +or decline. Purely additive: one new table (no rebuild), one new endpoint group, +and inbox/affordance UI; no change to existing read or write semantics.** + +See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md) +(Part E slice S6) and `SPEC.md` §22.8 / §22.11. This closes the **request-to-join** +item flagged open at 0.45.0; the per-type *surfaces* (§22.4a items 1 & 3) remain +the last S6 item, still pending a discovery/spec pass. + +Added: + +- **Request-to-join a scope (§22.8)** — `join_requests` (migration 032), a + scope-grain analogue of `contribution_requests`: a `(scope_type ∈ + {project,collection}, scope_id, requester, requested_role, message, status)` + row, one-open-per-`(scope, requester)`. New endpoints under + `/api/scopes/{scope_type}/{scope_id}/`: `GET join-target`, `POST + join-requests`, and the Owner's `POST .../{id}/accept` / `.../{id}/decline`. + Accept writes the membership via `memberships.grant` (the §22.8 "accepting + writes the membership row"); the request POST does **not** require the scope be + readable — that is how one joins a *gated* scope they were told about. +- **The cross-collection inbox (§22.11)** — a join request fans one actionable + §15 notification to every Owner whose reach covers the scope (collection + Owners + project Owners + global Owners + deployment owners/admins), so it + surfaces in the one deployment-wide inbox of anyone who can grant it. New + event kinds `join_request_on_scope` (owner-facing, Accept/Decline inline) and + `join_request_accepted` / `join_request_declined` (requester-facing). +- **Frontend** — a "Request to join" affordance in the project collection + directory and the per-collection catalog footer (shown when the viewer is + signed in, granted, and holds no role reaching the scope — the new + `viewer.can_request_join` flag), a `JoinRequestModal`, and the actionable + `JoinRequestRow` in the inbox. +- **`auth.effective_role_at_scope(user, scope_type, scope_id)`** — the + scope-grain twin of `effective_scope_role` (which keys on a collection), + folding global → project for a project target; drives the "already a member?" + gate and the `can_request_join` flag. + +No upgrade steps: migration 032 is additive (a new table; no rebuild, no FK +changes to existing tables), and no env var or config changes are required. + ## 0.45.0 — 2026-06-06 **Minor (non-breaking) — §22 three-tier refactor, slice S6: *the SPEC merge + diff --git a/VERSION b/VERSION index bcce5d0..3010923 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.0 +0.46.0 diff --git a/backend/app/api.py b/backend/app/api.py index 78dceeb..e0cb986 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -27,6 +27,7 @@ from . import ( api_discussion, api_graduation, api_invitations, + api_join_requests, api_memberships, api_notifications, api_prs, @@ -158,6 +159,10 @@ def make_router( # §22 S4 (C.2): the scope-role invitation surface — Owners grant # {owner, contributor} at project/collection scope to existing accounts. router.include_router(api_memberships.make_router()) + # §22.8 S6: request-to-join + the cross-collection inbox — a user asks into a + # scope (naming a role); the scope's Owners across the subtree accept (writing + # the membership row) or decline. + router.include_router(api_join_requests.make_router()) # --------------------------------------------------------------- # §17: /api/health — unauthenticated post-flight probe. diff --git a/backend/app/api_collections.py b/backend/app/api_collections.py index 61132ad..d95b5ef 100644 --- a/backend/app/api_collections.py +++ b/backend/app/api_collections.py @@ -57,6 +57,14 @@ def _project_viewer_caps(viewer: Any, project_id: str) -> dict[str, Any]: return { "can_create_collection": auth.can_create_collection(viewer, project_id), "can_invite": auth.can_invite_at_project(viewer, project_id), + # §22.8: a signed-in, granted account with no role at the project may ask + # to join it (the request-to-join affordance). Owners/members and + # not-yet-granted accounts don't see it. + "can_request_join": ( + viewer is not None + and viewer.permission_state == "granted" + and auth.effective_role_at_scope(viewer, "project", project_id) is None + ), "role": role, } @@ -97,6 +105,13 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: col["viewer"] = { "can_contribute": auth.can_contribute_in_collection(viewer, collection_id), "can_invite": auth.can_invite_at_collection(viewer, collection_id), + # §22.8: a signed-in, granted account with no role reaching this + # collection may ask to join it. + "can_request_join": ( + viewer is not None + and viewer.permission_state == "granted" + and auth.effective_scope_role(viewer, collection_id) is None + ), "role": auth.effective_scope_role(viewer, collection_id), } return col diff --git a/backend/app/api_join_requests.py b/backend/app/api_join_requests.py new file mode 100644 index 0000000..aa0e307 --- /dev/null +++ b/backend/app/api_join_requests.py @@ -0,0 +1,311 @@ +"""§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 diff --git a/backend/app/auth.py b/backend/app/auth.py index 0c70c45..c7ab37f 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -574,6 +574,42 @@ def effective_scope_role(user: SessionUser | None, collection_id: str) -> str | return row["role"] if row else None +def _effective_project_role(user: SessionUser | None, project_id: str) -> str | None: + """The most-permissive role the user holds *over a project* — folding the + global tier (deployment owner/admin, or a `scope_type='global'` grant) and a + `scope_type='project'` grant on this project. Unlike `effective_scope_role` + (which keys on a collection), this answers the project grain directly, for the + §22.8 request-to-join membership check. Subject to the §6 admission floor.""" + if user is None or user.permission_state != "granted": + return None + if user.role in _DEPLOYMENT_SUPERUSER_ROLES: + return "owner" + row = db.conn().execute( + "SELECT role FROM memberships " + "WHERE user_id = ? AND (" + " scope_type = 'global'" + " OR (scope_type = 'project' AND scope_id = ?)) " + "ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1", + (user.user_id, project_id), + ).fetchone() + return row["role"] if row else None + + +def effective_role_at_scope( + user: SessionUser | None, scope_type: str, scope_id: str +) -> str | None: + """The most-permissive scope role the user holds over a `(scope_type, + scope_id)` target — the scope-grain twin of `effective_scope_role`. A + `collection` target folds global → project → collection (the existing + resolver); a `project` target folds global → project. Returns None when no + grant reaches the scope. Drives the §22.8 "already a member?" gate.""" + if scope_type == "collection": + return effective_scope_role(user, scope_id) + if scope_type == "project": + return _effective_project_role(user, scope_id) + return None + + def collection_visibility(collection_id: str) -> str: """The collection's own §22.5 visibility. A missing row reads as 'gated' — an unknown collection is invisible rather than open.""" diff --git a/backend/app/notify.py b/backend/app/notify.py index 4427158..ae99fb7 100644 --- a/backend/app/notify.py +++ b/backend/app/notify.py @@ -392,6 +392,95 @@ def notify_contribution_decided( ) +def fan_out_join_request( + *, + scope_type: str, + scope_id: str, + scope_name: str | None, + project_id: str | None, + project_name: str | None, + requester_user_id: int, + request_id: int, + requested_role: str, + message: str | None, +) -> list[int]: + """§22.8: a user asked to join a scope. Land one actionable notification per + Owner across the scope's subtree (the cross-collection inbox, §22.11) and + return their ids (the caller stamps the first onto the request row as the + inbox-action handle — any of them can act on it). + + Personal-direct: each Owner is a named subject able to act, so it rides the + `email_personal_direct` gate like the other owner-facing personal events. The + requested role + message ride in the payload so the inbox row shows the full + ask inline. Actor is the requester per §15.9. + """ + requester = db.conn().execute( + "SELECT display_name FROM users WHERE id = ?", (requester_user_id,) + ).fetchone() + display = (requester["display_name"] if requester else None) or "Someone" + details = { + "request_id": request_id, + "scope_type": scope_type, + "scope_id": scope_id, + "scope_name": scope_name or scope_id, + "project_id": project_id or "", + "project_name": project_name or "", + "requested_role": requested_role, + "requester_user_id": requester_user_id, + "requester_display": display, + "message": message or "", + } + notif_ids: list[int] = [] + for recipient_id in _scope_owner_user_ids(scope_type, scope_id): + if recipient_id == requester_user_id: + continue + notif_ids.append( + _emit_one( + recipient_user_id=recipient_id, + event_kind="join_request_on_scope", + category=CATEGORY_PERSONAL, + actor_user_id=requester_user_id, + rfc_slug=None, + branch_name=None, + pr_number=None, + details=details, + ) + ) + return notif_ids + + +def notify_join_decided( + *, + requester_user_id: int, + decider_user_id: int, + request_id: int, + scope_type: str, + scope_id: str, + scope_name: str | None, + granted_role: str | None, + accepted: bool, +) -> None: + """§22.8: tell the requester an Owner accepted (writing their `memberships` + row) or declined their request to join. The scope + granted role ride in the + payload so the inbox row names where they were let in without a second fetch.""" + _emit_one( + recipient_user_id=requester_user_id, + event_kind=("join_request_accepted" if accepted else "join_request_declined"), + category=CATEGORY_PERSONAL, + actor_user_id=decider_user_id, + rfc_slug=None, + branch_name=None, + pr_number=None, + details={ + "request_id": request_id, + "scope_type": scope_type, + "scope_id": scope_id, + "scope_name": scope_name or scope_id, + "granted_role": granted_role or "", + }, + ) + + def fan_out_chat_message( *, actor_user_id: int, @@ -667,6 +756,41 @@ def _admin_user_ids() -> set[int]: } +def _scope_owner_user_ids(scope_type: str, scope_id: str) -> set[int]: + """The Owners who administer a scope *across the subtree* (§22.8 / §22.11) — + the recipients of a request-to-join, aggregated upward so the request reaches + everyone who could grant it. For a `collection`: its collection-scope Owners, + its project's Owners, the global Owners, and deployment owners/admins. For a + `project`: its project-scope Owners plus global Owners and deployment + owners/admins. (Mirrors the upward fold in `auth.can_invite_at_*`.)""" + # Deployment owners/admins are global Owners by §B.1; explicit + # scope_type='global' Owner grants join them. + ids: set[int] = set(_admin_user_ids()) + for r in db.conn().execute( + "SELECT user_id AS id FROM memberships WHERE scope_type = 'global' AND role = 'owner'" + ): + ids.add(r["id"]) + + def _owners_at(stype: str, sid: str) -> None: + for r in db.conn().execute( + "SELECT user_id AS id FROM memberships " + "WHERE scope_type = ? AND scope_id = ? AND role = 'owner'", + (stype, sid), + ): + ids.add(r["id"]) + + if scope_type == "collection": + _owners_at("collection", scope_id) + prow = db.conn().execute( + "SELECT project_id FROM collections WHERE id = ?", (scope_id,) + ).fetchone() + if prow and prow["project_id"]: + _owners_at("project", prow["project_id"]) + elif scope_type == "project": + _owners_at("project", scope_id) + return ids + + def _proposer_user_id(rfc_slug: str) -> set[int]: row = db.conn().execute( """ @@ -914,6 +1038,23 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str | if scope_type == "global": return f"{actor} granted you {role_label} across the whole deployment." return f"{actor} granted you {role_label} on project {project_label}." + if event_kind == "join_request_on_scope": + # §22.8: owner-facing, actionable. Names who wants in, where, and as + # what; the inbox row renders Accept/Decline beneath this line. + role_label = "Owner" if extras.get("requested_role") == "owner" else "RFC Contributor" + scope_type = extras.get("scope_type") + scope_label = extras.get("scope_name") or extras.get("scope_id") or "a scope" + where = ( + f"collection {scope_label}" if scope_type == "collection" else f"project {scope_label}" + ) + return f"{actor} asked to join {where} as {role_label}." + if event_kind == "join_request_accepted": + role_label = "Owner" if extras.get("granted_role") == "owner" else "RFC Contributor" + scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope" + return f"{actor} accepted your request to join {scope_label} — you're in as {role_label}." + if event_kind == "join_request_declined": + scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope" + return f"{actor} declined your request to join {scope_label}." if event_kind == "new_beta_request": # v0.9.0: framework-scoped, not RFC-scoped. The actor (the # requester) and the captured full name + email read as diff --git a/backend/migrations/032_join_requests.sql b/backend/migrations/032_join_requests.sql new file mode 100644 index 0000000..3780ec3 --- /dev/null +++ b/backend/migrations/032_join_requests.sql @@ -0,0 +1,58 @@ +-- §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 a user +-- who knows a scope exists can ask to join it: they name a desired role and the +-- request is recorded here, then fanned out to that scope's Owners *across the +-- subtree* (a collection request reaches the collection's Owners, its project's +-- Owners, and global Owners — the cross-collection inbox, §22.11). An Owner +-- accepts (which writes the `memberships` row via memberships.grant) or declines; +-- the requester is §15-notified of the decision either way. +-- +-- This mirrors `contribution_requests` (migration 024) but at the scope grain +-- instead of the per-RFC grain: the target is a `(scope_type, scope_id)` pair +-- (matching the `memberships` scope vocabulary, minus 'global' — joining is for a +-- project or collection a user discovers, not the deployment), and accept grants +-- a scope role rather than minting an RFC invitation. +-- +-- The request row is the persistent record; the inbox notification is the +-- owner-facing actionable surface keyed back to it via `notification_id`. + +CREATE TABLE IF NOT EXISTS join_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- The target scope. 'global' is intentionally excluded: the deployment is + -- not a thing one "discovers and joins" (§22.8 names a project/collection). + scope_type TEXT NOT NULL + CHECK (scope_type IN ('project', 'collection')), + scope_id TEXT NOT NULL, + requester_user_id INTEGER NOT NULL + REFERENCES users(id) ON DELETE CASCADE, + -- The role the requester is asking for ({owner, contributor}, the §22.6 + -- unified vocabulary). The accepting Owner may grant this or a narrower role. + requested_role TEXT NOT NULL + CHECK (requested_role IN ('owner', 'contributor')), + -- Optional free text — "who I am / why I want in". Bounded by the API layer. + message TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'declined')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + decided_at TEXT, + decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + -- The role actually granted on accept (may differ from requested_role if the + -- Owner narrowed it); NULL until accepted. + granted_role TEXT CHECK (granted_role IN ('owner', 'contributor')), + -- The owner-facing notification row that carries the Accept/Decline action. + notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_join_requests_scope + ON join_requests(scope_type, scope_id, status); + +CREATE INDEX IF NOT EXISTS idx_join_requests_requester + ON join_requests(requester_user_id, status); + +-- At most one open (pending) request per (scope, requester): a second ask while +-- one is still pending is a 409, not a duplicate row. A decided request +-- (accepted/declined) does not block a fresh ask later. +CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_one_open + ON join_requests(scope_type, scope_id, requester_user_id) + WHERE status = 'pending'; diff --git a/backend/tests/test_join_requests_vertical.py b/backend/tests/test_join_requests_vertical.py new file mode 100644 index 0000000..74e915d --- /dev/null +++ b/backend/tests/test_join_requests_vertical.py @@ -0,0 +1,339 @@ +"""§22.8 S6 — request-to-join a scope + the cross-collection inbox. + +A user who knows a (gated) scope exists asks to join it, naming a desired role; +the request is recorded and fanned out to that scope's Owners *across the +subtree* (the cross-collection inbox, §22.11). An Owner accepts — which writes +the `memberships` row via memberships.grant — or declines, and the requester is +§15-notified either way. + +Built by analogy to test_contributions_vertical.py (the per-RFC contribute flow) +and test_s4_invitations_vertical.py (the scope/membership world-builders). + +World: project "ohm" owns collections "model" (document, gated) and "features" +(bdd, gated). eve is project Owner; dan is collection Owner of features only; +zoe is a global Owner; ada is a deployment admin. ben is a plain granted account +(no scope role) — the would-be joiner. +""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app import db + +from test_propose_vertical import ( # noqa: F401 — fixtures land via import + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) + + +# --------------------------------------------------------------------------- +# World-builders (mirror the S4 vertical) +# --------------------------------------------------------------------------- + + +def _project(pid: str, visibility: str = "gated", content_repo: str = "meta") -> None: + db.conn().execute( + "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) " + "VALUES (?, ?, ?, ?, datetime('now'))", + (pid, pid.capitalize(), content_repo, visibility), + ) + + +def _collection(cid: str, project_id: str, *, ctype: str = "document", + visibility: str = "gated") -> None: + db.conn().execute( + "INSERT OR REPLACE INTO collections " + "(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) " + "VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))", + (cid, project_id, ctype, cid, visibility, cid.capitalize()), + ) + + +def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None: + db.conn().execute( + "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES (?, ?, ?, ?)", + (scope_type, scope_id, user_id, role), + ) + + +def _membership(user_id: int): + rows = db.conn().execute( + "SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?", + (user_id,), + ).fetchall() + return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows} + + +def _join_requests(scope_type: str, scope_id: str): + rows = db.conn().execute( + "SELECT id, requester_user_id, requested_role, status, granted_role " + "FROM join_requests WHERE scope_type = ? AND scope_id = ?", + (scope_type, scope_id), + ).fetchall() + return [dict(r) for r in rows] + + +def _join_notif_recipients(event_kind: str = "join_request_on_scope") -> set[int]: + return { + r["recipient_user_id"] + for r in db.conn().execute( + "SELECT recipient_user_id FROM notifications WHERE event_kind = ?", + (event_kind,), + ) + } + + +def _seed_world() -> None: + _project("ohm", "gated") + _collection("model", "ohm", ctype="document") + _collection("features", "ohm", ctype="bdd") + provision_user_row(user_id=2, login="ben", role="contributor") # the joiner + provision_user_row(user_id=4, login="dan", role="contributor") # collection Owner (features) + provision_user_row(user_id=5, login="eve", role="contributor") # project Owner + provision_user_row(user_id=6, login="zoe", role="contributor") # global Owner + provision_user_row(user_id=7, login="ada", role="admin") # deployment admin + _grant("project", "ohm", 5, "owner") + _grant("collection", "features", 4, "owner") + _grant("global", "*", 6, "owner") + + +def _login(client, uid: int, login: str, role: str = "contributor") -> None: + sign_in_as(client, user_id=uid, gitea_login=login, display_name=login.capitalize(), role=role) + + +# --------------------------------------------------------------------------- +# Request → cross-collection fan-out +# --------------------------------------------------------------------------- + + +def test_request_to_join_collection_fans_out_to_subtree_owners(app_with_fake_gitea): + """A request to join a collection lands a row and notifies every Owner whose + reach covers it — the collection's Owner, the project's Owner, a global + Owner, and the deployment admin (the cross-collection inbox) — never the + requester.""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + _login(client, 2, "ben") + r = client.post( + "/api/scopes/collection/features/join-requests", + json={"role": "contributor", "message": "I work on BDD corpora."}, + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "pending" + + reqs = _join_requests("collection", "features") + assert len(reqs) == 1 + assert reqs[0]["requester_user_id"] == 2 + assert reqs[0]["requested_role"] == "contributor" + assert reqs[0]["status"] == "pending" + + # Owners across the subtree are notified; ben (requester) is not. + recips = _join_notif_recipients() + assert {4, 5, 6, 7}.issubset(recips) # dan, eve, zoe, ada + assert 2 not in recips + + +def test_request_to_join_project_reaches_project_and_global_owners(app_with_fake_gitea): + """A project-scope request reaches the project's Owners + global Owners + + admin, but NOT a collection-only Owner (their reach doesn't cover the + project).""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + _login(client, 2, "ben") + r = client.post( + "/api/scopes/project/ohm/join-requests", + json={"role": "owner"}, + ) + assert r.status_code == 200, r.text + recips = _join_notif_recipients() + assert {5, 6, 7}.issubset(recips) # eve (project), zoe (global), ada (admin) + assert 4 not in recips # dan is only a collection Owner + + +# --------------------------------------------------------------------------- +# Accept → writes membership + notifies +# --------------------------------------------------------------------------- + + +def test_owner_accept_writes_membership_and_notifies(app_with_fake_gitea): + """The collection Owner accepts; a `memberships` row is written at the + requested scope/role and the requester gets a join_request_accepted inbox + row.""" + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + _login(client, 2, "ben") + client.post( + "/api/scopes/collection/features/join-requests", + json={"role": "contributor"}, + ) + req_id = _join_requests("collection", "features")[0]["id"] + + # dan (collection Owner of features) accepts. + _login(client, 4, "dan") + r = client.post( + f"/api/scopes/collection/features/join-requests/{req_id}/accept", + json={}, + ) + assert r.status_code == 200, r.text + assert r.json()["granted_role"] == "contributor" + + # ben now holds the collection role and can contribute there. + assert ("collection", "features", "contributor") in _membership(2) + ben = auth.SessionUser( + user_id=2, gitea_id=2, gitea_login="ben", display_name="Ben", + email="ben@test", avatar_url="", role="contributor", permission_state="granted", + ) + assert auth.can_contribute_in_collection(ben, "features") is True + assert auth.can_contribute_in_collection(ben, "model") is False + + # the row is closed; the requester is notified. + assert _join_requests("collection", "features")[0]["status"] == "accepted" + _login(client, 2, "ben") + inbox = client.get("/api/notifications").json()["items"] + accepted = [n for n in inbox if n["event_kind"] == "join_request_accepted"] + assert accepted, inbox + assert "Features" in accepted[0]["summary"] + + +def test_owner_may_narrow_role_on_accept(app_with_fake_gitea): + """A request for Owner may be accepted as RFC Contributor — the Owner narrows + the grant; the membership row carries the granted (not requested) role.""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + _login(client, 2, "ben") + client.post("/api/scopes/collection/features/join-requests", json={"role": "owner"}) + req_id = _join_requests("collection", "features")[0]["id"] + + _login(client, 5, "eve") # project Owner — reach covers the collection + r = client.post( + f"/api/scopes/collection/features/join-requests/{req_id}/accept", + json={"role": "contributor"}, + ) + assert r.status_code == 200, r.text + assert ("collection", "features", "contributor") in _membership(2) + assert _join_requests("collection", "features")[0]["granted_role"] == "contributor" + + +def test_owner_decline_notifies_and_grants_nothing(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + _login(client, 2, "ben") + client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"}) + req_id = _join_requests("collection", "features")[0]["id"] + + _login(client, 4, "dan") + r = client.post(f"/api/scopes/collection/features/join-requests/{req_id}/decline") + assert r.status_code == 200, r.text + assert _membership(2) == set() + assert _join_requests("collection", "features")[0]["status"] == "declined" + + _login(client, 2, "ben") + inbox = client.get("/api/notifications").json()["items"] + assert any(n["event_kind"] == "join_request_declined" for n in inbox) + + +# --------------------------------------------------------------------------- +# Gates & guards +# --------------------------------------------------------------------------- + + +def test_duplicate_pending_request_is_conflict(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + _login(client, 2, "ben") + r1 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"}) + assert r1.status_code == 200, r1.text + r2 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"}) + assert r2.status_code == 409, r2.text + + +def test_existing_member_cannot_request(app_with_fake_gitea): + """dan already owns the collection — there is nothing to request (409).""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + _login(client, 4, "dan") + r = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"}) + assert r.status_code == 409, r.text + + +def test_non_owner_cannot_accept(app_with_fake_gitea): + """A plain requester (or any non-Owner) is refused the accept action.""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + _login(client, 2, "ben") + client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"}) + req_id = _join_requests("collection", "features")[0]["id"] + # provision a second plain account that tries to accept + provision_user_row(user_id=12, login="mal", role="contributor") + _login(client, 12, "mal") + r = client.post( + f"/api/scopes/collection/features/join-requests/{req_id}/accept", json={} + ) + assert r.status_code == 403, r.text + assert _membership(2) == set() + + +def test_collection_owner_cannot_act_on_sibling_collection(app_with_fake_gitea): + """dan owns 'features' only; a request to join 'model' is not his to act on.""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + _login(client, 2, "ben") + client.post("/api/scopes/collection/model/join-requests", json={"role": "contributor"}) + req_id = _join_requests("collection", "model")[0]["id"] + _login(client, 4, "dan") + r = client.post( + f"/api/scopes/collection/model/join-requests/{req_id}/accept", json={} + ) + assert r.status_code == 403, r.text + + +def test_unknown_scope_404(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + _login(client, 2, "ben") + assert client.post( + "/api/scopes/collection/nope/join-requests", json={"role": "contributor"} + ).status_code == 404 + assert client.post( + "/api/scopes/project/nope/join-requests", json={"role": "contributor"} + ).status_code == 404 + # 'global' is not a join-able scope_type. + assert client.post( + "/api/scopes/global/*/join-requests", json={"role": "contributor"} + ).status_code == 404 + + +def test_join_target_reports_eligibility(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + # ben: eligible (granted, no role). + _login(client, 2, "ben") + t = client.get("/api/scopes/collection/features/join-target").json() + assert t["eligible"] is True + assert t["name"] == "Features" + assert t["current_role"] is None + # after requesting, already_requested flips and eligible drops. + client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"}) + t2 = client.get("/api/scopes/collection/features/join-target").json() + assert t2["already_requested"] is True + assert t2["eligible"] is False + # dan: already a member → ineligible with current_role. + _login(client, 4, "dan") + t3 = client.get("/api/scopes/collection/features/join-target").json() + assert t3["eligible"] is False + assert t3["current_role"] == "owner" diff --git a/backend/tests/test_migration_032_join_requests.py b/backend/tests/test_migration_032_join_requests.py new file mode 100644 index 0000000..8d165b4 --- /dev/null +++ b/backend/tests/test_migration_032_join_requests.py @@ -0,0 +1,95 @@ +"""Migration 032 — the join_requests table (§22.8 S6). + +Proves: the table exists with its CHECK constraints (scope_type ∈ +{project,collection}; role/status enums), the one-open-per-(scope,user) partial +unique index holds, and a decided request frees a fresh ask. +Template: test_migration_030_global_scope.py. +""" +from __future__ import annotations + +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +from app import db + + +class _Cfg: + def __init__(self, path): + self.database_path = path + + +def _fresh_db(): + d = tempfile.mkdtemp() + path = Path(d) / "t.db" + db.run_migrations(_Cfg(str(path))) + return db.connect(str(path)) + + +def _add_user(conn, uid, login): + conn.execute( + "INSERT INTO users (id, gitea_id, gitea_login, display_name, role) " + "VALUES (?, ?, ?, ?, 'contributor')", + (uid, uid, login, login.capitalize()), + ) + + +def _request(conn, scope_type="collection", scope_id="features", uid=1, role="contributor"): + conn.execute( + "INSERT INTO join_requests (scope_type, scope_id, requester_user_id, requested_role) " + "VALUES (?, ?, ?, ?)", + (scope_type, scope_id, uid, role), + ) + + +def test_join_request_row_round_trips(): + conn = _fresh_db() + _add_user(conn, 1, "ben") + _request(conn) + row = conn.execute("SELECT * FROM join_requests WHERE requester_user_id = 1").fetchone() + assert row["scope_type"] == "collection" + assert row["requested_role"] == "contributor" + assert row["status"] == "pending" + assert row["granted_role"] is None + + +def test_global_scope_type_is_rejected(): + conn = _fresh_db() + _add_user(conn, 1, "ben") + with pytest.raises(sqlite3.IntegrityError): + _request(conn, scope_type="global", scope_id="*") + + +def test_bad_role_and_status_rejected(): + conn = _fresh_db() + _add_user(conn, 1, "ben") + with pytest.raises(sqlite3.IntegrityError): + _request(conn, role="viewer") + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO join_requests (scope_type, scope_id, requester_user_id, requested_role, status) " + "VALUES ('project', 'ohm', 1, 'owner', 'maybe')" + ) + + +def test_one_open_request_per_scope_user(): + conn = _fresh_db() + _add_user(conn, 1, "ben") + _request(conn) + with pytest.raises(sqlite3.IntegrityError): + _request(conn) + + +def test_decided_request_frees_a_fresh_ask(): + conn = _fresh_db() + _add_user(conn, 1, "ben") + _request(conn) + conn.execute("UPDATE join_requests SET status = 'declined' WHERE requester_user_id = 1") + # a second open ask is now allowed + _request(conn) + n = conn.execute( + "SELECT COUNT(*) AS n FROM join_requests WHERE requester_user_id = 1" + ).fetchone()["n"] + assert n == 2 diff --git a/frontend/package.json b/frontend/package.json index 3046176..8a8b588 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.45.0", + "version": "0.46.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.joinrequests.test.js b/frontend/src/api.joinrequests.test.js new file mode 100644 index 0000000..adefe09 --- /dev/null +++ b/frontend/src/api.joinrequests.test.js @@ -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') + }) +}) diff --git a/frontend/src/api.js b/frontend/src/api.js index 213a9c3..f0535ce 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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) diff --git a/frontend/src/components/Catalog.jsx b/frontend/src/components/Catalog.jsx index fe7dbfd..3c0b2ac 100644 --- a/frontend/src/components/Catalog.jsx +++ b/frontend/src/components/Catalog.jsx @@ -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 ? ( + ) : ( + // §22.8: signed in but no contribute right here — offer to join. + canRequestJoin && ( + + ) ) ) : ( @@ -191,6 +203,13 @@ export default function Catalog({ viewer, onProposeRFC, version }) { )} + {joinOpen && ( + setJoinOpen(false)} + /> + )} ) } diff --git a/frontend/src/components/CollectionDirectory.jsx b/frontend/src/components/CollectionDirectory.jsx index bc5835e..ef49d3e 100644 --- a/frontend/src/components/CollectionDirectory.jsx +++ b/frontend/src/components/CollectionDirectory.jsx @@ -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 (
@@ -56,6 +59,9 @@ export default function CollectionDirectory({ projectId }) { {canInvite && ( )} + {canRequestJoin && ( + + )} {canCreate && cols.length > 0 && ( )} @@ -101,6 +107,13 @@ export default function CollectionDirectory({ projectId }) { onClose={() => setMembersOpen(false)} /> )} + {joinOpen && ( + setJoinOpen(false)} + /> + )}
) } diff --git a/frontend/src/components/CollectionDirectory.test.jsx b/frontend/src/components/CollectionDirectory.test.jsx index ccc10b2..c75137c 100644 --- a/frontend/src/components/CollectionDirectory.test.jsx +++ b/frontend/src/components/CollectionDirectory.test.jsx @@ -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( @@ -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() + }) }) diff --git a/frontend/src/components/Inbox.jsx b/frontend/src/components/Inbox.jsx index 98211b0..55846eb 100644 --- a/frontend/src/components/Inbox.jsx +++ b/frontend/src/components/Inbox.jsx @@ -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 ( +
  • +
    + + {item.category || '·'} + {item.summary} + {formatWhen(item.created_at)} +
    +
    +

    Requested role: {roleLabel}

    + {x.message &&

    Message: {x.message}

    } +
    + {error &&

    {error}

    } + {outcome ? ( +

    + {outcome === 'accepted' + ? `Accepted — they're now a member as ${roleLabel}.` + : 'Declined.'} +

    + ) : ( +
    + + +
    + )} +
  • + ) +} + function InboxRow({ item, onClick, onMarkRead, onClose }) { const pid = useProjectId() if (item.event_kind === 'contribution_request_on_pending_rfc') { return } + if (item.event_kind === 'join_request_on_scope') { + return + } const unread = !item.read_at const target = deepLink(item, pid) const handle = async () => { diff --git a/frontend/src/components/JoinRequestModal.jsx b/frontend/src/components/JoinRequestModal.jsx new file mode 100644 index 0000000..3a6c026 --- /dev/null +++ b/frontend/src/components/JoinRequestModal.jsx @@ -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 ( +
    { if (e.target === e.currentTarget) onClose() }}> +
    +
    +

    Request to join

    + +
    +
    + {done ? ( +

    + Your request to join {where} {label} has been sent to + its Owners. You'll get an inbox notification when it's decided. +

    + ) : target && !target.eligible ? ( +

    + {target.already_requested + ? `You already have a pending request to join ${where} ${label}.` + : (target.reason || 'You cannot request to join this scope.')} +

    + ) : ( +
    +

    + Ask the Owners of {where} {label} for a role. An{' '} + RFC Contributor may propose entries; an{' '} + Owner administers the scope. +

    + + + +