f9ebcddf28
#1 JWT middleware extended: - Was: /api/admin/* only - Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT - Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password, /password/reset, /reset-password, /setup-password, /google; /api/gdpr/consent; any path ending /avatar - 14 mutating endpoints verified to return 401 without token #2 Avatar upload demo mode (routers/clan_panel_router.py): - Anonymous → returns {demo_mode:true, slika_url:null, message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'}, no FS write, no DB write - Authenticated (valid JWT, allowed role) → real save as before - Auth check now uses auth.auth_v2.decode_token (proper secret + revocation) instead of the broken local _resolve_role #3 Mock mailer (auth/mailer.py): - send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl - send_password_reset, send_invite helpers with HR text + HTML alt - Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default) - forgot-password and admin invite both call mailer; audit logs mail status #5 Rate limiting on /api/auth/login: - Per-user: 5 wrong attempts → 5-minute DB-backed lockout (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES. - Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429 Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful login clears the IP counter. - Failed attempts respond '(N/5) — račun je zaključan na 5 minuta' - New audit actions: login.ratelimit.ip; login.fail meta now includes fails count, locked, lock_minutes #4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14 mutating endpoints, public path whitelist, demo-mode avatar + real save, forgot-password e-mail to mailbox, no-leak unknown email, 5-fail lockout, 423 during lockout, audit coverage.
1010 lines
41 KiB
Python
1010 lines
41 KiB
Python
#!/usr/bin/env python3
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Fajl: routers/crm_extras_router.py | v1.0.0 | 05.05.2026
|
|
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
|
|
# Lokacija: /opt/pgz-sport/routers/crm_extras_router.py
|
|
# Svrha: R5 — bulk akcije za članarine, XLSX export članova, /crm/stats,
|
|
# notifikacije za isteke liječničkih (Email + InApp)
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
"""R5 CRM extras.
|
|
|
|
Endpointi (montirani na /api/crm):
|
|
POST /clanarine/bulk/notify → opomena svim koji duguju (mock email + InApp)
|
|
POST /clanarine/bulk/uplatnice → batch HUB-3 PDF (zip ili JSON s URL-ovima)
|
|
GET /clanovi/export.xlsx → XLSX svih članova (filteri klub, aktivan)
|
|
GET /stats → aktivni vs neaktivni, trend uplata, ...
|
|
|
|
POST /lijecnicki/notify-scan → skenira pretvorbe < N dana, kreira notifikacije
|
|
GET /notifications → lista (filter user/status/channel)
|
|
POST /notifications/{id}/read → mark read
|
|
POST /notifications/mark-all-read → mark all read za usera
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json as _json
|
|
import re as _re
|
|
import sys
|
|
import zipfile
|
|
from datetime import date, datetime, timedelta
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
|
|
import psycopg2
|
|
from psycopg2.extras import RealDictCursor
|
|
from fastapi import APIRouter, HTTPException, Query, Header
|
|
from fastapi.responses import Response
|
|
from pydantic import BaseModel
|
|
|
|
import openpyxl
|
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
|
|
sys.path.insert(0, "/opt/pgz-sport")
|
|
from crm.payments import (
|
|
build_hub3_pdf, make_poziv_na_broj, normalize_iban,
|
|
)
|
|
|
|
DEFAULT_PRIMATELJ_IBAN = "HR0000000000000000000"
|
|
DEFAULT_PRIMATELJ_NAZIV = "PGŽ Odjel za sport"
|
|
DEFAULT_PRIMATELJ_ADRESA = "Adamićeva 10, 51000 Rijeka"
|
|
|
|
router = APIRouter(prefix="/api/crm", tags=["crm-extras"])
|
|
|
|
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
|
|
|
|
# Pragovi za scan liječničkih (dana do isteka)
|
|
LIJEC_THRESHOLDS = (30, 15, 7)
|
|
|
|
|
|
def _conn():
|
|
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
|
|
|
|
|
|
def _conv(v):
|
|
if isinstance(v, (date, datetime)):
|
|
return v.isoformat()
|
|
if isinstance(v, Decimal):
|
|
return float(v)
|
|
return v
|
|
|
|
|
|
def _row(d):
|
|
return None if d is None else {k: _conv(v) for k, v in dict(d).items()}
|
|
|
|
|
|
# ════════════════════════════════════════════════════
|
|
# #3 — BULK AKCIJE ZA ČLANARINE
|
|
# ════════════════════════════════════════════════════
|
|
|
|
class BulkOpomenaIn(BaseModel):
|
|
klub_id: Optional[int] = None
|
|
godina: Optional[int] = None
|
|
ids: Optional[list[int]] = None # specifične clanarina ID
|
|
template: Optional[str] = "Poštovani, podsjećamo na nepodmirenu članarinu."
|
|
|
|
|
|
@router.post("/clanarine/bulk/notify")
|
|
def bulk_opomena(body: BulkOpomenaIn):
|
|
"""Pošalji opomenu (mock e-mail + InApp notification) svim dužnicima."""
|
|
where = ["c.status IN ('nepodmireno','djelomicno')"]
|
|
params: list = []
|
|
if body.ids:
|
|
where.append("c.id = ANY(%s)"); params.append(body.ids)
|
|
if body.klub_id:
|
|
where.append("c.klub_id = %s"); params.append(body.klub_id)
|
|
if body.godina:
|
|
where.append("c.godina = %s"); params.append(body.godina)
|
|
where_sql = "WHERE " + " AND ".join(where)
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(f"""
|
|
SELECT c.id, c.godina, c.iznos_propisan,
|
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
|
cl.id AS clan_id, cl.ime || ' ' || cl.prezime AS clan,
|
|
cl.email AS clan_email, k.naziv AS klub
|
|
FROM pgz_sport.clanarine c
|
|
JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
|
{where_sql}
|
|
ORDER BY dug DESC
|
|
LIMIT 1000
|
|
""", params)
|
|
rows = [_row(r) for r in cur.fetchall()]
|
|
|
|
# Insert notifications za one s e-mailom
|
|
n_email, n_inapp = 0, 0
|
|
for r in rows:
|
|
subject = f"Opomena: nepodmirena članarina {r['godina']} ({r['dug']:.2f} €)"
|
|
body_txt = (f"{body.template}\n\n"
|
|
f"Klub: {r.get('klub')}\n"
|
|
f"Iznos duga: {r['dug']:.2f} EUR\n"
|
|
f"Godina: {r['godina']}\n\n"
|
|
f"PGŽ Sport ERP/CRM")
|
|
meta = _json.dumps({
|
|
"clanarina_id": r["id"], "clan_id": r["clan_id"],
|
|
"iznos_dug": float(r["dug"]),
|
|
"uplatnica_url": f"/sport/api/crm/clanarine/{r['id']}/uplatnica.pdf",
|
|
})
|
|
# InApp uvijek
|
|
cur.execute("""INSERT INTO pgz_sport.notifications
|
|
(channel, subject, body, status, scheduled_at, meta)
|
|
VALUES ('inapp', %s, %s, 'pending', now(), %s::jsonb)""",
|
|
(subject, body_txt, meta))
|
|
n_inapp += 1
|
|
# Email mock — samo log
|
|
if r.get("clan_email"):
|
|
cur.execute("""INSERT INTO pgz_sport.notifications
|
|
(channel, subject, body, status, scheduled_at, meta)
|
|
VALUES ('email', %s, %s, 'pending', now(), %s::jsonb)""",
|
|
(subject, body_txt, _json.dumps({**_json.loads(meta),
|
|
"to": r["clan_email"]})))
|
|
n_email += 1
|
|
conn.commit()
|
|
return {
|
|
"ok": True,
|
|
"matched": len(rows),
|
|
"queued_inapp": n_inapp,
|
|
"queued_email": n_email,
|
|
"note": "Mock — SMTP nije konfiguriran; e-mail je upisan u notifications tablicu sa status='pending'.",
|
|
"recipients_preview": rows[:20],
|
|
}
|
|
|
|
|
|
class BulkUplatniceIn(BaseModel):
|
|
ids: Optional[list[int]] = None
|
|
klub_id: Optional[int] = None
|
|
godina: Optional[int] = None
|
|
|
|
|
|
@router.post("/clanarine/bulk/uplatnice")
|
|
def bulk_uplatnice(body: BulkUplatniceIn):
|
|
"""
|
|
Vraća JSON s listom uplatnica + linkovima na pojedinačne PDF-ove.
|
|
(PDF-ovi se generiraju on-demand kroz /clanarine/{id}/uplatnica.pdf.)
|
|
"""
|
|
where = ["c.status IN ('nepodmireno','djelomicno')"]
|
|
params: list = []
|
|
if body.ids:
|
|
where = ["c.id = ANY(%s)"]; params = [body.ids]
|
|
else:
|
|
if body.klub_id:
|
|
where.append("c.klub_id = %s"); params.append(body.klub_id)
|
|
if body.godina:
|
|
where.append("c.godina = %s"); params.append(body.godina)
|
|
where_sql = "WHERE " + " AND ".join(where)
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(f"""
|
|
SELECT c.id, c.godina, c.iznos_propisan, c.iznos_placen,
|
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
|
cl.ime || ' ' || cl.prezime AS clan,
|
|
k.naziv AS klub, k.iban AS klub_iban
|
|
FROM pgz_sport.clanarine c
|
|
JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
|
{where_sql}
|
|
ORDER BY k.naziv, cl.prezime
|
|
LIMIT 500
|
|
""", params)
|
|
rows = [_row(r) for r in cur.fetchall()]
|
|
return {
|
|
"ok": True,
|
|
"count": len(rows),
|
|
"total_dug_eur": round(sum(float(r["dug"] or 0) for r in rows), 2),
|
|
"uplatnice": [{
|
|
"id": r["id"], "clan": r["clan"], "klub": r["klub"],
|
|
"godina": r["godina"], "iznos_eur": float(r["dug"] or 0),
|
|
"pdf_url": f"/sport/api/crm/clanarine/{r['id']}/uplatnica.pdf",
|
|
"qr_url": f"/sport/api/crm/clanarine/{r['id']}/qr.png",
|
|
} for r in rows],
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════
|
|
# #4 — XLSX EXPORT ČLANOVA
|
|
# ════════════════════════════════════════════════════
|
|
|
|
@router.get("/clanovi/export.xlsx")
|
|
def export_clanovi_xlsx(
|
|
klub_id: Optional[int] = Query(None),
|
|
aktivan: Optional[bool] = Query(None),
|
|
sport: Optional[str] = Query(None),
|
|
q: Optional[str] = Query(None),
|
|
limit: int = Query(5000, le=20000),
|
|
):
|
|
where, params = ["1=1"], []
|
|
if klub_id: where.append("c.klub_id = %s"); params.append(klub_id)
|
|
if aktivan is not None: where.append("c.aktivan = %s"); params.append(aktivan)
|
|
if sport: where.append("(c.sport ILIKE %s OR k.sport ILIKE %s)"); params += [f"%{sport}%", f"%{sport}%"]
|
|
if q: where.append("(c.ime || ' ' || c.prezime) ILIKE %s"); params.append(f"%{q}%")
|
|
params.append(limit)
|
|
where_sql = "WHERE " + " AND ".join(where)
|
|
sql = f"""
|
|
SELECT c.id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol,
|
|
c.email, c.telefon, c.adresa, c.grad, c.postanski_broj,
|
|
c.kategorija, c.podkategorija, c.pozicija, c.broj_dresa,
|
|
c.visina_cm, c.tezina_kg, c.dominantna_noga,
|
|
c.aktivan, c.datum_pristupa, c.reprezentativac,
|
|
c.kategoriziran, c.kategorija_hoo,
|
|
c.stipendiran, c.stipendija_iznos,
|
|
c.licenca_broj, c.licenca_vrijedi_do,
|
|
k.naziv AS klub, k.oib AS klub_oib,
|
|
s.naziv AS savez
|
|
FROM pgz_sport.clanovi c
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
|
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
|
|
{where_sql}
|
|
ORDER BY k.naziv NULLS LAST, c.prezime, c.ime
|
|
LIMIT %s
|
|
"""
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(sql, params)
|
|
rows = [_row(r) for r in cur.fetchall()]
|
|
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "Članovi PGŽ"
|
|
|
|
headers = [
|
|
"ID", "Ime", "Prezime", "OIB", "Datum rođ.", "Spol",
|
|
"E-mail", "Telefon", "Adresa", "Grad", "Pošt.",
|
|
"Kategorija", "Podkat.", "Pozicija", "Dres",
|
|
"Vis. (cm)", "Tež. (kg)", "Dom. noga",
|
|
"Aktivan", "Datum prist.", "Repr.",
|
|
"Kategoriziran", "HOO kat.",
|
|
"Stipendiran", "Stipendija (€)",
|
|
"Licenca", "Licenca do",
|
|
"Klub", "OIB kluba", "Savez",
|
|
]
|
|
for col, h in enumerate(headers, 1):
|
|
cell = ws.cell(row=1, column=col, value=h)
|
|
cell.font = Font(bold=True, color="FFFFFF", size=10)
|
|
cell.fill = PatternFill(start_color="1E3A8A", end_color="1E3A8A", fill_type="solid")
|
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
|
cell.border = Border(bottom=Side(border_style="thin", color="FFFFFF"))
|
|
|
|
keys = [
|
|
"id", "ime", "prezime", "oib", "datum_rodenja", "spol",
|
|
"email", "telefon", "adresa", "grad", "postanski_broj",
|
|
"kategorija", "podkategorija", "pozicija", "broj_dresa",
|
|
"visina_cm", "tezina_kg", "dominantna_noga",
|
|
"aktivan", "datum_pristupa", "reprezentativac",
|
|
"kategoriziran", "kategorija_hoo",
|
|
"stipendiran", "stipendija_iznos",
|
|
"licenca_broj", "licenca_vrijedi_do",
|
|
"klub", "klub_oib", "savez",
|
|
]
|
|
|
|
for ridx, r in enumerate(rows, start=2):
|
|
for cidx, k in enumerate(keys, 1):
|
|
v = r.get(k)
|
|
if isinstance(v, bool):
|
|
v = "DA" if v else "NE"
|
|
ws.cell(row=ridx, column=cidx, value=v)
|
|
|
|
# Auto column widths
|
|
col_letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + ["AA", "AB", "AC", "AD", "AE", "AF"]
|
|
for col_letter, h in zip(col_letters, headers):
|
|
ws.column_dimensions[col_letter].width = max(10, min(28, len(h) + 4))
|
|
|
|
ws.freeze_panes = "A2"
|
|
ws.auto_filter.ref = ws.dimensions
|
|
|
|
buf = io.BytesIO()
|
|
wb.save(buf)
|
|
fname = f"clanovi-pgz-{date.today().isoformat()}.xlsx"
|
|
return Response(
|
|
content=buf.getvalue(),
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
|
)
|
|
|
|
|
|
# ════════════════════════════════════════════════════
|
|
# #5 — /crm/stats
|
|
# ════════════════════════════════════════════════════
|
|
|
|
@router.get("/stats")
|
|
def crm_stats(klub_id: Optional[int] = Query(None)):
|
|
"""Aktivni/neaktivni članovi, trend uplata, KPI summary."""
|
|
klub_filter = "AND klub_id = %s" if klub_id else ""
|
|
klub_params = [klub_id] if klub_id else []
|
|
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
# aktivni vs neaktivni
|
|
cur.execute(f"""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE aktivan = TRUE) AS aktivni,
|
|
COUNT(*) FILTER (WHERE aktivan = FALSE) AS neaktivni,
|
|
COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE reprezentativac = TRUE) AS reprezentativci,
|
|
COUNT(*) FILTER (WHERE kategoriziran = TRUE) AS kategorizirani,
|
|
COUNT(*) FILTER (WHERE stipendiran = TRUE) AS stipendirani
|
|
FROM pgz_sport.clanovi
|
|
WHERE 1=1 {klub_filter}
|
|
""", klub_params)
|
|
clanovi_summary = _row(cur.fetchone())
|
|
|
|
# po spolu
|
|
cur.execute(f"""
|
|
SELECT spol, COUNT(*) AS n
|
|
FROM pgz_sport.clanovi
|
|
WHERE aktivan = TRUE {klub_filter}
|
|
GROUP BY spol ORDER BY n DESC
|
|
""", klub_params)
|
|
po_spolu = [_row(r) for r in cur.fetchall()]
|
|
|
|
# po kategoriji
|
|
cur.execute(f"""
|
|
SELECT COALESCE(kategorija, '(nepoznato)') AS kategorija, COUNT(*) AS n
|
|
FROM pgz_sport.clanovi
|
|
WHERE aktivan = TRUE {klub_filter}
|
|
GROUP BY kategorija ORDER BY n DESC LIMIT 12
|
|
""", klub_params)
|
|
po_kategoriji = [_row(r) for r in cur.fetchall()]
|
|
|
|
# trend uplata po mjesecu — zadnjih 12
|
|
cur.execute(f"""
|
|
SELECT to_char(date_trunc('month', datum_uplate), 'YYYY-MM') AS mjesec,
|
|
COUNT(*) AS broj_uplata,
|
|
SUM(iznos_placen)::numeric(10,2) AS iznos_total
|
|
FROM pgz_sport.clanarine
|
|
WHERE datum_uplate IS NOT NULL
|
|
AND datum_uplate >= (CURRENT_DATE - INTERVAL '12 months')
|
|
{('AND klub_id = %s' if klub_id else '')}
|
|
GROUP BY date_trunc('month', datum_uplate)
|
|
ORDER BY mjesec
|
|
""", klub_params)
|
|
trend_uplata = [_row(r) for r in cur.fetchall()]
|
|
|
|
# članarine summary
|
|
cur.execute(f"""
|
|
SELECT COUNT(*) AS total,
|
|
SUM(iznos_propisan)::numeric(10,2) AS propisan,
|
|
SUM(iznos_placen)::numeric(10,2) AS placen,
|
|
SUM(iznos_propisan - COALESCE(iznos_placen,0))::numeric(10,2) AS dug,
|
|
COUNT(*) FILTER (WHERE status='nepodmireno') AS n_nepodmireno,
|
|
COUNT(*) FILTER (WHERE status='djelomicno') AS n_djelomicno,
|
|
COUNT(*) FILTER (WHERE status='podmireno') AS n_podmireno
|
|
FROM pgz_sport.clanarine
|
|
WHERE 1=1 {klub_filter}
|
|
""", klub_params)
|
|
clanarine_summary = _row(cur.fetchone())
|
|
|
|
# liječnički status
|
|
cur.execute(f"""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci,
|
|
COUNT(*) FILTER (WHERE vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro,
|
|
COUNT(*) FILTER (WHERE vrijedi_do < CURRENT_DATE) AS istekli,
|
|
COUNT(*) AS total
|
|
FROM pgz_sport.lijecnicki_pregledi
|
|
WHERE 1=1 {klub_filter}
|
|
""", klub_params)
|
|
lijecnicki_summary = _row(cur.fetchone())
|
|
|
|
# najnovije uplate (zadnjih 10)
|
|
cur.execute(f"""
|
|
SELECT c.id, c.iznos_placen, c.datum_uplate, c.godina,
|
|
cl.ime||' '||cl.prezime AS clan, k.naziv AS klub
|
|
FROM pgz_sport.clanarine c
|
|
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
|
WHERE c.datum_uplate IS NOT NULL {klub_filter.replace('klub_id', 'c.klub_id')}
|
|
ORDER BY c.datum_uplate DESC
|
|
LIMIT 10
|
|
""", klub_params)
|
|
najnovije_uplate = [_row(r) for r in cur.fetchall()]
|
|
|
|
return {
|
|
"klub_id": klub_id,
|
|
"clanovi": clanovi_summary,
|
|
"po_spolu": po_spolu,
|
|
"po_kategoriji": po_kategoriji,
|
|
"trend_uplata_12m": trend_uplata,
|
|
"clanarine": clanarine_summary,
|
|
"lijecnicki": lijecnicki_summary,
|
|
"najnovije_uplate": najnovije_uplate,
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════
|
|
# #6 — NOTIFIKACIJE LIJEČNIČKI ISTECI
|
|
# ════════════════════════════════════════════════════
|
|
|
|
class NotifScanIn(BaseModel):
|
|
klub_id: Optional[int] = None
|
|
thresholds: Optional[list[int]] = None # default = LIJEC_THRESHOLDS
|
|
include_expired: bool = True # uključi i one koji su već istekli
|
|
|
|
|
|
@router.post("/lijecnicki/notify-scan")
|
|
def lijecnicki_notify_scan(body: NotifScanIn):
|
|
"""
|
|
Skenira nadolazeće isteke i kreira notifikacije (InApp + Email mock)
|
|
za pragove 30/15/7 dana. Ako include_expired=True, isto kreira jednu
|
|
notifikaciju (threshold=0) za već istekle.
|
|
Ne duplicira: gleda meta.lijecnicki_id+threshold u zadnjih 7 dana.
|
|
"""
|
|
thresholds = sorted(set(body.thresholds or LIJEC_THRESHOLDS), reverse=True)
|
|
klub_filter = "AND l.klub_id = %s" if body.klub_id else ""
|
|
klub_params = [body.klub_id] if body.klub_id else []
|
|
|
|
# threshold=0 → već istekli (poseban "expired" bucket)
|
|
scan_buckets = [(thr, "uskoro") for thr in thresholds]
|
|
if body.include_expired:
|
|
scan_buckets.append((0, "expired"))
|
|
|
|
created = []
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
for thr, kind in scan_buckets:
|
|
if kind == "expired":
|
|
where_window = "(l.vrijedi_do - CURRENT_DATE) < 0"
|
|
where_params = []
|
|
else:
|
|
where_window = "(l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s"
|
|
where_params = [thr]
|
|
cur.execute(f"""
|
|
SELECT l.id, l.vrijedi_do, l.clan_id,
|
|
(l.vrijedi_do - CURRENT_DATE)::int AS dana,
|
|
cl.ime || ' ' || cl.prezime AS clan,
|
|
cl.email AS clan_email,
|
|
k.naziv AS klub
|
|
FROM pgz_sport.lijecnicki_pregledi l
|
|
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
|
|
WHERE l.vrijedi_do IS NOT NULL
|
|
AND {where_window}
|
|
{klub_filter}
|
|
""", where_params + klub_params)
|
|
kandidati = [_row(r) for r in cur.fetchall()]
|
|
|
|
for r in kandidati:
|
|
# de-dup: već postoji notifikacija za ovaj lijec_id+threshold u <7 dana?
|
|
cur.execute("""
|
|
SELECT 1 FROM pgz_sport.notifications
|
|
WHERE meta->>'lijecnicki_id' = %s
|
|
AND meta->>'threshold' = %s
|
|
AND scheduled_at > now() - INTERVAL '7 days'
|
|
LIMIT 1
|
|
""", (str(r["id"]), str(thr)))
|
|
if cur.fetchone():
|
|
continue
|
|
|
|
if r['dana'] is not None and r['dana'] < 0:
|
|
subject = f"⚠ Liječnički pregled ISTEKAO ({-r['dana']} dana): {r['clan']}"
|
|
msg_dana = f"istekao prije {-r['dana']} dana"
|
|
else:
|
|
subject = f"⚕ Liječnički pregled ističe za {r['dana']} dana: {r['clan']}"
|
|
msg_dana = f"{r['dana']} dana ostalo"
|
|
body_txt = (
|
|
f"Liječnički pregled za sportaša {r['clan']} "
|
|
f"({r.get('klub') or '(bez kluba)'}) — vrijedi do {r['vrijedi_do']} "
|
|
f"— {msg_dana}.\n\n"
|
|
f"Molimo zakažite novi termin u ZZJZ PGŽ "
|
|
f"(ili koristite /sport/api/crm/lijecnicki/{r['id']}/zakazi).\n\n"
|
|
f"PGŽ Sport ERP/CRM"
|
|
)
|
|
meta = _json.dumps({
|
|
"lijecnicki_id": r["id"],
|
|
"clan_id": r["clan_id"],
|
|
"threshold": thr,
|
|
"vrijedi_do": str(r["vrijedi_do"]),
|
|
"dana": r["dana"],
|
|
"zakazi_url": f"/sport/api/crm/lijecnicki/{r['id']}/zakazi",
|
|
"klub": r.get("klub"),
|
|
})
|
|
cur.execute("""INSERT INTO pgz_sport.notifications
|
|
(channel, subject, body, status, scheduled_at, meta)
|
|
VALUES ('inapp', %s, %s, 'pending', now(), %s::jsonb)
|
|
RETURNING id""", (subject, body_txt, meta))
|
|
inapp_id = cur.fetchone()["id"]
|
|
created.append({"channel": "inapp", "id": inapp_id, "lijec_id": r["id"], "thr": thr})
|
|
|
|
if r.get("clan_email"):
|
|
cur.execute("""INSERT INTO pgz_sport.notifications
|
|
(channel, subject, body, status, scheduled_at, meta)
|
|
VALUES ('email', %s, %s, 'pending', now(), %s::jsonb)
|
|
RETURNING id""",
|
|
(subject, body_txt,
|
|
_json.dumps({**_json.loads(meta), "to": r["clan_email"]})))
|
|
em_id = cur.fetchone()["id"]
|
|
created.append({"channel": "email", "id": em_id, "lijec_id": r["id"], "thr": thr,
|
|
"to": r["clan_email"]})
|
|
conn.commit()
|
|
|
|
return {
|
|
"ok": True,
|
|
"thresholds_dana": thresholds,
|
|
"created": len(created),
|
|
"items": created[:50],
|
|
"note": "Mock — SMTP nije konfiguriran. Email notifikacije su upisane u DB sa status='pending'.",
|
|
}
|
|
|
|
|
|
@router.get("/notifications")
|
|
def list_notifications(
|
|
user_id: Optional[int] = Query(None),
|
|
status: Optional[str] = Query(None, description="pending|sent|read"),
|
|
channel: Optional[str] = Query(None, description="inapp|email"),
|
|
limit: int = Query(100, le=500),
|
|
):
|
|
where, params = [], []
|
|
if user_id is not None:
|
|
where.append("user_id = %s"); params.append(user_id)
|
|
if status:
|
|
where.append("status = %s"); params.append(status)
|
|
if channel:
|
|
where.append("channel = %s"); params.append(channel)
|
|
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
|
params.append(limit)
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(f"""
|
|
SELECT id, user_id, channel, subject, body, status,
|
|
scheduled_at, sent_at, read_at, meta
|
|
FROM pgz_sport.notifications
|
|
{where_sql}
|
|
ORDER BY scheduled_at DESC NULLS LAST
|
|
LIMIT %s
|
|
""", params)
|
|
rows = [_row(r) for r in cur.fetchall()]
|
|
cur.execute(f"""
|
|
SELECT COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE status='pending') AS pending,
|
|
COUNT(*) FILTER (WHERE status='sent') AS sent,
|
|
COUNT(*) FILTER (WHERE read_at IS NULL AND channel='inapp') AS unread_inapp
|
|
FROM pgz_sport.notifications
|
|
{where_sql}
|
|
""", params[:-1])
|
|
summary = _row(cur.fetchone())
|
|
return {"count": len(rows), "summary": summary, "rows": rows}
|
|
|
|
|
|
@router.post("/notifications/{nid}/read")
|
|
def mark_read(nid: int):
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute("""UPDATE pgz_sport.notifications
|
|
SET read_at = now(), status = 'sent'
|
|
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, "status": "read"}
|
|
|
|
|
|
class MarkAllReadIn(BaseModel):
|
|
user_id: Optional[int] = None
|
|
channel: Optional[str] = "inapp"
|
|
|
|
|
|
@router.post("/notifications/mark-all-read")
|
|
def mark_all_read(body: MarkAllReadIn):
|
|
where = ["read_at IS NULL"]
|
|
params = []
|
|
if body.user_id is not None:
|
|
where.append("user_id = %s"); params.append(body.user_id)
|
|
if body.channel:
|
|
where.append("channel = %s"); params.append(body.channel)
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(f"""UPDATE pgz_sport.notifications
|
|
SET read_at = now(), status = 'sent'
|
|
WHERE {' AND '.join(where)}
|
|
RETURNING id""", params)
|
|
ids = [r["id"] for r in cur.fetchall()]
|
|
conn.commit()
|
|
return {"ok": True, "marked_read": len(ids), "ids": ids[:200]}
|
|
|
|
|
|
# ════════════════════════════════════════════════════
|
|
# R6 #2 — BATCH HUB-3 PDFs ZIP
|
|
# ════════════════════════════════════════════════════
|
|
|
|
class BulkZipIn(BaseModel):
|
|
ids: Optional[list[int]] = None
|
|
klub_id: Optional[int] = None
|
|
godina: Optional[int] = None
|
|
only_unpaid: bool = True
|
|
limit: int = 200
|
|
|
|
|
|
def _safe_filename(s: str) -> str:
|
|
s = (s or "x").strip()
|
|
s = _re.sub(r"[^\w\-\.]+", "_", s, flags=_re.UNICODE)
|
|
return s[:80] or "x"
|
|
|
|
|
|
@router.post("/clanarine/bulk/uplatnice.zip")
|
|
def bulk_uplatnice_zip(body: BulkZipIn):
|
|
"""
|
|
Generira ZIP archive sa svim HUB-3 PDF uplatnicama za odabrane članarine.
|
|
Filename pattern: <KlubSlug>/<Prezime_Ime>-<id>-<godina>.pdf
|
|
"""
|
|
where, params = [], []
|
|
if body.ids:
|
|
where.append("c.id = ANY(%s)"); params.append(body.ids)
|
|
if body.klub_id:
|
|
where.append("c.klub_id = %s"); params.append(body.klub_id)
|
|
if body.godina:
|
|
where.append("c.godina = %s"); params.append(body.godina)
|
|
if body.only_unpaid and not body.ids:
|
|
where.append("c.status IN ('nepodmireno','djelomicno')")
|
|
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
|
params.append(body.limit)
|
|
|
|
sql = f"""
|
|
SELECT c.id, c.godina, c.razdoblje,
|
|
c.iznos_propisan, c.iznos_placen,
|
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
|
cl.ime, cl.prezime, cl.adresa AS clan_adresa, cl.grad AS clan_grad,
|
|
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban,
|
|
k.adresa AS klub_adresa, k.grad AS klub_grad
|
|
FROM pgz_sport.clanarine c
|
|
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
|
{where_sql}
|
|
ORDER BY k.naziv NULLS LAST, cl.prezime, cl.ime
|
|
LIMIT %s
|
|
"""
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(sql, params)
|
|
rows = [_row(r) for r in cur.fetchall()]
|
|
if not rows:
|
|
raise HTTPException(404, "Nema članarina za batch")
|
|
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
|
manifest = []
|
|
for r in rows:
|
|
dug = float(r["dug"] or 0)
|
|
if dug <= 0:
|
|
dug = float(r["iznos_propisan"] or 0)
|
|
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
|
|
primatelj_naziv = r.get("klub") or DEFAULT_PRIMATELJ_NAZIV
|
|
primatelj_adresa = ", ".join(
|
|
[x for x in [r.get("klub_adresa"), r.get("klub_grad")] if x]
|
|
) or DEFAULT_PRIMATELJ_ADRESA
|
|
platitelj_naziv = f"{r.get('ime') or ''} {r.get('prezime') or ''}".strip() or "Član"
|
|
platitelj_adresa = ", ".join(
|
|
[x for x in [r.get("clan_adresa"), r.get("clan_grad")] if x]
|
|
) or "—"
|
|
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
|
|
try:
|
|
pdf = build_hub3_pdf(
|
|
platitelj_naziv=platitelj_naziv,
|
|
platitelj_adresa=platitelj_adresa,
|
|
primatelj_naziv=primatelj_naziv,
|
|
primatelj_adresa=primatelj_adresa,
|
|
iban=iban,
|
|
amount_eur=dug,
|
|
model="HR00",
|
|
poziv_na_broj=poziv,
|
|
opis=f"Članarina {r['godina']} — {r.get('razdoblje') or 'godišnja'}",
|
|
sifra_namjene="OTHR",
|
|
)
|
|
except Exception as e:
|
|
manifest.append(f"{r['id']}\tERROR\t{e}")
|
|
continue
|
|
klub_dir = _safe_filename(primatelj_naziv)
|
|
fname = (f"{klub_dir}/"
|
|
f"{_safe_filename(r.get('prezime') or 'X')}_"
|
|
f"{_safe_filename(r.get('ime') or 'X')}-"
|
|
f"{r['id']}-{r['godina']}.pdf")
|
|
z.writestr(fname, pdf)
|
|
manifest.append(f"{r['id']}\t{fname}\t{dug:.2f} EUR\t{poziv}")
|
|
# Manifest TXT
|
|
z.writestr("_manifest.txt",
|
|
"ID\tFILENAME\tIZNOS\tPOZIV_NA_BROJ\n" + "\n".join(manifest))
|
|
# Manifest JSON
|
|
z.writestr("_manifest.json", _json.dumps(
|
|
{"count": len(rows),
|
|
"generated_at": datetime.now().isoformat(),
|
|
"items": [{"id": r["id"], "klub": r.get("klub"),
|
|
"clan": f"{r.get('ime','')} {r.get('prezime','')}".strip(),
|
|
"godina": r["godina"], "iznos_eur": float(r["dug"] or r["iznos_propisan"] or 0)}
|
|
for r in rows]},
|
|
ensure_ascii=False, indent=2))
|
|
|
|
fname = f"hub3-batch-{date.today().isoformat()}-{len(rows)}.zip"
|
|
return Response(
|
|
content=buf.getvalue(),
|
|
media_type="application/zip",
|
|
headers={"Content-Disposition": f'attachment; filename="{fname}"',
|
|
"X-Batch-Count": str(len(rows))},
|
|
)
|
|
|
|
|
|
# ════════════════════════════════════════════════════
|
|
# R6 #3 — E-MAIL TEMPLATES (CRUD + render + send-mock)
|
|
# ════════════════════════════════════════════════════
|
|
|
|
def _render(tpl: str, vars: dict) -> str:
|
|
"""Vrlo jednostavan {{key}} render."""
|
|
if not tpl:
|
|
return ""
|
|
out = tpl
|
|
for k, v in (vars or {}).items():
|
|
out = out.replace("{{" + str(k) + "}}", "" if v is None else str(v))
|
|
return out
|
|
|
|
|
|
class EmailTemplateIn(BaseModel):
|
|
code: str
|
|
naziv: str
|
|
kategorija: Optional[str] = None
|
|
subject_tpl: str
|
|
body_tpl: str
|
|
variables: Optional[list[str]] = None
|
|
active: bool = True
|
|
|
|
|
|
class EmailTemplatePatch(BaseModel):
|
|
naziv: Optional[str] = None
|
|
kategorija: Optional[str] = None
|
|
subject_tpl: Optional[str] = None
|
|
body_tpl: Optional[str] = None
|
|
variables: Optional[list[str]] = None
|
|
active: Optional[bool] = None
|
|
|
|
|
|
@router.get("/email-templates")
|
|
def list_email_templates(kategorija: Optional[str] = Query(None),
|
|
active_only: bool = Query(True)):
|
|
where, params = [], []
|
|
if active_only:
|
|
where.append("active = TRUE")
|
|
if kategorija:
|
|
where.append("kategorija = %s"); params.append(kategorija)
|
|
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(f"""
|
|
SELECT id, code, naziv, kategorija, subject_tpl, body_tpl,
|
|
variables, active, created_at, updated_at
|
|
FROM pgz_sport.email_templates
|
|
{where_sql}
|
|
ORDER BY kategorija NULLS LAST, naziv
|
|
""", params)
|
|
rows = [_row(r) for r in cur.fetchall()]
|
|
return {"count": len(rows), "templates": rows}
|
|
|
|
|
|
@router.get("/email-templates/{code_or_id}")
|
|
def get_email_template(code_or_id: str):
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
if code_or_id.isdigit():
|
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),))
|
|
else:
|
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,))
|
|
r = cur.fetchone()
|
|
if not r:
|
|
raise HTTPException(404, "Email template ne postoji")
|
|
return _row(r)
|
|
|
|
|
|
@router.post("/email-templates")
|
|
def create_email_template(body: EmailTemplateIn):
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute("""
|
|
INSERT INTO pgz_sport.email_templates
|
|
(code, naziv, kategorija, subject_tpl, body_tpl, variables, active)
|
|
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s)
|
|
RETURNING *
|
|
""", (body.code, body.naziv, body.kategorija, body.subject_tpl,
|
|
body.body_tpl, _json.dumps(body.variables or []), body.active))
|
|
r = cur.fetchone(); conn.commit()
|
|
return _row(r)
|
|
|
|
|
|
@router.put("/email-templates/{code_or_id}")
|
|
def update_email_template(code_or_id: str, body: EmailTemplatePatch):
|
|
fields, params = [], []
|
|
for f in ("naziv", "kategorija", "subject_tpl", "body_tpl", "active"):
|
|
v = getattr(body, f)
|
|
if v is not None:
|
|
fields.append(f"{f} = %s"); params.append(v)
|
|
if body.variables is not None:
|
|
fields.append("variables = %s::jsonb"); params.append(_json.dumps(body.variables))
|
|
if not fields:
|
|
raise HTTPException(400, "Nema polja za izmjenu")
|
|
fields.append("updated_at = now()")
|
|
where_col = "id" if code_or_id.isdigit() else "code"
|
|
where_val = int(code_or_id) if code_or_id.isdigit() else code_or_id
|
|
params.append(where_val)
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(f"UPDATE pgz_sport.email_templates SET {', '.join(fields)} WHERE {where_col}=%s RETURNING *",
|
|
params)
|
|
r = cur.fetchone()
|
|
if not r:
|
|
raise HTTPException(404, "Template ne postoji")
|
|
conn.commit()
|
|
return _row(r)
|
|
|
|
|
|
class EmailRenderIn(BaseModel):
|
|
variables: dict = {}
|
|
|
|
|
|
@router.post("/email-templates/{code_or_id}/render")
|
|
def render_email_template(code_or_id: str, body: EmailRenderIn):
|
|
"""Vrati subject/body s popunjenim {{vars}}."""
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
if code_or_id.isdigit():
|
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),))
|
|
else:
|
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,))
|
|
t = cur.fetchone()
|
|
if not t:
|
|
raise HTTPException(404, "Template ne postoji")
|
|
return {
|
|
"code": t["code"],
|
|
"naziv": t["naziv"],
|
|
"subject": _render(t["subject_tpl"], body.variables),
|
|
"body": _render(t["body_tpl"], body.variables),
|
|
"variables_provided": list(body.variables.keys()),
|
|
"variables_required": t.get("variables") or [],
|
|
}
|
|
|
|
|
|
class EmailSendIn(BaseModel):
|
|
to: Optional[str] = None
|
|
user_id: Optional[int] = None
|
|
variables: dict = {}
|
|
schedule_inapp: bool = True
|
|
|
|
|
|
@router.post("/email-templates/{code_or_id}/send")
|
|
def send_email_template(code_or_id: str, body: EmailSendIn):
|
|
"""
|
|
Mock send: rendera template i upiše u notifications (channel=email + inapp).
|
|
Stvarni SMTP nije konfiguriran.
|
|
"""
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
if code_or_id.isdigit():
|
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),))
|
|
else:
|
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,))
|
|
t = cur.fetchone()
|
|
if not t:
|
|
raise HTTPException(404, "Template ne postoji")
|
|
|
|
subject = _render(t["subject_tpl"], body.variables)
|
|
body_txt = _render(t["body_tpl"], body.variables)
|
|
meta = _json.dumps({"template_code": t["code"],
|
|
"to": body.to,
|
|
"variables": body.variables})
|
|
ids = []
|
|
if body.to:
|
|
cur.execute("""INSERT INTO pgz_sport.notifications
|
|
(user_id, channel, subject, body, status, scheduled_at, meta)
|
|
VALUES (%s,'email',%s,%s,'pending',now(),%s::jsonb)
|
|
RETURNING id""",
|
|
(body.user_id, subject, body_txt, meta))
|
|
ids.append({"channel": "email", "id": cur.fetchone()["id"]})
|
|
if body.schedule_inapp:
|
|
cur.execute("""INSERT INTO pgz_sport.notifications
|
|
(user_id, channel, subject, body, status, scheduled_at, meta)
|
|
VALUES (%s,'inapp',%s,%s,'pending',now(),%s::jsonb)
|
|
RETURNING id""",
|
|
(body.user_id, subject, body_txt, meta))
|
|
ids.append({"channel": "inapp", "id": cur.fetchone()["id"]})
|
|
conn.commit()
|
|
return {"ok": True, "queued": ids, "subject": subject,
|
|
"body_preview": body_txt[:200]}
|
|
|
|
|
|
# ════════════════════════════════════════════════════
|
|
# R6 #4 — /api/notifications/me (alias na /api/crm/notifications/me)
|
|
# ════════════════════════════════════════════════════
|
|
|
|
def _resolve_user_id(authorization: Optional[str], x_user_id: Optional[str]) -> Optional[int]:
|
|
"""
|
|
Priority:
|
|
1) X-User-Id header (UI / debug)
|
|
2) JWT 'sub' claim iz Bearer tokena (auth_v2)
|
|
"""
|
|
if x_user_id:
|
|
try:
|
|
return int(x_user_id)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if not authorization:
|
|
return None
|
|
tok = authorization.replace("Bearer ", "").strip()
|
|
try:
|
|
import jwt as _jwt # type: ignore
|
|
for secret in (
|
|
__import__("os").environ.get("JWT_SECRET"),
|
|
"rinet-jwt-secret-2026",
|
|
):
|
|
if not secret:
|
|
continue
|
|
try:
|
|
payload = _jwt.decode(tok, secret, algorithms=["HS256"])
|
|
sub = payload.get("sub") or payload.get("user_id")
|
|
if sub is not None:
|
|
return int(sub)
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
@router.get("/notifications/me")
|
|
def my_notifications(
|
|
only_unread: bool = Query(True),
|
|
channel: Optional[str] = Query(None),
|
|
limit: int = Query(50, le=200),
|
|
authorization: Optional[str] = Header(None),
|
|
x_user_id: Optional[str] = Header(None),
|
|
):
|
|
"""
|
|
Lista notifikacija za current usera (iz JWT sub ili X-User-Id headera).
|
|
Kao fallback (kad nije autentikiran) vraća notifikacije BEZ user_id
|
|
(broadcast / system).
|
|
"""
|
|
user_id = _resolve_user_id(authorization, x_user_id)
|
|
where = []
|
|
params: list = []
|
|
if user_id is None:
|
|
# broadcast: notifs bez user_id
|
|
where.append("user_id IS NULL")
|
|
else:
|
|
where.append("(user_id = %s OR user_id IS NULL)"); params.append(user_id)
|
|
if only_unread:
|
|
where.append("read_at IS NULL")
|
|
if channel:
|
|
where.append("channel = %s"); params.append(channel)
|
|
params.append(limit)
|
|
with _conn() as conn, conn.cursor() as cur:
|
|
cur.execute(f"""
|
|
SELECT id, user_id, channel, subject, body, status,
|
|
scheduled_at, sent_at, read_at, meta
|
|
FROM pgz_sport.notifications
|
|
WHERE {' AND '.join(where)}
|
|
ORDER BY scheduled_at DESC NULLS LAST
|
|
LIMIT %s
|
|
""", params)
|
|
rows = [_row(r) for r in cur.fetchall()]
|
|
# summary za badge
|
|
sum_where = ["read_at IS NULL"]
|
|
sum_params = []
|
|
if user_id is not None:
|
|
sum_where.append("(user_id = %s OR user_id IS NULL)")
|
|
sum_params.append(user_id)
|
|
else:
|
|
sum_where.append("user_id IS NULL")
|
|
cur.execute(f"""
|
|
SELECT COUNT(*) AS unread,
|
|
COUNT(*) FILTER (WHERE channel='inapp') AS unread_inapp,
|
|
COUNT(*) FILTER (WHERE channel='email') AS unread_email
|
|
FROM pgz_sport.notifications
|
|
WHERE {' AND '.join(sum_where)}
|
|
""", sum_params)
|
|
summary = _row(cur.fetchone())
|
|
return {
|
|
"user_id": user_id,
|
|
"count": len(rows),
|
|
"summary": summary,
|
|
"rows": rows,
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════
|
|
# Alias router: /api/notifications/me (bez /crm prefiksa)
|
|
# ════════════════════════════════════════════════════
|
|
|
|
alias_router = APIRouter(prefix="/api/notifications", tags=["notifications-alias"])
|
|
|
|
|
|
@alias_router.get("/me")
|
|
def my_notifications_alias(
|
|
only_unread: bool = Query(True),
|
|
channel: Optional[str] = Query(None),
|
|
limit: int = Query(50, le=200),
|
|
authorization: Optional[str] = Header(None),
|
|
x_user_id: Optional[str] = Header(None),
|
|
):
|
|
"""Alias za /api/crm/notifications/me — kompatibilnost s /api/notifications/me."""
|
|
return my_notifications(only_unread=only_unread, channel=channel, limit=limit,
|
|
authorization=authorization, x_user_id=x_user_id)
|