#!/usr/bin/env python3 from dotenv import load_dotenv load_dotenv('/opt/rinet-gpu/.env.master') # auto-added by patch_scrapers_with_dotenv.sh # ============================================================================== # 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}