package devicecatalog import ( "context" "strings" "production/internal/dbutil" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5/pgxpool" ) type Handler struct { db *pgxpool.Pool } func NewHandler(db *pgxpool.Pool) *Handler { return &Handler{db: db} } type groupRow struct { ID string `json:"id"` Name string `json:"name"` Prefix string `json:"prefix"` SortOrder int `json:"sort_order"` } type brandRow struct { ID string `json:"id"` Name string `json:"name"` } type faultRow struct { ID string `json:"id"` Label string `json:"label"` } func (h *Handler) ListGroups(c *fiber.Ctx) error { rows, err := h.db.Query(context.Background(), `SELECT id, name, prefix, sort_order FROM device_groups ORDER BY sort_order, name`) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } defer rows.Close() groups := []groupRow{} for rows.Next() { var g groupRow if err := rows.Scan(&g.ID, &g.Name, &g.Prefix, &g.SortOrder); err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } groups = append(groups, g) } return c.JSON(groups) } func (h *Handler) CreateGroup(c *fiber.Ctx) error { var body struct { Name string `json:"name"` Prefix string `json:"prefix"` SortOrder int `json:"sort_order"` } if err := c.BodyParser(&body); err != nil { return c.Status(400).JSON(fiber.Map{"error": "invalid request body"}) } if msg := (groupInput{Name: body.Name, Prefix: body.Prefix}).validate(); msg != "" { return c.Status(400).JSON(fiber.Map{"error": msg}) } name := strings.TrimSpace(body.Name) prefix := strings.ToUpper(strings.TrimSpace(body.Prefix)) var id string err := h.db.QueryRow(context.Background(), `INSERT INTO device_groups (name, prefix, sort_order) VALUES ($1, $2, $3) RETURNING id`, name, prefix, body.SortOrder, ).Scan(&id) if err != nil { if dbutil.IsUniqueViolation(err) { return c.Status(409).JSON(fiber.Map{"error": "group name or prefix already exists"}) } return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } return c.Status(201).JSON(fiber.Map{"id": id, "name": name, "prefix": prefix, "sort_order": body.SortOrder}) } func (h *Handler) UpdateGroup(c *fiber.Ctx) error { id := c.Params("id") var body struct { Name string `json:"name"` Prefix string `json:"prefix"` SortOrder int `json:"sort_order"` } if err := c.BodyParser(&body); err != nil { return c.Status(400).JSON(fiber.Map{"error": "invalid request body"}) } if msg := (groupInput{Name: body.Name, Prefix: body.Prefix}).validate(); msg != "" { return c.Status(400).JSON(fiber.Map{"error": msg}) } name := strings.TrimSpace(body.Name) prefix := strings.ToUpper(strings.TrimSpace(body.Prefix)) tag, err := h.db.Exec(context.Background(), `UPDATE device_groups SET name = $1, prefix = $2, sort_order = $3 WHERE id = $4::uuid`, name, prefix, body.SortOrder, id) if err != nil { if dbutil.IsUniqueViolation(err) { return c.Status(409).JSON(fiber.Map{"error": "group name or prefix already exists"}) } return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } if tag.RowsAffected() == 0 { return c.Status(404).JSON(fiber.Map{"error": "group not found"}) } return c.JSON(fiber.Map{"ok": true}) } func (h *Handler) DeleteGroup(c *fiber.Ctx) error { id := c.Params("id") tag, err := h.db.Exec(context.Background(), `DELETE FROM device_groups WHERE id = $1::uuid`, id) if err != nil { if dbutil.IsFKViolation(err) { return c.Status(409).JSON(fiber.Map{"error": "group is used by existing orders"}) } return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } if tag.RowsAffected() == 0 { return c.Status(404).JSON(fiber.Map{"error": "group not found"}) } return c.JSON(fiber.Map{"ok": true}) } func (h *Handler) ListBrands(c *fiber.Ctx) error { rows, err := h.db.Query(context.Background(), `SELECT id, name FROM device_brands ORDER BY name`) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } defer rows.Close() brands := []brandRow{} for rows.Next() { var b brandRow if err := rows.Scan(&b.ID, &b.Name); err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } brands = append(brands, b) } return c.JSON(brands) } // CreateBrand is open to any staff role (unlike groups, which gate on // owner via main.go's route wiring) — a brand is just a label, adding one // carries no structural risk like a group's order-number prefix does. func (h *Handler) CreateBrand(c *fiber.Ctx) error { var body struct { Name string `json:"name"` } if err := c.BodyParser(&body); err != nil { return c.Status(400).JSON(fiber.Map{"error": "invalid request body"}) } if msg := (brandInput{Name: body.Name}).validate(); msg != "" { return c.Status(400).JSON(fiber.Map{"error": msg}) } name := strings.TrimSpace(body.Name) var id string err := h.db.QueryRow(context.Background(), `INSERT INTO device_brands (name) VALUES ($1) RETURNING id`, name, ).Scan(&id) if err != nil { if dbutil.IsUniqueViolation(err) { return c.Status(409).JSON(fiber.Map{"error": "brand already exists"}) } return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } return c.Status(201).JSON(fiber.Map{"id": id, "name": name}) } // RecentModels suggests device_model values already seen on past orders for // a brand — there's no models catalog table (too many models per brand to // maintain), so this is a lightweight assist instead: whatever staff typed // before for this brand becomes a suggestion next time. func (h *Handler) RecentModels(c *fiber.Ctx) error { brandID := c.Query("brand_id") if brandID == "" { return c.JSON([]string{}) } rows, err := h.db.Query(context.Background(), `SELECT DISTINCT device_model FROM orders WHERE device_brand_id = $1::uuid AND device_model IS NOT NULL AND device_model != '' ORDER BY device_model LIMIT 20`, brandID) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } defer rows.Close() models := []string{} for rows.Next() { var m string if err := rows.Scan(&m); err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } models = append(models, m) } return c.JSON(models) } func (h *Handler) DeleteBrand(c *fiber.Ctx) error { id := c.Params("id") tag, err := h.db.Exec(context.Background(), `DELETE FROM device_brands WHERE id = $1::uuid`, id) if err != nil { if dbutil.IsFKViolation(err) { return c.Status(409).JSON(fiber.Map{"error": "brand is used by existing orders"}) } return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } if tag.RowsAffected() == 0 { return c.Status(404).JSON(fiber.Map{"error": "brand not found"}) } return c.JSON(fiber.Map{"ok": true}) } // ListFaults/CreateFault/DeleteFault manage common_faults — quick-pick // problem descriptions shown as buttons under "Описание проблемы" // (migrations/061_common_faults.sql). Same flat, unscoped, any-staff-can- // add shape as brands above; orders never reference this table, a click // just inserts the label as plain text. func (h *Handler) ListFaults(c *fiber.Ctx) error { rows, err := h.db.Query(context.Background(), `SELECT id, label FROM common_faults ORDER BY label`) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } defer rows.Close() faults := []faultRow{} for rows.Next() { var f faultRow if err := rows.Scan(&f.ID, &f.Label); err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } faults = append(faults, f) } return c.JSON(faults) } func (h *Handler) CreateFault(c *fiber.Ctx) error { var body struct { Label string `json:"label"` } if err := c.BodyParser(&body); err != nil { return c.Status(400).JSON(fiber.Map{"error": "invalid request body"}) } if msg := (faultInput{Label: body.Label}).validate(); msg != "" { return c.Status(400).JSON(fiber.Map{"error": msg}) } label := strings.TrimSpace(body.Label) var id string err := h.db.QueryRow(context.Background(), `INSERT INTO common_faults (label) VALUES ($1) RETURNING id`, label, ).Scan(&id) if err != nil { if dbutil.IsUniqueViolation(err) { return c.Status(409).JSON(fiber.Map{"error": "fault already exists"}) } return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } return c.Status(201).JSON(fiber.Map{"id": id, "label": label}) } func (h *Handler) DeleteFault(c *fiber.Ctx) error { id := c.Params("id") tag, err := h.db.Exec(context.Background(), `DELETE FROM common_faults WHERE id = $1::uuid`, id) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } if tag.RowsAffected() == 0 { return c.Status(404).JSON(fiber.Map{"error": "fault not found"}) } return c.JSON(fiber.Map{"ok": true}) }