Files
aura-crm/production/backend/internal/pcbuilder/handler.go
T

200 lines
6.6 KiB
Go

package pcbuilder
import (
"context"
"encoding/json"
"strings"
"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 componentRow struct {
ID string `json:"id"`
Name string `json:"name"`
SKU string `json:"sku"`
SalePrice *string `json:"sale_price"`
Spec Spec `json:"spec"`
QtyOnHand int `json:"qty_on_hand"`
Source string `json:"source,omitempty"` // "" = our own parts stock; else the supplier site name (see internal/scraper)
ProductURL string `json:"product_url,omitempty"` // set only for external-source rows
}
// extIDPrefix marks a component id as belonging to external_components
// rather than parts, so Check (below) knows which table to resolve it
// against without a second lookup. Public/staff pickers never construct
// these themselves — they're echoed back verbatim from what ListComponents
// returned.
const extIDPrefix = "ext:"
// ListComponents is public — same "just enough for the public form,
// nothing from the authenticated side" stance as settings.PublicInfo:
// only retail-tagged parts with actual stock, only name/sku/price/spec,
// never cost basis, supplier, or non-retail rows. Used by both the public
// /pc-builder page and (indirectly, staff already gets the richer
// authenticated /api/parts list) as the one place both frontends can pull
// the same catalog projection from without duplicating a second query.
func (h *Handler) ListComponents(c *fiber.Ctx) error {
componentType := c.Query("type")
if componentType == "" || TypeLabels[componentType] == "" {
return c.Status(400).JSON(fiber.Map{"error": "type must be one of: " + joinTypes()})
}
rows, err := h.db.Query(context.Background(), `
SELECT p.id, p.name, p.sku, p.sale_price::text, COALESCE(p.pc_spec, '{}'::jsonb),
COALESCE(SUM(b.qty_remaining), 0) - COALESCE(SUM(b.qty_reserved), 0) AS qty_on_hand
FROM parts p
LEFT JOIN stock_batches b ON b.part_id = p.id
WHERE p.is_retail = true AND p.pc_component_type = $1
GROUP BY p.id
HAVING COALESCE(SUM(b.qty_remaining), 0) - COALESCE(SUM(b.qty_reserved), 0) > 0
ORDER BY p.name`, componentType)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
out := []componentRow{}
for rows.Next() {
var r componentRow
var specRaw []byte
var qty int64
if err := rows.Scan(&r.ID, &r.Name, &r.SKU, &r.SalePrice, &specRaw, &qty); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
_ = json.Unmarshal(specRaw, &r.Spec)
r.QtyOnHand = int(qty)
out = append(out, r)
}
extRows, err := h.db.Query(context.Background(), `
SELECT id, source, name, price::text, spec, product_url
FROM external_components
WHERE pc_component_type = $1 AND in_stock = true
ORDER BY price`, componentType)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer extRows.Close()
for extRows.Next() {
var r componentRow
var id, priceText string
var specRaw []byte
if err := extRows.Scan(&id, &r.Source, &r.Name, &priceText, &specRaw, &r.ProductURL); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
r.ID = extIDPrefix + id
r.SalePrice = &priceText
_ = json.Unmarshal(specRaw, &r.Spec)
r.QtyOnHand = 1 // external stock isn't a real count — just "available"
out = append(out, r)
}
return c.JSON(out)
}
func joinTypes() string {
out := ""
for i, t := range AllTypes {
if i > 0 {
out += ", "
}
out += t
}
return out
}
type multiItem struct {
ID string `json:"id"`
Qty int `json:"qty"`
}
type checkInput struct {
// Selection maps component type -> part id, e.g. {"cpu": "<uuid>",
// "gpu": "<uuid>"} — every single-quantity slot (everything except
// ram/storage). Missing keys mean that slot is still empty in the
// build.
Selection map[string]string `json:"selection"`
// MultiSelection covers the two multi-quantity slots — {"ram": [{"id":
// "<uuid>", "qty": 2}], "storage": [...]}. A part id repeated across
// rows is legal (two separate line items of the same part); qty <= 0
// is treated as an empty row and skipped, same "stale/incomplete input
// degrades to nothing" stance the rest of this handler already takes.
MultiSelection map[string][]multiItem `json:"multi_selection"`
}
// resolveComponent re-fetches a part's real spec/price from the DB rather
// than trusting whatever the client sent, so a tampered request can't lie
// about compatibility or price — shared by both Selection and
// MultiSelection resolution below. Only ever looks at retail-tagged parts;
// an id for a non-retail repair part (or a stale/foreign id) just returns
// nil, not an error.
func (h *Handler) resolveComponent(componentType, id string) *Component {
if TypeLabels[componentType] == "" || id == "" {
return nil
}
var comp Component
var specRaw []byte
var err error
if extID, isExt := strings.CutPrefix(id, extIDPrefix); isExt {
err = h.db.QueryRow(context.Background(), `
SELECT id::text, name, price::text, spec
FROM external_components WHERE id = $1::uuid AND pc_component_type = $2`,
extID, componentType,
).Scan(&comp.ID, &comp.Name, &comp.Price, &specRaw)
comp.ID = extIDPrefix + comp.ID
} else {
err = h.db.QueryRow(context.Background(), `
SELECT id, name, COALESCE(sale_price, 0)::text, COALESCE(pc_spec, '{}'::jsonb)
FROM parts WHERE id = $1::uuid AND is_retail = true AND pc_component_type = $2`,
id, componentType,
).Scan(&comp.ID, &comp.Name, &comp.Price, &specRaw)
}
if err != nil {
return nil
}
_ = json.Unmarshal(specRaw, &comp.Spec)
comp.Type = componentType
return &comp
}
// Check is public — same reasoning as ListComponents: a build-in-progress
// is browsing state, not an authenticated action, and the specs it reads
// back are the same public projection ListComponents already exposes.
func (h *Handler) Check(c *fiber.Ctx) error {
var body checkInput
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
by := map[string]*Component{}
for componentType, id := range body.Selection {
if comp := h.resolveComponent(componentType, id); comp != nil {
by[componentType] = comp
}
}
multi := map[string][]MultiEntry{}
for componentType, items := range body.MultiSelection {
for _, item := range items {
if item.Qty <= 0 {
continue
}
if comp := h.resolveComponent(componentType, item.ID); comp != nil {
multi[componentType] = append(multi[componentType], MultiEntry{Component: comp, Qty: item.Qty})
}
}
}
return c.JSON(Check(by, multi))
}