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

214 lines
7.6 KiB
Go

// Package tasks implements Задачи — an internal staff to-do board,
// independent of orders/bookings (see migrations/057_staff_tasks.sql's doc
// comment for why this is its own domain). Every staff role sees it —
// no permission gate in main.go, unlike Касса/Аналитика, since it carries
// no financial data.
package tasks
import (
"context"
"time"
"unicode/utf8"
"production/internal/auth"
"production/internal/dbutil"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
maxTitleLen = 255
maxDescriptionLen = 5000
)
var validStatuses = map[string]bool{"new": true, "in_progress": true, "postponed": true, "done": true}
type Handler struct {
db *pgxpool.Pool
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{db: db}
}
type taskRow struct {
ID string `json:"id"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
AssignedStaffID *string `json:"assigned_staff_id"`
AssignedStaffName *string `json:"assigned_staff_name"`
DueDate *string `json:"due_date"`
CreatedByStaffName string `json:"created_by_staff_name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at"`
}
const taskColumns = `id, title, description, status, assigned_staff_id, assigned_staff_name,
due_date::text, created_by_staff_name, created_at, updated_at, completed_at`
func scanTask(row pgx.Row) (taskRow, error) {
var t taskRow
err := row.Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.AssignedStaffID, &t.AssignedStaffName,
&t.DueDate, &t.CreatedByStaffName, &t.CreatedAt, &t.UpdatedAt, &t.CompletedAt)
return t, err
}
// List returns every task, newest first — a to-do board this size (a
// single small service center's internal tasks) doesn't need pagination or
// server-side filtering; the board groups by status client-side same as
// Kanban does with orders.
func (h *Handler) List(c *fiber.Ctx) error {
rows, err := h.db.Query(context.Background(), `SELECT `+taskColumns+` FROM staff_tasks ORDER BY created_at DESC`)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
out := []taskRow{}
for rows.Next() {
t, err := scanTask(rows)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
out = append(out, t)
}
return c.JSON(out)
}
func (h *Handler) Create(c *fiber.Ctx) error {
var body struct {
Title string `json:"title"`
Description string `json:"description"`
AssignedStaffID string `json:"assigned_staff_id"`
AssignedStaffName string `json:"assigned_staff_name"`
DueDate string `json:"due_date"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if body.Title == "" {
return c.Status(400).JSON(fiber.Map{"error": "title is required"})
}
if utf8.RuneCountInString(body.Title) > maxTitleLen {
return c.Status(400).JSON(fiber.Map{"error": "title is too long"})
}
if utf8.RuneCountInString(body.Description) > maxDescriptionLen {
return c.Status(400).JSON(fiber.Map{"error": "description is too long"})
}
ctx := context.Background()
row, err := scanTask(h.db.QueryRow(ctx,
`INSERT INTO staff_tasks (title, description, assigned_staff_id, assigned_staff_name, due_date, created_by_staff_id, created_by_staff_name)
VALUES ($1, $2, $3::uuid, $4, $5::date, $6::uuid, $7)
RETURNING `+taskColumns,
body.Title, dbutil.NullIfEmpty(body.Description), dbutil.NullIfEmpty(body.AssignedStaffID), dbutil.NullIfEmpty(body.AssignedStaffName),
dbutil.NullIfEmpty(body.DueDate), auth.StaffID(c), auth.StaffName(c),
))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.Status(201).JSON(row)
}
// Update handles title/description/assignment/due-date edits — status has
// its own endpoint (UpdateStatus) since that's the board's drag/click
// action and wants its own completed_at side effect, not bundled into a
// general-purpose PATCH.
func (h *Handler) Update(c *fiber.Ctx) error {
id := c.Params("id")
var body struct {
Title *string `json:"title"`
Description *string `json:"description"`
AssignedStaffID *string `json:"assigned_staff_id"`
AssignedStaffName *string `json:"assigned_staff_name"`
DueDate *string `json:"due_date"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if body.Title != nil {
if *body.Title == "" {
return c.Status(400).JSON(fiber.Map{"error": "title cannot be empty"})
}
if utf8.RuneCountInString(*body.Title) > maxTitleLen {
return c.Status(400).JSON(fiber.Map{"error": "title is too long"})
}
}
if body.Description != nil && utf8.RuneCountInString(*body.Description) > maxDescriptionLen {
return c.Status(400).JSON(fiber.Map{"error": "description is too long"})
}
// Same "" -> nil collapse order.Update does for assigned_master_id/
// warranty_until/price_estimate, and the same resulting limitation: an
// empty string ends up indistinguishable from the field being omitted
// entirely, so COALESCE below can't tell "clear it" from "didn't touch
// it" — both are a no-op. Consistent with that existing endpoint rather
// than solving it differently here.
if body.AssignedStaffID != nil && *body.AssignedStaffID == "" {
body.AssignedStaffID = nil
}
if body.DueDate != nil && *body.DueDate == "" {
body.DueDate = nil
}
tag, err := h.db.Exec(context.Background(),
`UPDATE staff_tasks SET
title = COALESCE($1, title),
description = COALESCE($2, description),
assigned_staff_id = COALESCE($3::uuid, assigned_staff_id),
assigned_staff_name = COALESCE($4, assigned_staff_name),
due_date = COALESCE($5::date, due_date),
updated_at = NOW()
WHERE id = $6::uuid`,
body.Title, body.Description, body.AssignedStaffID, body.AssignedStaffName, body.DueDate, 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": "task not found"})
}
return c.JSON(fiber.Map{"ok": true})
}
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 || !validStatuses[body.Status] {
return c.Status(400).JSON(fiber.Map{"error": "status must be one of: new, in_progress, postponed, done"})
}
// completed_at is set going into 'done' and cleared coming back out —
// re-opening a task that was marked done shouldn't leave a stale
// completion timestamp behind.
tag, err := h.db.Exec(context.Background(),
`UPDATE staff_tasks SET status = $1,
completed_at = CASE WHEN $1 = 'done' THEN NOW() ELSE NULL END,
updated_at = NOW()
WHERE id = $2::uuid`,
body.Status, 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": "task not found"})
}
return c.JSON(fiber.Map{"ok": true})
}
func (h *Handler) Delete(c *fiber.Ctx) error {
id := c.Params("id")
tag, err := h.db.Exec(context.Background(), `DELETE FROM staff_tasks WHERE id = $1::uuid`, 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": "task not found"})
}
return c.JSON(fiber.Map{"ok": true})
}