package treasure_chest import ( "context" "errors" "fmt" "log/slog" "slices" "spend-sparrow/internal/auth_types" "spend-sparrow/internal/core" "spend-sparrow/internal/treasure_chest_types" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) type Service interface { Add(ctx context.Context, user *auth_types.User, parentId, name string) (*treasure_chest_types.TreasureChest, error) Update(ctx context.Context, user *auth_types.User, id, parentId, name string) (*treasure_chest_types.TreasureChest, error) Get(ctx context.Context, user *auth_types.User, id string) (*treasure_chest_types.TreasureChest, error) GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error) Delete(ctx context.Context, user *auth_types.User, id string) error } type ServiceImpl struct { db *sqlx.DB clock core.Clock random core.Random } func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service { return ServiceImpl{ db: db, clock: clock, random: random, } } func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, parentId, name string) (*treasure_chest_types.TreasureChest, error) { if user == nil { return nil, core.ErrUnauthorized } newId, err := s.random.UUID(ctx) if err != nil { return nil, core.ErrInternal } err = core.ValidateString(name, "name") if err != nil { return nil, err } var parentUuid *uuid.UUID if parentId != "" { parent, err := s.Get(ctx, user, parentId) if err != nil { return nil, err } if parent.ParentId != nil { return nil, fmt.Errorf("only a depth of 1 allowed: %w", core.ErrBadRequest) } parentUuid = &parent.Id } treasureChest := &treasure_chest_types.TreasureChest{ Id: newId, ParentId: parentUuid, UserId: user.Id, Name: name, CurrentBalance: 0, CreatedAt: s.clock.Now(), CreatedBy: user.Id, UpdatedAt: nil, UpdatedBy: nil, } r, err := s.db.NamedExecContext(ctx, ` INSERT INTO treasure_chest (id, parent_id, user_id, name, current_balance, created_at, created_by) VALUES (:id, :parent_id, :user_id, :name, :current_balance, :created_at, :created_by)`, treasureChest) err = core.TransformAndLogDbError(ctx, "treasureChest Insert", r, err) if err != nil { return nil, err } return treasureChest, nil } func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, idStr, parentId, name string) (*treasure_chest_types.TreasureChest, error) { if user == nil { return nil, core.ErrUnauthorized } err := core.ValidateString(name, "name") if err != nil { return nil, err } id, err := uuid.Parse(idStr) if err != nil { slog.ErrorContext(ctx, "treasureChest update", "err", err) return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest) } tx, err := s.db.BeginTxx(ctx, nil) err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() treasureChest := &treasure_chest_types.TreasureChest{} err = tx.GetContext(ctx, treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, id) err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err) if err != nil { if errors.Is(err, core.ErrNotFound) { return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err) } return nil, core.ErrInternal } var parentUuid *uuid.UUID if parentId != "" { parent, err := s.Get(ctx, user, parentId) if err != nil { return nil, err } var childCount int err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id) err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err) if err != nil { return nil, err } if parent.ParentId != nil || childCount > 0 { return nil, fmt.Errorf("only one level allowed: %w", core.ErrBadRequest) } parentUuid = &parent.Id } timestamp := s.clock.Now() treasureChest.Name = name treasureChest.ParentId = parentUuid treasureChest.UpdatedAt = ×tamp treasureChest.UpdatedBy = &user.Id r, err := tx.NamedExecContext(ctx, ` UPDATE treasure_chest SET parent_id = :parent_id, name = :name, current_balance = :current_balance, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id AND user_id = :user_id`, treasureChest) err = core.TransformAndLogDbError(ctx, "treasureChest Update", r, err) if err != nil { return nil, err } err = tx.Commit() err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err) if err != nil { return nil, err } return treasureChest, nil } func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*treasure_chest_types.TreasureChest, error) { if user == nil { return nil, core.ErrUnauthorized } uuid, err := uuid.Parse(id) if err != nil { slog.ErrorContext(ctx, "treasureChest get", "err", err) return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest) } var treasureChest treasure_chest_types.TreasureChest err = s.db.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, uuid) err = core.TransformAndLogDbError(ctx, "treasureChest Get", nil, err) if err != nil { if errors.Is(err, core.ErrNotFound) { return nil, fmt.Errorf("treasureChest %v not found: %w", id, err) } return nil, core.ErrInternal } return &treasureChest, nil } func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error) { if user == nil { return nil, core.ErrUnauthorized } treasureChests := make([]*treasure_chest_types.TreasureChest, 0) err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id) err = core.TransformAndLogDbError(ctx, "treasureChest GetAll", nil, err) if err != nil { return nil, err } return SortTreasureChests(treasureChests), nil } func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, idStr string) error { if user == nil { return core.ErrUnauthorized } id, err := uuid.Parse(idStr) if err != nil { slog.ErrorContext(ctx, "treasureChest delete", "err", err) return fmt.Errorf("could not parse Id: %w", core.ErrBadRequest) } tx, err := s.db.BeginTxx(ctx, nil) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err) if err != nil { return nil } defer func() { _ = tx.Rollback() }() childCount := 0 err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err) if err != nil { return err } if childCount > 0 { return fmt.Errorf("treasure chest has children: %w", core.ErrBadRequest) } transactionsCount := 0 err = tx.GetContext(ctx, &transactionsCount, `SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`, user.Id, id) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err) if err != nil { return err } if transactionsCount > 0 { return fmt.Errorf("treasure chest has transactions: %w", core.ErrBadRequest) } recurringCount := 0 err = tx.GetContext(ctx, &recurringCount, ` SELECT COUNT(*) FROM transaction_recurring WHERE user_id = ? AND treasure_chest_id = ?`, user.Id, id) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err) if err != nil { return err } if recurringCount > 0 { return fmt.Errorf("cannot delete treasure chest with existing recurring transactions: %w", core.ErrBadRequest) } r, err := tx.ExecContext(ctx, `DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", r, err) if err != nil { return err } err = tx.Commit() err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err) if err != nil { return err } return nil } func SortTreasureChests(nodes []*treasure_chest_types.TreasureChest) []*treasure_chest_types.TreasureChest { var ( roots []*treasure_chest_types.TreasureChest ) children := make(map[uuid.UUID][]*treasure_chest_types.TreasureChest) result := make([]*treasure_chest_types.TreasureChest, 0) for _, node := range nodes { if node.ParentId == nil { roots = append(roots, node) } else { children[*node.ParentId] = append(children[*node.ParentId], node) } } slices.SortFunc(roots, func(a, b *treasure_chest_types.TreasureChest) int { return compareStrings(a.Name, b.Name) }) for _, root := range roots { result = append(result, root) childList := children[root.Id] slices.SortFunc(childList, func(a, b *treasure_chest_types.TreasureChest) int { return compareStrings(a.Name, b.Name) }) result = append(result, childList...) } return result } func compareStrings(a, b string) int { if a == b { return 0 } if a < b { return -1 } return 1 }