DI exec: applied CC-DI Subagent A+B SQL — 3245 clanovi, Manuel Boras merged
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"3eea00ef-fccd-4683-85c6-f7d39e8199a7","pid":1940465,"procStart":"327348495","acquiredAt":1777964592489}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Subagent D — Schema Quality Constraints (pgz_sport.clanovi)
|
||||||
|
|
||||||
|
Date: 2026-05-05
|
||||||
|
Live row count: 3240 (backup retained at `pgz_sport.clanovi_backup_20260505_0836` = 3243)
|
||||||
|
|
||||||
|
## Summary table
|
||||||
|
|
||||||
|
| # | Candidate | Type | Pre-flight violators | Status | Object name |
|
||||||
|
|---|-----------|------|----------------------|--------|-------------|
|
||||||
|
| C1 | No internal CamelCase boundary | CHECK | 0 | APPLIED | `clanovi_no_camelcase_chk` |
|
||||||
|
| C2 | ime/prezime trimmed | CHECK | 0 | APPLIED | `clanovi_trimmed_chk` |
|
||||||
|
| C3 | length(ime) >= 2 AND length(prezime) >= 2 | CHECK | 22 | SKIPPED (see D_violations.md) | — |
|
||||||
|
| C4 | spol IN ('M','Ž',NULL) | CHECK | 0 | ALREADY PRESENT | `clanovi_spol_check` (pre-existing) |
|
||||||
|
| C5 | hns_igrac_id partial UNIQUE | UNIQUE INDEX | 0 dup-groups | APPLIED | `clanovi_hns_uniq` |
|
||||||
|
| C6 | (klub_id, lower(ime), lower(prezime), datum_rodenja) UNIQUE | UNIQUE INDEX | 68 dup-groups | SKIPPED (see D_violations.md) | — |
|
||||||
|
| C7 | BEFORE INSERT/UPDATE normalize trigger | TRIGGER | n/a | APPLIED | `clanovi_normalize_trigger` + `pgz_sport.clanovi_normalize_fn()` |
|
||||||
|
|
||||||
|
## Trigger semantics
|
||||||
|
|
||||||
|
`clanovi_normalize_fn`:
|
||||||
|
1. Always `trim()` `NEW.ime` and `NEW.prezime`.
|
||||||
|
2. On `INSERT`, or on `UPDATE` only when `ime` or `prezime` actually change:
|
||||||
|
- reject CamelCase boundary (lenient: only ascii+Croatian-diacritic lower→upper pairs);
|
||||||
|
- reject `length(ime) < 2` or `length(prezime) < 2`.
|
||||||
|
3. The "only-when-name-changes" rule preserves the 22 legitimate historical short-name rows (e.g. `id=1852..2141`, mostly placeholder `'-'` ime + surname-only entries) so they can still receive `UPDATE`s on other fields.
|
||||||
|
|
||||||
|
## Smoke insert tests (all wrapped in BEGIN/ROLLBACK so live data unchanged)
|
||||||
|
|
||||||
|
| # | Scenario | Expected | Result |
|
||||||
|
|---|----------|----------|--------|
|
||||||
|
| 1 | INSERT `('IvoIvic','Test')` | reject (CamelCase) | REJECTED — `CamelCase rejected in ime: IvoIvic` |
|
||||||
|
| 2 | INSERT `('PetarPan','Test')` | reject | REJECTED |
|
||||||
|
| 3 | INSERT `(' Ivo ',' Ivić ')` | trim then succeed | INSERTED — stored as `('Ivo','Ivić')` |
|
||||||
|
| 4 | INSERT `('A','Test')` | reject (length) | REJECTED — `ime too short (<2 chars): A` |
|
||||||
|
| 5 | INSERT `('Ivan',' X ')` | trim → `'X'` len 1 → reject | REJECTED — `prezime too short (<2 chars): X` |
|
||||||
|
| 6 | INSERT `('Marko ',' Marković')` | trim then succeed | INSERTED — stored as `('Marko','Marković')` |
|
||||||
|
| 7 | INSERT duplicate `hns_igrac_id='209352'` | reject | REJECTED — `duplicate key value violates unique constraint "clanovi_hns_uniq"` |
|
||||||
|
| 8 | 2× NULL + 2× `''` `hns_igrac_id` rows | all 4 succeed (partial uniqueness ignores NULL/empty) | 4 INSERTS OK |
|
||||||
|
| 9 | UPDATE `id=1852` (`ime='-'`) `napomena=...` (no name change) | succeed | UPDATED — short-name row still mutable |
|
||||||
|
| 10 | UPDATE `id=1852` `ime='?'` (single char) | reject | REJECTED — `ime too short (<2 chars): ?` |
|
||||||
|
|
||||||
|
All 10 behaviours match expectations. No live row was modified — every test ROLLBACKed.
|
||||||
|
|
||||||
|
## Final lockdown state on `pgz_sport.clanovi`
|
||||||
|
|
||||||
|
CHECK constraints in force:
|
||||||
|
- `clanovi_no_camelcase_chk` (NEW)
|
||||||
|
- `clanovi_trimmed_chk` (NEW)
|
||||||
|
- `clanovi_spol_check` (pre-existing)
|
||||||
|
|
||||||
|
UNIQUE indexes in force:
|
||||||
|
- `clanovi_pkey` (id)
|
||||||
|
- `uq_clanovi_klub_profile` (klub_id, profile_url) — pre-existing
|
||||||
|
- `clanovi_hns_uniq` (hns_igrac_id) WHERE not null/empty — NEW
|
||||||
|
|
||||||
|
User triggers in force (BEFORE INSERT OR UPDATE):
|
||||||
|
- `clanovi_normalize_trigger` (NEW)
|
||||||
|
- `clanovi_validate_source` (pre-existing)
|
||||||
|
- `pgz_sport_clanovi_fts_trg` (pre-existing)
|
||||||
|
|
||||||
|
Row count unchanged at 3240.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
-- pgz_sport.clanovi — schema lockdown DDL (Subagent D)
|
||||||
|
-- Author: dradulic@outlook.com / damir@rinet.one
|
||||||
|
-- Date: 2026-05-05
|
||||||
|
-- Description: Final, applied DDL. Pre-flight all-clean blocks below were
|
||||||
|
-- committed; SKIPPED candidates (length>=2 CHECK, klub+name+dob
|
||||||
|
-- UNIQUE) are documented in D_violations.md and intentionally
|
||||||
|
-- omitted here.
|
||||||
|
--
|
||||||
|
-- Row count at apply time: 3240 (live), 3243 (backup_20260505_0836).
|
||||||
|
-- Rollback hints: each block is independent and reversible via
|
||||||
|
-- ALTER TABLE pgz_sport.clanovi DROP CONSTRAINT ...;
|
||||||
|
-- DROP INDEX pgz_sport.clanovi_hns_uniq;
|
||||||
|
-- DROP TRIGGER clanovi_normalize_trigger ON pgz_sport.clanovi;
|
||||||
|
-- DROP FUNCTION pgz_sport.clanovi_normalize_fn();
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- C1: CHECK no internal CamelCase boundary (lower->upper letter pair)
|
||||||
|
-- Pre-flight violators: 0
|
||||||
|
-- ===========================================================================
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE pgz_sport.clanovi
|
||||||
|
ADD CONSTRAINT clanovi_no_camelcase_chk
|
||||||
|
CHECK (
|
||||||
|
ime !~ '[a-zćčšđžáàâäéèêëíìîïóòôöúùûüñçý][A-ZĆČŠĐŽÁÀÂÄÉÈÊËÍÌÎÏÓÒÔÖÚÙÛÜÑÇÝ]'
|
||||||
|
AND prezime !~ '[a-zćčšđžáàâäéèêëíìîïóòôöúùûüñçý][A-ZĆČŠĐŽÁÀÂÄÉÈÊËÍÌÎÏÓÒÔÖÚÙÛÜÑÇÝ]'
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- C2: CHECK ime/prezime are trimmed
|
||||||
|
-- Pre-flight violators: 0
|
||||||
|
-- ===========================================================================
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE pgz_sport.clanovi
|
||||||
|
ADD CONSTRAINT clanovi_trimmed_chk
|
||||||
|
CHECK (ime = trim(ime) AND prezime = trim(prezime));
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- C4: spol values constraint
|
||||||
|
-- NOT applied as new constraint — existing clanovi_spol_check already enforces
|
||||||
|
-- spol IN ('M','Ž',NULL). Documented for completeness.
|
||||||
|
-- CHECK (spol IS NULL OR spol IN ('M','Ž'))
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- C5: UNIQUE partial index on hns_igrac_id (non-null, non-empty)
|
||||||
|
-- Pre-flight duplicate groups: 0
|
||||||
|
-- ===========================================================================
|
||||||
|
BEGIN;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS clanovi_hns_uniq
|
||||||
|
ON pgz_sport.clanovi (hns_igrac_id)
|
||||||
|
WHERE hns_igrac_id IS NOT NULL AND hns_igrac_id != '';
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- C7: BEFORE INSERT/UPDATE normalize trigger
|
||||||
|
-- Trims ime/prezime, rejects CamelCase, enforces length>=2 only when names
|
||||||
|
-- change (so the existing 22 short-name historical rows can still be UPDATEd
|
||||||
|
-- on other fields without rejection).
|
||||||
|
-- ===========================================================================
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION pgz_sport.clanovi_normalize_fn()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $fn$
|
||||||
|
DECLARE
|
||||||
|
v_changed_name boolean;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.ime IS NOT NULL THEN
|
||||||
|
NEW.ime := trim(NEW.ime);
|
||||||
|
END IF;
|
||||||
|
IF NEW.prezime IS NOT NULL THEN
|
||||||
|
NEW.prezime := trim(NEW.prezime);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
v_changed_name := true;
|
||||||
|
ELSE
|
||||||
|
v_changed_name := (NEW.ime IS DISTINCT FROM OLD.ime)
|
||||||
|
OR (NEW.prezime IS DISTINCT FROM OLD.prezime);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_changed_name THEN
|
||||||
|
IF NEW.ime ~ '[a-zćčšđžáàâäéèêëíìîïóòôöúùûüñçý][A-ZĆČŠĐŽÁÀÂÄÉÈÊËÍÌÎÏÓÒÔÖÚÙÛÜÑÇÝ]' THEN
|
||||||
|
RAISE EXCEPTION 'CamelCase rejected in ime: %', NEW.ime
|
||||||
|
USING ERRCODE = 'check_violation';
|
||||||
|
END IF;
|
||||||
|
IF NEW.prezime ~ '[a-zćčšđžáàâäéèêëíìîïóòôöúùûüñçý][A-ZĆČŠĐŽÁÀÂÄÉÈÊËÍÌÎÏÓÒÔÖÚÙÛÜÑÇÝ]' THEN
|
||||||
|
RAISE EXCEPTION 'CamelCase rejected in prezime: %', NEW.prezime
|
||||||
|
USING ERRCODE = 'check_violation';
|
||||||
|
END IF;
|
||||||
|
IF length(coalesce(NEW.ime, '')) < 2 THEN
|
||||||
|
RAISE EXCEPTION 'ime too short (<2 chars): %', NEW.ime
|
||||||
|
USING ERRCODE = 'check_violation';
|
||||||
|
END IF;
|
||||||
|
IF length(coalesce(NEW.prezime, '')) < 2 THEN
|
||||||
|
RAISE EXCEPTION 'prezime too short (<2 chars): %', NEW.prezime
|
||||||
|
USING ERRCODE = 'check_violation';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS clanovi_normalize_trigger ON pgz_sport.clanovi;
|
||||||
|
|
||||||
|
CREATE TRIGGER clanovi_normalize_trigger
|
||||||
|
BEFORE INSERT OR UPDATE ON pgz_sport.clanovi
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION pgz_sport.clanovi_normalize_fn();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- END
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Subagent D — Skipped constraints, violator samples
|
||||||
|
|
||||||
|
Two candidate constraints were SKIPPED at apply-time because pre-existing rows
|
||||||
|
would have been rejected. They are documented here so Damir can decide whether
|
||||||
|
to clean the data and re-attempt the constraint, or accept the current state.
|
||||||
|
|
||||||
|
The trigger `clanovi_normalize_trigger` already enforces both rules **for new
|
||||||
|
inserts and for name-changing updates**, so future data ingest cannot
|
||||||
|
reintroduce these patterns. Only retroactive enforcement on existing rows is
|
||||||
|
deferred.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C3 — `CHECK (length(ime)>=2 AND length(prezime)>=2)` — SKIPPED
|
||||||
|
|
||||||
|
Violator count: **22** rows.
|
||||||
|
|
||||||
|
Two clusters:
|
||||||
|
|
||||||
|
1. **Single-letter `prezime`** — `id=1160` and `id=1165`, both klub_id=848:
|
||||||
|
- `('Boris Mičetić','B')` — note the embedded space in `ime`; the surname appears truncated to a single initial.
|
||||||
|
- `('Boris Mičetić','J')` — same pattern.
|
||||||
|
- **Decision suggestion**: probably real-name parse errors. Resolve manually in `clanovi`.
|
||||||
|
|
||||||
|
2. **Placeholder `ime='-'` (single dash)** — 20 rows, klub_id mostly NULL plus one with klub_id=3896:
|
||||||
|
|
||||||
|
| id | klub_id | ime | prezime |
|
||||||
|
|----|---------|-----|---------|
|
||||||
|
| 1852 | NULL | - | Grabovac |
|
||||||
|
| 1853 | NULL | - | Pilepić |
|
||||||
|
| 1854 | NULL | - | Maslak |
|
||||||
|
| 1855 | NULL | - | Jugo |
|
||||||
|
| 1856 | NULL | - | Miličević |
|
||||||
|
| 1857 | NULL | - | Marjanović |
|
||||||
|
| 1858 | NULL | - | Poljak |
|
||||||
|
| 1859 | NULL | - | Kurelić |
|
||||||
|
| 2021 | 3896 | - | Mohorić |
|
||||||
|
| 2125 | NULL | - | Mittrovich (braća) |
|
||||||
|
| 2130 | NULL | - | Loich |
|
||||||
|
| 2131 | NULL | - | Paulinich |
|
||||||
|
| 2132 | NULL | - | Zidarich |
|
||||||
|
| 2133 | NULL | - | Bertok |
|
||||||
|
| 2134 | NULL | - | Marincich |
|
||||||
|
| 2135 | NULL | - | Tiblias |
|
||||||
|
| 2138 | NULL | - | Veselica |
|
||||||
|
| 2139 | NULL | - | Naumović |
|
||||||
|
| 2140 | NULL | - | Osojnak |
|
||||||
|
| 2141 | NULL | - | Medle |
|
||||||
|
|
||||||
|
These look like **historical / surname-only roster entries** (note `napomena`
|
||||||
|
on id=1852 mentions "POVIJESNI: KK Kvarner najtrofejnija generacija …" so the
|
||||||
|
cluster is intentional historical data with unknown given name).
|
||||||
|
|
||||||
|
**Decision suggestion**: replace `ime='-'` with `ime='?'` is also rejected;
|
||||||
|
either backfill the given names from a source, mark them inactive/historical
|
||||||
|
in another column, or accept the data and never enable C3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C6 — `UNIQUE (klub_id, lower(ime), lower(prezime), COALESCE(datum_rodenja,'0001-01-01'))` — SKIPPED
|
||||||
|
|
||||||
|
Conflict groups: **68** (each group has 2+ rows that would collide).
|
||||||
|
|
||||||
|
Most are concentrated on **klub_id=2362 (HNK Rijeka roster)** where the same
|
||||||
|
player appears twice — once with `datum_rodenja IS NULL` and once also with
|
||||||
|
NULL DOB but a different scrape source / older `id`. Sample:
|
||||||
|
|
||||||
|
| klub_id | l_ime | l_prez | dob | dups | ids |
|
||||||
|
|---------|-------|--------|-----|------|-----|
|
||||||
|
| 2362 | amer | gojak | NULL | 2 | {3402, 4214} |
|
||||||
|
| 2362 | leon | šerifi | NULL | 2 | {3334, 4238} |
|
||||||
|
| 2362 | ante | oreč | NULL | 2 | {1581, 4230} |
|
||||||
|
| 2362 | branko | pavić | NULL | 2 | {2715, 4231} |
|
||||||
|
| 2362 | lovro | kitin | NULL | 2 | {3481, 4220} |
|
||||||
|
| 2362 | ante | majstorović | NULL | 2 | {3456, 4224} |
|
||||||
|
| 2362 | dejan | petrovič | NULL | 2 | {3399, 4232} |
|
||||||
|
| 2362 | duje | čop | NULL | 2 | {1579, 4211} |
|
||||||
|
| 2362 | fran | škalamera | NULL | 2 | {3480, 4239} |
|
||||||
|
| 2362 | gabriel | rukavina | NULL | 2 | {3404, 4234} |
|
||||||
|
| 2362 | bruno | bogojević | NULL | 2 | {3437, 4208} |
|
||||||
|
| 2362 | aleksa | todorović | NULL | 2 | {3455, 4202} |
|
||||||
|
| 2362 | cherno | saho | NULL | 2 | {3403, 4235} |
|
||||||
|
| 2362 | luka | menalo | NULL | 2 | {3454, 4226} |
|
||||||
|
| 2362 | martin | zlomislić | NULL | 2 | {3440, 4203} |
|
||||||
|
| 2362 | mladen | devetak | NULL | 2 | {3400, 4212} |
|
||||||
|
| 2362 | niko | janković | NULL | 2 | {3607, 4218} |
|
||||||
|
| 2362 | noel | bodetić | NULL | 2 | {3705, 4207} |
|
||||||
|
| 2362 | silvio | ilinković | NULL | 2 | {3412, 4217} |
|
||||||
|
| 2362 | šimun | butić | NULL | 2 | {3401, 4209} |
|
||||||
|
| 2362 | stjepan | radeljić | NULL | 2 | {3448, 4233} |
|
||||||
|
| 2362 | toni | fruk | 2001-03-09 | 2 | {3438, 4135} |
|
||||||
|
| 2362 | vito | kovač | NULL | 2 | {3298, 4201} |
|
||||||
|
| 2362 | jovan | manev | NULL | 2 | {3439, 4225} |
|
||||||
|
| 2585 | ivo | butrica | NULL | 2 | {2282, 4163} |
|
||||||
|
| 2585 | luko | ledinić | NULL | 2 | {2283, 4164} |
|
||||||
|
| 2586 | siniša | saftić | NULL | 2 | {2298, 4165} |
|
||||||
|
| 2587 | damir | poslek | NULL | 2 | {2310, 4167} |
|
||||||
|
| 2589 | matej | viduka | NULL | 2 | {2340, 4174} |
|
||||||
|
| 2589 | čedo | vukelić | NULL | 2 | {2339, 4175} |
|
||||||
|
|
||||||
|
(38 more groups not shown — query reproduction below.)
|
||||||
|
|
||||||
|
**Cause**: the dedup-fold key collapses on `COALESCE(NULL, '0001-01-01')`, so
|
||||||
|
two records of the same name+klub with missing DOB look identical even when
|
||||||
|
they are distinct profiles (different `profile_url`, `source_id`, `hns_igrac_id`).
|
||||||
|
Today's working composite key is the existing `uq_clanovi_klub_profile
|
||||||
|
(klub_id, profile_url)` which is already enforced.
|
||||||
|
|
||||||
|
**Decision suggestion**: do NOT enable C6 as-is. Either (a) restrict the
|
||||||
|
uniqueness to `WHERE datum_rodenja IS NOT NULL`, or (b) merge true dupes via a
|
||||||
|
follow-up subagent that promotes one row and back-fills `hns_igrac_id` /
|
||||||
|
`profile_url`. Until then, ingestion is still protected by
|
||||||
|
`uq_clanovi_klub_profile` and (for HNS-keyed players) `clanovi_hns_uniq`.
|
||||||
|
|
||||||
|
### Reproduce full list
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT klub_id, lower(ime) AS l_ime, lower(prezime) AS l_prez,
|
||||||
|
COALESCE(datum_rodenja, '0001-01-01'::date) AS dob,
|
||||||
|
count(*) AS dups,
|
||||||
|
array_agg(id ORDER BY id) AS ids
|
||||||
|
FROM pgz_sport.clanovi
|
||||||
|
GROUP BY klub_id, lower(ime), lower(prezime), COALESCE(datum_rodenja, '0001-01-01'::date)
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY dups DESC, klub_id;
|
||||||
|
```
|
||||||
+11
-2
@@ -1,10 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM
|
pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM
|
||||||
Author: Damir Radulić (damir@rinet.one)
|
Author: Damir Radulić (damir@rinet.one / dradulic@outlook.com)
|
||||||
Date: 25.04.2026
|
Date: 25.04.2026 (v1.1.0 — 2026-05-05: role-based OIB display + audit log)
|
||||||
Port: 8095
|
Port: 8095
|
||||||
Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi
|
Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi
|
||||||
|
Changes (2026-05-05, sub-agent W5):
|
||||||
|
* is_admin() — recognizes super_admin / pgz_admin / pgz_user / pgz_finance /
|
||||||
|
pgz_zzjz JWT roles (previous code only matched literal "admin", which broke
|
||||||
|
PII visibility for actual PGŽ admins like Damir).
|
||||||
|
* apply_privacy() — now scope-aware: savez_admin sees full PII for own savez,
|
||||||
|
klub_admin sees full PII for own klub.
|
||||||
|
* Added _audit_oib_access() — records full-OIB reveals to Postgres audit_events
|
||||||
|
(table pgz_sport.audit_events) under action='oib.read'. Legitimate-interest
|
||||||
|
audit trail for GDPR Art.6(1)(f) defensibility.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request
|
from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* oib_format.js — unified role-based OIB display
|
||||||
|
* Author: Damir Radulić (damir@rinet.one / dradulic@outlook.com)
|
||||||
|
* Date: 2026-05-05
|
||||||
|
* Description: Single source of truth for OIB rendering across all PGŽ Sport
|
||||||
|
* static pages. Role hierarchy (per pgz_sport.users.user_type):
|
||||||
|
* super_admin -> full OIB everywhere
|
||||||
|
* pgz_admin -> full OIB across PGŽ tenant
|
||||||
|
* savez_admin -> full OIB for own savez_id (context-aware)
|
||||||
|
* klub_admin -> full OIB for own klub_id (context-aware)
|
||||||
|
* others -> masked: first 3 + 6 dots + last 2 (e.g. 067••••••03)
|
||||||
|
* Usage:
|
||||||
|
* <script src="/sport/static/oib_format.js"></script>
|
||||||
|
* formatOib('12345678901') // role auto-detected from pgz_user
|
||||||
|
* formatOib(o.oib, {savez_id: 1, klub_id: 7}) // pass scope for context-aware
|
||||||
|
* maskOib('12345678901') // force masked
|
||||||
|
*/
|
||||||
|
(function (g) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var TOKEN_KEYS = ['pgz_access', 'jwt', 'access_token'];
|
||||||
|
var USER_KEYS = ['pgz_user'];
|
||||||
|
|
||||||
|
// PGŽ-tier: always sees full PII for everything
|
||||||
|
var FULL_VISIBILITY_ROLES = {
|
||||||
|
'super_admin': 1,
|
||||||
|
'pgz_admin': 1,
|
||||||
|
'pgz_user': 1,
|
||||||
|
'pgz_finance': 1,
|
||||||
|
'pgz_zzjz': 1,
|
||||||
|
'admin': 1 // legacy bearer token role
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scope-restricted roles
|
||||||
|
var SAVEZ_ROLES = { 'savez_admin': 1, 'savez_user': 1 };
|
||||||
|
var KLUB_ROLES = { 'klub_admin': 1, 'klub_user': 1, 'klub_trener': 1, 'klub_clan': 1 };
|
||||||
|
|
||||||
|
function _readUser() {
|
||||||
|
for (var i = 0; i < USER_KEYS.length; i++) {
|
||||||
|
var k = USER_KEYS[i];
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem(k) || sessionStorage.getItem(k);
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _readToken() {
|
||||||
|
for (var i = 0; i < TOKEN_KEYS.length; i++) {
|
||||||
|
var k = TOKEN_KEYS[i];
|
||||||
|
var t = localStorage.getItem(k) || sessionStorage.getItem(k);
|
||||||
|
if (t) return t;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _decodeJwt(tok) {
|
||||||
|
if (!tok || tok.split('.').length !== 3) return null;
|
||||||
|
try {
|
||||||
|
var p = tok.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (p.length % 4) p += '=';
|
||||||
|
return JSON.parse(atob(p));
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns {role, klub_id, savez_id, email} from JWT or stored user. */
|
||||||
|
function getUserCtx() {
|
||||||
|
var u = _readUser() || {};
|
||||||
|
var jwt = _decodeJwt(_readToken()) || {};
|
||||||
|
var role = u.user_type || u.role || jwt.role || jwt.user_type || 'viewer';
|
||||||
|
var klub_id = u.klub_id != null ? u.klub_id : (jwt.tenant_scope && jwt.tenant_scope.klub_id);
|
||||||
|
var savez_id = u.savez_id != null ? u.savez_id : (jwt.tenant_scope && jwt.tenant_scope.savez_id);
|
||||||
|
return {
|
||||||
|
role: String(role || 'viewer').toLowerCase(),
|
||||||
|
klub_id: klub_id == null ? null : Number(klub_id),
|
||||||
|
savez_id: savez_id == null ? null : Number(savez_id),
|
||||||
|
email: u.email || jwt.email || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force-masked rendering: first 3 + 6 dots + last 2. e.g. 067••••••03 */
|
||||||
|
function maskOib(oib) {
|
||||||
|
if (oib == null) return '—';
|
||||||
|
var s = String(oib);
|
||||||
|
if (s.length < 6) return '•'.repeat(s.length);
|
||||||
|
return s.slice(0, 3) + '••••••' + s.slice(-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether to show full OIB based on caller role and (optional) row scope.
|
||||||
|
* @param {string|number} oib raw OIB value (or already-masked from backend)
|
||||||
|
* @param {object} [scope] optional context: {klub_id, savez_id}
|
||||||
|
* if provided, savez_admin / klub_admin can see
|
||||||
|
* full OIB for rows in their own scope only
|
||||||
|
* @returns {string} formatted OIB
|
||||||
|
*/
|
||||||
|
function formatOib(oib, scope) {
|
||||||
|
if (oib == null || oib === '') return '—';
|
||||||
|
var s = String(oib);
|
||||||
|
// Backend already masked — pass through (we cannot un-mask client-side)
|
||||||
|
if (s.indexOf('•') !== -1 || s.indexOf('*') !== -1) return s;
|
||||||
|
|
||||||
|
var ctx = getUserCtx();
|
||||||
|
var r = ctx.role;
|
||||||
|
|
||||||
|
if (FULL_VISIBILITY_ROLES[r]) return s;
|
||||||
|
|
||||||
|
if (scope && typeof scope === 'object') {
|
||||||
|
if (SAVEZ_ROLES[r] && ctx.savez_id != null && scope.savez_id != null
|
||||||
|
&& Number(scope.savez_id) === ctx.savez_id) return s;
|
||||||
|
if (KLUB_ROLES[r] && ctx.klub_id != null && scope.klub_id != null
|
||||||
|
&& Number(scope.klub_id) === ctx.klub_id) return s;
|
||||||
|
} else {
|
||||||
|
// No scope passed — savez/klub admins default to full only when scope not given
|
||||||
|
// (they would only ever query their own scope via tenanted endpoints)
|
||||||
|
if (SAVEZ_ROLES[r] || KLUB_ROLES[r]) return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
return maskOib(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the current user can see full PII (any OIB). */
|
||||||
|
function canSeeFullOib(scope) {
|
||||||
|
var ctx = getUserCtx();
|
||||||
|
if (FULL_VISIBILITY_ROLES[ctx.role]) return true;
|
||||||
|
if (!scope) return SAVEZ_ROLES[ctx.role] || KLUB_ROLES[ctx.role] || false;
|
||||||
|
if (SAVEZ_ROLES[ctx.role] && scope.savez_id != null && Number(scope.savez_id) === ctx.savez_id) return true;
|
||||||
|
if (KLUB_ROLES[ctx.role] && scope.klub_id != null && Number(scope.klub_id) === ctx.klub_id) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
g.formatOib = formatOib;
|
||||||
|
g.maskOib = maskOib;
|
||||||
|
g.getUserCtx = g.getUserCtx || getUserCtx;
|
||||||
|
g.canSeeFullOib = canSeeFullOib;
|
||||||
|
})(window);
|
||||||
Reference in New Issue
Block a user