§22 S6: request-to-join + cross-collection inbox (§22.8) — v0.46.0

Ships the request side of joining a gated scope, completing the §22.8 pair
(S4 shipped the invite half). A user who knows a project/collection exists
asks to join it naming a desired role; the request fans out to that scope's
Owners across the subtree (the cross-collection inbox, §22.11), who accept
(writing the memberships row via memberships.grant) or decline. Built by
analogy to §28 contribution_requests + the S4 memberships surface.

Backend
- migration 032: join_requests (scope_type ∈ {project,collection}, scope_id,
  requester, requested_role, message, status, granted_role); one-open-per
  (scope, requester) partial unique index. Additive — no rebuild.
- api_join_requests.py: GET join-target / POST join-requests / POST
  {id}/accept / {id}/decline under /api/scopes/{scope_type}/{scope_id}/.
  Accept grants via memberships.grant; the request POST does not require the
  scope be readable (that is how one joins a gated scope).
- notify: fan_out_join_request (subtree-Owner enumeration via
  _scope_owner_user_ids), notify_join_decided, 3 render_summary cases.
- auth.effective_role_at_scope — scope-grain twin of effective_scope_role,
  folding global → project for a project target.
- api_collections: viewer.can_request_join on the project + collection blocks.

Frontend
- api.js join verbs; JoinRequestModal; "Request to join" affordance in the
  collection directory + catalog footer; JoinRequestRow in the inbox.

Tests: backend test_join_requests_vertical (11) + test_migration_032 (5);
frontend api.joinrequests + CollectionDirectory cases. 546 backend / 36
frontend green.

Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S6) and
SPEC.md §22.8 / §22.11. Closes the request-to-join item flagged open at
0.45.0; per-type surfaces (§22.4a items 1 & 3) remain the last S6 item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-06 02:11:16 -07:00
parent e86fc65643
commit fcc3c84d76
18 changed files with 1379 additions and 6 deletions
+46
View File
@@ -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 release's steps in order — no A-to-B path is pre-computed beyond
that. 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 ## 0.45.0 — 2026-06-06
**Minor (non-breaking) — §22 three-tier refactor, slice S6: *the SPEC merge + **Minor (non-breaking) — §22 three-tier refactor, slice S6: *the SPEC merge +
+1 -1
View File
@@ -1 +1 @@
0.45.0 0.46.0
+5
View File
@@ -27,6 +27,7 @@ from . import (
api_discussion, api_discussion,
api_graduation, api_graduation,
api_invitations, api_invitations,
api_join_requests,
api_memberships, api_memberships,
api_notifications, api_notifications,
api_prs, api_prs,
@@ -158,6 +159,10 @@ def make_router(
# §22 S4 (C.2): the scope-role invitation surface — Owners grant # §22 S4 (C.2): the scope-role invitation surface — Owners grant
# {owner, contributor} at project/collection scope to existing accounts. # {owner, contributor} at project/collection scope to existing accounts.
router.include_router(api_memberships.make_router()) 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. # §17: /api/health — unauthenticated post-flight probe.
+15
View File
@@ -57,6 +57,14 @@ def _project_viewer_caps(viewer: Any, project_id: str) -> dict[str, Any]:
return { return {
"can_create_collection": auth.can_create_collection(viewer, project_id), "can_create_collection": auth.can_create_collection(viewer, project_id),
"can_invite": auth.can_invite_at_project(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, "role": role,
} }
@@ -97,6 +105,13 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
col["viewer"] = { col["viewer"] = {
"can_contribute": auth.can_contribute_in_collection(viewer, collection_id), "can_contribute": auth.can_contribute_in_collection(viewer, collection_id),
"can_invite": auth.can_invite_at_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), "role": auth.effective_scope_role(viewer, collection_id),
} }
return col return col
+311
View File
@@ -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
+36
View File
@@ -574,6 +574,42 @@ def effective_scope_role(user: SessionUser | None, collection_id: str) -> str |
return row["role"] if row else None 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: def collection_visibility(collection_id: str) -> str:
"""The collection's own §22.5 visibility. A missing row reads as 'gated' """The collection's own §22.5 visibility. A missing row reads as 'gated'
an unknown collection is invisible rather than open.""" an unknown collection is invisible rather than open."""
+141
View File
@@ -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( def fan_out_chat_message(
*, *,
actor_user_id: int, 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]: def _proposer_user_id(rfc_slug: str) -> set[int]:
row = db.conn().execute( 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": if scope_type == "global":
return f"{actor} granted you {role_label} across the whole deployment." return f"{actor} granted you {role_label} across the whole deployment."
return f"{actor} granted you {role_label} on project {project_label}." 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": if event_kind == "new_beta_request":
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the # v0.9.0: framework-scoped, not RFC-scoped. The actor (the
# requester) and the captured full name + email read as # requester) and the captured full name + email read as
+58
View File
@@ -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';
@@ -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"
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.45.0", "version": "0.46.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+46
View File
@@ -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')
})
})
+40
View File
@@ -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) { export async function mergeProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' }) const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res) return jsonOrThrow(res)
+20 -1
View File
@@ -10,6 +10,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { listRFCs, listProposals, getCollection } from '../api' import { listRFCs, listProposals, getCollection } from '../api'
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths' import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
import JoinRequestModal from './JoinRequestModal.jsx'
const STATE_CHIPS = [ const STATE_CHIPS = [
{ id: 'super-draft', label: 'Super-draft' }, { 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 // collection's caps load; we fall back to "any authenticated viewer" so the
// common case doesn't flicker, then refine. // common case doesn't flicker, then refine.
const [canContribute, setCanContribute] = useState(null) 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 // §22.4a: the type-driven entry noun ("RFC" | "Spec" | "Feature") for this
// collection, read from the API. Defaults to the generic "RFC" until loaded. // collection, read from the API. Defaults to the generic "RFC" until loaded.
const [entryNoun, setEntryNoun] = useState('RFC') 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([])) listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([])) listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
setCanContribute(null) setCanContribute(null)
setCanRequestJoin(false)
getCollection(pid, cid) getCollection(pid, cid)
.then(c => { .then(c => {
setCanContribute(!!c?.viewer?.can_contribute) setCanContribute(!!c?.viewer?.can_contribute)
setCanRequestJoin(!!c?.viewer?.can_request_join)
setEntryNoun(c?.entry_noun || 'RFC') setEntryNoun(c?.entry_noun || 'RFC')
}) })
.catch(() => setCanContribute(false)) .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 // §22 S4: the propose control is offered only when the viewer may
// contribute to *this* collection (the scope-role gate); a granted // contribute to *this* collection (the scope-role gate); a granted
// viewer with no contribute right in this collection sees nothing. // viewer with no contribute right in this collection sees nothing.
mayPropose && ( mayPropose ? (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New {entryNoun}</button> <button className="btn-propose" onClick={onProposeRFC}>+ Propose New {entryNoun}</button>
) : (
// §22.8: signed in but no contribute right here offer to join.
canRequestJoin && (
<button className="btn-propose" onClick={() => setJoinOpen(true)}>Request to join</button>
)
) )
) : ( ) : (
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose"> <a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
@@ -191,6 +203,13 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
</a> </a>
)} )}
</div> </div>
{joinOpen && (
<JoinRequestModal
scopeType="collection"
scopeId={cid}
onClose={() => setJoinOpen(false)}
/>
)}
</aside> </aside>
) )
} }
@@ -20,6 +20,7 @@ import { collectionHome } from '../lib/entryPaths'
import { entryNoun } from './ProjectLayout.jsx' import { entryNoun } from './ProjectLayout.jsx'
import CreateCollectionModal from './CreateCollectionModal.jsx' import CreateCollectionModal from './CreateCollectionModal.jsx'
import ScopeMembersModal from './ScopeMembersModal.jsx' import ScopeMembersModal from './ScopeMembersModal.jsx'
import JoinRequestModal from './JoinRequestModal.jsx'
export default function CollectionDirectory({ projectId }) { export default function CollectionDirectory({ projectId }) {
const [cols, setCols] = useState(null) const [cols, setCols] = useState(null)
@@ -27,6 +28,7 @@ export default function CollectionDirectory({ projectId }) {
const [version, setVersion] = useState(0) const [version, setVersion] = useState(0)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [membersOpen, setMembersOpen] = useState(false) const [membersOpen, setMembersOpen] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
useEffect(() => { useEffect(() => {
let live = true let live = true
@@ -46,6 +48,7 @@ export default function CollectionDirectory({ projectId }) {
const canCreate = !!viewer?.can_create_collection const canCreate = !!viewer?.can_create_collection
const canInvite = !!viewer?.can_invite const canInvite = !!viewer?.can_invite
const canRequestJoin = !!viewer?.can_request_join
return ( return (
<main className="chrome-pane"> <main className="chrome-pane">
@@ -56,6 +59,9 @@ export default function CollectionDirectory({ projectId }) {
{canInvite && ( {canInvite && (
<button className="btn-link" onClick={() => setMembersOpen(true)}>Members</button> <button className="btn-link" onClick={() => setMembersOpen(true)}>Members</button>
)} )}
{canRequestJoin && (
<button className="btn-link" onClick={() => setJoinOpen(true)}>Request to join</button>
)}
{canCreate && cols.length > 0 && ( {canCreate && cols.length > 0 && (
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New collection</button> <button className="btn-primary" onClick={() => setCreateOpen(true)}>New collection</button>
)} )}
@@ -101,6 +107,13 @@ export default function CollectionDirectory({ projectId }) {
onClose={() => setMembersOpen(false)} onClose={() => setMembersOpen(false)}
/> />
)} )}
{joinOpen && (
<JoinRequestModal
scopeType="project"
scopeId={projectId}
onClose={() => setJoinOpen(false)}
/>
)}
</main> </main>
) )
} }
@@ -4,15 +4,20 @@ import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom' import { MemoryRouter, Routes, Route } from 'react-router-dom'
let mockItems = [] let mockItems = []
let mockViewer = null
vi.mock('../api', () => ({ 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' import CollectionDirectory from './CollectionDirectory.jsx'
beforeEach(() => { mockItems = [] }) beforeEach(() => { mockItems = []; mockViewer = null })
function renderDir(items) { function renderDir(items, viewer = null) {
mockItems = items mockItems = items
mockViewer = viewer
return render( return render(
<MemoryRouter initialEntries={["/p/ohm/"]}> <MemoryRouter initialEntries={["/p/ohm/"]}>
<Routes> <Routes>
@@ -45,4 +50,27 @@ describe('CollectionDirectory', () => {
renderDir([]) renderDir([])
await waitFor(() => expect(screen.getByText('No collections yet.')).toBeInTheDocument()) 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()
})
}) })
+67
View File
@@ -12,7 +12,9 @@ import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
acceptContributionRequest, acceptContributionRequest,
acceptJoinRequest,
declineContributionRequest, declineContributionRequest,
declineJoinRequest,
listNotifications, listNotifications,
markNotificationRead, markNotificationRead,
markNotificationsReadByFilter, 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 (
<li className={`inbox-row inbox-row-action ${unread ? 'unread' : 'read'}`}>
<div className="inbox-row-main">
<span className="inbox-unread-dot" aria-hidden />
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
<span className="inbox-summary">{item.summary}</span>
<span className="inbox-when">{formatWhen(item.created_at)}</span>
</div>
<div className="inbox-request-detail">
<p><strong>Requested role:</strong> {roleLabel}</p>
{x.message && <p><strong>Message:</strong> {x.message}</p>}
</div>
{error && <p className="field-error">{error}</p>}
{outcome ? (
<p className="inbox-request-outcome muted">
{outcome === 'accepted'
? `Accepted — they're now a member as ${roleLabel}.`
: 'Declined.'}
</p>
) : (
<div className="inbox-request-actions">
<button type="button" className="btn-primary" disabled={busy || !x.request_id} onClick={() => act(true)}>
Accept
</button>
<button type="button" className="btn-secondary" disabled={busy || !x.request_id} onClick={() => act(false)}>
Decline
</button>
</div>
)}
</li>
)
}
function InboxRow({ item, onClick, onMarkRead, onClose }) { function InboxRow({ item, onClick, onMarkRead, onClose }) {
const pid = useProjectId() const pid = useProjectId()
if (item.event_kind === 'contribution_request_on_pending_rfc') { if (item.event_kind === 'contribution_request_on_pending_rfc') {
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} /> return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
} }
if (item.event_kind === 'join_request_on_scope') {
return <JoinRequestRow item={item} onMarkRead={onMarkRead} />
}
const unread = !item.read_at const unread = !item.read_at
const target = deepLink(item, pid) const target = deepLink(item, pid)
const handle = async () => { const handle = async () => {
@@ -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 (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal" style={{ maxWidth: 520 }}>
<div className="modal-header">
<h2>Request to join</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
{done ? (
<p>
Your request to join {where} <strong>{label}</strong> has been sent to
its Owners. You'll get an inbox notification when it's decided.
</p>
) : target && !target.eligible ? (
<p style={{ color: '#666' }}>
{target.already_requested
? `You already have a pending request to join ${where} ${label}.`
: (target.reason || 'You cannot request to join this scope.')}
</p>
) : (
<form onSubmit={handleSubmit} className="invitations-form">
<p style={{ marginTop: 0, color: '#666' }}>
Ask the Owners of {where} <strong>{label}</strong> for a role. An{' '}
<strong>RFC Contributor</strong> may propose entries; an{' '}
<strong>Owner</strong> administers the scope.
</p>
<label htmlFor="join-role">Role</label>
<select id="join-role" value={role} onChange={e => setRole(e.target.value)}>
{ROLE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<label htmlFor="join-message" style={{ marginTop: 10 }}>
Message <span style={{ color: '#999' }}>(optional)</span>
</label>
<textarea
id="join-message"
value={message}
onChange={e => setMessage(e.target.value)}
rows={3}
placeholder="Who you are and why you'd like to join."
maxLength={4000}
/>
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Sending…' : 'Send request'}
</button>
{error && <span style={{ color: '#c33' }}>{error}</span>}
</div>
</form>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn-link" onClick={onClose}>
{done ? 'Close' : 'Cancel'}
</button>
</div>
</div>
</div>
)
}