Files
kanban/db.go
T
2026-05-06 19:04:45 +02:00

443 lines
12 KiB
Go

package main
import (
"database/sql"
_ "embed"
"fmt"
"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"`
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"`
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 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"},
{"cards", "color", "TEXT NOT NULL DEFAULT ''"},
}
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)
}
}
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, &notnull, &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) }
// --- Columns ---
func (db *DB) ListColumns() ([]Column, error) {
rows, err := db.conn.Query(`SELECT id, name, position, location, width, 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
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.CreatedAt); err != nil {
return nil, err
}
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, CreatedAt: nowRFC3339()}
_, err := db.conn.Exec(
`INSERT INTO columns (id, name, position, location, width, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
c.ID, c.Name, c.Position, c.Location, c.Width, c.CreatedAt,
)
if err != nil {
return nil, err
}
return &c, nil
}
type ColumnPatch struct {
Name *string
Position *int
Location *string
Width *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
}
}
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.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
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
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
return nil, err
}
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 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,
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, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, 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) VALUES (?, ?, ?, ?)`,
newID(), c.ID, c.ColumnID, now,
); 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
}
func (db *DB) UpdateCard(id string, patch CardPatch) 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
}
}
return tx.Commit()
}
func (db *DB) DeleteCard(id string) error {
_, err := db.conn.Exec(`DELETE FROM cards WHERE id=?`, id)
return 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).
func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var srcColumnID string
if err := tx.QueryRow(`SELECT column_id FROM cards WHERE id=?`, cardID).Scan(&srcColumnID); err != nil {
return fmt.Errorf("card not found: %w", err)
}
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) VALUES (?, ?, ?, ?)`,
newID(), cardID, destColumnID, now,
); err != nil {
return err
}
if _, err := tx.Exec(
`UPDATE cards SET column_id=?, updated_at=? WHERE id=?`,
destColumnID, now, 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) ([]HistoryEntry, 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()
out := []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()
out = append(out, h)
}
return out, rows.Err()
}