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 }