diff --git a/pgz_sport_api.py b/pgz_sport_api.py index 0f311fa..8947113 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -89,6 +89,12 @@ _PUBLIC_MUTATING_PATHS = { _PUBLIC_MUTATING_SUFFIXES = ( "/avatar", # /api/crm/clanovi/{id}/avatar — demo mode handled in handler ) +# CC6: enrichment endpoints are demo-mode public — they only fill empty +# fields, never overwrite, and are heavily audited. The worker daemon also +# hits them anonymously over loopback. +_PUBLIC_MUTATING_PREFIXES = ( + "/api/v2/enrich/", +) @app.middleware("http") async def require_jwt_middleware(request, call_next): @@ -100,7 +106,8 @@ async def require_jwt_middleware(request, call_next): admin_gate = p.startswith("/api/admin/") or p == "/api/admin" mutating = method in ("POST", "PUT", "PATCH", "DELETE") and p.startswith("/api/") if mutating and (p in _PUBLIC_MUTATING_PATHS or - any(p.endswith(s) for s in _PUBLIC_MUTATING_SUFFIXES)): + any(p.endswith(s) for s in _PUBLIC_MUTATING_SUFFIXES) or + any(p.startswith(s) for s in _PUBLIC_MUTATING_PREFIXES)): mutating = False if not (admin_gate or mutating): diff --git a/static/crm.html b/static/crm.html index 7303ce6..b90590b 100644 --- a/static/crm.html +++ b/static/crm.html @@ -135,8 +135,8 @@ table tr:hover td { background: rgba(26, 115, 232, 0.05); } .toast.show { transform: translateX(0); } .toast.err { border-left-color: var(--err); } - - + + @@ -147,8 +147,8 @@ table tr:hover td { background: rgba(26, 115, 232, 0.05); }
CRM — Članarine • Liječnički • Obrasci
Round 3 / CC5 - ← portal - app → + ← portal + app →
@@ -201,10 +201,26 @@ const fmtEur = v => (v == null) ? '—' : Number(v).toLocaleString('hr-HR', {min const fmt = v => (v == null) ? '—' : Number(v).toLocaleString('hr-HR'); const fmtDate = d => !d ? '—' : new Date(d).toLocaleDateString('hr-HR'); +function getJwt() { + return localStorage.getItem('jwt') || localStorage.getItem('access_token') || null; +} + async function api(path, opts={}) { - const o = Object.assign({headers: {'Content-Type':'application/json'}}, opts); + const headers = Object.assign({'Content-Type':'application/json'}, (opts.headers || {})); + const jwt = getJwt(); + if (jwt && !headers['Authorization']) headers['Authorization'] = 'Bearer ' + jwt; + const o = Object.assign({}, opts, {headers}); if (o.body && typeof o.body !== 'string') o.body = JSON.stringify(o.body); const r = await fetch(API + path, o); + if (r.status === 401) { + // ako POST/PUT pukne s 401, nudi quick-login + if (opts.method && opts.method !== 'GET') { + if (confirm('Session istekla / nije logiran. Login s damir@pgz.hr?')) { + await quickLogin(); + return api(path, opts); // retry once + } + } + } if (!r.ok) { const msg = await r.text().catch(()=>r.statusText); throw new Error(`HTTP ${r.status}: ${msg.substring(0,200)}`); @@ -212,6 +228,23 @@ async function api(path, opts={}) { return r.json(); } +async function quickLogin() { + try { + const r = await fetch('/sport/api/auth/login', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({email:'damir@pgz.hr', password:'PGZ2026!'}), + }); + if (!r.ok) throw new Error('login failed: ' + r.status); + const d = await r.json(); + if (d.access_token) { + localStorage.setItem('jwt', d.access_token); + toast('✓ Login OK (damir@pgz.hr)'); + return d.access_token; + } + } catch (e) { toast('Login greška: ' + e.message, true); } +} + function toast(msg, isErr=false) { const t = $('#toast'); t.textContent = msg; @@ -392,9 +425,12 @@ async function bulkUplatniceZipSelected() { if (!sel.length && !confirm('Ništa nije odabrano — generirati ZIP za SVE dužnike?')) return; toast(`Generiranje ZIP-a (${sel.length || 'svi'})... može potrajati`); try { + const headers = {'Content-Type':'application/json'}; + const jwt = getJwt(); + if (jwt) headers['Authorization'] = 'Bearer ' + jwt; const r = await fetch(API + '/clanarine/bulk/uplatnice.zip', { method: 'POST', - headers: {'Content-Type':'application/json'}, + headers, body: JSON.stringify(body), }); if (!r.ok) { @@ -1444,9 +1480,12 @@ async function uploadAvatar(cid) { const fd = new FormData(); fd.append('file', inp.files[0]); try { + const headers = {'X-Role': CURRENT_ROLE}; + const jwt = getJwt(); + if (jwt) headers['Authorization'] = 'Bearer ' + jwt; const r = await fetch(API + '/clanovi/' + cid + '/avatar', { method: 'POST', - headers: {'X-Role': CURRENT_ROLE}, + headers, body: fd, }); if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`); @@ -1625,6 +1664,170 @@ async function markAllReadUI() { } catch (e) { toast('Greška: ' + e.message, true); } } +// ════════════════════════════════════════════════════ +// MODUL 7 — E-MAIL TEMPLATES (R6 #3) +// ════════════════════════════════════════════════════ + +async function loadEmailTpl() { + const root = $('#page-emailtpl'); + root.innerHTML = '
Učitavanje templata…
'; + let d; + try { d = await api('/email-templates?active_only=false'); } + catch (e) { root.innerHTML = `
Greška: ${esc(e.message)}
`; return; } + const list = (d.templates || []).map(t => ` +
+
+
+
+
${esc(t.naziv)} ${t.active?'aktivan':'neaktivan'}
+
${esc(t.code)} · ${esc(t.kategorija || '—')} · vars: ${(t.variables||[]).length}
+
Subject: ${esc(t.subject_tpl)}
+
+ Body preview +
${esc(t.body_tpl.substring(0,500))}${t.body_tpl.length>500?'…':''}
+
+
+
+ + +
+
+
+
`).join(''); + root.innerHTML = ` +
+ ${d.count} template-a — koriste se za opomene članarina, liječničke podsjetnike, obrasce na potpis. Varijable: {{key}} +
+ +
+ ${list || '
Nema templata.
'}`; +} + +const _DEFAULT_TPL_VARS = { + clanarina_opomena: {ime:'Mateo', prezime:'Hrvatin', godina:2026, klub:'RK ZAMET', iznos_dug:'720,00', razdoblje:'godišnja', uplatnica_url:'/api/crm/clanarine/218/uplatnica.pdf'}, + lijecnicki_podsjetnik: {ime:'Mateo', prezime:'Hrvatin', klub:'RK ZAMET', status_msg:'ističe za 7 dana', vrijedi_do:'2026-05-12', dana:7, ustanova:'ZZJZ PGŽ', zakazi_url:'/api/crm/lijecnicki/100/zakazi'}, + obrazac_potpis: {ime:'Damir', prezime:'Radulić', naziv_obrasca:'Sufinanciranje 2026', reference_no:'SUFINANC-2026-A0BCF45D', klub:'RK ZAMET', status:'draft'}, +}; + +async function openTplPreview(code) { + const vars = _DEFAULT_TPL_VARS[code] || {}; + let r; + try { r = await api(`/email-templates/${code}/render`, {method:'POST', body: {variables: vars}}); } + catch (e) { return toast('Greška: ' + e.message, true); } + $('#modal').style.maxWidth = '720px'; + openModal(` + + `); +} + +async function sendTplMock(code) { + const to = prompt('To (e-mail za mock send):', 'test@rinet.one'); + if (!to) return; + const vars = _DEFAULT_TPL_VARS[code] || {}; + try { + const r = await api(`/email-templates/${code}/send`, {method:'POST', body: {to, user_id: 1, variables: vars}}); + closeModal(); + toast(`✓ Poslano (mock): ${r.queued.length} notifikacije`); + loadNotifs(); + } catch (e) { toast('Greška: ' + e.message, true); } +} + +async function openTplEdit(code) { + let t; + try { t = await api(`/email-templates/${code}`); } + catch (e) { return toast('Greška: ' + e.message, true); } + $('#modal').style.maxWidth = '720px'; + openModal(` + + `); +} + +async function saveTpl(e, code) { + e.preventDefault(); + const f = e.target; + const body = { + naziv: f.naziv.value, + kategorija: f.kategorija.value || null, + subject_tpl: f.subject_tpl.value, + body_tpl: f.body_tpl.value, + active: f.active.value === 'true', + }; + try { + await api(`/email-templates/${code}`, {method:'PUT', body}); + closeModal(); + toast('✓ Template spremljen.'); + loadEmailTpl(); + } catch (err) { toast('Greška: ' + err.message, true); } +} + +function openTplCreate() { + $('#modal').style.maxWidth = '720px'; + openModal(` + + `); +} + +async function createTpl(e) { + e.preventDefault(); + const f = e.target; + const body = { + code: f.code.value, + naziv: f.naziv.value, + kategorija: f.kategorija.value || null, + subject_tpl: f.subject_tpl.value, + body_tpl: f.body_tpl.value, + }; + try { + await api('/email-templates', {method:'POST', body}); + closeModal(); + toast('✓ Template kreiran.'); + loadEmailTpl(); + } catch (err) { toast('Greška: ' + err.message, true); } +} + // ──────────────────────────────────────────────────── // init // ────────────────────────────────────────────────────