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

348 lines
13 KiB
Go

// Package booking implements Phase 13's public "запись на приём" request
// queue — production/web's unauthenticated /book page posts here (same
// public-page pattern as /track/:token), staff review the queue and either
// confirm (creates client+order, exactly as if they'd taken the request
// over the phone) or decline. See migrations/014_bookings.sql's doc comment
// for why this is a request queue, not a slot/capacity calendar.
package booking
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"production/internal/auth"
"production/internal/clientnotify"
"production/internal/customfields"
"production/internal/dbutil"
"production/internal/notify"
"production/internal/ordernum"
"production/internal/settings"
"production/internal/smsgw"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Handler struct {
db *pgxpool.Pool
notify *notify.Handler
clientNotify *clientnotify.Handler
}
func NewHandler(db *pgxpool.Pool, notify *notify.Handler, clientNotify *clientnotify.Handler) *Handler {
return &Handler{db: db, notify: notify, clientNotify: clientNotify}
}
// Create is public and unauthenticated — production/web's /book page posts
// here directly. Always 201s on a benign honeypot hit (matching site's lead
// form) so a bot never learns its submission was dropped.
func (h *Handler) Create(c *fiber.Ctx) error {
var body createInput
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
body.Name = cleanField(body.Name)
body.Phone = cleanField(body.Phone)
body.DeviceType = cleanField(body.DeviceType)
body.ProblemDescription = cleanField(body.ProblemDescription)
if body.Website != "" {
return c.Status(201).JSON(fiber.Map{"ok": true})
}
if msg := body.validate(); msg != "" {
return c.Status(400).JSON(fiber.Map{"error": msg})
}
preferredAt, _ := time.Parse(time.RFC3339, body.PreferredAt)
ctx := context.Background()
var id string
err := h.db.QueryRow(ctx,
`INSERT INTO bookings (name, phone, device_type, problem_description, preferred_at)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
body.Name, body.Phone, body.DeviceType, dbutil.NullIfEmpty(body.ProblemDescription), preferredAt,
).Scan(&id)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
h.notify.Send(fmt.Sprintf("📅 Новая запись на приём: %s · %s\n%s\nЖелаемое время: %s",
body.Name, body.Phone, body.DeviceType, preferredAt.Local().Format("02.01.2006 15:04")))
return c.Status(201).JSON(fiber.Map{"ok": true})
}
// CreateByStaff is Create's staff-authenticated counterpart — used by the
// "+Заявка" type-selector's "Выездной ремонт" branch (an on-site repair
// request needs an address, which the public form never collects) and for
// staff taking a phone request directly rather than pointing the caller at
// /book. No honeypot check (staff-authenticated, not public), and address
// is required when is_onsite is set — nothing downstream can schedule an
// on-site visit without knowing where to go.
func (h *Handler) CreateByStaff(c *fiber.Ctx) error {
var body createInput
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
body.Name = cleanField(body.Name)
body.Phone = cleanField(body.Phone)
body.DeviceType = cleanField(body.DeviceType)
body.ProblemDescription = cleanField(body.ProblemDescription)
body.Address = cleanField(body.Address)
if msg := body.validate(); msg != "" {
return c.Status(400).JSON(fiber.Map{"error": msg})
}
if body.IsOnsite && body.Address == "" {
return c.Status(400).JSON(fiber.Map{"error": "address is required for an on-site repair booking"})
}
preferredAt, _ := time.Parse(time.RFC3339, body.PreferredAt)
ctx := context.Background()
var id string
err := h.db.QueryRow(ctx,
`INSERT INTO bookings (name, phone, device_type, problem_description, preferred_at, address, is_onsite)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
body.Name, body.Phone, body.DeviceType, dbutil.NullIfEmpty(body.ProblemDescription), preferredAt,
dbutil.NullIfEmpty(body.Address), body.IsOnsite,
).Scan(&id)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
kind := "Новая запись на приём"
if body.IsOnsite {
kind = "Новая заявка на выездной ремонт"
}
h.notify.Send(fmt.Sprintf("📅 %s: %s · %s\n%s\nЖелаемое время: %s",
kind, body.Name, body.Phone, body.DeviceType, preferredAt.Local().Format("02.01.2006 15:04")))
return c.Status(201).JSON(fiber.Map{"ok": true, "id": id})
}
type bookingRow struct {
ID string `json:"id"`
Status string `json:"status"`
Name string `json:"name"`
Phone string `json:"phone"`
DeviceType string `json:"device_type"`
ProblemDescription *string `json:"problem_description"`
PreferredAt time.Time `json:"preferred_at"`
Address *string `json:"address"`
IsOnsite bool `json:"is_onsite"`
StaffNote *string `json:"staff_note"`
ClientID *string `json:"client_id"`
OrderID *string `json:"order_id"`
ReviewedByStaffName *string `json:"reviewed_by_staff_name"`
ReviewedAt *time.Time `json:"reviewed_at"`
CreatedAt time.Time `json:"created_at"`
}
const bookingColumns = `id, status, name, phone, device_type, problem_description, preferred_at,
address, is_onsite, staff_note, client_id, order_id, reviewed_by_staff_name, reviewed_at, created_at`
func scanBooking(row pgx.Row) (bookingRow, error) {
var r bookingRow
err := row.Scan(&r.ID, &r.Status, &r.Name, &r.Phone, &r.DeviceType, &r.ProblemDescription, &r.PreferredAt,
&r.Address, &r.IsOnsite, &r.StaffNote, &r.ClientID, &r.OrderID, &r.ReviewedByStaffName, &r.ReviewedAt, &r.CreatedAt)
return r, err
}
// List is staff-facing — every role can see the queue (it's an operational
// intake list, not financial data), filterable by ?status=.
func (h *Handler) List(c *fiber.Ctx) error {
status := c.Query("status")
rows, err := h.db.Query(context.Background(),
`SELECT `+bookingColumns+` FROM bookings WHERE $1 = '' OR status = $1 ORDER BY created_at DESC LIMIT 200`, status)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
out := []bookingRow{}
for rows.Next() {
r, err := scanBooking(rows)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
out = append(out, r)
}
return c.JSON(out)
}
// Confirm turns a pending booking into a real client + order — everything
// order.Create itself does (order_number, initial timeline event, staff
// notify, "created" client notification) except custom-field enforcement
// (enforceRequired=false, same as order.Update's partial-patch semantics —
// staff fill in anything required right after, in OrderModal). Locks the
// booking row so a booking can't be confirmed twice by two staff clicking
// at once.
func (h *Handler) Confirm(c *fiber.Ctx) error {
id := c.Params("id")
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)
var name, phone, deviceType, status string
var problemDescription *string
err = tx.QueryRow(ctx,
`SELECT name, phone, device_type, problem_description, status FROM bookings WHERE id = $1::uuid FOR UPDATE`, id,
).Scan(&name, &phone, &deviceType, &problemDescription, &status)
if err != nil {
if err == pgx.ErrNoRows {
return c.Status(404).JSON(fiber.Map{"error": "booking not found"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if status != "pending" {
return c.Status(409).JSON(fiber.Map{"error": "booking already reviewed"})
}
clientID, err := findOrCreateClientByPhone(ctx, tx, name, phone, auth.StaffID(c), auth.StaffName(c))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
problem := ""
if problemDescription != nil {
problem = *problemDescription
}
if problem == "" {
problem = "Заявка с онлайн-записи"
}
defs, err := customfields.Fetch(ctx, h.db, true)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
validated, err := customfields.ValidateValues(defs, nil, false)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
customFieldsJSON, err := json.Marshal(validated)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
orderNumber, err := ordernum.Next(ctx, h.db, ordernum.Prefix(deviceType))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
var orderID, trackingToken string
err = tx.QueryRow(ctx,
`INSERT INTO orders (client_id, device_type, problem_description, created_by_staff_id, created_by_staff_name, custom_fields, order_number)
VALUES ($1::uuid, $2, $3, $4::uuid, $5, $6::jsonb, $7) RETURNING id, tracking_token`,
clientID, deviceType, problem, auth.StaffID(c), auth.StaffName(c), customFieldsJSON, orderNumber,
).Scan(&orderID, &trackingToken)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if _, err := tx.Exec(ctx,
`INSERT INTO order_events (order_id, type, body, is_public, staff_id, staff_name)
VALUES ($1::uuid, 'status_change', 'new', true, $2::uuid, $3)`,
orderID, auth.StaffID(c), auth.StaffName(c),
); err != nil {
log.Printf("booking: initial order_events insert failed for order %s: %v", orderID, err)
}
if _, err := tx.Exec(ctx,
`UPDATE bookings SET status = 'confirmed', client_id = $1::uuid, order_id = $2::uuid,
reviewed_by_staff_id = $3::uuid, reviewed_by_staff_name = $4, reviewed_at = NOW()
WHERE id = $5::uuid`,
clientID, orderID, auth.StaffID(c), auth.StaffName(c), id,
); 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.notify.Send(fmt.Sprintf("🆕 Новая заявка (из записи): %s\n%s\nПринял: %s", deviceType, problem, auth.StaffName(c)))
trackingURL := settings.TrackingURL(ctx, h.db, trackingToken)
h.clientNotify.Enqueue(clientnotify.Event{
ClientID: clientID,
OrderID: orderID,
Trigger: "created",
DedupeSeed: orderID,
TGBody: clientnotify.OrderCreated("telegram", deviceType, trackingURL),
SMSBody: clientnotify.OrderCreated("sms", deviceType, trackingURL),
})
return c.JSON(fiber.Map{"ok": true, "client_id": clientID, "order_id": orderID})
}
type declineInput struct {
Note string `json:"note"`
}
// Decline marks a pending booking reviewed without creating anything —
// staff called back and it didn't work out, or it looked like spam.
func (h *Handler) Decline(c *fiber.Ctx) error {
id := c.Params("id")
var body declineInput
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
tag, err := h.db.Exec(context.Background(),
`UPDATE bookings SET status = 'declined', staff_note = $1,
reviewed_by_staff_id = $2::uuid, reviewed_by_staff_name = $3, reviewed_at = NOW()
WHERE id = $4::uuid AND status = 'pending'`,
dbutil.NullIfEmpty(body.Note), auth.StaffID(c), auth.StaffName(c), 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": "booking not found or already reviewed"})
}
return c.JSON(fiber.Map{"ok": true})
}
// findOrCreateClientByPhone mirrors internal/sale's own helper of the same
// name — same advisory-lock rationale (see that package's doc comment):
// without it, two staff confirming two bookings from the same new phone at
// once could both miss each other's INSERT under READ COMMITTED and split
// one client's history across two cards. Unlike sale's webhook-attributed
// version, this one runs as an authenticated staff action, so the new
// client is attributed to the confirming staff member, not a system
// account — and phone_normalized is set immediately (sale's version
// predates Phase 11 and doesn't set it), so the new client is
// notification-ready without waiting for a later edit.
func findOrCreateClientByPhone(ctx context.Context, tx pgx.Tx, name, phone, staffID, staffName string) (string, error) {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, phone); err != nil {
return "", err
}
var clientID string
err := tx.QueryRow(ctx, `SELECT id FROM clients WHERE phone = $1 LIMIT 1`, phone).Scan(&clientID)
if err == nil {
return clientID, nil
}
if err != pgx.ErrNoRows {
return "", err
}
normalizedPhone, _ := smsgw.Normalize(phone)
err = tx.QueryRow(ctx,
`INSERT INTO clients (type, name, phone, phone_normalized, created_by_staff_id, created_by_staff_name)
VALUES ('individual', $1, $2, $3, $4::uuid, $5) RETURNING id`,
name, phone, dbutil.NullIfEmpty(normalizedPhone), staffID, staffName,
).Scan(&clientID)
return clientID, err
}