Skip to content

Commit 5617b1a

Browse files
committed
[comments] add module and API
1 parent 891283e commit 5617b1a

17 files changed

Lines changed: 1092 additions & 99 deletions

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ require (
2626
go.uber.org/fx v1.24.0
2727
go.uber.org/zap v1.27.1
2828
golang.org/x/crypto v0.49.0
29-
golang.org/x/sync v0.20.0
3029
)
3130

3231
require (
@@ -92,6 +91,7 @@ require (
9291
go.yaml.in/yaml/v3 v3.0.4 // indirect
9392
golang.org/x/mod v0.33.0 // indirect
9493
golang.org/x/net v0.51.0 // indirect
94+
golang.org/x/sync v0.20.0 // indirect
9595
golang.org/x/sys v0.43.0 // indirect
9696
golang.org/x/text v0.35.0 // indirect
9797
golang.org/x/tools v0.42.0 // indirect

internal/app.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package internal
33
import (
44
"context"
55

6+
"github.com/bit-issues/backend/internal/comments"
67
"github.com/bit-issues/backend/internal/config"
78
"github.com/bit-issues/backend/internal/db"
89
"github.com/bit-issues/backend/internal/jwt"
@@ -54,6 +55,7 @@ func Run(version healthfx.Version) {
5455
users.Module(),
5556
projects.Module(),
5657
tasks.Module(),
58+
comments.Module(),
5759
//
5860
fx.Invoke(func(lc fx.Lifecycle, logger *zap.Logger) {
5961
lc.Append(fx.Hook{

internal/comments/doc.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Package comments provides functionality for managing comments on tasks.
2+
//
3+
// The comments module supports:
4+
// - Creating comments with Markdown content
5+
// - Editing comments (author or admin only)
6+
// - Soft deleting comments (author or admin only)
7+
// - Retrieving comments by task
8+
//
9+
// # Usage
10+
//
11+
// Create a new comment:
12+
//
13+
// svc := comments.NewService(repo, tasksSvc, logger)
14+
// input := comments.CommentInput{
15+
// TaskID: 123,
16+
// AuthorID: 456,
17+
// Content: "This is a comment",
18+
// }
19+
// comment, err := svc.Create(ctx, input)
20+
//
21+
// List comments for a task:
22+
//
23+
// pagination := &db.Pagination{}
24+
// comments, err := svc.ListByTask(ctx, taskID, pagination)
25+
//
26+
// Update a comment:
27+
//
28+
// update := comments.CommentUpdate{Content: "Updated content"}
29+
// err := svc.Update(ctx, userID, commentID, update)
30+
//
31+
// Delete a comment (soft delete):
32+
//
33+
// err := svc.Delete(ctx, userID, commentID)
34+
package comments

internal/comments/domain.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package comments
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
"unicode/utf8"
8+
)
9+
10+
const (
11+
// MaxContentLength is the maximum length of comment content.
12+
MaxContentLength = 10000
13+
)
14+
15+
// Comment represents a complete comment entity with all fields.
16+
type Comment struct {
17+
ID int64
18+
TaskID int64
19+
AuthorID int64
20+
Content string
21+
CreatedAt time.Time
22+
UpdatedAt time.Time
23+
DeletedAt *time.Time
24+
}
25+
26+
// CommentInput contains the data required to create a new comment.
27+
type CommentInput struct {
28+
TaskID int64
29+
AuthorID int64
30+
Content string
31+
}
32+
33+
// CommentUpdate represents the data that can be updated for a comment.
34+
type CommentUpdate struct {
35+
Content string
36+
}
37+
38+
// Validate checks that the input data is valid for comment creation.
39+
func (i CommentInput) Validate() error {
40+
// Validate task ID
41+
if i.TaskID <= 0 {
42+
return fmt.Errorf("%w: task_id must be positive", ErrValidationFailed)
43+
}
44+
45+
// Validate author ID
46+
if i.AuthorID <= 0 {
47+
return fmt.Errorf("%w: author_id must be positive", ErrValidationFailed)
48+
}
49+
50+
// Validate content
51+
content := strings.TrimSpace(i.Content)
52+
if content == "" {
53+
return fmt.Errorf("%w: content is required", ErrValidationFailed)
54+
}
55+
56+
if utf8.RuneCountInString(content) > MaxContentLength {
57+
return fmt.Errorf("%w: content too long (max %d characters)", ErrValidationFailed, MaxContentLength)
58+
}
59+
60+
return nil
61+
}
62+
63+
// Validate checks that the update data is valid.
64+
func (u CommentUpdate) Validate() error {
65+
// Validate content
66+
content := strings.TrimSpace(u.Content)
67+
if content == "" {
68+
return fmt.Errorf("%w: content is required", ErrValidationFailed)
69+
}
70+
71+
if utf8.RuneCountInString(content) > MaxContentLength {
72+
return fmt.Errorf("%w: content too long (max %d characters)", ErrValidationFailed, MaxContentLength)
73+
}
74+
75+
return nil
76+
}

internal/comments/errors.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package comments
2+
3+
import "errors"
4+
5+
// Module-specific error definitions.
6+
// These errors can be checked using [errors.Is](err, ErrXXX).
7+
var (
8+
// ErrNotFound indicates the requested comment does not exist.
9+
ErrNotFound = errors.New("comment not found")
10+
11+
// ErrValidationFailed indicates input validation failed.
12+
ErrValidationFailed = errors.New("validation failed")
13+
14+
// ErrUnauthorized indicates the user lacks permission for this action.
15+
ErrUnauthorized = errors.New("unauthorized")
16+
)

internal/comments/models.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package comments
2+
3+
import (
4+
"time"
5+
6+
"github.com/go-core-fx/bunfx"
7+
"github.com/uptrace/bun"
8+
)
9+
10+
// commentModel is the database representation using Bun ORM.
11+
// This struct maps to the `comments` table and is used for all database operations.
12+
type commentModel struct {
13+
bun.BaseModel `bun:"table:comments,alias:c"`
14+
bunfx.TimedModel
15+
16+
ID int64 `bun:"id,pk,autoincrement"`
17+
TaskID int64 `bun:"task_id,notnull"`
18+
AuthorID int64 `bun:"author_id,notnull"`
19+
Content string `bun:"content,notnull,type:text"`
20+
DeletedAt *time.Time `bun:"deleted_at,soft_delete,nullzero"`
21+
}
22+
23+
// newCommentModel creates a new commentModel from CommentInput.
24+
func newCommentModel(input CommentInput) *commentModel {
25+
now := time.Now()
26+
return &commentModel{
27+
BaseModel: bun.BaseModel{},
28+
TimedModel: bunfx.TimedModel{CreatedAt: now, UpdatedAt: now},
29+
ID: 0, // Auto-generated by database
30+
TaskID: input.TaskID,
31+
AuthorID: input.AuthorID,
32+
Content: input.Content,
33+
DeletedAt: nil,
34+
}
35+
}
36+
37+
// toDomain converts the database model to a domain Comment entity.
38+
// Returns nil if the model is nil.
39+
func (m *commentModel) toDomain() *Comment {
40+
if m == nil {
41+
return nil
42+
}
43+
return &Comment{
44+
ID: m.ID,
45+
TaskID: m.TaskID,
46+
AuthorID: m.AuthorID,
47+
Content: m.Content,
48+
CreatedAt: m.CreatedAt,
49+
UpdatedAt: m.UpdatedAt,
50+
DeletedAt: m.DeletedAt,
51+
}
52+
}

internal/comments/module.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package comments
2+
3+
import (
4+
"github.com/go-core-fx/logger"
5+
"go.uber.org/fx"
6+
)
7+
8+
// Module creates and returns an FX module for the comments package.
9+
//
10+
// The module provides:
11+
// - comments.Service (public) - for use by HTTP handlers and other modules
12+
// - comments.Repository (private) - internal data access layer
13+
// - Named logger "comments" for structured logging
14+
func Module() fx.Option {
15+
return fx.Module(
16+
"comments",
17+
logger.WithNamedLogger("comments"),
18+
fx.Provide(NewRepository, fx.Private),
19+
fx.Provide(NewService),
20+
)
21+
}

internal/comments/repository.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package comments
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"errors"
7+
"fmt"
8+
9+
"github.com/uptrace/bun"
10+
)
11+
12+
// Repository handles data access operations for comments.
13+
type Repository struct {
14+
db *bun.DB
15+
}
16+
17+
// NewRepository creates a new Repository instance with the given database connection.
18+
func NewRepository(db *bun.DB) *Repository {
19+
return &Repository{db: db}
20+
}
21+
22+
// Create inserts a new comment.
23+
func (r *Repository) Create(ctx context.Context, input CommentInput) (*Comment, error) {
24+
model := newCommentModel(input)
25+
26+
if _, err := r.db.NewInsert().Model(model).Returning("*").Exec(ctx); err != nil {
27+
return nil, fmt.Errorf("failed to insert comment: %w", err)
28+
}
29+
30+
return model.toDomain(), nil
31+
}
32+
33+
// GetByID retrieves a comment by its ID.
34+
func (r *Repository) GetByID(ctx context.Context, id int64) (*Comment, error) {
35+
var model commentModel
36+
if err := r.db.NewSelect().Model(&model).Where("id = ?", id).Scan(ctx); err != nil {
37+
if errors.Is(err, sql.ErrNoRows) {
38+
return nil, ErrNotFound
39+
}
40+
return nil, fmt.Errorf("failed to get comment by ID: %w", err)
41+
}
42+
return model.toDomain(), nil
43+
}
44+
45+
// List retrieves all comments for a specific task.
46+
func (r *Repository) List(ctx context.Context, taskID int64) ([]Comment, error) {
47+
models := make([]commentModel, 0)
48+
49+
query := r.db.NewSelect().Model(&models).Where("task_id = ?", taskID)
50+
51+
if err := query.OrderExpr("created_at ASC").Scan(ctx); err != nil {
52+
return nil, fmt.Errorf("failed to list comments by task: %w", err)
53+
}
54+
55+
comments := make([]Comment, 0, len(models))
56+
for _, model := range models {
57+
comments = append(comments, *model.toDomain())
58+
}
59+
60+
return comments, nil
61+
}
62+
63+
// Update modifies an existing comment with the provided content.
64+
func (r *Repository) Update(ctx context.Context, id int64, content string) error {
65+
if _, err := r.db.NewUpdate().
66+
Model((*commentModel)(nil)).
67+
Set("content = ?", content).
68+
Where("id = ?", id).
69+
Exec(ctx); err != nil {
70+
return fmt.Errorf("failed to update comment: %w", err)
71+
}
72+
73+
return nil
74+
}
75+
76+
// Delete soft-deletes a comment by setting the deleted_at timestamp.
77+
func (r *Repository) Delete(ctx context.Context, id int64) error {
78+
_, err := r.db.NewDelete().
79+
Model((*commentModel)(nil)).
80+
Where("id = ?", id).
81+
Exec(ctx)
82+
83+
if err != nil {
84+
return fmt.Errorf("failed to delete comment: %w", err)
85+
}
86+
87+
return nil
88+
}

0 commit comments

Comments
 (0)