package main import ( "database/sql" _ "embed" "encoding/json" "fmt" "sort" "strings" "time" "fn-registry/functions/core" "fn-registry/functions/infra" ) //go:embed migrations/001_init.sql var migrationSQL string 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 Card struct { ID string `json:"id"` 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"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` EnteredAt string `json:"entered_at"` TimeInColumn int64 `json:"time_in_column_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"` } 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"` } type CardHistoryResponse struct { ColumnHistory []HistoryEntry `json:"column_history"` LockPeriods []LockPeriod `json:"lock_periods"` TotalLockedMs int64 `json:"total_locked_ms"` CurrentlyLock bool `json:"currently_locked"` } 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 := conn.Exec(migrationSQL); err != nil { conn.Close() return nil, fmt.Errorf("migrate: %w", err) } // Idempotent column adds for forward-compat with older DBs. 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 '[]'"}, {"card_column_history", "actor_id", "TEXT"}, {"card_lock_history", "actor_id", "TEXT"}, } for _, s := range specs { exists, err := 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 columnExists(conn *sql.DB, table, name string) (bool, error) { rows, err := conn.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) if err != nil { return false, err } defer rows.Close() for rows.Next() { var cid int var colName, ctype string var notnull int var dflt sql.NullString var pk int if err := rows.Scan(&cid, &colName, &ctype, ¬null, &dflt, &pk); err != nil { return false, err } if colName == name { return true, nil } } return false, rows.Err() } 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 (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 } // --- 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.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.created_at, c.updated_at, h.entered_at FROM cards c LEFT JOIN card_column_history h ON h.card_id = c.id AND h.exited_at IS NULL WHERE c.deleted_at IS NULL ORDER BY c.column_id, c.position, c.created_at `) 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 locked int if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil { return nil, err } 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() c := Card{ ID: newID(), Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos, Tags: []string{}, CreatedAt: now, UpdatedAt: now, EnteredAt: now, } tx, err := db.conn.Begin() if err != nil { return nil, err } defer tx.Rollback() if _, err := tx.Exec( `INSERT INTO cards (id, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, c.ID, 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 := 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 } 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 { if _, err := tx.Exec(`UPDATE cards SET requester=?, updated_at=? WHERE id=?`, *patch.Requester, nowRFC3339(), id); err != nil { return err } } if patch.Title != nil { if _, err := tx.Exec(`UPDATE cards SET title=?, updated_at=? WHERE id=?`, *patch.Title, nowRFC3339(), id); err != nil { return err } } if patch.Description != nil { if _, err := tx.Exec(`UPDATE cards SET description=?, updated_at=? WHERE id=?`, *patch.Description, nowRFC3339(), id); 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 patch.HasAssignee { 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 } } else { if _, err := tx.Exec(`UPDATE cards SET assignee_id=?, updated_at=? WHERE id=?`, *patch.AssigneeID, nowRFC3339(), id); 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 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 { _, err := db.conn.Exec(`UPDATE cards SET deleted_at=?, updated_at=? WHERE id=?`, nowRFC3339(), nowRFC3339(), id) return err } // RestoreCard removes the deleted_at flag. func (db *DB) RestoreCard(id string) error { _, err := db.conn.Exec(`UPDATE cards SET deleted_at=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id) return err } // 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.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.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 locked int if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } 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 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 if err := rows.Scan(&h.ID, &h.CardID, &h.ColumnID, &h.ColumnName, &h.EnteredAt, &exited); err != nil { return nil, err } 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 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 if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked); err != nil { return nil, err } 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 } return &CardHistoryResponse{ ColumnHistory: cols, LockPeriods: locks, TotalLockedMs: totalLocked, CurrentlyLock: currently, }, nil }