From d5a7f60cb6d7d581e27b7490cebe618a364e82dc Mon Sep 17 00:00:00 2001 From: squeakycheese75 Date: Mon, 4 May 2026 14:47:52 +0200 Subject: [PATCH] feat: added remove portfolio target --- db/query/query.sql | 8 ++++- internal/app/runtime.go | 2 ++ internal/cli/target.go | 39 +++++++++++++++++++++++- internal/db/query.sql.go | 20 +++++++++++- internal/domain/errors.go | 1 + internal/domain/usecase.go | 6 ++++ internal/render/target.go | 15 +++++++-- internal/repository/target_repository.go | 21 +++++++++++++ internal/usecase/target_remove.go | 37 ++++++++++++++++++++++ internal/usecase/usecase.go | 2 +- 10 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 internal/usecase/target_remove.go diff --git a/db/query/query.sql b/db/query/query.sql index 6292849..e5e33ef 100644 --- a/db/query/query.sql +++ b/db/query/query.sql @@ -175,9 +175,15 @@ SELECT t.symbol, t.target_price, t.type, - t.quote_currency + t.quote_currency, + t.id FROM portfolio_targets AS t JOIN portfolios AS p ON t.portfolio_id = p.id WHERE t.portfolio_id = ? AND t.deleted_at IS NULL ORDER BY t.symbol ASC; + +-- name: DeleteTarget :execresult +DELETE FROM portfolio_targets +WHERE id = ? +AND portfolio_id = ?; diff --git a/internal/app/runtime.go b/internal/app/runtime.go index ff223b1..870b5f8 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -21,6 +21,7 @@ type Runtime struct { GetMorningBrief *usecase.GetMorningBriefUsecase SetTarget *usecase.SetTargetUseCase ListTargets *usecase.ListTargetsUseCase + RemoveTarget *usecase.RemoveTargetUsecase } func BuildRuntime(dbPath string) (*Runtime, error) { @@ -116,5 +117,6 @@ func BuildRuntime(dbPath string) (*Runtime, error) { GetMorningBrief: usecase.NewGetMorningBriefUsecase(reportingBuilder), SetTarget: usecase.NewSetTargetUseCase(portfolioRepo, targetRespository), ListTargets: usecase.NewListTargetsUseCase(portfolioRepo, targetRespository), + RemoveTarget: usecase.NewRemoveTargetUsecase(portfolioRepo, targetRespository), }, nil } diff --git a/internal/cli/target.go b/internal/cli/target.go index de8e65f..2d10cee 100644 --- a/internal/cli/target.go +++ b/internal/cli/target.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strconv" "strings" "github.com/spf13/cobra" @@ -17,7 +18,7 @@ func newTargetCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { cmd.AddCommand(newTargetSetCmd(runtimeBuilder)) cmd.AddCommand(newTargetListCmd(runtimeBuilder)) - // cmd.AddCommand(newTargetDeleteCmd(runtimeBuilder)) + cmd.AddCommand(newTargetDeleteCmd(runtimeBuilder)) return cmd } @@ -104,3 +105,39 @@ func newTargetListCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { return cmd } + +func newTargetDeleteCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { + var portfolioName string + + cmd := &cobra.Command{ + Use: "remove TARGET_ID", + Short: "Remove a portfolio price target", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + targetID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid target ID %q", args[0]) + } + + rt, err := runtimeBuilder() + if err != nil { + return err + } + + err = rt.RemoveTarget.Execute(cmd.Context(), domain.DeleteTargetUseCaseInput{ + PortfolioName: portfolioName, + TargetID: targetID, + }) + if err != nil { + return err + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Removed target %d\n", targetID) + return err + }, + } + + cmd.Flags().StringVar(&portfolioName, "portfolio", "main", "Portfolio name") + + return cmd +} diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index 21174e0..bfea4d4 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -216,6 +216,21 @@ func (q *Queries) CreateTarget(ctx context.Context, arg CreateTargetParams) (int return id, err } +const deleteTarget = `-- name: DeleteTarget :execresult +DELETE FROM portfolio_targets +WHERE id = ? +AND portfolio_id = ? +` + +type DeleteTargetParams struct { + ID int64 `json:"id"` + PortfolioID int64 `json:"portfolio_id"` +} + +func (q *Queries) DeleteTarget(ctx context.Context, arg DeleteTargetParams) (sql.Result, error) { + return q.db.ExecContext(ctx, deleteTarget, arg.ID, arg.PortfolioID) +} + const getFXCacheByPair = `-- name: GetFXCacheByPair :one SELECT base_currency, @@ -500,7 +515,8 @@ SELECT t.symbol, t.target_price, t.type, - t.quote_currency + t.quote_currency, + t.id FROM portfolio_targets AS t JOIN portfolios AS p ON t.portfolio_id = p.id WHERE t.portfolio_id = ? @@ -514,6 +530,7 @@ type ListTargetsByPortfolioRow struct { TargetPrice float64 `json:"target_price"` Type string `json:"type"` QuoteCurrency string `json:"quote_currency"` + ID int64 `json:"id"` } func (q *Queries) ListTargetsByPortfolio(ctx context.Context, portfolioID int64) ([]ListTargetsByPortfolioRow, error) { @@ -531,6 +548,7 @@ func (q *Queries) ListTargetsByPortfolio(ctx context.Context, portfolioID int64) &i.TargetPrice, &i.Type, &i.QuoteCurrency, + &i.ID, ); err != nil { return nil, err } diff --git a/internal/domain/errors.go b/internal/domain/errors.go index f78993a..56397d4 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -12,4 +12,5 @@ var ( ErrPriceCacheNotFound = errors.New("price cache not found") ErrFXCacheNotFound = errors.New("fx cache not found") ErrPortfolioSnapshotNotFound = errors.New("portfolio snapshot not found") + ErrTargetNotFound = errors.New("target not found") ) diff --git a/internal/domain/usecase.go b/internal/domain/usecase.go index 30f1948..2c3e411 100644 --- a/internal/domain/usecase.go +++ b/internal/domain/usecase.go @@ -228,6 +228,7 @@ type SetTargetUseCaseOutput struct { Type TargetType TargetPrice float64 QuoteCurrency string + TargetID int64 } type ListTargetsUseCaseInput struct { @@ -238,3 +239,8 @@ type ListTargetsUseCaseOutput struct { PortfolioName string Targets []Target } + +type DeleteTargetUseCaseInput struct { + TargetID int64 + PortfolioName string +} diff --git a/internal/render/target.go b/internal/render/target.go index b666d51..fca3ec1 100644 --- a/internal/render/target.go +++ b/internal/render/target.go @@ -7,11 +7,15 @@ import ( "github.com/squeakycheese75/tick/internal/domain" ) +const targetListFormat = "%-4s %-8s %-14s %12s %-3s\n" +const targetRowFormat = "%-4d %-8s %-14s %12.2f %-3s\n" + func RenderSetTarget(w io.Writer, out domain.SetTargetUseCaseOutput) error { _, err := fmt.Fprintf( w, - "Set %s target for %s in portfolio %s: %.2f %s\n", + "Set %s target %d for %s in portfolio %s: %.2f %s\n", out.Type, + out.TargetID, out.Symbol, out.PortfolioName, out.TargetPrice, @@ -19,7 +23,6 @@ func RenderSetTarget(w io.Writer, out domain.SetTargetUseCaseOutput) error { ) return err } - func RenderListTargets(w io.Writer, out domain.ListTargetsUseCaseOutput) error { if len(out.Targets) == 0 { _, err := fmt.Fprintf(w, "No targets set for portfolio %q\n", out.PortfolioName) @@ -31,10 +34,16 @@ func RenderListTargets(w io.Writer, out domain.ListTargetsUseCaseOutput) error { return err } + _, err = fmt.Fprintf(w, targetListFormat, "ID", "SYMBOL", "TYPE", "PRICE", "CCY") + if err != nil { + return err + } + for _, t := range out.Targets { _, err := fmt.Fprintf( w, - "%-6s %-11s %12.2f %s\n", + targetRowFormat, + t.ID, t.Symbol, t.Type, t.TargetPrice, diff --git a/internal/repository/target_repository.go b/internal/repository/target_repository.go index 595dfdf..ee0197c 100644 --- a/internal/repository/target_repository.go +++ b/internal/repository/target_repository.go @@ -53,8 +53,29 @@ func (r *TargetRepository) ListByPortfolio(ctx context.Context, portfolioID int6 Type: domain.TargetType(row.Type), QuoteCurrency: row.QuoteCurrency, TargetPrice: row.TargetPrice, + ID: row.ID, }) } return targets, nil } + +func (r *TargetRepository) Delete(ctx context.Context, targetID, portfolioID int64) error { + result, err := r.q.DeleteTarget(ctx, db.DeleteTargetParams{ + ID: targetID, + PortfolioID: portfolioID, + }) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return domain.ErrTargetNotFound + } + + return nil +} diff --git a/internal/usecase/target_remove.go b/internal/usecase/target_remove.go new file mode 100644 index 0000000..e496031 --- /dev/null +++ b/internal/usecase/target_remove.go @@ -0,0 +1,37 @@ +package usecase + +import ( + "context" + "errors" + "fmt" + + "github.com/squeakycheese75/tick/internal/domain" +) + +type RemoveTargetUsecase struct { + targets TargetRepository + portfolios PortfolioRepository +} + +func NewRemoveTargetUsecase(portfolioRepo PortfolioRepository, targetRepo TargetRepository) *RemoveTargetUsecase { + return &RemoveTargetUsecase{ + portfolios: portfolioRepo, + targets: targetRepo, + } +} + +func (uc *RemoveTargetUsecase) Execute(ctx context.Context, in domain.DeleteTargetUseCaseInput) error { + portfolio, err := uc.portfolios.GetByName(ctx, in.PortfolioName) + if err != nil { + if errors.Is(err, domain.ErrPortfolioNotFound) { + return fmt.Errorf("portfolio %q not found", in.PortfolioName) + } + return fmt.Errorf("get portfolio %q: %w", in.PortfolioName, err) + } + + if in.TargetID <= 0 { + return domain.ErrTargetNotFound + } + + return uc.targets.Delete(ctx, in.TargetID, portfolio.ID) +} diff --git a/internal/usecase/usecase.go b/internal/usecase/usecase.go index 250d10c..b3821a5 100644 --- a/internal/usecase/usecase.go +++ b/internal/usecase/usecase.go @@ -37,7 +37,7 @@ type ( TargetRepository interface { Save(ctx context.Context, target domain.Target) error ListByPortfolio(ctx context.Context, portfolioID int64) ([]domain.Target, error) - // Delete(ctx context.Context, portfolioID int64, symbol string, targetType domain.TargetType) error + Delete(ctx context.Context, targetID, portfolioID int64) error } )