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"` 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, 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.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 } 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 } } } 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(¤t); 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 }