This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -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
|
||||
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -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 }}
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -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"]
|
||||
75
README.md
75
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.
|
||||
|
||||
50
cmd/server/main.go
Normal file
50
cmd/server/main.go
Normal file
@@ -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))
|
||||
}
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -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:
|
||||
7
go.mod
Normal file
7
go.mod
Normal file
@@ -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
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -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=
|
||||
243
internal/auth/auth.go
Normal file
243
internal/auth/auth.go
Normal file
@@ -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)
|
||||
}))
|
||||
}
|
||||
0
internal/auth/auth.go.tmp
Normal file
0
internal/auth/auth.go.tmp
Normal file
50
internal/db/db.go
Normal file
50
internal/db/db.go
Normal file
@@ -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
|
||||
}
|
||||
210
internal/handlers/admin.go
Normal file
210
internal/handlers/admin.go
Normal file
@@ -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
|
||||
}
|
||||
123
internal/handlers/app.go
Normal file
123
internal/handlers/app.go
Normal file
@@ -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)
|
||||
}
|
||||
122
migrations/001_init.sql
Normal file
122
migrations/001_init.sql
Normal file
@@ -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;
|
||||
43
migrations/002_admin.sql
Normal file
43
migrations/002_admin.sql
Normal file
@@ -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;
|
||||
10
web/templates/admin.html
Normal file
10
web/templates/admin.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Admin</title><style>body{font-family:system-ui;margin:2rem;background:#111;color:#eee}a{color:#9ad}table{border-collapse:collapse;width:100%;margin:1rem 0}td,th{border-bottom:1px solid #333;padding:.45rem;text-align:left;vertical-align:top}.card{background:#1b1b1b;border:1px solid #333;border-radius:.7rem;padding:1rem;margin:1rem 0}button,input,select,textarea{padding:.35rem;border-radius:.35rem;margin:.1rem}textarea{width:24rem;height:3.8rem}.small{width:5rem}.wide{width:20rem}</style></head><body>
|
||||
<p><a href="/">Dashboard</a> · <a href="/requests/new">Neue Anfrage</a> · <a href="/logout">Logout</a></p>
|
||||
<h1>Admin-Konfiguration</h1>
|
||||
<div class="card"><h2>Globale Einstellungen</h2><table><tr><th>Key</th><th>Wert</th><th>Beschreibung</th><th></th></tr>{{range .Settings}}<tr><form method="post" action="/admin/settings"><td><input name="setting_key" value="{{.Key}}" readonly></td><td><input class="wide" name="setting_value" value="{{.Value}}"></td><td><input class="wide" name="description" value="{{.Description}}"></td><td><button>Speichern</button></td></form></tr>{{end}}<tr><form method="post" action="/admin/settings"><td><input name="setting_key" placeholder="neuer_key"></td><td><input name="setting_value"></td><td><input class="wide" name="description"></td><td><button>Anlegen</button></td></form></tr></table></div>
|
||||
<div class="card"><h2>Abteilungen</h2><table><tr><th>ID</th><th>Name</th><th></th></tr>{{range .Departments}}<tr><form method="post" action="/admin/departments"><td>{{.ID}}<input type="hidden" name="id" value="{{.ID}}"></td><td><input name="name" value="{{.Name}}"></td><td><button>Speichern</button><button name="action" value="delete">Löschen</button></td></form></tr>{{end}}<tr><form method="post" action="/admin/departments"><td>neu</td><td><input name="name" placeholder="z.B. Salvage"></td><td><button>Anlegen</button></td></form></tr></table></div>
|
||||
<div class="card"><h2>Items & Ressourcen</h2><table><tr><th>ID</th><th>SKU</th><th>Name</th><th>Kategorie</th><th></th></tr>{{range .Items}}<tr><form method="post" action="/admin/items"><td>{{.ID}}<input type="hidden" name="id" value="{{.ID}}"></td><td><input name="sku" value="{{.SKU}}"></td><td><input name="name" value="{{.Name}}"></td><td><input name="category" value="{{.Category}}"></td><td><button>Speichern</button><button name="action" value="delete">Löschen</button></td></form></tr>{{end}}<tr><form method="post" action="/admin/items"><td>neu</td><td><input name="sku"></td><td><input name="name"></td><td><input name="category" value="item"></td><td><button>Anlegen</button></td></form></tr></table></div>
|
||||
<div class="card"><h2>Statuswerte</h2><table><tr><th>Scope</th><th>Wert</th><th>Label</th><th>Sortierung</th><th>Final</th><th></th></tr>{{range .Statuses}}<tr><form method="post" action="/admin/statuses"><input type="hidden" name="id" value="{{.ID}}"><td><select name="scope"><option {{if eq .Scope "request"}}selected{{end}}>request</option><option {{if eq .Scope "quest"}}selected{{end}}>quest</option></select></td><td><input name="value" value="{{.Value}}"></td><td><input name="label" value="{{.Label}}"></td><td><input class="small" name="sort_order" value="{{.Sort}}"></td><td><input type="checkbox" name="is_terminal" {{if .Terminal}}checked{{end}}></td><td><button>Speichern</button><button name="action" value="delete">Löschen</button></td></form></tr>{{end}}<tr><form method="post" action="/admin/statuses"><td><select name="scope"><option>request</option><option>quest</option></select></td><td><input name="value"></td><td><input name="label"></td><td><input class="small" name="sort_order" value="100"></td><td><input type="checkbox" name="is_terminal"></td><td><button>Anlegen</button></td></form></tr></table></div>
|
||||
<div class="card"><h2>Quest-Templates</h2><p>Titel unterstützt Platzhalter <code>{{"{{item}}"}}</code> und <code>{{"{{quantity}}"}}</code>. reward_percent ist z.B. 0.25 für 25% des Anfragepreises.</p><table><tr><th>Typ</th><th>Titel</th><th>Abteilung</th><th>Reward%</th><th>Aktiv</th><th>Sort</th><th></th></tr>{{range .QuestTemplates}}<tr><form method="post" action="/admin/quest-templates"><input type="hidden" name="id" value="{{.ID}}"><td><input name="type" value="{{.Type}}"></td><td><input class="wide" name="title_template" value="{{.Title}}"></td><td><select name="department_id"><option value="0">-</option>{{$dept := .DeptID}}{{range $.Departments}}<option value="{{.ID}}" {{if eq .ID $dept}}selected{{end}}>{{.Name}}</option>{{end}}</select></td><td><input class="small" name="reward_percent" value="{{printf "%.4f" .Reward}}"></td><td><input type="checkbox" name="enabled" {{if .Enabled}}checked{{end}}></td><td><input class="small" name="sort_order" value="{{.Sort}}"></td><td><button>Speichern</button><button name="action" value="delete">Löschen</button></td></form></tr>{{end}}<tr><form method="post" action="/admin/quest-templates"><td><input name="type"></td><td><input class="wide" name="title_template"></td><td><select name="department_id"><option value="0">-</option>{{range .Departments}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select></td><td><input class="small" name="reward_percent" value="0.0000"></td><td><input type="checkbox" name="enabled" checked></td><td><input class="small" name="sort_order" value="100"></td><td><button>Anlegen</button></td></form></tr></table></div>
|
||||
<div class="card"><h2>Workflow-Regeln</h2><table><tr><th>Name</th><th>Trigger</th><th>Condition JSON</th><th>Actions JSON</th><th>Aktiv</th><th></th></tr>{{range .WorkflowRules}}<tr><form method="post" action="/admin/workflow-rules"><input type="hidden" name="id" value="{{.ID}}"><td><input name="name" value="{{.Name}}"></td><td><input name="trigger_name" value="{{.Trigger}}"></td><td><textarea name="condition_json">{{.Condition}}</textarea></td><td><textarea name="actions_json">{{.Actions}}</textarea></td><td><input type="checkbox" name="enabled" {{if .Enabled}}checked{{end}}></td><td><button>Speichern</button><button name="action" value="delete">Löschen</button></td></form></tr>{{end}}<tr><form method="post" action="/admin/workflow-rules"><td><input name="name"></td><td><input name="trigger_name" value="request.created"></td><td><textarea name="condition_json">{}</textarea></td><td><textarea name="actions_json">[]</textarea></td><td><input type="checkbox" name="enabled" checked></td><td><button>Anlegen</button></td></form></tr></table></div>
|
||||
</body></html>
|
||||
1
web/templates/home.html
Normal file
1
web/templates/home.html
Normal file
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Trading Tool</title><style>body{font-family:system-ui;margin:2rem;background:#111;color:#eee}a{color:#9ad}table{border-collapse:collapse;width:100%;margin:1rem 0}td,th{border-bottom:1px solid #333;padding:.5rem;text-align:left;vertical-align:top}.card{background:#1b1b1b;border:1px solid #333;border-radius:.7rem;padding:1rem;margin:1rem 0}button,input,select,textarea{padding:.4rem;border-radius:.35rem}textarea{width:18rem;height:3rem}</style></head><body><p><a href="/requests/new">Neue Anfrage</a> · <a href="/admin">Admin-Konfiguration</a> · <a href="/logout">Logout</a></p><h1>Trading Dashboard</h1><div class="card"><h2>Anfragen</h2><table><tr><th>ID</th><th>Typ</th><th>Item</th><th>Menge</th><th>Preis</th><th>Status</th></tr>{{range .Requests}}<tr><td>{{.ID}}</td><td>{{.Kind}}</td><td>{{.Item}}</td><td>{{.Qty}}</td><td>{{printf "%.2f" .Price}}</td><td>{{.Status}}</td></tr>{{end}}</table></div><div class="card"><h2>Quests / Auftragnehmer-Workflow</h2><table><tr><th>ID</th><th>Typ</th><th>Titel</th><th>Abteilung</th><th>Status</th><th>Notiz</th><th>Aktion</th></tr>{{range .Quests}}<tr><td>{{.ID}}</td><td>{{.Type}}</td><td>{{.Title}}</td><td>{{.Dept}}</td><td>{{.Status}}</td><td>{{.Note}}</td><td><form method="post" action="/quests/update"><input type="hidden" name="id" value="{{.ID}}"><select name="status"><option>open</option><option>accepted</option><option>in_progress</option><option>done</option><option>blocked</option><option>change_requested</option><option>cancelled</option></select><br><textarea name="note" placeholder="Rückmeldung, Gegenvorschlag, Material fehlt …">{{.Note}}</textarea><br><button>Workflow aktualisieren</button></form></td></tr>{{end}}</table></div></body></html>
|
||||
1
web/templates/login.html
Normal file
1
web/templates/login.html
Normal file
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Trading Login</title><style>body{font-family:system-ui;margin:3rem;background:#111;color:#eee}a,button{background:#7c5cff;color:white;padding:.8rem 1rem;border-radius:.5rem;text-decoration:none;border:0}</style></head><body><h1>Trading Tool</h1><p>Login über Pocket ID / OIDC.</p><a href="/auth/login">Mit Pocket ID anmelden</a></body></html>
|
||||
1
web/templates/request.html
Normal file
1
web/templates/request.html
Normal file
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Neue Anfrage</title><style>body{font-family:system-ui;margin:2rem;background:#111;color:#eee}label{display:block;margin:.8rem 0}input,select,button{padding:.5rem;border-radius:.4rem}</style></head><body><p><a href="/">Zurück</a></p><h1>Neue Trading-Anfrage</h1><form method="post"><label>Typ <select name="kind"><option value="buy">Ankauf</option><option value="sell">Verkauf</option></select></label><label>Item <select name="item_id">{{range .Items}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select></label><label>Menge <input name="quantity" type="number" value="1" min="1"></label><label>Preisvorstellung <input name="price_limit" type="number" step="0.01" value="1000"></label><button>Anfrage erstellen und Quests generieren</button></form></body></html>
|
||||
Reference in New Issue
Block a user