9f4fd85db3
Adds column-level max time limit. Cards whose time_in_column_ms exceeds the limit show a red border + red halo. Columns marked as Done never trigger the visual regardless of the limit (per spec). Backend: - Migration 011_column_max_time.sql adds columns.max_time_minutes INTEGER NOT NULL DEFAULT 0 (0 = no limit). Aditiva, idempotente. - Column struct + ColumnPatch + UpdateColumn handle the new field; negatives clamp to 0; listing query includes it. - handleUpdateColumn (PATCH /api/columns/:id) accepts max_time_minutes in the JSON body. Frontend: - Column TS interface + UpdateColumnInput updated. - KanbanColumn context menu: new entry "Tiempo maximo" using window.prompt for low-friction config; shows current value when >0. - KanbanCard receives columnOverdue prop calculated from the column state and card.time_in_column_ms; renders red border (var --mantine-color-red-6) with 2px width + 2px red halo when overdue. - data-card-id, data-column-overdue, data-locked attributes on the card paper element so e2e tests / scripts can query state. Tests: TestColumnMaxTimeMinutes_Defaults + _Update verify the schema default, the clamp on negative input, and that updating max_time leaves other fields untouched. Visual regression of the red border kept out of automated e2e because it requires either clock control or real cards aged > N minutes; will be verified manually after merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1213 lines
35 KiB
Go
1213 lines
35 KiB
Go
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"`
|
|
MaxTimeMinutes int `json:"max_time_minutes"`
|
|
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, max_time_minutes, 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.MaxTimeMinutes, &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
|
|
MaxTimeMinutes *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
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
if patch.MaxTimeMinutes != nil {
|
|
m := *patch.MaxTimeMinutes
|
|
if m < 0 {
|
|
m = 0
|
|
}
|
|
if _, err := db.conn.Exec(`UPDATE columns SET max_time_minutes=? WHERE id=?`, m, 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
|
|
}
|