443 lines
12 KiB
Go
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, ¬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) }
|
|
|
|
// --- 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()
|
|
}
|