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-PortalQuest-Portal: {{.DepartmentName}}
Zurück zu Abteilungen
| ID | Request | Typ | Titel | Status | Rückmeldung | Aktion |
{{range .Quests}}| #{{.ID}} | #{{.RequestID}} {{.Item}} | {{.Type}} | {{.Title}} | {{.Status}} | {{.Note}}{{if .Block}} Blocker: {{.Block}}{{end}} | {{if eq .Status "waiting"}}Wartet auf vorherige Quest{{else}}{{end}} |
{{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 @@
+AbteilungenAbteilungsportale
Jede Abteilung sieht hier ihre eigenen Quests und kann diese annehmen, blockieren, kommentieren oder abschließen.
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 ToolNeue Anfrage · Admin-Konfiguration · Logout
Trading Dashboard
Anfragen
| ID | Typ | Item | Menge | Preis | Status |
{{range .Requests}}| {{.ID}} | {{.Kind}} | {{.Item}} | {{.Qty}} | {{printf "%.2f" .Price}} | {{.Status}} |
{{end}}
Quests / Auftragnehmer-Workflow
| ID | Typ | Titel | Abteilung | Status | Notiz | Aktion |
{{range .Quests}}| {{.ID}} | {{.Type}} | {{.Title}} | {{.Dept}} | {{.Status}} | {{.Note}} | |
{{end}}
+Trading Tool
+Trading Dashboard
+Anfragen
| ID | Typ | Item | Menge | Preis | Status | Notiz |
{{range .Requests}}| #{{.ID}} | {{.Kind}} | {{.Item}} | {{.Qty}} | {{printf "%.2f" .Price}} | {{.Status}} | {{.Note}} |
{{end}}
+Alle Quests
| ID | Request | Typ | Titel | Abteilung | Status | Rückmeldung | Aktion |
{{range .Quests}}| #{{.ID}} | #{{.RequestID}} {{.Item}} | {{.Type}} | {{.Title}} | {{.Dept}} | {{.Status}} | {{.Note}}{{if .Block}} Blocker: {{.Block}}{{end}} | {{if eq .Status "waiting"}}Wartet auf vorherige Quest{{else}}{{end}} |
{{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 AnfrageZurück
Neue Trading-Anfrage
+Neue Anfrage← Dashboard
Neue Trading-Anfrage