bee688e574
- app.md - auth.go - chat.go - chat.log - db.go - frontend/package.json - frontend/pnpm-lock.yaml - frontend/src/App.tsx - frontend/src/Root.tsx - frontend/src/api.ts - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
720 lines
20 KiB
Go
720 lines
20 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"`
|
|
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"`
|
|
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"},
|
|
{"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 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.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 locked int
|
|
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &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
|
|
}
|
|
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,
|
|
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, 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"
|
|
}
|
|
|
|
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.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.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 locked int
|
|
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &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
|
|
}
|
|
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
|
|
}
|