package services import ( "context" "github.com/gofiber/fiber/v3" "github.com/jackc/pgx/v5/pgtype" "golang.org/x/sync/errgroup" "history-api/internal/dtos/request" "history-api/internal/dtos/response" "history-api/internal/gen/sqlc" "history-api/internal/models" "history-api/internal/repositories" "history-api/pkg/constants" "history-api/pkg/convert" ) type ProjectService interface { GetProjectByID(ctx context.Context, id string) (*response.ProjectResponse, *fiber.Error) GetProjectByUserID(ctx context.Context, userID string, dto *request.GetProjectsByUserDto) ([]*response.ProjectResponse, *fiber.Error) SearchProject(ctx context.Context, dto *request.SearchProjectDto) (*response.PaginatedResponse, *fiber.Error) DeleteProject(ctx context.Context, claims *response.JWTClaims, id string) *fiber.Error CreateProject(ctx context.Context, userID string, dto *request.CreateProjectDto) (*response.ProjectResponse, *fiber.Error) UpdateProject(ctx context.Context, claims *response.JWTClaims, id string, dto *request.UpdateProjectDto) (*response.ProjectResponse, *fiber.Error) AddMember(ctx context.Context, claims *response.JWTClaims, projectID string, dto *request.AddProjectMemberDto) (*response.ProjectResponse, *fiber.Error) UpdateMemberRole(ctx context.Context, claims *response.JWTClaims, projectID string, memberUserID string, dto *request.UpdateProjectMemberDto) (*response.ProjectResponse, *fiber.Error) RemoveMember(ctx context.Context, claims *response.JWTClaims, projectID string, memberUserID string) *fiber.Error ChangeOwner(ctx context.Context, claims *response.JWTClaims, projectID string, newOwnerID string) (*response.ProjectResponse, *fiber.Error) } type projectService struct { projectRepo repositories.ProjectRepository } func NewProjectService(projectRepo repositories.ProjectRepository) ProjectService { return &projectService{ projectRepo: projectRepo, } } func (s *projectService) checkPermission(ctx context.Context, claims *response.JWTClaims, projectUUID pgtype.UUID) *fiber.Error { project, err := s.projectRepo.GetByID(ctx, projectUUID) if err != nil { return fiber.NewError(fiber.StatusNotFound, "Project not found") } if project.UserID == claims.ID { return nil } for _, r := range claims.Roles { if r == constants.RoleTypeAdmin || r == constants.RoleTypeMod { return nil } } callerUUID, _ := convert.StringToUUID(claims.ID) role, err := s.projectRepo.CheckPermission(ctx, sqlc.CheckProjectPermissionParams{ ProjectID: projectUUID, UserID: callerUUID, }) if err != nil || constants.ParseProjectMemberRole(role) != constants.ProjectMemberRoleOwner { return fiber.NewError(fiber.StatusForbidden, "Only project owner can perform this action") } return nil } func (s *projectService) GetProjectByID(ctx context.Context, id string) (*response.ProjectResponse, *fiber.Error) { projectUUID, err := convert.StringToUUID(id) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") } project, err := s.projectRepo.GetByID(ctx, projectUUID) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, "Project not found") } return project.ToResponse(), nil } func (s *projectService) GetProjectByUserID(ctx context.Context, userID string, dto *request.GetProjectsByUserDto) ([]*response.ProjectResponse, *fiber.Error) { userUUID, err := convert.StringToUUID(userID) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid user ID format") } limit := int32(20) if dto.Limit > 0 { limit = dto.Limit } arg := sqlc.GetProjectsByUserIdParams{ UserID: userUUID, Limit: limit, } if dto.CursorID != "" { if cursorID, err := convert.StringToUUID(dto.CursorID); err == nil { arg.CursorID = cursorID } } projects, err := s.projectRepo.GetByUserID(ctx, arg) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user projects") } return models.ProjectsEntityToResponse(projects), nil } func (s *projectService) fillSearchArgs(arg *sqlc.SearchProjectsParams, dto *request.SearchProjectDto) { if dto.Sort != "" { arg.Sort = dto.Sort } else { arg.Sort = "updated_at" } arg.Order = "desc" if dto.Order == "asc" { arg.Order = "asc" } if len(dto.Statuses) > 0 { for _, statusStr := range dto.Statuses { statusType := constants.ParseProjectStatusTypeText(statusStr) if statusType != constants.ProjectStatusTypeUnknow { arg.Statuses = append(arg.Statuses, statusType.Int16()) } } } if len(dto.UserIDs) > 0 { for _, id := range dto.UserIDs { if u, err := convert.StringToUUID(id); err == nil { arg.UserIds = append(arg.UserIds, u) } } } if dto.CreatedFrom != nil { arg.CreatedFrom = pgtype.Timestamptz{Time: *dto.CreatedFrom, Valid: true} } if dto.CreatedTo != nil { arg.CreatedTo = pgtype.Timestamptz{Time: *dto.CreatedTo, Valid: true} } if dto.Search != "" { arg.SearchText = pgtype.Text{String: dto.Search, Valid: true} } } func (s *projectService) SearchProject(ctx context.Context, dto *request.SearchProjectDto) (*response.PaginatedResponse, *fiber.Error) { if dto.Page < 1 { dto.Page = 1 } if dto.Limit == 0 { dto.Limit = 20 } offset := (dto.Page - 1) * dto.Limit arg := sqlc.SearchProjectsParams{ Limit: int32(dto.Limit), Offset: int32(offset), } s.fillSearchArgs(&arg, dto) var rows []*models.ProjectEntity var totalRecords int64 g, gCtx := errgroup.WithContext(ctx) g.Go(func() error { var err error rows, err = s.projectRepo.Search(gCtx, arg) return err }) g.Go(func() error { countArg := sqlc.CountProjectsParams{ Statuses: arg.Statuses, UserIds: arg.UserIds, SearchText: arg.SearchText, CreatedFrom: arg.CreatedFrom, CreatedTo: arg.CreatedTo, } var err error totalRecords, err = s.projectRepo.Count(gCtx, countArg) return err }) if err := g.Wait(); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to search projects") } projects := models.ProjectsEntityToResponse(rows) return response.BuildPaginatedResponse(projects, totalRecords, dto.Page, dto.Limit), nil } func (s *projectService) CreateProject(ctx context.Context, userID string, dto *request.CreateProjectDto) (*response.ProjectResponse, *fiber.Error) { userUUID, err := convert.StringToUUID(userID) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid user ID format") } arg := sqlc.CreateProjectParams{ Title: dto.Title, Description: convert.PtrToText(dto.Description), ProjectStatus: constants.ProjectStatusTypePrivate.Int16(), UserID: userUUID, } if dto.Status != nil { statusType := constants.ParseProjectStatusTypeText(*dto.Status) if statusType == constants.ProjectStatusTypeUnknow { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid status type") } arg.ProjectStatus = statusType.Int16() } project, err := s.projectRepo.Create(ctx, arg) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create project") } return project.ToResponse(), nil } func (s *projectService) UpdateProject(ctx context.Context, claims *response.JWTClaims, id string, dto *request.UpdateProjectDto) (*response.ProjectResponse, *fiber.Error) { projectUUID, err := convert.StringToUUID(id) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") } if fErr := s.checkPermission(ctx, claims, projectUUID); fErr != nil { return nil, fErr } arg := sqlc.UpdateProjectParams{ ID: projectUUID, Title: convert.PtrToText(dto.Title), Description: convert.PtrToText(dto.Description), } if dto.Status != nil { statusType := constants.ParseProjectStatusTypeText(*dto.Status) if statusType == constants.ProjectStatusTypeUnknow { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid status type") } arg.Status = pgtype.Int2{Int16: statusType.Int16(), Valid: true} } project, err := s.projectRepo.Update(ctx, arg) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update project") } return project.ToResponse(), nil } func (s *projectService) DeleteProject(ctx context.Context, claims *response.JWTClaims, id string) *fiber.Error { projectUUID, err := convert.StringToUUID(id) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") } if fErr := s.checkPermission(ctx, claims, projectUUID); fErr != nil { return fErr } err = s.projectRepo.Delete(ctx, projectUUID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete project") } return nil } func (s *projectService) AddMember(ctx context.Context, claims *response.JWTClaims, projectID string, dto *request.AddProjectMemberDto) (*response.ProjectResponse, *fiber.Error) { projectUUID, err := convert.StringToUUID(projectID) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") } if fErr := s.checkPermission(ctx, claims, projectUUID); fErr != nil { return nil, fErr } memberUUID, err := convert.StringToUUID(dto.UserID) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid member user ID format") } callerUUID, _ := convert.StringToUUID(claims.ID) if dto.UserID == claims.ID { return nil, fiber.NewError(fiber.StatusBadRequest, "Cannot add yourself as a member") } role := constants.ParseProjectMemberRoleText(dto.Role) err = s.projectRepo.AddMember(ctx, sqlc.AddProjectMemberParams{ ProjectID: projectUUID, UserID: memberUUID, Role: role.Int16(), InvitedBy: callerUUID, }) if err != nil { return nil, fiber.NewError(fiber.StatusConflict, "Member already exists or user not found") } project, err := s.projectRepo.GetByID(ctx, projectUUID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch updated project") } return project.ToResponse(), nil } func (s *projectService) UpdateMemberRole(ctx context.Context, claims *response.JWTClaims, projectID string, memberUserID string, dto *request.UpdateProjectMemberDto) (*response.ProjectResponse, *fiber.Error) { projectUUID, err := convert.StringToUUID(projectID) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") } if fErr := s.checkPermission(ctx, claims, projectUUID); fErr != nil { return nil, fErr } memberUUID, err := convert.StringToUUID(memberUserID) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid member user ID format") } role := constants.ParseProjectMemberRoleText(dto.Role) err = s.projectRepo.UpdateMemberRole(ctx, sqlc.UpdateProjectMemberRoleParams{ ProjectID: projectUUID, UserID: memberUUID, Role: role.Int16(), }) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, "Member not found") } project, err := s.projectRepo.GetByID(ctx, projectUUID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch updated project") } return project.ToResponse(), nil } func (s *projectService) RemoveMember(ctx context.Context, claims *response.JWTClaims, projectID string, memberUserID string) *fiber.Error { projectUUID, err := convert.StringToUUID(projectID) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") } if fErr := s.checkPermission(ctx, claims, projectUUID); fErr != nil { return fErr } memberUUID, err := convert.StringToUUID(memberUserID) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid member user ID format") } if claims.ID == memberUserID { return fiber.NewError(fiber.StatusBadRequest, "Cannot remove yourself from the project") } err = s.projectRepo.RemoveMember(ctx, sqlc.RemoveProjectMemberParams{ ProjectID: projectUUID, UserID: memberUUID, }) if err != nil { return fiber.NewError(fiber.StatusNotFound, "Member not found") } return nil } func (s *projectService) ChangeOwner(ctx context.Context, claims *response.JWTClaims, projectID string, newOwnerID string) (*response.ProjectResponse, *fiber.Error) { projectUUID, err := convert.StringToUUID(projectID) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") } if fErr := s.checkPermission(ctx, claims, projectUUID); fErr != nil { return nil, fErr } if claims.ID == newOwnerID { return nil, fiber.NewError(fiber.StatusBadRequest, "You are already the owner") } newOwnerUUID, err := convert.StringToUUID(newOwnerID) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid new owner ID format") } err = s.projectRepo.ChangeOwner(ctx, sqlc.ChangeProjectOwnerParams{ ID: projectUUID, UserID: newOwnerUUID, }) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to change owner") } project, err := s.projectRepo.GetByID(ctx, projectUUID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch updated project") } return project.ToResponse(), nil }