// Package app contains the transactional business rules for the fixed Haven board. package app import ( "context" "database/sql" "fmt" "math" "strings" "time" "havenllo/internal/domain" "havenllo/internal/store" ) const positionStep = 1024.0 const minimumPositionGap = 0.0001 type Service struct{ db *sql.DB } func New(db *sql.DB) *Service { return &Service{db: db} } func (s *Service) Health(ctx context.Context) error { return s.db.PingContext(ctx) } func (s *Service) Board(ctx context.Context) (domain.Snapshot, error) { return store.Snapshot(ctx, s.db) } func (s *Service) Lists(ctx context.Context) ([]domain.List, error) { return store.Lists(ctx, s.db, true) } func (s *Service) List(ctx context.Context, id int64) (domain.List, error) { return store.List(ctx, s.db, id) } func (s *Service) Cards(ctx context.Context, listID int64) ([]domain.Card, error) { if _, err := store.List(ctx, s.db, listID); err != nil { return nil, err } return store.Cards(ctx, s.db, listID) } func (s *Service) Card(ctx context.Context, id int64) (domain.Card, error) { return store.Card(ctx, s.db, id) } func (s *Service) CreateList(ctx context.Context, name string, position *float64) (domain.List, error) { name, err := validName(name) if err != nil { return domain.List{}, err } return withTx(ctx, s.db, func(tx *sql.Tx) (domain.List, error) { pos, err := positionForNewList(ctx, tx, position) if err != nil { return domain.List{}, err } result, err := tx.ExecContext(ctx, "INSERT INTO lists (name, position) VALUES (?, ?)", name, pos) if err != nil { return domain.List{}, err } id, err := result.LastInsertId() if err != nil { return domain.List{}, err } if err := normalizeLists(ctx, tx); err != nil { return domain.List{}, err } return store.List(ctx, tx, id) }) } func (s *Service) UpdateList(ctx context.Context, id int64, name *string, position *float64) (domain.List, error) { if name != nil { var err error if *name, err = validName(*name); err != nil { return domain.List{}, err } } if position != nil && !validPosition(*position) { return domain.List{}, domain.ValidationError{Message: "position must be a finite number"} } return withTx(ctx, s.db, func(tx *sql.Tx) (domain.List, error) { list, err := store.List(ctx, tx, id) if err != nil { return domain.List{}, err } if name != nil { list.Name = *name } if position != nil { list.Position = *position } if _, err := tx.ExecContext(ctx, "UPDATE lists SET name = ?, position = ? WHERE id = ?", list.Name, list.Position, id); err != nil { return domain.List{}, err } if err := normalizeLists(ctx, tx); err != nil { return domain.List{}, err } return store.List(ctx, tx, id) }) } func (s *Service) DeleteList(ctx context.Context, id int64) error { return withTxVoid(ctx, s.db, func(tx *sql.Tx) error { if _, err := store.List(ctx, tx, id); err != nil { return err } count, err := store.CountLists(ctx, tx) if err != nil { return err } if count <= 1 { return domain.ErrLastList } _, err = tx.ExecContext(ctx, "DELETE FROM lists WHERE id = ?", id) return err }) } func (s *Service) CreateCard(ctx context.Context, listID int64, title, description string, position *float64) (domain.Card, error) { title, err := validTitle(title) if err != nil { return domain.Card{}, err } if err := validDescription(description); err != nil { return domain.Card{}, err } return withTx(ctx, s.db, func(tx *sql.Tx) (domain.Card, error) { if _, err := store.List(ctx, tx, listID); err != nil { return domain.Card{}, err } pos, err := positionForNewCard(ctx, tx, listID, position) if err != nil { return domain.Card{}, err } now := timestamp() result, err := tx.ExecContext(ctx, `INSERT INTO cards (list_id, title, description, position, done, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?)`, listID, title, description, pos, now, now) if err != nil { return domain.Card{}, err } id, err := result.LastInsertId() if err != nil { return domain.Card{}, err } if err := normalizeCards(ctx, tx, listID); err != nil { return domain.Card{}, err } return store.Card(ctx, tx, id) }) } type CardPatch struct { Title *string Description *string Done *bool ListID *int64 Position *float64 } func (s *Service) UpdateCard(ctx context.Context, id int64, patch CardPatch) (domain.Card, error) { if patch.Title != nil { var err error if *patch.Title, err = validTitle(*patch.Title); err != nil { return domain.Card{}, err } } if patch.Description != nil { if err := validDescription(*patch.Description); err != nil { return domain.Card{}, err } } if patch.Position != nil && !validPosition(*patch.Position) { return domain.Card{}, domain.ValidationError{Message: "position must be a finite number"} } if patch.ListID != nil && *patch.ListID < 1 { return domain.Card{}, domain.ValidationError{Message: "list_id must be a positive integer"} } return withTx(ctx, s.db, func(tx *sql.Tx) (domain.Card, error) { card, err := store.Card(ctx, tx, id) if err != nil { return domain.Card{}, err } sourceListID := card.ListID if patch.Title != nil { card.Title = *patch.Title } if patch.Description != nil { card.Description = *patch.Description } if patch.Done != nil { card.Done = *patch.Done } if patch.ListID != nil { if _, err := store.List(ctx, tx, *patch.ListID); err != nil { return domain.Card{}, err } card.ListID = *patch.ListID } if patch.Position != nil { card.Position = *patch.Position } else if card.ListID != sourceListID { pos, err := store.MaxCardPosition(ctx, tx, card.ListID) if err != nil { return domain.Card{}, err } card.Position = pos + positionStep } done := 0 if card.Done { done = 1 } if _, err := tx.ExecContext(ctx, `UPDATE cards SET list_id = ?, title = ?, description = ?, position = ?, done = ?, updated_at = ? WHERE id = ?`, card.ListID, card.Title, card.Description, card.Position, done, timestamp(), id); err != nil { return domain.Card{}, err } if err := normalizeCards(ctx, tx, sourceListID); err != nil { return domain.Card{}, err } if card.ListID != sourceListID { if err := normalizeCards(ctx, tx, card.ListID); err != nil { return domain.Card{}, err } } return store.Card(ctx, tx, id) }) } func (s *Service) DeleteCard(ctx context.Context, id int64) error { result, err := s.db.ExecContext(ctx, "DELETE FROM cards WHERE id = ?", id) return store.MustRows(result, err) } func positionForNewList(ctx context.Context, q store.DBTX, requested *float64) (float64, error) { if requested != nil { if !validPosition(*requested) { return 0, domain.ValidationError{Message: "position must be a finite number"} } return *requested, nil } max, err := store.MaxListPosition(ctx, q) return max + positionStep, err } func positionForNewCard(ctx context.Context, q store.DBTX, listID int64, requested *float64) (float64, error) { if requested != nil { if !validPosition(*requested) { return 0, domain.ValidationError{Message: "position must be a finite number"} } return *requested, nil } max, err := store.MaxCardPosition(ctx, q, listID) return max + positionStep, err } func normalizeLists(ctx context.Context, tx *sql.Tx) error { lists, err := store.Lists(ctx, tx, false) if err != nil { return err } if !needsNormalizationList(lists) { return nil } for i, list := range lists { if _, err := tx.ExecContext(ctx, "UPDATE lists SET position = ? WHERE id = ?", float64(i+1)*positionStep, list.ID); err != nil { return err } } return nil } func normalizeCards(ctx context.Context, tx *sql.Tx, listID int64) error { cards, err := store.Cards(ctx, tx, listID) if err != nil { return err } if !needsNormalizationCards(cards) { return nil } for i, card := range cards { if _, err := tx.ExecContext(ctx, "UPDATE cards SET position = ? WHERE id = ?", float64(i+1)*positionStep, card.ID); err != nil { return err } } return nil } func needsNormalizationList(lists []domain.List) bool { for i, list := range lists { if !validPosition(list.Position) || (i > 0 && list.Position-lists[i-1].Position < minimumPositionGap) { return true } } return false } func needsNormalizationCards(cards []domain.Card) bool { for i, card := range cards { if !validPosition(card.Position) || (i > 0 && card.Position-cards[i-1].Position < minimumPositionGap) { return true } } return false } func validName(name string) (string, error) { return validTrimmed(name, 120, "name") } func validTitle(title string) (string, error) { return validTrimmed(title, 240, "title") } func validTrimmed(value string, max int, field string) (string, error) { value = strings.TrimSpace(value) if value == "" { return "", domain.ValidationError{Message: field + " is required"} } if len([]rune(value)) > max { return "", domain.ValidationError{Message: fmt.Sprintf("%s must be at most %d characters", field, max)} } return value, nil } func validDescription(description string) error { if len([]rune(description)) > 10000 { return domain.ValidationError{Message: "description must be at most 10000 characters"} } return nil } func validPosition(position float64) bool { return !math.IsNaN(position) && !math.IsInf(position, 0) } func timestamp() string { return time.Now().UTC().Format(time.RFC3339Nano) } func withTx[T any](ctx context.Context, db *sql.DB, run func(*sql.Tx) (T, error)) (T, error) { var zero T tx, err := db.BeginTx(ctx, nil) if err != nil { return zero, err } value, err := run(tx) if err != nil { _ = tx.Rollback() return zero, err } if err := tx.Commit(); err != nil { return zero, err } return value, nil } func withTxVoid(ctx context.Context, db *sql.DB, run func(*sql.Tx) error) error { _, err := withTx(ctx, db, func(tx *sql.Tx) (struct{}, error) { return struct{}{}, run(tx) }) return err }