Files
kanban/backend/db.go
T
egutierrez 9b503f0555 feat(kanban): archive automatico para cards Done +30 dias (issue 0092)
Adds an archive layer separate from the trash. Cards in is_done columns
that have been there for more than 30 days are auto-archived on the next
board load (throttled to once every 30 minutes). Archived cards leave
the board but stay in the DB and are listed in a new sidebar drawer
"Hecho (archivo)" below the existing Papelera, with a one-click restore.

Schema (migration 012_card_archived.sql):
- ALTER TABLE cards ADD COLUMN archived_at TEXT;
- NULL = active, ISO timestamp = archived. Independent from deleted_at.

Backend:
- Card.ArchivedAt + JSON; ListCardsWithTime filters archived_at IS NULL.
- New methods: ArchiveCard, UnarchiveCard, ListArchivedCards,
  AutoArchiveDoneOlderThan.
- New endpoints: GET /api/archive, POST /api/cards/:id/archive,
  POST /api/cards/:id/unarchive.
- handleGetBoard invokes maybeAutoArchive (atomic throttle, 30 min sweep,
  30 day cutoff). Errors logged but never block the board response.

Frontend:
- Card type + api.ts add the new field and helpers.
- App.tsx state for archive list, reload, archive/unarchive handlers.
- New sidebar drawer with toggle, count badge, restore button.
- KanbanCard gains an "Archivar" menu item (gated on isDone +
  onArchive prop) for manual archiving of any done card.

Tests:
- Playwright e2e/archive.spec.ts: manual archive via menu, drawer
  toggle, unarchive. Picks a done card via /api/board introspection so
  it stays stable regardless of board state.
- Auto-archive of >30d cards: not under e2e (real time travel needed);
  covered by code review of the SQL query and the throttle.

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

1303 lines
38 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"`
ArchivedAt *string `json:"archived_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.archived_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 AND c.archived_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 archived 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, &archived, &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
}
if archived.Valid && archived.String != "" {
s := archived.String
c.ArchivedAt = &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()
}
// ArchiveCard moves a card to the archive (out of the board, retrievable).
// Used both manually and by AutoArchiveDoneOlderThan.
func (db *DB) ArchiveCard(id string) error {
now := nowRFC3339()
_, err := db.conn.Exec(`UPDATE cards SET archived_at=?, updated_at=? WHERE id=? AND archived_at IS NULL AND deleted_at IS NULL`, now, now, id)
return err
}
// UnarchiveCard pulls a card out of the archive back into its column.
func (db *DB) UnarchiveCard(id string) error {
now := nowRFC3339()
_, err := db.conn.Exec(`UPDATE cards SET archived_at=NULL, updated_at=? WHERE id=? AND archived_at IS NOT NULL`, now, id)
return err
}
// AutoArchiveDoneOlderThan archives every card whose column is is_done=1 AND
// whose entered_at in that column is older than `older`. Idempotent: cards
// already archived or deleted are skipped. Returns the count affected.
func (db *DB) AutoArchiveDoneOlderThan(older time.Duration) (int64, error) {
cutoff := time.Now().UTC().Add(-older).Format(time.RFC3339Nano)
now := nowRFC3339()
res, err := db.conn.Exec(`
UPDATE cards SET archived_at=?, updated_at=?
WHERE archived_at IS NULL
AND deleted_at IS NULL
AND column_id IN (SELECT id FROM columns WHERE is_done=1)
AND id IN (
SELECT card_id FROM card_column_history
WHERE exited_at IS NULL AND entered_at < ?
)
`, now, now, cutoff)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
// ListArchivedCards returns cards in the archive, newest first.
func (db *DB) ListArchivedCards() ([]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.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at
FROM cards c
WHERE c.archived_at IS NOT NULL AND c.deleted_at IS NULL
ORDER BY c.archived_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Card{}
for rows.Next() {
var c Card
var assignee, completed, deleted, archived sql.NullString
var tagsJSON, 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, &archived, &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 archived.Valid && archived.String != "" {
s := archived.String
c.ArchivedAt = &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
}