diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae6b554 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# ── Build stage ─────────────────────────────────────────────────────────────── +FROM golang:1.23-alpine AS builder +WORKDIR /app +RUN apk add --no-cache git +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o workorders ./cmd/server + +# ── Runtime stage ───────────────────────────────────────────────────────────── +FROM alpine:3.20 +WORKDIR /app +RUN apk add --no-cache tzdata ca-certificates curl +COPY --from=builder /app/workorders . +COPY web/ ./web/ +EXPOSE 8080 +CMD ["./workorders"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..d4e93d9 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "log" + "net/http" + "os" + + "github.com/joho/godotenv" + "workorders/internal/api" + "workorders/internal/config" + "workorders/internal/repository" +) + +func main() { + // Load .env if present (ignored in production containers where env is injected) + if _, err := os.Stat(".env"); err == nil { + if err := godotenv.Load(); err != nil { + log.Printf("warning: could not load .env: %v", err) + } + } + + cfg := config.Load() + + log.Printf("connecting to database...") + db, err := repository.Connect(cfg.DBDSN) + if err != nil { + log.Fatalf("database connect: %v", err) + } + defer db.Close() + + log.Printf("running migrations...") + if err := repository.RunMigrations(db); err != nil { + log.Fatalf("migrations: %v", err) + } + + log.Printf("fetching JWKS from %s", cfg.JWKSUrl) + api.InitJWKS(cfg.JWKSUrl) + + r := api.NewRouter(cfg, db) + + log.Printf("listening on %s", cfg.Addr) + if err := http.ListenAndServe(cfg.Addr, r); err != nil { + log.Fatal(err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9e1c88c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +services: + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + SA_PASSWORD: ${SA_PASSWORD} + ACCEPT_EULA: "Y" + MSSQL_PID: Developer + ports: + - "1433:1433" + volumes: + - mssql_data:/var/opt/mssql + healthcheck: + test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "${SA_PASSWORD}", "-Q", "SELECT 1", "-No", "-l", "5"] + interval: 10s + timeout: 10s + retries: 12 + start_period: 40s + + keycloak: + image: quay.io/keycloak/keycloak:24.0 + command: start-dev --import-realm --http-port=8180 + environment: + KEYCLOAK_ADMIN: ${KC_ADMIN} + KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD} + KC_DB: dev-file + KC_HTTP_PORT: "8180" + KC_HOSTNAME_STRICT: "false" + KC_HOSTNAME_STRICT_HTTPS: "false" + KC_HTTP_ENABLED: "true" + KC_HEALTH_ENABLED: "true" + ports: + - "8180:8180" + volumes: + - ./keycloak:/opt/keycloak/data/import + - keycloak_data:/opt/keycloak/data + healthcheck: + # Keycloak image has no curl/wget; use bash TCP to confirm port is open + test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/localhost/8180' 2>/dev/null && echo ok || exit 1"] + interval: 10s + timeout: 5s + retries: 18 + start_period: 30s + + api: + build: . + ports: + - "9080:9080" + env_file: .env + environment: + DB_DSN: "sqlserver://sa:${SA_PASSWORD}@mssql:1433?database=workorders" + OIDC_ISSUER: "http://localhost:8180/realms/workorders" + JWKS_URL: "http://keycloak:8180/realms/workorders/protocol/openid-connect/certs" + extra_hosts: + - "localhost:host-gateway" + volumes: + - uploads:/uploads + depends_on: + mssql: + condition: service_healthy + keycloak: + condition: service_healthy + restart: on-failure + +volumes: + mssql_data: + keycloak_data: + uploads: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8c43e23 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module workorders + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/jmoiron/sqlx v1.3.5 + github.com/joho/godotenv v1.5.1 + github.com/microsoft/go-mssqldb v1.7.2 +) + +require ( + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fc501b9 --- /dev/null +++ b/go.sum @@ -0,0 +1,54 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 h1:lGlwhPtrX6EVml1hO0ivjkUxsSyl4dsiw9qcA1k/3IQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 h1:6oNBlSdi1QqM1PNW7FPA6xOGA5UNsXnkaYZz9vdPGhA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 h1:DzHpqpoJVaCgOUdVHxE8QB52S6NiVdDQvGlny1qvPqA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= +github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/handlers/accounting.go b/internal/api/handlers/accounting.go new file mode 100644 index 0000000..046ea4c --- /dev/null +++ b/internal/api/handlers/accounting.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "net/http" + + "github.com/jmoiron/sqlx" + "workorders/internal/model" +) + +type AccountingHandler struct{ db *sqlx.DB } + +func NewAccountingHandler(db *sqlx.DB) *AccountingHandler { return &AccountingHandler{db: db} } + +func (h *AccountingHandler) Get(w http.ResponseWriter, r *http.Request) { + woID, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + var codes []model.AccountingCode + h.db.Select(&codes, `SELECT * FROM wo_accounting WHERE wo_id=@p1`, woID) + respond(w, http.StatusOK, codes) +} + +func (h *AccountingHandler) Upsert(w http.ResponseWriter, r *http.Request) { + woID, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + var codes []model.AccountingCode + if err := decode(r, &codes); err != nil { + respondError(w, http.StatusBadRequest, "invalid body") + return + } + for _, c := range codes { + h.db.Exec(` + MERGE wo_accounting AS t + USING (SELECT @p1 AS wo_id, @p2 AS code_type) AS s + ON t.wo_id=s.wo_id AND t.code_type=s.code_type + WHEN MATCHED THEN UPDATE SET code_value=@p3,description=@p4 + WHEN NOT MATCHED THEN INSERT (wo_id,code_type,code_value,description) + VALUES (@p1,@p2,@p3,@p4);`, + woID, c.CodeType, c.CodeValue, c.Description, + ) + } + var result []model.AccountingCode + h.db.Select(&result, `SELECT * FROM wo_accounting WHERE wo_id=@p1`, woID) + respond(w, http.StatusOK, result) +} diff --git a/internal/api/handlers/attachment.go b/internal/api/handlers/attachment.go new file mode 100644 index 0000000..0c75a3a --- /dev/null +++ b/internal/api/handlers/attachment.go @@ -0,0 +1,107 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "workorders/internal/config" + "workorders/internal/model" +) + +type AttachmentHandler struct { + db *sqlx.DB + cfg *config.Config +} + +func NewAttachmentHandler(db *sqlx.DB, cfg *config.Config) *AttachmentHandler { + return &AttachmentHandler{db: db, cfg: cfg} +} + +func (h *AttachmentHandler) List(w http.ResponseWriter, r *http.Request) { + woID, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + var atts []model.Attachment + if err := h.db.Select(&atts, `SELECT * FROM wo_attachments WHERE wo_id=@p1 ORDER BY uploaded_at`, woID); err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + for i := range atts { + atts[i].URL = "/uploads/" + atts[i].FilePath + } + respond(w, http.StatusOK, atts) +} + +func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) { + woID, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + user := userFromCtx(r) + + if err := r.ParseMultipartForm(32 << 20); err != nil { + respondError(w, http.StatusBadRequest, "parse form failed") + return + } + file, header, err := r.FormFile("file") + if err != nil { + respondError(w, http.StatusBadRequest, "missing file") + return + } + defer file.Close() + + ext := filepath.Ext(header.Filename) + fname := uuid.New().String() + ext + relPath := fmt.Sprintf("%d/%s", woID, fname) + absPath := filepath.Join(h.cfg.UploadPath, relPath) + + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + respondError(w, http.StatusInternalServerError, "storage error") + return + } + dst, err := os.Create(absPath) + if err != nil { + respondError(w, http.StatusInternalServerError, "storage error") + return + } + defer dst.Close() + size, _ := io.Copy(dst, file) + + var aid int + err = h.db.QueryRow(` + INSERT INTO wo_attachments (wo_id,file_name,file_path,file_type,file_size,caption,phase,uploaded_by) + OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8)`, + woID, header.Filename, relPath, header.Header.Get("Content-Type"), + size, r.FormValue("caption"), r.FormValue("phase"), user.Email, + ).Scan(&aid) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + respond(w, http.StatusCreated, map[string]any{ + "id": aid, + "url": "/uploads/" + relPath, + }) +} + +func (h *AttachmentHandler) Delete(w http.ResponseWriter, r *http.Request) { + aid, err := intParam(r, "aid") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid aid") + return + } + var att model.Attachment + if err := h.db.Get(&att, `SELECT * FROM wo_attachments WHERE id=@p1`, aid); err == nil { + os.Remove(filepath.Join(h.cfg.UploadPath, att.FilePath)) + } + h.db.Exec(`DELETE FROM wo_attachments WHERE id=@p1`, aid) + respond(w, http.StatusOK, map[string]any{"deleted": aid}) +} diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go new file mode 100644 index 0000000..753f89f --- /dev/null +++ b/internal/api/handlers/auth.go @@ -0,0 +1,7 @@ +package handlers + +import "net/http" + +func Me(w http.ResponseWriter, r *http.Request) { + respond(w, http.StatusOK, userFromCtx(r)) +} diff --git a/internal/api/handlers/helpers.go b/internal/api/handlers/helpers.go new file mode 100644 index 0000000..9cb5295 --- /dev/null +++ b/internal/api/handlers/helpers.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "workorders/internal/model" +) + +func respond(w http.ResponseWriter, code int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]any{"data": data}) +} + +func respondError(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + fmt.Fprintf(w, `{"error":%q}`, msg) +} + +func intParam(r *http.Request, key string) (int, error) { + return strconv.Atoi(chi.URLParam(r, key)) +} + +func decode(r *http.Request, v any) error { + return json.NewDecoder(r.Body).Decode(v) +} + +func userFromCtx(r *http.Request) model.UserClaims { + u, _ := r.Context().Value(model.CtxUserKey).(model.UserClaims) + return u +} diff --git a/internal/api/handlers/registry.go b/internal/api/handlers/registry.go new file mode 100644 index 0000000..7d53c7b --- /dev/null +++ b/internal/api/handlers/registry.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "net/http" + + "github.com/jmoiron/sqlx" + "workorders/internal/model" +) + +type RegistryHandler struct{ db *sqlx.DB } + +func NewRegistryHandler(db *sqlx.DB) *RegistryHandler { return &RegistryHandler{db: db} } + +func (h *RegistryHandler) People(w http.ResponseWriter, r *http.Request) { + var rows []model.RegistryPerson + h.db.Select(&rows, `SELECT id,name,role,email,phone FROM resource_people WHERE active=1 ORDER BY name`) + respond(w, http.StatusOK, rows) +} + +func (h *RegistryHandler) Vehicles(w http.ResponseWriter, r *http.Request) { + var rows []model.RegistryVehicle + h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type FROM resource_vehicles WHERE active=1 ORDER BY unit_number`) + respond(w, http.StatusOK, rows) +} + +func (h *RegistryHandler) Equipment(w http.ResponseWriter, r *http.Request) { + var rows []model.RegistryEquipment + h.db.Select(&rows, `SELECT id,name,asset_tag,category FROM resource_equipment WHERE active=1 ORDER BY name`) + respond(w, http.StatusOK, rows) +} + +func (h *RegistryHandler) Materials(w http.ResponseWriter, r *http.Request) { + var rows []model.RegistryMaterial + h.db.Select(&rows, `SELECT id,name,unit,part_number FROM resource_materials WHERE active=1 ORDER BY name`) + respond(w, http.StatusOK, rows) +} diff --git a/internal/api/handlers/resource.go b/internal/api/handlers/resource.go new file mode 100644 index 0000000..bcf681a --- /dev/null +++ b/internal/api/handlers/resource.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "net/http" + + "github.com/jmoiron/sqlx" + "workorders/internal/model" +) + +type ResourceHandler struct{ db *sqlx.DB } + +func NewResourceHandler(db *sqlx.DB) *ResourceHandler { return &ResourceHandler{db: db} } + +func (h *ResourceHandler) List(w http.ResponseWriter, r *http.Request) { + woID, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + var rows []model.AssignedResource + err = h.db.Select(&rows, ` + SELECT r.id, r.wo_id, r.resource_type, r.resource_id, r.quantity, r.notes, r.assigned_at, + ISNULL(p.name, ISNULL(v.unit_number, ISNULL(e.name, m.name))) AS name + FROM wo_resources r + LEFT JOIN resource_people p ON r.resource_type='person' AND r.resource_id=p.id + LEFT JOIN resource_vehicles v ON r.resource_type='vehicle' AND r.resource_id=v.id + LEFT JOIN resource_equipment e ON r.resource_type='equipment' AND r.resource_id=e.id + LEFT JOIN resource_materials m ON r.resource_type='material' AND r.resource_id=m.id + WHERE r.wo_id=@p1`, woID) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + respond(w, http.StatusOK, rows) +} + +func (h *ResourceHandler) Assign(w http.ResponseWriter, r *http.Request) { + woID, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + var body struct { + ResourceType string `json:"resource_type"` + ResourceID int `json:"resource_id"` + Quantity *float64 `json:"quantity"` + Notes string `json:"notes"` + } + if err := decode(r, &body); err != nil { + respondError(w, http.StatusBadRequest, "invalid body") + return + } + var rid int + err = h.db.QueryRow(` + INSERT INTO wo_resources (wo_id,resource_type,resource_id,quantity,notes) + OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5)`, + woID, body.ResourceType, body.ResourceID, body.Quantity, body.Notes, + ).Scan(&rid) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + respond(w, http.StatusCreated, map[string]any{"id": rid}) +} + +func (h *ResourceHandler) Remove(w http.ResponseWriter, r *http.Request) { + rid, err := intParam(r, "rid") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid rid") + return + } + h.db.Exec(`DELETE FROM wo_resources WHERE id=@p1`, rid) + respond(w, http.StatusOK, map[string]any{"deleted": rid}) +} diff --git a/internal/api/handlers/step.go b/internal/api/handlers/step.go new file mode 100644 index 0000000..e5b84b4 --- /dev/null +++ b/internal/api/handlers/step.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/jmoiron/sqlx" + "workorders/internal/model" +) + +type StepHandler struct{ db *sqlx.DB } + +func NewStepHandler(db *sqlx.DB) *StepHandler { return &StepHandler{db: db} } + +func (h *StepHandler) List(w http.ResponseWriter, r *http.Request) { + woID, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + var steps []model.Step + if err := h.db.Select(&steps, + `SELECT * FROM wo_steps WHERE wo_id=@p1 ORDER BY step_order`, woID); err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + respond(w, http.StatusOK, steps) +} + +func (h *StepHandler) Create(w http.ResponseWriter, r *http.Request) { + woID, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + var body model.Step + if err := decode(r, &body); err != nil { + respondError(w, http.StatusBadRequest, "invalid body") + return + } + var maxOrder int + h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder) + + var sid int + err = h.db.QueryRow(` + INSERT INTO wo_steps (wo_id,step_order,title,description,required) + OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5)`, + woID, maxOrder+1, body.Title, body.Description, body.Required, + ).Scan(&sid) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + var step model.Step + h.db.Get(&step, `SELECT * FROM wo_steps WHERE id=@p1`, sid) + respond(w, http.StatusCreated, step) +} + +func (h *StepHandler) Update(w http.ResponseWriter, r *http.Request) { + sid, err := intParam(r, "sid") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid sid") + return + } + var body model.Step + if err := decode(r, &body); err != nil { + respondError(w, http.StatusBadRequest, "invalid body") + return + } + h.db.Exec(`UPDATE wo_steps SET title=@p1,description=@p2,required=@p3 WHERE id=@p4`, + body.Title, body.Description, body.Required, sid) + var step model.Step + h.db.Get(&step, `SELECT * FROM wo_steps WHERE id=@p1`, sid) + respond(w, http.StatusOK, step) +} + +func (h *StepHandler) Complete(w http.ResponseWriter, r *http.Request) { + sid, err := intParam(r, "sid") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid sid") + return + } + user := userFromCtx(r) + var body struct { + Notes string `json:"notes"` + } + decode(r, &body) + now := time.Now().UTC() + h.db.Exec(`UPDATE wo_steps SET completed=1,completed_by=@p1,completed_at=@p2,notes=@p3 WHERE id=@p4`, + user.Email, now, body.Notes, sid) + var step model.Step + h.db.Get(&step, `SELECT * FROM wo_steps WHERE id=@p1`, sid) + respond(w, http.StatusOK, step) +} + +func (h *StepHandler) Delete(w http.ResponseWriter, r *http.Request) { + sid, err := intParam(r, "sid") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid sid") + return + } + h.db.Exec(`DELETE FROM wo_steps WHERE id=@p1`, sid) + respond(w, http.StatusOK, map[string]any{"deleted": sid}) +} diff --git a/internal/api/handlers/workorder.go b/internal/api/handlers/workorder.go new file mode 100644 index 0000000..8a607ad --- /dev/null +++ b/internal/api/handlers/workorder.go @@ -0,0 +1,171 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/jmoiron/sqlx" + "workorders/internal/config" + "workorders/internal/model" +) + +type WorkOrderHandler struct { + db *sqlx.DB + cfg *config.Config +} + +func NewWorkOrderHandler(db *sqlx.DB, cfg *config.Config) *WorkOrderHandler { + return &WorkOrderHandler{db: db, cfg: cfg} +} + +func (h *WorkOrderHandler) List(w http.ResponseWriter, r *http.Request) { + status := r.URL.Query().Get("status") + search := r.URL.Query().Get("search") + if search != "" { + search = "%" + search + "%" + } + + query := ` + SELECT + wo.id, wo.wo_number, wo.title, wo.status, wo.priority, + wo.site_name, wo.scheduled_start, + COUNT(s.id) AS step_count, + ISNULL(SUM(CAST(s.completed AS INT)),0) AS steps_done, + COUNT(a.id) AS photo_count + FROM work_orders wo + LEFT JOIN wo_steps s ON s.wo_id = wo.id + LEFT JOIN wo_attachments a ON a.wo_id = wo.id + WHERE (@p1 = '' OR wo.status = @p1) + AND (@p2 = '' OR wo.title LIKE @p2 OR wo.wo_number LIKE @p2 OR wo.site_name LIKE @p2) + GROUP BY wo.id, wo.wo_number, wo.title, wo.status, + wo.priority, wo.site_name, wo.scheduled_start + ORDER BY wo.scheduled_start DESC, wo.id DESC` + + rows := []model.WorkOrderListItem{} + if err := h.db.Select(&rows, query, status, search); err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + respond(w, http.StatusOK, rows) +} + +func (h *WorkOrderHandler) Get(w http.ResponseWriter, r *http.Request) { + id, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + var wo model.WorkOrder + if err = h.db.Get(&wo, `SELECT * FROM work_orders WHERE id = @p1`, id); err != nil { + respondError(w, http.StatusNotFound, "not found") + return + } + respond(w, http.StatusOK, wo) +} + +func (h *WorkOrderHandler) Create(w http.ResponseWriter, r *http.Request) { + user := userFromCtx(r) + var body model.WorkOrder + if err := decode(r, &body); err != nil { + respondError(w, http.StatusBadRequest, "invalid body") + return + } + if body.Priority == "" { + body.Priority = "normal" + } + + var id int + err := h.db.QueryRow(` + INSERT INTO work_orders + (title, description, instructions, status, priority, + scheduled_start, scheduled_end, site_name, address, lat, lng, + access_notes, parent_type, parent_id, created_by, updated_at) + OUTPUT INSERTED.id + VALUES (@p1,@p2,@p3,'draft',@p4,@p5,@p6,@p7,@p8,@p9,@p10,@p11,@p12,@p13,@p14,GETUTCDATE())`, + body.Title, body.Description, body.Instructions, body.Priority, + body.ScheduledStart, body.ScheduledEnd, body.SiteName, body.Address, + body.Lat, body.Lng, body.AccessNotes, body.ParentType, body.ParentID, + user.Email, + ).Scan(&id) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + var wo model.WorkOrder + h.db.Get(&wo, `SELECT * FROM work_orders WHERE id = @p1`, id) + respond(w, http.StatusCreated, wo) +} + +func (h *WorkOrderHandler) Update(w http.ResponseWriter, r *http.Request) { + id, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + var body model.WorkOrder + if err := decode(r, &body); err != nil { + respondError(w, http.StatusBadRequest, "invalid body") + return + } + _, err = h.db.Exec(` + UPDATE work_orders SET + title=@p1, description=@p2, instructions=@p3, priority=@p4, + scheduled_start=@p5, scheduled_end=@p6, site_name=@p7, address=@p8, + lat=@p9, lng=@p10, access_notes=@p11, updated_at=GETUTCDATE() + WHERE id=@p12`, + body.Title, body.Description, body.Instructions, body.Priority, + body.ScheduledStart, body.ScheduledEnd, body.SiteName, body.Address, + body.Lat, body.Lng, body.AccessNotes, id, + ) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + var wo model.WorkOrder + h.db.Get(&wo, `SELECT * FROM work_orders WHERE id = @p1`, id) + respond(w, http.StatusOK, wo) +} + +func (h *WorkOrderHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + h.db.Exec(`DELETE FROM work_orders WHERE id = @p1`, id) + respond(w, http.StatusOK, map[string]any{"deleted": id}) +} + +func (h *WorkOrderHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { + id, err := intParam(r, "id") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + user := userFromCtx(r) + var body struct { + Status string `json:"status"` + } + if err := decode(r, &body); err != nil { + respondError(w, http.StatusBadRequest, "invalid body") + return + } + now := time.Now().UTC() + _, err = h.db.Exec(` + UPDATE work_orders SET + status=@p1, updated_at=GETUTCDATE(), + actual_start = CASE WHEN @p1='in_progress' AND actual_start IS NULL THEN @p2 ELSE actual_start END, + actual_end = CASE WHEN @p1='closed' THEN @p2 ELSE actual_end END, + closed_at = CASE WHEN @p1='closed' THEN @p2 ELSE closed_at END, + closed_by = CASE WHEN @p1='closed' THEN @p3 ELSE closed_by END + WHERE id=@p4`, + body.Status, now, user.Email, id, + ) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + var wo model.WorkOrder + h.db.Get(&wo, `SELECT * FROM work_orders WHERE id = @p1`, id) + respond(w, http.StatusOK, wo) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..c60030d --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,190 @@ +package api + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "workorders/internal/model" +) + +// jwksCache caches the public keys from Keycloak +type jwksCache struct { + mu sync.RWMutex + keys map[string]*rsa.PublicKey + fetchAt time.Time + url string +} + +var cache = &jwksCache{} + +func InitJWKS(url string) { + cache.url = url + if err := cache.refresh(); err != nil { + log.Printf("JWKS initial fetch warning: %v (will retry per-request)", err) + } +} + +func (c *jwksCache) refresh() error { + resp, err := http.Get(c.url) //nolint:gosec + if err != nil { + return fmt.Errorf("fetch JWKS: %w", err) + } + defer resp.Body.Close() + + var jwks struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return fmt.Errorf("decode JWKS: %w", err) + } + + keys := make(map[string]*rsa.PublicKey, len(jwks.Keys)) + for _, k := range jwks.Keys { + if k.Kty != "RSA" { + continue + } + pub, err := rsaPublicKey(k.N, k.E) + if err != nil { + continue + } + keys[k.Kid] = pub + } + + c.mu.Lock() + c.keys = keys + c.fetchAt = time.Now() + c.mu.Unlock() + return nil +} + +func (c *jwksCache) get(kid string) (*rsa.PublicKey, error) { + c.mu.RLock() + key, ok := c.keys[kid] + stale := time.Since(c.fetchAt) > 10*time.Minute + c.mu.RUnlock() + + if ok && !stale { + return key, nil + } + if err := c.refresh(); err != nil { + if ok { + return key, nil // use stale key if refresh fails + } + return nil, err + } + c.mu.RLock() + key, ok = c.keys[kid] + c.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("key %q not found", kid) + } + return key, nil +} + +func rsaPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(nStr) + if err != nil { + return nil, err + } + eBytes, err := base64.RawURLEncoding.DecodeString(eStr) + if err != nil { + return nil, err + } + n := new(big.Int).SetBytes(nBytes) + e := new(big.Int).SetBytes(eBytes) + return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil +} + +// OIDCAuth validates a Keycloak-issued JWT in the Authorization header. +func OIDCAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return + } + raw := strings.TrimPrefix(auth, "Bearer ") + + token, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + kid, _ := t.Header["kid"].(string) + return cache.get(kid) + }, jwt.WithExpirationRequired()) + + if err != nil || !token.Valid { + jsonError(w, "invalid token", http.StatusUnauthorized) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + jsonError(w, "invalid claims", http.StatusUnauthorized) + return + } + + user := model.UserClaims{ + Sub: stringClaim(claims, "sub"), + Email: stringClaim(claims, "email"), + Name: stringClaim(claims, "name"), + } + if ra, ok := claims["realm_access"].(map[string]any); ok { + if roles, ok := ra["roles"].([]any); ok { + for _, r := range roles { + if s, ok := r.(string); ok { + user.Roles = append(user.Roles, s) + } + } + } + } + + ctx := context.WithValue(r.Context(), model.CtxUserKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func stringClaim(c jwt.MapClaims, key string) string { + v, _ := c[key].(string) + return v +} + +func jsonError(w http.ResponseWriter, msg string, code int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + fmt.Fprintf(w, `{"error":%q}`, msg) +} + +func UserFromCtx(r *http.Request) model.UserClaims { + u, _ := r.Context().Value(model.CtxUserKey).(model.UserClaims) + return u +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..3a9669b --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,67 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/jmoiron/sqlx" + "workorders/internal/api/handlers" + "workorders/internal/config" +) + +func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler { + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RealIP) + r.Use(CORS) + + // Serve frontend static files + r.Handle("/*", http.FileServer(http.Dir("./web"))) + + // Protected API + r.Group(func(r chi.Router) { + r.Use(OIDCAuth) + + wo := handlers.NewWorkOrderHandler(db, cfg) + r.Get("/api/work-orders", wo.List) + r.Post("/api/work-orders", wo.Create) + r.Get("/api/work-orders/{id}", wo.Get) + r.Put("/api/work-orders/{id}", wo.Update) + r.Delete("/api/work-orders/{id}", wo.Delete) + r.Put("/api/work-orders/{id}/status", wo.UpdateStatus) + + step := handlers.NewStepHandler(db) + r.Get("/api/work-orders/{id}/steps", step.List) + r.Post("/api/work-orders/{id}/steps", step.Create) + r.Put("/api/work-orders/{id}/steps/{sid}", step.Update) + r.Post("/api/work-orders/{id}/steps/{sid}/complete", step.Complete) + r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete) + + res := handlers.NewResourceHandler(db) + r.Get("/api/work-orders/{id}/resources", res.List) + r.Post("/api/work-orders/{id}/resources", res.Assign) + r.Delete("/api/work-orders/{id}/resources/{rid}", res.Remove) + + att := handlers.NewAttachmentHandler(db, cfg) + r.Get("/api/work-orders/{id}/attachments", att.List) + r.Post("/api/work-orders/{id}/attachments", att.Upload) + r.Delete("/api/work-orders/{id}/attachments/{aid}", att.Delete) + r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath)))) + + acc := handlers.NewAccountingHandler(db) + r.Get("/api/work-orders/{id}/accounting", acc.Get) + r.Put("/api/work-orders/{id}/accounting", acc.Upsert) + + reg := handlers.NewRegistryHandler(db) + r.Get("/api/registry/people", reg.People) + r.Get("/api/registry/vehicles", reg.Vehicles) + r.Get("/api/registry/equipment", reg.Equipment) + r.Get("/api/registry/materials", reg.Materials) + + r.Get("/api/me", handlers.Me) + }) + + return r +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d26dca3 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,33 @@ +package config + +import "os" + +type Config struct { + Addr string + DBDSN string + JWTSecret string + UploadPath string + BaseURL string + OIDCIssuer string + JWKSUrl string + AppEnv string +} + +func Load() *Config { + return &Config{ + Addr: env("ADDR", ":8080"), + DBDSN: env("DB_DSN", ""), + UploadPath: env("UPLOAD_PATH", "./uploads"), + BaseURL: env("BASE_URL", "http://localhost:8080"), + OIDCIssuer: env("OIDC_ISSUER", "http://localhost:8180/realms/workorders"), + JWKSUrl: env("JWKS_URL", "http://localhost:8180/realms/workorders/protocol/openid-connect/certs"), + AppEnv: env("APP_ENV", "development"), + } +} + +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/model/models.go b/internal/model/models.go new file mode 100644 index 0000000..b60f5c4 --- /dev/null +++ b/internal/model/models.go @@ -0,0 +1,131 @@ +package model + +import "time" + +type WorkOrder struct { + ID int `db:"id" json:"id"` + WONumber string `db:"wo_number" json:"wo_number"` + Title string `db:"title" json:"title"` + Description string `db:"description" json:"description"` + Instructions string `db:"instructions" json:"instructions"` + Status string `db:"status" json:"status"` + Priority string `db:"priority" json:"priority"` + ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"` + ScheduledEnd *time.Time `db:"scheduled_end" json:"scheduled_end"` + ActualStart *time.Time `db:"actual_start" json:"actual_start"` + ActualEnd *time.Time `db:"actual_end" json:"actual_end"` + SiteName string `db:"site_name" json:"site_name"` + Address string `db:"address" json:"address"` + Lat *float64 `db:"lat" json:"lat"` + Lng *float64 `db:"lng" json:"lng"` + AccessNotes string `db:"access_notes" json:"access_notes"` + ParentType *string `db:"parent_type" json:"parent_type"` + ParentID *int `db:"parent_id" json:"parent_id"` + CreatedBy string `db:"created_by" json:"created_by"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ClosedAt *time.Time `db:"closed_at" json:"closed_at"` + ClosedBy *string `db:"closed_by" json:"closed_by"` +} + +type WorkOrderListItem struct { + ID int `db:"id" json:"id"` + WONumber string `db:"wo_number" json:"wo_number"` + Title string `db:"title" json:"title"` + Status string `db:"status" json:"status"` + Priority string `db:"priority" json:"priority"` + SiteName string `db:"site_name" json:"site_name"` + ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"` + StepCount int `db:"step_count" json:"step_count"` + StepsDone int `db:"steps_done" json:"steps_done"` + PhotoCount int `db:"photo_count" json:"photo_count"` +} + +type Step struct { + ID int `db:"id" json:"id"` + WOID int `db:"wo_id" json:"wo_id"` + StepOrder int `db:"step_order" json:"step_order"` + Title string `db:"title" json:"title"` + Description string `db:"description" json:"description"` + Required bool `db:"required" json:"required"` + Completed bool `db:"completed" json:"completed"` + CompletedBy *string `db:"completed_by" json:"completed_by"` + CompletedAt *time.Time `db:"completed_at" json:"completed_at"` + Notes string `db:"notes" json:"notes"` +} + +type AssignedResource struct { + ID int `db:"id" json:"id"` + WOID int `db:"wo_id" json:"wo_id"` + ResourceType string `db:"resource_type" json:"resource_type"` + ResourceID int `db:"resource_id" json:"resource_id"` + Name string `db:"name" json:"name"` + Quantity *float64 `db:"quantity" json:"quantity"` + Notes string `db:"notes" json:"notes"` + AssignedAt time.Time `db:"assigned_at" json:"assigned_at"` +} + +type Attachment struct { + ID int `db:"id" json:"id"` + WOID int `db:"wo_id" json:"wo_id"` + StepID *int `db:"step_id" json:"step_id"` + FileName string `db:"file_name" json:"file_name"` + FilePath string `db:"file_path" json:"file_path"` + FileType string `db:"file_type" json:"file_type"` + FileSize int64 `db:"file_size" json:"file_size"` + Caption string `db:"caption" json:"caption"` + Phase string `db:"phase" json:"phase"` + Lat *float64 `db:"lat" json:"lat"` + Lng *float64 `db:"lng" json:"lng"` + UploadedBy string `db:"uploaded_by" json:"uploaded_by"` + UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"` + URL string `db:"-" json:"url"` +} + +type AccountingCode struct { + ID int `db:"id" json:"id"` + WOID int `db:"wo_id" json:"wo_id"` + CodeType string `db:"code_type" json:"code_type"` + CodeValue string `db:"code_value" json:"code_value"` + Description string `db:"description" json:"description"` +} + +type RegistryPerson struct { + ID int `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Role string `db:"role" json:"role"` + Email string `db:"email" json:"email"` + Phone string `db:"phone" json:"phone"` +} + +type RegistryVehicle struct { + ID int `db:"id" json:"id"` + UnitNumber string `db:"unit_number" json:"unit_number"` + Description string `db:"description" json:"description"` + VehicleType string `db:"vehicle_type" json:"vehicle_type"` +} + +type RegistryEquipment struct { + ID int `db:"id" json:"id"` + Name string `db:"name" json:"name"` + AssetTag string `db:"asset_tag" json:"asset_tag"` + Category string `db:"category" json:"category"` +} + +type RegistryMaterial struct { + ID int `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Unit string `db:"unit" json:"unit"` + PartNumber string `db:"part_number" json:"part_number"` +} + +type UserClaims struct { + Sub string + Email string + Name string + Roles []string +} + +type CtxKey string + +const CtxUserKey CtxKey = "user" diff --git a/internal/repository/db.go b/internal/repository/db.go new file mode 100644 index 0000000..67e9d70 --- /dev/null +++ b/internal/repository/db.go @@ -0,0 +1,134 @@ +package repository + +import ( + "context" + "embed" + "fmt" + "log" + "strings" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/microsoft/go-mssqldb" +) + +//go:embed migrations/*.sql +var migrations embed.FS + +func Connect(dsn string) (*sqlx.DB, error) { + // Extract base DSN without the database name for initial connection to master + masterDSN := strings.Replace(dsn, "database=workorders", "database=master", 1) + + // Retry loop — MSSQL takes time to start + var db *sqlx.DB + var err error + for i := range 20 { + db, err = sqlx.Open("sqlserver", masterDSN) + if err == nil { + if pingErr := db.PingContext(context.Background()); pingErr == nil { + break + } + db.Close() + } + log.Printf("waiting for MSSQL (%d/20)...", i+1) + time.Sleep(3 * time.Second) + } + if err != nil { + return nil, fmt.Errorf("connect to master: %w", err) + } + + // Create the workorders database if it doesn't exist + _, err = db.Exec(` + IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'workorders') + CREATE DATABASE workorders; + `) + if err != nil { + return nil, fmt.Errorf("create database: %w", err) + } + db.Close() + + // Reconnect to the workorders database + db, err = sqlx.Open("sqlserver", dsn) + if err != nil { + return nil, fmt.Errorf("connect to workorders: %w", err) + } + if err = db.PingContext(context.Background()); err != nil { + return nil, fmt.Errorf("ping workorders: %w", err) + } + + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + + return db, nil +} + +func RunMigrations(db *sqlx.DB) error { + // Create migrations tracking table + _, err := db.Exec(` + IF NOT EXISTS ( + SELECT * FROM sys.tables WHERE name = 'schema_migrations' + ) + CREATE TABLE schema_migrations ( + filename NVARCHAR(255) PRIMARY KEY, + applied_at DATETIME2 NOT NULL DEFAULT GETUTCDATE() + ); + `) + if err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + entries, err := migrations.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read migrations dir: %w", err) + } + + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + + var count int + _ = db.QueryRow(`SELECT COUNT(1) FROM schema_migrations WHERE filename = @p1`, entry.Name()).Scan(&count) + if count > 0 { + continue + } + + content, err := migrations.ReadFile("migrations/" + entry.Name()) + if err != nil { + return fmt.Errorf("read %s: %w", entry.Name(), err) + } + + // Split on GO statements for multi-batch SQL + statements := splitSQL(string(content)) + for _, stmt := range statements { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := db.Exec(stmt); err != nil { + return fmt.Errorf("migration %s: %w", entry.Name(), err) + } + } + + _, err = db.Exec(`INSERT INTO schema_migrations (filename) VALUES (@p1)`, entry.Name()) + if err != nil { + return fmt.Errorf("record migration %s: %w", entry.Name(), err) + } + log.Printf("applied migration: %s", entry.Name()) + } + return nil +} + +func splitSQL(sql string) []string { + var stmts []string + for _, part := range strings.Split(sql, "\nGO") { + if s := strings.TrimSpace(part); s != "" { + stmts = append(stmts, s) + } + } + if len(stmts) == 0 { + stmts = []string{sql} + } + return stmts +} diff --git a/internal/repository/migrations/001_initial_schema.sql b/internal/repository/migrations/001_initial_schema.sql new file mode 100644 index 0000000..32ecb02 --- /dev/null +++ b/internal/repository/migrations/001_initial_schema.sql @@ -0,0 +1,157 @@ +-- Run once: create database if needed (executed against master) +-- The Go migration runner handles this separately. + +-- ── Work Orders ─────────────────────────────────────────────────────────────── +CREATE TABLE work_orders ( + id INT IDENTITY PRIMARY KEY, + wo_number AS ('WO-' + RIGHT('000000' + CAST(id AS VARCHAR), 6)) PERSISTED, + title NVARCHAR(200) NOT NULL, + description NVARCHAR(MAX), + instructions NVARCHAR(MAX), + status NVARCHAR(30) NOT NULL DEFAULT 'draft', + priority NVARCHAR(10) NOT NULL DEFAULT 'normal', + scheduled_start DATETIME2, + scheduled_end DATETIME2, + actual_start DATETIME2, + actual_end DATETIME2, + site_name NVARCHAR(200), + address NVARCHAR(400), + lat DECIMAL(10,7), + lng DECIMAL(10,7), + access_notes NVARCHAR(MAX), + parent_type NVARCHAR(50), + parent_id INT, + created_by NVARCHAR(200) NOT NULL DEFAULT 'system', + created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), + updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), + closed_at DATETIME2, + closed_by NVARCHAR(200), + CONSTRAINT chk_wo_status CHECK (status IN ('draft','assigned','scheduled','in_progress','pending_review','closed')), + CONSTRAINT chk_wo_priority CHECK (priority IN ('low','normal','high','urgent')) +); + +-- ── Resource Registry ───────────────────────────────────────────────────────── +CREATE TABLE resource_people ( + id INT IDENTITY PRIMARY KEY, + name NVARCHAR(100) NOT NULL, + role NVARCHAR(100), + email NVARCHAR(200), + phone NVARCHAR(30), + active BIT NOT NULL DEFAULT 1 +); + +CREATE TABLE resource_vehicles ( + id INT IDENTITY PRIMARY KEY, + unit_number NVARCHAR(50) NOT NULL, + description NVARCHAR(200), + vehicle_type NVARCHAR(100), + active BIT NOT NULL DEFAULT 1 +); + +CREATE TABLE resource_equipment ( + id INT IDENTITY PRIMARY KEY, + name NVARCHAR(200) NOT NULL, + asset_tag NVARCHAR(100), + category NVARCHAR(100), + active BIT NOT NULL DEFAULT 1 +); + +CREATE TABLE resource_materials ( + id INT IDENTITY PRIMARY KEY, + name NVARCHAR(200) NOT NULL, + unit NVARCHAR(30), + part_number NVARCHAR(100), + active BIT NOT NULL DEFAULT 1 +); + +-- ── Work Order Resources ─────────────────────────────────────────────────────── +CREATE TABLE wo_resources ( + id INT IDENTITY PRIMARY KEY, + wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + resource_type NVARCHAR(20) NOT NULL, + resource_id INT NOT NULL, + quantity DECIMAL(10,2), + notes NVARCHAR(500), + assigned_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), + CONSTRAINT chk_resource_type CHECK (resource_type IN ('person','vehicle','equipment','material')) +); + +-- ── Checklist Steps ─────────────────────────────────────────────────────────── +CREATE TABLE wo_steps ( + id INT IDENTITY PRIMARY KEY, + wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + step_order INT NOT NULL, + title NVARCHAR(200) NOT NULL, + description NVARCHAR(MAX), + required BIT NOT NULL DEFAULT 1, + completed BIT NOT NULL DEFAULT 0, + completed_by NVARCHAR(200), + completed_at DATETIME2, + notes NVARCHAR(MAX) +); + +-- ── Attachments ─────────────────────────────────────────────────────────────── +CREATE TABLE wo_attachments ( + id INT IDENTITY PRIMARY KEY, + wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + step_id INT REFERENCES wo_steps(id), + file_name NVARCHAR(500) NOT NULL, + file_path NVARCHAR(1000) NOT NULL, + file_type NVARCHAR(100), + file_size BIGINT, + caption NVARCHAR(500), + phase NVARCHAR(20), + lat DECIMAL(10,7), + lng DECIMAL(10,7), + uploaded_by NVARCHAR(200) NOT NULL, + uploaded_at DATETIME2 NOT NULL DEFAULT GETUTCDATE() +); + +-- ── Accounting Codes ────────────────────────────────────────────────────────── +CREATE TABLE wo_accounting ( + id INT IDENTITY PRIMARY KEY, + wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + code_type NVARCHAR(50) NOT NULL, + code_value NVARCHAR(200) NOT NULL, + description NVARCHAR(500), + CONSTRAINT uq_wo_accounting UNIQUE (wo_id, code_type) +); + +-- ── Audit Log ───────────────────────────────────────────────────────────────── +CREATE TABLE wo_audit_log ( + id INT IDENTITY PRIMARY KEY, + wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + action NVARCHAR(100) NOT NULL, + old_value NVARCHAR(MAX), + new_value NVARCHAR(MAX), + performed_by NVARCHAR(200) NOT NULL, + performed_at DATETIME2 NOT NULL DEFAULT GETUTCDATE() +); + +-- ── Indexes ─────────────────────────────────────────────────────────────────── +CREATE INDEX ix_wo_status ON work_orders (status); +CREATE INDEX ix_wo_parent ON work_orders (parent_type, parent_id); +CREATE INDEX ix_wo_scheduled ON work_orders (scheduled_start); +CREATE INDEX ix_resources ON wo_resources (wo_id, resource_type); +CREATE INDEX ix_attachments ON wo_attachments (wo_id); + +-- ── Seed registry data ──────────────────────────────────────────────────────── +INSERT INTO resource_people (name, role, email, phone) VALUES + ('Alice Johnson', 'Lead Technician', 'alice@example.com', '555-0101'), + ('Bob Smith', 'Field Tech', 'bob@example.com', '555-0102'), + ('Carol White', 'Supervisor', 'carol@example.com', '555-0103'); + +INSERT INTO resource_vehicles (unit_number, description, vehicle_type) VALUES + ('TRK-001', 'Ford F-250 Service Truck', 'Pickup'), + ('VAN-002', 'Transit Connect Work Van', 'Van'), + ('SUV-003', 'Chevy Tahoe Supervisor', 'SUV'); + +INSERT INTO resource_equipment (name, asset_tag, category) VALUES + ('Bucket Truck', 'EQ-1001', 'Heavy Equipment'), + ('Portable Generator', 'EQ-1002', 'Power'), + ('Cable Puller', 'EQ-1003', 'Tools'); + +INSERT INTO resource_materials (name, unit, part_number) VALUES + ('Wire 12 AWG', 'ft', 'WR-12AWG'), + ('Conduit 1"', 'ft', 'CD-1IN'), + ('Junction Box', 'each', 'JB-100'); diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json new file mode 100644 index 0000000..d5ab2be --- /dev/null +++ b/keycloak/realm-export.json @@ -0,0 +1,87 @@ +{ + "id": "workorders", + "realm": "workorders", + "displayName": "Work Orders", + "enabled": true, + "registrationAllowed": false, + "resetPasswordAllowed": true, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "sslRequired": "none", + "clients": [ + { + "clientId": "workorders-app", + "name": "Work Orders App", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:9080/*", + "http://localhost:8081/*" + ], + "webOrigins": [ + "http://localhost:9080", + "http://localhost:8081" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:9080/*" + }, + "protocol": "openid-connect" + } + ], + "roles": { + "realm": [ + { "name": "admin", "description": "Full administrative access" }, + { "name": "dispatcher", "description": "Create and assign work orders" }, + { "name": "field_tech", "description": "Complete steps and upload photos" }, + { "name": "viewer", "description": "Read-only access" } + ] + }, + "users": [ + { + "username": "admin", + "email": "admin@workorders.local", + "firstName": "Admin", + "lastName": "User", + "enabled": true, + "emailVerified": true, + "credentials": [{ "type": "password", "value": "admin123", "temporary": false }], + "realmRoles": ["admin"] + }, + { + "username": "dispatcher", + "email": "dispatcher@workorders.local", + "firstName": "Dispatcher", + "lastName": "User", + "enabled": true, + "emailVerified": true, + "credentials": [{ "type": "password", "value": "dispatcher123", "temporary": false }], + "realmRoles": ["dispatcher"] + }, + { + "username": "tech1", + "email": "tech1@workorders.local", + "firstName": "Tech", + "lastName": "One", + "enabled": true, + "emailVerified": true, + "credentials": [{ "type": "password", "value": "tech123", "temporary": false }], + "realmRoles": ["field_tech"] + }, + { + "username": "viewer", + "email": "viewer@workorders.local", + "firstName": "View", + "lastName": "Only", + "enabled": true, + "emailVerified": true, + "credentials": [{ "type": "password", "value": "viewer123", "temporary": false }], + "realmRoles": ["viewer"] + } + ] +} diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..32ecb02 --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -0,0 +1,157 @@ +-- Run once: create database if needed (executed against master) +-- The Go migration runner handles this separately. + +-- ── Work Orders ─────────────────────────────────────────────────────────────── +CREATE TABLE work_orders ( + id INT IDENTITY PRIMARY KEY, + wo_number AS ('WO-' + RIGHT('000000' + CAST(id AS VARCHAR), 6)) PERSISTED, + title NVARCHAR(200) NOT NULL, + description NVARCHAR(MAX), + instructions NVARCHAR(MAX), + status NVARCHAR(30) NOT NULL DEFAULT 'draft', + priority NVARCHAR(10) NOT NULL DEFAULT 'normal', + scheduled_start DATETIME2, + scheduled_end DATETIME2, + actual_start DATETIME2, + actual_end DATETIME2, + site_name NVARCHAR(200), + address NVARCHAR(400), + lat DECIMAL(10,7), + lng DECIMAL(10,7), + access_notes NVARCHAR(MAX), + parent_type NVARCHAR(50), + parent_id INT, + created_by NVARCHAR(200) NOT NULL DEFAULT 'system', + created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), + updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), + closed_at DATETIME2, + closed_by NVARCHAR(200), + CONSTRAINT chk_wo_status CHECK (status IN ('draft','assigned','scheduled','in_progress','pending_review','closed')), + CONSTRAINT chk_wo_priority CHECK (priority IN ('low','normal','high','urgent')) +); + +-- ── Resource Registry ───────────────────────────────────────────────────────── +CREATE TABLE resource_people ( + id INT IDENTITY PRIMARY KEY, + name NVARCHAR(100) NOT NULL, + role NVARCHAR(100), + email NVARCHAR(200), + phone NVARCHAR(30), + active BIT NOT NULL DEFAULT 1 +); + +CREATE TABLE resource_vehicles ( + id INT IDENTITY PRIMARY KEY, + unit_number NVARCHAR(50) NOT NULL, + description NVARCHAR(200), + vehicle_type NVARCHAR(100), + active BIT NOT NULL DEFAULT 1 +); + +CREATE TABLE resource_equipment ( + id INT IDENTITY PRIMARY KEY, + name NVARCHAR(200) NOT NULL, + asset_tag NVARCHAR(100), + category NVARCHAR(100), + active BIT NOT NULL DEFAULT 1 +); + +CREATE TABLE resource_materials ( + id INT IDENTITY PRIMARY KEY, + name NVARCHAR(200) NOT NULL, + unit NVARCHAR(30), + part_number NVARCHAR(100), + active BIT NOT NULL DEFAULT 1 +); + +-- ── Work Order Resources ─────────────────────────────────────────────────────── +CREATE TABLE wo_resources ( + id INT IDENTITY PRIMARY KEY, + wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + resource_type NVARCHAR(20) NOT NULL, + resource_id INT NOT NULL, + quantity DECIMAL(10,2), + notes NVARCHAR(500), + assigned_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), + CONSTRAINT chk_resource_type CHECK (resource_type IN ('person','vehicle','equipment','material')) +); + +-- ── Checklist Steps ─────────────────────────────────────────────────────────── +CREATE TABLE wo_steps ( + id INT IDENTITY PRIMARY KEY, + wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + step_order INT NOT NULL, + title NVARCHAR(200) NOT NULL, + description NVARCHAR(MAX), + required BIT NOT NULL DEFAULT 1, + completed BIT NOT NULL DEFAULT 0, + completed_by NVARCHAR(200), + completed_at DATETIME2, + notes NVARCHAR(MAX) +); + +-- ── Attachments ─────────────────────────────────────────────────────────────── +CREATE TABLE wo_attachments ( + id INT IDENTITY PRIMARY KEY, + wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + step_id INT REFERENCES wo_steps(id), + file_name NVARCHAR(500) NOT NULL, + file_path NVARCHAR(1000) NOT NULL, + file_type NVARCHAR(100), + file_size BIGINT, + caption NVARCHAR(500), + phase NVARCHAR(20), + lat DECIMAL(10,7), + lng DECIMAL(10,7), + uploaded_by NVARCHAR(200) NOT NULL, + uploaded_at DATETIME2 NOT NULL DEFAULT GETUTCDATE() +); + +-- ── Accounting Codes ────────────────────────────────────────────────────────── +CREATE TABLE wo_accounting ( + id INT IDENTITY PRIMARY KEY, + wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + code_type NVARCHAR(50) NOT NULL, + code_value NVARCHAR(200) NOT NULL, + description NVARCHAR(500), + CONSTRAINT uq_wo_accounting UNIQUE (wo_id, code_type) +); + +-- ── Audit Log ───────────────────────────────────────────────────────────────── +CREATE TABLE wo_audit_log ( + id INT IDENTITY PRIMARY KEY, + wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + action NVARCHAR(100) NOT NULL, + old_value NVARCHAR(MAX), + new_value NVARCHAR(MAX), + performed_by NVARCHAR(200) NOT NULL, + performed_at DATETIME2 NOT NULL DEFAULT GETUTCDATE() +); + +-- ── Indexes ─────────────────────────────────────────────────────────────────── +CREATE INDEX ix_wo_status ON work_orders (status); +CREATE INDEX ix_wo_parent ON work_orders (parent_type, parent_id); +CREATE INDEX ix_wo_scheduled ON work_orders (scheduled_start); +CREATE INDEX ix_resources ON wo_resources (wo_id, resource_type); +CREATE INDEX ix_attachments ON wo_attachments (wo_id); + +-- ── Seed registry data ──────────────────────────────────────────────────────── +INSERT INTO resource_people (name, role, email, phone) VALUES + ('Alice Johnson', 'Lead Technician', 'alice@example.com', '555-0101'), + ('Bob Smith', 'Field Tech', 'bob@example.com', '555-0102'), + ('Carol White', 'Supervisor', 'carol@example.com', '555-0103'); + +INSERT INTO resource_vehicles (unit_number, description, vehicle_type) VALUES + ('TRK-001', 'Ford F-250 Service Truck', 'Pickup'), + ('VAN-002', 'Transit Connect Work Van', 'Van'), + ('SUV-003', 'Chevy Tahoe Supervisor', 'SUV'); + +INSERT INTO resource_equipment (name, asset_tag, category) VALUES + ('Bucket Truck', 'EQ-1001', 'Heavy Equipment'), + ('Portable Generator', 'EQ-1002', 'Power'), + ('Cable Puller', 'EQ-1003', 'Tools'); + +INSERT INTO resource_materials (name, unit, part_number) VALUES + ('Wire 12 AWG', 'ft', 'WR-12AWG'), + ('Conduit 1"', 'ft', 'CD-1IN'), + ('Junction Box', 'each', 'JB-100'); diff --git a/web/app.mjs b/web/app.mjs new file mode 100644 index 0000000..41dcfde --- /dev/null +++ b/web/app.mjs @@ -0,0 +1,84 @@ +import { setToken } from './lib/api.mjs'; +import './components/wo-list.mjs'; +import './components/wo-form.mjs'; + +// ── Keycloak init ───────────────────────────────────────────────────────────── +const keycloak = new Keycloak({ + url: window.KEYCLOAK_URL || 'http://localhost:8180', + realm: window.KEYCLOAK_REALM || 'workorders', + clientId: window.KEYCLOAK_CLIENT_ID || 'workorders-app', +}); + +keycloak.init({ + onLoad: 'login-required', + pkceMethod: 'S256', + checkLoginIframe: false, +}) + .then(authenticated => { + if (!authenticated) { keycloak.login(); return; } + setToken(keycloak.token); + + // Refresh token before it expires + setInterval(async () => { + try { await keycloak.updateToken(60); setToken(keycloak.token); } + catch { keycloak.login(); } + }, 30_000); + + window.addEventListener('auth:expired', () => keycloak.login()); + renderApp(keycloak); + }) + .catch(() => document.getElementById('app').innerHTML = + '

Failed to connect to authentication server.

'); + +// ── App shell ───────────────────────────────────────────────────────────────── +function renderApp(kc) { + const app = document.getElementById('app'); + const userName = kc.tokenParsed?.name || kc.tokenParsed?.preferred_username || 'User'; + + // Nav + const nav = document.createElement('nav'); + nav.innerHTML = ` + Work Orders + + ${userName} + `; + document.body.insertBefore(nav, app); + nav.querySelector('#logout').addEventListener('click', () => kc.logout()); + + showList(); + + app.addEventListener('wo:select', e => showDetail(e.detail.id)); + app.addEventListener('wo:cancel', () => showList()); + app.addEventListener('wo:saved', () => showList()); + + document.addEventListener('keydown', e => { if (e.key === 'Escape') showList(); }); +} + +function showList() { + const app = document.getElementById('app'); + app.innerHTML = ` + + `; + app.querySelector('#new-wo').addEventListener('click', showCreate); +} + +function showCreate() { + const app = document.getElementById('app'); + app.innerHTML = ` + +
`; +} + +function showDetail(id) { + const app = document.getElementById('app'); + app.innerHTML = ` + +
`; + app.querySelector('#back').addEventListener('click', showList); +} diff --git a/web/components/wo-form.mjs b/web/components/wo-form.mjs new file mode 100644 index 0000000..e3584cc --- /dev/null +++ b/web/components/wo-form.mjs @@ -0,0 +1,115 @@ +import { api } from '../lib/api.mjs'; + +class WoForm extends HTMLElement { + #woId = null; + #wo = null; + + static get observedAttributes() { return ['wo-id']; } + attributeChangedCallback(_, __, val) { this.#woId = val ? +val : null; this.#load(); } + + connectedCallback() { + this.attachShadow({ mode: 'open' }); + this.#render(); + if (this.#woId) this.#load(); + } + + async #load() { + if (!this.#woId) return; + this.#wo = await api.get(`/work-orders/${this.#woId}`); + this.#render(); + } + + #render() { + const wo = this.#wo ?? {}; + this.shadowRoot.innerHTML = ` + +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ + +
+
`; + + this.shadowRoot.querySelector('#cancel').addEventListener('click', () => + this.dispatchEvent(new CustomEvent('wo:cancel', { bubbles: true, composed: true }))); + + this.shadowRoot.querySelector('form').addEventListener('submit', async e => { + e.preventDefault(); + + // Read fields directly — Shadow DOM + FormData has unreliable browser support + const val = name => (this.shadowRoot.querySelector(`[name="${name}"]`)?.value ?? '').trim(); + // datetime-local gives "YYYY-MM-DDTHH:MM"; convert to full ISO so Go can parse it + const dt = name => { const v = val(name); return v ? new Date(v).toISOString() : null; }; + + const body = { + title: val('title'), + priority: val('priority') || 'normal', + site_name: val('site_name'), + address: val('address'), + scheduled_start: dt('scheduled_start'), + scheduled_end: dt('scheduled_end'), + description: val('description'), + instructions: val('instructions'), + access_notes: val('access_notes'), + }; + + if (!body.title) { + this.shadowRoot.querySelector('#err').textContent = 'Title is required.'; + return; + } + + this.shadowRoot.querySelector('#err').textContent = ''; + try { + const wo = this.#woId + ? await api.put(`/work-orders/${this.#woId}`, body) + : await api.post('/work-orders', body); + this.dispatchEvent(new CustomEvent('wo:saved', + { detail: { wo }, bubbles: true, composed: true })); + } catch (err) { + this.shadowRoot.querySelector('#err').textContent = err.message; + } + }); + } +} + +customElements.define('wo-form', WoForm); diff --git a/web/components/wo-list.mjs b/web/components/wo-list.mjs new file mode 100644 index 0000000..c5810bf --- /dev/null +++ b/web/components/wo-list.mjs @@ -0,0 +1,66 @@ +import { api } from '../lib/api.mjs'; + +class WoList extends HTMLElement { + #data = []; + + connectedCallback() { + this.attachShadow({ mode: 'open' }); + this.#load(); + } + + async #load() { + const status = this.getAttribute('filter-status') || ''; + this.#data = await api.get(`/work-orders?status=${status}`) ?? []; + this.#render(); + } + + refresh() { this.#load(); } + + #render() { + this.shadowRoot.innerHTML = ` + + ${this.#data.length === 0 + ? '
No work orders found.
' + : `
${this.#data.map(wo => ` +
+
+
${wo.wo_number}
+
${wo.title}
+
${wo.site_name || '—'} · ${wo.steps_done}/${wo.step_count} steps
+
+ ${wo.status.replace('_',' ')} +
`).join('')} +
`}`; + + this.shadowRoot.querySelectorAll('.card').forEach(c => + c.addEventListener('click', () => + this.dispatchEvent(new CustomEvent('wo:select', + { detail: { id: +c.dataset.id }, bubbles: true, composed: true })))); + } +} + +customElements.define('wo-list', WoList); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0723416 --- /dev/null +++ b/web/index.html @@ -0,0 +1,19 @@ + + + + + + Work Orders + + + + + + + +
+

Connecting to authentication server...

+
+ + + diff --git a/web/lib/api.mjs b/web/lib/api.mjs new file mode 100644 index 0000000..83d81a6 --- /dev/null +++ b/web/lib/api.mjs @@ -0,0 +1,33 @@ +const BASE = '/api'; +let _token = ''; + +export function setToken(t) { _token = t; } +export function getToken() { return _token; } + +async function request(method, path, body, isForm = false) { + const headers = { Authorization: `Bearer ${_token}` }; + if (!isForm && body) headers['Content-Type'] = 'application/json'; + + const res = await fetch(BASE + path, { + method, + headers, + body: body ? (isForm ? body : JSON.stringify(body)) : undefined, + }); + + if (res.status === 401) { + window.dispatchEvent(new CustomEvent('auth:expired')); + return null; + } + + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`); + return json.data; +} + +export const api = { + get: (path) => request('GET', path), + post: (path, body) => request('POST', path, body), + put: (path, body) => request('PUT', path, body), + delete: (path) => request('DELETE', path), + upload: (path, form) => request('POST', path, form, true), +}; diff --git a/web/styles/global.css b/web/styles/global.css new file mode 100644 index 0000000..6190eb6 --- /dev/null +++ b/web/styles/global.css @@ -0,0 +1,104 @@ +:root { + --navy: #0d2137; + --accent: #0a7ea4; + --accent-lt: #14b8d4; + --surface: #ffffff; + --bg: #f0f6fa; + --border: #e2ebf0; + --text: #1a2e3b; + --muted: #64748b; + --danger: #c0392b; + --success: #1d9d6c; + --warning: #e07b39; + --radius: 8px; + --shadow: 0 2px 8px rgba(0,0,0,.08); + + font-family: Calibri, 'Segoe UI', system-ui, sans-serif; + font-size: 16px; + color: var(--text); + background: var(--bg); +} + +body { min-height: 100vh; } + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +button { + cursor: pointer; + border: none; + border-radius: var(--radius); + padding: .5rem 1rem; + background: var(--accent); + color: #fff; + font-weight: 600; + transition: opacity .15s; +} +button:hover { opacity: .85; } +button.secondary { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} + +input, select, textarea { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: .5rem .75rem; + width: 100%; + background: var(--surface); + color: var(--text); + transition: border-color .15s; +} +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--accent); +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + box-shadow: var(--shadow); +} + +.badge { + display: inline-block; + padding: .2rem .6rem; + border-radius: 999px; + font-size: .75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; +} +.badge-draft { background: #e2e8f0; color: #475569; } +.badge-assigned { background: #dbeafe; color: #1d4ed8; } +.badge-scheduled { background: #fef3c7; color: #92400e; } +.badge-in_progress { background: #d1fae5; color: #065f46; } +.badge-pending_review { background: #ede9fe; color: #5b21b6; } +.badge-closed { background: #f3f4f6; color: #6b7280; } + +#app { max-width: 1200px; margin: 0 auto; padding: 0 1rem; } + +nav { + background: var(--navy); + color: #fff; + padding: .75rem 1.5rem; + display: flex; + align-items: center; + gap: 1.5rem; +} +nav .brand { font-weight: 700; font-size: 1.1rem; color: #fff; } +nav a { color: rgba(255,255,255,.8); font-size: .9rem; } +nav a:hover { color: #fff; text-decoration: none; } +nav .spacer { flex: 1; } +nav .user-info { font-size: .85rem; color: rgba(255,255,255,.7); } + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem 0 1rem; +} +.page-header h1 { font-size: 1.4rem; } diff --git a/web/styles/reset.css b/web/styles/reset.css new file mode 100644 index 0000000..8247323 --- /dev/null +++ b/web/styles/reset.css @@ -0,0 +1,6 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { -webkit-text-size-adjust: 100%; } +body { line-height: 1.5; -webkit-font-smoothing: antialiased; } +img, svg { display: block; max-width: 100%; } +input, button, textarea, select { font: inherit; } +p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }