Skip to content

Commit 25cdaf0

Browse files
committed
[projects] introduce module
1 parent 8bd230e commit 25cdaf0

17 files changed

Lines changed: 1282 additions & 1 deletion

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/gofiber/fiber/v2 v2.52.12
1717
github.com/golang-jwt/jwt/v5 v5.3.1
1818
github.com/google/uuid v1.6.0
19+
github.com/gosimple/slug v1.15.0
1920
github.com/pressly/goose/v3 v3.27.0
2021
github.com/prometheus/client_golang v1.23.2
2122
github.com/samber/lo v1.52.0
@@ -50,6 +51,7 @@ require (
5051
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
5152
github.com/gofiber/contrib/fiberzap/v2 v2.1.6 // indirect
5253
github.com/gofiber/swagger v1.1.1 // indirect
54+
github.com/gosimple/unidecode v1.0.1 // indirect
5355
github.com/jinzhu/inflection v1.0.0 // indirect
5456
github.com/joho/godotenv v1.5.1 // indirect
5557
github.com/josharian/intern v1.0.0 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
8484
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
8585
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
8686
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
87+
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
88+
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
89+
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
90+
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
8791
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
8892
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
8993
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=

internal/app.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/bit-issues/backend/internal/config"
77
"github.com/bit-issues/backend/internal/db"
88
"github.com/bit-issues/backend/internal/jwt"
9+
"github.com/bit-issues/backend/internal/projects"
910
"github.com/bit-issues/backend/internal/server"
1011
"github.com/bit-issues/backend/internal/users"
1112
"github.com/go-core-fx/bunfx"
@@ -50,6 +51,7 @@ func Run(version healthfx.Version) {
5051
fx.Supply(version),
5152
jwt.Module(),
5253
users.Module(),
54+
projects.Module(),
5355
//
5456
fx.Invoke(func(lc fx.Lifecycle, logger *zap.Logger) {
5557
lc.Append(fx.Hook{
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
CREATE TABLE `projects` (
4+
`id` VARCHAR(255) NOT NULL,
5+
`name` VARCHAR(255) NOT NULL,
6+
`repo_url` VARCHAR(512) NOT NULL,
7+
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
9+
PRIMARY KEY (`id`),
10+
UNIQUE KEY `idx_projects_name` (`name`)
11+
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
12+
-- +goose StatementEnd
13+
---
14+
-- +goose Down
15+
-- +goose StatementBegin
16+
DROP TABLE `projects`;
17+
-- +goose StatementEnd

internal/projects/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package projects
2+
3+
// Config holds configuration for the projects module.
4+
// Currently empty as no module-specific configuration is needed for MVP.
5+
// This struct can be extended in the future for feature flags or
6+
// other configurable aspects.
7+
type Config struct {
8+
// Example: MaxProjectsPerPage int `env:"MAX_PROJECTS_PER_PAGE" default:"100"`
9+
}

internal/projects/doc.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Package projects provides project management functionality for the
2+
// Corporate Task Tracker. Projects serve as containers for tasks and
3+
// are linked to external BitBucket repositories.
4+
//
5+
// The projects module follows a clean architecture pattern with clear
6+
// separation between domain logic, data access, and HTTP presentation.
7+
//
8+
// Domain Layer:
9+
// - Project: Core business entity
10+
// - ProjectInput: Data for creating projects
11+
// - ProjectUpdate: Data for updating projects
12+
//
13+
// Repository Layer:
14+
// - Handles all database operations
15+
// - Uses Bun ORM for type-safe queries
16+
//
17+
// Service Layer:
18+
// - Implements business rules and validation
19+
// - Validates repository URLs
20+
// - Ensures name uniqueness
21+
//
22+
// HTTP Layer:
23+
// - RESTful API endpoints
24+
// - Admin-only write operations
25+
// - Authenticated read operations
26+
//
27+
// Usage:
28+
//
29+
// app := fx.New(
30+
// projects.Module(),
31+
// // other modules...
32+
// )
33+
package projects

internal/projects/domain.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package projects
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"strings"
7+
"time"
8+
)
9+
10+
// Project represents the core business entity for a project.
11+
// A project is a container for tasks and is linked to a BitBucket repository.
12+
type Project struct {
13+
ID string // Primary key
14+
Name string // Unique project name
15+
RepoURL string // BitBucket repository URL
16+
CreatedAt time.Time // Creation timestamp
17+
UpdatedAt time.Time // Last update timestamp
18+
}
19+
20+
// ProjectInput represents the data required to create a new project.
21+
// All fields are required and must be validated before creation.
22+
type ProjectInput struct {
23+
Name string // Unique project name, required
24+
RepoURL string // BitBucket repository URL, required
25+
}
26+
27+
// ProjectUpdate represents the data that can be updated for a project.
28+
// All fields are optional (pointers) to support partial updates.
29+
type ProjectUpdate struct {
30+
Name *string // Optional new name, must be unique if provided
31+
RepoURL *string // Optional new repository URL, must be valid if provided
32+
}
33+
34+
// Validate checks if the input data is valid for creating a new project.
35+
func (i ProjectInput) Validate() error {
36+
// Trim and validate name
37+
name := strings.TrimSpace(i.Name)
38+
if name == "" {
39+
return fmt.Errorf("%w: project name is required", ErrValidationFailed)
40+
}
41+
42+
// Validate repository URL
43+
repoURL := strings.TrimSpace(i.RepoURL)
44+
if err := validateRepoURL(repoURL); err != nil {
45+
return err
46+
}
47+
48+
return nil
49+
}
50+
51+
// IsEmpty returns true if no update fields are set.
52+
// This prevents unnecessary database operations when no data is provided.
53+
func (u ProjectUpdate) IsEmpty() bool {
54+
return u.Name == nil && u.RepoURL == nil
55+
}
56+
57+
func (u ProjectUpdate) Validate() error {
58+
if u.Name != nil {
59+
// Trim and validate name
60+
name := strings.TrimSpace(*u.Name)
61+
if name == "" {
62+
return fmt.Errorf("%w: project name is required", ErrValidationFailed)
63+
}
64+
}
65+
66+
if u.RepoURL != nil {
67+
// Validate repository URL
68+
repoURL := strings.TrimSpace(*u.RepoURL)
69+
if err := validateRepoURL(repoURL); err != nil {
70+
return err
71+
}
72+
}
73+
74+
return nil
75+
}
76+
77+
// validateRepoURL validates that the repository URL is in a valid format.
78+
// Accepts HTTPS URLs (https://bitbucket.org/...).
79+
func validateRepoURL(repoURL string) error {
80+
if repoURL == "" {
81+
return fmt.Errorf("%w: repository URL is required", ErrValidationFailed)
82+
}
83+
84+
u, err := url.Parse(repoURL)
85+
if err != nil {
86+
return fmt.Errorf("%w: failed to parse repository URL: %w", ErrValidationFailed, err)
87+
}
88+
89+
// Accept HTTPS scheme
90+
if u.Scheme != "https" {
91+
return fmt.Errorf("%w: repository URL must be in HTTPS format", ErrValidationFailed)
92+
}
93+
94+
// Basic validation: must have host
95+
if u.Host == "" {
96+
return fmt.Errorf("%w: repository URL must have a host", ErrValidationFailed)
97+
}
98+
99+
return nil
100+
}

internal/projects/errors.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package projects
2+
3+
import "errors"
4+
5+
var (
6+
// ErrValidationFailed is returned when input data fails validation.
7+
ErrValidationFailed = errors.New("validation failed")
8+
9+
// ErrNotFound is returned when a project with the given ID does not exist.
10+
ErrNotFound = errors.New("project not found")
11+
12+
// ErrNameAlreadyUsed is returned when attempting to create or update
13+
// a project with a name that is already in use by another project.
14+
ErrNameAlreadyUsed = errors.New("project name already in use")
15+
16+
// ErrInvalidURL is returned when the repository URL is not in a valid
17+
// format.
18+
ErrInvalidURL = errors.New("invalid repository URL")
19+
)

internal/projects/models.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package projects
2+
3+
import (
4+
"time"
5+
6+
"github.com/go-core-fx/bunfx"
7+
"github.com/uptrace/bun"
8+
"github.com/uptrace/bun/schema"
9+
)
10+
11+
// projectModel is the database representation of a project.
12+
// This struct is used for Bun ORM operations and maps to the `projects` table.
13+
type projectModel struct {
14+
bun.BaseModel `bun:"table:projects,alias:p"`
15+
bunfx.TimedModel
16+
17+
ID string `bun:"id,pk"` // Primary key
18+
Name string `bun:"name,notnull,unique"`
19+
RepoURL string `bun:"repo_url,notnull"`
20+
}
21+
22+
// newProjectModel creates a new projectModel from a ProjectInput.
23+
// It automatically sets the ID from the name and timestamps.
24+
func newProjectModel(input ProjectInput, slug string) *projectModel {
25+
now := time.Now()
26+
return &projectModel{
27+
BaseModel: schema.BaseModel{},
28+
TimedModel: bunfx.TimedModel{
29+
CreatedAt: now,
30+
UpdatedAt: now,
31+
},
32+
33+
ID: slug,
34+
Name: input.Name,
35+
RepoURL: input.RepoURL,
36+
}
37+
}
38+
39+
// toDomain converts the database model to a domain Project entity.
40+
// Returns nil if the model is nil.
41+
func (m *projectModel) toDomain() *Project {
42+
if m == nil {
43+
return nil
44+
}
45+
return &Project{
46+
ID: m.ID,
47+
Name: m.Name,
48+
RepoURL: m.RepoURL,
49+
CreatedAt: m.CreatedAt,
50+
UpdatedAt: m.UpdatedAt,
51+
}
52+
}

internal/projects/module.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package projects
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 projects package.
9+
// This module wires up all dependencies for the projects functionality:
10+
// - Repository (private): Data access layer, only used within this module
11+
// - Service (public): Business logic layer, can be injected into other modules
12+
//
13+
// The module also registers a named logger for structured logging.
14+
func Module() fx.Option {
15+
return fx.Module(
16+
"projects",
17+
// Add a named logger for this module
18+
logger.WithNamedLogger("projects"),
19+
20+
// Provide the repository as a private dependency
21+
// This means it can only be used within this module
22+
fx.Provide(NewRepository, fx.Private),
23+
24+
// Provide the service as a public dependency
25+
// This means it can be injected into other modules (e.g., tasks module)
26+
fx.Provide(NewService),
27+
)
28+
}

0 commit comments

Comments
 (0)