200 lines
6.6 KiB
Go
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))
|
|
}
|