§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:
@@ -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 +
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user