From b8dfed8ae07de8814db96d0e6751fe86e0bd6383 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Fri, 17 Apr 2026 13:47:37 +0700 Subject: [PATCH 1/6] [db] pagination helper --- internal/db/pagination.go | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 internal/db/pagination.go diff --git a/internal/db/pagination.go b/internal/db/pagination.go new file mode 100644 index 0000000..7fd1ae5 --- /dev/null +++ b/internal/db/pagination.go @@ -0,0 +1,54 @@ +package db + +import "github.com/uptrace/bun" + +type DefaultPagination interface { + DefaultLimit() int + MaxLimit() int +} + +type Pagination[T DefaultPagination] struct { + def T + + limit int + offset int +} + +func NewPagination[T DefaultPagination](limit, offset int) *Pagination[T] { + //nolint:exhaustruct // zero value + return &Pagination[T]{ + limit: limit, + offset: offset, + } +} + +func (p *Pagination[T]) Limit() int { + if p == nil { + var def T + return def.DefaultLimit() + } + + if p.limit <= 0 { + p.limit = p.def.DefaultLimit() + } + if p.limit > p.def.MaxLimit() { + p.limit = p.def.MaxLimit() + } + + return p.limit +} + +func (p *Pagination[T]) Offset() int { + if p == nil { + return 0 + } + + if p.offset < 0 { + p.offset = 0 + } + return p.offset +} + +func (p *Pagination[T]) Apply(query *bun.SelectQuery) *bun.SelectQuery { + return query.Limit(p.Limit()).Offset(p.Offset()) +} From 236344f1432cf7b55733398d1c7af9a522283fe5 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Fri, 17 Apr 2026 13:48:14 +0700 Subject: [PATCH 2/6] [projects] use universal pagination and optimize counting --- internal/projects/domain.go | 23 +++++++++++++++++++++++ internal/projects/repository.go | 23 +++++++---------------- internal/projects/service.go | 18 ++---------------- internal/server/projects/dto.go | 4 ++-- internal/server/projects/handler.go | 8 +------- 5 files changed, 35 insertions(+), 41 deletions(-) diff --git a/internal/projects/domain.go b/internal/projects/domain.go index abf9b1e..b680eb5 100644 --- a/internal/projects/domain.go +++ b/internal/projects/domain.go @@ -5,6 +5,8 @@ import ( "net/url" "strings" "time" + + "github.com/bit-issues/backend/internal/db" ) // Project represents the core business entity for a project. @@ -98,3 +100,24 @@ func validateRepoURL(repoURL string) error { return nil } + +type pagination struct { +} + +// DefaultLimit implements [db.DefaultPagination]. +func (p *pagination) DefaultLimit() int { + return DefaultLimit +} + +// MaxLimit implements [db.DefaultPagination]. +func (p *pagination) MaxLimit() int { + return MaxLimit +} + +var _ db.DefaultPagination = (*pagination)(nil) + +type Pagination = db.Pagination[*pagination] + +func NewPagination(limit, offset int) *Pagination { + return db.NewPagination[*pagination](limit, offset) +} diff --git a/internal/projects/repository.go b/internal/projects/repository.go index 03efcf7..7fa5159 100644 --- a/internal/projects/repository.go +++ b/internal/projects/repository.go @@ -53,14 +53,14 @@ func (r *Repository) GetBySlug(ctx context.Context, slug string) (*Project, erro // List retrieves a paginated list of projects, ordered by name ascending. // Returns the list of projects and no error if successful. -func (r *Repository) List(ctx context.Context, limit, offset int) ([]Project, error) { +func (r *Repository) List(ctx context.Context, pagination *Pagination) ([]Project, int, error) { models := make([]projectModel, 0) - query := r.db.NewSelect().Model(&models).OrderExpr("name ASC") - query = query.Limit(limit).Offset(offset) - - if err := query.Scan(ctx); err != nil { - return nil, fmt.Errorf("failed to list projects: %w", err) + query := r.db.NewSelect().Model(&models).OrderBy("name", bun.OrderAsc) + query = pagination.Apply(query) + total, err := query.ScanAndCount(ctx) + if err != nil { + return nil, 0, fmt.Errorf("failed to list projects: %w", err) } projects := make([]Project, 0, len(models)) @@ -68,16 +68,7 @@ func (r *Repository) List(ctx context.Context, limit, offset int) ([]Project, er projects = append(projects, *model.toDomain()) } - return projects, nil -} - -// Count returns the total number of projects in the database. -func (r *Repository) Count(ctx context.Context) (int64, error) { - count, err := r.db.NewSelect().Model((*projectModel)(nil)).Count(ctx) - if err != nil { - return 0, fmt.Errorf("failed to count projects: %w", err) - } - return int64(count), nil + return projects, total, nil } // Update modifies an existing project with the provided update data. diff --git a/internal/projects/service.go b/internal/projects/service.go index 340550e..336eddc 100644 --- a/internal/projects/service.go +++ b/internal/projects/service.go @@ -50,22 +50,8 @@ func (s *Service) GetBySlug(ctx context.Context, slug string) (*Project, error) // List retrieves a paginated list of projects. // Projects are ordered by name ascending. -func (s *Service) List(ctx context.Context, limit, offset int) ([]Project, error) { - if limit <= 0 { - limit = DefaultLimit // default - } - if limit > MaxLimit { - limit = MaxLimit // max - } - if offset < 0 { - offset = 0 - } - return s.projects.List(ctx, limit, offset) -} - -// Count returns the total number of projects. -func (s *Service) Count(ctx context.Context) (int64, error) { - return s.projects.Count(ctx) +func (s *Service) List(ctx context.Context, pagination *Pagination) ([]Project, int, error) { + return s.projects.List(ctx, pagination) } // Update modifies an existing project with the provided update data. diff --git a/internal/server/projects/dto.go b/internal/server/projects/dto.go index 44bf625..55c0629 100644 --- a/internal/server/projects/dto.go +++ b/internal/server/projects/dto.go @@ -42,10 +42,10 @@ func NewProjectResponse(p *projects.Project) ProjectResponse { // ProjectListResponse represents the API response for a list of projects. type ProjectListResponse struct { Items []ProjectResponse `json:"items"` - Total int64 `json:"total" example:"1"` + Total int `json:"total" example:"1"` } -func NewProjectListResponse(items []projects.Project, total int64) ProjectListResponse { +func NewProjectListResponse(items []projects.Project, total int) ProjectListResponse { return ProjectListResponse{ Items: lo.Map( items, diff --git a/internal/server/projects/handler.go b/internal/server/projects/handler.go index e8f737f..f176a4e 100644 --- a/internal/server/projects/handler.go +++ b/internal/server/projects/handler.go @@ -90,17 +90,11 @@ func (h *Handler) list(c *fiber.Ctx) error { } // Fetch projects from service - projectsList, err := h.projectsSvc.List(c.Context(), query.Limit, query.Offset) + projectsList, total, err := h.projectsSvc.List(c.Context(), projects.NewPagination(query.Limit, query.Offset)) if err != nil { return fmt.Errorf("failed to list projects: %w", err) } - // Get total count - total, err := h.projectsSvc.Count(c.Context()) - if err != nil { - return fmt.Errorf("failed to count projects: %w", err) - } - return c.JSON(NewProjectListResponse(projectsList, total)) } From 6dd8f29d62664a35d599992c533e1480ab69d4c0 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Fri, 17 Apr 2026 13:48:20 +0700 Subject: [PATCH 3/6] [tasks] use universal pagination and optimize counting --- internal/server/tasks/dto.go | 4 +-- internal/tasks/domain.go | 64 +++++++++++++----------------------- internal/tasks/repository.go | 25 ++++---------- internal/tasks/service.go | 28 ++-------------- 4 files changed, 32 insertions(+), 89 deletions(-) diff --git a/internal/server/tasks/dto.go b/internal/server/tasks/dto.go index 609964b..0749b64 100644 --- a/internal/server/tasks/dto.go +++ b/internal/server/tasks/dto.go @@ -106,7 +106,7 @@ type TaskResponse struct { // @Description Paginated list of tasks with total count. type TaskListResponse struct { Items []TaskResponse `json:"items"` - Total int64 `json:"total"` + Total int `json:"total"` } // UserBrief represents a minimal user profile for task author/assignee. @@ -154,7 +154,7 @@ func toTaskResponse(task *tasks.Task) TaskResponse { } // toTaskListResponse converts a list of domain Tasks to TaskListResponse DTO. -func toTaskListResponse(items []tasks.Task, total int64) TaskListResponse { +func toTaskListResponse(items []tasks.Task, total int) TaskListResponse { return TaskListResponse{ Items: lo.Map( items, diff --git a/internal/tasks/domain.go b/internal/tasks/domain.go index 126e6ea..aa77dc7 100644 --- a/internal/tasks/domain.go +++ b/internal/tasks/domain.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/bit-issues/backend/internal/db" "github.com/uptrace/bun" ) @@ -181,48 +182,6 @@ func (u TaskUpdate) Validate() error { return nil } -type Pagination struct { - limit int - offset int -} - -func NewPagination(limit, offset int) *Pagination { - return &Pagination{ - limit: limit, - offset: offset, - } -} - -func (p *Pagination) Limit() int { - if p == nil { - return DefaultLimit - } - - if p.limit <= 0 { - p.limit = DefaultLimit // default - } - if p.limit > MaxLimit { - p.limit = MaxLimit // max - } - - return p.limit -} - -func (p *Pagination) Offset() int { - if p == nil { - return 0 - } - - if p.offset < 0 { - p.offset = 0 - } - return p.offset -} - -func (p *Pagination) apply(query *bun.SelectQuery) *bun.SelectQuery { - return query.Limit(p.Limit()).Offset(p.Offset()) -} - // TaskFilter contains filtering criteria for querying tasks. type TaskFilter struct { ProjectSlug *string @@ -284,3 +243,24 @@ func (f TaskFilter) apply(query *bun.SelectQuery) *bun.SelectQuery { return query } + +type pagination struct { +} + +// DefaultLimit implements [db.DefaultPagination]. +func (p *pagination) DefaultLimit() int { + return DefaultLimit +} + +// MaxLimit implements [db.DefaultPagination]. +func (p *pagination) MaxLimit() int { + return MaxLimit +} + +var _ db.DefaultPagination = (*pagination)(nil) + +type Pagination = db.Pagination[*pagination] + +func NewPagination(limit, offset int) *Pagination { + return db.NewPagination[*pagination](limit, offset) +} diff --git a/internal/tasks/repository.go b/internal/tasks/repository.go index 4d19bcc..ed15be7 100644 --- a/internal/tasks/repository.go +++ b/internal/tasks/repository.go @@ -87,7 +87,7 @@ func (r *Repository) List( filter TaskFilter, sort string, pagination *Pagination, -) ([]Task, error) { +) ([]Task, int, error) { models := make([]taskModel, 0) query := r.db.NewSelect().Model(&models) @@ -123,10 +123,11 @@ func (r *Repository) List( } // Apply pagination - query = pagination.apply(query) + query = pagination.Apply(query) - if err := query.Scan(ctx, &models); err != nil { - return nil, fmt.Errorf("failed to list tasks: %w", err) + total, err := query.ScanAndCount(ctx, &models) + if err != nil { + return nil, 0, fmt.Errorf("failed to list tasks: %w", err) } tasks := make([]Task, 0, len(models)) @@ -134,21 +135,7 @@ func (r *Repository) List( tasks = append(tasks, *model.toDomain()) } - return tasks, nil -} - -// Count returns the total number of tasks matching the given filter. -func (r *Repository) Count(ctx context.Context, filter TaskFilter) (int64, error) { - query := r.db.NewSelect().Model((*taskModel)(nil)) - - // Apply the same filters as List - query = filter.apply(query) - - count, err := query.Count(ctx) - if err != nil { - return 0, fmt.Errorf("failed to count tasks: %w", err) - } - return int64(count), nil + return tasks, total, nil } // Update modifies an existing task with the provided update data. diff --git a/internal/tasks/service.go b/internal/tasks/service.go index b4558ba..c8968b9 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -6,7 +6,6 @@ import ( "github.com/bit-issues/backend/internal/projects" "go.uber.org/zap" - "golang.org/x/sync/errgroup" ) const ( @@ -62,33 +61,10 @@ func (s *Service) List( filter TaskFilter, sort string, pagination *Pagination, -) ([]Task, int64, error) { - var tasks []Task - var total int64 - - g, ctx := errgroup.WithContext(ctx) - g.Go(func() error { - var err error - tasks, err = s.tasks.List(ctx, filter, sort, pagination) - return err - }) - g.Go(func() error { - var err error - total, err = s.tasks.Count(ctx, filter) - return err - }) - - if err := g.Wait(); err != nil { - return nil, 0, fmt.Errorf("failed to list tasks: %w", err) - } - - return tasks, total, nil +) ([]Task, int, error) { + return s.tasks.List(ctx, filter, sort, pagination) } -// func (s *Service) ListByUser(ctx context.Context, userID int64, sort string, limit, offset int) ([]Task, int64, error) { - -// } - // Update modifies an existing task with the provided data. // Returns the updated task or an error if not found or validation fails. func (s *Service) Update(ctx context.Context, id int64, update TaskUpdate) (*Task, error) { From b66a4d357aa62b3428c731b9f7927207b8d5740a Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Fri, 17 Apr 2026 13:48:31 +0700 Subject: [PATCH 4/6] [users] use universal pagination and optimize counting --- internal/server/users/handler.go | 15 +++++++------- internal/users/domain.go | 32 +++++++++++++++++++++++++++++- internal/users/repository.go | 34 +++++++++++--------------------- internal/users/service.go | 17 ++++++++++------ 4 files changed, 60 insertions(+), 38 deletions(-) diff --git a/internal/server/users/handler.go b/internal/server/users/handler.go index fbcd44e..57bd481 100644 --- a/internal/server/users/handler.go +++ b/internal/server/users/handler.go @@ -70,17 +70,16 @@ func (h *Handler) handleList(c *fiber.Ctx) error { } // Get users from service - usersList, err := h.usersSvc.List(c.Context(), filter.Status, filter.Role, filter.Limit, filter.Offset) + usersList, total, err := h.usersSvc.List( + c.Context(), + filter.Status, + filter.Role, + users.NewPagination(filter.Limit, filter.Offset), + ) if err != nil { return fmt.Errorf("failed to list users: %w", err) } - // Get total count for pagination - total, err := h.usersSvc.Count(c.Context(), filter.Status, filter.Role) - if err != nil { - return fmt.Errorf("failed to count users: %w", err) - } - // Convert to response DTOs items := make([]GetResponse, 0, len(usersList)) for _, u := range usersList { @@ -89,7 +88,7 @@ func (h *Handler) handleList(c *fiber.Ctx) error { return c.JSON(ListResponse{ Items: items, - Total: int(total), + Total: total, }) } diff --git a/internal/users/domain.go b/internal/users/domain.go index 9b6b8fe..e131365 100644 --- a/internal/users/domain.go +++ b/internal/users/domain.go @@ -1,6 +1,15 @@ package users -import "time" +import ( + "time" + + "github.com/bit-issues/backend/internal/db" +) + +const ( + DefaultLimit = 20 + MaxLimit = 100 +) type Role string @@ -65,3 +74,24 @@ func IsValidStatus(s Status) bool { } return false } + +type pagination struct { +} + +// DefaultLimit implements [db.DefaultPagination]. +func (p *pagination) DefaultLimit() int { + return DefaultLimit +} + +// MaxLimit implements [db.DefaultPagination]. +func (p *pagination) MaxLimit() int { + return MaxLimit +} + +var _ db.DefaultPagination = (*pagination)(nil) + +type Pagination = db.Pagination[*pagination] + +func NewPagination(limit, offset int) *Pagination { + return db.NewPagination[*pagination](limit, offset) +} diff --git a/internal/users/repository.go b/internal/users/repository.go index a35f077..14788b6 100644 --- a/internal/users/repository.go +++ b/internal/users/repository.go @@ -57,7 +57,12 @@ func (r *Repository) GetByID(ctx context.Context, id int64) (*UserWithPasswordHa return model.toDomain(), nil } -func (r *Repository) List(ctx context.Context, status *Status, role *Role, limit, offset int) ([]User, error) { +func (r *Repository) List( + ctx context.Context, + status *Status, + role *Role, + pagination *Pagination, +) ([]User, int, error) { models := make([]userModel, 0) query := r.db.NewSelect().Model(&models).OrderExpr("id DESC") @@ -68,10 +73,11 @@ func (r *Repository) List(ctx context.Context, status *Status, role *Role, limit query = query.Where("role = ?", *role) } - query = query.Limit(limit).Offset(offset) + query = pagination.Apply(query) - if err := query.Scan(ctx); err != nil { - return nil, fmt.Errorf("failed to list users: %w", err) + total, err := query.ScanAndCount(ctx) + if err != nil { + return nil, 0, fmt.Errorf("failed to list users: %w", err) } users := make([]User, 0, len(models)) @@ -79,25 +85,7 @@ func (r *Repository) List(ctx context.Context, status *Status, role *Role, limit users = append(users, model.toDomain().User) } - return users, nil -} - -func (r *Repository) Count(ctx context.Context, status *Status, role *Role) (int64, error) { - query := r.db.NewSelect().Model((*userModel)(nil)) - - if status != nil { - query = query.Where("status = ?", *status) - } - if role != nil { - query = query.Where("role = ?", *role) - } - - count, err := query.Count(ctx) - if err != nil { - return 0, fmt.Errorf("failed to count users: %w", err) - } - - return int64(count), nil + return users, total, nil } func (r *Repository) UpdatePasswordHash(ctx context.Context, id int64, passwordHash string) error { diff --git a/internal/users/service.go b/internal/users/service.go index c83c01d..3dc50ae 100644 --- a/internal/users/service.go +++ b/internal/users/service.go @@ -54,12 +54,8 @@ func (s *Service) Login(ctx context.Context, email, password string) (*User, err return &user.User, nil } -func (s *Service) List(ctx context.Context, status *Status, role *Role, limit, offset int) ([]User, error) { - return s.repo.List(ctx, status, role, limit, offset) -} - -func (s *Service) Count(ctx context.Context, status *Status, role *Role) (int64, error) { - return s.repo.Count(ctx, status, role) +func (s *Service) List(ctx context.Context, status *Status, role *Role, pagination *Pagination) ([]User, int, error) { + return s.repo.List(ctx, status, role, pagination) } func (s *Service) Update(ctx context.Context, id int64, update UserUpdate) error { @@ -75,6 +71,15 @@ func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) { return &user.User, nil } +func (s *Service) IsAdmin(ctx context.Context, id int64) (bool, error) { + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return false, err + } + + return user.Role == RoleAdmin, nil +} + func (s *Service) ChangePassword(ctx context.Context, id int64, oldPassword, newPassword string) error { user, err := s.repo.GetByID(ctx, id) if err != nil { From 891283ec860e16e5998cce3aa77d5567c6d0909d Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Fri, 17 Apr 2026 21:00:41 +0700 Subject: [PATCH 5/6] [users] add admin check method --- internal/users/repository.go | 55 ++++++++++++++++++++++-------------- internal/users/service.go | 4 +-- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/internal/users/repository.go b/internal/users/repository.go index 14788b6..e5edb23 100644 --- a/internal/users/repository.go +++ b/internal/users/repository.go @@ -57,6 +57,19 @@ func (r *Repository) GetByID(ctx context.Context, id int64) (*UserWithPasswordHa return model.toDomain(), nil } +func (r *Repository) IsAdminByID(ctx context.Context, id int64) (bool, error) { + query := r.db.NewSelect().Model((*userModel)(nil)). + Where("id = ?", id). + Where("role = ?", RoleAdmin) + + isAdmin, err := query.Exists(ctx) + if err != nil { + return false, fmt.Errorf("failed to check admin status: %w", err) + } + + return isAdmin, nil +} + func (r *Repository) List( ctx context.Context, status *Status, @@ -88,27 +101,6 @@ func (r *Repository) List( return users, total, nil } -func (r *Repository) UpdatePasswordHash(ctx context.Context, id int64, passwordHash string) error { - result, err := r.db.NewUpdate(). - Model((*userModel)(nil)). - Set("password_hash = ?", passwordHash). - Where("id = ?", id). - Exec(ctx) - if err != nil { - return fmt.Errorf("failed to update user password hash: %w", err) - } - - rows, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get affected rows: %w", err) - } - if rows == 0 { - return ErrNotFound - } - - return nil -} - func (r *Repository) Update(ctx context.Context, id int64, update UserUpdate) error { if update.IsEmpty() { return nil @@ -138,3 +130,24 @@ func (r *Repository) Update(ctx context.Context, id int64, update UserUpdate) er return nil } + +func (r *Repository) UpdatePasswordHash(ctx context.Context, id int64, passwordHash string) error { + result, err := r.db.NewUpdate(). + Model((*userModel)(nil)). + Set("password_hash = ?", passwordHash). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to update user password hash: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + if rows == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/users/service.go b/internal/users/service.go index 3dc50ae..a34da82 100644 --- a/internal/users/service.go +++ b/internal/users/service.go @@ -72,12 +72,12 @@ func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) { } func (s *Service) IsAdmin(ctx context.Context, id int64) (bool, error) { - user, err := s.repo.GetByID(ctx, id) + isAdmin, err := s.repo.IsAdminByID(ctx, id) if err != nil { return false, err } - return user.Role == RoleAdmin, nil + return isAdmin, nil } func (s *Service) ChangePassword(ctx context.Context, id int64, oldPassword, newPassword string) error { From 5617b1a80720c42121f01f2240baa4b1bdab76fb Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Fri, 17 Apr 2026 13:49:04 +0700 Subject: [PATCH 6/6] [comments] add module and API --- go.mod | 2 +- internal/app.go | 2 + internal/comments/doc.go | 34 ++ internal/comments/domain.go | 76 ++++ internal/comments/errors.go | 16 + internal/comments/models.go | 52 +++ internal/comments/module.go | 21 ++ internal/comments/repository.go | 88 +++++ internal/comments/service.go | 123 +++++++ .../20260417000000_create_comments.sql | 23 ++ internal/server/docs/docs.go | 343 ++++++++++++++++-- internal/server/dto/response.go | 11 + internal/server/tasks/dto.go | 108 +++--- internal/server/tasks/dto_comments.go | 59 +++ internal/server/tasks/handler.go | 47 ++- internal/server/tasks/handler_comments.go | 139 +++++++ requests.http | 47 +++ 17 files changed, 1092 insertions(+), 99 deletions(-) create mode 100644 internal/comments/doc.go create mode 100644 internal/comments/domain.go create mode 100644 internal/comments/errors.go create mode 100644 internal/comments/models.go create mode 100644 internal/comments/module.go create mode 100644 internal/comments/repository.go create mode 100644 internal/comments/service.go create mode 100644 internal/db/migrations/20260417000000_create_comments.sql create mode 100644 internal/server/dto/response.go create mode 100644 internal/server/tasks/dto_comments.go create mode 100644 internal/server/tasks/handler_comments.go diff --git a/go.mod b/go.mod index 94a23fa..fe3b02b 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,6 @@ require ( go.uber.org/fx v1.24.0 go.uber.org/zap v1.27.1 golang.org/x/crypto v0.49.0 - golang.org/x/sync v0.20.0 ) require ( @@ -92,6 +91,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.42.0 // indirect diff --git a/internal/app.go b/internal/app.go index b9ea56e..62bdaf5 100644 --- a/internal/app.go +++ b/internal/app.go @@ -3,6 +3,7 @@ package internal import ( "context" + "github.com/bit-issues/backend/internal/comments" "github.com/bit-issues/backend/internal/config" "github.com/bit-issues/backend/internal/db" "github.com/bit-issues/backend/internal/jwt" @@ -54,6 +55,7 @@ func Run(version healthfx.Version) { users.Module(), projects.Module(), tasks.Module(), + comments.Module(), // fx.Invoke(func(lc fx.Lifecycle, logger *zap.Logger) { lc.Append(fx.Hook{ diff --git a/internal/comments/doc.go b/internal/comments/doc.go new file mode 100644 index 0000000..0e93f2e --- /dev/null +++ b/internal/comments/doc.go @@ -0,0 +1,34 @@ +// Package comments provides functionality for managing comments on tasks. +// +// The comments module supports: +// - Creating comments with Markdown content +// - Editing comments (author or admin only) +// - Soft deleting comments (author or admin only) +// - Retrieving comments by task +// +// # Usage +// +// Create a new comment: +// +// svc := comments.NewService(repo, tasksSvc, logger) +// input := comments.CommentInput{ +// TaskID: 123, +// AuthorID: 456, +// Content: "This is a comment", +// } +// comment, err := svc.Create(ctx, input) +// +// List comments for a task: +// +// pagination := &db.Pagination{} +// comments, err := svc.ListByTask(ctx, taskID, pagination) +// +// Update a comment: +// +// update := comments.CommentUpdate{Content: "Updated content"} +// err := svc.Update(ctx, userID, commentID, update) +// +// Delete a comment (soft delete): +// +// err := svc.Delete(ctx, userID, commentID) +package comments diff --git a/internal/comments/domain.go b/internal/comments/domain.go new file mode 100644 index 0000000..8f0e562 --- /dev/null +++ b/internal/comments/domain.go @@ -0,0 +1,76 @@ +package comments + +import ( + "fmt" + "strings" + "time" + "unicode/utf8" +) + +const ( + // MaxContentLength is the maximum length of comment content. + MaxContentLength = 10000 +) + +// Comment represents a complete comment entity with all fields. +type Comment struct { + ID int64 + TaskID int64 + AuthorID int64 + Content string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +// CommentInput contains the data required to create a new comment. +type CommentInput struct { + TaskID int64 + AuthorID int64 + Content string +} + +// CommentUpdate represents the data that can be updated for a comment. +type CommentUpdate struct { + Content string +} + +// Validate checks that the input data is valid for comment creation. +func (i CommentInput) Validate() error { + // Validate task ID + if i.TaskID <= 0 { + return fmt.Errorf("%w: task_id must be positive", ErrValidationFailed) + } + + // Validate author ID + if i.AuthorID <= 0 { + return fmt.Errorf("%w: author_id must be positive", ErrValidationFailed) + } + + // Validate content + content := strings.TrimSpace(i.Content) + if content == "" { + return fmt.Errorf("%w: content is required", ErrValidationFailed) + } + + if utf8.RuneCountInString(content) > MaxContentLength { + return fmt.Errorf("%w: content too long (max %d characters)", ErrValidationFailed, MaxContentLength) + } + + return nil +} + +// Validate checks that the update data is valid. +func (u CommentUpdate) Validate() error { + // Validate content + content := strings.TrimSpace(u.Content) + if content == "" { + return fmt.Errorf("%w: content is required", ErrValidationFailed) + } + + if utf8.RuneCountInString(content) > MaxContentLength { + return fmt.Errorf("%w: content too long (max %d characters)", ErrValidationFailed, MaxContentLength) + } + + return nil +} diff --git a/internal/comments/errors.go b/internal/comments/errors.go new file mode 100644 index 0000000..eac46c7 --- /dev/null +++ b/internal/comments/errors.go @@ -0,0 +1,16 @@ +package comments + +import "errors" + +// Module-specific error definitions. +// These errors can be checked using [errors.Is](err, ErrXXX). +var ( + // ErrNotFound indicates the requested comment does not exist. + ErrNotFound = errors.New("comment not found") + + // ErrValidationFailed indicates input validation failed. + ErrValidationFailed = errors.New("validation failed") + + // ErrUnauthorized indicates the user lacks permission for this action. + ErrUnauthorized = errors.New("unauthorized") +) diff --git a/internal/comments/models.go b/internal/comments/models.go new file mode 100644 index 0000000..4527bcd --- /dev/null +++ b/internal/comments/models.go @@ -0,0 +1,52 @@ +package comments + +import ( + "time" + + "github.com/go-core-fx/bunfx" + "github.com/uptrace/bun" +) + +// commentModel is the database representation using Bun ORM. +// This struct maps to the `comments` table and is used for all database operations. +type commentModel struct { + bun.BaseModel `bun:"table:comments,alias:c"` + bunfx.TimedModel + + ID int64 `bun:"id,pk,autoincrement"` + TaskID int64 `bun:"task_id,notnull"` + AuthorID int64 `bun:"author_id,notnull"` + Content string `bun:"content,notnull,type:text"` + DeletedAt *time.Time `bun:"deleted_at,soft_delete,nullzero"` +} + +// newCommentModel creates a new commentModel from CommentInput. +func newCommentModel(input CommentInput) *commentModel { + now := time.Now() + return &commentModel{ + BaseModel: bun.BaseModel{}, + TimedModel: bunfx.TimedModel{CreatedAt: now, UpdatedAt: now}, + ID: 0, // Auto-generated by database + TaskID: input.TaskID, + AuthorID: input.AuthorID, + Content: input.Content, + DeletedAt: nil, + } +} + +// toDomain converts the database model to a domain Comment entity. +// Returns nil if the model is nil. +func (m *commentModel) toDomain() *Comment { + if m == nil { + return nil + } + return &Comment{ + ID: m.ID, + TaskID: m.TaskID, + AuthorID: m.AuthorID, + Content: m.Content, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + DeletedAt: m.DeletedAt, + } +} diff --git a/internal/comments/module.go b/internal/comments/module.go new file mode 100644 index 0000000..0bc64f6 --- /dev/null +++ b/internal/comments/module.go @@ -0,0 +1,21 @@ +package comments + +import ( + "github.com/go-core-fx/logger" + "go.uber.org/fx" +) + +// Module creates and returns an FX module for the comments package. +// +// The module provides: +// - comments.Service (public) - for use by HTTP handlers and other modules +// - comments.Repository (private) - internal data access layer +// - Named logger "comments" for structured logging +func Module() fx.Option { + return fx.Module( + "comments", + logger.WithNamedLogger("comments"), + fx.Provide(NewRepository, fx.Private), + fx.Provide(NewService), + ) +} diff --git a/internal/comments/repository.go b/internal/comments/repository.go new file mode 100644 index 0000000..4b53483 --- /dev/null +++ b/internal/comments/repository.go @@ -0,0 +1,88 @@ +package comments + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/uptrace/bun" +) + +// Repository handles data access operations for comments. +type Repository struct { + db *bun.DB +} + +// NewRepository creates a new Repository instance with the given database connection. +func NewRepository(db *bun.DB) *Repository { + return &Repository{db: db} +} + +// Create inserts a new comment. +func (r *Repository) Create(ctx context.Context, input CommentInput) (*Comment, error) { + model := newCommentModel(input) + + if _, err := r.db.NewInsert().Model(model).Returning("*").Exec(ctx); err != nil { + return nil, fmt.Errorf("failed to insert comment: %w", err) + } + + return model.toDomain(), nil +} + +// GetByID retrieves a comment by its ID. +func (r *Repository) GetByID(ctx context.Context, id int64) (*Comment, error) { + var model commentModel + if err := r.db.NewSelect().Model(&model).Where("id = ?", id).Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to get comment by ID: %w", err) + } + return model.toDomain(), nil +} + +// List retrieves all comments for a specific task. +func (r *Repository) List(ctx context.Context, taskID int64) ([]Comment, error) { + models := make([]commentModel, 0) + + query := r.db.NewSelect().Model(&models).Where("task_id = ?", taskID) + + if err := query.OrderExpr("created_at ASC").Scan(ctx); err != nil { + return nil, fmt.Errorf("failed to list comments by task: %w", err) + } + + comments := make([]Comment, 0, len(models)) + for _, model := range models { + comments = append(comments, *model.toDomain()) + } + + return comments, nil +} + +// Update modifies an existing comment with the provided content. +func (r *Repository) Update(ctx context.Context, id int64, content string) error { + if _, err := r.db.NewUpdate(). + Model((*commentModel)(nil)). + Set("content = ?", content). + Where("id = ?", id). + Exec(ctx); err != nil { + return fmt.Errorf("failed to update comment: %w", err) + } + + return nil +} + +// Delete soft-deletes a comment by setting the deleted_at timestamp. +func (r *Repository) Delete(ctx context.Context, id int64) error { + _, err := r.db.NewDelete(). + Model((*commentModel)(nil)). + Where("id = ?", id). + Exec(ctx) + + if err != nil { + return fmt.Errorf("failed to delete comment: %w", err) + } + + return nil +} diff --git a/internal/comments/service.go b/internal/comments/service.go new file mode 100644 index 0000000..576cdb7 --- /dev/null +++ b/internal/comments/service.go @@ -0,0 +1,123 @@ +package comments + +import ( + "context" + "fmt" + + "github.com/bit-issues/backend/internal/users" + "go.uber.org/zap" +) + +const ( + // DefaultLimit is the default number of comments to return per page. + DefaultLimit = 20 + // MaxLimit is the maximum number of comments to return per page. + MaxLimit = 100 +) + +// Service implements the business logic for comment management. +type Service struct { + comments *Repository + + usersSvc *users.Service + + logger *zap.Logger +} + +// NewService creates a new Service instance with the given dependencies. +func NewService(comments *Repository, usersSvc *users.Service, logger *zap.Logger) *Service { + return &Service{ + comments: comments, + + usersSvc: usersSvc, + + logger: logger, + } +} + +// Create validates input and creates a new comment. +func (s *Service) Create(ctx context.Context, input CommentInput) (*Comment, error) { + // Validate input + if err := input.Validate(); err != nil { + return nil, err + } + + // Create comment + return s.comments.Create(ctx, input) +} + +// GetByID retrieves a comment by its ID. +func (s *Service) GetByID(ctx context.Context, id int64) (*Comment, error) { + return s.comments.GetByID(ctx, id) +} + +// ListByTask retrieves all comments for a specific task. +func (s *Service) ListByTask(ctx context.Context, taskID int64) ([]Comment, error) { + return s.comments.List(ctx, taskID) +} + +// Update modifies an existing comment with the provided content. +// Returns an error if the comment is not found, validation fails, or the user is not authorized. +func (s *Service) Update(ctx context.Context, userID int64, taskID, id int64, update CommentUpdate) (*Comment, error) { + // Validate update content + if err := update.Validate(); err != nil { + return nil, err + } + + // Fetch existing comment + comment, err := s.comments.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if comment.TaskID != taskID { + return nil, ErrUnauthorized + } + + // Check authorization + if authErr := s.validateAuthor(ctx, userID, comment.AuthorID); authErr != nil { + return nil, authErr + } + + // Perform update + if updErr := s.comments.Update(ctx, id, update.Content); updErr != nil { + return nil, updErr + } + + // Return the updated comment + return s.comments.GetByID(ctx, id) +} + +// Delete soft-deletes a comment. +// Returns an error if the comment is not found, validation fails, or the user is not authorized. +func (s *Service) Delete(ctx context.Context, userID int64, taskID, id int64) error { + // Fetch existing comment + comment, err := s.comments.GetByID(ctx, id) + if err != nil { + return err + } + + if comment.TaskID != taskID { + return ErrUnauthorized + } + + // Check authorization + if authErr := s.validateAuthor(ctx, userID, comment.AuthorID); authErr != nil { + return authErr + } + + // Perform soft delete + return s.comments.Delete(ctx, id) +} + +func (s *Service) validateAuthor(ctx context.Context, userID int64, authorID int64) error { + if userID != authorID { + if isAdmin, err := s.usersSvc.IsAdmin(ctx, userID); err != nil { + return fmt.Errorf("failed to check admin status: %w", err) + } else if !isAdmin { + return ErrUnauthorized + } + } + + return nil +} diff --git a/internal/db/migrations/20260417000000_create_comments.sql b/internal/db/migrations/20260417000000_create_comments.sql new file mode 100644 index 0000000..82dc6c9 --- /dev/null +++ b/internal/db/migrations/20260417000000_create_comments.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS `comments` ( + `id` SERIAL, + `task_id` BIGINT UNSIGNED NOT NULL, + `author_id` BIGINT UNSIGNED NOT NULL, + `content` TEXT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + INDEX `idx_comments_task_id` (`task_id`), + INDEX `idx_comments_author_id` (`author_id`), + INDEX `idx_comments_deleted_at` (`deleted_at`), + CONSTRAINT `fk_comments_task` FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comments_author` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; +-- +goose StatementEnd +--- +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS `comments`; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go index 6bc4f4e..9c33fe6 100644 --- a/internal/server/docs/docs.go +++ b/internal/server/docs/docs.go @@ -593,7 +593,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/tasks.TaskResponse" + "$ref": "#/definitions/tasks.TaskDetailsResponse" } }, "400": { @@ -699,7 +699,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/tasks.TaskResponse" + "$ref": "#/definitions/tasks.TaskDetailsResponse" } }, "400": { @@ -837,6 +837,209 @@ const docTemplate = `{ } } }, + "/tasks/{task_id}/comments": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Adds a new comment to the specified task.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Comments" + ], + "summary": "Create a new comment on a task", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Task ID", + "name": "task_id", + "in": "path", + "required": true + }, + { + "description": "Comment creation data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tasks.CommentCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/tasks.CommentResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, + "/tasks/{task_id}/comments/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the content of an existing comment. Only the comment author or admin can update.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Comments" + ], + "summary": "Update a comment", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Task ID", + "name": "task_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Comment update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tasks.CommentUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/tasks.CommentResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft deletes a comment. Only the comment author or admin can delete.", + "tags": [ + "Comments" + ], + "summary": "Delete a comment", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Task ID", + "name": "task_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, "/users": { "get": { "security": [ @@ -1075,6 +1278,24 @@ const docTemplate = `{ } } }, + "dto.UserBrief": { + "description": "Minimal user information for task relationships.", + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + }, "fiberfx.ErrorResponse": { "type": "object", "properties": { @@ -1157,6 +1378,55 @@ const docTemplate = `{ } } }, + "tasks.CommentCreateRequest": { + "description": "Comment creation request with content.", + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string", + "maxLength": 10000, + "minLength": 1 + } + } + }, + "tasks.CommentResponse": { + "description": "Full comment details with author information.", + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/dto.UserBrief" + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "tasks.CommentUpdateRequest": { + "description": "Comment update request with content.", + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string", + "maxLength": 10000, + "minLength": 1 + } + } + }, "tasks.TaskCreateRequest": { "description": "Task creation request with title, description, priority, assignee, and due date.", "type": "object", @@ -1196,6 +1466,53 @@ const docTemplate = `{ } } }, + "tasks.TaskDetailsResponse": { + "type": "object", + "properties": { + "assignee": { + "$ref": "#/definitions/dto.UserBrief" + }, + "author": { + "$ref": "#/definitions/dto.UserBrief" + }, + "comments": { + "type": "array", + "items": { + "$ref": "#/definitions/tasks.CommentResponse" + } + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "due_date": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "number": { + "type": "integer" + }, + "priority": { + "type": "string" + }, + "project_slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "tasks.TaskListResponse": { "description": "Paginated list of tasks with total count.", "type": "object", @@ -1216,10 +1533,10 @@ const docTemplate = `{ "type": "object", "properties": { "assignee": { - "$ref": "#/definitions/tasks.UserBrief" + "$ref": "#/definitions/dto.UserBrief" }, "author": { - "$ref": "#/definitions/tasks.UserBrief" + "$ref": "#/definitions/dto.UserBrief" }, "created_at": { "type": "string" @@ -1296,24 +1613,6 @@ const docTemplate = `{ } } }, - "tasks.UserBrief": { - "description": "Minimal user information for task relationships.", - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "role": { - "type": "string" - } - } - }, "users.GetResponse": { "description": "User data returned in admin API responses (password excluded).", "type": "object", diff --git a/internal/server/dto/response.go b/internal/server/dto/response.go new file mode 100644 index 0000000..75aa903 --- /dev/null +++ b/internal/server/dto/response.go @@ -0,0 +1,11 @@ +package dto + +// UserBrief represents a minimal user profile for task author/assignee. +// +// @Description Minimal user information for task relationships. +type UserBrief struct { + ID int64 `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + CreatedAt string `json:"created_at"` +} diff --git a/internal/server/tasks/dto.go b/internal/server/tasks/dto.go index 0749b64..4d35148 100644 --- a/internal/server/tasks/dto.go +++ b/internal/server/tasks/dto.go @@ -4,6 +4,7 @@ import ( "strings" "time" + "github.com/bit-issues/backend/internal/comments" "github.com/bit-issues/backend/internal/server/dto" "github.com/bit-issues/backend/internal/tasks" "github.com/samber/lo" @@ -87,46 +88,28 @@ type TaskUpdateRequest struct { // // @Description Full task details with nested author and assignee information. type TaskResponse struct { - ID int64 `json:"id"` - ProjectSlug string `json:"project_slug"` - Number int `json:"number"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Priority string `json:"priority"` - Status string `json:"status"` - Author UserBrief `json:"author"` - Assignee *UserBrief `json:"assignee,omitempty"` - DueDate *string `json:"due_date,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID int64 `json:"id"` + ProjectSlug string `json:"project_slug"` + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority string `json:"priority"` + Status string `json:"status"` + Author dto.UserBrief `json:"author"` + Assignee *dto.UserBrief `json:"assignee,omitempty"` + DueDate *string `json:"due_date,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } -// TaskListResponse represents the API response for a list of tasks. -// -// @Description Paginated list of tasks with total count. -type TaskListResponse struct { - Items []TaskResponse `json:"items"` - Total int `json:"total"` -} - -// UserBrief represents a minimal user profile for task author/assignee. -// -// @Description Minimal user information for task relationships. -type UserBrief struct { - ID int64 `json:"id"` - Email string `json:"email"` - Role string `json:"role"` - CreatedAt string `json:"created_at"` -} - -// toTaskResponse converts a domain Task to a TaskResponse DTO. +// newTaskResponse converts a domain Task to a TaskResponse DTO. // It requires fetching author and assignee from the users service. -func toTaskResponse(task *tasks.Task) TaskResponse { - var assignee *UserBrief +func newTaskResponse(task *tasks.Task) TaskResponse { + var assignee *dto.UserBrief if task.AssigneeID != nil { - assignee = &UserBrief{ + assignee = &dto.UserBrief{ ID: *task.AssigneeID, - Email: "", // Will be populated by service + Email: "", Role: "", CreatedAt: "", } @@ -140,54 +123,47 @@ func toTaskResponse(task *tasks.Task) TaskResponse { Description: task.Description, Priority: string(task.Priority), Status: string(task.Status), - Author: UserBrief{ + Author: dto.UserBrief{ ID: task.AuthorID, Email: "", Role: "", CreatedAt: "", }, Assignee: assignee, - DueDate: nil, + DueDate: task.DueDate, CreatedAt: task.CreatedAt.Format(time.RFC3339), UpdatedAt: task.UpdatedAt.Format(time.RFC3339), } } +type TaskDetailsResponse struct { + TaskResponse + + Comments []CommentResponse `json:"comments"` +} + +func newTaskDetailsResponse(task *tasks.Task, comments []comments.Comment) *TaskDetailsResponse { + return &TaskDetailsResponse{ + TaskResponse: newTaskResponse(task), + Comments: toCommentsList(comments), + } +} + +// TaskListResponse represents the API response for a list of tasks. +// +// @Description Paginated list of tasks with total count. +type TaskListResponse struct { + Items []TaskResponse `json:"items"` + Total int `json:"total"` +} + // toTaskListResponse converts a list of domain Tasks to TaskListResponse DTO. func toTaskListResponse(items []tasks.Task, total int) TaskListResponse { return TaskListResponse{ Items: lo.Map( items, func(t tasks.Task, _ int) TaskResponse { - var assignee *UserBrief - if t.AssigneeID != nil { - assignee = &UserBrief{ - ID: *t.AssigneeID, - Email: "", // Will be populated by service - Role: "", - CreatedAt: "", - } - } - - return TaskResponse{ - ID: t.ID, - ProjectSlug: t.ProjectSlug, - Number: t.Number, - Title: t.Title, - Description: t.Description, - Priority: string(t.Priority), - Status: string(t.Status), - Author: UserBrief{ - ID: t.AuthorID, - Email: "", // Will be populated by service - Role: "", - CreatedAt: "", - }, - Assignee: assignee, - DueDate: t.DueDate, - CreatedAt: t.CreatedAt.Format(time.RFC3339), - UpdatedAt: t.UpdatedAt.Format(time.RFC3339), - } + return newTaskResponse(&t) }, ), Total: total, diff --git a/internal/server/tasks/dto_comments.go b/internal/server/tasks/dto_comments.go new file mode 100644 index 0000000..8b06d1c --- /dev/null +++ b/internal/server/tasks/dto_comments.go @@ -0,0 +1,59 @@ +package tasks + +import ( + "time" + + "github.com/bit-issues/backend/internal/comments" + "github.com/bit-issues/backend/internal/server/dto" +) + +// CommentCreateRequest represents the request body for creating a new comment. +// +// @Description Comment creation request with content. +type CommentCreateRequest struct { + Content string `json:"content" validate:"required,min=1,max=10000"` +} + +// CommentUpdateRequest represents the request body for updating a comment. +// +// @Description Comment update request with content. +type CommentUpdateRequest struct { + Content string `json:"content" validate:"required,min=1,max=10000"` +} + +// CommentResponse represents the API response for a single comment. +// +// @Description Full comment details with author information. +type CommentResponse struct { + ID int64 `json:"id"` + Author dto.UserBrief `json:"author"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// toCommentResponse converts a domain Comment to an API response. +func toCommentResponse(comment *comments.Comment) CommentResponse { + return CommentResponse{ + ID: comment.ID, + Author: dto.UserBrief{ + ID: comment.AuthorID, + Email: "", + Role: "", + CreatedAt: "", + }, + Content: comment.Content, + CreatedAt: comment.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: comment.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// toCommentsList converts a list of comments to an API response. +func toCommentsList(items []comments.Comment) []CommentResponse { + comments := make([]CommentResponse, 0, len(items)) + for _, item := range items { + comments = append(comments, toCommentResponse(&item)) + } + + return comments +} diff --git a/internal/server/tasks/handler.go b/internal/server/tasks/handler.go index c6379aa..c86ba86 100644 --- a/internal/server/tasks/handler.go +++ b/internal/server/tasks/handler.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" + "github.com/bit-issues/backend/internal/comments" "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" "github.com/bit-issues/backend/internal/tasks" "github.com/bit-issues/backend/internal/users" @@ -18,20 +19,24 @@ import ( type Handler struct { handler.Base - tasksSvc *tasks.Service - usersSvc *users.Service + tasksSvc *tasks.Service + commentsSvc *comments.Service + usersSvc *users.Service } // NewHandler creates a new Handler instance with the given dependencies. func NewHandler( tasksSvc *tasks.Service, + commentsSvc *comments.Service, usersSvc *users.Service, validate *validator.Validate, ) handler.Handler { return &Handler{ - Base: handler.Base{Validator: validate}, - tasksSvc: tasksSvc, - usersSvc: usersSvc, + Base: handler.Base{Validator: validate}, + + tasksSvc: tasksSvc, + commentsSvc: commentsSvc, + usersSvc: usersSvc, } } @@ -55,6 +60,15 @@ func (h *Handler) Register(r fiber.Router) { validation.DecorateWithBodyEx(h.Validator, h.patch), ) tasks.Delete("/:id", h.delete) + + comments := tasks.Group("/:task_id/comments") + comments.Post("/", + validation.DecorateWithBodyEx(h.Validator, h.createComment), + ) + comments.Put("/:id", + validation.DecorateWithBodyEx(h.Validator, h.updateComment), + ) + comments.Delete("/:id", h.deleteComment) } // @Summary List all tasks @@ -100,7 +114,7 @@ func (h *Handler) list(c *fiber.Ctx) error { // @Produce json // @Security BearerAuth // @Param id path int64 true "Task ID" -// @Success 200 {object} TaskResponse +// @Success 200 {object} TaskDetailsResponse // @Failure 400 {object} fiberfx.ErrorResponse // @Failure 401 {object} fiberfx.ErrorResponse // @Failure 404 {object} fiberfx.ErrorResponse @@ -119,7 +133,12 @@ func (h *Handler) get(c *fiber.Ctx) error { return fmt.Errorf("failed to get task: %w", err) } - return c.JSON(toTaskResponse(task)) + comments, err := h.commentsSvc.ListByTask(c.Context(), task.ID) + if err != nil { + return fmt.Errorf("failed to get comments: %w", err) + } + + return c.JSON(newTaskDetailsResponse(task, comments)) } // @Summary Create a new task @@ -129,7 +148,7 @@ func (h *Handler) get(c *fiber.Ctx) error { // @Produce json // @Security BearerAuth // @Param request body TaskCreateRequest true "Task creation data" -// @Success 201 {object} TaskResponse +// @Success 201 {object} TaskDetailsResponse // @Failure 400 {object} fiberfx.ErrorResponse // @Failure 401 {object} fiberfx.ErrorResponse // @Failure 404 {object} fiberfx.ErrorResponse @@ -152,7 +171,7 @@ func (h *Handler) post(c *fiber.Ctx, req *TaskCreateRequest) error { return fmt.Errorf("failed to create task: %w", err) } - return c.Status(fiber.StatusCreated).JSON(toTaskResponse(task)) + return c.Status(fiber.StatusCreated).JSON(newTaskDetailsResponse(task, nil)) } // @Summary Update a task @@ -185,7 +204,7 @@ func (h *Handler) patch(c *fiber.Ctx, req *TaskUpdateRequest) error { return fmt.Errorf("failed to update task: %w", err) } - return c.JSON(toTaskResponse(task)) + return c.JSON(newTaskResponse(task)) } // @Summary Delete a task @@ -267,6 +286,14 @@ func (h *Handler) errorsHandler(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusNotFound, err.Error()) case errors.Is(err, tasks.ErrProjectNotFound): return fiber.NewError(fiber.StatusNotFound, err.Error()) + + case errors.Is(err, comments.ErrNotFound): + return fiber.NewError(fiber.StatusNotFound, err.Error()) + case errors.Is(err, comments.ErrUnauthorized): + return fiber.NewError(fiber.StatusForbidden, err.Error()) + case errors.Is(err, comments.ErrValidationFailed): + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + default: return err //nolint:wrapcheck // err is already wrapped } diff --git a/internal/server/tasks/handler_comments.go b/internal/server/tasks/handler_comments.go new file mode 100644 index 0000000..660414d --- /dev/null +++ b/internal/server/tasks/handler_comments.go @@ -0,0 +1,139 @@ +package tasks + +import ( + "fmt" + "strconv" + + "github.com/bit-issues/backend/internal/comments" + "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" + "github.com/gofiber/fiber/v2" +) + +// @Summary Create a new comment on a task +// @Description Adds a new comment to the specified task. +// @Tags Comments +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param task_id path int64 true "Task ID" +// @Param request body CommentCreateRequest true "Comment creation data" +// @Success 201 {object} CommentResponse +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Router /tasks/{task_id}/comments [post] +// +// createComment creates a new comment on a task. +func (h *Handler) createComment(c *fiber.Ctx, req *CommentCreateRequest) error { + // Extract task ID from URL params + taskID, err := strconv.ParseInt(c.Params("task_id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid task ID") + } + + // Get current user from JWT context + user, ok := jwtauth.GetUser(c) + if !ok { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + // Create comment via service + comment, err := h.commentsSvc.Create(c.Context(), comments.CommentInput{ + TaskID: taskID, + AuthorID: user.ID, + Content: req.Content, + }) + if err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + + return c.Status(fiber.StatusCreated).JSON(toCommentResponse(comment)) +} + +// @Summary Update a comment +// @Description Updates the content of an existing comment. Only the comment author or admin can update. +// @Tags Comments +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param task_id path int64 true "Task ID" +// @Param id path int64 true "Comment ID" +// @Param request body CommentUpdateRequest true "Comment update data" +// @Success 200 {object} CommentResponse +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 403 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Router /tasks/{task_id}/comments/{id} [put] +// +// updateComment modifies an existing comment. +func (h *Handler) updateComment(c *fiber.Ctx, req *CommentUpdateRequest) error { + // Extract task ID from URL params + taskID, err := strconv.ParseInt(c.Params("task_id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid task ID") + } + + // Extract comment ID from URL params + commentID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid comment ID") + } + + // Get current user from JWT context + user, ok := jwtauth.GetUser(c) + if !ok { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + // Update comment via service + comment, err := h.commentsSvc.Update(c.Context(), user.ID, taskID, commentID, comments.CommentUpdate{ + Content: req.Content, + }) + if err != nil { + return fmt.Errorf("failed to update comment: %w", err) + } + + return c.JSON(toCommentResponse(comment)) +} + +// @Summary Delete a comment +// @Description Soft deletes a comment. Only the comment author or admin can delete. +// @Tags Comments +// @Security BearerAuth +// @Param task_id path int64 true "Task ID" +// @Param id path int64 true "Comment ID" +// @Success 204 +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 403 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Router /tasks/{task_id}/comments/{id} [delete] +// +// deleteComment soft-deletes a comment. +func (h *Handler) deleteComment(c *fiber.Ctx) error { + // Extract task ID from URL params + taskID, err := strconv.ParseInt(c.Params("task_id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid task ID") + } + + // Extract comment ID from URL params + commentID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid comment ID") + } + + // Get current user from JWT context + user, ok := jwtauth.GetUser(c) + if !ok { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + // Delete comment via service + if delErr := h.commentsSvc.Delete(c.Context(), user.ID, taskID, commentID); delErr != nil { + return fmt.Errorf("failed to delete comment: %w", delErr) + } + + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/requests.http b/requests.http index 2241af9..28466d7 100644 --- a/requests.http +++ b/requests.http @@ -266,3 +266,50 @@ Authorization: Bearer {{accessToken}} # Delete a task (soft delete) DELETE {{baseURL}}/tasks/{{taskId}} Authorization: Bearer {{accessToken}} + +### +# Comments API + +### +# Create a new comment on a task +# @name createComment +POST {{baseURL}}/tasks/{{taskId}}/comments +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "content": "This is a comment on the task" +} + +### +# Create another comment on the same task +POST {{baseURL}}/tasks/{{taskId}}/comments +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "content": "Another comment with more details about the task" +} + +### +# Update a comment +# Requires comment ID from createComment response +@commentId={{createComment.response.body.$.id}} +PUT {{baseURL}}/tasks/{{taskId}}/comments/{{commentId}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "content": "Updated comment content" +} + +### +# Delete a comment +# Requires comment ID from createComment response +DELETE {{baseURL}}/tasks/{{taskId}}/comments/{{commentId}} +Authorization: Bearer {{accessToken}} + +### +# Get task with comments (comments are included in task details) +GET {{baseURL}}/tasks/{{taskId}} +Authorization: Bearer {{accessToken}}