158 lines
5.6 KiB
Python
158 lines
5.6 KiB
Python
"""PostgreSQL-backed notification store.
|
|||
|
|
|
||
|
|
One row per triggering event, shared by every user; a separate read-receipt
|
||
|
|
table tracks per-user read state so broadcasting to everyone needs no fan-out
|
||
|
|
insert per user. See base_notification_store.py for the port contract and the
|
||
|
|
design doc for why this shape was chosen over per-user subscriptions.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from contextlib import contextmanager
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import psycopg2
|
||
|
|
import psycopg2.extras
|
||
|
|
from psycopg2.pool import ThreadedConnectionPool
|
||
|
|
|
||
|
|
from app.config.settings import settings
|
||
|
|
from app.infrastructure.perception.base_notification_store import BaseNotificationStore
|
||
|
|
|
||
|
|
_CREATE_TABLES = """
|
||
|
|
CREATE TABLE IF NOT EXISTS perception_notifications (
|
||
|
|
id SERIAL PRIMARY KEY,
|
||
|
|
event_id TEXT NOT NULL REFERENCES regulation_events(id) ON DELETE CASCADE,
|
||
|
|
kind TEXT NOT NULL,
|
||
|
|
title TEXT NOT NULL,
|
||
|
|
impact_level TEXT,
|
||
|
|
summary TEXT,
|
||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
|
|
);
|
||
|
|
CREATE TABLE IF NOT EXISTS perception_notification_reads (
|
||
|
|
notification_id INTEGER NOT NULL REFERENCES perception_notifications(id) ON DELETE CASCADE,
|
||
|
|
user_id TEXT NOT NULL,
|
||
|
|
read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
|
|
PRIMARY KEY (notification_id, user_id)
|
||
|
|
);
|
||
|
|
CREATE INDEX IF NOT EXISTS perception_notif_created
|
||
|
|
ON perception_notifications (created_at DESC);
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def _row_to_dict(row: dict[str, Any]) -> dict:
|
||
|
|
"""Convert a psycopg2 RealDictRow to a plain dict with an ISO timestamp."""
|
||
|
|
d = dict(row)
|
||
|
|
if d.get("created_at") is not None:
|
||
|
|
d["created_at"] = d["created_at"].isoformat()
|
||
|
|
return d
|
||
|
|
|
||
|
|
|
||
|
|
class PostgresNotificationStore(BaseNotificationStore):
|
||
|
|
"""Notification store backed by PostgreSQL."""
|
||
|
|
|
||
|
|
def __init__(self) -> None:
|
||
|
|
"""Open a connection pool and ensure both tables exist."""
|
||
|
|
self._pool = ThreadedConnectionPool(
|
||
|
|
minconn=1,
|
||
|
|
maxconn=5,
|
||
|
|
host=settings.postgres_host,
|
||
|
|
port=settings.postgres_port,
|
||
|
|
user=settings.postgres_user,
|
||
|
|
password=settings.postgres_password,
|
||
|
|
dbname=settings.postgres_db,
|
||
|
|
)
|
||
|
|
self._ensure_schema()
|
||
|
|
|
||
|
|
def _ensure_schema(self) -> None:
|
||
|
|
with self._conn() as conn:
|
||
|
|
try:
|
||
|
|
with conn.cursor() as cur:
|
||
|
|
cur.execute(_CREATE_TABLES)
|
||
|
|
conn.commit()
|
||
|
|
except Exception:
|
||
|
|
conn.rollback()
|
||
|
|
raise
|
||
|
|
|
||
|
|
@contextmanager
|
||
|
|
def _conn(self):
|
||
|
|
conn = None
|
||
|
|
try:
|
||
|
|
conn = self._pool.getconn()
|
||
|
|
yield conn
|
||
|
|
finally:
|
||
|
|
if conn is not None:
|
||
|
|
self._pool.putconn(conn)
|
||
|
|
|
||
|
|
def create(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
event_id: str,
|
||
|
|
kind: str,
|
||
|
|
title: str,
|
||
|
|
impact_level: str | None,
|
||
|
|
summary: str | None,
|
||
|
|
) -> None:
|
||
|
|
"""Insert one notification row for the triggering event."""
|
||
|
|
with self._conn() as conn:
|
||
|
|
with conn.cursor() as cur:
|
||
|
|
cur.execute(
|
||
|
|
"INSERT INTO perception_notifications "
|
||
|
|
"(event_id, kind, title, impact_level, summary) "
|
||
|
|
"VALUES (%s, %s, %s, %s, %s)",
|
||
|
|
(event_id, kind, title, impact_level, summary),
|
||
|
|
)
|
||
|
|
conn.commit()
|
||
|
|
|
||
|
|
def list_for_user(self, user_id: str, limit: int = 20) -> list[dict]:
|
||
|
|
"""Return the newest notifications with this user's read state joined in."""
|
||
|
|
with self._conn() as conn:
|
||
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||
|
|
cur.execute(
|
||
|
|
"""
|
||
|
|
SELECT n.*, (r.user_id IS NOT NULL) AS read
|
||
|
|
FROM perception_notifications n
|
||
|
|
LEFT JOIN perception_notification_reads r
|
||
|
|
ON r.notification_id = n.id AND r.user_id = %s
|
||
|
|
ORDER BY n.created_at DESC
|
||
|
|
LIMIT %s
|
||
|
|
""",
|
||
|
|
(user_id, limit),
|
||
|
|
)
|
||
|
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
||
|
|
|
||
|
|
def unread_count(self, user_id: str) -> int:
|
||
|
|
"""Count notifications with no read receipt for this user."""
|
||
|
|
with self._conn() as conn:
|
||
|
|
with conn.cursor() as cur:
|
||
|
|
cur.execute(
|
||
|
|
"""
|
||
|
|
SELECT COUNT(*) FROM perception_notifications n
|
||
|
|
WHERE NOT EXISTS (
|
||
|
|
SELECT 1 FROM perception_notification_reads r
|
||
|
|
WHERE r.notification_id = n.id AND r.user_id = %s
|
||
|
|
)
|
||
|
|
""",
|
||
|
|
(user_id,),
|
||
|
|
)
|
||
|
|
return cur.fetchone()[0]
|
||
|
|
|
||
|
|
def mark_all_read(self, user_id: str) -> int:
|
||
|
|
"""Insert a read receipt for every notification this user hasn't read."""
|
||
|
|
with self._conn() as conn:
|
||
|
|
with conn.cursor() as cur:
|
||
|
|
cur.execute(
|
||
|
|
"""
|
||
|
|
INSERT INTO perception_notification_reads (notification_id, user_id)
|
||
|
|
SELECT n.id, %s FROM perception_notifications n
|
||
|
|
WHERE NOT EXISTS (
|
||
|
|
SELECT 1 FROM perception_notification_reads r
|
||
|
|
WHERE r.notification_id = n.id AND r.user_id = %s
|
||
|
|
)
|
||
|
|
ON CONFLICT (notification_id, user_id) DO NOTHING
|
||
|
|
""",
|
||
|
|
(user_id, user_id),
|
||
|
|
)
|
||
|
|
marked = cur.rowcount
|
||
|
|
conn.commit()
|
||
|
|
return marked
|