247 lines
7.9 KiB
Python
247 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
# ==============================================================================
|
|
# notif_router.py — Notification center API for /app#notif
|
|
# Author : Damir Radulić — dradulic@outlook.com / damir@rinet.one
|
|
# Version: 1.0.0
|
|
# Date : 2026-05-05
|
|
# Purpose: REST endpoints under /api/v2/notif/* powering the in-app
|
|
# notification center. Operates on pgz_sport.notifications table
|
|
# (extended by migrations/notifications_20260505.sql).
|
|
# Routes : GET /api/v2/notif/list?unread_only=&limit=&user_id=
|
|
# GET /api/v2/notif/count?user_id=
|
|
# POST /api/v2/notif/{id}/read
|
|
# POST /api/v2/notif/mark-all-read
|
|
# DELETE /api/v2/notif/{id}
|
|
# ==============================================================================
|
|
from __future__ import annotations
|
|
import os
|
|
|
|
from typing import Optional, List, Dict, Any
|
|
from fastapi import APIRouter, HTTPException, Query, Header
|
|
from pydantic import BaseModel
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
|
|
# Reuse the v2 DB config (same as pgz_sport_v2_router)
|
|
DB = dict(
|
|
host="10.10.0.2",
|
|
port=6432,
|
|
dbname="rinet_v3",
|
|
user="rinet",
|
|
password=os.environ["DB_PASSWORD"],
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/v2/notif", tags=["pgz_sport_notif"])
|
|
|
|
|
|
# ----------------------------- helpers ---------------------------------------
|
|
def _conn():
|
|
return psycopg2.connect(**DB)
|
|
|
|
|
|
def _row(r) -> Dict[str, Any]:
|
|
if r is None:
|
|
return {}
|
|
if isinstance(r, dict):
|
|
d = dict(r)
|
|
else:
|
|
d = dict(r)
|
|
# JSON-friendly conversions
|
|
for k, v in list(d.items()):
|
|
if hasattr(v, "isoformat"):
|
|
d[k] = v.isoformat()
|
|
return d
|
|
|
|
|
|
def _resolve_user_id(authorization: Optional[str]) -> Optional[int]:
|
|
"""Best-effort: pull user_id from Bearer token. Tries JWT (auth_v2) first
|
|
then falls back to legacy session-token-hash lookup."""
|
|
if not authorization:
|
|
return None
|
|
token = authorization.replace("Bearer ", "").strip()
|
|
if not token:
|
|
return None
|
|
# 1) Try JWT (preferred — current auth)
|
|
try:
|
|
from auth.auth_v2 import decode_token # type: ignore
|
|
payload = decode_token(token)
|
|
sub = payload.get("sub")
|
|
if sub is not None:
|
|
try:
|
|
return int(sub)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
except Exception:
|
|
pass
|
|
# 2) Legacy: SHA-256 hash lookup in user_sessions
|
|
import hashlib
|
|
th = hashlib.sha256(token.encode()).hexdigest()
|
|
try:
|
|
with _conn() as c:
|
|
cur = c.cursor()
|
|
cur.execute(
|
|
"""SELECT user_id FROM pgz_sport.user_sessions
|
|
WHERE token_hash=%s AND revoked=false AND expires_at>now()
|
|
LIMIT 1""",
|
|
(th,),
|
|
)
|
|
r = cur.fetchone()
|
|
return r[0] if r else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ----------------------------- LIST ------------------------------------------
|
|
@router.get("/list")
|
|
def notif_list(
|
|
unread_only: bool = Query(False),
|
|
limit: int = Query(50, ge=1, le=500),
|
|
user_id: Optional[int] = Query(None, description="Override (admin); else taken from JWT"),
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""
|
|
Returns InApp notifications visible to the user. System-wide (user_id IS NULL)
|
|
rows are always included. If no auth + no user_id, returns only system-wide.
|
|
"""
|
|
uid = user_id if user_id is not None else _resolve_user_id(authorization)
|
|
|
|
where = ["channel = 'inapp'"]
|
|
params: List[Any] = []
|
|
if uid is not None:
|
|
where.append("(user_id = %s OR user_id IS NULL)")
|
|
params.append(uid)
|
|
else:
|
|
where.append("user_id IS NULL")
|
|
|
|
if unread_only:
|
|
where.append("COALESCE(is_read, false) = false")
|
|
|
|
sql = f"""
|
|
SELECT id, user_id, kind, title, COALESCE(subject, title) AS subject, body,
|
|
link, COALESCE(is_read, read_at IS NOT NULL) AS is_read,
|
|
status, created_at, read_at
|
|
FROM pgz_sport.notifications
|
|
WHERE {' AND '.join(where)}
|
|
ORDER BY created_at DESC NULLS LAST, id DESC
|
|
LIMIT %s
|
|
"""
|
|
params.append(limit)
|
|
|
|
with _conn() as conn:
|
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
|
cur.execute(sql, params)
|
|
rows = [_row(r) for r in cur.fetchall()]
|
|
return {"count": len(rows), "rows": rows, "user_id": uid}
|
|
|
|
|
|
# ----------------------------- COUNT -----------------------------------------
|
|
@router.get("/count")
|
|
def notif_count(
|
|
user_id: Optional[int] = Query(None),
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
uid = user_id if user_id is not None else _resolve_user_id(authorization)
|
|
|
|
where = ["channel = 'inapp'"]
|
|
params: List[Any] = []
|
|
if uid is not None:
|
|
where.append("(user_id = %s OR user_id IS NULL)")
|
|
params.append(uid)
|
|
else:
|
|
where.append("user_id IS NULL")
|
|
|
|
sql = f"""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE COALESCE(is_read, read_at IS NOT NULL) = false) AS unread,
|
|
COUNT(*) AS total
|
|
FROM pgz_sport.notifications
|
|
WHERE {' AND '.join(where)}
|
|
"""
|
|
with _conn() as conn:
|
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
|
cur.execute(sql, params)
|
|
r = cur.fetchone() or {"unread": 0, "total": 0}
|
|
return {
|
|
"unread": int(r["unread"] or 0),
|
|
"total": int(r["total"] or 0),
|
|
"user_id": uid,
|
|
}
|
|
|
|
|
|
# ----------------------------- MARK ONE READ ---------------------------------
|
|
@router.post("/{nid}/read")
|
|
def notif_mark_read(nid: int):
|
|
with _conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""UPDATE pgz_sport.notifications
|
|
SET is_read = true,
|
|
read_at = COALESCE(read_at, now()),
|
|
status = CASE WHEN status='pending' THEN 'sent' ELSE status END
|
|
WHERE id = %s
|
|
RETURNING id""",
|
|
(nid,),
|
|
)
|
|
r = cur.fetchone()
|
|
if not r:
|
|
raise HTTPException(404, "Notifikacija ne postoji")
|
|
conn.commit()
|
|
return {"ok": True, "id": nid, "is_read": True}
|
|
|
|
|
|
# ----------------------------- MARK ALL READ ---------------------------------
|
|
class MarkAllIn(BaseModel):
|
|
user_id: Optional[int] = None
|
|
include_system: bool = True
|
|
|
|
|
|
@router.post("/mark-all-read")
|
|
def notif_mark_all_read(
|
|
body: Optional[MarkAllIn] = None,
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
body = body or MarkAllIn()
|
|
uid = body.user_id if body.user_id is not None else _resolve_user_id(authorization)
|
|
|
|
where = ["channel = 'inapp'", "COALESCE(is_read, false) = false"]
|
|
params: List[Any] = []
|
|
if uid is not None:
|
|
if body.include_system:
|
|
where.append("(user_id = %s OR user_id IS NULL)")
|
|
else:
|
|
where.append("user_id = %s")
|
|
params.append(uid)
|
|
else:
|
|
where.append("user_id IS NULL")
|
|
|
|
with _conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
f"""UPDATE pgz_sport.notifications
|
|
SET is_read = true,
|
|
read_at = COALESCE(read_at, now()),
|
|
status = CASE WHEN status='pending' THEN 'sent' ELSE status END
|
|
WHERE {' AND '.join(where)}
|
|
RETURNING id""",
|
|
params,
|
|
)
|
|
ids = [r[0] for r in cur.fetchall()]
|
|
conn.commit()
|
|
return {"ok": True, "marked_read": len(ids), "ids": ids[:500], "user_id": uid}
|
|
|
|
|
|
# ----------------------------- DELETE ----------------------------------------
|
|
@router.delete("/{nid}")
|
|
def notif_delete(nid: int):
|
|
with _conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"DELETE FROM pgz_sport.notifications WHERE id = %s RETURNING id",
|
|
(nid,),
|
|
)
|
|
r = cur.fetchone()
|
|
if not r:
|
|
raise HTTPException(404, "Notifikacija ne postoji")
|
|
conn.commit()
|
|
return {"ok": True, "id": nid, "deleted": True}
|