348 lines
13 KiB
Go
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
|
|
}
|