Initialize work order management system with database schema, API handlers, web client, and Docker configuration.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
func Me(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, http.StatusOK, userFromCtx(r))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user