214 lines
7.6 KiB
Go
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})
|
|
}
|