Files
kanban/backend/db.go
T
egutierrez 9f4fd85db3 feat(kanban): tiempo maximo por columna con borde rojo (issue 0089)
Adds column-level max time limit. Cards whose time_in_column_ms exceeds
the limit show a red border + red halo. Columns marked as Done never
trigger the visual regardless of the limit (per spec).

Backend:
- Migration 011_column_max_time.sql adds columns.max_time_minutes
  INTEGER NOT NULL DEFAULT 0 (0 = no limit). Aditiva, idempotente.
- Column struct + ColumnPatch + UpdateColumn handle the new field;
  negatives clamp to 0; listing query includes it.
- handleUpdateColumn (PATCH /api/columns/:id) accepts max_time_minutes
  in the JSON body.

Frontend:
- Column TS interface + UpdateColumnInput updated.
- KanbanColumn context menu: new entry "Tiempo maximo" using
  window.prompt for low-friction config; shows current value when >0.
- KanbanCard receives columnOverdue prop calculated from the column
  state and card.time_in_column_ms; renders red border (var
  --mantine-color-red-6) with 2px width + 2px red halo when overdue.
- data-card-id, data-column-overdue, data-locked attributes on the card
  paper element so e2e tests / scripts can query state.

Tests: TestColumnMaxTimeMinutes_Defaults + _Update verify the schema
default, the clamp on negative input, and that updating max_time leaves
other fields untouched.

Visual regression of the red border kept out of automated e2e because
it requires either clock control or real cards aged > N minutes; will be
verified manually after merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00

1213 lines
35 KiB
Go

package main
import (
"database/sql"
"embed"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"fn-registry/functions/core"
"fn-registry/functions/infra"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type Column struct {
ID string `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
Location string `json:"location"`
Width int `json:"width"`
WIPLimit int `json:"wip_limit"`
IsDone bool `json:"is_done"`
MaxTimeMinutes int `json:"max_time_minutes"`
CreatedAt string `json:"created_at"`
}
type Sticker struct {
Emoji string `json:"emoji"`
X float64 `json:"x"`
Y float64 `json:"y"`
}
type Card struct {
ID string `json:"id"`
SeqNum int `json:"seq_num"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
Color string `json:"color"`
ColumnID string `json:"column_id"`
Position int `json:"position"`
Locked bool `json:"locked"`
AssigneeID *string `json:"assignee_id"`
CompletedAt *string `json:"completed_at"`
DeletedAt *string `json:"deleted_at"`
Tags []string `json:"tags"`
Stickers []Sticker `json:"stickers"`
Deadline *string `json:"deadline"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
EnteredAt string `json:"entered_at"`
TimeInColumn int64 `json:"time_in_column_ms"`
LockedAt *string `json:"locked_at"`
TotalLockedMs int64 `json:"total_locked_ms"`
}
type HistoryEntry struct {
ID string `json:"id"`
CardID string `json:"card_id"`
ColumnID string `json:"column_id"`
ColumnName string `json:"column_name"`
EnteredAt string `json:"entered_at"`
ExitedAt *string `json:"exited_at"`
DurationMs int64 `json:"duration_ms"`
ActorID *string `json:"actor_id"`
}
type LockPeriod struct {
ID string `json:"id"`
CardID string `json:"card_id"`
LockedAt string `json:"locked_at"`
UnlockedAt *string `json:"unlocked_at"`
DurationMs int64 `json:"duration_ms"`
ActorID *string `json:"actor_id"`
}
type CardHistoryResponse struct {
ColumnHistory []HistoryEntry `json:"column_history"`
LockPeriods []LockPeriod `json:"lock_periods"`
Events []CardEvent `json:"events"`
TotalLockedMs int64 `json:"total_locked_ms"`
CurrentlyLock bool `json:"currently_locked"`
}
type CardEvent struct {
ID string `json:"id"`
CardID string `json:"card_id"`
Kind string `json:"kind"`
ActorID *string `json:"actor_id"`
Payload string `json:"payload"`
CreatedAt string `json:"created_at"`
}
type DB struct{ conn *sql.DB }
func openDB(path string) (*DB, error) {
conn, err := infra.SQLiteOpen(path, "")
if err != nil {
return nil, err
}
if err := infra.ApplyMigrations(conn, migrationsFS, "migrations/*.sql"); err != nil {
conn.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
// Idempotent backstop for very old DBs whose schema diverged before
// migration files existed. New columns SIEMPRE se añaden via migracion.
if err := ensureColumns(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("ensure columns: %w", err)
}
return &DB{conn: conn}, nil
}
// ensureColumns adds columns missing from older schemas without dropping data.
// SQLite ALTER TABLE ADD COLUMN supports NOT NULL with literal DEFAULT but not CHECK,
// so location's CHECK is enforced in Go (UpdateColumn) when the column is added later.
func ensureColumns(conn *sql.DB) error {
type colSpec struct{ table, name, ddl string }
specs := []colSpec{
{"columns", "location", "TEXT NOT NULL DEFAULT 'board'"},
{"columns", "width", "INTEGER NOT NULL DEFAULT 300"},
{"columns", "wip_limit", "INTEGER NOT NULL DEFAULT 0"},
{"columns", "is_done", "INTEGER NOT NULL DEFAULT 0"},
{"cards", "color", "TEXT NOT NULL DEFAULT ''"},
{"cards", "locked", "INTEGER NOT NULL DEFAULT 0"},
{"cards", "assignee_id", "TEXT"},
{"cards", "completed_at", "TEXT"},
{"cards", "deleted_at", "TEXT"},
{"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"},
{"cards", "stickers", "TEXT NOT NULL DEFAULT '[]'"},
{"cards", "deadline", "TEXT"},
{"card_column_history", "actor_id", "TEXT"},
{"card_lock_history", "actor_id", "TEXT"},
}
for _, s := range specs {
exists, err := infra.ColumnExists(conn, s.table, s.name)
if err != nil {
return err
}
if exists {
continue
}
if _, err := conn.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", s.table, s.name, s.ddl)); err != nil {
return fmt.Errorf("add %s.%s: %w", s.table, s.name, err)
}
}
if _, err := conn.Exec(`CREATE INDEX IF NOT EXISTS idx_cards_assignee ON cards(assignee_id)`); err != nil {
return fmt.Errorf("create assignee index: %w", err)
}
return nil
}
func (db *DB) Close() error { return db.conn.Close() }
func newID() string {
id, err := core.RandomHexID(8)
if err != nil {
panic(fmt.Errorf("kanban: cannot generate id: %w", err))
}
return id
}
func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) }
func parseTags(s string) []string {
out := []string{}
if s == "" {
return out
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []string{}
}
return out
}
func normalizeTags(in []string) []string {
seen := map[string]struct{}{}
out := []string{}
for _, t := range in {
t = strings.TrimSpace(t)
if t == "" {
continue
}
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
sort.Strings(out)
return out
}
func encodeTags(in []string) string {
b, _ := json.Marshal(normalizeTags(in))
return string(b)
}
func parseStickers(s string) []Sticker {
out := []Sticker{}
if s == "" {
return out
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []Sticker{}
}
return out
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func normalizeStickers(in []Sticker) []Sticker {
out := make([]Sticker, 0, len(in))
for _, s := range in {
emoji := strings.TrimSpace(s.Emoji)
if emoji == "" {
continue
}
out = append(out, Sticker{Emoji: emoji, X: clamp01(s.X), Y: clamp01(s.Y)})
}
return out
}
func encodeStickers(in []Sticker) string {
b, _ := json.Marshal(normalizeStickers(in))
return string(b)
}
func (db *DB) UpdateStickers(id string, stickers []Sticker) error {
_, err := db.conn.Exec(`UPDATE cards SET stickers=?, updated_at=? WHERE id=?`, encodeStickers(stickers), nowRFC3339(), id)
return err
}
func (db *DB) ListAllTags() ([]string, error) {
rows, err := db.conn.Query(`SELECT DISTINCT tags FROM cards WHERE deleted_at IS NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
seen := map[string]struct{}{}
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
for _, t := range parseTags(s) {
seen[t] = struct{}{}
}
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
func (db *DB) ListDistinctRequesters() ([]string, error) {
rows, err := db.conn.Query(`SELECT DISTINCT requester FROM cards WHERE deleted_at IS NULL AND requester != '' ORDER BY requester`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func nullableActor(actorID string) any {
if actorID == "" {
return nil
}
return actorID
}
// insertEvent registra un evento timeline de la card. tx puede ser nil para usar conn.
func insertCardEvent(execer interface {
Exec(string, ...any) (sql.Result, error)
}, cardID, kind, actorID string, payload any) error {
pj, _ := json.Marshal(payload)
_, err := execer.Exec(
`INSERT INTO card_events (id, card_id, kind, actor_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
newID(), cardID, kind, nullableActor(actorID), string(pj), nowRFC3339(),
)
return err
}
// --- Columns ---
func (db *DB) ListColumns() ([]Column, error) {
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, max_time_minutes, created_at FROM columns ORDER BY position, created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Column{}
for rows.Next() {
var c Column
var isDone int
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.MaxTimeMinutes, &c.CreatedAt); err != nil {
return nil, err
}
c.IsDone = isDone != 0
out = append(out, c)
}
return out, rows.Err()
}
func (db *DB) CreateColumn(name string) (*Column, error) {
var maxPos sql.NullInt64
if err := db.conn.QueryRow(`SELECT MAX(position) FROM columns`).Scan(&maxPos); err != nil {
return nil, err
}
pos := 0
if maxPos.Valid {
pos = int(maxPos.Int64) + 1
}
c := Column{ID: newID(), Name: name, Position: pos, Location: "board", Width: 300, WIPLimit: 0, IsDone: false, CreatedAt: nowRFC3339()}
_, err := db.conn.Exec(
`INSERT INTO columns (id, name, position, location, width, wip_limit, is_done, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.Name, c.Position, c.Location, c.Width, c.WIPLimit, 0, c.CreatedAt,
)
if err != nil {
return nil, err
}
return &c, nil
}
type ColumnPatch struct {
Name *string
Position *int
Location *string
Width *int
WIPLimit *int
IsDone *bool
MaxTimeMinutes *int
}
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
if patch.Name != nil {
if _, err := db.conn.Exec(`UPDATE columns SET name=? WHERE id=?`, *patch.Name, id); err != nil {
return err
}
}
if patch.Position != nil {
if _, err := db.conn.Exec(`UPDATE columns SET position=? WHERE id=?`, *patch.Position, id); err != nil {
return err
}
}
if patch.Location != nil {
if *patch.Location != "board" && *patch.Location != "sidebar" {
return fmt.Errorf("invalid location: %s", *patch.Location)
}
if _, err := db.conn.Exec(`UPDATE columns SET location=? WHERE id=?`, *patch.Location, id); err != nil {
return err
}
}
if patch.Width != nil {
w := *patch.Width
if w < 200 {
w = 200
} else if w > 800 {
w = 800
}
if _, err := db.conn.Exec(`UPDATE columns SET width=? WHERE id=?`, w, id); err != nil {
return err
}
}
if patch.WIPLimit != nil {
l := *patch.WIPLimit
if l < 0 {
l = 0
}
if _, err := db.conn.Exec(`UPDATE columns SET wip_limit=? WHERE id=?`, l, id); err != nil {
return err
}
}
if patch.IsDone != nil {
v := 0
if *patch.IsDone {
v = 1
}
if _, err := db.conn.Exec(`UPDATE columns SET is_done=? WHERE id=?`, v, id); err != nil {
return err
}
// Re-evaluate completed_at for cards in this column.
now := nowRFC3339()
if v == 1 {
if _, err := db.conn.Exec(`UPDATE cards SET completed_at=? WHERE column_id=? AND completed_at IS NULL`, now, id); err != nil {
return err
}
} else {
if _, err := db.conn.Exec(`UPDATE cards SET completed_at=NULL WHERE column_id=?`, id); err != nil {
return err
}
}
}
if patch.MaxTimeMinutes != nil {
m := *patch.MaxTimeMinutes
if m < 0 {
m = 0
}
if _, err := db.conn.Exec(`UPDATE columns SET max_time_minutes=? WHERE id=?`, m, id); err != nil {
return err
}
}
return nil
}
func (db *DB) DeleteColumn(id string) error {
_, err := db.conn.Exec(`DELETE FROM columns WHERE id=?`, id)
return err
}
func (db *DB) ReorderColumns(ids []string) error {
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for i, id := range ids {
if _, err := tx.Exec(`UPDATE columns SET position=? WHERE id=?`, i, id); err != nil {
return err
}
}
return tx.Commit()
}
// --- Cards ---
func (db *DB) ListCardsWithTime() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
h.entered_at, l.locked_at,
COALESCE((
SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER)
FROM card_lock_history WHERE card_id = c.id
), 0) AS total_locked_ms
FROM cards c
LEFT JOIN card_column_history h
ON h.card_id = c.id AND h.exited_at IS NULL
LEFT JOIN card_lock_history l
ON l.card_id = c.id AND l.unlocked_at IS NULL
WHERE c.deleted_at IS NULL
ORDER BY c.column_id, c.position, c.created_at
`, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return nil, err
}
defer rows.Close()
now := time.Now().UTC()
out := []Card{}
for rows.Next() {
var c Card
var entered sql.NullString
var assignee sql.NullString
var completed sql.NullString
var deleted sql.NullString
var tagsJSON string
var stickersJSON string
var deadline sql.NullString
var lockedAt sql.NullString
var locked int
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
return nil, err
}
c.Stickers = parseStickers(stickersJSON)
if deadline.Valid && deadline.String != "" {
s := deadline.String
c.Deadline = &s
}
if lockedAt.Valid && lockedAt.String != "" {
s := lockedAt.String
c.LockedAt = &s
}
c.Locked = locked != 0
if assignee.Valid && assignee.String != "" {
s := assignee.String
c.AssigneeID = &s
}
if completed.Valid && completed.String != "" {
s := completed.String
c.CompletedAt = &s
}
if deleted.Valid && deleted.String != "" {
s := deleted.String
c.DeletedAt = &s
}
c.Tags = parseTags(tagsJSON)
if entered.Valid {
c.EnteredAt = entered.String
if t, err := time.Parse(time.RFC3339Nano, entered.String); err == nil {
c.TimeInColumn = now.Sub(t).Milliseconds()
}
}
out = append(out, c)
}
return out, rows.Err()
}
func (db *DB) CreateCard(columnID, requester, title, description, actorID string) (*Card, error) {
var maxPos sql.NullInt64
if err := db.conn.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, columnID).Scan(&maxPos); err != nil {
return nil, err
}
pos := 0
if maxPos.Valid {
pos = int(maxPos.Int64) + 1
}
now := nowRFC3339()
tx, err := db.conn.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var maxSeq sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil {
return nil, err
}
seqNum := 1
if maxSeq.Valid {
seqNum = int(maxSeq.Int64) + 1
}
c := Card{
ID: newID(), SeqNum: seqNum, Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos,
Tags: []string{},
Stickers: []Sticker{},
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
}
if _, err := tx.Exec(
`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, encodeTags(c.Tags), c.CreatedAt, c.UpdatedAt,
); err != nil {
return nil, err
}
if _, err := tx.Exec(
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
); err != nil {
return nil, err
}
// If the destination column is_done, set completed_at.
var destDone int
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, columnID).Scan(&destDone); err != nil {
return nil, err
}
if destDone == 1 {
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
return nil, err
}
c.CompletedAt = &now
}
if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": title, "column_id": columnID}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &c, nil
}
type CardPatch struct {
Requester *string
Title *string
Description *string
Color *string
Locked *bool
AssigneeID *string // empty string clears assignment
HasAssignee bool // distinguishes "set to null" from "not provided"
Tags *[]string
Deadline *string // empty string clears deadline
HasDeadline bool // distinguishes "set to null" from "not provided"
}
func (db *DB) UpdateCard(id string, patch CardPatch) error {
return db.UpdateCardWithActor(id, patch, "")
}
func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) error {
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if patch.Requester != nil {
var oldReq string
_ = tx.QueryRow(`SELECT requester FROM cards WHERE id=?`, id).Scan(&oldReq)
if _, err := tx.Exec(`UPDATE cards SET requester=?, updated_at=? WHERE id=?`, *patch.Requester, nowRFC3339(), id); err != nil {
return err
}
if oldReq != *patch.Requester {
if err := insertCardEvent(tx, id, "requester_changed", actorID, map[string]any{"old": oldReq, "new": *patch.Requester}); err != nil {
return err
}
}
}
if patch.Title != nil {
var oldTitle string
_ = tx.QueryRow(`SELECT title FROM cards WHERE id=?`, id).Scan(&oldTitle)
if _, err := tx.Exec(`UPDATE cards SET title=?, updated_at=? WHERE id=?`, *patch.Title, nowRFC3339(), id); err != nil {
return err
}
if oldTitle != *patch.Title {
if err := insertCardEvent(tx, id, "title_changed", actorID, map[string]any{"old": oldTitle, "new": *patch.Title}); err != nil {
return err
}
}
}
if patch.Description != nil {
var oldDesc string
_ = tx.QueryRow(`SELECT description FROM cards WHERE id=?`, id).Scan(&oldDesc)
if _, err := tx.Exec(`UPDATE cards SET description=?, updated_at=? WHERE id=?`, *patch.Description, nowRFC3339(), id); err != nil {
return err
}
if oldDesc != *patch.Description {
if err := insertCardEvent(tx, id, "description_changed", actorID, map[string]any{}); err != nil {
return err
}
}
}
if patch.Color != nil {
if _, err := tx.Exec(`UPDATE cards SET color=?, updated_at=? WHERE id=?`, *patch.Color, nowRFC3339(), id); err != nil {
return err
}
if err := insertCardEvent(tx, id, "color_changed", actorID, map[string]any{"color": *patch.Color}); err != nil {
return err
}
}
if patch.HasAssignee {
var oldAssignee sql.NullString
_ = tx.QueryRow(`SELECT assignee_id FROM cards WHERE id=?`, id).Scan(&oldAssignee)
if patch.AssigneeID == nil || *patch.AssigneeID == "" {
if _, err := tx.Exec(`UPDATE cards SET assignee_id=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
return err
}
if oldAssignee.Valid && oldAssignee.String != "" {
if err := insertCardEvent(tx, id, "unassigned", actorID, map[string]any{"prev": oldAssignee.String}); err != nil {
return err
}
}
} else {
if _, err := tx.Exec(`UPDATE cards SET assignee_id=?, updated_at=? WHERE id=?`, *patch.AssigneeID, nowRFC3339(), id); err != nil {
return err
}
if oldAssignee.String != *patch.AssigneeID {
if err := insertCardEvent(tx, id, "assigned", actorID, map[string]any{"assignee_id": *patch.AssigneeID}); err != nil {
return err
}
}
}
}
if patch.Tags != nil {
if _, err := tx.Exec(`UPDATE cards SET tags=?, updated_at=? WHERE id=?`, encodeTags(*patch.Tags), nowRFC3339(), id); err != nil {
return err
}
if err := insertCardEvent(tx, id, "tags_changed", actorID, map[string]any{"tags": *patch.Tags}); err != nil {
return err
}
}
if patch.HasDeadline {
var oldDeadline sql.NullString
_ = tx.QueryRow(`SELECT deadline FROM cards WHERE id=?`, id).Scan(&oldDeadline)
if patch.Deadline == nil || *patch.Deadline == "" {
if _, err := tx.Exec(`UPDATE cards SET deadline=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
return err
}
if oldDeadline.Valid && oldDeadline.String != "" {
if err := insertCardEvent(tx, id, "deadline_cleared", actorID, map[string]any{"prev": oldDeadline.String}); err != nil {
return err
}
}
} else {
if _, err := tx.Exec(`UPDATE cards SET deadline=?, updated_at=? WHERE id=?`, *patch.Deadline, nowRFC3339(), id); err != nil {
return err
}
if oldDeadline.String != *patch.Deadline {
if err := insertCardEvent(tx, id, "deadline_set", actorID, map[string]any{"deadline": *patch.Deadline}); err != nil {
return err
}
}
}
}
if patch.Locked != nil {
var current int
if err := tx.QueryRow(`SELECT locked FROM cards WHERE id=?`, id).Scan(&current); err != nil {
return err
}
desired := 0
if *patch.Locked {
desired = 1
}
if current != desired {
now := nowRFC3339()
if _, err := tx.Exec(`UPDATE cards SET locked=?, updated_at=? WHERE id=?`, desired, now, id); err != nil {
return err
}
if desired == 1 {
if _, err := tx.Exec(
`INSERT INTO card_lock_history (id, card_id, locked_at, actor_id) VALUES (?, ?, ?, ?)`,
newID(), id, now, nullableActor(actorID),
); err != nil {
return err
}
} else {
if _, err := tx.Exec(
`UPDATE card_lock_history SET unlocked_at=? WHERE card_id=? AND unlocked_at IS NULL`,
now, id,
); err != nil {
return err
}
}
}
}
return tx.Commit()
}
// DeleteCard soft-deletes the card (moves it to trash).
func (db *DB) DeleteCard(id string) error {
return db.DeleteCardWithActor(id, "")
}
func (db *DB) DeleteCardWithActor(id, actorID string) error {
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE cards SET deleted_at=?, updated_at=? WHERE id=?`, nowRFC3339(), nowRFC3339(), id); err != nil {
return err
}
if err := insertCardEvent(tx, id, "deleted", actorID, map[string]any{}); err != nil {
return err
}
return tx.Commit()
}
// RestoreCard removes the deleted_at flag.
func (db *DB) RestoreCard(id string) error {
return db.RestoreCardWithActor(id, "")
}
func (db *DB) RestoreCardWithActor(id, actorID string) error {
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE cards SET deleted_at=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
return err
}
if err := insertCardEvent(tx, id, "restored", actorID, map[string]any{}); err != nil {
return err
}
return tx.Commit()
}
// PurgeCard permanently removes the card from the DB.
func (db *DB) PurgeCard(id string) error {
_, err := db.conn.Exec(`DELETE FROM cards WHERE id=?`, id)
return err
}
// ListDeletedCards returns cards in the trash, newest first.
func (db *DB) ListDeletedCards() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at
FROM cards c
WHERE c.deleted_at IS NOT NULL
ORDER BY c.deleted_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Card{}
for rows.Next() {
var c Card
var assignee sql.NullString
var completed sql.NullString
var deleted sql.NullString
var tagsJSON string
var stickersJSON string
var deadline sql.NullString
var locked int
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
c.Stickers = parseStickers(stickersJSON)
if deadline.Valid && deadline.String != "" {
s := deadline.String
c.Deadline = &s
}
c.Locked = locked != 0
if assignee.Valid && assignee.String != "" {
s := assignee.String
c.AssigneeID = &s
}
if completed.Valid && completed.String != "" {
s := completed.String
c.CompletedAt = &s
}
if deleted.Valid {
s := deleted.String
c.DeletedAt = &s
}
c.Tags = parseTags(tagsJSON)
out = append(out, c)
}
return out, rows.Err()
}
// MoveCard updates the card's column and/or position. If the column changes,
// the open history entry is closed and a new one is opened.
// orderedIDs is the new order of cards in the destination column (including this card).
// actorID is the user performing the move (empty string for system/anonymous).
func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string, actorID string) error {
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var srcColumnID string
var locked int
if err := tx.QueryRow(`SELECT column_id, locked FROM cards WHERE id=?`, cardID).Scan(&srcColumnID, &locked); err != nil {
return fmt.Errorf("card not found: %w", err)
}
if locked != 0 && srcColumnID != destColumnID {
return fmt.Errorf("card locked: cannot move between columns")
}
now := nowRFC3339()
if srcColumnID != destColumnID {
if _, err := tx.Exec(
`UPDATE card_column_history SET exited_at=? WHERE card_id=? AND exited_at IS NULL`,
now, cardID,
); err != nil {
return err
}
if _, err := tx.Exec(
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
newID(), cardID, destColumnID, now, nullableActor(actorID),
); err != nil {
return err
}
_ = actorID
if _, err := tx.Exec(
`UPDATE cards SET column_id=?, updated_at=? WHERE id=?`,
destColumnID, now, cardID,
); err != nil {
return err
}
// Recompute completed_at based on destination column's is_done flag.
var destDone int
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, destColumnID).Scan(&destDone); err != nil {
return err
}
if destDone == 1 {
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=? AND completed_at IS NULL`, now, cardID); err != nil {
return err
}
// Auto-assign: if card had no assignee and an actor is moving it, claim it.
if actorID != "" {
if _, err := tx.Exec(`UPDATE cards SET assignee_id=? WHERE id=? AND (assignee_id IS NULL OR assignee_id='')`, actorID, cardID); err != nil {
return err
}
}
} else {
if _, err := tx.Exec(`UPDATE cards SET completed_at=NULL WHERE id=?`, cardID); err != nil {
return err
}
}
}
for i, id := range orderedIDs {
if _, err := tx.Exec(`UPDATE cards SET position=? WHERE id=?`, i, id); err != nil {
return err
}
}
// Re-pack source column positions to keep them dense.
if srcColumnID != destColumnID {
rows, err := tx.Query(`SELECT id FROM cards WHERE column_id=? ORDER BY position, created_at`, srcColumnID)
if err != nil {
return err
}
var srcIDs []string
for rows.Next() {
var sid string
if err := rows.Scan(&sid); err != nil {
rows.Close()
return err
}
srcIDs = append(srcIDs, sid)
}
rows.Close()
for i, sid := range srcIDs {
if _, err := tx.Exec(`UPDATE cards SET position=? WHERE id=?`, i, sid); err != nil {
return err
}
}
}
return tx.Commit()
}
func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
rows, err := db.conn.Query(`
SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at, h.actor_id
FROM card_column_history h
LEFT JOIN columns c ON c.id = h.column_id
WHERE h.card_id=?
ORDER BY h.entered_at
`, cardID)
if err != nil {
return nil, err
}
defer rows.Close()
now := time.Now().UTC()
cols := []HistoryEntry{}
for rows.Next() {
var h HistoryEntry
var exited sql.NullString
var actor sql.NullString
if err := rows.Scan(&h.ID, &h.CardID, &h.ColumnID, &h.ColumnName, &h.EnteredAt, &exited, &actor); err != nil {
return nil, err
}
if actor.Valid && actor.String != "" {
s := actor.String
h.ActorID = &s
}
entered, err := time.Parse(time.RFC3339Nano, h.EnteredAt)
if err != nil {
return nil, err
}
var end time.Time
if exited.Valid {
h.ExitedAt = &exited.String
end, _ = time.Parse(time.RFC3339Nano, exited.String)
} else {
end = now
}
h.DurationMs = end.Sub(entered).Milliseconds()
cols = append(cols, h)
}
if err := rows.Err(); err != nil {
return nil, err
}
lockRows, err := db.conn.Query(`
SELECT id, card_id, locked_at, unlocked_at, actor_id
FROM card_lock_history
WHERE card_id=?
ORDER BY locked_at
`, cardID)
if err != nil {
return nil, err
}
defer lockRows.Close()
locks := []LockPeriod{}
var totalLocked int64
currently := false
for lockRows.Next() {
var lp LockPeriod
var unlocked sql.NullString
var actor sql.NullString
if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked, &actor); err != nil {
return nil, err
}
if actor.Valid && actor.String != "" {
s := actor.String
lp.ActorID = &s
}
start, err := time.Parse(time.RFC3339Nano, lp.LockedAt)
if err != nil {
return nil, err
}
var end time.Time
if unlocked.Valid {
lp.UnlockedAt = &unlocked.String
end, _ = time.Parse(time.RFC3339Nano, unlocked.String)
} else {
end = now
currently = true
}
lp.DurationMs = end.Sub(start).Milliseconds()
totalLocked += lp.DurationMs
locks = append(locks, lp)
}
if err := lockRows.Err(); err != nil {
return nil, err
}
evRows, err := db.conn.Query(`
SELECT id, card_id, kind, actor_id, payload, created_at
FROM card_events
WHERE card_id=?
ORDER BY created_at
`, cardID)
if err != nil {
return nil, err
}
defer evRows.Close()
events := []CardEvent{}
for evRows.Next() {
var e CardEvent
var actor sql.NullString
if err := evRows.Scan(&e.ID, &e.CardID, &e.Kind, &actor, &e.Payload, &e.CreatedAt); err != nil {
return nil, err
}
if actor.Valid && actor.String != "" {
s := actor.String
e.ActorID = &s
}
events = append(events, e)
}
return &CardHistoryResponse{
ColumnHistory: cols,
LockPeriods: locks,
Events: events,
TotalLockedMs: totalLocked,
CurrentlyLock: currently,
}, nil
}
type CardMessage struct {
ID string `json:"id"`
CardID string `json:"card_id"`
AuthorID *string `json:"author_id"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
}
func (db *DB) ListCardMessages(cardID string) ([]CardMessage, error) {
rows, err := db.conn.Query(
`SELECT id, card_id, author_id, body, created_at FROM card_messages WHERE card_id=? ORDER BY created_at`,
cardID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CardMessage{}
for rows.Next() {
var m CardMessage
var author sql.NullString
if err := rows.Scan(&m.ID, &m.CardID, &author, &m.Body, &m.CreatedAt); err != nil {
return nil, err
}
if author.Valid && author.String != "" {
s := author.String
m.AuthorID = &s
}
out = append(out, m)
}
return out, rows.Err()
}
func (db *DB) CreateCardMessage(cardID, authorID, body string) (*CardMessage, error) {
body = strings.TrimSpace(body)
if body == "" {
return nil, fmt.Errorf("body required")
}
if authorID == "" {
return nil, fmt.Errorf("author required")
}
var exists int
if err := db.conn.QueryRow(`SELECT 1 FROM cards WHERE id=?`, cardID).Scan(&exists); err != nil {
return nil, fmt.Errorf("card not found: %w", err)
}
s := authorID
m := &CardMessage{ID: newID(), CardID: cardID, AuthorID: &s, Body: body, CreatedAt: nowRFC3339()}
if _, err := db.conn.Exec(
`INSERT INTO card_messages (id, card_id, author_id, body, created_at) VALUES (?, ?, ?, ?, ?)`,
m.ID, m.CardID, authorID, m.Body, m.CreatedAt,
); err != nil {
return nil, err
}
return m, nil
}
func (db *DB) DeleteCardMessage(id, requesterID string) error {
if requesterID == "" {
return fmt.Errorf("session required")
}
res, err := db.conn.Exec(`DELETE FROM card_messages WHERE id=? AND author_id=?`, id, requesterID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("not found or not author")
}
return nil
}
// DuplicateCard clones a card into the same column at the end of the list.
// Copies title, description, color, requester, assignee, tags, deadline, stickers.
// Does NOT copy card_column_history, card_lock_history, card_events, card_messages.
// Title gets " (copia)" suffix.
func (db *DB) DuplicateCard(srcID, actorID string) (*Card, error) {
tx, err := db.conn.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var src Card
var assignee sql.NullString
var deadline sql.NullString
var tagsJSON, stickersJSON string
if err := tx.QueryRow(
`SELECT requester, title, description, color, column_id, assignee_id, tags, stickers, deadline
FROM cards WHERE id=? AND deleted_at IS NULL`, srcID,
).Scan(&src.Requester, &src.Title, &src.Description, &src.Color, &src.ColumnID, &assignee, &tagsJSON, &stickersJSON, &deadline); err != nil {
return nil, fmt.Errorf("card not found: %w", err)
}
if assignee.Valid && assignee.String != "" {
s := assignee.String
src.AssigneeID = &s
}
if deadline.Valid && deadline.String != "" {
s := deadline.String
src.Deadline = &s
}
src.Tags = parseTags(tagsJSON)
src.Stickers = parseStickers(stickersJSON)
var maxPos sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, src.ColumnID).Scan(&maxPos); err != nil {
return nil, err
}
pos := 0
if maxPos.Valid {
pos = int(maxPos.Int64) + 1
}
var maxSeq sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil {
return nil, err
}
seqNum := 1
if maxSeq.Valid {
seqNum = int(maxSeq.Int64) + 1
}
now := nowRFC3339()
newTitle := src.Title + " (copia)"
c := Card{
ID: newID(), SeqNum: seqNum, Requester: src.Requester, Title: newTitle,
Description: src.Description, Color: src.Color, ColumnID: src.ColumnID, Position: pos,
AssigneeID: src.AssigneeID, Tags: src.Tags, Stickers: src.Stickers, Deadline: src.Deadline,
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
}
var assigneeVal any
if c.AssigneeID != nil && *c.AssigneeID != "" {
assigneeVal = *c.AssigneeID
}
var deadlineVal any
if c.Deadline != nil && *c.Deadline != "" {
deadlineVal = *c.Deadline
}
if _, err := tx.Exec(
`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, assignee_id, tags, stickers, deadline, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position,
assigneeVal, encodeTags(c.Tags), encodeStickers(c.Stickers), deadlineVal, c.CreatedAt, c.UpdatedAt,
); err != nil {
return nil, err
}
if _, err := tx.Exec(
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
); err != nil {
return nil, err
}
var destDone int
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, c.ColumnID).Scan(&destDone); err != nil {
return nil, err
}
if destDone == 1 {
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
return nil, err
}
c.CompletedAt = &now
}
if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": newTitle, "column_id": c.ColumnID, "duplicated_from": srcID}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &c, nil
}