§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:
@@ -392,6 +392,95 @@ def notify_contribution_decided(
|
||||
)
|
||||
|
||||
|
||||
def fan_out_join_request(
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
scope_name: str | None,
|
||||
project_id: str | None,
|
||||
project_name: str | None,
|
||||
requester_user_id: int,
|
||||
request_id: int,
|
||||
requested_role: str,
|
||||
message: str | None,
|
||||
) -> list[int]:
|
||||
"""§22.8: a user asked to join a scope. Land one actionable notification per
|
||||
Owner across the scope's subtree (the cross-collection inbox, §22.11) and
|
||||
return their ids (the caller stamps the first onto the request row as the
|
||||
inbox-action handle — any of them can act on it).
|
||||
|
||||
Personal-direct: each Owner is a named subject able to act, so it rides the
|
||||
`email_personal_direct` gate like the other owner-facing personal events. The
|
||||
requested role + message ride in the payload so the inbox row shows the full
|
||||
ask inline. Actor is the requester per §15.9.
|
||||
"""
|
||||
requester = db.conn().execute(
|
||||
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
|
||||
).fetchone()
|
||||
display = (requester["display_name"] if requester else None) or "Someone"
|
||||
details = {
|
||||
"request_id": request_id,
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"scope_name": scope_name or scope_id,
|
||||
"project_id": project_id or "",
|
||||
"project_name": project_name or "",
|
||||
"requested_role": requested_role,
|
||||
"requester_user_id": requester_user_id,
|
||||
"requester_display": display,
|
||||
"message": message or "",
|
||||
}
|
||||
notif_ids: list[int] = []
|
||||
for recipient_id in _scope_owner_user_ids(scope_type, scope_id):
|
||||
if recipient_id == requester_user_id:
|
||||
continue
|
||||
notif_ids.append(
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind="join_request_on_scope",
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=requester_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
return notif_ids
|
||||
|
||||
|
||||
def notify_join_decided(
|
||||
*,
|
||||
requester_user_id: int,
|
||||
decider_user_id: int,
|
||||
request_id: int,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
scope_name: str | None,
|
||||
granted_role: str | None,
|
||||
accepted: bool,
|
||||
) -> None:
|
||||
"""§22.8: tell the requester an Owner accepted (writing their `memberships`
|
||||
row) or declined their request to join. The scope + granted role ride in the
|
||||
payload so the inbox row names where they were let in without a second fetch."""
|
||||
_emit_one(
|
||||
recipient_user_id=requester_user_id,
|
||||
event_kind=("join_request_accepted" if accepted else "join_request_declined"),
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=decider_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details={
|
||||
"request_id": request_id,
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"scope_name": scope_name or scope_id,
|
||||
"granted_role": granted_role or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def fan_out_chat_message(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
@@ -667,6 +756,41 @@ def _admin_user_ids() -> set[int]:
|
||||
}
|
||||
|
||||
|
||||
def _scope_owner_user_ids(scope_type: str, scope_id: str) -> set[int]:
|
||||
"""The Owners who administer a scope *across the subtree* (§22.8 / §22.11) —
|
||||
the recipients of a request-to-join, aggregated upward so the request reaches
|
||||
everyone who could grant it. For a `collection`: its collection-scope Owners,
|
||||
its project's Owners, the global Owners, and deployment owners/admins. For a
|
||||
`project`: its project-scope Owners plus global Owners and deployment
|
||||
owners/admins. (Mirrors the upward fold in `auth.can_invite_at_*`.)"""
|
||||
# Deployment owners/admins are global Owners by §B.1; explicit
|
||||
# scope_type='global' Owner grants join them.
|
||||
ids: set[int] = set(_admin_user_ids())
|
||||
for r in db.conn().execute(
|
||||
"SELECT user_id AS id FROM memberships WHERE scope_type = 'global' AND role = 'owner'"
|
||||
):
|
||||
ids.add(r["id"])
|
||||
|
||||
def _owners_at(stype: str, sid: str) -> None:
|
||||
for r in db.conn().execute(
|
||||
"SELECT user_id AS id FROM memberships "
|
||||
"WHERE scope_type = ? AND scope_id = ? AND role = 'owner'",
|
||||
(stype, sid),
|
||||
):
|
||||
ids.add(r["id"])
|
||||
|
||||
if scope_type == "collection":
|
||||
_owners_at("collection", scope_id)
|
||||
prow = db.conn().execute(
|
||||
"SELECT project_id FROM collections WHERE id = ?", (scope_id,)
|
||||
).fetchone()
|
||||
if prow and prow["project_id"]:
|
||||
_owners_at("project", prow["project_id"])
|
||||
elif scope_type == "project":
|
||||
_owners_at("project", scope_id)
|
||||
return ids
|
||||
|
||||
|
||||
def _proposer_user_id(rfc_slug: str) -> set[int]:
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
@@ -914,6 +1038,23 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
||||
if scope_type == "global":
|
||||
return f"{actor} granted you {role_label} across the whole deployment."
|
||||
return f"{actor} granted you {role_label} on project {project_label}."
|
||||
if event_kind == "join_request_on_scope":
|
||||
# §22.8: owner-facing, actionable. Names who wants in, where, and as
|
||||
# what; the inbox row renders Accept/Decline beneath this line.
|
||||
role_label = "Owner" if extras.get("requested_role") == "owner" else "RFC Contributor"
|
||||
scope_type = extras.get("scope_type")
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "a scope"
|
||||
where = (
|
||||
f"collection {scope_label}" if scope_type == "collection" else f"project {scope_label}"
|
||||
)
|
||||
return f"{actor} asked to join {where} as {role_label}."
|
||||
if event_kind == "join_request_accepted":
|
||||
role_label = "Owner" if extras.get("granted_role") == "owner" else "RFC Contributor"
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
|
||||
return f"{actor} accepted your request to join {scope_label} — you're in as {role_label}."
|
||||
if event_kind == "join_request_declined":
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
|
||||
return f"{actor} declined your request to join {scope_label}."
|
||||
if event_kind == "new_beta_request":
|
||||
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||
# requester) and the captured full name + email read as
|
||||
|
||||
Reference in New Issue
Block a user