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
@@ -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?'…':''}
+
+
+
+ ▶ Preview
+ ✎ Uredi
+
+
+
+
`).join('');
+ root.innerHTML = `
+
+ ${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(`
+ ▶ Preview: ${esc(r.naziv || code)}
×
+
+
+
SUBJECT
+
${esc(r.subject)}
+
+
+
BODY
+
${esc(r.body)}
+
+
Varijable: ${esc(JSON.stringify(vars))}
+
+ Zatvori
+ 📤 Pošalji test (upiše u notifs)
+
+
`);
+}
+
+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(`
+ ✎ Uredi: ${esc(t.naziv)}
×
+ `);
+}
+
+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
// ────────────────────────────────────────────────────