# Work Order System — Technical Build Plan **Stack:** Go (backend API) · Pure JavaScript + Web Components (frontend) · SQL Server (MSSQL) · Docker --- ## Table of Contents 1. [Architecture Overview](#1-architecture-overview) 2. [Project Structure](#2-project-structure) 3. [Database Schema](#3-database-schema) 4. [Go Backend](#4-go-backend) 5. [REST API Design](#5-rest-api-design) 6. [Frontend — Web Components](#6-frontend--web-components) 7. [File & Photo Handling](#7-file--photo-handling) 8. [Maps & Location](#8-maps--location) 9. [Authentication & Authorization](#9-authentication--authorization) 10. [Docker Deployment](#10-docker-deployment) 11. [Phased Build Plan](#11-phased-build-plan) 12. [Development Conventions](#12-development-conventions) --- ## 1. Architecture Overview ``` ┌─────────────────────────────────────────────────────────┐ │ Browser Client │ │ ┌────────────────────────────────────────────────────┐ │ │ │ Pure JS + Web Components SPA │ │ │ │ │ │ │ └───────────────────┬────────────────────────────────┘ │ └──────────────────────│──────────────────────────────────┘ │ REST/JSON over HTTPS ┌──────────────────────▼──────────────────────────────────┐ │ Go API Server │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Router │ │ Handlers │ │ Service │ │ │ │ (chi) │ │ │ │ Layer │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Auth │ │ Upload │ │ Spatial │ │ │ │ (JWT) │ │ Handler │ │ Utils │ │ │ └──────────┘ └──────────┘ └──────────┘ │ └──────────────────────┬──────────────────────────────────┘ │ sqlx / database/sql ┌──────────────────────▼──────────────────────────────────┐ │ SQL Server (MSSQL) │ │ work_orders · wo_resources · wo_steps · wo_attachments │ │ wo_accounting · wo_parents · users · resource_registry │ └─────────────────────────────────────────────────────────┘ ``` **Key decisions:** - **No framework on the frontend** — Custom Elements v1, Shadow DOM, ES Modules. Works in all modern browsers with zero build step. - **chi router** for Go — lightweight, idiomatic, stdlib-compatible middleware. - **sqlx** for DB access — thin wrapper over `database/sql`, named params, struct scanning. - **SQL Server** — matches your existing infrastructure; geography type for spatial data. - **Multipart uploads** stored to local volume (or swap for Azure Blob / S3 later). - **JWT** stateless auth — fits well with a SPA + API split. --- ## 2. Project Structure ``` workorder/ ├── cmd/ │ └── server/ │ └── main.go # Entry point, wires everything together ├── internal/ │ ├── api/ │ │ ├── router.go # chi router setup, middleware chain │ │ ├── middleware.go # JWT, CORS, logging, recovery │ │ └── handlers/ │ │ ├── workorder.go # WO CRUD handlers │ │ ├── resource.go # People / vehicle / equipment assignment │ │ ├── step.go # Checklist steps │ │ ├── attachment.go # Photo & file upload │ │ ├── accounting.go # Accounting code endpoints │ │ ├── parent.go # Parent record linking (polymorphic) │ │ └── auth.go # Login / token refresh │ ├── service/ │ │ ├── workorder.go # Business logic layer │ │ ├── resource.go │ │ ├── notification.go # Email/push when WO assigned │ │ └── spatial.go # Geocoding, distance calc │ ├── repository/ │ │ ├── workorder.go # SQL queries — work orders │ │ ├── resource.go # SQL queries — resources │ │ ├── step.go │ │ ├── attachment.go │ │ └── db.go # sqlx connection pool setup │ ├── model/ │ │ ├── workorder.go # Structs: WorkOrder, WorkOrderSummary │ │ ├── resource.go # Person, Vehicle, Equipment, Material │ │ ├── step.go # Step, StepCompletion │ │ ├── attachment.go # Attachment │ │ ├── accounting.go # AccountingCode │ │ └── auth.go # User, Claims │ └── config/ │ └── config.go # Env-based config (DB DSN, JWT secret, upload path) ├── web/ # Frontend — served as static files by Go │ ├── index.html # Shell — loads the app component │ ├── app.mjs # component, client-side router │ ├── components/ │ │ ├── wo-list.mjs # Work order list / search │ │ ├── wo-form.mjs # Create / edit work order │ │ ├── wo-detail.mjs # Read-only detail view │ │ ├── wo-checklist.mjs # Step checklist with check-off │ │ ├── wo-resource-panel.mjs # Assign people, vehicles, equipment │ │ ├── wo-photo-panel.mjs # Photo capture and gallery │ │ ├── wo-map.mjs # Embedded map + directions │ │ ├── wo-accounting.mjs # Accounting code fields │ │ ├── wo-status-badge.mjs # Status pill (reusable) │ │ └── wo-timeline.mjs # Activity / audit log │ ├── lib/ │ │ ├── api.mjs # Fetch wrapper, auth header injection │ │ ├── router.mjs # Tiny hash/history router │ │ ├── store.mjs # Lightweight reactive state (no Redux) │ │ └── utils.mjs # Formatters, validators │ └── styles/ │ ├── global.css # CSS custom properties (design tokens) │ └── reset.css ├── uploads/ # Stored attachments (bind-mounted in Docker) ├── migrations/ │ ├── 001_initial_schema.sql │ ├── 002_resource_registry.sql │ └── 003_spatial_fields.sql ├── docker-compose.yml ├── Dockerfile └── .env.example ``` --- ## 3. Database Schema ### Core Tables ```sql -- ── 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), -- Rich text / markdown status NVARCHAR(30) NOT NULL DEFAULT 'draft', -- draft | assigned | scheduled | in_progress | pending_review | closed priority NVARCHAR(10) NOT NULL DEFAULT 'normal', -- low | normal | high | urgent scheduled_start DATETIME2, scheduled_end DATETIME2, actual_start DATETIME2, actual_end DATETIME2, -- Location site_name NVARCHAR(200), address NVARCHAR(400), lat DECIMAL(10,7), lng DECIMAL(10,7), access_notes NVARCHAR(MAX), -- Gate codes, access roads -- Polymorphic parent parent_type NVARCHAR(50), -- 'project' | 'ticket' | 'service_order' | NULL parent_id INT, -- Audit created_by INT NOT NULL, created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), closed_at DATETIME2, closed_by INT, 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 (master lists) ───────────────────────────────────────── 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), -- 'ft', 'each', 'box' part_number NVARCHAR(100), active BIT NOT NULL DEFAULT 1 ); -- ── Work Order Resource Assignments ─────────────────────────────────────────── 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, -- 'person' | 'vehicle' | 'equipment' | 'material' resource_id INT NOT NULL, quantity DECIMAL(10,2), -- For materials 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 INT, completed_at DATETIME2, notes NVARCHAR(MAX) -- Field notes added at completion ); -- ── Attachments (photos, docs) ──────────────────────────────────────────────── 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), -- Optional: attach to a specific step file_name NVARCHAR(500) NOT NULL, file_path NVARCHAR(1000) NOT NULL, -- Relative path under /uploads file_type NVARCHAR(100), -- MIME type file_size BIGINT, caption NVARCHAR(500), phase NVARCHAR(20), -- 'before' | 'during' | 'after' lat DECIMAL(10,7), -- Geo-tag from EXIF or device lng DECIMAL(10,7), uploaded_by INT 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, -- 'gl_account' | 'cost_center' | 'wbs' | 'billing_ref' 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 INT 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_wo_resources ON wo_resources (wo_id, resource_type); CREATE INDEX ix_wo_attachments ON wo_attachments (wo_id); ``` --- ## 4. Go Backend ### `cmd/server/main.go` ```go package main import ( "log" "net/http" "github.com/yourorg/workorder/internal/api" "github.com/yourorg/workorder/internal/config" "github.com/yourorg/workorder/internal/repository" ) func main() { cfg := config.Load() // reads .env / environment variables db, err := repository.Connect(cfg.DBDSN) if err != nil { log.Fatalf("db connect: %v", err) } defer db.Close() r := api.NewRouter(cfg, db) log.Printf("listening on %s", cfg.Addr) if err := http.ListenAndServe(cfg.Addr, r); err != nil { log.Fatal(err) } } ``` ### `internal/config/config.go` ```go package config import "os" type Config struct { Addr string DBDSN string JWTSecret string UploadPath string BaseURL string } func Load() *Config { return &Config{ Addr: env("ADDR", ":8080"), DBDSN: env("DB_DSN", ""), JWTSecret: env("JWT_SECRET", "change-me"), UploadPath: env("UPLOAD_PATH", "./uploads"), BaseURL: env("BASE_URL", "http://localhost:8080"), } } func env(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } ``` ### `internal/api/router.go` ```go package api import ( "net/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/yourorg/workorder/internal/api/handlers" mw "github.com/yourorg/workorder/internal/api/middleware" "github.com/yourorg/workorder/internal/config" "github.com/jmoiron/sqlx" ) func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler { r := chi.NewRouter() // Global middleware r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.RealIP) r.Use(mw.CORS) // Serve frontend static files r.Handle("/*", http.FileServer(http.Dir("./web"))) // Auth routes (no JWT required) r.Post("/api/auth/login", handlers.NewAuthHandler(cfg, db).Login) r.Post("/api/auth/refresh", handlers.NewAuthHandler(cfg, db).Refresh) // Protected API routes r.Group(func(r chi.Router) { r.Use(mw.JWT(cfg.JWTSecret)) wo := handlers.NewWorkOrderHandler(db) 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) 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) 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) att := handlers.NewAttachmentHandler(cfg, db) 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) // Resource registries (master lists for assignment pickers) r.Get("/api/registry/people", handlers.RegistryPeople(db)) r.Get("/api/registry/vehicles", handlers.RegistryVehicles(db)) r.Get("/api/registry/equipment", handlers.RegistryEquipment(db)) r.Get("/api/registry/materials", handlers.RegistryMaterials(db)) }) return r } ``` ### `internal/model/workorder.go` ```go 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 int `db:"created_by" json:"created_by"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // Enriched view returned by GET /work-orders/{id} type WorkOrderDetail struct { WorkOrder Resources []AssignedResource `json:"resources"` Steps []Step `json:"steps"` Attachments []Attachment `json:"attachments"` Accounting []AccountingCode `json:"accounting"` } 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"` } ``` ### `internal/repository/workorder.go` ```go package repository import ( "context" "github.com/jmoiron/sqlx" "github.com/yourorg/workorder/internal/model" ) type WorkOrderRepo struct{ db *sqlx.DB } func NewWorkOrderRepo(db *sqlx.DB) *WorkOrderRepo { return &WorkOrderRepo{db: db} } func (r *WorkOrderRepo) List(ctx context.Context, status, search string) ([]model.WorkOrderListItem, error) { 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, SUM(CAST(s.completed AS INT)) 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 (@status = '' OR wo.status = @status) AND (@search = '' OR wo.title LIKE '%' + @search + '%' OR wo.wo_number LIKE '%' + @search + '%' OR wo.site_name LIKE '%' + @search + '%') 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{} err := r.db.SelectContext(ctx, &rows, query, sqlx.Named("status", status), sqlx.Named("search", search), ) return rows, err } func (r *WorkOrderRepo) GetByID(ctx context.Context, id int) (*model.WorkOrder, error) { var wo model.WorkOrder err := r.db.GetContext(ctx, &wo, `SELECT * FROM work_orders WHERE id = @p1`, id) if err != nil { return nil, err } return &wo, nil } func (r *WorkOrderRepo) Create(ctx context.Context, wo *model.WorkOrder) (int, error) { var id int err := r.db.QueryRowContext(ctx, ` 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 (@title, @description, @instructions, @status, @priority, @scheduled_start, @scheduled_end, @site_name, @address, @lat, @lng, @access_notes, @parent_type, @parent_id, @created_by, GETUTCDATE())`, sqlx.Named("title", wo.Title), // ... remaining named params ).Scan(&id) return id, err } func (r *WorkOrderRepo) UpdateStatus(ctx context.Context, id int, status string, userID int) error { _, err := r.db.ExecContext(ctx, ` UPDATE work_orders SET status = @p1, updated_at = GETUTCDATE(), actual_start = CASE WHEN @p1 = 'in_progress' AND actual_start IS NULL THEN GETUTCDATE() ELSE actual_start END, actual_end = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE actual_end END, closed_at = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE closed_at END, closed_by = CASE WHEN @p1 = 'closed' THEN @p2 ELSE closed_by END WHERE id = @p3`, status, userID, id) return err } ``` --- ## 5. REST API Design | Method | Path | Description | |--------|------|-------------| | POST | `/api/auth/login` | Login, returns JWT | | GET | `/api/work-orders?status=&search=&page=` | Paginated list | | POST | `/api/work-orders` | Create work order | | GET | `/api/work-orders/{id}` | Full detail (WO + resources + steps + photos + accounting) | | PUT | `/api/work-orders/{id}` | Update work order fields | | PUT | `/api/work-orders/{id}/status` | Status transition with audit | | GET | `/api/work-orders/{id}/resources` | List assigned resources | | POST | `/api/work-orders/{id}/resources` | Assign a resource | | DELETE | `/api/work-orders/{id}/resources/{rid}` | Remove assignment | | GET | `/api/work-orders/{id}/steps` | List checklist steps | | POST | `/api/work-orders/{id}/steps` | Add a step | | PUT | `/api/work-orders/{id}/steps/{sid}` | Edit step | | POST | `/api/work-orders/{id}/steps/{sid}/complete` | Check off step | | POST | `/api/work-orders/{id}/attachments` | Upload photo/file (multipart) | | GET | `/api/work-orders/{id}/attachments` | List attachments | | PUT | `/api/work-orders/{id}/accounting` | Upsert accounting codes | | GET | `/api/registry/people` | Lookup: people for assignment picker | | GET | `/api/registry/vehicles` | Lookup: vehicles | | GET | `/api/registry/equipment` | Lookup: equipment | | GET | `/api/registry/materials` | Lookup: materials | ### Standard Response Envelope ```json { "data": { ... }, "meta": { "page": 1, "per_page": 25, "total": 142 }, "error": null } ``` ### Status Transition Rules (enforced server-side) ``` draft → assigned → scheduled → in_progress → pending_review → closed ↘ (re-open) ↗ ``` --- ## 6. Frontend — Web Components ### Design Principles - **No build step** — plain ` ``` Zero build tooling. Zero npm for the frontend. Open in browser and go.