fcc3c84d76
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>
1320 lines
49 KiB
Python
1320 lines
49 KiB
Python
"""§15: the notifications fan-out and the SSE broadcaster.
|
||
|
||
This module is the single chokepoint for the producer side of §15.
|
||
Every write that names an `rfc_slug` flows through `fan_out_from_action`
|
||
(called from `bot._log`) or `fan_out_chat_message` (called from the chat
|
||
write paths in `api_branches` / `api_prs`, since chat messages don't go
|
||
through the bot wrapper). The chokepoint:
|
||
|
||
- upserts a `watches` row for the actor per §15.6's auto-watch rule
|
||
(a substantive gesture sets `watching`, never downgrades, and bumps
|
||
`last_participation_at` for the 90-day decay);
|
||
- applies the §15.1 routing rules to determine which other users
|
||
receive a notification for the event;
|
||
- filters out muted recipients per §15.8 (per-user mute list);
|
||
- filters out `watches.state='muted'` per §15.6 (per-RFC mute);
|
||
- inserts one `notifications` row per recipient with the underlying
|
||
actor's user id per §15.9 (the bot never appears as actor);
|
||
- dispatches the row to the §15.4 email path if the recipient's
|
||
category toggle is on (held during quiet hours per §15.8, deferred
|
||
to the digest otherwise);
|
||
- publishes each new row to the per-user SSE subscriber queue so the
|
||
inbox refresh and header-badge count back the same stream per §15.3.
|
||
|
||
If you find yourself wanting to insert a row into `notifications`
|
||
directly from an endpoint, the spec is right and you are wrong: the
|
||
chokepoint is the invariant. Read paths can query the table from
|
||
anywhere; the producer side flows through here.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Iterable
|
||
|
||
from . import db
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §15.1 routing — action_kind → (event_kind, category, recipient_set)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Three recipient sets are used by the routing rules below:
|
||
#
|
||
# "watchers" — every user with a watches row on this slug whose state
|
||
# is 'watching' (full stream) or 'following' (structural
|
||
# only), minus the actor and minus 'muted' rows. The
|
||
# structural/full split is applied per-event below.
|
||
# "owners" — every user named in the entry's owners_json frontmatter
|
||
# on cached_rfcs (resolved to user_ids via gitea_login).
|
||
# "proposer" — the user whose action_kind=propose_rfc opened the slug,
|
||
# resolved via the most recent matching audit row.
|
||
#
|
||
# The category of each event maps to one of the §15.4 buckets:
|
||
#
|
||
# "personal-direct" — recipient is named subject of the action.
|
||
# "structural" — structural beats on watched RFCs.
|
||
# "churn" — per-commit / per-message activity. Never emails
|
||
# per §15.4; aggregated by the §15.5 digest.
|
||
|
||
CATEGORY_PERSONAL = "personal-direct"
|
||
CATEGORY_STRUCTURAL = "structural"
|
||
CATEGORY_CHURN = "churn"
|
||
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
|
||
|
||
# Action kinds whose actor's first interaction with a slug triggers
|
||
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
||
# the source; we map onto our action_kind taxonomy. Reads, view advances,
|
||
# and own-action observations are intentionally excluded.
|
||
_AUTO_WATCH_ACTIONS = {
|
||
"propose_rfc",
|
||
"merge_proposal",
|
||
"decline_proposal",
|
||
"create_branch",
|
||
"accept_change",
|
||
"decline_change",
|
||
"manual_flush",
|
||
"open_branch_pr",
|
||
"merge_branch_pr",
|
||
"withdraw_branch_pr",
|
||
"supersede_branch_pr",
|
||
"open_metadata_pr",
|
||
"open_claim_pr",
|
||
"merge_claim_pr",
|
||
"create_resolution_branch",
|
||
"replay_change",
|
||
"graduate_start",
|
||
"graduate_complete",
|
||
"open_review_thread",
|
||
"resolve_thread",
|
||
"drop_flag",
|
||
"resolve_flag",
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SSE broadcaster — per-user subscriber registry
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class _Subscriber:
|
||
user_id: int
|
||
queue: asyncio.Queue = field(default_factory=asyncio.Queue)
|
||
|
||
|
||
_subscribers: dict[int, list[_Subscriber]] = {}
|
||
_subscribers_lock: asyncio.Lock | None = None
|
||
|
||
|
||
def _get_lock() -> asyncio.Lock:
|
||
global _subscribers_lock
|
||
if _subscribers_lock is None:
|
||
_subscribers_lock = asyncio.Lock()
|
||
return _subscribers_lock
|
||
|
||
|
||
async def subscribe(user_id: int) -> _Subscriber:
|
||
"""Register a new SSE subscriber for a user. Each browser tab gets its
|
||
own subscriber; the per-user fan-out pushes the same event to every
|
||
subscriber for that user per §15 (multiple tabs see the same updates)."""
|
||
sub = _Subscriber(user_id=user_id)
|
||
async with _get_lock():
|
||
_subscribers.setdefault(user_id, []).append(sub)
|
||
return sub
|
||
|
||
|
||
async def unsubscribe(sub: _Subscriber) -> None:
|
||
async with _get_lock():
|
||
bucket = _subscribers.get(sub.user_id, [])
|
||
if sub in bucket:
|
||
bucket.remove(sub)
|
||
if not bucket:
|
||
_subscribers.pop(sub.user_id, None)
|
||
|
||
|
||
async def _broadcast(user_id: int, event: str, payload: Any) -> None:
|
||
"""Push an event to every subscriber for one user.
|
||
|
||
Best-effort; if the queue is unbounded a slow consumer just sees the
|
||
backlog when it next drains. We deliberately do not block writers on
|
||
consumer presence — the durable inbox row is the source of truth."""
|
||
async with _get_lock():
|
||
subs = list(_subscribers.get(user_id, []))
|
||
for sub in subs:
|
||
try:
|
||
sub.queue.put_nowait({"event": event, "payload": payload})
|
||
except Exception:
|
||
log.exception("notify broadcast: drop event for user %s", user_id)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Producer-side entry points
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def fan_out_from_action(
|
||
*,
|
||
actor_user_id: int | None,
|
||
action_kind: str,
|
||
rfc_slug: str | None,
|
||
branch_name: str | None,
|
||
pr_number: int | None,
|
||
details: dict | None,
|
||
) -> None:
|
||
"""Called from `bot._log` after every successful Git write.
|
||
|
||
Synchronous on purpose — runs inside the same request as the action,
|
||
so a failure here surfaces with the action rather than as a silent
|
||
drop. The SSE push is asyncio-scheduled if a loop is running; if
|
||
no loop is available (test harness, sync test client) the dispatch
|
||
falls back to writing the row and relying on the next subscriber
|
||
poll to surface it.
|
||
"""
|
||
if rfc_slug is None or action_kind not in _AUTO_WATCH_ACTIONS:
|
||
# Actions without an rfc_slug (e.g. permission events) or that
|
||
# aren't a §15.6 substantive gesture don't drive the chokepoint.
|
||
return
|
||
|
||
if actor_user_id is not None:
|
||
_bump_auto_watch(actor_user_id, rfc_slug)
|
||
|
||
rules = _ROUTING.get(action_kind)
|
||
if rules is None:
|
||
return
|
||
|
||
for rule in rules:
|
||
recipients = _resolve_recipients(
|
||
rule=rule,
|
||
actor_user_id=actor_user_id,
|
||
rfc_slug=rfc_slug,
|
||
details=details,
|
||
)
|
||
if not recipients:
|
||
continue
|
||
for recipient_id in recipients:
|
||
_emit_one(
|
||
recipient_user_id=recipient_id,
|
||
event_kind=rule["event_kind"],
|
||
category=rule["category"],
|
||
actor_user_id=actor_user_id,
|
||
rfc_slug=rfc_slug,
|
||
branch_name=branch_name,
|
||
pr_number=pr_number,
|
||
details=details or {},
|
||
)
|
||
|
||
|
||
def fan_out_new_beta_request(
|
||
*,
|
||
requester_user_id: int,
|
||
) -> None:
|
||
"""v0.9.0 (roadmap item #7): announce a fresh beta-access request to
|
||
every admin/owner.
|
||
|
||
Called from `POST /api/auth/me/beta-request` after the row's
|
||
first/last/why fields are populated. Fan-out shape mirrors the §15
|
||
chokepoint contract: one row per recipient, written via `_emit_one`
|
||
so the SSE broadcast + email dispatch run through the same surface
|
||
every other notification uses. The event has no rfc_slug (it is
|
||
framework-scoped, not RFC-scoped); the deep-link payload points
|
||
`/admin/users` instead of `/rfc/<slug>`.
|
||
|
||
Actor is the requester per §15.9 (the underlying user, never the
|
||
bot). Category is `admin-actionable` so the §15.4 email gate
|
||
consults `email_admin_actionable` (owners/admins-only by
|
||
construction) and the digest exclusion rules treat it identically
|
||
to other admin-actionable signals (graduation_ready et al).
|
||
|
||
Recipients are owners + admins minus the requester themselves
|
||
(a self-promotion shouldn't reach the requester's own inbox). The
|
||
requester is never in the role set in practice — the endpoint
|
||
refuses 'granted'/'revoked' callers and a fresh OTC user lands
|
||
`contributor`+`pending` — but we filter regardless so the call
|
||
is robust to future changes in the auth gate.
|
||
"""
|
||
requester = db.conn().execute(
|
||
"SELECT first_name, last_name, email, display_name FROM users WHERE id = ?",
|
||
(requester_user_id,),
|
||
).fetchone()
|
||
if requester is None:
|
||
return
|
||
first = (requester["first_name"] or "").strip()
|
||
last = (requester["last_name"] or "").strip()
|
||
email = requester["email"] or ""
|
||
display = requester["display_name"] or email or "a new user"
|
||
full_name = (f"{first} {last}").strip() or display
|
||
details = {
|
||
"requester_user_id": requester_user_id,
|
||
"requester_first_name": first,
|
||
"requester_last_name": last,
|
||
"requester_email": email,
|
||
"requester_display": full_name,
|
||
}
|
||
for recipient_id in _admin_user_ids():
|
||
if recipient_id == requester_user_id:
|
||
continue
|
||
_emit_one(
|
||
recipient_user_id=recipient_id,
|
||
event_kind="new_beta_request",
|
||
category=CATEGORY_ADMIN_ACTIONABLE,
|
||
actor_user_id=requester_user_id,
|
||
rfc_slug=None,
|
||
branch_name=None,
|
||
pr_number=None,
|
||
details=details,
|
||
)
|
||
|
||
|
||
def fan_out_contribution_request(
|
||
*,
|
||
rfc_slug: str,
|
||
requester_user_id: int,
|
||
request_id: int,
|
||
matched_term: str,
|
||
who_i_am: str,
|
||
why: str,
|
||
use_case: str | None,
|
||
) -> list[int]:
|
||
"""Roadmap #28 Part 3: a reader asked to contribute to a pending
|
||
(super-draft) RFC. Land one actionable notification per owner and
|
||
return their ids (the caller stamps the first onto the request row as
|
||
the inbox-action handle).
|
||
|
||
Personal-direct: the owner is the named subject of the request, so the
|
||
§15.4 email gate consults `email_personal_direct` exactly as for the
|
||
other owner-facing personal events — no new preference column is
|
||
needed. The request's three free-text fields ride along in the payload
|
||
so the inbox row can show the full ask inline without a second fetch.
|
||
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,
|
||
"matched_term": matched_term,
|
||
"requester_user_id": requester_user_id,
|
||
"requester_display": display,
|
||
"who_i_am": who_i_am,
|
||
"why": why,
|
||
"use_case": use_case or "",
|
||
}
|
||
notif_ids: list[int] = []
|
||
for recipient_id in _entry_owner_user_ids(rfc_slug):
|
||
if recipient_id == requester_user_id:
|
||
continue
|
||
notif_ids.append(
|
||
_emit_one(
|
||
recipient_user_id=recipient_id,
|
||
event_kind="contribution_request_on_pending_rfc",
|
||
category=CATEGORY_PERSONAL,
|
||
actor_user_id=requester_user_id,
|
||
rfc_slug=rfc_slug,
|
||
branch_name=None,
|
||
pr_number=None,
|
||
details=details,
|
||
)
|
||
)
|
||
return notif_ids
|
||
|
||
|
||
def notify_scope_role_granted(
|
||
*,
|
||
recipient_user_id: int,
|
||
granter_user_id: int | None,
|
||
scope_type: str,
|
||
scope_id: str,
|
||
role: str,
|
||
project_id: str | None,
|
||
project_name: str | None,
|
||
collection_name: str | None,
|
||
) -> int | None:
|
||
"""§22 S4 (C.2): a scope Owner granted `recipient` a role at a scope.
|
||
Personal-direct — the recipient is the named subject — so it rides the
|
||
`email_personal_direct` gate like the other owner-facing personal events.
|
||
The scope facts ride in the payload so the inbox row (and email body) names
|
||
the project and role without a second fetch. Actor is the granter (§15.9);
|
||
a system/administrative grant with no granter renders as "the app".
|
||
|
||
Returns the notification id, or None when the grantee would be notifying
|
||
themselves (a self-grant — no notification)."""
|
||
if granter_user_id is not None and recipient_user_id == granter_user_id:
|
||
return None
|
||
details = {
|
||
"scope_type": scope_type,
|
||
"scope_id": scope_id,
|
||
"role": role,
|
||
"project_id": project_id or "",
|
||
"project_name": project_name or "",
|
||
"collection_name": collection_name or "",
|
||
}
|
||
return _emit_one(
|
||
recipient_user_id=recipient_user_id,
|
||
event_kind="scope_role_granted",
|
||
category=CATEGORY_PERSONAL,
|
||
actor_user_id=granter_user_id,
|
||
rfc_slug=None,
|
||
branch_name=None,
|
||
pr_number=None,
|
||
details=details,
|
||
)
|
||
|
||
|
||
def notify_contribution_decided(
|
||
*,
|
||
rfc_slug: str,
|
||
requester_user_id: int,
|
||
decider_user_id: int,
|
||
request_id: int,
|
||
accepted: bool,
|
||
) -> None:
|
||
"""Roadmap #28 Part 3: tell the requester an owner accepted or declined
|
||
their contribute request. On accept the requester also receives the
|
||
#12 invitation email out-of-band; this inbox row is the in-app echo
|
||
that points them at it."""
|
||
_emit_one(
|
||
recipient_user_id=requester_user_id,
|
||
event_kind=(
|
||
"contribution_request_accepted" if accepted else "contribution_request_declined"
|
||
),
|
||
category=CATEGORY_PERSONAL,
|
||
actor_user_id=decider_user_id,
|
||
rfc_slug=rfc_slug,
|
||
branch_name=None,
|
||
pr_number=None,
|
||
details={"request_id": request_id},
|
||
)
|
||
|
||
|
||
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,
|
||
rfc_slug: str,
|
||
branch_name: str | None,
|
||
thread_id: int,
|
||
message_id: int,
|
||
is_review_thread: bool = False,
|
||
pr_number: int | None = None,
|
||
) -> None:
|
||
"""Called from chat write paths after a user message is persisted.
|
||
|
||
Chat messages don't flow through bot.py (no Git write); the chokepoint
|
||
here is the equivalent for §15's chat-message events. The routing
|
||
rule is: prior authors in the thread (besides the message author) get
|
||
a personal-direct `chat_reply_to_my_message`; users watching the RFC
|
||
(state='watching', i.e. full stream) get a churn-class
|
||
`chat_message_in_participated_thread`. The two are union'd so a user
|
||
who is both gets only the personal-direct row.
|
||
|
||
v0.5.0: `branch_name` may be None — that is the PR-less per-RFC
|
||
discussion shape (`threads.branch_name IS NULL`, §5). The
|
||
notifications row carries the null through; the inbox prose renders
|
||
identically whether the chat lives on a branch or on the RFC's
|
||
discussion surface, and the §15.7 reconciler keys on
|
||
(rfc_slug, branch_name) so a null branch correctly matches the
|
||
PR-less discussion's eventual chat-seen-equivalent advance.
|
||
"""
|
||
_bump_auto_watch(actor_user_id, rfc_slug)
|
||
|
||
prior_authors = {
|
||
row["author_user_id"]
|
||
for row in db.conn().execute(
|
||
"""
|
||
SELECT DISTINCT author_user_id
|
||
FROM thread_messages
|
||
WHERE thread_id = ?
|
||
AND author_user_id IS NOT NULL
|
||
AND author_user_id != ?
|
||
AND id < ?
|
||
""",
|
||
(thread_id, actor_user_id, message_id),
|
||
)
|
||
}
|
||
|
||
# §15.6 / §15.8 filters apply here too — a muted recipient never sees
|
||
# a row regardless of which producer path generated it.
|
||
muted_rfc = _muted_user_ids_for_rfc(rfc_slug)
|
||
muters = _muters_of(actor_user_id)
|
||
prior_authors = (prior_authors - muted_rfc) - muters
|
||
|
||
# Personal-direct: prior thread authors.
|
||
for recipient_id in prior_authors:
|
||
event_kind = (
|
||
"pr_review_thread_reply" if is_review_thread else "chat_reply_to_my_message"
|
||
)
|
||
_emit_one(
|
||
recipient_user_id=recipient_id,
|
||
event_kind=event_kind,
|
||
category=CATEGORY_PERSONAL,
|
||
actor_user_id=actor_user_id,
|
||
rfc_slug=rfc_slug,
|
||
branch_name=branch_name,
|
||
pr_number=pr_number,
|
||
thread_id=thread_id,
|
||
details={"message_id": message_id},
|
||
)
|
||
|
||
# Churn: watchers of the RFC who weren't already covered.
|
||
watcher_ids = _watcher_user_ids(rfc_slug, scope="watching") - prior_authors
|
||
watcher_ids.discard(actor_user_id)
|
||
watcher_ids = (watcher_ids - muted_rfc) - muters
|
||
for recipient_id in watcher_ids:
|
||
event_kind = (
|
||
"pr_review_thread_new" if is_review_thread
|
||
else "chat_message_in_participated_thread"
|
||
)
|
||
_emit_one(
|
||
recipient_user_id=recipient_id,
|
||
event_kind=event_kind,
|
||
category=CATEGORY_CHURN,
|
||
actor_user_id=actor_user_id,
|
||
rfc_slug=rfc_slug,
|
||
branch_name=branch_name,
|
||
pr_number=pr_number,
|
||
thread_id=thread_id,
|
||
details={"message_id": message_id},
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Routing rules — per action_kind, a list of recipient×event×category
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
_ROUTING: dict[str, list[dict]] = {
|
||
# §9.1: propose surfaces the new slug. The proposer is the actor;
|
||
# the personal-direct beats fire on merge/decline, not on propose
|
||
# itself. Admins and owners get a structural ping so the
|
||
# pending-ideas queue isn't invisible.
|
||
"propose_rfc": [
|
||
{"event_kind": "proposal_opened_on_watched_topic",
|
||
"category": CATEGORY_STRUCTURAL, "recipients": "admins"},
|
||
],
|
||
# §9.3: merge fires personal-direct to the proposer per §15.4's
|
||
# named-subject rule. Watchers also see the structural beat.
|
||
"merge_proposal": [
|
||
{"event_kind": "proposal_merged",
|
||
"category": CATEGORY_PERSONAL, "recipients": "proposer"},
|
||
{"event_kind": "proposal_merged",
|
||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||
],
|
||
"decline_proposal": [
|
||
{"event_kind": "proposal_declined",
|
||
"category": CATEGORY_PERSONAL, "recipients": "proposer"},
|
||
],
|
||
# §10.1: PR opened. Watchers see it; the opener auto-watches but
|
||
# doesn't get an inbox row for their own gesture.
|
||
"open_branch_pr": [
|
||
{"event_kind": "pr_opened",
|
||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||
],
|
||
"open_metadata_pr": [
|
||
{"event_kind": "pr_opened",
|
||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||
],
|
||
"open_claim_pr": [
|
||
{"event_kind": "claim_opened",
|
||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||
],
|
||
# §10.5: merge fires personal-direct to the PR opener if someone
|
||
# else merged; watchers see the structural beat regardless.
|
||
"merge_branch_pr": [
|
||
{"event_kind": "pr_merged",
|
||
"category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"},
|
||
{"event_kind": "pr_merged",
|
||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||
],
|
||
"withdraw_branch_pr": [
|
||
{"event_kind": "pr_withdrawn",
|
||
"category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"},
|
||
{"event_kind": "pr_withdrawn",
|
||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||
],
|
||
"supersede_branch_pr": [
|
||
{"event_kind": "pr_withdrawn",
|
||
"category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"},
|
||
],
|
||
# §8.6: accept_change is per-commit churn. Watchers (full stream)
|
||
# get the row; following-only watchers don't.
|
||
"accept_change": [
|
||
{"event_kind": "pr_commit_added",
|
||
"category": CATEGORY_CHURN, "recipients": "watchers_full"},
|
||
],
|
||
"manual_flush": [
|
||
{"event_kind": "pr_commit_added",
|
||
"category": CATEGORY_CHURN, "recipients": "watchers_full"},
|
||
],
|
||
"replay_change": [
|
||
{"event_kind": "pr_commit_added",
|
||
"category": CATEGORY_CHURN, "recipients": "watchers_full"},
|
||
],
|
||
# §13.3: graduation_complete is personal-direct to owners (the
|
||
# super-draft's owners list) and structural to watchers.
|
||
"graduate_complete": [
|
||
{"event_kind": "graduation_complete",
|
||
"category": CATEGORY_PERSONAL, "recipients": "owners"},
|
||
{"event_kind": "graduation_complete",
|
||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||
],
|
||
"graduate_start": [
|
||
{"event_kind": "super_draft_graduation_ready",
|
||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||
],
|
||
"create_resolution_branch": [
|
||
{"event_kind": "pr_conflict_with_main",
|
||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||
],
|
||
"create_branch": [
|
||
# No notification — branch creation is a private gesture until
|
||
# work lands on it. Auto-watch is the visible effect.
|
||
],
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Recipient resolvers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _resolve_recipients(
|
||
*,
|
||
rule: dict,
|
||
actor_user_id: int | None,
|
||
rfc_slug: str,
|
||
details: dict | None,
|
||
) -> set[int]:
|
||
kind = rule["recipients"]
|
||
if kind == "watchers_structural":
|
||
# following (structural only) + watching (full stream) — both
|
||
# see structural events.
|
||
ids = _watcher_user_ids(rfc_slug, scope="any")
|
||
elif kind == "watchers_full":
|
||
ids = _watcher_user_ids(rfc_slug, scope="watching")
|
||
elif kind == "owners":
|
||
ids = _entry_owner_user_ids(rfc_slug)
|
||
elif kind == "admins":
|
||
ids = _admin_user_ids()
|
||
elif kind == "proposer":
|
||
ids = _proposer_user_id(rfc_slug)
|
||
elif kind == "pr_opener_if_other":
|
||
opener = _pr_opener_user_id(rfc_slug, details)
|
||
ids = {opener} if opener is not None and opener != actor_user_id else set()
|
||
else:
|
||
log.warning("notify: unknown recipient kind %s", kind)
|
||
return set()
|
||
|
||
if actor_user_id is not None:
|
||
ids.discard(actor_user_id)
|
||
|
||
# §15.6: per-RFC mute suppresses every signal for the (user, slug).
|
||
muted = _muted_user_ids_for_rfc(rfc_slug)
|
||
ids -= muted
|
||
|
||
# §15.8: per-user mute suppresses notifications produced by the actor
|
||
# for each muter. (Inbox-volume only; content visibility unchanged.)
|
||
if actor_user_id is not None:
|
||
muters = _muters_of(actor_user_id)
|
||
ids -= muters
|
||
|
||
return ids
|
||
|
||
|
||
def _watcher_user_ids(rfc_slug: str, *, scope: str) -> set[int]:
|
||
"""scope: 'watching' = full stream only; 'any' = watching+following."""
|
||
if scope == "watching":
|
||
rows = db.conn().execute(
|
||
"SELECT user_id FROM watches WHERE rfc_slug = ? AND state = 'watching'",
|
||
(rfc_slug,),
|
||
)
|
||
else:
|
||
rows = db.conn().execute(
|
||
"SELECT user_id FROM watches WHERE rfc_slug = ? AND state IN ('watching', 'following')",
|
||
(rfc_slug,),
|
||
)
|
||
return {r["user_id"] for r in rows}
|
||
|
||
|
||
def _entry_owner_user_ids(rfc_slug: str) -> set[int]:
|
||
row = db.conn().execute(
|
||
"SELECT owners_json FROM cached_rfcs WHERE slug = ?", (rfc_slug,),
|
||
).fetchone()
|
||
if row is None:
|
||
return set()
|
||
try:
|
||
owners = json.loads(row["owners_json"] or "[]")
|
||
except json.JSONDecodeError:
|
||
return set()
|
||
if not owners:
|
||
return set()
|
||
placeholders = ",".join("?" * len(owners))
|
||
user_rows = db.conn().execute(
|
||
f"SELECT id FROM users WHERE gitea_login IN ({placeholders})",
|
||
tuple(owners),
|
||
)
|
||
return {r["id"] for r in user_rows}
|
||
|
||
|
||
def _admin_user_ids() -> set[int]:
|
||
return {
|
||
r["id"]
|
||
for r in db.conn().execute("SELECT id FROM users WHERE role IN ('owner', 'admin')")
|
||
}
|
||
|
||
|
||
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(
|
||
"""
|
||
SELECT actor_user_id FROM actions
|
||
WHERE action_kind = 'propose_rfc' AND rfc_slug = ?
|
||
ORDER BY id LIMIT 1
|
||
""",
|
||
(rfc_slug,),
|
||
).fetchone()
|
||
if row is None or row["actor_user_id"] is None:
|
||
return set()
|
||
return {row["actor_user_id"]}
|
||
|
||
|
||
def _pr_opener_user_id(rfc_slug: str, details: dict | None) -> int | None:
|
||
"""For pr_opener_if_other recipients, find the user who opened the PR.
|
||
|
||
Uses cached_prs.opened_by (gitea_login) → users.id, falling back to
|
||
the audit log if the cache row isn't there yet.
|
||
"""
|
||
pr_number = (details or {}).get("pr_number")
|
||
# When called from merge/withdraw routing, details is the original
|
||
# action's details — pr_number is on the audit row, not here. Pull
|
||
# it from the most-recent relevant action.
|
||
row = db.conn().execute(
|
||
"""
|
||
SELECT actor_user_id FROM actions
|
||
WHERE action_kind = 'open_branch_pr' AND rfc_slug = ?
|
||
ORDER BY id DESC LIMIT 1
|
||
""",
|
||
(rfc_slug,),
|
||
).fetchone()
|
||
if row and row["actor_user_id"] is not None:
|
||
return row["actor_user_id"]
|
||
return pr_number # last-ditch: keep typing consistent (rarely matters)
|
||
|
||
|
||
def _muted_user_ids_for_rfc(rfc_slug: str) -> set[int]:
|
||
return {
|
||
r["user_id"]
|
||
for r in db.conn().execute(
|
||
"SELECT user_id FROM watches WHERE rfc_slug = ? AND state = 'muted'",
|
||
(rfc_slug,),
|
||
)
|
||
}
|
||
|
||
|
||
def _muters_of(actor_user_id: int) -> set[int]:
|
||
return {
|
||
r["muter_user_id"]
|
||
for r in db.conn().execute(
|
||
"SELECT muter_user_id FROM notification_user_mutes WHERE muted_user_id = ?",
|
||
(actor_user_id,),
|
||
)
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Auto-watch upsert
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _bump_auto_watch(user_id: int, rfc_slug: str) -> None:
|
||
"""Per §15.6 substantive-gesture rule: upsert a `watching` row for
|
||
the actor, never downgrade. Bumps `last_participation_at` for the
|
||
90-day decay timer regardless of current state.
|
||
|
||
The role-implicit watching for owners/arbiters is computed at fan-out
|
||
time (recipient resolution), not stored on the row, so this upsert
|
||
doesn't need to special-case them.
|
||
"""
|
||
existing = db.conn().execute(
|
||
"SELECT state, set_by FROM watches WHERE user_id = ? AND rfc_slug = ?",
|
||
(user_id, rfc_slug),
|
||
).fetchone()
|
||
now = "datetime('now')"
|
||
if existing is None:
|
||
db.conn().execute(
|
||
f"""
|
||
INSERT INTO watches
|
||
(user_id, rfc_slug, state, set_by, set_at, last_participation_at)
|
||
VALUES (?, ?, 'watching', 'auto', {now}, {now})
|
||
""",
|
||
(user_id, rfc_slug),
|
||
)
|
||
return
|
||
# Don't downgrade: a 'muted' explicit setting stays muted; a 'watching'
|
||
# row stays watching. The only auto-upgrade is following → watching.
|
||
if existing["state"] == "following" and existing["set_by"] != "explicit":
|
||
db.conn().execute(
|
||
f"UPDATE watches SET state = 'watching', last_participation_at = {now} WHERE user_id = ? AND rfc_slug = ?",
|
||
(user_id, rfc_slug),
|
||
)
|
||
else:
|
||
db.conn().execute(
|
||
f"UPDATE watches SET last_participation_at = {now} WHERE user_id = ? AND rfc_slug = ?",
|
||
(user_id, rfc_slug),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Inserting a notification row + dispatch
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _emit_one(
|
||
*,
|
||
recipient_user_id: int,
|
||
event_kind: str,
|
||
category: str,
|
||
actor_user_id: int | None,
|
||
rfc_slug: str | None,
|
||
branch_name: str | None,
|
||
pr_number: int | None,
|
||
details: dict,
|
||
thread_id: int | None = None,
|
||
) -> int:
|
||
payload = {"category": category, **details}
|
||
cur = db.conn().execute(
|
||
"""
|
||
INSERT INTO notifications
|
||
(recipient_user_id, event_kind, rfc_slug, branch_name, pr_number,
|
||
thread_id, actor_user_id, payload)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
recipient_user_id,
|
||
event_kind,
|
||
rfc_slug,
|
||
branch_name,
|
||
pr_number,
|
||
thread_id,
|
||
actor_user_id,
|
||
json.dumps(payload),
|
||
),
|
||
)
|
||
notif_id = cur.lastrowid
|
||
|
||
row_payload = _row_payload(notif_id)
|
||
_schedule_broadcast(recipient_user_id, "notification", row_payload)
|
||
|
||
# Email dispatch: §15.4. Held during quiet hours per §15.8; held
|
||
# during a global opt-out (bounce) per §15.4. Defers to the digest
|
||
# otherwise per §15.5's exclusion rules.
|
||
_schedule_email(notif_id, recipient_user_id, event_kind, category, row_payload)
|
||
|
||
return notif_id
|
||
|
||
|
||
def _row_payload(notif_id: int) -> dict:
|
||
row = db.conn().execute(
|
||
"""
|
||
SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number,
|
||
n.thread_id, n.actor_user_id, n.payload, n.created_at, n.read_at,
|
||
u.display_name AS actor_display, u.gitea_login AS actor_login,
|
||
r.title AS rfc_title
|
||
FROM notifications n
|
||
LEFT JOIN users u ON u.id = n.actor_user_id
|
||
LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug
|
||
WHERE n.id = ?
|
||
""",
|
||
(notif_id,),
|
||
).fetchone()
|
||
if row is None:
|
||
return {}
|
||
try:
|
||
extras = json.loads(row["payload"] or "{}")
|
||
except json.JSONDecodeError:
|
||
extras = {}
|
||
return {
|
||
"id": row["id"],
|
||
"event_kind": row["event_kind"],
|
||
"rfc_slug": row["rfc_slug"],
|
||
"rfc_title": row["rfc_title"],
|
||
"branch_name": row["branch_name"],
|
||
"pr_number": row["pr_number"],
|
||
"thread_id": row["thread_id"],
|
||
"actor_user_id": row["actor_user_id"],
|
||
"actor_login": row["actor_login"],
|
||
"actor_display": row["actor_display"],
|
||
"created_at": row["created_at"],
|
||
"read_at": row["read_at"],
|
||
"category": extras.get("category"),
|
||
"summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
|
||
"extras": extras,
|
||
}
|
||
|
||
|
||
def render_summary(event_kind: str, actor_display: str | None, rfc_title: str | None, extras: dict) -> str:
|
||
"""One short sentence shared by the inbox row and the email body
|
||
per §15.4. Actor is the underlying user per §15.9; null actor renders
|
||
as "the app" so a system-generated event still reads as a sentence."""
|
||
actor = actor_display or "the app"
|
||
title = rfc_title or extras.get("slug") or "this RFC"
|
||
if event_kind == "proposal_merged":
|
||
return f"{actor} merged your proposal — {title} is now a super-draft."
|
||
if event_kind == "proposal_declined":
|
||
return f"{actor} declined your proposal for {title}."
|
||
if event_kind == "proposal_opened_on_watched_topic":
|
||
return f"{actor} proposed a new RFC: {title}."
|
||
if event_kind == "pr_opened":
|
||
return f"{actor} opened a PR on {title}."
|
||
if event_kind == "pr_merged":
|
||
return f"{actor} merged a PR on {title}."
|
||
if event_kind == "pr_withdrawn":
|
||
return f"{actor} withdrew a PR on {title}."
|
||
if event_kind == "pr_commit_added":
|
||
return f"{actor} added a commit on {title}."
|
||
if event_kind == "pr_review_thread_new":
|
||
return f"{actor} opened a review thread on {title}."
|
||
if event_kind == "pr_review_thread_reply":
|
||
return f"{actor} replied to your review thread on {title}."
|
||
if event_kind == "chat_message_in_participated_thread":
|
||
return f"{actor} posted a chat message on {title}."
|
||
if event_kind == "chat_reply_to_my_message":
|
||
return f"{actor} replied to your message on {title}."
|
||
if event_kind == "claim_opened":
|
||
return f"{actor} opened a claim PR on {title}."
|
||
if event_kind == "graduation_complete":
|
||
return f"{title} graduated to an active RFC."
|
||
if event_kind == "super_draft_graduation_ready":
|
||
return f"{actor} began graduating {title}."
|
||
if event_kind == "pr_conflict_with_main":
|
||
return f"{actor} started a resolution branch on {title}."
|
||
if event_kind == "contribution_request_on_pending_rfc":
|
||
# Roadmap #28 Part 3: owner-facing, actionable. The term is the
|
||
# super-draft reference that surfaced the offer; the inbox row
|
||
# renders Accept/Decline beneath this line.
|
||
term = extras.get("matched_term") or title
|
||
return f"{actor} wants to contribute to your pending RFC for '{term}'."
|
||
if event_kind == "contribution_request_accepted":
|
||
return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
|
||
if event_kind == "contribution_request_declined":
|
||
return f"{actor} declined your request to contribute to {title}."
|
||
if event_kind == "scope_role_granted":
|
||
# §22 S4 (C.2): names the role and the scope (the project, and the
|
||
# collection when collection-scoped) per "a §15 notification naming the
|
||
# project and role".
|
||
role_label = "Owner" if extras.get("role") == "owner" else "RFC Contributor"
|
||
project_label = extras.get("project_name") or extras.get("project_id") or "a project"
|
||
scope_type = extras.get("scope_type")
|
||
if scope_type == "collection":
|
||
col_label = extras.get("collection_name") or extras.get("scope_id") or "a collection"
|
||
return f"{actor} granted you {role_label} on collection {project_label}/{col_label}."
|
||
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
|
||
# one self-contained sentence; the inbox row and the email
|
||
# body share this text per §15.4.
|
||
full_name = extras.get("requester_display") or actor
|
||
email_addr = extras.get("requester_email") or ""
|
||
if email_addr:
|
||
return f"New beta-access request from {full_name} ({email_addr})."
|
||
return f"New beta-access request from {full_name}."
|
||
return f"{event_kind} on {title}"
|
||
|
||
|
||
def _schedule_broadcast(user_id: int, event: str, payload: Any) -> None:
|
||
"""Best-effort SSE push; survives the absence of a running loop so
|
||
sync test clients still write the row."""
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
return
|
||
loop.create_task(_broadcast(user_id, event, payload))
|
||
|
||
|
||
def _schedule_email(
|
||
notif_id: int,
|
||
recipient_user_id: int,
|
||
event_kind: str,
|
||
category: str,
|
||
payload: dict,
|
||
) -> None:
|
||
"""§15.4 dispatch — synchronous so the email_sent_at timestamp lands
|
||
in the same tick as the notification row, which is what the §15.5
|
||
digest's exclusion rule 1 keys on. The actual SMTP send happens in
|
||
`email.py`; this just consults the recipient's preferences and quiet
|
||
hours, then calls into the adapter."""
|
||
from . import email as email_mod # avoid import cycle
|
||
|
||
email_mod.maybe_send(
|
||
notif_id=notif_id,
|
||
recipient_user_id=recipient_user_id,
|
||
event_kind=event_kind,
|
||
category=category,
|
||
payload=payload,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-user mute and watch helpers used by api_notifications
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def list_inbox(
|
||
*,
|
||
user_id: int,
|
||
unread: bool | None = None,
|
||
rfc_slug: str | None = None,
|
||
category: str | None = None,
|
||
actor_user_id: int | None = None,
|
||
bundled: bool = False,
|
||
limit: int = 200,
|
||
) -> dict:
|
||
"""§15.2: inbox query. Filter chips are AND-combined.
|
||
|
||
When `bundled=True` is set, rows are grouped server-side by
|
||
(rfc_slug, event_kind) per the §15.2 bundle toggle, with the most
|
||
recent timestamp on each bundle.
|
||
"""
|
||
where = ["recipient_user_id = ?"]
|
||
args: list[Any] = [user_id]
|
||
if unread:
|
||
where.append("read_at IS NULL")
|
||
if rfc_slug:
|
||
where.append("rfc_slug = ?")
|
||
args.append(rfc_slug)
|
||
if actor_user_id is not None:
|
||
where.append("actor_user_id = ?")
|
||
args.append(actor_user_id)
|
||
rows = db.conn().execute(
|
||
f"""
|
||
SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number,
|
||
n.thread_id, n.actor_user_id, n.payload, n.created_at, n.read_at,
|
||
u.display_name AS actor_display, u.gitea_login AS actor_login,
|
||
r.title AS rfc_title
|
||
FROM notifications n
|
||
LEFT JOIN users u ON u.id = n.actor_user_id
|
||
LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug
|
||
WHERE {' AND '.join(where)}
|
||
ORDER BY n.id DESC
|
||
LIMIT ?
|
||
""",
|
||
(*args, limit),
|
||
).fetchall()
|
||
items = []
|
||
for row in rows:
|
||
try:
|
||
extras = json.loads(row["payload"] or "{}")
|
||
except json.JSONDecodeError:
|
||
extras = {}
|
||
if category and extras.get("category") != category:
|
||
continue
|
||
items.append({
|
||
"id": row["id"],
|
||
"event_kind": row["event_kind"],
|
||
"rfc_slug": row["rfc_slug"],
|
||
"rfc_title": row["rfc_title"],
|
||
"branch_name": row["branch_name"],
|
||
"pr_number": row["pr_number"],
|
||
"thread_id": row["thread_id"],
|
||
"actor_user_id": row["actor_user_id"],
|
||
"actor_login": row["actor_login"],
|
||
"actor_display": row["actor_display"],
|
||
"created_at": row["created_at"],
|
||
"read_at": row["read_at"],
|
||
"category": extras.get("category"),
|
||
"summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
|
||
# The row's payload, surfaced for kinds that render inline
|
||
# detail (e.g. #28 Part 3's contribute-request who/why/use-case
|
||
# + Accept/Decline). Safe to expose: a recipient only ever sees
|
||
# their own notifications.
|
||
"extras": extras,
|
||
})
|
||
|
||
if bundled:
|
||
items = _bundle(items)
|
||
|
||
unread_count = db.conn().execute(
|
||
"SELECT COUNT(*) AS c FROM notifications WHERE recipient_user_id = ? AND read_at IS NULL",
|
||
(user_id,),
|
||
).fetchone()["c"]
|
||
|
||
return {"items": items, "unread_count": unread_count}
|
||
|
||
|
||
def _bundle(items: list[dict]) -> list[dict]:
|
||
"""§15.2: collapse per-row notifications into per-(RFC, event_kind)
|
||
bundles; the bundle's representative row is the most-recent
|
||
constituent, with a `bundled_ids` array carrying the rest."""
|
||
buckets: dict[tuple, list[dict]] = {}
|
||
for it in items:
|
||
key = (it["rfc_slug"], it["event_kind"])
|
||
buckets.setdefault(key, []).append(it)
|
||
bundled = []
|
||
for key, rows in buckets.items():
|
||
rep = dict(rows[0]) # most recent (sorted DESC above)
|
||
rep["bundled_ids"] = [r["id"] for r in rows]
|
||
rep["bundled_count"] = len(rows)
|
||
bundled.append(rep)
|
||
bundled.sort(key=lambda r: r["created_at"], reverse=True)
|
||
return bundled
|
||
|
||
|
||
def mark_read_by_filter(
|
||
*,
|
||
user_id: int,
|
||
unread: bool | None = None,
|
||
rfc_slug: str | None = None,
|
||
category: str | None = None,
|
||
actor_user_id: int | None = None,
|
||
ids: Iterable[int] | None = None,
|
||
) -> int:
|
||
"""`Mark all read` per §15.2 — respects the active filter so the
|
||
user can mark all churn read without touching personal-direct."""
|
||
where = ["recipient_user_id = ?", "read_at IS NULL"]
|
||
args: list[Any] = [user_id]
|
||
if rfc_slug:
|
||
where.append("rfc_slug = ?")
|
||
args.append(rfc_slug)
|
||
if actor_user_id is not None:
|
||
where.append("actor_user_id = ?")
|
||
args.append(actor_user_id)
|
||
if ids:
|
||
ids = list(ids)
|
||
if not ids:
|
||
return 0
|
||
placeholders = ",".join("?" * len(ids))
|
||
where.append(f"id IN ({placeholders})")
|
||
args.extend(ids)
|
||
# Category lives inside payload JSON; we filter post-fetch to keep
|
||
# the SQL portable.
|
||
rows = db.conn().execute(
|
||
f"SELECT id, payload FROM notifications WHERE {' AND '.join(where)}",
|
||
args,
|
||
).fetchall()
|
||
selected_ids: list[int] = []
|
||
for r in rows:
|
||
if category:
|
||
try:
|
||
extras = json.loads(r["payload"] or "{}")
|
||
except json.JSONDecodeError:
|
||
extras = {}
|
||
if extras.get("category") != category:
|
||
continue
|
||
selected_ids.append(r["id"])
|
||
if not selected_ids:
|
||
return 0
|
||
placeholders = ",".join("?" * len(selected_ids))
|
||
db.conn().execute(
|
||
f"UPDATE notifications SET read_at = datetime('now') WHERE id IN ({placeholders})",
|
||
selected_ids,
|
||
)
|
||
for nid in selected_ids:
|
||
_schedule_broadcast(user_id, "read", {"id": nid})
|
||
return len(selected_ids)
|
||
|
||
|
||
def reconcile_seen_advance(
|
||
*,
|
||
user_id: int,
|
||
rfc_slug: str,
|
||
branch_name: str | None = None,
|
||
pr_number: int | None = None,
|
||
) -> int:
|
||
"""§15.7 reconciler — when a scope cursor advances on visit, mark
|
||
matching unread notifications read. Called from the chat-seen and
|
||
pr-seen advance endpoints.
|
||
"""
|
||
where = ["recipient_user_id = ?", "read_at IS NULL", "rfc_slug = ?"]
|
||
args: list[Any] = [user_id, rfc_slug]
|
||
if pr_number is not None:
|
||
where.append("pr_number = ?")
|
||
args.append(pr_number)
|
||
elif branch_name is not None:
|
||
where.append("branch_name = ?")
|
||
args.append(branch_name)
|
||
rows = db.conn().execute(
|
||
f"SELECT id FROM notifications WHERE {' AND '.join(where)}",
|
||
args,
|
||
).fetchall()
|
||
ids = [r["id"] for r in rows]
|
||
if not ids:
|
||
return 0
|
||
placeholders = ",".join("?" * len(ids))
|
||
db.conn().execute(
|
||
f"UPDATE notifications SET read_at = datetime('now') WHERE id IN ({placeholders})",
|
||
ids,
|
||
)
|
||
for nid in ids:
|
||
_schedule_broadcast(user_id, "read", {"id": nid})
|
||
return len(ids)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 90-day decay sweep (§15.6) — called by the digest job's nightly loop
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def decay_watches() -> int:
|
||
"""Downgrade `watching` rows whose last_participation_at is older
|
||
than 90 days to `following`. Explicit and role-implicit rows are
|
||
exempt; role-implicit isn't stored on the row, so it's a no-op for
|
||
those. Returns the number of rows downgraded."""
|
||
cur = db.conn().execute(
|
||
"""
|
||
UPDATE watches
|
||
SET state = 'following', last_participation_at = datetime('now')
|
||
WHERE state = 'watching'
|
||
AND set_by = 'auto'
|
||
AND (last_participation_at IS NULL
|
||
OR datetime(last_participation_at, '+90 days') < datetime('now'))
|
||
"""
|
||
)
|
||
return cur.rowcount or 0
|