Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down
34 changes: 34 additions & 0 deletions internal/comments/doc.go
Original file line number Diff line number Diff line change
@@ -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
Comment thread
capcom6 marked this conversation as resolved.
76 changes: 76 additions & 0 deletions internal/comments/domain.go
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions internal/comments/errors.go
Original file line number Diff line number Diff line change
@@ -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")
)
52 changes: 52 additions & 0 deletions internal/comments/models.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
21 changes: 21 additions & 0 deletions internal/comments/module.go
Original file line number Diff line number Diff line change
@@ -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),
)
}
88 changes: 88 additions & 0 deletions internal/comments/repository.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading