9 Commits
19 changed files with 319 additions and 20 deletions
+7 -5
View File
@@ -86,6 +86,7 @@ The goal is simple: everything the repair shop needs to track is located in one
- Flash messages — one-time feedback after an action
- Automatic SQLite backup — with configurable number of retained copies; restore from a copy (safe, with no downtime)
- Charts — monthly revenue on reports (Chart.js)
- KPO book (ledger of income and expenses)
- Structured logging — `log/slog` (JSON in production, text in development); separate auth log in fail2ban format
- Automated tests — unit and integration over a SQLite database (crypto, RBAC, login flows, form validators, reports)
- **Demo mode** (`NTECH_ENV=demo`) — auto-created demo user, pre-filled login form, restricted backup count, blocked password/2FA changes
@@ -96,8 +97,8 @@ The goal is simple: everything the repair shop needs to track is located in one
### Planned
- KPO book and double-entry bookkeeping (optional, later phase)
- PostgreSQL support (for multi-user environments)
- Double-entry bookkeeping (optional, later phase)
- PostgreSQL support (for multi-user environments) — an empty `internal/db/postgres` package exists as a placeholder, but there is no `pgx` dependency yet and `NTECH_DB`/`NTECH_DSN` are not read by the app
- WebAuthn / Passkey login (database schema is already prepared)
- Notifications (email / WhatsApp) — deferred to a later phase
- Barcode scanning via camera — deferred to a later phase
@@ -114,7 +115,7 @@ The goal is simple: everything the repair shop needs to track is located in one
| [HTMX](https://htmx.org) | dynamic HTML over HTTP |
| [Alpine.js](https://alpinejs.dev) | client-side UI logic |
| [SQLite](https://sqlite.org) + [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | main database (pure Go, no CGO) |
| [PostgreSQL](https://www.postgresql.org) + [pgx/v5](https://github.com/jackc/pgx) | optional production database |
| [PostgreSQL](https://www.postgresql.org) + [pgx/v5](https://github.com/jackc/pgx) | planned production database (not implemented yet) |
---
@@ -171,9 +172,7 @@ The application reads environment variables on startup. In development, place th
| ---------------- | ------------- | ----------------------------------------------------------------- |
| `NTECH_ENV` | `development` | Mode: `development`, `production`, or `demo` |
| `NTECH_PORT` | `8080` | HTTP port |
| `NTECH_DB` | `sqlite` | Database type: `sqlite` or `postgres` |
| `NTECH_SQLITE` | `ntech.db` | Path to the SQLite file |
| `NTECH_DSN` | — | PostgreSQL connection string |
| `NTECH_SECRET` | — | Session signing key (min. 32 bytes); auto-generated if missing |
| `NTECH_TOTP_KEY` | — | AES-256 key for TOTP secret encryption; auto-generated if missing |
| `BE_ENABLED` | `true` | Enables the built-in card-emulator (fiscalization signing device) |
@@ -378,9 +377,11 @@ ntech/
│ └── ntech/ # entry point
├── internal/
│ ├── auth/ # login, sessions, fail2ban log
│ ├── be/ # built-in card-emulator (fiscalization signing device)
│ ├── config/ # settings, setup wizard
│ ├── db/ # database layer
│ │ └── sqlite/ # SQLite implementation
│ ├── fiskal/ # fiscalization (ESIR/L-PFR) client
│ ├── handler/ # HTTP handlers
│ ├── middleware/ # CSRF, security headers, authentication
│ └── model/ # shared data types
@@ -388,6 +389,7 @@ ntech/
│ ├── static/ # CSS, JavaScript, images, logos
│ └── templates/ # HTML templates
├── migrations/ # SQL migrations (001_desc.sql, 002_desc.sql, ...)
├── Fisk/ # Teron L-PFR mock server (Python) for fiscalization testing
├── logs/ # auth.log and other logs
├── backups/ # database backups
├── start.sh # interactive build and Docker push script
+7 -5
View File
@@ -86,6 +86,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
- Flash poruke — jednokratne povratne informacije nakon akcije
- Automatski backup SQLite baze — sa podešavanjem broja čuvanih kopija; vraćanje baze iz kopije (bezbedno, bez prekida rada)
- Grafikoni — mesečni prihod na izveštajima (Chart.js)
- KPO knjiga (knjiga prihoda i rashoda)
- Strukturisano logovanje — `log/slog` (JSON u produkciji, tekst u razvoju); zaseban auth log u fail2ban formatu
- Automatski testovi — jedinični i integracioni nad SQLite bazom (kripto, RBAC, tokovi prijave, validatori forme, izveštaji)
- **Demo mod** (`NTECH_ENV=demo`) — automatski kreiran demo korisnik, pre-popunjeni login, ograničen bekap, blokirana promena lozinke i 2FA
@@ -96,8 +97,8 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
### Planirano
- KPO knjiga i dvojno knjigovodstvo (opciono, kasnija faza)
- Podrška za PostgreSQL (za višekorisničko okruženje)
- Dvojno knjigovodstvo (opciono, kasnija faza)
- Podrška za PostgreSQL (za višekorisničko okruženje) — prazan `internal/db/postgres` paket postoji kao mesto za buduću implementaciju, ali `pgx` još nije zavisnost projekta, a `NTECH_DB`/`NTECH_DSN` aplikacija trenutno ne čita
- WebAuthn / Passkey prijava (šema baze je pripremljena)
- Obaveštenja (e-pošta / WhatsApp) — odloženo za kasniju fazu
- Skeniranje barkodova putem kamere — odloženo za kasniju fazu
@@ -114,7 +115,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
| [HTMX](https://htmx.org) | dinamički HTML preko HTTP-a |
| [Alpine.js](https://alpinejs.dev) | UI logika na strani klijenta |
| [SQLite](https://sqlite.org) + [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | glavna baza (čisti Go, bez CGO) |
| [PostgreSQL](https://www.postgresql.org) + [pgx/v5](https://github.com/jackc/pgx) | opciona baza za produkciju |
| [PostgreSQL](https://www.postgresql.org) + [pgx/v5](https://github.com/jackc/pgx) | planirana baza za produkciju (još nije implementirano) |
---
@@ -171,9 +172,7 @@ Fajl `ntech.env` se **ne commituje** u Git.
| ---------------- | ------------- | ------------------------------------------------------------------ |
| `NTECH_ENV` | `development` | Mod: `development`, `production` ili `demo` |
| `NTECH_PORT` | `8080` | HTTP port |
| `NTECH_DB` | `sqlite` | Tip baze: `sqlite` ili `postgres` |
| `NTECH_SQLITE` | `ntech.db` | Putanja do SQLite fajla |
| `NTECH_DSN` | — | PostgreSQL connection string |
| `NTECH_SECRET` | — | Ključ za potpisivanje sesija (min. 32 bajta); auto-generiše se |
| `NTECH_TOTP_KEY` | — | AES-256 ključ za šifrovanje TOTP tajni; auto-generiše se |
| `BE_ENABLED` | `true` | Uključuje ugrađeni kartica-emulator (uređaj za potpisivanje pri fiskalizaciji) |
@@ -378,9 +377,11 @@ ntech/
│ └── ntech/ # ulazna tačka programa
├── internal/
│ ├── auth/ # prijava, sesije, fail2ban log
│ ├── be/ # ugrađeni kartica-emulator (uređaj za potpisivanje pri fiskalizaciji)
│ ├── config/ # podešavanja, setup wizard
│ ├── db/ # sloj baze podataka
│ │ └── sqlite/ # SQLite implementacija
│ ├── fiskal/ # klijent za fiskalizaciju (ESIR/L-PFR)
│ ├── handler/ # HTTP handleri
│ ├── middleware/ # CSRF, bezbednost headeri, autentifikacija
│ └── model/ # zajednički tipovi podataka
@@ -388,6 +389,7 @@ ntech/
│ ├── static/ # CSS, JavaScript, slike, logotipi
│ └── templates/ # HTML šabloni
├── migrations/ # SQL migracije (001_opis.sql, 002_opis.sql, ...)
├── Fisk/ # Teron L-PFR mock server (Python) za testiranje fiskalizacije
├── logs/ # auth.log i ostali logovi
├── backups/ # rezervne kopije baze
├── start.sh # interaktivna skripta za build i Docker push
+21 -3
View File
@@ -3,6 +3,7 @@ package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
@@ -195,6 +196,23 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
}
defer tx.Rollback()
// idempotency zaštita: ako je pozivalac poslao ključ (frontend ga generiše po
// otvaranju forme) i nalog sa tim ključem već postoji, to znači da je ovo dupliran
// POST (dupli klik, "Nazad" pa ponovni submit, mrežni retry, dva otvorena taba) —
// vraćamo ID postojećeg naloga umesto da pravimo drugi, identičan.
if n.IdempotencyKey != "" {
var postojeciID int64
err := tx.QueryRowContext(ctx,
"SELECT id FROM prodajni_nalozi WHERE idempotency_key = ?", n.IdempotencyKey,
).Scan(&postojeciID)
if err == nil {
return postojeciID, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: provera idempotency key: %w", err)
}
}
// broj naloga se generiše OVDE, unutar iste transakcije kao insert —
// ne sme se prosleđivati kao unapred generisana vrednost iz handlera,
// jer bi dupliran/ponovljen POST zahtev tada napravio dva zasebna,
@@ -207,9 +225,9 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
// insert zaglavlja naloga pre stavki da bismo imali nalogID za magacin
rezultat, err := tx.ExecContext(ctx, `
INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum)
VALUES (?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.NacinPlacanja, n.Datum,
INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum, idempotency_key)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.NacinPlacanja, n.Datum, nullString(n.IdempotencyKey),
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert nalog: %w", err)
@@ -122,6 +122,60 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) {
}
}
// TestProdajaKreiraj_IdempotencyKey: dva poziva Kreiraj sa istim IdempotencyKey
// (simulira dupliran POST — dupli klik, "Nazad" pa ponovni submit, mrežni retry)
// vraćaju ISTI nalogID, ne prave drugi nalog i ne skidaju stanje dvaput.
func TestProdajaKreiraj_IdempotencyKey(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
artID, _ := artRepo.Kreiraj(ctx, &model.Artikal{
Naziv: "Slušalice", Tip: model.TipProizvod, Kolicina: 10,
})
nalog := &model.ProdajniNalog{
Ukupno: 1000, NacinPlacanja: "gotovina", Datum: time.Now(),
IdempotencyKey: "test-kljuc-123",
}
stavke := []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 2, CenaPoKomadu: 500},
}
id1, err := prodRepo.Kreiraj(ctx, nalog, stavke, nil)
if err != nil {
t.Fatalf("prvi Kreiraj: %v", err)
}
// drugi poziv sa ISTIM ključem (nov nalog/stavke, kao pri ponovljenom POST-u)
id2, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{
Ukupno: 1000, NacinPlacanja: "gotovina", Datum: time.Now(),
IdempotencyKey: "test-kljuc-123",
}, []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 2, CenaPoKomadu: 500},
}, nil)
if err != nil {
t.Fatalf("drugi Kreiraj (dupliran POST): %v", err)
}
if id1 != id2 {
t.Errorf("drugi poziv sa istim idempotency ključem vratio drugačiji ID: %d != %d — napravljen dupli nalog", id1, id2)
}
var brNaloga int
baza.QueryRowContext(ctx, "SELECT COUNT(*) FROM prodajni_nalozi WHERE idempotency_key = ?", "test-kljuc-123").Scan(&brNaloga)
if brNaloga != 1 {
t.Errorf("broj naloga sa ovim idempotency ključem = %d, očekivano 1", brNaloga)
}
// stanje skinuto SAMO jednom (2 kom), ne dvaput (4 kom)
a, _ := artRepo.DohvatiID(ctx, artID)
if a.Kolicina != 8 {
t.Errorf("stanje = %d, očekivano 8 (10-2, skinuto samo jednom)", a.Kolicina)
}
}
func absF(x float64) float64 {
if x < 0 {
return -x
+19 -3
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
@@ -145,6 +146,21 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
}
defer tx.Rollback()
// idempotency zaštita: isti obrazac kao ProdajaRepo.Kreiraj — ako pozivalac pošalje
// ključ i nalog sa tim ključem već postoji, to je dupliran POST — vraćamo postojeći ID.
if n.IdempotencyKey != "" {
var postojeciID int64
err := tx.QueryRowContext(ctx,
"SELECT id FROM servisni_nalozi WHERE idempotency_key = ?", n.IdempotencyKey,
).Scan(&postojeciID)
if err == nil {
return postojeciID, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: provera idempotency key: %w", err)
}
}
brojNaloga, err := sledeciBrojServisa(ctx, tx)
if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: broj naloga: %w", err)
@@ -155,15 +171,15 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
INSERT INTO servisni_nalozi
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, trazene_nadogradnje,
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, garancija_dana, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token, idempotency_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj,
nullString(n.SerijskiBroj), n.OpisKvara, n.TrazeneNadogradnje, n.Status,
nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena),
nullTime(n.GarancijaDo), nullInt(n.GarancijaDana), nullTime(n.DatumZavrsetka), nullTime(n.PredvidjenDatum),
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
n.DatumPrijema, token,
n.DatumPrijema, token, nullString(n.IdempotencyKey),
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: %w", err)
@@ -0,0 +1,65 @@
package sqlite
import (
"context"
"testing"
"ntech/internal/model"
)
// TestServisKreiraj_IdempotencyKey: dva poziva Kreiraj sa istim IdempotencyKey
// (simulira dupliran POST — dupli klik, "Nazad" pa ponovni submit, mrežni retry)
// vraćaju ISTI nalogID i ne prave drugi nalog — isti obrazac kao za prodaju
// (v. TestProdajaKreiraj_IdempotencyKey u prodaja_kreiraj_test.go).
func TestServisKreiraj_IdempotencyKey(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
repo := NoviServisRepo(baza)
id1, err := repo.Kreiraj(ctx, &model.ServisniNalog{
Uredjaj: "Laptop", OpisKvara: "ne pali", Status: "Primljeno",
IdempotencyKey: "test-servis-kljuc-123",
})
if err != nil {
t.Fatalf("prvi Kreiraj: %v", err)
}
// drugi poziv sa ISTIM ključem (nov nalog, kao pri ponovljenom POST-u)
id2, err := repo.Kreiraj(ctx, &model.ServisniNalog{
Uredjaj: "Laptop", OpisKvara: "ne pali", Status: "Primljeno",
IdempotencyKey: "test-servis-kljuc-123",
})
if err != nil {
t.Fatalf("drugi Kreiraj (dupliran POST): %v", err)
}
if id1 != id2 {
t.Errorf("drugi poziv sa istim idempotency ključem vratio drugačiji ID: %d != %d — napravljen dupli nalog", id1, id2)
}
var brNaloga int
baza.QueryRowContext(ctx, "SELECT COUNT(*) FROM servisni_nalozi WHERE idempotency_key = ?", "test-servis-kljuc-123").Scan(&brNaloga)
if brNaloga != 1 {
t.Errorf("broj naloga sa ovim idempotency ključem = %d, očekivano 1", brNaloga)
}
}
// TestServisKreiraj_BezIdempotencyKljuca: prazan ključ (pozivalac ga ne koristi) —
// dva odvojena poziva prave DVA odvojena naloga, kao i pre uvođenja zaštite.
func TestServisKreiraj_BezIdempotencyKljuca(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
repo := NoviServisRepo(baza)
id1, err := repo.Kreiraj(ctx, &model.ServisniNalog{Uredjaj: "PC", OpisKvara: "kvar", Status: "Primljeno"})
if err != nil {
t.Fatalf("prvi Kreiraj: %v", err)
}
id2, err := repo.Kreiraj(ctx, &model.ServisniNalog{Uredjaj: "PC", OpisKvara: "kvar", Status: "Primljeno"})
if err != nil {
t.Fatalf("drugi Kreiraj: %v", err)
}
if id1 == id2 {
t.Errorf("bez idempotency ključa očekivana dva različita naloga, dobijen isti ID %d", id1)
}
}
+1 -1
View File
@@ -250,7 +250,7 @@ func (h *Handler) AdminPromeniUlogu(w http.ResponseWriter, r *http.Request) {
// AdminObrisiKorisnika briše korisnika sa ulogom radnik
func (h *Handler) AdminObrisiKorisnika(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil || k.Uloga != "superadmin" {
if !middleware.JeAdmin(k) {
http.Error(w, "Pristup odbijen", http.StatusForbidden)
return
}
+39
View File
@@ -172,6 +172,16 @@ func (h *Handler) SacuvajIzmenePodsetnika(w http.ResponseWriter, r *http.Request
return
}
postojeci, err := h.PodsetnikRepo.DohvatiID(r.Context(), id)
if err != nil {
http.Error(w, "Podsetnik nije pronađen", http.StatusNotFound)
return
}
if !korisnikSmeDaMenjaPodsetnik(k, postojeci) {
http.Error(w, "Nemate dozvolu da menjate ovaj podsetnik", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
@@ -196,6 +206,8 @@ func (h *Handler) SacuvajIzmenePodsetnika(w http.ResponseWriter, r *http.Request
// OznaciPodsetnik prima POST zahtev i menja status završenosti podsetnika
func (h *Handler) OznaciPodsetnik(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID podsetnika", http.StatusBadRequest)
@@ -208,6 +220,10 @@ func (h *Handler) OznaciPodsetnik(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Podsetnik nije pronađen", http.StatusNotFound)
return
}
if !korisnikSmeDaMenjaPodsetnik(k, podsetnik) {
http.Error(w, "Nemate dozvolu da menjate ovaj podsetnik", http.StatusForbidden)
return
}
if err := h.PodsetnikRepo.OznaciZavrsenim(r.Context(), id, !podsetnik.Zavrseno); err != nil {
http.Error(w, "Greška pri ažuriranju statusa", http.StatusInternalServerError)
@@ -219,12 +235,24 @@ func (h *Handler) OznaciPodsetnik(w http.ResponseWriter, r *http.Request) {
// ObrisiPodsetnik prima POST zahtev i briše podsetnik po ID-u
func (h *Handler) ObrisiPodsetnik(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID podsetnika", http.StatusBadRequest)
return
}
podsetnik, err := h.PodsetnikRepo.DohvatiID(r.Context(), id)
if err != nil {
http.Error(w, "Podsetnik nije pronađen", http.StatusNotFound)
return
}
if !korisnikSmeDaMenjaPodsetnik(k, podsetnik) {
http.Error(w, "Nemate dozvolu da brišete ovaj podsetnik", http.StatusForbidden)
return
}
if err := h.PodsetnikRepo.Obrisi(r.Context(), id); err != nil {
http.Error(w, "Greška pri brisanju podsetnika", http.StatusInternalServerError)
return
@@ -233,6 +261,17 @@ func (h *Handler) ObrisiPodsetnik(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/podsetnici?obrisan=1", http.StatusSeeOther)
}
// korisnikSmeDaMenjaPodsetnik proverava vlasništvo nad podsetnikom — sprečava da jedan
// korisnik (npr. radnik) menja/završava/briše tuđi podsetnik pogađanjem ID-a u URL-u.
// Admin/superadmin smeju sve (isti kriterijum kao za dodelu podsetnika drugom korisniku
// u parseFormuPodsetnika); radnik sme samo podsetnik dodeljen njemu lično.
func korisnikSmeDaMenjaPodsetnik(k *model.Korisnik, p *model.Podsetnik) bool {
if middleware.JeAdmin(k) {
return true
}
return p.KorisnikID != nil && k != nil && *p.KorisnikID == k.ID
}
// parseFormuPodsetnika čita polja iz HTTP forme, validira ih i vraća model i eventualnu grešku
func parseFormuPodsetnika(r *http.Request, k *model.Korisnik) (model.Podsetnik, string) {
naslov := strings.TrimSpace(r.FormValue("naslov"))
+43
View File
@@ -0,0 +1,43 @@
package handler
import (
"testing"
"ntech/internal/model"
)
// TestKorisnikSmeDaMenjaPodsetnik proverava zaštitu od IDOR-a: radnik sme da menja/briše
// samo sopstveni podsetnik, admin/superadmin smeju bilo koji.
func TestKorisnikSmeDaMenjaPodsetnik(t *testing.T) {
radnik := &model.Korisnik{ID: 1, Uloga: "radnik"}
drugiRadnik := &model.Korisnik{ID: 2, Uloga: "radnik"}
admin := &model.Korisnik{ID: 3, Uloga: "admin"}
svojPodsetnik := &model.Podsetnik{ID: 100, KorisnikID: p(1)}
tudjPodsetnik := &model.Podsetnik{ID: 101, KorisnikID: p(2)}
nedodeljenPodsetnik := &model.Podsetnik{ID: 102, KorisnikID: nil}
slucajevi := []struct {
naziv string
korisnik *model.Korisnik
podsetnik *model.Podsetnik
ocekivano bool
}{
{"radnik menja svoj podsetnik", radnik, svojPodsetnik, true},
{"radnik ne sme tuđi podsetnik", radnik, tudjPodsetnik, false},
{"drugi radnik ne sme tuđi podsetnik", drugiRadnik, svojPodsetnik, false},
{"radnik ne sme nedodeljen podsetnik", radnik, nedodeljenPodsetnik, false},
{"admin sme tuđi podsetnik", admin, tudjPodsetnik, true},
{"admin sme nedodeljen podsetnik", admin, nedodeljenPodsetnik, true},
}
for _, sc := range slucajevi {
t.Run(sc.naziv, func(t *testing.T) {
dobijeno := korisnikSmeDaMenjaPodsetnik(sc.korisnik, sc.podsetnik)
if dobijeno != sc.ocekivano {
t.Errorf("korisnikSmeDaMenjaPodsetnik(%+v, %+v) = %v, očekivano %v",
sc.korisnik, sc.podsetnik, dobijeno, sc.ocekivano)
}
})
}
}
+3
View File
@@ -543,6 +543,9 @@ func parseFormuProdaje(r *http.Request, validneStope map[float64]bool) (model.Pr
if nalog.NacinPlacanja != "gotovina" && nalog.NacinPlacanja != "kartica" && nalog.NacinPlacanja != "prenos" {
nalog.NacinPlacanja = "gotovina"
}
// idempotency_key: UUID koji frontend generiše po otvaranju forme (skriveno polje);
// prosleđen dalje do ProdajaRepo.Kreiraj radi zaštite od duplog POST-a
nalog.IdempotencyKey = strings.TrimSpace(r.FormValue("idempotency_key"))
artikalIDovi := r.Form["artikal_id[]"]
kolicine := r.Form["kolicina[]"]
+3
View File
@@ -1324,6 +1324,9 @@ func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) {
PinUredjaja: strings.TrimSpace(r.FormValue("pin_uredjaja")),
Pribor: strings.TrimSpace(r.FormValue("pribor")),
DatumPrijema: time.Now(),
// idempotency_key: UUID koji frontend generiše po otvaranju forme (skriveno polje);
// koristi ga samo ServisRepo.Kreiraj (zaštita od duplog POST-a), Izmeni ga ignoriše
IdempotencyKey: strings.TrimSpace(r.FormValue("idempotency_key")),
}
// datum prijema — korisnik može da unese drugi datum (npr. retroaktivno)
+5
View File
@@ -13,6 +13,11 @@ type ProdajniNalog struct {
Stornirano bool
RazlogStorniranja string
Datum time.Time
// IdempotencyKey je UUID koji frontend generiše po otvaranju forme (skriveno polje).
// Ako isti ključ već postoji u bazi, Kreiraj ne pravi novi nalog nego vraća postojeći —
// štiti od duplog POST-a (dupli klik, "Nazad" pa ponovni submit, mrežni retry, dva taba).
// Prazan string znači da pozivalac ne koristi zaštitu (npr. testovi, budući pozivaoci).
IdempotencyKey string
}
// StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje
+5
View File
@@ -62,6 +62,11 @@ type ServisniNalog struct {
Naplaceno float64 // iznos koji je naplaćen pri preuzimanju
Stornirano bool
RazlogStorniranja string
// IdempotencyKey je UUID koji frontend generiše po otvaranju forme (skriveno polje).
// Ako isti ključ već postoji u bazi, Kreiraj ne pravi novi nalog nego vraća postojeći —
// štiti od duplog POST-a (dupli klik, "Nazad" pa ponovni submit, mrežni retry, dva taba).
// Prazan string znači da pozivalac ne koristi zaštitu (npr. testovi, budući pozivaoci).
IdempotencyKey string
}
// ServisniLog je jedan zapis u istoriji događaja servisnog naloga.
@@ -0,0 +1,10 @@
-- Idempotency ključ za prodajni nalog: frontend generiše UUID po otvaranju forme
-- (jedan ključ po pokušaju unosa) i šalje ga kao skriveno polje. Ako isti POST
-- stigne na server dva puta (dupli klik koji je promakao JS zaštiti, "Nazad" pa
-- ponovni submit, mrežni retry, dva otvorena taba), drugi zahtev se prepoznaje
-- po već postojećem ključu i vraća VEĆ kreirani nalog umesto da napravi drugi.
-- Isti obrazac kao šifra usluge/troška (migracija 105): NULL dozvoljen i ne ulazi
-- u UNIQUE proveru (stari zapisi, ili budući pozivaoci koji ne šalju ključ).
ALTER TABLE prodajni_nalozi ADD COLUMN idempotency_key TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_prodajni_nalozi_idempotency_key
ON prodajni_nalozi(idempotency_key) WHERE idempotency_key IS NOT NULL;
+10
View File
@@ -0,0 +1,10 @@
-- Idempotency ključ za servisni nalog — isti obrazac kao za prodaju
-- (migracija 106_prodaja_idempotency_key.sql): frontend generiše UUID po otvaranju
-- forme i šalje ga kao skriveno polje. Ako isti POST stigne na server dva puta
-- (dupli klik, "Nazad" pa ponovni submit, mrežni retry, dva otvorena taba),
-- drugi zahtev se prepoznaje po već postojećem ključu i vraća VEĆ kreirani nalog
-- umesto da napravi drugi. NULL dozvoljen i ne ulazi u UNIQUE proveru (stari
-- zapisi, ili budući pozivaoci koji ne šalju ključ).
ALTER TABLE servisni_nalozi ADD COLUMN idempotency_key TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_servisni_nalozi_idempotency_key
ON servisni_nalozi(idempotency_key) WHERE idempotency_key IS NOT NULL;
+4
View File
@@ -177,6 +177,10 @@ document.addEventListener('alpine:init', () => {
primljenoIznos: '',
prikaziRacun: true,
saljemSe: false,
// generisan jednom po otvaranju forme; server ga koristi da prepozna dupli POST
// (dupli klik, "Nazad" pa ponovni submit, mrežni retry, dva otvorena taba) i
// vrati postojeći nalog umesto da napravi drugi — v. ProdajaRepo.Kreiraj
idempotencyKey: (window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2)),
_fiskalniTab: null,
artikliOpcije: [],
pretragaArtikal: '',
+7 -3
View File
@@ -32,8 +32,12 @@
Nazad na prodaju
</a>
<form method="POST" action="/prodaja/nova" @submit.prevent="posaljiProdaju($event)">
<!-- data-full-reload: isključuje formu iz generičkog AJAX submit-interceptora u base.html
(koji se inače kači na SVAKU form[method=POST]) — bez ovoga su i generički handler i
Alpine-ov @submit.prevent slali po jedan POST na isti klik (dupli zahtev ka /prodaja/nova) -->
<form method="POST" action="/prodaja/nova" data-full-reload @submit.prevent="posaljiProdaju($event)">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}" />
<input type="hidden" name="idempotency_key" :value="idempotencyKey" />
{{if .Greska}}
<div class="poruka-greska greska-animacija">{{.Greska}}</div>
{{end}}
@@ -128,7 +132,7 @@
<template x-for="(stavka, i) in stavke" :key="i">
<tr style="border-bottom: 0.5px solid var(--ivica)">
<td style="padding: 8px 10px; position: relative;">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa" :disabled="isMobile">
<input type="hidden" :name="'artikal_id[]'" :value="stavka.artikal_id" :disabled="isMobile">
<template x-if="stavka.artikal_id">
<div style="display:flex;align-items:center;justify-content:space-between;gap:6px;padding:7px 10px;border:0.5px solid var(--ivica);border-radius:8px;">
@@ -220,7 +224,7 @@
style="background: none; border: 0.5px solid #dc2626; color: #dc2626; cursor: pointer; font-size: 13px; padding: 2px 8px; border-radius: 4px;">Ukloni</button>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa" :disabled="!isMobile">
<div style="position: relative;">
<label class="polje-labela">Artikal</label>
<input type="hidden" :name="'artikal_id[]'" :value="stavka.artikal_id" :disabled="!isMobile">
+11
View File
@@ -35,6 +35,17 @@
<input type="hidden" name="broj_naloga" value="{{.Nalog.BrojNaloga}}">
<!-- CSRF token — bez njega middleware odbija POST sa 403 -->
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
{{if not .Izmena}}
<!-- idempotency_key: generiše se jednom po otvaranju forme (samo za NOVI nalog —
izmena ide kroz Izmeni, ne kroz Kreiraj, pa joj ključ nije potreban); server ga
koristi da prepozna dupli POST i vrati postojeći nalog umesto da napravi drugi -->
<input type="hidden" name="idempotency_key" id="idempotency-key-servis">
<script>
document.getElementById('idempotency-key-servis').value =
(window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID()
: (Date.now().toString(36) + Math.random().toString(36).slice(2));
</script>
{{end}}
<!-- ===================== KARTICA: UREĐAJ ===================== -->
<div class="kartica forma-kartica animiraj" style="margin-bottom:20px;">
@@ -511,6 +511,11 @@
}
// promena teme zahteva reload (menja se ceo CSS fajl)
if (f.querySelector('[name="lokalna_tema"]')) location.reload();
} else if (!res.ok) {
// greška bez redirekta (http.Error — 403/404/500/...) — obavesti korisnika,
// ne navigiraj na POST-only rutu (izaziva 405 kod GET-a)
window.ntechToast('Došlo je do greške. Pokušajte ponovo.', 'greska');
if (btn) btn.disabled = false;
} else {
// redirect na drugu stranicu ili bez sacuvano — navigiraj normalno
location.href = res.url;