#!/usr/bin/env python3 # ═══════════════════════════════════════════════════════════════════ # Fajl: routers/crm_router.py | v1.0.0 | 05.05.2026 # Autor: Damir Radulić / damir@rinet.one # Lokacija: /opt/pgz-sport/routers/crm_router.py # Svrha: Salesforce-Lite CRM (Accounts/Contacts/Leads/Opportunities/Activities/Cases) # + pipeline kanban view, /convert za leads, /complete za activities. # ═══════════════════════════════════════════════════════════════════ """CRM v2 router (Salesforce-style). Prefix: /api/v2/crm """ from __future__ import annotations import hashlib from datetime import date, datetime from decimal import Decimal from typing import Optional, List, Dict, Any import psycopg2 from psycopg2.extras import RealDictCursor from fastapi import APIRouter, HTTPException, Query, Body, Depends, Header from pydantic import BaseModel, Field router = APIRouter(prefix="/api/v2/crm", tags=["crm-v2"]) DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7" STAGES = ['prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won', 'closed_lost'] LEAD_STATUSES = ['new', 'contacted', 'qualified', 'lost', 'converted'] CASE_STATUSES = ['open', 'in_progress', 'waiting', 'resolved', 'closed'] CASE_PRIORITIES = ['low', 'normal', 'high', 'urgent'] ACT_TYPES = ['call', 'meeting', 'email', 'task', 'note'] # ─────────── DB helpers ─────────── 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): if d is None: return None return {k: _conv(v) for k, v in dict(d).items()} def _rows(seq): return [_row(r) for r in (seq or [])] # ─────────── Auth helper (lightweight, mirrors v2 router) ─────────── def _current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict[str, Any]]: """Resolve session user from Bearer token (token_hash = sha256(token)).""" if not authorization: return None tok = authorization.replace("Bearer ", "").strip() if not tok: return None th = hashlib.sha256(tok.encode()).hexdigest() try: with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT s.user_id, u.email, u.user_type, u.klub_id, u.savez_id, u.aktivan, u.ime, u.prezime FROM pgz_sport.user_sessions s JOIN pgz_sport.users u ON u.id = s.user_id WHERE s.token_hash = %s AND s.revoked = false AND s.expires_at > now() LIMIT 1 """, (th,)) r = cur.fetchone() if not r: return None d = _row(r) if d.get("aktivan") is False: return None return d except Exception: return None def _require_user(user=Depends(_current_user)): if not user: raise HTTPException(status_code=401, detail="Authentication required") return user def _is_admin(user: Dict) -> bool: return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin") def _check_owner_or_admin(user: Dict, owner_user_id: Optional[int]): if _is_admin(user): return if owner_user_id is not None and user.get("user_id") == owner_user_id: return raise HTTPException(status_code=403, detail="Insufficient privileges") # ─────────── PIPELINE / DASHBOARD ─────────── @router.get("/pipeline") def pipeline(user=Depends(_require_user)): """Aggregate opportunities by stage (count, sum, weighted, items).""" with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT o.id, o.naziv, o.stage, o.amount_eur, o.probability, o.close_date, o.account_id, o.type, a.naziv AS account_naziv FROM pgz_sport.crm_opportunities o LEFT JOIN pgz_sport.crm_accounts a ON a.id = o.account_id ORDER BY o.close_date NULLS LAST, o.id """) opps = _rows(cur.fetchall()) buckets = {s: {"stage": s, "count": 0, "amount_total": 0.0, "weighted_total": 0.0, "items": []} for s in STAGES} for o in opps: s = o.get("stage") if s not in buckets: continue amt = float(o.get("amount_eur") or 0) prob = float(o.get("probability") or 0) buckets[s]["count"] += 1 buckets[s]["amount_total"] += amt buckets[s]["weighted_total"] += amt * prob / 100.0 buckets[s]["items"].append(o) return {"stages": [buckets[s] for s in STAGES]} @router.get("/dashboard") def dashboard(user=Depends(_require_user)): out = {} with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT COUNT(*) FILTER (WHERE stage NOT IN ('closed_won','closed_lost')) AS open_opps, COALESCE(SUM(amount_eur) FILTER (WHERE stage NOT IN ('closed_won','closed_lost')), 0) AS open_amount, COALESCE(SUM(amount_eur * probability / 100.0) FILTER (WHERE stage NOT IN ('closed_won','closed_lost')), 0) AS weighted_amount, COUNT(*) FILTER (WHERE stage = 'closed_won' AND close_date >= date_trunc('quarter', current_date)) AS won_q, COALESCE(SUM(amount_eur) FILTER (WHERE stage = 'closed_won' AND close_date >= date_trunc('quarter', current_date)), 0) AS won_q_amount FROM pgz_sport.crm_opportunities """) out["opportunities"] = _row(cur.fetchone()) cur.execute(""" SELECT status, COUNT(*) AS n FROM pgz_sport.crm_leads GROUP BY status """) out["leads_by_status"] = _rows(cur.fetchall()) cur.execute(""" SELECT COUNT(*) FILTER (WHERE completed_at IS NULL AND due_at < now()) AS overdue, COUNT(*) FILTER (WHERE completed_at IS NULL AND due_at >= now()) AS upcoming, COUNT(*) FILTER (WHERE completed_at IS NOT NULL) AS done FROM pgz_sport.crm_activities """) out["activities"] = _row(cur.fetchone()) cur.execute(""" SELECT status, COUNT(*) AS n FROM pgz_sport.crm_cases GROUP BY status """) out["cases_by_status"] = _rows(cur.fetchall()) cur.execute("SELECT COUNT(*) AS n FROM pgz_sport.crm_accounts") out["accounts_total"] = _row(cur.fetchone())["n"] cur.execute("SELECT COUNT(*) AS n FROM pgz_sport.crm_contacts") out["contacts_total"] = _row(cur.fetchone())["n"] return out # ─────────── ACCOUNTS ─────────── class AccountIn(BaseModel): naziv: str type: str = 'klub' klub_id: Optional[int] = None savez_id: Optional[int] = None oib: Optional[str] = None email: Optional[str] = None telefon: Optional[str] = None web: Optional[str] = None adresa: Optional[str] = None grad: Optional[str] = None industry: Optional[str] = None napomene: Optional[str] = None @router.get("/accounts") def list_accounts(q: Optional[str] = None, type: Optional[str] = None, owner_id: Optional[int] = None, limit: int = 200, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if q: where.append("(a.naziv ILIKE %s OR a.oib ILIKE %s OR a.email ILIKE %s OR a.grad ILIKE %s)") like = f"%{q}%" params += [like, like, like, like] if type: where.append("a.type = %s") params.append(type) if owner_id: where.append("a.owner_user_id = %s") params.append(owner_id) sql = f""" SELECT a.*, (SELECT COUNT(*) FROM pgz_sport.crm_contacts c WHERE c.account_id = a.id) AS contacts_n, (SELECT COUNT(*) FROM pgz_sport.crm_opportunities o WHERE o.account_id = a.id) AS opps_n, u.email AS owner_email FROM pgz_sport.crm_accounts a LEFT JOIN pgz_sport.users u ON u.id = a.owner_user_id WHERE {' AND '.join(where)} ORDER BY a.updated_at DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/accounts") def create_account(req: AccountIn, user=Depends(_require_user)): if req.type not in ('klub', 'savez', 'sponzor', 'drzava', 'drugo'): raise HTTPException(400, "invalid type") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_accounts (naziv, type, klub_id, savez_id, oib, email, telefon, web, adresa, grad, industry, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.naziv, req.type, req.klub_id, req.savez_id, req.oib, req.email, req.telefon, req.web, req.adresa, req.grad, req.industry, req.napomene, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/accounts/{aid}") def get_account(aid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.crm_accounts WHERE id=%s", (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") out = _row(row) cur.execute("SELECT * FROM pgz_sport.crm_contacts WHERE account_id=%s ORDER BY updated_at DESC", (aid,)) out["contacts"] = _rows(cur.fetchall()) cur.execute("SELECT * FROM pgz_sport.crm_opportunities WHERE account_id=%s ORDER BY updated_at DESC", (aid,)) out["opportunities"] = _rows(cur.fetchall()) cur.execute("""SELECT * FROM pgz_sport.crm_activities WHERE account_id=%s ORDER BY COALESCE(due_at, created_at) DESC LIMIT 50""", (aid,)) out["activities"] = _rows(cur.fetchall()) cur.execute("SELECT * FROM pgz_sport.crm_cases WHERE account_id=%s ORDER BY updated_at DESC", (aid,)) out["cases"] = _rows(cur.fetchall()) return out @router.put("/accounts/{aid}") def update_account(aid: int, req: AccountIn, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_accounts WHERE id=%s", (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_accounts SET naziv=%s, type=%s, klub_id=%s, savez_id=%s, oib=%s, email=%s, telefon=%s, web=%s, adresa=%s, grad=%s, industry=%s, napomene=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.naziv, req.type, req.klub_id, req.savez_id, req.oib, req.email, req.telefon, req.web, req.adresa, req.grad, req.industry, req.napomene, aid)) cn.commit() return _row(cur.fetchone()) @router.delete("/accounts/{aid}") def delete_account(aid: int, user=Depends(_require_user)): if not _is_admin(user): raise HTTPException(403, "admin only") with _conn() as cn, cn.cursor() as cur: cur.execute("DELETE FROM pgz_sport.crm_accounts WHERE id=%s RETURNING id", (aid,)) if not cur.fetchone(): raise HTTPException(404, "not found") cn.commit() return {"ok": True, "id": aid} # ─────────── CONTACTS ─────────── class ContactIn(BaseModel): account_id: Optional[int] = None clan_id: Optional[int] = None ime: str prezime: str funkcija: Optional[str] = None email: Optional[str] = None telefon: Optional[str] = None mobitel: Optional[str] = None napomene: Optional[str] = None @router.get("/contacts") def list_contacts(account_id: Optional[int] = None, q: Optional[str] = None, limit: int = 200, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if account_id: where.append("c.account_id = %s") params.append(account_id) if q: where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.email ILIKE %s OR c.funkcija ILIKE %s)") like = f"%{q}%" params += [like, like, like, like] sql = f""" SELECT c.*, a.naziv AS account_naziv FROM pgz_sport.crm_contacts c LEFT JOIN pgz_sport.crm_accounts a ON a.id = c.account_id WHERE {' AND '.join(where)} ORDER BY c.updated_at DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/contacts") def create_contact(req: ContactIn, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_contacts (account_id, clan_id, ime, prezime, funkcija, email, telefon, mobitel, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.account_id, req.clan_id, req.ime, req.prezime, req.funkcija, req.email, req.telefon, req.mobitel, req.napomene, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/contacts/{cid}") def get_contact(cid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT c.*, a.naziv AS account_naziv FROM pgz_sport.crm_contacts c LEFT JOIN pgz_sport.crm_accounts a ON a.id = c.account_id WHERE c.id=%s """, (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.put("/contacts/{cid}") def update_contact(cid: int, req: ContactIn, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_contacts WHERE id=%s", (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_contacts SET account_id=%s, clan_id=%s, ime=%s, prezime=%s, funkcija=%s, email=%s, telefon=%s, mobitel=%s, napomene=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.account_id, req.clan_id, req.ime, req.prezime, req.funkcija, req.email, req.telefon, req.mobitel, req.napomene, cid)) cn.commit() return _row(cur.fetchone()) @router.delete("/contacts/{cid}") def delete_contact(cid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_contacts WHERE id=%s", (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute("DELETE FROM pgz_sport.crm_contacts WHERE id=%s", (cid,)) cn.commit() return {"ok": True, "id": cid} # ─────────── LEADS ─────────── class LeadIn(BaseModel): ime: Optional[str] = None prezime: Optional[str] = None organizacija: Optional[str] = None email: Optional[str] = None telefon: Optional[str] = None izvor: Optional[str] = None status: str = 'new' napomene: Optional[str] = None class ConvertReq(BaseModel): account: Optional[Dict[str, Any]] = None contact: Optional[Dict[str, Any]] = None opportunity: Optional[Dict[str, Any]] = None @router.get("/leads") def list_leads(status: Optional[str] = None, q: Optional[str] = None, limit: int = 200, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if status: where.append("status = %s") params.append(status) if q: where.append("(ime ILIKE %s OR prezime ILIKE %s OR organizacija ILIKE %s OR email ILIKE %s)") like = f"%{q}%" params += [like, like, like, like] sql = f""" SELECT * FROM pgz_sport.crm_leads WHERE {' AND '.join(where)} ORDER BY updated_at DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/leads") def create_lead(req: LeadIn, user=Depends(_require_user)): if req.status not in LEAD_STATUSES: raise HTTPException(400, "invalid status") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_leads (ime, prezime, organizacija, email, telefon, izvor, status, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.ime, req.prezime, req.organizacija, req.email, req.telefon, req.izvor, req.status, req.napomene, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/leads/{lid}") def get_lead(lid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.crm_leads WHERE id=%s", (lid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.put("/leads/{lid}") def update_lead(lid: int, req: LeadIn, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_leads WHERE id=%s", (lid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_leads SET ime=%s, prezime=%s, organizacija=%s, email=%s, telefon=%s, izvor=%s, status=%s, napomene=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.ime, req.prezime, req.organizacija, req.email, req.telefon, req.izvor, req.status, req.napomene, lid)) cn.commit() return _row(cur.fetchone()) @router.delete("/leads/{lid}") def delete_lead(lid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_leads WHERE id=%s", (lid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute("DELETE FROM pgz_sport.crm_leads WHERE id=%s", (lid,)) cn.commit() return {"ok": True, "id": lid} @router.post("/leads/{lid}/convert") def convert_lead(lid: int, req: ConvertReq, user=Depends(_require_user)): """Lead → Account + Contact (+ optional Opportunity). Sets lead status=converted.""" with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.crm_leads WHERE id=%s", (lid,)) lead = cur.fetchone() if not lead: raise HTTPException(404, "lead not found") if lead["status"] == "converted": raise HTTPException(409, "already converted") a_payload = req.account or {} a_naziv = a_payload.get("naziv") or lead["organizacija"] or f'{lead.get("ime","")} {lead.get("prezime","")}'.strip() or "Lead" a_type = a_payload.get("type") or "drugo" cur.execute(""" INSERT INTO pgz_sport.crm_accounts (naziv, type, email, telefon, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s) RETURNING * """, (a_naziv, a_type, a_payload.get("email") or lead["email"], a_payload.get("telefon") or lead["telefon"], a_payload.get("napomene"), user["user_id"])) new_account = cur.fetchone() new_contact = None if (lead["ime"] or lead["prezime"]) or req.contact: c_payload = req.contact or {} cur.execute(""" INSERT INTO pgz_sport.crm_contacts (account_id, ime, prezime, email, telefon, funkcija, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (new_account["id"], c_payload.get("ime") or lead["ime"] or "", c_payload.get("prezime") or lead["prezime"] or "", c_payload.get("email") or lead["email"], c_payload.get("telefon") or lead["telefon"], c_payload.get("funkcija"), user["user_id"])) new_contact = cur.fetchone() new_opp = None if req.opportunity: o = req.opportunity cur.execute(""" INSERT INTO pgz_sport.crm_opportunities (account_id, contact_id, naziv, type, stage, amount_eur, probability, close_date, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (new_account["id"], new_contact["id"] if new_contact else None, o.get("naziv") or f"Opportunity from lead {lid}", o.get("type") or "financiranje", o.get("stage") or "prospecting", o.get("amount_eur"), o.get("probability") or 20, o.get("close_date"), o.get("napomene"), user["user_id"])) new_opp = cur.fetchone() cur.execute(""" UPDATE pgz_sport.crm_leads SET status='converted', converted_account_id=%s, converted_contact_id=%s, converted_at=now(), updated_at=now() WHERE id=%s RETURNING * """, (new_account["id"], new_contact["id"] if new_contact else None, lid)) updated_lead = cur.fetchone() cn.commit() return { "lead": _row(updated_lead), "account": _row(new_account), "contact": _row(new_contact), "opportunity": _row(new_opp), } # ─────────── OPPORTUNITIES ─────────── class OppIn(BaseModel): account_id: int contact_id: Optional[int] = None naziv: str type: str = 'financiranje' stage: str = 'prospecting' amount_eur: Optional[float] = None probability: int = 20 close_date: Optional[str] = None napomene: Optional[str] = None class StageReq(BaseModel): stage: str @router.get("/opportunities") def list_opps(stage: Optional[str] = None, account_id: Optional[int] = None, q: Optional[str] = None, limit: int = 500, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if stage: where.append("o.stage = %s") params.append(stage) if account_id: where.append("o.account_id = %s") params.append(account_id) if q: where.append("(o.naziv ILIKE %s OR a.naziv ILIKE %s)") like = f"%{q}%" params += [like, like] sql = f""" SELECT o.*, a.naziv AS account_naziv FROM pgz_sport.crm_opportunities o LEFT JOIN pgz_sport.crm_accounts a ON a.id = o.account_id WHERE {' AND '.join(where)} ORDER BY o.close_date NULLS LAST, o.id DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/opportunities") def create_opp(req: OppIn, user=Depends(_require_user)): if req.stage not in STAGES: raise HTTPException(400, "invalid stage") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_opportunities (account_id, contact_id, naziv, type, stage, amount_eur, probability, close_date, napomene, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.account_id, req.contact_id, req.naziv, req.type, req.stage, req.amount_eur, req.probability, req.close_date, req.napomene, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/opportunities/{oid}") def get_opp(oid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT o.*, a.naziv AS account_naziv FROM pgz_sport.crm_opportunities o LEFT JOIN pgz_sport.crm_accounts a ON a.id = o.account_id WHERE o.id=%s """, (oid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.put("/opportunities/{oid}") def update_opp(oid: int, req: OppIn, user=Depends(_require_user)): if req.stage not in STAGES: raise HTTPException(400, "invalid stage") with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_opportunities WHERE id=%s", (oid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_opportunities SET account_id=%s, contact_id=%s, naziv=%s, type=%s, stage=%s, amount_eur=%s, probability=%s, close_date=%s, napomene=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.account_id, req.contact_id, req.naziv, req.type, req.stage, req.amount_eur, req.probability, req.close_date, req.napomene, oid)) cn.commit() return _row(cur.fetchone()) @router.patch("/opportunities/{oid}/stage") def patch_stage(oid: int, req: StageReq, user=Depends(_require_user)): if req.stage not in STAGES: raise HTTPException(400, "invalid stage") with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_opportunities WHERE id=%s", (oid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_opportunities SET stage=%s, updated_at=now() WHERE id=%s RETURNING * """, (req.stage, oid)) cn.commit() return _row(cur.fetchone()) @router.delete("/opportunities/{oid}") def delete_opp(oid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_opportunities WHERE id=%s", (oid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute("DELETE FROM pgz_sport.crm_opportunities WHERE id=%s", (oid,)) cn.commit() return {"ok": True, "id": oid} # ─────────── ACTIVITIES ─────────── class ActivityIn(BaseModel): type: str subject: str body: Optional[str] = None account_id: Optional[int] = None contact_id: Optional[int] = None opportunity_id: Optional[int] = None lead_id: Optional[int] = None due_at: Optional[str] = None @router.get("/activities") def list_activities(account_id: Optional[int] = None, contact_id: Optional[int] = None, opp_id: Optional[int] = None, lead_id: Optional[int] = None, type: Optional[str] = None, open_only: Optional[bool] = None, limit: int = 200, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if account_id: where.append("ac.account_id = %s"); params.append(account_id) if contact_id: where.append("ac.contact_id = %s"); params.append(contact_id) if opp_id: where.append("ac.opportunity_id = %s"); params.append(opp_id) if lead_id: where.append("ac.lead_id = %s"); params.append(lead_id) if type: where.append("ac.type = %s"); params.append(type) if open_only is True: where.append("ac.completed_at IS NULL") elif open_only is False: where.append("ac.completed_at IS NOT NULL") sql = f""" SELECT ac.*, a.naziv AS account_naziv, c.ime || ' ' || c.prezime AS contact_naziv, o.naziv AS opp_naziv FROM pgz_sport.crm_activities ac LEFT JOIN pgz_sport.crm_accounts a ON a.id = ac.account_id LEFT JOIN pgz_sport.crm_contacts c ON c.id = ac.contact_id LEFT JOIN pgz_sport.crm_opportunities o ON o.id = ac.opportunity_id WHERE {' AND '.join(where)} ORDER BY COALESCE(ac.due_at, ac.created_at) DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/activities") def create_activity(req: ActivityIn, user=Depends(_require_user)): if req.type not in ACT_TYPES: raise HTTPException(400, "invalid type") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_activities (type, subject, body, account_id, contact_id, opportunity_id, lead_id, due_at, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.type, req.subject, req.body, req.account_id, req.contact_id, req.opportunity_id, req.lead_id, req.due_at, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/activities/{aid}") def get_activity(aid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.crm_activities WHERE id=%s", (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.put("/activities/{aid}") def update_activity(aid: int, req: ActivityIn, user=Depends(_require_user)): if req.type not in ACT_TYPES: raise HTTPException(400, "invalid type") with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_activities WHERE id=%s", (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute(""" UPDATE pgz_sport.crm_activities SET type=%s, subject=%s, body=%s, account_id=%s, contact_id=%s, opportunity_id=%s, lead_id=%s, due_at=%s WHERE id=%s RETURNING * """, (req.type, req.subject, req.body, req.account_id, req.contact_id, req.opportunity_id, req.lead_id, req.due_at, aid)) cn.commit() return _row(cur.fetchone()) @router.patch("/activities/{aid}/complete") def complete_activity(aid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" UPDATE pgz_sport.crm_activities SET completed_at=now() WHERE id=%s RETURNING * """, (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") cn.commit() return _row(row) @router.delete("/activities/{aid}") def delete_activity(aid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_activities WHERE id=%s", (aid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute("DELETE FROM pgz_sport.crm_activities WHERE id=%s", (aid,)) cn.commit() return {"ok": True, "id": aid} # ─────────── CASES ─────────── class CaseIn(BaseModel): account_id: Optional[int] = None contact_id: Optional[int] = None subject: str description: Optional[str] = None status: str = 'open' priority: str = 'normal' @router.get("/cases") def list_cases(status: Optional[str] = None, priority: Optional[str] = None, account_id: Optional[int] = None, q: Optional[str] = None, limit: int = 200, offset: int = 0, user=Depends(_require_user)): where, params = ["1=1"], [] if status: where.append("k.status = %s"); params.append(status) if priority: where.append("k.priority = %s"); params.append(priority) if account_id: where.append("k.account_id = %s"); params.append(account_id) if q: where.append("(k.subject ILIKE %s OR k.description ILIKE %s)") like = f"%{q}%" params += [like, like] sql = f""" SELECT k.*, a.naziv AS account_naziv FROM pgz_sport.crm_cases k LEFT JOIN pgz_sport.crm_accounts a ON a.id = k.account_id WHERE {' AND '.join(where)} ORDER BY (CASE k.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END), k.updated_at DESC LIMIT %s OFFSET %s """ params += [limit, offset] with _conn() as cn, cn.cursor() as cur: cur.execute(sql, params) return {"items": _rows(cur.fetchall())} @router.post("/cases") def create_case(req: CaseIn, user=Depends(_require_user)): if req.status not in CASE_STATUSES: raise HTTPException(400, "invalid status") if req.priority not in CASE_PRIORITIES: raise HTTPException(400, "invalid priority") with _conn() as cn, cn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.crm_cases (account_id, contact_id, subject, description, status, priority, owner_user_id) VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (req.account_id, req.contact_id, req.subject, req.description, req.status, req.priority, user["user_id"])) cn.commit() return _row(cur.fetchone()) @router.get("/cases/{cid}") def get_case(cid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute(""" SELECT k.*, a.naziv AS account_naziv FROM pgz_sport.crm_cases k LEFT JOIN pgz_sport.crm_accounts a ON a.id = k.account_id WHERE k.id=%s """, (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") return _row(row) @router.put("/cases/{cid}") def update_case(cid: int, req: CaseIn, user=Depends(_require_user)): if req.status not in CASE_STATUSES: raise HTTPException(400, "invalid status") if req.priority not in CASE_PRIORITIES: raise HTTPException(400, "invalid priority") with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_cases WHERE id=%s", (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) resolved_at_clause = ", resolved_at=now()" if req.status in ('resolved', 'closed') else "" cur.execute(f""" UPDATE pgz_sport.crm_cases SET account_id=%s, contact_id=%s, subject=%s, description=%s, status=%s, priority=%s, updated_at=now() {resolved_at_clause} WHERE id=%s RETURNING * """, (req.account_id, req.contact_id, req.subject, req.description, req.status, req.priority, cid)) cn.commit() return _row(cur.fetchone()) @router.delete("/cases/{cid}") def delete_case(cid: int, user=Depends(_require_user)): with _conn() as cn, cn.cursor() as cur: cur.execute("SELECT owner_user_id FROM pgz_sport.crm_cases WHERE id=%s", (cid,)) row = cur.fetchone() if not row: raise HTTPException(404, "not found") _check_owner_or_admin(user, row["owner_user_id"]) cur.execute("DELETE FROM pgz_sport.crm_cases WHERE id=%s", (cid,)) cn.commit() return {"ok": True, "id": cid}