570 lines
20 KiB
Go
570 lines
20 KiB
Go
// Package cartridge implements Phase 3 — заправка картриджей. A batch is one
|
||
// drop-off visit (often several cartridges at once, especially for B2B
|
||
// clients on a recurring schedule); each cartridge is its own item with its
|
||
// own status, since some finish before others. See migrations/003_cartridges.sql
|
||
// for why this is separate tables rather than reusing orders, and items.go's
|
||
// Suggest for why refill_count is staff-entered rather than computed.
|
||
package cartridge
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"production/internal/auth"
|
||
"production/internal/authz"
|
||
"production/internal/clientnotify"
|
||
"production/internal/dbutil"
|
||
"production/internal/file"
|
||
"production/internal/inventory"
|
||
"production/internal/loyalty"
|
||
"production/internal/manufacture"
|
||
"production/internal/notification"
|
||
"production/internal/notify"
|
||
"production/internal/ordernum"
|
||
"production/internal/realtime"
|
||
"production/internal/settings"
|
||
|
||
"github.com/gofiber/fiber/v2"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
)
|
||
|
||
var validBatchStatuses = map[string]bool{
|
||
"new": true, "in_progress": true, "ready": true, "completed": true, "cancelled": true,
|
||
}
|
||
|
||
// Mirrors web/src/cartridges/statuses.js's STATUS_LABELS (a separate enum
|
||
// from order's — batches and orders don't share a status lifecycle). Only
|
||
// used for notification text.
|
||
var batchStatusLabels = map[string]string{
|
||
"new": "Новая",
|
||
"in_progress": "В работе",
|
||
"ready": "Готово",
|
||
"completed": "Выдано",
|
||
"cancelled": "Отменено",
|
||
}
|
||
|
||
var validItemStatuses = map[string]bool{
|
||
"pending": true, "in_progress": true, "done": true, "replacement_needed": true,
|
||
}
|
||
|
||
const (
|
||
maxShortFieldLen = 255
|
||
maxLongFieldLen = 5000
|
||
// Caps the batch item count server-side — item fields each carry their
|
||
// own length cap, but an unbounded *count* of items multiplies that into
|
||
// the same MultiCell resource-exhaustion shape security-reviewer flagged
|
||
// for Phase 2 (see order/handler.go). 50 is generous for the stated
|
||
// real-world max of ~15 cartridges per visit.
|
||
maxItemsPerBatch = 50
|
||
// price is rendered via formatMoney (fixed-width), never MultiCell, so it
|
||
// doesn't carry the same DoS shape as the text fields above — this cap is
|
||
// just to reject garbage before it hits the ::numeric cast with a clean
|
||
// 400 instead of a bare 500.
|
||
maxPriceLen = 32
|
||
)
|
||
|
||
type Handler struct {
|
||
db *pgxpool.Pool
|
||
files *file.Handler
|
||
notify *notify.Handler
|
||
clientNotify *clientnotify.Handler
|
||
realtime *realtime.Hub
|
||
inv *inventory.Handler
|
||
mfg *manufacture.Handler
|
||
notifications *notification.Handler
|
||
}
|
||
|
||
func NewHandler(db *pgxpool.Pool, files *file.Handler, notify *notify.Handler, clientNotify *clientnotify.Handler, realtime *realtime.Hub, inv *inventory.Handler, mfg *manufacture.Handler, notifications *notification.Handler) *Handler {
|
||
return &Handler{db: db, files: files, notify: notify, clientNotify: clientNotify, realtime: realtime, inv: inv, mfg: mfg, notifications: notifications}
|
||
}
|
||
|
||
type itemInput struct {
|
||
Model string `json:"model"`
|
||
Color string `json:"color"`
|
||
Tag string `json:"tag"`
|
||
RefillCount int `json:"refill_count"`
|
||
Price *string `json:"price"`
|
||
Note string `json:"note"`
|
||
CartridgeBrandID string `json:"cartridge_brand_id"`
|
||
CartridgeModelID string `json:"cartridge_model_id"`
|
||
// Set when this item comes from a QR/code scan of a previously-labeled
|
||
// cartridge (see recurring.go) — chains the new item onto that same
|
||
// physical cartridge's history instead of starting a fresh one.
|
||
RecurringItemID string `json:"recurring_item_id"`
|
||
}
|
||
|
||
func (it itemInput) validate() string {
|
||
if it.Model == "" {
|
||
return "each item requires a model"
|
||
}
|
||
if utf8.RuneCountInString(it.Model) > maxShortFieldLen || utf8.RuneCountInString(it.Color) > maxShortFieldLen ||
|
||
utf8.RuneCountInString(it.Tag) > maxShortFieldLen {
|
||
return "one of the item fields is too long"
|
||
}
|
||
if utf8.RuneCountInString(it.Note) > maxLongFieldLen {
|
||
return "item note is too long"
|
||
}
|
||
if it.RefillCount < 0 || it.RefillCount > 50 {
|
||
return "refill_count must be between 0 and 50"
|
||
}
|
||
if it.Price != nil && len(*it.Price) > maxPriceLen {
|
||
return "price is too long"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (h *Handler) Create(c *fiber.Ctx) error {
|
||
var body struct {
|
||
ClientID string `json:"client_id"`
|
||
PickupRequired bool `json:"pickup_required"`
|
||
Items []itemInput `json:"items"`
|
||
// Optional — lets the create form assign a master up front instead
|
||
// of requiring a separate UpdateBatch call right after (mirrors
|
||
// order.Create's own assigned_master_id/name pair).
|
||
AssignedMasterID string `json:"assigned_master_id"`
|
||
AssignedMasterName string `json:"assigned_master_name"`
|
||
}
|
||
if err := c.BodyParser(&body); err != nil {
|
||
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
|
||
}
|
||
if body.ClientID == "" {
|
||
return c.Status(400).JSON(fiber.Map{"error": "client_id is required"})
|
||
}
|
||
if utf8.RuneCountInString(body.AssignedMasterName) > maxShortFieldLen {
|
||
return c.Status(400).JSON(fiber.Map{"error": "assigned_master_name is too long"})
|
||
}
|
||
if len(body.Items) == 0 {
|
||
return c.Status(400).JSON(fiber.Map{"error": "at least one item is required"})
|
||
}
|
||
if len(body.Items) > maxItemsPerBatch {
|
||
return c.Status(400).JSON(fiber.Map{"error": "too many items in one batch"})
|
||
}
|
||
for _, it := range body.Items {
|
||
if msg := it.validate(); msg != "" {
|
||
return c.Status(400).JSON(fiber.Map{"error": msg})
|
||
}
|
||
}
|
||
|
||
ctx := context.Background()
|
||
tx, err := h.db.Begin(ctx)
|
||
if err != nil {
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
defer tx.Rollback(ctx)
|
||
|
||
orderNumber, err := ordernum.Next(ctx, h.db, ordernum.CartridgePrefix)
|
||
if err != nil {
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
|
||
var batchID, token string
|
||
err = tx.QueryRow(ctx,
|
||
`INSERT INTO cartridge_batches (client_id, pickup_required, created_by_staff_id, created_by_staff_name, order_number, assigned_master_id, assigned_master_name)
|
||
VALUES ($1::uuid, $2, $3::uuid, $4, $5, $6::uuid, $7) RETURNING id, tracking_token`,
|
||
body.ClientID, body.PickupRequired, auth.StaffID(c), auth.StaffName(c), orderNumber,
|
||
dbutil.NullIfEmpty(body.AssignedMasterID), dbutil.NullIfEmpty(body.AssignedMasterName),
|
||
).Scan(&batchID, &token)
|
||
if err != nil {
|
||
if dbutil.IsFKViolation(err) {
|
||
return c.Status(400).JSON(fiber.Map{"error": "client_id does not exist"})
|
||
}
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
|
||
for i, it := range body.Items {
|
||
color := it.Color
|
||
if color == "" {
|
||
color = "black"
|
||
}
|
||
price := it.Price
|
||
if price != nil && *price == "" {
|
||
price = nil
|
||
}
|
||
_, err = tx.Exec(ctx,
|
||
`INSERT INTO cartridge_items (batch_id, position, model, color, tag, refill_count, price, note, cartridge_brand_id, cartridge_model_id, recurring_item_id)
|
||
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7::numeric, $8, $9::uuid, $10::uuid, $11::uuid)`,
|
||
batchID, i+1, it.Model, color, dbutil.NullIfEmpty(it.Tag), it.RefillCount, price, dbutil.NullIfEmpty(it.Note),
|
||
dbutil.NullIfEmpty(it.CartridgeBrandID), dbutil.NullIfEmpty(it.CartridgeModelID), dbutil.NullIfEmpty(it.RecurringItemID),
|
||
)
|
||
if err != nil {
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
|
||
h.logEvent(ctx, batchID, "status_change", "new", "", true, c)
|
||
|
||
models := make([]string, len(body.Items))
|
||
for i, it := range body.Items {
|
||
models[i] = it.Model
|
||
}
|
||
h.notify.Send(fmt.Sprintf("🆕 Новая партия картриджей (%d шт.): %s\nПринял: %s",
|
||
len(body.Items), strings.Join(models, ", "), auth.StaffName(c)))
|
||
|
||
itemsLabel := fmt.Sprintf("Партия картриджей (%d шт.): %s", len(body.Items), strings.Join(models, ", "))
|
||
createdTrackingURL := settings.TrackingURL(ctx, h.db, token)
|
||
h.clientNotify.Enqueue(clientnotify.Event{
|
||
ClientID: body.ClientID,
|
||
CartridgeBatchID: batchID,
|
||
Trigger: "created",
|
||
DedupeSeed: batchID,
|
||
TGBody: clientnotify.OrderCreated("telegram", itemsLabel, createdTrackingURL),
|
||
SMSBody: clientnotify.OrderCreated("sms", itemsLabel, createdTrackingURL),
|
||
})
|
||
|
||
h.realtime.Broadcast("cartridge_batches")
|
||
|
||
return c.Status(201).JSON(fiber.Map{"id": batchID, "tracking_token": token, "order_number": orderNumber})
|
||
}
|
||
|
||
type batchRow struct {
|
||
ID string `json:"id"`
|
||
ClientID string `json:"client_id"`
|
||
TrackingToken string `json:"tracking_token"`
|
||
OrderNumber *string `json:"order_number"`
|
||
Status string `json:"status"`
|
||
PickupRequired bool `json:"pickup_required"`
|
||
AssignedMasterID *string `json:"assigned_master_id"`
|
||
AssignedMasterName *string `json:"assigned_master_name"`
|
||
ItemCount int `json:"item_count"`
|
||
ItemsDone int `json:"items_done"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
const batchListColumns = `
|
||
cb.id, cb.client_id, cb.tracking_token, cb.order_number, cb.status, cb.pickup_required,
|
||
cb.assigned_master_id, cb.assigned_master_name,
|
||
COUNT(ci.id), COUNT(ci.id) FILTER (WHERE ci.status = 'done'),
|
||
cb.created_at, cb.updated_at`
|
||
|
||
// List is the Kanban feed for /cartridges — one card per batch, with an
|
||
// items-done/total count so staff see progress without opening it.
|
||
func (h *Handler) List(c *fiber.Ctx) error {
|
||
status := c.Query("status")
|
||
clientID := c.Query("client_id")
|
||
|
||
query := `SELECT ` + batchListColumns + `
|
||
FROM cartridge_batches cb LEFT JOIN cartridge_items ci ON ci.batch_id = cb.id
|
||
WHERE ($1 = '' OR cb.status = $1) AND ($2 = '' OR cb.client_id::text = $2)`
|
||
args := []any{status, clientID}
|
||
// Same scoping as order.List — a master only ever sees their own
|
||
// batches and unassigned ones.
|
||
if !auth.HasPermission(c, "unscoped") {
|
||
query += fmt.Sprintf(" AND (cb.assigned_master_id IS NULL OR cb.assigned_master_id = $%d::uuid)", len(args)+1)
|
||
args = append(args, auth.StaffID(c))
|
||
}
|
||
query += ` GROUP BY cb.id ORDER BY cb.created_at DESC LIMIT 500`
|
||
|
||
rows, err := h.db.Query(context.Background(), query, args...)
|
||
if err != nil {
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []batchRow
|
||
for rows.Next() {
|
||
var r batchRow
|
||
if err := rows.Scan(&r.ID, &r.ClientID, &r.TrackingToken, &r.OrderNumber, &r.Status, &r.PickupRequired,
|
||
&r.AssignedMasterID, &r.AssignedMasterName, &r.ItemCount, &r.ItemsDone, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return c.JSON(out)
|
||
}
|
||
|
||
type itemRow struct {
|
||
ID string `json:"id"`
|
||
Position int `json:"position"`
|
||
Model string `json:"model"`
|
||
Color string `json:"color"`
|
||
Tag *string `json:"tag"`
|
||
RefillCount int `json:"refill_count"`
|
||
Status string `json:"status"`
|
||
Price *string `json:"price"`
|
||
Note *string `json:"note"`
|
||
CartridgeModelID *string `json:"cartridge_model_id"`
|
||
TonerGramsUsed *int `json:"toner_grams_used"`
|
||
RecurringItemID *string `json:"recurring_item_id"`
|
||
}
|
||
|
||
type eventRow struct {
|
||
ID string `json:"id"`
|
||
Type string `json:"type"`
|
||
Body *string `json:"body"`
|
||
FileKey *string `json:"file_key"`
|
||
IsPublic bool `json:"is_public"`
|
||
StaffName string `json:"staff_name"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
func (h *Handler) Get(c *fiber.Ctx) error {
|
||
id := c.Params("id")
|
||
ctx := context.Background()
|
||
|
||
var r batchRow
|
||
err := h.db.QueryRow(ctx,
|
||
`SELECT `+batchListColumns+`
|
||
FROM cartridge_batches cb LEFT JOIN cartridge_items ci ON ci.batch_id = cb.id
|
||
WHERE cb.id = $1::uuid
|
||
GROUP BY cb.id`, id,
|
||
).Scan(&r.ID, &r.ClientID, &r.TrackingToken, &r.OrderNumber, &r.Status, &r.PickupRequired,
|
||
&r.AssignedMasterID, &r.AssignedMasterName, &r.ItemCount, &r.ItemsDone, &r.CreatedAt, &r.UpdatedAt)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return c.Status(404).JSON(fiber.Map{"error": "batch not found"})
|
||
}
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
if !authz.CanAccessAssigned(auth.StaffPermissions(c), r.AssignedMasterID, auth.StaffID(c)) {
|
||
return c.Status(403).JSON(fiber.Map{"error": "not assigned to you"})
|
||
}
|
||
|
||
items, err := h.items(ctx, id)
|
||
if err != nil {
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
events, err := h.events(ctx, id, false)
|
||
if err != nil {
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
|
||
return c.JSON(fiber.Map{"batch": r, "items": items, "events": events})
|
||
}
|
||
|
||
func (h *Handler) items(ctx context.Context, batchID string) ([]itemRow, error) {
|
||
rows, err := h.db.Query(ctx,
|
||
`SELECT id, position, model, color, tag, refill_count, status, price::text, note, cartridge_model_id, toner_grams_used, recurring_item_id
|
||
FROM cartridge_items WHERE batch_id = $1::uuid ORDER BY position`, batchID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []itemRow
|
||
for rows.Next() {
|
||
var it itemRow
|
||
if err := rows.Scan(&it.ID, &it.Position, &it.Model, &it.Color, &it.Tag, &it.RefillCount, &it.Status, &it.Price, &it.Note,
|
||
&it.CartridgeModelID, &it.TonerGramsUsed, &it.RecurringItemID); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, it)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (h *Handler) events(ctx context.Context, batchID string, publicOnly bool) ([]eventRow, error) {
|
||
query := `SELECT id, type, body, file_key, is_public, staff_name, created_at FROM batch_events WHERE batch_id = $1::uuid`
|
||
if publicOnly {
|
||
query += ` AND is_public = true`
|
||
}
|
||
query += ` ORDER BY created_at`
|
||
|
||
rows, err := h.db.Query(ctx, query, batchID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []eventRow
|
||
for rows.Next() {
|
||
var e eventRow
|
||
if err := rows.Scan(&e.ID, &e.Type, &e.Body, &e.FileKey, &e.IsPublic, &e.StaffName, &e.CreatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (h *Handler) UpdateStatus(c *fiber.Ctx) error {
|
||
id := c.Params("id")
|
||
var body struct {
|
||
Status string `json:"status"`
|
||
}
|
||
if err := c.BodyParser(&body); err != nil || !validBatchStatuses[body.Status] {
|
||
return c.Status(400).JSON(fiber.Map{"error": "status must be one of: new, in_progress, ready, completed, cancelled"})
|
||
}
|
||
if err := h.assertBatchAccess(context.Background(), id, c); err != nil {
|
||
return err
|
||
}
|
||
|
||
ctx := context.Background()
|
||
var models *string
|
||
var clientID, trackingToken, itemsTotal string
|
||
err := h.db.QueryRow(ctx,
|
||
`UPDATE cartridge_batches SET status = $1, updated_at = NOW() WHERE id = $2::uuid
|
||
RETURNING (SELECT string_agg(model, ', ') FROM cartridge_items WHERE batch_id = $2::uuid), client_id, tracking_token,
|
||
(SELECT COALESCE(SUM(price), 0) FROM cartridge_items WHERE batch_id = $2::uuid)::text`,
|
||
body.Status, id,
|
||
).Scan(&models, &clientID, &trackingToken, &itemsTotal)
|
||
if err != nil {
|
||
if err == pgx.ErrNoRows {
|
||
return c.Status(404).JSON(fiber.Map{"error": "batch not found"})
|
||
}
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
|
||
eventID := h.logEvent(ctx, id, "status_change", body.Status, "", true, c)
|
||
|
||
label := batchStatusLabels[body.Status]
|
||
if label == "" {
|
||
label = body.Status
|
||
}
|
||
itemsLabel := fmt.Sprintf("Партия картриджей (%s)", ptrToStr(models))
|
||
h.notify.Send(fmt.Sprintf("🔄 %s — статус: %s", itemsLabel, label))
|
||
|
||
if body.Status == "completed" {
|
||
loyalty.AccrueFromPrice(ctx, h.db, h.clientNotify, clientID, "", id, itemsTotal, auth.StaffID(c), auth.StaffName(c))
|
||
}
|
||
|
||
if eventID != "" {
|
||
trigger := "status_changed"
|
||
trackingURL := settings.TrackingURL(ctx, h.db, trackingToken)
|
||
tgBody := clientnotify.StatusChanged("telegram", itemsLabel, label, trackingURL)
|
||
smsBody := clientnotify.StatusChanged("sms", itemsLabel, label, trackingURL)
|
||
if body.Status == "ready" {
|
||
trigger = "ready"
|
||
tgBody = clientnotify.Ready("telegram", itemsLabel, trackingURL)
|
||
smsBody = clientnotify.Ready("sms", itemsLabel, trackingURL)
|
||
}
|
||
h.clientNotify.Enqueue(clientnotify.Event{
|
||
ClientID: clientID,
|
||
CartridgeBatchID: id,
|
||
Trigger: trigger,
|
||
DedupeSeed: eventID,
|
||
TGBody: tgBody,
|
||
SMSBody: smsBody,
|
||
})
|
||
}
|
||
|
||
h.realtime.Broadcast("cartridge_batches")
|
||
|
||
return c.JSON(fiber.Map{"ok": true})
|
||
}
|
||
|
||
func ptrToStr(s *string) string {
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
return *s
|
||
}
|
||
|
||
// UpdateBatch handles master assignment — no timeline event, matches
|
||
// order.Update's rationale (bookkeeping, not a milestone).
|
||
func (h *Handler) UpdateBatch(c *fiber.Ctx) error {
|
||
id := c.Params("id")
|
||
var body struct {
|
||
AssignedMasterID *string `json:"assigned_master_id"`
|
||
AssignedMasterName *string `json:"assigned_master_name"`
|
||
}
|
||
if err := c.BodyParser(&body); err != nil {
|
||
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
|
||
}
|
||
if body.AssignedMasterName != nil && utf8.RuneCountInString(*body.AssignedMasterName) > maxShortFieldLen {
|
||
return c.Status(400).JSON(fiber.Map{"error": "assigned_master_name is too long"})
|
||
}
|
||
if err := h.assertBatchAccess(context.Background(), id, c); err != nil {
|
||
return err
|
||
}
|
||
|
||
tag, err := h.db.Exec(context.Background(),
|
||
`UPDATE cartridge_batches SET
|
||
assigned_master_id = COALESCE($1::uuid, assigned_master_id),
|
||
assigned_master_name = COALESCE($2, assigned_master_name),
|
||
updated_at = NOW()
|
||
WHERE id = $3::uuid`,
|
||
body.AssignedMasterID, body.AssignedMasterName, 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": "batch not found"})
|
||
}
|
||
h.realtime.Broadcast("cartridge_batches")
|
||
return c.JSON(fiber.Map{"ok": true})
|
||
}
|
||
|
||
func (h *Handler) AddComment(c *fiber.Ctx) error {
|
||
id := c.Params("id")
|
||
var body struct {
|
||
Body string `json:"body"`
|
||
IsPublic bool `json:"is_public"`
|
||
}
|
||
if err := c.BodyParser(&body); err != nil || body.Body == "" {
|
||
return c.Status(400).JSON(fiber.Map{"error": "body is required"})
|
||
}
|
||
if utf8.RuneCountInString(body.Body) > maxLongFieldLen {
|
||
return c.Status(400).JSON(fiber.Map{"error": "comment is too long"})
|
||
}
|
||
|
||
if err := h.assertBatchAccess(context.Background(), id, c); err != nil {
|
||
return err
|
||
}
|
||
h.logEvent(context.Background(), id, "comment", body.Body, "", body.IsPublic, c)
|
||
return c.Status(201).JSON(fiber.Map{"ok": true})
|
||
}
|
||
|
||
func (h *Handler) AddPhoto(c *fiber.Ctx) error {
|
||
id := c.Params("id")
|
||
if err := h.assertBatchAccess(context.Background(), id, c); err != nil {
|
||
return err
|
||
}
|
||
|
||
fh, err := c.FormFile("file")
|
||
if err != nil {
|
||
return c.Status(400).JSON(fiber.Map{"error": "file required"})
|
||
}
|
||
if fh.Size > 20*1024*1024 {
|
||
return c.Status(413).JSON(fiber.Map{"error": "file too large (max 20MB)"})
|
||
}
|
||
f, err := fh.Open()
|
||
if err != nil {
|
||
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
||
}
|
||
defer f.Close()
|
||
|
||
key, err := h.files.Put(context.Background(), fh.Filename, fh.Size, f)
|
||
if err != nil {
|
||
return c.Status(415).JSON(fiber.Map{"error": err.Error()})
|
||
}
|
||
|
||
// Opt-in, matching AddComment's own default below — an omitted field
|
||
// (missing frontend param, stray manual request) must never make a
|
||
// photo public by accident.
|
||
isPublic := c.FormValue("is_public") == "true"
|
||
h.logEvent(context.Background(), id, "photo", "", key, isPublic, c)
|
||
return c.Status(201).JSON(fiber.Map{"key": key})
|
||
}
|
||
|
||
// assertBatchAccess 404s if the batch doesn't exist and 403s if the calling
|
||
// staff member (anyone lacking the "unscoped" permission — see
|
||
// authz.CanAccessAssigned) isn't allowed to touch it.
|
||
func (h *Handler) assertBatchAccess(ctx context.Context, id string, c *fiber.Ctx) error {
|
||
return authz.CheckBatchAccess(ctx, h.db, id, auth.StaffPermissions(c), auth.StaffID(c))
|
||
}
|
||
|
||
// logEvent returns the new batch_events row's id (empty on failure) — see
|
||
// order.Handler.logEvent's doc comment for why UpdateStatus needs it.
|
||
func (h *Handler) logEvent(ctx context.Context, batchID, eventType, body, fileKey string, isPublic bool, c *fiber.Ctx) string {
|
||
var id string
|
||
if err := h.db.QueryRow(ctx,
|
||
`INSERT INTO batch_events (batch_id, type, body, file_key, is_public, staff_id, staff_name)
|
||
VALUES ($1::uuid, $2, $3, $4, $5, $6::uuid, $7) RETURNING id`,
|
||
batchID, eventType, dbutil.NullIfEmpty(body), dbutil.NullIfEmpty(fileKey), isPublic, auth.StaffID(c), auth.StaffName(c),
|
||
).Scan(&id); err != nil {
|
||
log.Printf("cartridge: logEvent batch=%s type=%s: %v", batchID, eventType, err)
|
||
}
|
||
return id
|
||
}
|