From b747039ec63dc639a0e7759e757cc161e1b1f971 Mon Sep 17 00:00:00 2001 From: jbergner Date: Wed, 27 May 2026 03:08:44 +0200 Subject: [PATCH] update production --- .dockerignore | 8 + .env.example | 11 +- README.md | 148 +++++---- cmd/server/main.go | 2 + go.mod | 2 - internal/auth/auth.go | 24 +- internal/db/db.go | 10 +- internal/handlers/app.go | 399 +++++++++++++++++++++---- migrations/003_production_workflow.sql | 35 +++ web/templates/department_quests.html | 1 + web/templates/departments.html | 1 + web/templates/home.html | 6 +- web/templates/request.html | 2 +- 13 files changed, 518 insertions(+), 131 deletions(-) create mode 100644 .dockerignore create mode 100644 migrations/003_production_workflow.sql create mode 100644 web/templates/department_quests.html create mode 100644 web/templates/departments.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..84009be --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +Dockerfile +README.md +.env +*.zip +mariadbdata +/tmp diff --git a/.env.example b/.env.example index ef73082..e794b75 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,4 @@ -APP_ADDR=:8080 -APP_BASE_URL=http://localhost:8080 -SESSION_SECRET=change-me-32-bytes-minimum -DATABASE_URL=trading:trading@tcp(127.0.0.1:3306)/trading?parseTime=true&multiStatements=true -OIDC_ISSUER=https://pocketid.example.com -OIDC_CLIENT_ID=your-client-id -OIDC_CLIENT_SECRET=your-client-secret +POCKET_ID_ISSUER=https://pocketid.example.com +POCKET_ID_CLIENT_ID=trading-tool +POCKET_ID_CLIENT_SECRET=change-me +SESSION_SECRET=replace-with-a-long-random-secret diff --git a/README.md b/README.md index 2952cae..393860c 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,111 @@ -# Trading Tool Prototype — MariaDB +# Trading Tool -Go-Webapp für Trading-Anfragen, automatische Quest-Erzeugung, MariaDB und Pocket ID Login via OIDC. +Webbasiertes Go/MariaDB-System für Trading-Anfragen, dynamische Workflows und abteilungsbezogene Quests. -## Webinterface +## Enthalten -Ja. Enthalten sind: - -- Login-Seite über Pocket ID / OIDC -- Dashboard für Trading-Anfragen -- Formular für neue Ankauf-/Verkaufs-Anfragen -- Quest-Tabelle für Mining, Logistik, Crafting usw. -- Auftragnehmer können Status und Notizen/Gegenvorschläge setzen - -## Auftragnehmer beeinflussen den Workflow - -Ja, im aktuellen Prototyp über Quest-Status und Notizen: - -- `accepted` -- `in_progress` -- `done` -- `blocked` -- `change_requested` -- `cancelled` - -Alle Änderungen werden zusätzlich in `quest_updates` protokolliert. Damit kann später die Workflow-Engine z. B. bei `blocked` eine zusätzliche Beschaffungsquest oder bei `change_requested` eine Trading-Freigabe erzeugen. - -## Pocket ID Setup - -In Pocket ID eine OIDC-App anlegen: - -- Callback URL: `http://localhost:8080/auth/callback` -- Scopes: `openid profile email` - -Pocket ID stellt Client ID, Client Secret und Issuer URL bereit. Die Issuer URL ist normalerweise die Basis-URL deiner Pocket-ID-Instanz; die Discovery URL ist `/.well-known/openid-configuration`. +- Go Webserver mit `net/http` und `html/template` +- Pocket ID / OIDC Login +- MariaDB Datenbank +- automatische Migrationen +- Admin-Konfiguration +- Trading-Anfragen für Ankauf und Verkauf +- Workflow-Regeln mit `condition_json` und `actions_json` +- Quest-Templates +- Quest-Abhängigkeiten: nur die erste Quest ist offen, Folgequests warten +- automatische Freischaltung der nächsten Quest nach Abschluss der vorherigen Quest +- automatische Request-Statusberechnung +- Abteilungsportale unter `/departments` +- Auftragnehmer-Rückmeldungen, Blocker, Änderungswünsche und Abschlussmeldungen +- Dockerfile und Docker Compose ## Start ```bash cp .env.example .env -docker compose up -d -set -a; source .env; set +a -go mod tidy -go run ./cmd/server +# Pocket ID Werte in .env eintragen +docker compose up --build ``` -Dann öffnen: http://localhost:8080 +App: http://localhost:8080 -## Architektur +Pocket ID Callback URL: -- Go-Standardbibliothek für HTTP, Templates, Cookies, OIDC-Discovery und JWT-RS256-Prüfung. -- Einzige externe Go-Abhängigkeit: `github.com/go-sql-driver/mysql` als MariaDB/MySQL-Treiber. -- Tabellen: `users`, `departments`, `items`, `trade_requests`, `quests`, `quest_updates`, `events`, `workflow_rules`. +```text +http://localhost:8080/auth/callback +``` -## Nächste sinnvolle Schritte +## Wichtige Routen -- Workflow-Regeln aus `workflow_rules.actions_json` ausführen statt Default-Schritte im Code. -- Rollen/Gruppen aus Pocket ID Claims auf Trading/Admin/Abteilungen mappen. -- Lagerbestand und Stücklisten/BOM für Crafting ergänzen. -- API-Endpunkte für externe Quest-Systeme ergänzen. +- `/` Trading Dashboard +- `/requests/new` neue Anfrage +- `/departments` Abteilungsportale +- `/departments/{id}/quests` Quest-Ansicht einer Abteilung +- `/admin` Konfiguration -## Admin-Konfiguration +## Workflow Conditions -Nach dem Login gibt es im Dashboard den Link **Admin-Konfiguration** (`/admin`). Dort kannst du ohne Codeänderung konfigurieren: +`condition_json` entscheidet, wann eine Workflow-Regel greift. Beispiele: -- globale Einstellungen wie Währung, Standardmarge, Auto-Quest-Erzeugung und Admin-E-Mails -- Abteilungen -- Items und Ressourcen -- Request- und Quest-Statuswerte -- Quest-Templates inklusive zuständiger Abteilung, Reihenfolge und Belohnungsfaktor -- Workflow-Regeln als JSON +```json +{ + "kind": "buy", + "item_category": "weapon", + "quantity_min": 1, + "price_min": 1000 +} +``` -Standardmäßig darf jeder eingeloggte Nutzer die Admin-Seite öffnen. Setze in `/admin` bei `admin_emails` eine kommagetrennte Liste, z. B. `you@example.com,lead@example.com`, dann erhalten nur diese Pocket-ID-Nutzer Zugriff. +Unterstützte Felder: -Bestehende Installationen erhalten die Admin-Tabellen über `migrations/002_admin.sql` automatisch beim nächsten Start. +- `kind` +- `item_category` +- `quantity_min` +- `quantity_max` +- `price_min` +- `price_max` + +`actions_json` enthält die Quest-Template-Typen in Reihenfolge: + +```json +["logistics_quote", "mining_procure", "crafting_order", "delivery"] +``` + +Die Reihenfolge erzeugt automatisch Abhängigkeiten. Quest 2 wartet auf Quest 1, Quest 3 wartet auf Quest 2 usw. + +## Statuslogik + +Requests: + +- `new` +- `in_progress` +- `blocked` +- `fulfilled` +- `cancelled` + +Quests: + +- `waiting` +- `open` +- `accepted` +- `in_progress` +- `blocked` +- `change_requested` +- `done` +- `cancelled` + +Wenn alle Quests eines Requests `done` sind, wird der Request automatisch auf `fulfilled` gesetzt. +Wenn eine Quest `blocked` ist, wird der Request auf `blocked` gesetzt. +Wenn eine Quest abgeschlossen wird, werden wartende Folgequests automatisch geöffnet, sobald alle Abhängigkeiten erledigt sind. + +## Hinweise zur Produktionsnutzung + +Dieses Paket ist deutlich robuster als der erste Prototyp, ersetzt aber keine finale Sicherheits- und Lastprüfung für deine konkrete Umgebung. Vor echter Produktion solltest du mindestens erledigen: + +- HTTPS und sichere Reverse-Proxy-Konfiguration +- langes zufälliges `SESSION_SECRET` +- restriktive `admin_emails` +- Backups für MariaDB +- Monitoring/Logging +- eventuell CSRF-Schutz für schreibende Formulare +- rollenbasierte Einschränkung pro Abteilung, falls nicht jeder eingeloggte Nutzer jede Abteilung sehen darf diff --git a/cmd/server/main.go b/cmd/server/main.go index 454c83b..c9c64f6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -38,6 +38,8 @@ func main() { mux.Handle("/", am.Require(http.HandlerFunc(app.Home))) mux.Handle("/requests/new", am.Require(http.HandlerFunc(app.NewRequest))) mux.Handle("/quests/update", am.Require(http.HandlerFunc(app.UpdateQuest))) + mux.Handle("/departments", am.Require(http.HandlerFunc(app.Departments))) + mux.Handle("/departments/", am.Require(http.HandlerFunc(app.DepartmentQuests))) mux.Handle("/admin", am.RequireAdmin(http.HandlerFunc(app.Admin))) mux.Handle("/admin/departments", am.RequireAdmin(http.HandlerFunc(app.AdminDepartment))) mux.Handle("/admin/items", am.RequireAdmin(http.HandlerFunc(app.AdminItem))) diff --git a/go.mod b/go.mod index c77e587..fa55406 100644 --- a/go.mod +++ b/go.mod @@ -3,5 +3,3 @@ module trading-tool go 1.23 require github.com/go-sql-driver/mysql v1.8.1 - -require filippo.io/edwards25519 v1.1.0 // indirect diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 288053e..a28e23d 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -36,8 +36,27 @@ type Manager struct { Client *http.Client } +type contextKey string + +const UserIDKey contextKey = "user_id" + +func UserID(r *http.Request) string { + if v, ok := r.Context().Value(UserIDKey).(string); ok { + return v + } + return "" +} + +func firstEnv(keys ...string) string { + for _, k := range keys { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" +} func New(ctx context.Context, db *sql.DB) (*Manager, error) { - c := Config{os.Getenv("APP_BASE_URL"), os.Getenv("OIDC_ISSUER"), os.Getenv("OIDC_CLIENT_ID"), os.Getenv("OIDC_CLIENT_SECRET"), os.Getenv("SESSION_SECRET")} + c := Config{firstEnv("APP_BASE_URL", "PUBLIC_URL"), firstEnv("OIDC_ISSUER", "POCKET_ID_ISSUER"), firstEnv("OIDC_CLIENT_ID", "POCKET_ID_CLIENT_ID"), firstEnv("OIDC_CLIENT_SECRET", "POCKET_ID_CLIENT_SECRET"), os.Getenv("SESSION_SECRET")} if c.SessionSecret == "" { c.SessionSecret = "dev-secret-change-me" } @@ -210,7 +229,8 @@ func (m *Manager) Require(next http.Handler) http.Handler { http.Redirect(w, r, "/login", 302) return } - next.ServeHTTP(w, r) + ctx := context.WithValue(r.Context(), UserIDKey, uid) + next.ServeHTTP(w, r.WithContext(ctx)) }) } func Logout(w http.ResponseWriter, r *http.Request) { diff --git a/internal/db/db.go b/internal/db/db.go index cea46ab..98258dd 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -5,6 +5,8 @@ import ( "database/sql" "fmt" "os" + "path/filepath" + "sort" _ "github.com/go-sql-driver/mysql" ) @@ -14,6 +16,8 @@ func Open(ctx context.Context, url string) (*sql.DB, error) { if err != nil { return nil, err } + d.SetMaxOpenConns(25) + d.SetMaxIdleConns(10) if err := d.PingContext(ctx); err != nil { return nil, err } @@ -26,7 +30,11 @@ func Migrate(ctx context.Context, d *sql.DB) error { if _, err := d.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations(version VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`); err != nil { return err } - files := []string{"migrations/001_init.sql", "migrations/002_admin.sql"} + files, err := filepath.Glob("migrations/*.sql") + if err != nil { + return err + } + sort.Strings(files) for _, f := range files { var exists bool if err := d.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=?)`, f).Scan(&exists); err != nil { diff --git a/internal/handlers/app.go b/internal/handlers/app.go index de9f543..20dbe1a 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -1,11 +1,17 @@ package handlers import ( + "context" "database/sql" + "encoding/json" + "fmt" "html/template" "net/http" "strconv" "strings" + "time" + + "trading-tool/internal/auth" ) type App struct { @@ -17,76 +23,83 @@ type Row map[string]any func New(db *sql.DB) *App { return &App{DB: db, T: template.Must(template.ParseGlob("web/templates/*.html"))} } -func (a *App) Home(w http.ResponseWriter, r *http.Request) { - reqs, _ := a.DB.Query(`select tr.id,tr.kind,i.name,tr.quantity,tr.price_limit,tr.status,tr.created_at from trade_requests tr join items i on i.id=tr.item_id order by tr.id desc limit 30`) - defer reqs.Close() - var rs []Row - for reqs.Next() { - var id, qty int - var kind, item, status string - var created []byte - var price float64 - reqs.Scan(&id, &kind, &item, &qty, &price, &status, &created) - rs = append(rs, Row{"ID": id, "Kind": kind, "Item": item, "Qty": qty, "Price": price, "Status": status, "Created": string(created)}) - } - qs, _ := a.DB.Query(`select q.id,q.type,q.title,coalesce(d.name,''),q.status,coalesce(q.contractor_note,'') from quests q left join departments d on d.id=q.department_id order by q.id desc limit 50`) - defer qs.Close() - var quests []Row - for qs.Next() { - var id int - var typ, title, dept, status, note string - qs.Scan(&id, &typ, &title, &dept, &status, ¬e) - quests = append(quests, Row{"ID": id, "Type": typ, "Title": title, "Dept": dept, "Status": status, "Note": note}) - } - a.T.ExecuteTemplate(w, "home.html", map[string]any{"Requests": rs, "Quests": quests}) -} func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) { a.T.ExecuteTemplate(w, "login.html", nil) } + +func (a *App) Home(w http.ResponseWriter, r *http.Request) { + requests := a.requestRows(r.Context(), ``, 50) + depts := a.departmentRows(r.Context()) + quests := a.questRows(r.Context(), 0, 80) + a.T.ExecuteTemplate(w, "home.html", map[string]any{"Requests": requests, "Quests": quests, "Departments": depts}) +} + func (a *App) NewRequest(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { - rows, _ := a.DB.Query(`select id,name from items order by name`) - defer rows.Close() - var items []Row - for rows.Next() { - var id int - var name string - rows.Scan(&id, &name) - items = append(items, Row{"ID": id, "Name": name}) - } - a.T.ExecuteTemplate(w, "request.html", map[string]any{"Items": items}) + a.T.ExecuteTemplate(w, "request.html", map[string]any{"Items": a.itemRows(r.Context()), "Departments": a.departmentRows(r.Context())}) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) return } - r.ParseForm() item, _ := strconv.Atoi(r.FormValue("item_id")) qty, _ := strconv.Atoi(r.FormValue("quantity")) price, _ := strconv.ParseFloat(r.FormValue("price_limit"), 64) + source := nullInt(r.FormValue("source_department_id")) + target := nullInt(r.FormValue("target_department_id")) kind := r.FormValue("kind") if kind != "sell" { kind = "buy" } - res, err := a.DB.Exec(`insert into trade_requests(kind,item_id,quantity,price_limit,status) values(?,?,?,?, 'new')`, kind, item, qty, price) + if item <= 0 || qty <= 0 { + http.Error(w, "Item und Menge sind Pflicht", 400) + return + } + tx, err := a.DB.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer tx.Rollback() + res, err := tx.ExecContext(r.Context(), `insert into trade_requests(kind,item_id,quantity,price_limit,source_department_id,target_department_id,status,created_by,notes) values(?,?,?,?,?,?, 'new',?,?)`, kind, item, qty, price, source, target, nullInt(auth.UserID(r)), r.FormValue("notes")) if err != nil { http.Error(w, err.Error(), 500) return } id, _ := res.LastInsertId() - a.generateQuests(r, id, kind, item, qty, price) - http.Redirect(w, r, "/", 303) -} -func (a *App) generateQuests(r *http.Request, reqID int64, kind string, itemID, qty int, price float64) { - var item string - a.DB.QueryRowContext(r.Context(), `select name from items where id=?`, itemID).Scan(&item) - var auto string - a.DB.QueryRowContext(r.Context(), `select setting_value from app_settings where setting_key='auto_generate_quests'`).Scan(&auto) - if auto == "false" || auto == "0" || auto == "no" { - a.DB.ExecContext(r.Context(), `insert into events(type,payload) values('request.created', JSON_OBJECT('request_id',?,'kind',?,'item_id',?,'quantity',?,'price',?,'auto_generate',false))`, reqID, kind, itemID, qty, price) + if err := a.generateQuestsTx(r.Context(), tx, id, kind, item, qty, price, source, target); err != nil { + http.Error(w, err.Error(), 500) return } - rows, err := a.DB.QueryContext(r.Context(), `select type,title_template,department_id,reward_percent from quest_templates where enabled=true order by sort_order,id`) - if err != nil { + if err := tx.Commit(); err != nil { + http.Error(w, err.Error(), 500) return } + http.Redirect(w, r, "/", 303) +} + +type workflowRule struct { + ID int64 + Name, Cond, Actions string +} +type condition map[string]any + +func (a *App) generateQuestsTx(ctx context.Context, tx *sql.Tx, reqID int64, kind string, itemID, qty int, price float64, source, target sql.NullInt64) error { + var item, category string + if err := tx.QueryRowContext(ctx, `select name,category from items where id=?`, itemID).Scan(&item, &category); err != nil { + return err + } + var auto string + _ = tx.QueryRowContext(ctx, `select setting_value from app_settings where setting_key='auto_generate_quests'`).Scan(&auto) + if auto == "false" || auto == "0" || auto == "no" { + return nil + } + rule, templateTypes := a.matchWorkflow(ctx, tx, kind, category, qty, price, source, target) + rows, err := queryTemplates(ctx, tx, templateTypes) + if err != nil { + return err + } defer rows.Close() var prev sql.NullInt64 created := 0 @@ -94,30 +107,294 @@ func (a *App) generateQuests(r *http.Request, reqID int64, kind string, itemID, var typ, title string var dept sql.NullInt64 var percent float64 - rows.Scan(&typ, &title, &dept, &percent) + var order int + if err := rows.Scan(&typ, &title, &dept, &percent, &order); err != nil { + return err + } title = strings.ReplaceAll(title, "{{item}}", item) title = strings.ReplaceAll(title, "{{quantity}}", strconv.Itoa(qty)) - res, err := a.DB.ExecContext(r.Context(), `insert into quests(parent_request_id,depends_on_quest_id,type,title,department_id,reward,status) values(?,?,?,?,?,?,'open')`, reqID, prev, typ, title, dept, price*percent) - if err == nil { - qid, _ := res.LastInsertId() - prev = sql.NullInt64{Int64: qid, Valid: true} - created++ + status := "open" + if created > 0 { + status = "waiting" + } + res, err := tx.ExecContext(ctx, `insert into quests(parent_request_id,depends_on_quest_id,type,title,department_id,reward,status,sequence_order) values(?,?,?,?,?,?,?,?)`, reqID, prev, typ, title, dept, price*percent, status, order) + if err != nil { + return err + } + qid, _ := res.LastInsertId() + if prev.Valid { + _, _ = tx.ExecContext(ctx, `insert ignore into quest_dependencies(quest_id,depends_on_quest_id) values(?,?)`, qid, prev.Int64) + } + prev = sql.NullInt64{Int64: qid, Valid: true} + created++ + } + st := "in_progress" + if created == 0 { + st = "fulfilled" + } + _, err = tx.ExecContext(ctx, `update trade_requests set status=?, workflow_rule_id=? where id=?`, st, rule, reqID) + if err != nil { + return err + } + _, _ = tx.ExecContext(ctx, `insert into events(type,payload) values('request.created', JSON_OBJECT('request_id',?,'quests_created',?,'workflow_rule_id',?))`, reqID, created, rule) + return nil +} + +func (a *App) matchWorkflow(ctx context.Context, tx *sql.Tx, kind, category string, qty int, price float64, source, target sql.NullInt64) (sql.NullInt64, []string) { + rows, err := tx.QueryContext(ctx, `select id,name,condition_json,actions_json from workflow_rules where enabled=true and trigger_name='request.created' order by id`) + if err != nil { + return sql.NullInt64{}, nil + } + defer rows.Close() + for rows.Next() { + var wr workflowRule + rows.Scan(&wr.ID, &wr.Name, &wr.Cond, &wr.Actions) + if conditionMatches(wr.Cond, kind, category, qty, price, source, target) { + var actions []string + _ = json.Unmarshal([]byte(wr.Actions), &actions) + return sql.NullInt64{Int64: wr.ID, Valid: true}, actions } } - a.DB.ExecContext(r.Context(), `insert into events(type,payload) values('request.created', JSON_OBJECT('request_id',?,'kind',?,'item_id',?,'quantity',?,'price',?,'quests_created',?))`, reqID, kind, itemID, qty, price, created) + return sql.NullInt64{}, nil } + +func conditionMatches(raw, kind, cat string, qty int, price float64, source, target sql.NullInt64) bool { + if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "{}" { + return true + } + var c map[string]any + if json.Unmarshal([]byte(raw), &c) != nil { + return false + } + eqs := map[string]string{"kind": kind, "item_category": cat} + for k, v := range eqs { + if want, ok := c[k].(string); ok && want != "" && want != v { + return false + } + } + if v, ok := num(c["quantity_min"]); ok && float64(qty) < v { + return false + } + if v, ok := num(c["quantity_max"]); ok && float64(qty) > v { + return false + } + if v, ok := num(c["price_min"]); ok && price < v { + return false + } + if v, ok := num(c["price_max"]); ok && price > v { + return false + } + return true +} +func num(v any) (float64, bool) { + switch t := v.(type) { + case float64: + return t, true + case int: + return float64(t), true + default: + return 0, false + } +} + +func queryTemplates(ctx context.Context, tx *sql.Tx, types []string) (*sql.Rows, error) { + if len(types) == 0 { + return tx.QueryContext(ctx, `select type,title_template,department_id,reward_percent,sort_order from quest_templates where enabled=true order by sort_order,id`) + } + qs := strings.TrimRight(strings.Repeat("?,", len(types)), ",") + args := make([]any, len(types)) + for i, t := range types { + args[i] = t + } + return tx.QueryContext(ctx, fmt.Sprintf(`select type,title_template,department_id,reward_percent,sort_order from quest_templates where enabled=true and type in (%s) order by field(type,%s)`, qs, qs), append(args, args...)...) +} + func (a *App) UpdateQuest(w http.ResponseWriter, r *http.Request) { - r.ParseForm() + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } id, _ := strconv.Atoi(r.FormValue("id")) status := r.FormValue("status") note := r.FormValue("note") - if status == "" { - status = "open" + if !validQuestStatus(status) { + http.Error(w, "Ungültiger Status", 400) + return } - a.DB.Exec(`update quests set status=?, contractor_note=? where id=?`, status, note, id) - a.DB.Exec(`insert into quest_updates(quest_id,status,note) values(?,?,?)`, id, status, note) + tx, err := a.DB.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer tx.Rollback() + var old string + var reqID sql.NullInt64 + if err := tx.QueryRowContext(r.Context(), `select status,parent_request_id from quests where id=? for update`, id).Scan(&old, &reqID); err != nil { + http.Error(w, err.Error(), 404) + return + } + if old == "waiting" && status != "cancelled" { + http.Error(w, "Quest wartet noch auf vorherige Abhängigkeiten", 409) + return + } + uid := nullInt(auth.UserID(r)) + completed := sql.NullTime{} if status == "done" { - a.DB.Exec(`update quests set status='open' where depends_on_quest_id=? and status='waiting'`, id) + completed = sql.NullTime{Time: time.Now(), Valid: true} } - http.Redirect(w, r, "/", 303) + _, err = tx.ExecContext(r.Context(), `update quests set status=?, contractor_note=?, blocked_reason=case when ?='blocked' then ? else blocked_reason end, claimed_by=coalesce(claimed_by,?), completed_at=? where id=?`, status, note, status, note, uid, completed, id) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + _, _ = tx.ExecContext(r.Context(), `insert into quest_updates(quest_id,actor_user_id,status,note) values(?,?,?,?)`, id, uid, status, note) + if status == "done" { + _ = a.unlockDependents(r.Context(), tx, int64(id)) + } + if reqID.Valid { + _ = a.recalculateRequestStatus(r.Context(), tx, reqID.Int64) + } + if err := tx.Commit(); err != nil { + http.Error(w, err.Error(), 500) + return + } + back := r.Referer() + if back == "" { + back = "/" + } + http.Redirect(w, r, back, 303) +} + +func validQuestStatus(s string) bool { + switch s { + case "open", "accepted", "in_progress", "blocked", "change_requested", "done", "cancelled": + return true + } + return false +} +func (a *App) unlockDependents(ctx context.Context, tx *sql.Tx, doneID int64) error { + rows, err := tx.QueryContext(ctx, `select q.id from quests q where q.status='waiting' and q.depends_on_quest_id=?`, doneID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var qid int64 + rows.Scan(&qid) + var blockers int + tx.QueryRowContext(ctx, `select count(*) from quest_dependencies d join quests q2 on q2.id=d.depends_on_quest_id where d.quest_id=? and q2.status <> 'done'`, qid).Scan(&blockers) + if blockers == 0 { + tx.ExecContext(ctx, `update quests set status='open' where id=?`, qid) + } + } + return nil +} +func (a *App) recalculateRequestStatus(ctx context.Context, tx *sql.Tx, reqID int64) error { + var total, done, active, blocked, cancelled int + tx.QueryRowContext(ctx, `select count(*), coalesce(sum(status='done'),0), coalesce(sum(status in ('open','accepted','in_progress','change_requested','waiting')),0), coalesce(sum(status='blocked'),0), coalesce(sum(status='cancelled'),0) from quests where parent_request_id=?`, reqID).Scan(&total, &done, &active, &blocked, &cancelled) + status := "new" + fulfilledAt := sql.NullTime{} + switch { + case total > 0 && done == total: + status = "fulfilled" + fulfilledAt = sql.NullTime{Time: time.Now(), Valid: true} + case blocked > 0: + status = "blocked" + case cancelled == total && total > 0: + status = "cancelled" + case active > 0 || done > 0: + status = "in_progress" + } + _, err := tx.ExecContext(ctx, `update trade_requests set status=?, fulfilled_at=? where id=?`, status, fulfilledAt, reqID) + return err +} + +func (a *App) Departments(w http.ResponseWriter, r *http.Request) { + a.T.ExecuteTemplate(w, "departments.html", map[string]any{"Departments": a.departmentRows(r.Context())}) +} +func (a *App) DepartmentQuests(w http.ResponseWriter, r *http.Request) { + idStr := strings.TrimPrefix(r.URL.Path, "/departments/") + idStr = strings.TrimSuffix(strings.TrimSuffix(idStr, "/quests"), "/") + id, _ := strconv.Atoi(idStr) + var name string + a.DB.QueryRowContext(r.Context(), `select name from departments where id=?`, id).Scan(&name) + a.T.ExecuteTemplate(w, "department_quests.html", map[string]any{"DepartmentID": id, "DepartmentName": name, "Quests": a.questRows(r.Context(), id, 200)}) +} + +func (a *App) requestRows(ctx context.Context, where string, limit int) []Row { + rows, err := a.DB.QueryContext(ctx, `select tr.id,tr.kind,i.name,tr.quantity,tr.price_limit,tr.status,tr.created_at,coalesce(tr.notes,'') from trade_requests tr join items i on i.id=tr.item_id order by tr.id desc limit ?`, limit) + if err != nil { + return nil + } + defer rows.Close() + var out []Row + for rows.Next() { + var id, qty int + var kind, item, status, note string + var created time.Time + var price float64 + rows.Scan(&id, &kind, &item, &qty, &price, &status, &created, ¬e) + out = append(out, Row{"ID": id, "Kind": kind, "Item": item, "Qty": qty, "Price": price, "Status": status, "Created": created.Format("2006-01-02 15:04"), "Note": note}) + } + return out +} +func (a *App) questRows(ctx context.Context, deptID int, limit int) []Row { + q := `select q.id,q.type,q.title,coalesce(d.name,''),q.status,coalesce(q.contractor_note,''),coalesce(q.blocked_reason,''),q.sequence_order,coalesce(tr.id,0),coalesce(i.name,'') from quests q left join departments d on d.id=q.department_id left join trade_requests tr on tr.id=q.parent_request_id left join items i on i.id=tr.item_id` + args := []any{} + if deptID > 0 { + q += ` where q.department_id=?` + args = append(args, deptID) + } + q += ` order by q.status='waiting', q.sequence_order, q.id desc limit ?` + args = append(args, limit) + rows, err := a.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil + } + defer rows.Close() + var out []Row + for rows.Next() { + var id, order, reqID int + var typ, title, dept, status, note, block, item string + rows.Scan(&id, &typ, &title, &dept, &status, ¬e, &block, &order, &reqID, &item) + out = append(out, Row{"ID": id, "Type": typ, "Title": title, "Dept": dept, "Status": status, "Note": note, "Block": block, "Order": order, "RequestID": reqID, "Item": item}) + } + return out +} +func (a *App) departmentRows(ctx context.Context) []Row { + rows, err := a.DB.QueryContext(ctx, `select id,name from departments order by name`) + if err != nil { + return nil + } + defer rows.Close() + var out []Row + for rows.Next() { + var id int + var name string + rows.Scan(&id, &name) + out = append(out, Row{"ID": id, "Name": name}) + } + return out +} +func (a *App) itemRows(ctx context.Context) []Row { + rows, err := a.DB.QueryContext(ctx, `select id,name,category from items order by name`) + if err != nil { + return nil + } + defer rows.Close() + var out []Row + for rows.Next() { + var id int + var name, cat string + rows.Scan(&id, &name, &cat) + out = append(out, Row{"ID": id, "Name": name, "Category": cat}) + } + return out +} +func nullInt(s string) sql.NullInt64 { + i, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) + if err != nil || i <= 0 { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: i, Valid: true} } diff --git a/migrations/003_production_workflow.sql b/migrations/003_production_workflow.sql new file mode 100644 index 0000000..f9fd107 --- /dev/null +++ b/migrations/003_production_workflow.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS user_roles ( + user_id BIGINT NOT NULL, + role VARCHAR(60) NOT NULL, + PRIMARY KEY(user_id, role), + CONSTRAINT fk_user_role_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS department_members ( + user_id BIGINT NOT NULL, + department_id BIGINT NOT NULL, + role VARCHAR(60) NOT NULL DEFAULT 'member', + PRIMARY KEY(user_id, department_id), + CONSTRAINT fk_dept_member_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_dept_member_dept FOREIGN KEY(department_id) REFERENCES departments(id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS quest_dependencies ( + quest_id BIGINT NOT NULL, + depends_on_quest_id BIGINT NOT NULL, + PRIMARY KEY(quest_id, depends_on_quest_id), + CONSTRAINT fk_qdep_quest FOREIGN KEY(quest_id) REFERENCES quests(id) ON DELETE CASCADE, + CONSTRAINT fk_qdep_depends FOREIGN KEY(depends_on_quest_id) REFERENCES quests(id) ON DELETE CASCADE +); +ALTER TABLE quests ADD COLUMN IF NOT EXISTS claimed_by BIGINT NULL; +ALTER TABLE quests ADD COLUMN IF NOT EXISTS sequence_order INT NOT NULL DEFAULT 100; +ALTER TABLE quests ADD COLUMN IF NOT EXISTS blocked_reason TEXT NULL; +ALTER TABLE quests ADD COLUMN IF NOT EXISTS completed_at TIMESTAMP NULL; +ALTER TABLE trade_requests ADD COLUMN IF NOT EXISTS fulfilled_at TIMESTAMP NULL; +ALTER TABLE trade_requests ADD COLUMN IF NOT EXISTS workflow_rule_id BIGINT NULL; +ALTER TABLE trade_requests ADD COLUMN IF NOT EXISTS notes TEXT NULL; +CREATE INDEX IF NOT EXISTS idx_quests_dept_status ON quests(department_id,status); +CREATE INDEX IF NOT EXISTS idx_quests_request ON quests(parent_request_id); +CREATE INDEX IF NOT EXISTS idx_requests_status ON trade_requests(status); + +INSERT IGNORE INTO app_settings(setting_key,setting_value,description) VALUES +('default_request_status_after_quest_generation','in_progress','Status einer Anfrage nach Quest-Erzeugung'), +('unlock_next_quests_automatically','true','Folge-Quests automatisch öffnen, wenn Abhängigkeiten erledigt sind'); diff --git a/web/templates/department_quests.html b/web/templates/department_quests.html new file mode 100644 index 0000000..fca565f --- /dev/null +++ b/web/templates/department_quests.html @@ -0,0 +1 @@ +Quest-Portal
Trading ToolDashboardAbteilungenAdminLogout

Quest-Portal: {{.DepartmentName}}

Zurück zu Abteilungen

{{range .Quests}}{{end}}
IDRequestTypTitelStatusRückmeldungAktion
#{{.ID}}#{{.RequestID}} {{.Item}}{{.Type}}{{.Title}}{{.Status}}{{.Note}}{{if .Block}}
Blocker: {{.Block}}{{end}}
{{if eq .Status "waiting"}}Wartet auf vorherige Quest{{else}}


{{end}}
diff --git a/web/templates/departments.html b/web/templates/departments.html new file mode 100644 index 0000000..9c84ba9 --- /dev/null +++ b/web/templates/departments.html @@ -0,0 +1 @@ +Abteilungen
Trading ToolDashboardAbteilungenAdminLogout

Abteilungsportale

Jede Abteilung sieht hier ihre eigenen Quests und kann diese annehmen, blockieren, kommentieren oder abschließen.

{{range .Departments}}

{{.Name}}

Quests anzeigen und bearbeiten

{{end}}
diff --git a/web/templates/home.html b/web/templates/home.html index 7036a3e..ca12802 100644 --- a/web/templates/home.html +++ b/web/templates/home.html @@ -1 +1,5 @@ -Trading Tool

Neue Anfrage · Admin-Konfiguration · Logout

Trading Dashboard

Anfragen

{{range .Requests}}{{end}}
IDTypItemMengePreisStatus
{{.ID}}{{.Kind}}{{.Item}}{{.Qty}}{{printf "%.2f" .Price}}{{.Status}}

Quests / Auftragnehmer-Workflow

{{range .Quests}}{{end}}
IDTypTitelAbteilungStatusNotizAktion
{{.ID}}{{.Type}}{{.Title}}{{.Dept}}{{.Status}}{{.Note}}


+Trading Tool
Trading ToolDashboardAbteilungenAdminLogout
+

Trading Dashboard

+

Anfragen

{{range .Requests}}{{end}}
IDTypItemMengePreisStatusNotiz
#{{.ID}}{{.Kind}}{{.Item}}{{.Qty}}{{printf "%.2f" .Price}}{{.Status}}{{.Note}}
+

Alle Quests

{{range .Quests}}{{end}}
IDRequestTypTitelAbteilungStatusRückmeldungAktion
#{{.ID}}#{{.RequestID}} {{.Item}}{{.Type}}{{.Title}}{{.Dept}}{{.Status}}{{.Note}}{{if .Block}}
Blocker: {{.Block}}{{end}}
{{if eq .Status "waiting"}}Wartet auf vorherige Quest{{else}}


{{end}}
+
diff --git a/web/templates/request.html b/web/templates/request.html index a13d97c..978d385 100644 --- a/web/templates/request.html +++ b/web/templates/request.html @@ -1 +1 @@ -Neue Anfrage

Zurück

Neue Trading-Anfrage

+Neue Anfrage

← Dashboard

Neue Trading-Anfrage