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

235 lines
8.3 KiB
Go

package client
import (
"context"
"fmt"
"time"
"unicode/utf8"
"production/internal/auth"
"production/internal/dbutil"
"production/internal/smsgw"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Length caps exist mainly so client/order text can't be used to force
// unbounded PDF rendering in internal/pdfgen/internal/document (MultiCell
// wraps arbitrarily long input into an arbitrarily long document) — these
// fields end up on invoices/acts, not just displayed in the UI.
const (
maxShortFieldLen = 255
maxAddressLen = 2000
)
type Handler struct {
db *pgxpool.Pool
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{db: db}
}
type createBody struct {
Type string `json:"type"`
Name string `json:"name"`
Phone string `json:"phone"`
Email string `json:"email"`
INN string `json:"inn"`
KPP string `json:"kpp"`
CompanyAddress string `json:"company_address"`
}
func (b createBody) validate() string {
if b.Type != "individual" && b.Type != "company" {
return "type must be 'individual' or 'company'"
}
if b.Name == "" {
return "name is required"
}
if b.Phone == "" {
return "phone is required"
}
if b.Type == "company" && b.INN == "" {
return "inn is required for company clients"
}
for _, f := range []string{b.Name, b.Phone, b.Email, b.INN, b.KPP} {
if utf8.RuneCountInString(f) > maxShortFieldLen {
return "one of the fields is too long"
}
}
if utf8.RuneCountInString(b.CompanyAddress) > maxAddressLen {
return "company_address is too long"
}
return ""
}
func (h *Handler) Create(c *fiber.Ctx) error {
var body createBody
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if msg := body.validate(); msg != "" {
return c.Status(400).JSON(fiber.Map{"error": msg})
}
// Best-effort — an unrecognized phone format just leaves
// phone_normalized NULL (no SMS channel for this client), never blocks
// client creation.
normalizedPhone, _ := smsgw.Normalize(body.Phone)
var id string
err := h.db.QueryRow(context.Background(),
`INSERT INTO clients (type, name, phone, phone_normalized, email, inn, kpp, company_address, created_by_staff_id, created_by_staff_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::uuid, $10) RETURNING id`,
body.Type, body.Name, body.Phone, dbutil.NullIfEmpty(normalizedPhone), dbutil.NullIfEmpty(body.Email), dbutil.NullIfEmpty(body.INN), dbutil.NullIfEmpty(body.KPP), dbutil.NullIfEmpty(body.CompanyAddress),
auth.StaffID(c), auth.StaffName(c),
).Scan(&id)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.Status(201).JSON(fiber.Map{"id": id})
}
type clientRow struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Phone string `json:"phone"`
Email *string `json:"email"`
INN *string `json:"inn"`
KPP *string `json:"kpp"`
CompanyAddress *string `json:"company_address"`
TGLinked bool `json:"tg_linked"`
MaxLinked bool `json:"max_linked"`
VkLinked bool `json:"vk_linked"`
NotifyTelegram bool `json:"notify_telegram"`
NotifyMax bool `json:"notify_max"`
NotifyVk bool `json:"notify_vk"`
NotifySMS bool `json:"notify_sms"`
CreatedAt time.Time `json:"created_at"`
}
const clientColumns = `id, type, name, phone, email, inn, kpp, company_address,
(tg_chat_id IS NOT NULL), (max_chat_id IS NOT NULL), (vk_chat_id IS NOT NULL),
notify_telegram, notify_max, notify_vk, notify_sms, created_at`
func scanClient(row pgx.Row, r *clientRow) error {
return row.Scan(&r.ID, &r.Type, &r.Name, &r.Phone, &r.Email, &r.INN, &r.KPP, &r.CompanyAddress,
&r.TGLinked, &r.MaxLinked, &r.VkLinked, &r.NotifyTelegram, &r.NotifyMax, &r.NotifyVk, &r.NotifySMS, &r.CreatedAt)
}
// List returns clients, optionally filtered by ?q= matching name or phone (substring).
func (h *Handler) List(c *fiber.Ctx) error {
q := c.Query("q")
clientType := c.Query("type")
dateFrom := c.Query("date_from")
dateTo := c.Query("date_to")
rows, err := h.db.Query(context.Background(),
`SELECT `+clientColumns+`
FROM clients
WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR phone ILIKE '%' || $1 || '%')
AND ($2 = '' OR type = $2)
AND ($3 = '' OR created_at >= $3::date)
AND ($4 = '' OR created_at < ($4::date + interval '1 day'))
ORDER BY created_at DESC LIMIT 200`, q, clientType, dateFrom, dateTo)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
out := []clientRow{}
for rows.Next() {
var r clientRow
if err := scanClient(rows, &r); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
out = append(out, r)
}
return c.JSON(out)
}
// Get returns a client with their order history.
func (h *Handler) Get(c *fiber.Ctx) error {
id := c.Params("id")
var r clientRow
err := scanClient(h.db.QueryRow(context.Background(), `SELECT `+clientColumns+` FROM clients WHERE id = $1::uuid`, id), &r)
if err != nil {
if err == pgx.ErrNoRows {
return c.Status(404).JSON(fiber.Map{"error": "client not found"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
// A master only ever sees their own orders and unassigned ones here too
// — same restriction order.List already applies to its own listing.
// Without it, a master who knows (or enumerates) a client ID could read
// tracking_token for a colleague's assigned order out of this endpoint,
// then use that token against the fully public /api/track/:token to see
// that order's timeline.
query := `SELECT id, tracking_token, device_type, status, warranty_until::text, created_at FROM orders WHERE client_id = $1::uuid`
args := []any{id}
if !auth.HasPermission(c, "unscoped") {
query += fmt.Sprintf(" AND (assigned_master_id IS NULL OR assigned_master_id = $%d::uuid)", len(args)+1)
args = append(args, auth.StaffID(c))
}
query += " ORDER BY created_at DESC"
orderRows, err := h.db.Query(context.Background(), query, args...)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer orderRows.Close()
type orderSummary struct {
ID string `json:"id"`
Token string `json:"tracking_token"`
DeviceType string `json:"device_type"`
Status string `json:"status"`
WarrantyUntil *string `json:"warranty_until"`
CreatedAt time.Time `json:"created_at"`
}
var orders []orderSummary
for orderRows.Next() {
var o orderSummary
if err := orderRows.Scan(&o.ID, &o.Token, &o.DeviceType, &o.Status, &o.WarrantyUntil, &o.CreatedAt); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
orders = append(orders, o)
}
return c.JSON(fiber.Map{"client": r, "orders": orders})
}
// Debt is what this client still owes across every non-cancelled order —
// same GREATEST/COALESCE-per-order shape as analytics.fetchClientDebtTotal,
// just scoped to one client_id instead of summed company-wide. Kept as its
// own lightweight endpoint (not folded into Get's response) so ClientPicker
// can show it inline wherever a client is selected — OrderModal/
// TradeInModal — without also paying for Get's order-history join every
// time. staffAuth only, no extra permission: a master already sees
// per-order cash history inside that order's own card (see PROJECT.md's
// role table), so a same-shape aggregate for "this one client" isn't a
// bigger financial disclosure than what's already visible to that role.
func (h *Handler) Debt(c *fiber.Ctx) error {
id := c.Params("id")
var debt string
err := h.db.QueryRow(context.Background(),
`SELECT COALESCE(SUM(GREATEST(COALESCE(o.final_price, o.price_estimate, 0) - COALESCE(paid.amt, 0), 0)), 0)::text
FROM orders o
LEFT JOIN (
SELECT order_id, SUM(amount) AS amt FROM cash_transactions
WHERE type = 'income' AND order_id IS NOT NULL GROUP BY order_id
) paid ON paid.order_id = o.id
WHERE o.client_id = $1::uuid AND o.deleted_at IS NULL
AND o.status NOT IN (SELECT key FROM order_statuses WHERE system_role = 'cancelled')`,
id,
).Scan(&debt)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(fiber.Map{"debt": debt})
}