Fix BASE_URL config loading, add tasks/projects; robust .env path resolution
- Config: try ENV_FILE, .env, ../.env for loading; trim trailing slash from BaseURL - Log BASE_URL at server startup for verification - .env.example: document BASE_URL - Tasks, projects, tags, migrations and related API/handlers Made-with: Cursor
This commit is contained in:
274
internal/service/project.go
Normal file
274
internal/service/project.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type ProjectService struct {
|
||||
queries *repository.Queries
|
||||
audit *AuditService
|
||||
}
|
||||
|
||||
func NewProjectService(queries *repository.Queries, audit *AuditService) *ProjectService {
|
||||
return &ProjectService{queries: queries, audit: audit}
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string
|
||||
Color *string
|
||||
IsShared *bool
|
||||
Deadline *time.Time
|
||||
SortOrder *int
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name *string
|
||||
Color *string
|
||||
IsShared *bool
|
||||
Deadline *time.Time
|
||||
SortOrder *int
|
||||
}
|
||||
|
||||
func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, req CreateProjectRequest) (*models.Project, error) {
|
||||
if len(req.Name) < 1 || len(req.Name) > 100 {
|
||||
return nil, models.NewValidationError("project name must be 1-100 characters")
|
||||
}
|
||||
color := "#3B82F6"
|
||||
if req.Color != nil {
|
||||
if err := utils.ValidateColor(*req.Color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
color = *req.Color
|
||||
}
|
||||
isShared := false
|
||||
if req.IsShared != nil {
|
||||
isShared = *req.IsShared
|
||||
}
|
||||
sortOrder := int32(0)
|
||||
if req.SortOrder != nil {
|
||||
sortOrder = int32(*req.SortOrder)
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
p, err := s.queries.CreateProject(ctx, repository.CreateProjectParams{
|
||||
ID: utils.ToPgUUID(id),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
Name: req.Name,
|
||||
Color: color,
|
||||
Column5: isShared,
|
||||
Deadline: utils.ToPgTimestamptzPtr(req.Deadline),
|
||||
Column7: sortOrder,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "project", id, "CREATE_PROJECT", userID)
|
||||
return projectFromDB(p), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Get(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (*models.Project, error) {
|
||||
p, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
return projectFromDB(p), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) List(ctx context.Context, userID uuid.UUID) ([]models.Project, error) {
|
||||
rows, err := s.queries.ListProjectsForUser(ctx, utils.ToPgUUID(userID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
projects := make([]models.Project, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
projects = append(projects, *projectFromDB(r))
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Update(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, req UpdateProjectRequest) (*models.Project, error) {
|
||||
if req.Name != nil && (len(*req.Name) < 1 || len(*req.Name) > 100) {
|
||||
return nil, models.NewValidationError("project name must be 1-100 characters")
|
||||
}
|
||||
if req.Color != nil {
|
||||
if err := utils.ValidateColor(*req.Color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
updateParams := repository.UpdateProjectParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
}
|
||||
if req.Name != nil {
|
||||
updateParams.Name = utils.ToPgTextPtr(req.Name)
|
||||
}
|
||||
if req.Color != nil {
|
||||
updateParams.Color = utils.ToPgTextPtr(req.Color)
|
||||
}
|
||||
if req.IsShared != nil {
|
||||
updateParams.IsShared = utils.ToPgBoolPtr(req.IsShared)
|
||||
}
|
||||
if req.Deadline != nil {
|
||||
updateParams.Deadline = utils.ToPgTimestamptzPtr(req.Deadline)
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
updateParams.SortOrder = utils.ToPgInt4Ptr(req.SortOrder)
|
||||
}
|
||||
|
||||
p, err := s.queries.UpdateProject(ctx, updateParams)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "project", projectID, "UPDATE_PROJECT", userID)
|
||||
return projectFromDB(p), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Delete(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) error {
|
||||
err := s.queries.SoftDeleteProject(ctx, repository.SoftDeleteProjectParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
s.audit.Log(ctx, "project", projectID, "DELETE_PROJECT", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Share(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, targetEmail, role string) error {
|
||||
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
if role != "editor" && role != "viewer" {
|
||||
return models.NewValidationError("role must be editor or viewer")
|
||||
}
|
||||
|
||||
targetUser, err := s.queries.GetUserByEmail(ctx, utils.NormalizeEmail(targetEmail))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.NewNotFoundError("user not found")
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
targetID := utils.FromPgUUID(targetUser.ID)
|
||||
if targetID == userID {
|
||||
return models.NewValidationError("cannot share with yourself")
|
||||
}
|
||||
|
||||
return s.queries.AddProjectMember(ctx, repository.AddProjectMemberParams{
|
||||
ProjectID: utils.ToPgUUID(projectID),
|
||||
UserID: utils.ToPgUUID(targetID),
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProjectService) AddMember(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, targetUserID uuid.UUID, role string) error {
|
||||
if role != "owner" && role != "editor" && role != "viewer" {
|
||||
return models.NewValidationError("invalid role")
|
||||
}
|
||||
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
return s.queries.AddProjectMember(ctx, repository.AddProjectMemberParams{
|
||||
ProjectID: utils.ToPgUUID(projectID),
|
||||
UserID: utils.ToPgUUID(targetUserID),
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProjectService) RemoveMember(ctx context.Context, userID uuid.UUID, projectID uuid.UUID, targetUserID uuid.UUID) error {
|
||||
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
return s.queries.RemoveProjectMember(ctx, repository.RemoveProjectMemberParams{
|
||||
ProjectID: utils.ToPgUUID(projectID),
|
||||
UserID: utils.ToPgUUID(targetUserID),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProjectService) ListMembers(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) ([]models.ProjectMember, error) {
|
||||
_, err := s.queries.GetProjectByID(ctx, repository.GetProjectByIDParams{
|
||||
ID: utils.ToPgUUID(projectID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
rows, err := s.queries.ListProjectMembers(ctx, utils.ToPgUUID(projectID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
members := make([]models.ProjectMember, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
members = append(members, models.ProjectMember{
|
||||
UserID: utils.FromPgUUID(r.UserID),
|
||||
Email: r.Email,
|
||||
Role: r.Role,
|
||||
})
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func projectFromDB(p repository.Project) *models.Project {
|
||||
proj := &models.Project{
|
||||
ID: utils.FromPgUUID(p.ID),
|
||||
OwnerID: utils.FromPgUUID(p.OwnerID),
|
||||
Name: p.Name,
|
||||
Color: p.Color,
|
||||
IsShared: p.IsShared,
|
||||
SortOrder: int(p.SortOrder),
|
||||
CreatedAt: utils.FromPgTimestamptz(p.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(p.UpdatedAt),
|
||||
}
|
||||
if p.Deadline.Valid {
|
||||
proj.Deadline = &p.Deadline.Time
|
||||
}
|
||||
return proj
|
||||
}
|
||||
Reference in New Issue
Block a user