diff --git a/go.mod b/go.mod index 7a354ff..fe3b02b 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/gofiber/fiber/v2 v2.52.12 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 + github.com/gosimple/slug v1.15.0 github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 github.com/samber/lo v1.52.0 @@ -50,6 +51,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofiber/contrib/fiberzap/v2 v2.1.6 // indirect github.com/gofiber/swagger v1.1.1 // indirect + github.com/gosimple/unidecode v1.0.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/go.sum b/go.sum index 9c1e021..5a27731 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= +github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/internal/app.go b/internal/app.go index c94813e..b347c14 100644 --- a/internal/app.go +++ b/internal/app.go @@ -6,6 +6,7 @@ import ( "github.com/bit-issues/backend/internal/config" "github.com/bit-issues/backend/internal/db" "github.com/bit-issues/backend/internal/jwt" + "github.com/bit-issues/backend/internal/projects" "github.com/bit-issues/backend/internal/server" "github.com/bit-issues/backend/internal/users" "github.com/go-core-fx/bunfx" @@ -50,6 +51,7 @@ func Run(version healthfx.Version) { fx.Supply(version), jwt.Module(), users.Module(), + projects.Module(), // fx.Invoke(func(lc fx.Lifecycle, logger *zap.Logger) { lc.Append(fx.Hook{ diff --git a/internal/db/migrations/20260413013944_create_projects.sql b/internal/db/migrations/20260413013944_create_projects.sql new file mode 100644 index 0000000..49ed464 --- /dev/null +++ b/internal/db/migrations/20260413013944_create_projects.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE `projects` ( + `id` VARCHAR(255) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `repo_url` VARCHAR(512) NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_projects_name` (`name`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; +-- +goose StatementEnd +--- +-- +goose Down +-- +goose StatementBegin +DROP TABLE `projects`; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/projects/config.go b/internal/projects/config.go new file mode 100644 index 0000000..974d763 --- /dev/null +++ b/internal/projects/config.go @@ -0,0 +1,9 @@ +package projects + +// Config holds configuration for the projects module. +// Currently empty as no module-specific configuration is needed for MVP. +// This struct can be extended in the future for feature flags or +// other configurable aspects. +type Config struct { + // Example: MaxProjectsPerPage int `env:"MAX_PROJECTS_PER_PAGE" default:"100"` +} diff --git a/internal/projects/doc.go b/internal/projects/doc.go new file mode 100644 index 0000000..d0134d1 --- /dev/null +++ b/internal/projects/doc.go @@ -0,0 +1,33 @@ +// Package projects provides project management functionality for the +// Corporate Task Tracker. Projects serve as containers for tasks and +// are linked to external BitBucket repositories. +// +// The projects module follows a clean architecture pattern with clear +// separation between domain logic, data access, and HTTP presentation. +// +// Domain Layer: +// - Project: Core business entity +// - ProjectInput: Data for creating projects +// - ProjectUpdate: Data for updating projects +// +// Repository Layer: +// - Handles all database operations +// - Uses Bun ORM for type-safe queries +// +// Service Layer: +// - Implements business rules and validation +// - Validates repository URLs +// - Ensures name uniqueness +// +// HTTP Layer: +// - RESTful API endpoints +// - Admin-only write operations +// - Authenticated read operations +// +// Usage: +// +// app := fx.New( +// projects.Module(), +// // other modules... +// ) +package projects diff --git a/internal/projects/domain.go b/internal/projects/domain.go new file mode 100644 index 0000000..abf9b1e --- /dev/null +++ b/internal/projects/domain.go @@ -0,0 +1,100 @@ +package projects + +import ( + "fmt" + "net/url" + "strings" + "time" +) + +// Project represents the core business entity for a project. +// A project is a container for tasks and is linked to a BitBucket repository. +type Project struct { + ID string // Primary key + Name string // Unique project name + RepoURL string // BitBucket repository URL + CreatedAt time.Time // Creation timestamp + UpdatedAt time.Time // Last update timestamp +} + +// ProjectInput represents the data required to create a new project. +// All fields are required and must be validated before creation. +type ProjectInput struct { + Name string // Unique project name, required + RepoURL string // BitBucket repository URL, required +} + +// ProjectUpdate represents the data that can be updated for a project. +// All fields are optional (pointers) to support partial updates. +type ProjectUpdate struct { + Name *string // Optional new name, must be unique if provided + RepoURL *string // Optional new repository URL, must be valid if provided +} + +// Validate checks if the input data is valid for creating a new project. +func (i ProjectInput) Validate() error { + // Trim and validate name + name := strings.TrimSpace(i.Name) + if name == "" { + return fmt.Errorf("%w: project name is required", ErrValidationFailed) + } + + // Validate repository URL + repoURL := strings.TrimSpace(i.RepoURL) + if err := validateRepoURL(repoURL); err != nil { + return err + } + + return nil +} + +// IsEmpty returns true if no update fields are set. +// This prevents unnecessary database operations when no data is provided. +func (u ProjectUpdate) IsEmpty() bool { + return u.Name == nil && u.RepoURL == nil +} + +func (u ProjectUpdate) Validate() error { + if u.Name != nil { + // Trim and validate name + name := strings.TrimSpace(*u.Name) + if name == "" { + return fmt.Errorf("%w: project name is required", ErrValidationFailed) + } + } + + if u.RepoURL != nil { + // Validate repository URL + repoURL := strings.TrimSpace(*u.RepoURL) + if err := validateRepoURL(repoURL); err != nil { + return err + } + } + + return nil +} + +// validateRepoURL validates that the repository URL is in a valid format. +// Accepts HTTPS URLs (https://bitbucket.org/...). +func validateRepoURL(repoURL string) error { + if repoURL == "" { + return fmt.Errorf("%w: repository URL is required", ErrValidationFailed) + } + + u, err := url.Parse(repoURL) + if err != nil { + return fmt.Errorf("%w: failed to parse repository URL: %w", ErrValidationFailed, err) + } + + // Accept HTTPS scheme + if u.Scheme != "https" { + return fmt.Errorf("%w: repository URL must be in HTTPS format", ErrValidationFailed) + } + + // Basic validation: must have host + if u.Host == "" { + return fmt.Errorf("%w: repository URL must have a host", ErrValidationFailed) + } + + return nil +} diff --git a/internal/projects/errors.go b/internal/projects/errors.go new file mode 100644 index 0000000..2e89fb2 --- /dev/null +++ b/internal/projects/errors.go @@ -0,0 +1,19 @@ +package projects + +import "errors" + +var ( + // ErrValidationFailed is returned when input data fails validation. + ErrValidationFailed = errors.New("validation failed") + + // ErrNotFound is returned when a project with the given ID does not exist. + ErrNotFound = errors.New("project not found") + + // ErrNameAlreadyUsed is returned when attempting to create or update + // a project with a name that is already in use by another project. + ErrNameAlreadyUsed = errors.New("project name already in use") + + // ErrInvalidURL is returned when the repository URL is not in a valid + // format. + ErrInvalidURL = errors.New("invalid repository URL") +) diff --git a/internal/projects/models.go b/internal/projects/models.go new file mode 100644 index 0000000..a749fa7 --- /dev/null +++ b/internal/projects/models.go @@ -0,0 +1,52 @@ +package projects + +import ( + "time" + + "github.com/go-core-fx/bunfx" + "github.com/uptrace/bun" + "github.com/uptrace/bun/schema" +) + +// projectModel is the database representation of a project. +// This struct is used for Bun ORM operations and maps to the `projects` table. +type projectModel struct { + bun.BaseModel `bun:"table:projects,alias:p"` + bunfx.TimedModel + + ID string `bun:"id,pk"` // Primary key + Name string `bun:"name,notnull,unique"` + RepoURL string `bun:"repo_url,notnull"` +} + +// newProjectModel creates a new projectModel from a ProjectInput. +// It automatically sets the ID from the name and timestamps. +func newProjectModel(input ProjectInput, slug string) *projectModel { + now := time.Now() + return &projectModel{ + BaseModel: schema.BaseModel{}, + TimedModel: bunfx.TimedModel{ + CreatedAt: now, + UpdatedAt: now, + }, + + ID: slug, + Name: input.Name, + RepoURL: input.RepoURL, + } +} + +// toDomain converts the database model to a domain Project entity. +// Returns nil if the model is nil. +func (m *projectModel) toDomain() *Project { + if m == nil { + return nil + } + return &Project{ + ID: m.ID, + Name: m.Name, + RepoURL: m.RepoURL, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} diff --git a/internal/projects/module.go b/internal/projects/module.go new file mode 100644 index 0000000..59329fa --- /dev/null +++ b/internal/projects/module.go @@ -0,0 +1,28 @@ +package projects + +import ( + "github.com/go-core-fx/logger" + "go.uber.org/fx" +) + +// Module creates and returns an FX module for the projects package. +// This module wires up all dependencies for the projects functionality: +// - Repository (private): Data access layer, only used within this module +// - Service (public): Business logic layer, can be injected into other modules +// +// The module also registers a named logger for structured logging. +func Module() fx.Option { + return fx.Module( + "projects", + // Add a named logger for this module + logger.WithNamedLogger("projects"), + + // Provide the repository as a private dependency + // This means it can only be used within this module + fx.Provide(NewRepository, fx.Private), + + // Provide the service as a public dependency + // This means it can be injected into other modules (e.g., tasks module) + fx.Provide(NewService), + ) +} diff --git a/internal/projects/repository.go b/internal/projects/repository.go new file mode 100644 index 0000000..03efcf7 --- /dev/null +++ b/internal/projects/repository.go @@ -0,0 +1,151 @@ +package projects + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/bit-issues/backend/internal/db" + "github.com/uptrace/bun" +) + +// Repository is the concrete implementation of Repository using Bun ORM. +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 project into the database. +// Returns the created project, or an error if creation fails. +// Expected errors: +// - ErrNameAlreadyUsed if a project with the same name already exists +// - Other database errors +func (r *Repository) Create(ctx context.Context, input ProjectInput, slug string) (*Project, error) { + model := newProjectModel(input, slug) + + if _, err := r.db.NewInsert().Model(model).Exec(ctx); err != nil { + if db.IsUniqueViolation(err) { + return nil, ErrNameAlreadyUsed + } + return nil, fmt.Errorf("failed to create project: %w", err) + } + + return model.toDomain(), nil +} + +// GetBySlug retrieves a project by its unique slug identifier. +// Returns ErrNotFound if the project does not exist. +func (r *Repository) GetBySlug(ctx context.Context, slug string) (*Project, error) { + var model projectModel + if err := r.db.NewSelect().Model(&model).Where("id = ?", slug).Limit(1).Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to get project by slug: %w", err) + } + return model.toDomain(), nil +} + +// 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) { + 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) + } + + projects := make([]Project, 0, len(models)) + for _, model := range models { + 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 +} + +// Update modifies an existing project with the provided update data. +// Only the fields specified in the update struct will be changed. +// Returns ErrNotFound if the project does not exist. +// Returns ErrNameAlreadyUsed if the new name conflicts with another project. +func (r *Repository) Update(ctx context.Context, slug string, update ProjectUpdate) error { + if update.IsEmpty() { + return nil + } + + query := r.db.NewUpdate().Model((*projectModel)(nil)).Where("id = ?", slug) + + if update.Name != nil { + query = query.Set("name = ?", *update.Name) + } + if update.RepoURL != nil { + query = query.Set("repo_url = ?", *update.RepoURL) + } + + result, err := query.Exec(ctx) + if err != nil { + if db.IsUniqueViolation(err) { + return ErrNameAlreadyUsed + } + return fmt.Errorf("failed to update project: %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 +} + +// Delete removes a project from the database by its slug. +// Due to foreign key constraints with ON DELETE CASCADE, all associated +// tasks, comments, and attachments will be automatically deleted. +// Returns ErrNotFound if the project does not exist. +func (r *Repository) Delete(ctx context.Context, slug string) error { + result, err := r.db.NewDelete().Model((*projectModel)(nil)).Where("id = ?", slug).Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete project: %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 +} + +// Exists checks whether a project with the given slug exists. +// Returns true if found, false otherwise. +func (r *Repository) Exists(ctx context.Context, slug string) (bool, error) { + exists, err := r.db.NewSelect().Model((*projectModel)(nil)). + Where("id = ?", slug). + Exists(ctx) + if err != nil { + return false, fmt.Errorf("failed to check project existence: %w", err) + } + return exists, nil +} diff --git a/internal/projects/service.go b/internal/projects/service.go new file mode 100644 index 0000000..340550e --- /dev/null +++ b/internal/projects/service.go @@ -0,0 +1,111 @@ +package projects + +import ( + "context" + "fmt" + + "github.com/gosimple/slug" +) + +const ( + DefaultLimit = 20 + MaxLimit = 100 +) + +// Service implements the business logic for project management. +// It coordinates between the repository layer and validation rules. +type Service struct { + projects *Repository +} + +// NewService creates a new Service instance with the given repository. +func NewService(repo *Repository) *Service { + return &Service{projects: repo} +} + +// Create creates a new project after validating the input. +// Validation includes: +// - Name must be non-empty after trimming whitespace +// - Repository URL must be in valid format (HTTPS) +// - Project name must be unique (case-sensitive) +// +// Returns the created project or an error if validation fails. +func (s *Service) Create(ctx context.Context, input ProjectInput) (*Project, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + // Create the project + return s.projects.Create(ctx, input, slug.Make(input.Name)) +} + +// GetBySlug retrieves a project by its slug. +// Returns ErrNotFound if the project does not exist. +func (s *Service) GetBySlug(ctx context.Context, slug string) (*Project, error) { + if slug == "" { + return nil, fmt.Errorf("%w: slug is required", ErrValidationFailed) + } + return s.projects.GetBySlug(ctx, slug) +} + +// 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) +} + +// Update modifies an existing project with the provided update data. +// Validation includes: +// - At least one field must be provided +// - If name is provided, it must be non-empty and unique +// - If repoURL is provided, it must be in valid format +// +// Returns the updated project or an error if validation fails. +func (s *Service) Update(ctx context.Context, slug string, update ProjectUpdate) (*Project, error) { + // Validate update data + if err := update.Validate(); err != nil { + return nil, err + } + + // Perform update + if err := s.projects.Update(ctx, slug, update); err != nil { + return nil, err + } + + // Return updated project + return s.projects.GetBySlug(ctx, slug) +} + +// Delete removes a project by its slug. +// All associated tasks, comments, and attachments will be cascade-deleted +// due to foreign key constraints in the database. +// Returns ErrNotFound if the project does not exist. +func (s *Service) Delete(ctx context.Context, slug string) error { + if slug == "" { + return fmt.Errorf("%w: slug is required", ErrValidationFailed) + } + return s.projects.Delete(ctx, slug) +} + +// Exists checks whether a project with the given slug exists. +// Returns true if found, false otherwise. +func (s *Service) Exists(ctx context.Context, slug string) (bool, error) { + if slug == "" { + return false, fmt.Errorf("%w: slug is required", ErrValidationFailed) + } + return s.projects.Exists(ctx, slug) +} diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go index d149bd2..a8b2eb2 100644 --- a/internal/server/docs/docs.go +++ b/internal/server/docs/docs.go @@ -316,6 +316,302 @@ const docTemplate = `{ } } } + }, + "/projects": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns a paginated list of projects accessible to the authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "List all projects", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Page limit (max 100)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/projects.ProjectListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new project linked to a BitBucket repository. Only administrators can perform this action.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Create a new project", + "parameters": [ + { + "description": "Project creation data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/projects.ProjectRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/projects.ProjectResponse" + } + }, + "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" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, + "/projects/{slug}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns detailed information about a specific project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Get project by ID", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/projects.ProjectResponse" + } + }, + "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" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Permanently deletes a project and all its associated data. Only administrators can perform this action.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Delete a project", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "slug", + "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" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates project details. Only administrators can perform this action.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Update a project", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "slug", + "in": "path", + "required": true + }, + { + "description": "Project update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/projects.ProjectUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/projects.ProjectResponse" + } + }, + "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" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } } }, "definitions": { @@ -419,6 +715,76 @@ const docTemplate = `{ } } }, + "projects.ProjectListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/projects.ProjectResponse" + } + }, + "total": { + "type": "integer", + "example": 1 + } + } + }, + "projects.ProjectRequest": { + "type": "object", + "required": [ + "name", + "repo_url" + ], + "properties": { + "name": { + "type": "string", + "example": "Backend Service" + }, + "repo_url": { + "type": "string", + "example": "https://bitbucket.org/company/backend" + } + } + }, + "projects.ProjectResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "example": "2026-04-01T08:00:00Z" + }, + "id": { + "type": "string", + "example": "backend-service" + }, + "name": { + "type": "string", + "example": "Backend Service" + }, + "repo_url": { + "type": "string", + "example": "https://bitbucket.org/company/backend" + }, + "updated_at": { + "type": "string", + "example": "2026-04-02T09:00:00Z" + } + } + }, + "projects.ProjectUpdateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Backend Service" + }, + "repo_url": { + "type": "string", + "example": "https://bitbucket.org/company/backend" + } + } + }, "users.Role": { "type": "string", "enum": [ diff --git a/internal/server/module.go b/internal/server/module.go index a6021b3..dd07923 100644 --- a/internal/server/module.go +++ b/internal/server/module.go @@ -5,6 +5,7 @@ import ( "github.com/bit-issues/backend/internal/server/auth" "github.com/bit-issues/backend/internal/server/docs" "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" + "github.com/bit-issues/backend/internal/server/projects" "github.com/go-core-fx/fiberfx" "github.com/go-core-fx/fiberfx/handler" "github.com/go-core-fx/fiberfx/health" @@ -32,6 +33,7 @@ func Module() fx.Option { fx.Provide( fx.Annotate(users.NewHandler, fx.ResultTags(`group:"handlers"`)), fx.Annotate(auth.NewHandler, fx.ResultTags(`group:"handlers"`)), + fx.Annotate(projects.NewHandler, fx.ResultTags(`group:"handlers"`)), fx.Private, ), diff --git a/internal/server/projects/dto.go b/internal/server/projects/dto.go new file mode 100644 index 0000000..8452ac0 --- /dev/null +++ b/internal/server/projects/dto.go @@ -0,0 +1,82 @@ +package projects + +import ( + "time" + + "github.com/bit-issues/backend/internal/projects" + "github.com/samber/lo" +) + +// ProjectRequest represents the request body for creating a new project. +type ProjectRequest struct { + Name string `json:"name" validate:"required" example:"Backend Service"` + RepoURL string `json:"repo_url" validate:"required,url" example:"https://bitbucket.org/company/backend"` +} + +// ProjectUpdateRequest represents the request body for updating a project. +// All fields are optional to support partial updates. +type ProjectUpdateRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty" example:"Backend Service"` + RepoURL *string `json:"repo_url,omitempty" validate:"omitempty,url" example:"https://bitbucket.org/company/backend"` +} + +// ProjectResponse represents the API response for a single project. +type ProjectResponse struct { + ID string `json:"id" example:"backend-service"` + Name string `json:"name" example:"Backend Service"` + RepoURL string `json:"repo_url" example:"https://bitbucket.org/company/backend"` + CreatedAt string `json:"created_at" example:"2026-04-01T08:00:00Z"` + UpdatedAt string `json:"updated_at" example:"2026-04-02T09:00:00Z"` +} + +func NewProjectResponse(p *projects.Project) ProjectResponse { + return ProjectResponse{ + ID: p.ID, + Name: p.Name, + RepoURL: p.RepoURL, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + UpdatedAt: p.UpdatedAt.Format(time.RFC3339), + } +} + +// ProjectListResponse represents the API response for a list of projects. +type ProjectListResponse struct { + Items []ProjectResponse `json:"items"` + Total int64 `json:"total" example:"1"` +} + +func NewProjectListResponse(items []projects.Project, total int64) ProjectListResponse { + return ProjectListResponse{ + Items: lo.Map( + items, + func(p projects.Project, _ int) ProjectResponse { + return NewProjectResponse(&p) + }, + ), + Total: total, + } +} + +// PaginationQuery represents pagination query parameters. +type PaginationQuery struct { + Limit int `query:"limit" validate:"omitempty,min=1,max=100" default:"20"` + Offset int `query:"offset" validate:"omitempty,min=0" default:"0"` +} + +// Conversion functions + +// toProjectInput converts a ProjectRequest DTO to a domain ProjectInput. +func (req ProjectRequest) toProjectInput() projects.ProjectInput { + return projects.ProjectInput{ + Name: req.Name, + RepoURL: req.RepoURL, + } +} + +// toProjectUpdate converts a ProjectUpdateRequest DTO to a domain ProjectUpdate. +func (req ProjectUpdateRequest) toProjectUpdate() projects.ProjectUpdate { + return projects.ProjectUpdate{ + Name: req.Name, + RepoURL: req.RepoURL, + } +} diff --git a/internal/server/projects/handler.go b/internal/server/projects/handler.go new file mode 100644 index 0000000..c23fff1 --- /dev/null +++ b/internal/server/projects/handler.go @@ -0,0 +1,239 @@ +package projects + +import ( + "errors" + "fmt" + + "github.com/bit-issues/backend/internal/jwt" + "github.com/bit-issues/backend/internal/projects" + "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" + "github.com/bit-issues/backend/internal/users" + "github.com/go-core-fx/fiberfx/handler" + "github.com/go-core-fx/fiberfx/validation" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +// Handler handles HTTP requests for project-related endpoints. +type Handler struct { + handler.Base + + projectsSvc *projects.Service + usersSvc *users.Service + jwtSvc *jwt.Service +} + +// NewHandler creates a new Handler instance with the given dependencies. +func NewHandler( + projectsSvc *projects.Service, + usersSvc *users.Service, + jwtSvc *jwt.Service, + validate *validator.Validate, +) handler.Handler { + return &Handler{ + Base: handler.Base{Validator: validate}, + projectsSvc: projectsSvc, + usersSvc: usersSvc, + jwtSvc: jwtSvc, + } +} + +// Register sets up the project routes on the given router. +// Routes are organized with appropriate middleware for authentication +// and authorization based on the operation. +func (h *Handler) Register(r fiber.Router) { + // Public routes (authenticated users only) + projects := r.Group( + "/projects", + h.errorsHandler, + jwtauth.New(h.jwtSvc, h.usersSvc), + ) + + projects.Get("/", h.list) + projects.Get("/:slug", h.get) + + // Admin-only routes (require admin role) + projects.Post("/", + jwtauth.WithRole(users.RoleAdmin), + validation.DecorateWithBodyEx(h.Validator, h.post), + ) + projects.Patch("/:slug", + jwtauth.WithRole(users.RoleAdmin), + validation.DecorateWithBodyEx(h.Validator, h.patch), + ) + projects.Delete("/:slug", + jwtauth.WithRole(users.RoleAdmin), + h.delete, + ) +} + +// @Summary List all projects +// @Description Returns a paginated list of projects accessible to the authenticated user. +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param limit query int false "Page limit (max 100)" default(20) +// @Param offset query int false "Page offset" default(0) +// @Success 200 {object} ProjectListResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Router /projects [get] +// +// list retrieves a paginated list of all projects. +func (h *Handler) list(c *fiber.Ctx) error { + query := PaginationQuery{ + Limit: 0, + Offset: 0, + } + if err := h.QueryParserValidator(c, &query); err != nil { + return fmt.Errorf("failed to parse query: %w", err) + } + + // Fetch projects from service + projectsList, err := h.projectsSvc.List(c.Context(), 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)) +} + +// @Summary Get project by ID +// @Description Returns detailed information about a specific project. +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param slug path string true "Project ID" +// @Success 200 {object} ProjectResponse +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Router /projects/{slug} [get] +// +// get retrieves a single project by its slug. +func (h *Handler) get(c *fiber.Ctx) error { + slug := c.Params("slug") + + // Fetch project from service + project, err := h.projectsSvc.GetBySlug(c.Context(), slug) + if err != nil { + return fmt.Errorf("failed to get project: %w", err) + } + + return c.JSON(NewProjectResponse(project)) +} + +// @Summary Create a new project +// @Description Creates a new project linked to a BitBucket repository. Only administrators can perform this action. +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body ProjectRequest true "Project creation data" +// @Success 201 {object} ProjectResponse +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 403 {object} fiberfx.ErrorResponse +// @Failure 409 {object} fiberfx.ErrorResponse +// @Router /projects [post] +// +// post creates a new project (admin only). +func (h *Handler) post(c *fiber.Ctx, req *ProjectRequest) error { + // Convert DTO to domain input + input := req.toProjectInput() + + // Create project via service + project, err := h.projectsSvc.Create(c.Context(), input) + if err != nil { + return fmt.Errorf("failed to create project: %w", err) + } + + return c.Status(fiber.StatusCreated).JSON(NewProjectResponse(project)) +} + +// @Summary Update a project +// @Description Updates project details. Only administrators can perform this action. +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param slug path string true "Project ID" +// @Param request body ProjectUpdateRequest true "Project update data" +// @Success 200 {object} ProjectResponse +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 403 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Failure 409 {object} fiberfx.ErrorResponse +// @Router /projects/{slug} [patch] +// +// patch updates an existing project (admin only). +func (h *Handler) patch(c *fiber.Ctx, req *ProjectUpdateRequest) error { + slug := c.Params("slug") + + // Convert DTO to domain update + update := req.toProjectUpdate() + + // Update project via service + project, err := h.projectsSvc.Update(c.Context(), slug, update) + if err != nil { + return fmt.Errorf("failed to update project: %w", err) + } + + return c.JSON(NewProjectResponse(project)) +} + +// @Summary Delete a project +// @Description Permanently deletes a project and all its associated data. Only administrators can perform this action. +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param slug path string true "Project ID" +// @Success 204 +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 403 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Router /projects/{slug} [delete] +// +// delete removes a project (admin only). +func (h *Handler) delete(c *fiber.Ctx) error { + slug := c.Params("slug") + + // Delete project via service + if delErr := h.projectsSvc.Delete(c.Context(), slug); delErr != nil { + return fmt.Errorf("failed to delete project: %w", delErr) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +// errorsHandler is a middleware that converts service errors to appropriate HTTP responses. +// It should be registered as the first middleware in the route group. +func (h *Handler) errorsHandler(c *fiber.Ctx) error { + err := c.Next() + if err == nil { + return nil + } + + switch { + case errors.Is(err, projects.ErrValidationFailed): + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + case errors.Is(err, projects.ErrNotFound): + return fiber.NewError(fiber.StatusNotFound, err.Error()) + case errors.Is(err, projects.ErrNameAlreadyUsed): + return fiber.NewError(fiber.StatusConflict, err.Error()) + case errors.Is(err, projects.ErrInvalidURL): + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + default: + return err //nolint:wrapcheck // err is already wrapped + } +} diff --git a/requests.http b/requests.http index e5ddb2c..3ac8b0c 100644 --- a/requests.http +++ b/requests.http @@ -26,6 +26,7 @@ Content-Type: application/json # Register a new user # @name register @email={{register.response.body.$.email}} +@userID={{register.response.body.$.id}} POST {{baseURL}}/auth/register Content-Type: application/json @@ -67,7 +68,7 @@ Authorization: Bearer {{adminAccessToken}} ### # Update user status/role (admin only) # Requires admin JWT token in Authorization header -PATCH {{baseURL}}/admin/users/1 +PATCH {{baseURL}}/admin/users/{{userID}} Content-Type: application/json Authorization: Bearer {{adminAccessToken}} @@ -105,3 +106,66 @@ Authorization: Bearer {{adminAccessToken}} { "role": "admin" } + +### +# List all projects (authenticated users) +# Optional query parameters: limit (max 100), offset +GET {{baseURL}}/projects?limit=20&offset=0 +Authorization: Bearer {{accessToken}} + +### +# Get project by ID (authenticated users) +# Returns detailed information about a specific project +GET {{baseURL}}/projects/my-new-project +Authorization: Bearer {{accessToken}} + +### +# Create a new project (admin only) +# Requires admin JWT token in Authorization header +POST {{baseURL}}/projects +Content-Type: application/json +Authorization: Bearer {{adminAccessToken}} + +{ + "name": "My New Project", + "repo_url": "https://bitbucket.org/company/repo.git" +} + +### +# Update a project (admin only) +# Requires admin JWT token in Authorization header +# All fields are optional - only provided fields will be updated +PATCH {{baseURL}}/projects/my-new-project +Content-Type: application/json +Authorization: Bearer {{adminAccessToken}} + +{ + "name": "Updated Project Name", + "repo_url": "https://bitbucket.org/company/new-repo.git" +} + +### +# Update only the project name (admin only) +PATCH {{baseURL}}/projects/my-new-project +Content-Type: application/json +Authorization: Bearer {{adminAccessToken}} + +{ + "name": "New Project Name" +} + +### +# Update only the repository URL (admin only) +PATCH {{baseURL}}/projects/my-new-project +Content-Type: application/json +Authorization: Bearer {{adminAccessToken}} + +{ + "repo_url": "https://bitbucket.org/company/updated-repo.git" +} + +### +# Delete a project (admin only) +# WARNING: This permanently deletes the project and all associated tasks, comments, and attachments +DELETE {{baseURL}}/projects/my-new-project +Authorization: Bearer {{adminAccessToken}}