diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ef73082 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +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 diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..20912ac --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-fast + env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."${{ vars.DOCKER_REGISTRY }}"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40add1f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.26-bookworm AS builder +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/trading-tool ./cmd/server + +FROM gcr.io/distroless/static-debian13:nonroot +WORKDIR /app + +COPY --from=builder /out/trading-tool /app/trading-tool +COPY web /app/web +COPY migrations /app/migrations + +ENV APP_ADDR=:8080 +EXPOSE 8080 + +USER nonroot:nonroot +ENTRYPOINT ["/app/trading-tool"] diff --git a/README.md b/README.md index 4ce8f19..2952cae 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,75 @@ -# startrading +# Trading Tool Prototype — MariaDB +Go-Webapp für Trading-Anfragen, automatische Quest-Erzeugung, MariaDB und Pocket ID Login via OIDC. + +## Webinterface + +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`. + +## Start + +```bash +cp .env.example .env +docker compose up -d +set -a; source .env; set +a +go mod tidy +go run ./cmd/server +``` + +Dann öffnen: http://localhost:8080 + +## Architektur + +- 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`. + +## Nächste sinnvolle Schritte + +- 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. + +## Admin-Konfiguration + +Nach dem Login gibt es im Dashboard den Link **Admin-Konfiguration** (`/admin`). Dort kannst du ohne Codeänderung konfigurieren: + +- 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 + +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. + +Bestehende Installationen erhalten die Admin-Tabellen über `migrations/002_admin.sql` automatisch beim nächsten Start. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..454c83b --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "trading-tool/internal/auth" + "trading-tool/internal/db" + "trading-tool/internal/handlers" +) + +func env(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} +func main() { + ctx := context.Background() + database, err := db.Open(ctx, env("DATABASE_URL", "trading:trading@tcp(127.0.0.1:3306)/trading?parseTime=true&multiStatements=true")) + if err != nil { + log.Fatal(err) + } + if err := db.Migrate(ctx, database); err != nil { + log.Fatal(err) + } + am, err := auth.New(ctx, database) + if err != nil { + log.Fatal(err) + } + app := handlers.New(database) + mux := http.NewServeMux() + mux.HandleFunc("/login", app.LoginPage) + mux.HandleFunc("/auth/login", am.Login) + mux.HandleFunc("/auth/callback", am.Callback) + mux.HandleFunc("/logout", auth.Logout) + 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("/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))) + mux.Handle("/admin/settings", am.RequireAdmin(http.HandlerFunc(app.AdminSetting))) + mux.Handle("/admin/statuses", am.RequireAdmin(http.HandlerFunc(app.AdminStatus))) + mux.Handle("/admin/workflow-rules", am.RequireAdmin(http.HandlerFunc(app.AdminWorkflowRule))) + mux.Handle("/admin/quest-templates", am.RequireAdmin(http.HandlerFunc(app.AdminQuestTemplate))) + log.Println("listening", env("APP_ADDR", ":8080")) + log.Fatal(http.ListenAndServe(env("APP_ADDR", ":8080"), mux)) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..abf5819 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + db: + image: mariadb:11 + environment: + MARIADB_DATABASE: trading + MARIADB_USER: trading + MARIADB_PASSWORD: trading + MARIADB_ROOT_PASSWORD: trading-root + ports: ["3306:3306"] + volumes: ["mariadbdata:/var/lib/mysql"] + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 +volumes: + mariadbdata: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c77e587 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +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/go.sum b/go.sum new file mode 100644 index 0000000..19dbcec --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..288053e --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,243 @@ +package auth + +import ( + "context" + "crypto" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type Config struct{ BaseURL, Issuer, ClientID, ClientSecret, SessionSecret string } +type Provider struct{ AuthURL, TokenURL, JWKSURL string } +type Claims struct { + Sub, Email, Name string + Exp int64 + Iss, Aud string +} + +type Manager struct { + Cfg Config + P Provider + DB *sql.DB + Client *http.Client +} + +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")} + if c.SessionSecret == "" { + c.SessionSecret = "dev-secret-change-me" + } + m := &Manager{Cfg: c, DB: db, Client: &http.Client{Timeout: 10 * time.Second}} + if c.Issuer != "" { + if err := m.discover(ctx); err != nil { + return nil, err + } + } + return m, nil +} +func (m *Manager) discover(ctx context.Context) error { + req, _ := http.NewRequestWithContext(ctx, "GET", strings.TrimRight(m.Cfg.Issuer, "/")+"/.well-known/openid-configuration", nil) + resp, err := m.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + var v struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + } + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return err + } + m.P = Provider{v.AuthorizationEndpoint, v.TokenEndpoint, v.JWKSURI} + return nil +} +func Rand(n int) string { + b := make([]byte, n) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} +func sign(secret, s string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(s)) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} +func (m *Manager) SetSignedCookie(w http.ResponseWriter, name, value string, maxAge int) { + v := value + "." + sign(m.Cfg.SessionSecret, value) + http.SetCookie(w, &http.Cookie{Name: name, Value: v, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: strings.HasPrefix(m.Cfg.BaseURL, "https://"), MaxAge: maxAge}) +} +func (m *Manager) ReadSignedCookie(r *http.Request, name string) (string, bool) { + c, err := r.Cookie(name) + if err != nil { + return "", false + } + p := strings.LastIndex(c.Value, ".") + if p < 1 { + return "", false + } + val, sig := c.Value[:p], c.Value[p+1:] + return val, hmac.Equal([]byte(sig), []byte(sign(m.Cfg.SessionSecret, val))) +} +func (m *Manager) Login(w http.ResponseWriter, r *http.Request) { + st := Rand(24) + m.SetSignedCookie(w, "oidc_state", st, 300) + q := url.Values{"client_id": {m.Cfg.ClientID}, "redirect_uri": {m.Cfg.BaseURL + "/auth/callback"}, "response_type": {"code"}, "scope": {"openid profile email"}, "state": {st}} + http.Redirect(w, r, m.P.AuthURL+"?"+q.Encode(), 302) +} +func (m *Manager) Callback(w http.ResponseWriter, r *http.Request) { + st, ok := m.ReadSignedCookie(r, "oidc_state") + if !ok || st != r.URL.Query().Get("state") { + http.Error(w, "bad state", 400) + return + } + tok, err := m.exchange(r.Context(), r.URL.Query().Get("code")) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + cl, err := m.verify(r.Context(), tok) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + res, err := m.DB.ExecContext(r.Context(), `INSERT INTO users(subject,email,name) VALUES(?,?,?) ON DUPLICATE KEY UPDATE email=VALUES(email), name=VALUES(name), id=LAST_INSERT_ID(id)`, cl.Sub, cl.Email, cl.Name) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + uid, err := res.LastInsertId() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + m.SetSignedCookie(w, "session", fmt.Sprint(uid), 86400*30) + http.Redirect(w, r, "/", 302) +} +func (m *Manager) exchange(ctx context.Context, code string) (string, error) { + data := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {m.Cfg.BaseURL + "/auth/callback"}, "client_id": {m.Cfg.ClientID}, "client_secret": {m.Cfg.ClientSecret}} + req, _ := http.NewRequestWithContext(ctx, "POST", m.P.TokenURL, strings.NewReader(data.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := m.Client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode > 299 { + return "", fmt.Errorf("token error: %s", body) + } + var v struct { + IDToken string `json:"id_token"` + } + json.Unmarshal(body, &v) + return v.IDToken, nil +} +func (m *Manager) verify(ctx context.Context, jwt string) (Claims, error) { + parts := strings.Split(jwt, ".") + if len(parts) != 3 { + return Claims{}, errors.New("bad jwt") + } + headB, _ := base64.RawURLEncoding.DecodeString(parts[0]) + var h struct{ Kid, Alg string } + json.Unmarshal(headB, &h) + if h.Alg != "RS256" { + return Claims{}, errors.New("only RS256 supported") + } + key, err := m.jwk(ctx, h.Kid) + if err != nil { + return Claims{}, err + } + sig, _ := base64.RawURLEncoding.DecodeString(parts[2]) + sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], sig); err != nil { + return Claims{}, err + } + pay, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var raw map[string]any + json.Unmarshal(pay, &raw) + c := Claims{Sub: fmt.Sprint(raw["sub"]), Email: fmt.Sprint(raw["email"]), Name: fmt.Sprint(raw["name"]), Iss: fmt.Sprint(raw["iss"]), Aud: fmt.Sprint(raw["aud"])} + if exp, ok := raw["exp"].(float64); ok { + c.Exp = int64(exp) + } + if c.Exp < time.Now().Unix() { + return c, errors.New("token expired") + } + return c, nil +} +func (m *Manager) jwk(ctx context.Context, kid string) (*rsa.PublicKey, error) { + req, _ := http.NewRequestWithContext(ctx, "GET", m.P.JWKSURL, nil) + resp, err := m.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var jwks struct { + Keys []map[string]string `json:"keys"` + } + json.NewDecoder(resp.Body).Decode(&jwks) + for _, k := range jwks.Keys { + if k["kid"] == kid { + nB, _ := base64.RawURLEncoding.DecodeString(k["n"]) + eB, _ := base64.RawURLEncoding.DecodeString(k["e"]) + e := 0 + for _, b := range eB { + e = e*256 + int(b) + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(nB), E: e}, nil + } + } + return nil, errors.New("jwk not found") +} +func (m *Manager) Require(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uid, ok := m.ReadSignedCookie(r, "session") + if !ok || uid == "" { + http.Redirect(w, r, "/login", 302) + return + } + next.ServeHTTP(w, r) + }) +} +func Logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "session", Path: "/", MaxAge: -1}) + http.Redirect(w, r, "/login", 302) +} + +func (m *Manager) RequireAdmin(next http.Handler) http.Handler { + return m.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uid, _ := m.ReadSignedCookie(r, "session") + var email string + m.DB.QueryRowContext(r.Context(), `select email from users where id=?`, uid).Scan(&email) + var allowed string + m.DB.QueryRowContext(r.Context(), `select setting_value from app_settings where setting_key='admin_emails'`).Scan(&allowed) + if strings.TrimSpace(allowed) != "" { + ok := false + for _, part := range strings.Split(allowed, ",") { + if strings.EqualFold(strings.TrimSpace(part), strings.TrimSpace(email)) { + ok = true + break + } + } + if !ok { + http.Error(w, "admin access required", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + })) +} diff --git a/internal/auth/auth.go.tmp b/internal/auth/auth.go.tmp new file mode 100644 index 0000000..e69de29 diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..cea46ab --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,50 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "os" + + _ "github.com/go-sql-driver/mysql" +) + +func Open(ctx context.Context, url string) (*sql.DB, error) { + d, err := sql.Open("mysql", url) + if err != nil { + return nil, err + } + if err := d.PingContext(ctx); err != nil { + return nil, err + } + return d, nil +} + +func Migrate(ctx context.Context, d *sql.DB) error { + d.ExecContext(ctx, `SELECT GET_LOCK('trading_tool_migrate', 30)`) + defer d.ExecContext(ctx, `SELECT RELEASE_LOCK('trading_tool_migrate')`) + 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"} + 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 { + return err + } + if exists { + continue + } + b, err := os.ReadFile(f) + if err != nil { + return err + } + if _, err := d.ExecContext(ctx, string(b)); err != nil { + return fmt.Errorf("%s: %w", f, err) + } + if _, err := d.ExecContext(ctx, `INSERT INTO schema_migrations(version) VALUES(?)`, f); err != nil { + return err + } + } + return nil +} diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go new file mode 100644 index 0000000..6b16fb5 --- /dev/null +++ b/internal/handlers/admin.go @@ -0,0 +1,210 @@ +package handlers + +import ( + "database/sql" + "net/http" + "strconv" +) + +func (a *App) Admin(w http.ResponseWriter, r *http.Request) { + data := map[string]any{} + data["Departments"] = a.departments() + data["Items"] = a.items() + data["Settings"] = a.settings() + data["Statuses"] = a.statuses() + data["WorkflowRules"] = a.workflowRules() + data["QuestTemplates"] = a.questTemplates() + a.T.ExecuteTemplate(w, "admin.html", data) +} + +func (a *App) AdminDepartment(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + action := r.FormValue("action") + id, _ := strconv.Atoi(r.FormValue("id")) + name := r.FormValue("name") + switch action { + case "delete": + a.DB.Exec(`delete from departments where id=?`, id) + default: + if id > 0 { + a.DB.Exec(`update departments set name=? where id=?`, name, id) + } else if name != "" { + a.DB.Exec(`insert ignore into departments(name) values(?)`, name) + } + } + http.Redirect(w, r, "/admin", 303) +} + +func (a *App) AdminItem(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + action := r.FormValue("action") + id, _ := strconv.Atoi(r.FormValue("id")) + sku, name, category := r.FormValue("sku"), r.FormValue("name"), r.FormValue("category") + if category == "" { + category = "item" + } + switch action { + case "delete": + a.DB.Exec(`delete from items where id=?`, id) + default: + if id > 0 { + a.DB.Exec(`update items set sku=?, name=?, category=? where id=?`, sku, name, category, id) + } else if sku != "" && name != "" { + a.DB.Exec(`insert into items(sku,name,category) values(?,?,?)`, sku, name, category) + } + } + http.Redirect(w, r, "/admin", 303) +} + +func (a *App) AdminSetting(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + key, value, desc := r.FormValue("setting_key"), r.FormValue("setting_value"), r.FormValue("description") + if key != "" { + a.DB.Exec(`insert into app_settings(setting_key,setting_value,description) values(?,?,?) on duplicate key update setting_value=values(setting_value), description=values(description)`, key, value, desc) + } + http.Redirect(w, r, "/admin", 303) +} + +func (a *App) AdminStatus(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + action := r.FormValue("action") + id, _ := strconv.Atoi(r.FormValue("id")) + scope, value, label := r.FormValue("scope"), r.FormValue("value"), r.FormValue("label") + sortOrder, _ := strconv.Atoi(r.FormValue("sort_order")) + terminal := r.FormValue("is_terminal") == "on" + switch action { + case "delete": + a.DB.Exec(`delete from status_options where id=?`, id) + default: + if id > 0 { + a.DB.Exec(`update status_options set scope=?, value=?, label=?, sort_order=?, is_terminal=? where id=?`, scope, value, label, sortOrder, terminal, id) + } else if scope != "" && value != "" { + a.DB.Exec(`insert into status_options(scope,value,label,sort_order,is_terminal) values(?,?,?,?,?)`, scope, value, label, sortOrder, terminal) + } + } + http.Redirect(w, r, "/admin", 303) +} + +func (a *App) AdminWorkflowRule(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + action := r.FormValue("action") + id, _ := strconv.Atoi(r.FormValue("id")) + name, trigger, cond, actions := r.FormValue("name"), r.FormValue("trigger_name"), r.FormValue("condition_json"), r.FormValue("actions_json") + enabled := r.FormValue("enabled") == "on" + if cond == "" { + cond = "{}" + } + if actions == "" { + actions = "[]" + } + switch action { + case "delete": + a.DB.Exec(`delete from workflow_rules where id=?`, id) + default: + if id > 0 { + a.DB.Exec(`update workflow_rules set name=?, trigger_name=?, condition_json=?, actions_json=?, enabled=? where id=?`, name, trigger, cond, actions, enabled, id) + } else if name != "" { + a.DB.Exec(`insert into workflow_rules(name,trigger_name,condition_json,actions_json,enabled) values(?,?,?,?,?)`, name, trigger, cond, actions, enabled) + } + } + http.Redirect(w, r, "/admin", 303) +} + +func (a *App) AdminQuestTemplate(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + action := r.FormValue("action") + id, _ := strconv.Atoi(r.FormValue("id")) + deptID, _ := strconv.Atoi(r.FormValue("department_id")) + sortOrder, _ := strconv.Atoi(r.FormValue("sort_order")) + reward, _ := strconv.ParseFloat(r.FormValue("reward_percent"), 64) + typ, title := r.FormValue("type"), r.FormValue("title_template") + enabled := r.FormValue("enabled") == "on" + dept := sql.NullInt64{Int64: int64(deptID), Valid: deptID > 0} + switch action { + case "delete": + a.DB.Exec(`delete from quest_templates where id=?`, id) + default: + if id > 0 { + a.DB.Exec(`update quest_templates set type=?, title_template=?, department_id=?, reward_percent=?, enabled=?, sort_order=? where id=?`, typ, title, dept, reward, enabled, sortOrder, id) + } else if typ != "" && title != "" { + a.DB.Exec(`insert into quest_templates(type,title_template,department_id,reward_percent,enabled,sort_order) values(?,?,?,?,?,?)`, typ, title, dept, reward, enabled, sortOrder) + } + } + http.Redirect(w, r, "/admin", 303) +} + +func (a *App) departments() []Row { + rows, _ := a.DB.Query(`select id,name from departments order by name`) + 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) items() []Row { + rows, _ := a.DB.Query(`select id,sku,name,category from items order by name`) + defer rows.Close() + var out []Row + for rows.Next() { + var id int + var sku, name, cat string + rows.Scan(&id, &sku, &name, &cat) + out = append(out, Row{"ID": id, "SKU": sku, "Name": name, "Category": cat}) + } + return out +} +func (a *App) settings() []Row { + rows, _ := a.DB.Query(`select setting_key,setting_value,description from app_settings order by setting_key`) + defer rows.Close() + var out []Row + for rows.Next() { + var k, v, d string + rows.Scan(&k, &v, &d) + out = append(out, Row{"Key": k, "Value": v, "Description": d}) + } + return out +} +func (a *App) statuses() []Row { + rows, _ := a.DB.Query(`select id,scope,value,label,sort_order,is_terminal from status_options order by scope,sort_order`) + defer rows.Close() + var out []Row + for rows.Next() { + var id, sort int + var scope, value, label string + var term bool + rows.Scan(&id, &scope, &value, &label, &sort, &term) + out = append(out, Row{"ID": id, "Scope": scope, "Value": value, "Label": label, "Sort": sort, "Terminal": term}) + } + return out +} +func (a *App) workflowRules() []Row { + rows, _ := a.DB.Query(`select id,name,trigger_name,condition_json,actions_json,enabled from workflow_rules order by name`) + defer rows.Close() + var out []Row + for rows.Next() { + var id int + var name, trig, cond, actions string + var en bool + rows.Scan(&id, &name, &trig, &cond, &actions, &en) + out = append(out, Row{"ID": id, "Name": name, "Trigger": trig, "Condition": cond, "Actions": actions, "Enabled": en}) + } + return out +} +func (a *App) questTemplates() []Row { + rows, _ := a.DB.Query(`select qt.id,qt.type,qt.title_template,coalesce(qt.department_id,0),coalesce(d.name,''),qt.reward_percent,qt.enabled,qt.sort_order from quest_templates qt left join departments d on d.id=qt.department_id order by qt.sort_order`) + defer rows.Close() + var out []Row + for rows.Next() { + var id, deptID, sort int + var typ, title, dept string + var reward float64 + var en bool + rows.Scan(&id, &typ, &title, &deptID, &dept, &reward, &en, &sort) + out = append(out, Row{"ID": id, "Type": typ, "Title": title, "DeptID": deptID, "Dept": dept, "Reward": reward, "Enabled": en, "Sort": sort}) + } + return out +} diff --git a/internal/handlers/app.go b/internal/handlers/app.go new file mode 100644 index 0000000..de9f543 --- /dev/null +++ b/internal/handlers/app.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "database/sql" + "html/template" + "net/http" + "strconv" + "strings" +) + +type App struct { + DB *sql.DB + T *template.Template +} +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) 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}) + return + } + r.ParseForm() + item, _ := strconv.Atoi(r.FormValue("item_id")) + qty, _ := strconv.Atoi(r.FormValue("quantity")) + price, _ := strconv.ParseFloat(r.FormValue("price_limit"), 64) + 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 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) + 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 { + return + } + defer rows.Close() + var prev sql.NullInt64 + created := 0 + for rows.Next() { + var typ, title string + var dept sql.NullInt64 + var percent float64 + rows.Scan(&typ, &title, &dept, &percent) + 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++ + } + } + 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) +} +func (a *App) UpdateQuest(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + id, _ := strconv.Atoi(r.FormValue("id")) + status := r.FormValue("status") + note := r.FormValue("note") + if status == "" { + status = "open" + } + 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) + if status == "done" { + a.DB.Exec(`update quests set status='open' where depends_on_quest_id=? and status='waiting'`, id) + } + http.Redirect(w, r, "/", 303) +} diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..2749ef5 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,122 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + subject VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + name VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS departments ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(120) UNIQUE NOT NULL +); +CREATE TABLE IF NOT EXISTS items ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + sku VARCHAR(120) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + category VARCHAR(120) NOT NULL DEFAULT 'item' +); +CREATE TABLE IF NOT EXISTS trade_requests ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + kind ENUM('buy','sell') NOT NULL, + item_id BIGINT NOT NULL, + quantity INT NOT NULL, + price_limit DECIMAL(12,2) NOT NULL DEFAULT 0, + source_department_id BIGINT NULL, + target_department_id BIGINT NULL, + status VARCHAR(60) NOT NULL DEFAULT 'new', + created_by BIGINT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_trade_item FOREIGN KEY (item_id) REFERENCES items(id), + CONSTRAINT fk_trade_source_dept FOREIGN KEY (source_department_id) REFERENCES departments(id), + CONSTRAINT fk_trade_target_dept FOREIGN KEY (target_department_id) REFERENCES departments(id), + CONSTRAINT fk_trade_user FOREIGN KEY (created_by) REFERENCES users(id), + CONSTRAINT chk_trade_quantity CHECK (quantity > 0) +); +CREATE TABLE IF NOT EXISTS quests ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + parent_request_id BIGINT NULL, + depends_on_quest_id BIGINT NULL, + type VARCHAR(120) NOT NULL, + title VARCHAR(255) NOT NULL, + department_id BIGINT NULL, + reward DECIMAL(12,2) NOT NULL DEFAULT 0, + status VARCHAR(60) NOT NULL DEFAULT 'open', + contractor_note TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_quest_request FOREIGN KEY (parent_request_id) REFERENCES trade_requests(id) ON DELETE CASCADE, + CONSTRAINT fk_quest_depends FOREIGN KEY (depends_on_quest_id) REFERENCES quests(id), + CONSTRAINT fk_quest_dept FOREIGN KEY (department_id) REFERENCES departments(id) +); +CREATE TABLE IF NOT EXISTS quest_updates ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + quest_id BIGINT NOT NULL, + actor_user_id BIGINT NULL, + status VARCHAR(60) NOT NULL, + note TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_update_quest FOREIGN KEY (quest_id) REFERENCES quests(id) ON DELETE CASCADE, + CONSTRAINT fk_update_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) +); +CREATE TABLE IF NOT EXISTS events ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + type VARCHAR(120) NOT NULL, + payload JSON NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS workflow_rules ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255) NOT NULL UNIQUE, + trigger_name VARCHAR(120) NOT NULL, + condition_json JSON NOT NULL, + actions_json JSON NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true +); +INSERT IGNORE INTO departments(name) VALUES ('Trading'),('Mining'),('Logistik'),('Crafting'),('FPS'); +INSERT IGNORE INTO items(sku,name,category) VALUES ('P4-AR','P4-AR','weapon'),('ORE','Erz','resource'); +INSERT IGNORE INTO workflow_rules(name,trigger_name,condition_json,actions_json) VALUES +('Standard Beschaffung','request.created',JSON_OBJECT(),JSON_ARRAY('logistics_quote','mining_procure','crafting_order','delivery')); + +CREATE TABLE IF NOT EXISTS app_settings ( + setting_key VARCHAR(120) PRIMARY KEY, + setting_value TEXT NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '' +); +CREATE TABLE IF NOT EXISTS status_options ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + scope ENUM('request','quest') NOT NULL, + value VARCHAR(60) NOT NULL, + label VARCHAR(120) NOT NULL, + sort_order INT NOT NULL DEFAULT 100, + is_terminal BOOLEAN NOT NULL DEFAULT false, + UNIQUE KEY uq_status_scope_value (scope, value) +); +CREATE TABLE IF NOT EXISTS quest_templates ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + type VARCHAR(120) NOT NULL UNIQUE, + title_template VARCHAR(255) NOT NULL, + department_id BIGINT NULL, + reward_percent DECIMAL(6,4) NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 100, + CONSTRAINT fk_template_dept FOREIGN KEY (department_id) REFERENCES departments(id) +); + +INSERT IGNORE INTO app_settings(setting_key,setting_value,description) VALUES +('currency','aUEC','Währung für Preise und Belohnungen'), +('default_margin_percent','10','Standardmarge der Trading-Abteilung in Prozent'), +('auto_generate_quests','true','Automatisch Quests aus neuen Anfragen erzeugen'), +('admin_emails','','Kommagetrennte Admin-E-Mails. Leer bedeutet: jeder eingeloggte Nutzer darf Admin-Seiten öffnen.'); + +INSERT IGNORE INTO status_options(scope,value,label,sort_order,is_terminal) VALUES +('request','new','Neu',10,false),('request','quoted','Preisvorschlag',20,false),('request','approved','Freigegeben',30,false),('request','fulfilled','Erfüllt',90,true),('request','cancelled','Abgebrochen',99,true), +('quest','waiting','Wartet auf Abhängigkeit',5,false),('quest','open','Offen',10,false),('quest','accepted','Angenommen',20,false),('quest','in_progress','In Arbeit',30,false),('quest','blocked','Blockiert',40,false),('quest','change_requested','Änderung angefragt',50,false),('quest','done','Erledigt',90,true),('quest','cancelled','Abgebrochen',99,true); + +INSERT IGNORE INTO quest_templates(type,title_template,department_id,reward_percent,sort_order) +SELECT 'logistics_quote','Materialbedarf und Transport prüfen',(SELECT id FROM departments WHERE name='Logistik'),0.0300,10; +INSERT IGNORE INTO quest_templates(type,title_template,department_id,reward_percent,sort_order) +SELECT 'mining_procure','Ressourcen für {{item}} beschaffen',(SELECT id FROM departments WHERE name='Mining'),0.2500,20; +INSERT IGNORE INTO quest_templates(type,title_template,department_id,reward_percent,sort_order) +SELECT 'crafting_order','{{item}} herstellen oder bereitstellen',(SELECT id FROM departments WHERE name='Crafting'),0.1500,30; +INSERT IGNORE INTO quest_templates(type,title_template,department_id,reward_percent,sort_order) +SELECT 'delivery','{{item}} an Zielabteilung liefern',(SELECT id FROM departments WHERE name='Logistik'),0.0500,40; diff --git a/migrations/002_admin.sql b/migrations/002_admin.sql new file mode 100644 index 0000000..0455ce3 --- /dev/null +++ b/migrations/002_admin.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS app_settings ( + setting_key VARCHAR(120) PRIMARY KEY, + setting_value TEXT NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '' +); +CREATE TABLE IF NOT EXISTS status_options ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + scope ENUM('request','quest') NOT NULL, + value VARCHAR(60) NOT NULL, + label VARCHAR(120) NOT NULL, + sort_order INT NOT NULL DEFAULT 100, + is_terminal BOOLEAN NOT NULL DEFAULT false, + UNIQUE KEY uq_status_scope_value (scope, value) +); +CREATE TABLE IF NOT EXISTS quest_templates ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + type VARCHAR(120) NOT NULL UNIQUE, + title_template VARCHAR(255) NOT NULL, + department_id BIGINT NULL, + reward_percent DECIMAL(6,4) NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 100, + CONSTRAINT fk_template_dept FOREIGN KEY (department_id) REFERENCES departments(id) +); + +INSERT IGNORE INTO app_settings(setting_key,setting_value,description) VALUES +('currency','aUEC','Währung für Preise und Belohnungen'), +('default_margin_percent','10','Standardmarge der Trading-Abteilung in Prozent'), +('auto_generate_quests','true','Automatisch Quests aus neuen Anfragen erzeugen'), +('admin_emails','','Kommagetrennte Admin-E-Mails. Leer bedeutet: jeder eingeloggte Nutzer darf Admin-Seiten öffnen.'); + +INSERT IGNORE INTO status_options(scope,value,label,sort_order,is_terminal) VALUES +('request','new','Neu',10,false),('request','quoted','Preisvorschlag',20,false),('request','approved','Freigegeben',30,false),('request','fulfilled','Erfüllt',90,true),('request','cancelled','Abgebrochen',99,true), +('quest','waiting','Wartet auf Abhängigkeit',5,false),('quest','open','Offen',10,false),('quest','accepted','Angenommen',20,false),('quest','in_progress','In Arbeit',30,false),('quest','blocked','Blockiert',40,false),('quest','change_requested','Änderung angefragt',50,false),('quest','done','Erledigt',90,true),('quest','cancelled','Abgebrochen',99,true); + +INSERT IGNORE INTO quest_templates(type,title_template,department_id,reward_percent,sort_order) +SELECT 'logistics_quote','Materialbedarf und Transport prüfen',(SELECT id FROM departments WHERE name='Logistik'),0.0300,10; +INSERT IGNORE INTO quest_templates(type,title_template,department_id,reward_percent,sort_order) +SELECT 'mining_procure','Ressourcen für {{item}} beschaffen',(SELECT id FROM departments WHERE name='Mining'),0.2500,20; +INSERT IGNORE INTO quest_templates(type,title_template,department_id,reward_percent,sort_order) +SELECT 'crafting_order','{{item}} herstellen oder bereitstellen',(SELECT id FROM departments WHERE name='Crafting'),0.1500,30; +INSERT IGNORE INTO quest_templates(type,title_template,department_id,reward_percent,sort_order) +SELECT 'delivery','{{item}} an Zielabteilung liefern',(SELECT id FROM departments WHERE name='Logistik'),0.0500,40; diff --git a/web/templates/admin.html b/web/templates/admin.html new file mode 100644 index 0000000..44073f8 --- /dev/null +++ b/web/templates/admin.html @@ -0,0 +1,10 @@ +Admin +

Dashboard · Neue Anfrage · Logout

+

Admin-Konfiguration

+

Globale Einstellungen

{{range .Settings}}{{end}}
KeyWertBeschreibung
+

Abteilungen

{{range .Departments}}{{end}}
IDName
{{.ID}}
neu
+

Items & Ressourcen

{{range .Items}}{{end}}
IDSKUNameKategorie
{{.ID}}
neu
+

Statuswerte

{{range .Statuses}}{{end}}
ScopeWertLabelSortierungFinal
+

Quest-Templates

Titel unterstützt Platzhalter {{"{{item}}"}} und {{"{{quantity}}"}}. reward_percent ist z.B. 0.25 für 25% des Anfragepreises.

{{range .QuestTemplates}}{{end}}
TypTitelAbteilungReward%AktivSort
+

Workflow-Regeln

{{range .WorkflowRules}}{{end}}
NameTriggerCondition JSONActions JSONAktiv
+ diff --git a/web/templates/home.html b/web/templates/home.html new file mode 100644 index 0000000..7036a3e --- /dev/null +++ b/web/templates/home.html @@ -0,0 +1 @@ +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}}


diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..2a6bec0 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1 @@ +Trading Login

Trading Tool

Login über Pocket ID / OIDC.

Mit Pocket ID anmelden diff --git a/web/templates/request.html b/web/templates/request.html new file mode 100644 index 0000000..a13d97c --- /dev/null +++ b/web/templates/request.html @@ -0,0 +1 @@ +Neue Anfrage

Zurück

Neue Trading-Anfrage