// 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 }