From cea524f20273bae5f21665015777e23e6854af6c Mon Sep 17 00:00:00 2001 From: 01JAMIL Date: Sun, 13 Sep 2026 14:37:26 +0100 Subject: [PATCH 1/4] feat: add API endpoint and service for retrieving project integration tasks --- cmd/api/routes.go | 1 + docker-compose.yaml | 15 +++ internal/adapters/postgresql/sqlc/querier.go | 2 + internal/adapters/postgresql/sqlc/queries.sql | 25 +++++ .../adapters/postgresql/sqlc/queries.sql.go | 104 ++++++++++++++++++ internal/integrations/handlers.go | 22 ++++ internal/integrations/services.go | 59 ++++++++++ internal/integrations/types.go | 29 +++++ 8 files changed, 257 insertions(+) diff --git a/cmd/api/routes.go b/cmd/api/routes.go index f580a85..8775cc9 100644 --- a/cmd/api/routes.go +++ b/cmd/api/routes.go @@ -108,6 +108,7 @@ func (app *application) routes() http.Handler { authGroup.POST("/projects/:projectID/integrations/github", integrationsHandler.ConnectRepository) authGroup.GET("/projects/:projectID/integrations/github", integrationsHandler.GetProjectIntegration) authGroup.POST("/projects/:projectID/integrations/github/regenerate-secret", integrationsHandler.RegenerateSecret) + authGroup.GET("/projects/:projectID/integration-tasks", integrationsHandler.GetProjectIntegrationTasks) /* Tasks routes */ authGroup.POST("/projects/:projectID/tasks", tasksHandler.CreateTask) diff --git a/docker-compose.yaml b/docker-compose.yaml index 6849c9d..f040b52 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -30,6 +30,21 @@ services: interval: 10s timeout: 5s retries: 5 + app: + build: + context: . + dockerfile: Dockerfile + container_name: gin-api-app + restart: unless-stopped + env_file: + - .env + environment: + GOOSE_DBSTRING: host=postgres user=${DATABASE_USERNAME} password=${DATABASE_PASSWORD} dbname=${DATABASE_NAME} sslmode=disable + ports: + - "${PORT:-3700}:${PORT:-3700}" + depends_on: + postgres: + condition: service_healthy volumes: postgres_data: redis_data: \ No newline at end of file diff --git a/internal/adapters/postgresql/sqlc/querier.go b/internal/adapters/postgresql/sqlc/querier.go index d4cba2b..7c07a8f 100644 --- a/internal/adapters/postgresql/sqlc/querier.go +++ b/internal/adapters/postgresql/sqlc/querier.go @@ -29,8 +29,10 @@ type Querier interface { GetMemberFromWorkspace(ctx context.Context, arg GetMemberFromWorkspaceParams) (WorkspaceMember, error) GetMessagesBetweenUsers(ctx context.Context, arg GetMessagesBetweenUsersParams) ([]GetMessagesBetweenUsersRow, error) GetProjectById(ctx context.Context, id pgtype.UUID) (Project, error) + GetProjectIntegration(ctx context.Context, projectID pgtype.UUID) (ProjectIntegration, error) GetProjectIntegrationByProjectID(ctx context.Context, projectID pgtype.UUID) (ProjectIntegration, error) GetProjectIntegrationByRepository(ctx context.Context, arg GetProjectIntegrationByRepositoryParams) (ProjectIntegration, error) + GetProjectIntegrationTasks(ctx context.Context, arg GetProjectIntegrationTasksParams) ([]GetProjectIntegrationTasksRow, error) GetProjectTasks(ctx context.Context, arg GetProjectTasksParams) ([]GetProjectTasksRow, error) GetSubscriptionByStripeSubscription(ctx context.Context, stripeSubscriptionID string) (Subscription, error) GetTaskById(ctx context.Context, id pgtype.UUID) (Task, error) diff --git a/internal/adapters/postgresql/sqlc/queries.sql b/internal/adapters/postgresql/sqlc/queries.sql index 7ebe4b4..ef5cd0e 100644 --- a/internal/adapters/postgresql/sqlc/queries.sql +++ b/internal/adapters/postgresql/sqlc/queries.sql @@ -242,6 +242,11 @@ FROM subscriptions WHERE user_id = $1 AND status = 'ACTIVE' LIMIT 1; +-- name: GetProjectIntegration :one +SELECT * +FROM project_integrations +WHERE project_id = $1; + -- name: CreateIntegrationTask :one INSERT INTO integration_tasks (id, provider, @@ -296,6 +301,26 @@ SET status = $3 WHERE external_id = $1 AND project_id = $2 RETURNING *; +-- name: GetProjectIntegrationTasks :many +SELECT count(*) OVER () AS total_count, id, + provider, + resource_type, + external_id, + repository_name, + issue_number, + title, + description, + status, + assignee_id, + payload, + project_id, + created_at, + updated_at +FROM integration_tasks +WHERE project_id = $1 +ORDER BY created_at DESC LIMIT $2 +OFFSET $3; + -- name: GetUserKPIs :one WITH user_workspaces AS (SELECT id FROM workspaces diff --git a/internal/adapters/postgresql/sqlc/queries.sql.go b/internal/adapters/postgresql/sqlc/queries.sql.go index 15e6799..6d31697 100644 --- a/internal/adapters/postgresql/sqlc/queries.sql.go +++ b/internal/adapters/postgresql/sqlc/queries.sql.go @@ -557,6 +557,29 @@ func (q *Queries) GetProjectById(ctx context.Context, id pgtype.UUID) (Project, return i, err } +const getProjectIntegration = `-- name: GetProjectIntegration :one +SELECT id, project_id, provider, repository_owner, repository_name, webhook_secret, is_active, created_at, updated_at +FROM project_integrations +WHERE project_id = $1 +` + +func (q *Queries) GetProjectIntegration(ctx context.Context, projectID pgtype.UUID) (ProjectIntegration, error) { + row := q.db.QueryRow(ctx, getProjectIntegration, projectID) + var i ProjectIntegration + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Provider, + &i.RepositoryOwner, + &i.RepositoryName, + &i.WebhookSecret, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getProjectIntegrationByProjectID = `-- name: GetProjectIntegrationByProjectID :one SELECT id, project_id, provider, repository_owner, repository_name, webhook_secret, is_active, created_at, updated_at FROM project_integrations @@ -611,6 +634,87 @@ func (q *Queries) GetProjectIntegrationByRepository(ctx context.Context, arg Get return i, err } +const getProjectIntegrationTasks = `-- name: GetProjectIntegrationTasks :many +SELECT count(*) OVER () AS total_count, id, + provider, + resource_type, + external_id, + repository_name, + issue_number, + title, + description, + status, + assignee_id, + payload, + project_id, + created_at, + updated_at +FROM integration_tasks +WHERE project_id = $1 +ORDER BY created_at DESC LIMIT $2 +OFFSET $3 +` + +type GetProjectIntegrationTasksParams struct { + ProjectID pgtype.UUID `json:"project_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type GetProjectIntegrationTasksRow struct { + TotalCount int64 `json:"total_count"` + ID pgtype.UUID `json:"id"` + Provider string `json:"provider"` + ResourceType string `json:"resource_type"` + ExternalID string `json:"external_id"` + RepositoryName string `json:"repository_name"` + IssueNumber int32 `json:"issue_number"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Status string `json:"status"` + AssigneeID pgtype.UUID `json:"assignee_id"` + Payload []byte `json:"payload"` + ProjectID pgtype.UUID `json:"project_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetProjectIntegrationTasks(ctx context.Context, arg GetProjectIntegrationTasksParams) ([]GetProjectIntegrationTasksRow, error) { + rows, err := q.db.Query(ctx, getProjectIntegrationTasks, arg.ProjectID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetProjectIntegrationTasksRow + for rows.Next() { + var i GetProjectIntegrationTasksRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.Provider, + &i.ResourceType, + &i.ExternalID, + &i.RepositoryName, + &i.IssueNumber, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.Payload, + &i.ProjectID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getProjectTasks = `-- name: GetProjectTasks :many SELECT count(*) OVER () AS total_count, id, name, diff --git a/internal/integrations/handlers.go b/internal/integrations/handlers.go index 6202fc7..cc98a20 100644 --- a/internal/integrations/handlers.go +++ b/internal/integrations/handlers.go @@ -59,6 +59,28 @@ func (h *handler) GetProjectIntegration(c *gin.Context) { }) } +func (h *handler) GetProjectIntegrationTasks(c *gin.Context) { + projectID := c.Param("projectID") + + page, err := strconv.Atoi(c.DefaultQuery("page", "1")) + if err != nil || page < 1 { + page = 1 + } + + pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + if err != nil || pageSize < 1 { + pageSize = 10 + } + + response, err := h.service.GetProjectIntegrationTasks(c, projectID, page, pageSize) + if err != nil { + codeerror.HandleError(c, err) + return + } + + c.JSON(http.StatusOK, response) +} + func (h *handler) RegenerateSecret(c *gin.Context) { projectID := c.Param("projectID") loggedUser := c.MustGet("user").(auth.UserResponse) diff --git a/internal/integrations/services.go b/internal/integrations/services.go index 50447d6..8dee4dc 100644 --- a/internal/integrations/services.go +++ b/internal/integrations/services.go @@ -25,6 +25,7 @@ type Service interface { ConnectRepository(ctx context.Context, projectID, loggedUserID string, payload connectRepositoryPayload) (connectRepositoryResponse, error) GetProjectIntegration(ctx context.Context, projectID string) (projectIntegrationResponse, error) RegenerateSecret(ctx context.Context, projectID, loggedUserID string) (regenerateSecretResponse, error) + GetProjectIntegrationTasks(ctx context.Context, projectID string, page, pageSize int) (getProjectIntegrationTasksResponse, error) CreateIntegrationTask(ctx context.Context, body []byte, signature string, payload createIntegrationTaskParams) (repo.IntegrationTask, error) UpdateIntegrationTaskStatus(ctx context.Context, body []byte, signature string, payload updateIntegrationTaskStatusParams) (repo.IntegrationTask, error) } @@ -130,6 +131,64 @@ func (s *svc) GetProjectIntegration(ctx context.Context, projectID string) (proj }, nil } +func (s *svc) GetProjectIntegrationTasks(ctx context.Context, projectID string, page, pageSize int) (getProjectIntegrationTasksResponse, error) { + projectUUID, err := uuid.Parse(projectID) + if err != nil { + return getProjectIntegrationTasksResponse{}, codeerror.New(codeerror.InvalidUUID, "Invalid project ID") + } + + _, err = s.repo.GetProjectById(ctx, pgtype.UUID{Bytes: projectUUID, Valid: true}) + if err != nil { + return getProjectIntegrationTasksResponse{}, codeerror.New(codeerror.ProjectNotFound, "Project not found") + } + + offset := (page - 1) * pageSize + rows, err := s.repo.GetProjectIntegrationTasks(ctx, repo.GetProjectIntegrationTasksParams{ + ProjectID: pgtype.UUID{Bytes: projectUUID, Valid: true}, + Limit: int32(pageSize), + Offset: int32(offset), + }) + if err != nil { + return getProjectIntegrationTasksResponse{}, codeerror.Wrap(codeerror.StatusInternalServerError, "Failed to fetch integration tasks", err) + } + + integrationTasks := make([]integrationTaskResponse, 0, len(rows)) + + var total int64 + if len(rows) > 0 { + total = rows[0].TotalCount + } + + for _, row := range rows { + integrationTasks = append(integrationTasks, integrationTaskResponse{ + ID: row.ID.String(), + Provider: row.Provider, + ResourceType: row.ResourceType, + ExternalID: row.ExternalID, + RepositoryName: row.RepositoryName, + IssueNumber: row.IssueNumber, + Title: row.Title, + Description: row.Description, + Status: row.Status, + AssigneeID: row.AssigneeID, + Payload: row.Payload, + ProjectID: row.ProjectID.String(), + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }) + } + + return getProjectIntegrationTasksResponse{ + IntegrationTasks: integrationTasks, + Pagination: integrationPaginationResponse{ + Page: page, + PageSize: pageSize, + Total: total, + TotalPages: (int(total) + pageSize - 1) / pageSize, + }, + }, nil +} + func (s *svc) RegenerateSecret(ctx context.Context, projectID, loggedUserID string) (regenerateSecretResponse, error) { projectUUID, err := uuid.Parse(projectID) if err != nil { diff --git a/internal/integrations/types.go b/internal/integrations/types.go index 3ebbb62..8200128 100644 --- a/internal/integrations/types.go +++ b/internal/integrations/types.go @@ -52,6 +52,35 @@ type regenerateSecretResponse struct { WebhookSecret string `json:"webhookSecret"` } +type integrationTaskResponse struct { + ID string `json:"id"` + Provider string `json:"provider"` + ResourceType string `json:"resourceType"` + ExternalID string `json:"externalId"` + RepositoryName string `json:"repositoryName"` + IssueNumber int32 `json:"issueNumber"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Status string `json:"status"` + AssigneeID pgtype.UUID `json:"assigneeId"` + Payload []byte `json:"payload"` + ProjectID string `json:"projectId"` + CreatedAt pgtype.Timestamptz `json:"createdAt"` + UpdatedAt pgtype.Timestamptz `json:"updatedAt"` +} + +type integrationPaginationResponse struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + TotalPages int `json:"totalPages"` +} + +type getProjectIntegrationTasksResponse struct { + IntegrationTasks []integrationTaskResponse `json:"integrationTasks"` + Pagination integrationPaginationResponse `json:"pagination"` +} + type gitHubIssueWebhookPayload struct { Action string `json:"action"` From 544c721b2464eb73a94ec9a245ecc678e7f3ce79 Mon Sep 17 00:00:00 2001 From: 01JAMIL Date: Sun, 13 Sep 2026 14:43:55 +0100 Subject: [PATCH 2/4] feat: remove Payload field from integrationTaskResponse and adjust related code --- internal/integrations/services.go | 8 ++++---- internal/integrations/types.go | 28 ++++++++++++++-------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/integrations/services.go b/internal/integrations/services.go index 8dee4dc..a7b7a05 100644 --- a/internal/integrations/services.go +++ b/internal/integrations/services.go @@ -171,10 +171,10 @@ func (s *svc) GetProjectIntegrationTasks(ctx context.Context, projectID string, Description: row.Description, Status: row.Status, AssigneeID: row.AssigneeID, - Payload: row.Payload, - ProjectID: row.ProjectID.String(), - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + // Payload: row.Payload, + ProjectID: row.ProjectID.String(), + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, }) } diff --git a/internal/integrations/types.go b/internal/integrations/types.go index 8200128..edf4517 100644 --- a/internal/integrations/types.go +++ b/internal/integrations/types.go @@ -53,20 +53,20 @@ type regenerateSecretResponse struct { } type integrationTaskResponse struct { - ID string `json:"id"` - Provider string `json:"provider"` - ResourceType string `json:"resourceType"` - ExternalID string `json:"externalId"` - RepositoryName string `json:"repositoryName"` - IssueNumber int32 `json:"issueNumber"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Status string `json:"status"` - AssigneeID pgtype.UUID `json:"assigneeId"` - Payload []byte `json:"payload"` - ProjectID string `json:"projectId"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - UpdatedAt pgtype.Timestamptz `json:"updatedAt"` + ID string `json:"id"` + Provider string `json:"provider"` + ResourceType string `json:"resourceType"` + ExternalID string `json:"externalId"` + RepositoryName string `json:"repositoryName"` + IssueNumber int32 `json:"issueNumber"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Status string `json:"status"` + AssigneeID pgtype.UUID `json:"assigneeId"` + // Payload []byte `json:"payload"` + ProjectID string `json:"projectId"` + CreatedAt pgtype.Timestamptz `json:"createdAt"` + UpdatedAt pgtype.Timestamptz `json:"updatedAt"` } type integrationPaginationResponse struct { From c63838cc80655881327fff0b0a26d892971b5b27 Mon Sep 17 00:00:00 2001 From: 01JAMIL Date: Sun, 13 Sep 2026 21:04:50 +0100 Subject: [PATCH 3/4] feat: implement GetUsers API endpoint and related service for user retrieval --- cmd/api/routes.go | 7 + internal/adapters/postgresql/sqlc/querier.go | 1 + internal/adapters/postgresql/sqlc/queries.sql | 16 +++ .../adapters/postgresql/sqlc/queries.sql.go | 67 ++++++++++ internal/users/handlers.go | 46 +++++++ internal/users/service_test.go | 122 ++++++++++++++++++ internal/users/services.go | 77 +++++++++++ internal/users/types.go | 15 +++ 8 files changed, 351 insertions(+) create mode 100644 internal/users/handlers.go create mode 100644 internal/users/service_test.go create mode 100644 internal/users/services.go create mode 100644 internal/users/types.go diff --git a/cmd/api/routes.go b/cmd/api/routes.go index 8775cc9..0362188 100644 --- a/cmd/api/routes.go +++ b/cmd/api/routes.go @@ -13,6 +13,7 @@ import ( "gin-api-1/internal/projects" "gin-api-1/internal/subscriptions" "gin-api-1/internal/tasks" + "gin-api-1/internal/users" "gin-api-1/internal/websocket" "gin-api-1/internal/workspace" "gin-api-1/internal/workspacemembers" @@ -55,6 +56,9 @@ func (app *application) routes() http.Handler { dashboardService := dashboard.NewDashboardService(repo.New(app.db)) dashboardHandler := dashboard.NewDashboardHandler(dashboardService) + usersService := users.NewUsersService(repo.New(app.db), app.db) + usersHandler := users.NewUsersHandler(usersService) + /* Public routes */ v1 := r.Group("/api/v1") { @@ -78,6 +82,9 @@ func (app *application) routes() http.Handler { { authGroup.GET("/auth/me", authHandler.GetMe) + /* Users routes */ + authGroup.GET("/users", usersHandler.GetUsers) + /* Dashboard routes */ authGroup.GET("/dashboard/kpis", dashboardHandler.GetKPIs) diff --git a/internal/adapters/postgresql/sqlc/querier.go b/internal/adapters/postgresql/sqlc/querier.go index 7c07a8f..ed665f8 100644 --- a/internal/adapters/postgresql/sqlc/querier.go +++ b/internal/adapters/postgresql/sqlc/querier.go @@ -44,6 +44,7 @@ type Querier interface { GetUserKPIs(ctx context.Context, userID pgtype.UUID) (GetUserKPIsRow, error) GetUserWorkspaceByID(ctx context.Context, arg GetUserWorkspaceByIDParams) (Workspace, error) GetUserWorkspaces(ctx context.Context, arg GetUserWorkspacesParams) ([]GetUserWorkspacesRow, error) + GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) GetWorkspaceByID(ctx context.Context, id pgtype.UUID) (Workspace, error) GetWorkspaceMembers(ctx context.Context, arg GetWorkspaceMembersParams) ([]GetWorkspaceMembersRow, error) GetWorkspaceProjects(ctx context.Context, arg GetWorkspaceProjectsParams) ([]GetWorkspaceProjectsRow, error) diff --git a/internal/adapters/postgresql/sqlc/queries.sql b/internal/adapters/postgresql/sqlc/queries.sql index ef5cd0e..5cd7ae8 100644 --- a/internal/adapters/postgresql/sqlc/queries.sql +++ b/internal/adapters/postgresql/sqlc/queries.sql @@ -321,6 +321,22 @@ WHERE project_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3; +-- name: GetUsers :many +SELECT count(*) OVER () AS total_count, + id, + first_name, + last_name, + email, + created_at, + updated_at +FROM users +WHERE id <> sqlc.arg(excluded_user_id) + AND (first_name ILIKE '%' || sqlc.arg(search) || '%' + OR last_name ILIKE '%' || sqlc.arg(search) || '%' + OR email ILIKE '%' || sqlc.arg(search) || '%') +ORDER BY created_at DESC +LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset); + -- name: GetUserKPIs :one WITH user_workspaces AS (SELECT id FROM workspaces diff --git a/internal/adapters/postgresql/sqlc/queries.sql.go b/internal/adapters/postgresql/sqlc/queries.sql.go index 6d31697..55cd69c 100644 --- a/internal/adapters/postgresql/sqlc/queries.sql.go +++ b/internal/adapters/postgresql/sqlc/queries.sql.go @@ -1076,6 +1076,73 @@ func (q *Queries) GetUserWorkspaces(ctx context.Context, arg GetUserWorkspacesPa return items, nil } +const getUsers = `-- name: GetUsers :many +SELECT count(*) OVER () AS total_count, + id, + first_name, + last_name, + email, + created_at, + updated_at +FROM users +WHERE id <> $1 + AND (first_name ILIKE '%' || $2 || '%' + OR last_name ILIKE '%' || $2 || '%' + OR email ILIKE '%' || $2 || '%') +ORDER BY created_at DESC +LIMIT $4 OFFSET $3 +` + +type GetUsersParams struct { + ExcludedUserID pgtype.UUID `json:"excluded_user_id"` + Search pgtype.Text `json:"search"` + PageOffset int32 `json:"page_offset"` + PageLimit int32 `json:"page_limit"` +} + +type GetUsersRow struct { + TotalCount int64 `json:"total_count"` + ID pgtype.UUID `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) { + rows, err := q.db.Query(ctx, getUsers, + arg.ExcludedUserID, + arg.Search, + arg.PageOffset, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUsersRow + for rows.Next() { + var i GetUsersRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspaceByID = `-- name: GetWorkspaceByID :one SELECT id, workspace_name, description, user_id, created_at, updated_at FROM workspaces diff --git a/internal/users/handlers.go b/internal/users/handlers.go new file mode 100644 index 0000000..cac2a21 --- /dev/null +++ b/internal/users/handlers.go @@ -0,0 +1,46 @@ +package users + +import ( + "gin-api-1/internal/auth" + "gin-api-1/internal/codeerror" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +const DefaultPageSize = 10 + +type handler struct { + service Service +} + +func NewUsersHandler(service Service) *handler { + return &handler{ + service: service, + } +} + +func (h *handler) GetUsers(c *gin.Context) { + loggedUser := c.MustGet("user").(auth.UserResponse) + + page, err := strconv.Atoi(c.DefaultQuery("page", "1")) + if err != nil || page < 1 { + page = 1 + } + + pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", strconv.Itoa(DefaultPageSize))) + if err != nil || pageSize < 1 { + pageSize = DefaultPageSize + } + + search := c.Query("search") + + response, err := h.service.GetUsers(c, loggedUser.ID, search, page, pageSize) + if err != nil { + codeerror.HandleError(c, err) + return + } + + c.JSON(http.StatusOK, response) +} diff --git a/internal/users/service_test.go b/internal/users/service_test.go new file mode 100644 index 0000000..ba06aa0 --- /dev/null +++ b/internal/users/service_test.go @@ -0,0 +1,122 @@ +package users + +import ( + "context" + "errors" + repo "gin-api-1/internal/adapters/postgresql/sqlc" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type mockUsersRepository struct { + getUsersFunc func(ctx context.Context, arg repo.GetUsersParams) ([]repo.GetUsersRow, error) +} + +func (m *mockUsersRepository) GetUsers(ctx context.Context, arg repo.GetUsersParams) ([]repo.GetUsersRow, error) { + return m.getUsersFunc(ctx, arg) +} + +func TestGetUsers(t *testing.T) { + ctx := context.Background() + loggedInUserID := uuid.New() + otherUserID := uuid.New() + + otherUser := repo.GetUsersRow{ + TotalCount: 1, + ID: pgtype.UUID{Bytes: otherUserID, Valid: true}, + FirstName: "Jane", + LastName: "Doe", + Email: "jane@example.com", + CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + } + + tests := []struct { + name string + id string + repo *mockUsersRepository + wantErr bool + wantLen int + }{ + { + name: "success", + id: loggedInUserID.String(), + wantLen: 1, + repo: &mockUsersRepository{ + getUsersFunc: func(ctx context.Context, arg repo.GetUsersParams) ([]repo.GetUsersRow, error) { + if arg.ExcludedUserID.Bytes != loggedInUserID { + t.Errorf("excluded user ID = %v, want %v", arg.ExcludedUserID.Bytes, loggedInUserID) + } + return []repo.GetUsersRow{otherUser}, nil + }, + }, + wantErr: false, + }, + { + name: "invalid uuid", + id: "not-a-uuid", + repo: &mockUsersRepository{ + getUsersFunc: func(ctx context.Context, arg repo.GetUsersParams) ([]repo.GetUsersRow, error) { + t.Fatal("repo should not be called for an invalid UUID") + return nil, nil + }, + }, + wantErr: true, + }, + { + name: "empty result", + id: loggedInUserID.String(), + repo: &mockUsersRepository{ + getUsersFunc: func(ctx context.Context, arg repo.GetUsersParams) ([]repo.GetUsersRow, error) { + return []repo.GetUsersRow{}, nil + }, + }, + wantErr: false, + wantLen: 0, + }, + { + name: "repo failure", + id: loggedInUserID.String(), + repo: &mockUsersRepository{ + getUsersFunc: func(ctx context.Context, arg repo.GetUsersParams) ([]repo.GetUsersRow, error) { + return nil, errors.New("repo failure") + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &svc{repo: tt.repo} + result, err := service.GetUsers(ctx, tt.id, "", 1, 10) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Users) != tt.wantLen { + t.Errorf("users count = %d, want %d", len(result.Users), tt.wantLen) + } + + if tt.wantLen > 0 { + if result.Users[0].ID != otherUserID.String() { + t.Errorf("user ID = %s, want %s", result.Users[0].ID, otherUserID) + } + if result.Pagination.Total != 1 || result.Pagination.TotalPages != 1 { + t.Errorf("pagination = %+v, want total=1 totalPages=1", result.Pagination) + } + } + }) + } +} diff --git a/internal/users/services.go b/internal/users/services.go new file mode 100644 index 0000000..e97ddd6 --- /dev/null +++ b/internal/users/services.go @@ -0,0 +1,77 @@ +package users + +import ( + "context" + repo "gin-api-1/internal/adapters/postgresql/sqlc" + "gin-api-1/internal/auth" + "gin-api-1/internal/codeerror" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Service interface { + GetUsers(ctx context.Context, excludedUserID, search string, page, pageSize int) (getUsersResponse, error) +} + +type usersRepository interface { + GetUsers(ctx context.Context, arg repo.GetUsersParams) ([]repo.GetUsersRow, error) +} + +type svc struct { + repo usersRepository + db *pgxpool.Pool +} + +func NewUsersService(repo usersRepository, db *pgxpool.Pool) Service { + return &svc{ + repo: repo, + db: db, + } +} + +func (s *svc) GetUsers(ctx context.Context, excludedUserID, search string, page, pageSize int) (getUsersResponse, error) { + excludedUUID, err := uuid.Parse(excludedUserID) + if err != nil { + return getUsersResponse{}, codeerror.New(codeerror.InvalidUUID, "User ID is not a valid UUID") + } + + rows, err := s.repo.GetUsers(ctx, repo.GetUsersParams{ + ExcludedUserID: pgtype.UUID{Bytes: excludedUUID, Valid: true}, + Search: pgtype.Text{String: search, Valid: true}, + PageOffset: int32((page - 1) * pageSize), + PageLimit: int32(pageSize), + }) + if err != nil { + return getUsersResponse{}, codeerror.Wrap(codeerror.StatusInternalServerError, "Failed to fetch users", err) + } + + users := make([]auth.UserResponse, 0, len(rows)) + + var total int64 + if len(rows) > 0 { + total = rows[0].TotalCount + } + + for _, row := range rows { + users = append(users, auth.UserResponse{ + ID: row.ID.String(), + FirstName: row.FirstName, + LastName: row.LastName, + Email: row.Email, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }) + } + + return getUsersResponse{ + Users: users, + Pagination: paginationResponse{ + Page: page, + PageSize: pageSize, + Total: total, + TotalPages: (int(total) + pageSize - 1) / pageSize, + }, + }, nil +} diff --git a/internal/users/types.go b/internal/users/types.go new file mode 100644 index 0000000..246939a --- /dev/null +++ b/internal/users/types.go @@ -0,0 +1,15 @@ +package users + +import "gin-api-1/internal/auth" + +type paginationResponse struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + TotalPages int `json:"totalPages"` +} + +type getUsersResponse struct { + Users []auth.UserResponse `json:"users"` + Pagination paginationResponse `json:"pagination"` +} From 6d72c5e462d228b22cbbf2aefc1e8b30509f12c1 Mon Sep 17 00:00:00 2001 From: 01JAMIL Date: Fri, 18 Sep 2026 18:00:40 +0100 Subject: [PATCH 4/4] feat: add GetAccessibleWorkspaceByID method and update workspace access checks for users --- internal/adapters/postgresql/sqlc/querier.go | 1 + internal/adapters/postgresql/sqlc/queries.sql | 44 ++- .../adapters/postgresql/sqlc/queries.sql.go | 64 +++- internal/cache/middleware.go | 47 ++- internal/integrations/handlers.go | 6 +- internal/integrations/services.go | 45 ++- internal/projects/handlers.go | 6 +- internal/projects/service_test.go | 315 ++++++++++++++++ internal/projects/services.go | 57 ++- internal/tasks/handlers.go | 7 +- internal/tasks/service_test.go | 351 ++++++++++++++++++ internal/tasks/services.go | 70 +++- internal/workspace/service_test.go | 270 ++++++++++++++ internal/workspace/services.go | 25 +- internal/workspacemembers/handlers.go | 4 +- internal/workspacemembers/service_test.go | 165 ++++++++ internal/workspacemembers/services.go | 29 +- 17 files changed, 1438 insertions(+), 68 deletions(-) create mode 100644 internal/projects/service_test.go create mode 100644 internal/tasks/service_test.go create mode 100644 internal/workspace/service_test.go create mode 100644 internal/workspacemembers/service_test.go diff --git a/internal/adapters/postgresql/sqlc/querier.go b/internal/adapters/postgresql/sqlc/querier.go index ed665f8..f7b3ce9 100644 --- a/internal/adapters/postgresql/sqlc/querier.go +++ b/internal/adapters/postgresql/sqlc/querier.go @@ -26,6 +26,7 @@ type Querier interface { DeleteProject(ctx context.Context, id pgtype.UUID) error DeleteTask(ctx context.Context, id pgtype.UUID) error DeleteWorkspace(ctx context.Context, arg DeleteWorkspaceParams) error + GetAccessibleWorkspaceByID(ctx context.Context, arg GetAccessibleWorkspaceByIDParams) (Workspace, error) GetMemberFromWorkspace(ctx context.Context, arg GetMemberFromWorkspaceParams) (WorkspaceMember, error) GetMessagesBetweenUsers(ctx context.Context, arg GetMessagesBetweenUsersParams) ([]GetMessagesBetweenUsersRow, error) GetProjectById(ctx context.Context, id pgtype.UUID) (Project, error) diff --git a/internal/adapters/postgresql/sqlc/queries.sql b/internal/adapters/postgresql/sqlc/queries.sql index 5cd7ae8..fddb84e 100644 --- a/internal/adapters/postgresql/sqlc/queries.sql +++ b/internal/adapters/postgresql/sqlc/queries.sql @@ -18,16 +18,30 @@ FROM workspaces WHERE user_id = $1 AND id = $2; +-- name: GetAccessibleWorkspaceByID :one +SELECT w.id, w.workspace_name, w.description, w.user_id, w.created_at, w.updated_at +FROM workspaces w +WHERE w.id = $1 + AND (w.user_id = $2 + OR EXISTS (SELECT 1 + FROM workspace_members wm + WHERE wm.workspace_id = w.id + AND wm.user_id = $2)); + -- name: GetUserWorkspaces :many -SELECT count(*) OVER () AS total_count, id, - workspace_name, - description, - user_id, - created_at, - updated_at -FROM workspaces -WHERE user_id = $1 -ORDER BY created_at DESC LIMIT $2 +SELECT count(*) OVER () AS total_count, w.id, + w.workspace_name, + w.description, + w.user_id, + w.created_at, + w.updated_at +FROM workspaces w +WHERE w.user_id = $1 + OR EXISTS (SELECT 1 + FROM workspace_members wm + WHERE wm.workspace_id = w.id + AND wm.user_id = $1) +ORDER BY w.created_at DESC LIMIT $2 OFFSET $3; -- name: CreateWorkspace :one @@ -338,9 +352,13 @@ ORDER BY created_at DESC LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset); -- name: GetUserKPIs :one -WITH user_workspaces AS (SELECT id - FROM workspaces - WHERE user_id = $1) +WITH user_workspaces AS (SELECT w.id + FROM workspaces w + WHERE w.user_id = $1 + OR EXISTS (SELECT 1 + FROM workspace_members wm + WHERE wm.workspace_id = w.id + AND wm.user_id = $1)) SELECT (SELECT COUNT(*) FROM user_workspaces) AS total_workspaces, (SELECT COUNT(DISTINCT p.id) FROM projects p @@ -355,4 +373,4 @@ SELECT (SELECT COUNT(*) FROM user_workspaces) AS total_workspaces, (SELECT COUNT(DISTINCT wm.user_id) FROM workspace_members wm JOIN workspaces w ON w.id = wm.workspace_id - WHERE w.user_id = $1) AS team_members; + WHERE w.id IN (SELECT id FROM user_workspaces)) AS team_members; diff --git a/internal/adapters/postgresql/sqlc/queries.sql.go b/internal/adapters/postgresql/sqlc/queries.sql.go index 55cd69c..4715508 100644 --- a/internal/adapters/postgresql/sqlc/queries.sql.go +++ b/internal/adapters/postgresql/sqlc/queries.sql.go @@ -451,6 +451,36 @@ func (q *Queries) DeleteWorkspace(ctx context.Context, arg DeleteWorkspaceParams return err } +const getAccessibleWorkspaceByID = `-- name: GetAccessibleWorkspaceByID :one +SELECT w.id, w.workspace_name, w.description, w.user_id, w.created_at, w.updated_at +FROM workspaces w +WHERE w.id = $1 + AND (w.user_id = $2 + OR EXISTS (SELECT 1 + FROM workspace_members wm + WHERE wm.workspace_id = w.id + AND wm.user_id = $2)) +` + +type GetAccessibleWorkspaceByIDParams struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) GetAccessibleWorkspaceByID(ctx context.Context, arg GetAccessibleWorkspaceByIDParams) (Workspace, error) { + row := q.db.QueryRow(ctx, getAccessibleWorkspaceByID, arg.ID, arg.UserID) + var i Workspace + err := row.Scan( + &i.ID, + &i.WorkspaceName, + &i.Description, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getMemberFromWorkspace = `-- name: GetMemberFromWorkspace :one SELECT id, user_id, workspace_id, user_role, created_at FROM workspace_members @@ -954,9 +984,13 @@ func (q *Queries) GetUserByStripeCustomerID(ctx context.Context, stripeCustomerI } const getUserKPIs = `-- name: GetUserKPIs :one -WITH user_workspaces AS (SELECT id - FROM workspaces - WHERE user_id = $1) +WITH user_workspaces AS (SELECT w.id + FROM workspaces w + WHERE w.user_id = $1 + OR EXISTS (SELECT 1 + FROM workspace_members wm + WHERE wm.workspace_id = w.id + AND wm.user_id = $1)) SELECT (SELECT COUNT(*) FROM user_workspaces) AS total_workspaces, (SELECT COUNT(DISTINCT p.id) FROM projects p @@ -971,7 +1005,7 @@ SELECT (SELECT COUNT(*) FROM user_workspaces) AS total_workspaces, (SELECT COUNT(DISTINCT wm.user_id) FROM workspace_members wm JOIN workspaces w ON w.id = wm.workspace_id - WHERE w.user_id = $1) AS team_members + WHERE w.id IN (SELECT id FROM user_workspaces)) AS team_members ` type GetUserKPIsRow struct { @@ -1020,15 +1054,19 @@ func (q *Queries) GetUserWorkspaceByID(ctx context.Context, arg GetUserWorkspace } const getUserWorkspaces = `-- name: GetUserWorkspaces :many -SELECT count(*) OVER () AS total_count, id, - workspace_name, - description, - user_id, - created_at, - updated_at -FROM workspaces -WHERE user_id = $1 -ORDER BY created_at DESC LIMIT $2 +SELECT count(*) OVER () AS total_count, w.id, + w.workspace_name, + w.description, + w.user_id, + w.created_at, + w.updated_at +FROM workspaces w +WHERE w.user_id = $1 + OR EXISTS (SELECT 1 + FROM workspace_members wm + WHERE wm.workspace_id = w.id + AND wm.user_id = $1) +ORDER BY w.created_at DESC LIMIT $2 OFFSET $3 ` diff --git a/internal/cache/middleware.go b/internal/cache/middleware.go index 7c5e9dd..3224a30 100644 --- a/internal/cache/middleware.go +++ b/internal/cache/middleware.go @@ -2,7 +2,9 @@ package cache import ( "bytes" + "encoding/json" "fmt" + "io" "net/http" "gin-api-1/internal/auth" @@ -50,8 +52,9 @@ func (r *responseRecorder) WriteHeader(code int) { func ResponseCacheMiddleware(cache *RedisCache) gin.HandlerFunc { return func(c *gin.Context) { if c.Request.Method != http.MethodGet { + memberUserID := captureMemberUserID(c) c.Next() - invalidateAfterWrite(c, cache) + invalidateAfterWrite(c, cache, memberUserID) return } @@ -92,13 +95,13 @@ func handleGet(c *gin.Context, cache *RedisCache) { // invalidateAfterWrite bumps the version of every namespace affected by a // successful write so previously cached responses become stale. -func invalidateAfterWrite(c *gin.Context, cache *RedisCache) { +func invalidateAfterWrite(c *gin.Context, cache *RedisCache, memberUserID string) { status := c.Writer.Status() if status < http.StatusOK || status >= http.StatusMultipleChoices { return } - for _, namespace := range writeNamespaces(c) { + for _, namespace := range writeNamespaces(c, memberUserID) { cache.Increment(c, versionKey(namespace)) } } @@ -132,7 +135,7 @@ func readNamespace(c *gin.Context) (string, bool) { // writeNamespaces returns the cache namespaces to invalidate for a successful // write on the given route. -func writeNamespaces(c *gin.Context) []string { +func writeNamespaces(c *gin.Context, memberUserID string) []string { switch c.FullPath() { case "/api/v1/workspaces": return []string{"workspaces:" + currentUserID(c)} @@ -141,9 +144,17 @@ func writeNamespaces(c *gin.Context) []string { case "/api/v1/checkout": return []string{"user-subscription:" + currentUserID(c)} case "/api/v1/workspaces/:id/members": - return []string{"workspace-members:" + c.Param("id")} + namespaces := []string{"workspace-members:" + c.Param("id")} + if memberUserID != "" { + namespaces = append(namespaces, "workspaces:"+memberUserID) + } + return namespaces case "/api/v1/workspaces/:id/members/:userId": - return []string{"workspace-members:" + c.Param("id")} + namespaces := []string{"workspace-members:" + c.Param("id")} + if removedUserID := c.Param("userId"); removedUserID != "" { + namespaces = append(namespaces, "workspaces:"+removedUserID, "workspace:"+c.Param("id")) + } + return namespaces case "/api/v1/workspaces/:id/projects": return []string{"workspace-projects:" + c.Param("id")} case "/api/v1/projects/:projectID": @@ -175,6 +186,30 @@ func currentUserID(c *gin.Context) string { return userResponse.ID } +// captureMemberUserID extracts the invited user's ID from a member-add request +// body before the handler consumes it, so invalidating their cached workspace +// list does not depend on the handler. +func captureMemberUserID(c *gin.Context) string { + if c.Request.Method != http.MethodPost || c.FullPath() != "/api/v1/workspaces/:id/members" { + return "" + } + + body, err := io.ReadAll(c.Request.Body) + if err != nil { + return "" + } + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + + var payload struct { + UserID string `json:"userId"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + + return payload.UserID +} + func versionKey(namespace string) string { return versionKeyPrefix + namespace } diff --git a/internal/integrations/handlers.go b/internal/integrations/handlers.go index cc98a20..c6c1239 100644 --- a/internal/integrations/handlers.go +++ b/internal/integrations/handlers.go @@ -47,8 +47,9 @@ func (h *handler) ConnectRepository(c *gin.Context) { func (h *handler) GetProjectIntegration(c *gin.Context) { projectID := c.Param("projectID") + loggedUser := c.MustGet("user").(auth.UserResponse) - integration, err := h.service.GetProjectIntegration(c, projectID) + integration, err := h.service.GetProjectIntegration(c, projectID, loggedUser.ID) if err != nil { codeerror.HandleError(c, err) return @@ -61,6 +62,7 @@ func (h *handler) GetProjectIntegration(c *gin.Context) { func (h *handler) GetProjectIntegrationTasks(c *gin.Context) { projectID := c.Param("projectID") + loggedUser := c.MustGet("user").(auth.UserResponse) page, err := strconv.Atoi(c.DefaultQuery("page", "1")) if err != nil || page < 1 { @@ -72,7 +74,7 @@ func (h *handler) GetProjectIntegrationTasks(c *gin.Context) { pageSize = 10 } - response, err := h.service.GetProjectIntegrationTasks(c, projectID, page, pageSize) + response, err := h.service.GetProjectIntegrationTasks(c, projectID, loggedUser.ID, page, pageSize) if err != nil { codeerror.HandleError(c, err) return diff --git a/internal/integrations/services.go b/internal/integrations/services.go index a7b7a05..6bad930 100644 --- a/internal/integrations/services.go +++ b/internal/integrations/services.go @@ -23,9 +23,9 @@ var repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+$`) type Service interface { ConnectRepository(ctx context.Context, projectID, loggedUserID string, payload connectRepositoryPayload) (connectRepositoryResponse, error) - GetProjectIntegration(ctx context.Context, projectID string) (projectIntegrationResponse, error) + GetProjectIntegration(ctx context.Context, projectID, loggedUserID string) (projectIntegrationResponse, error) RegenerateSecret(ctx context.Context, projectID, loggedUserID string) (regenerateSecretResponse, error) - GetProjectIntegrationTasks(ctx context.Context, projectID string, page, pageSize int) (getProjectIntegrationTasksResponse, error) + GetProjectIntegrationTasks(ctx context.Context, projectID, loggedUserID string, page, pageSize int) (getProjectIntegrationTasksResponse, error) CreateIntegrationTask(ctx context.Context, body []byte, signature string, payload createIntegrationTaskParams) (repo.IntegrationTask, error) UpdateIntegrationTaskStatus(ctx context.Context, body []byte, signature string, payload updateIntegrationTaskStatusParams) (repo.IntegrationTask, error) } @@ -108,12 +108,21 @@ func (s *svc) ConnectRepository(ctx context.Context, projectID, loggedUserID str }, nil } -func (s *svc) GetProjectIntegration(ctx context.Context, projectID string) (projectIntegrationResponse, error) { +func (s *svc) GetProjectIntegration(ctx context.Context, projectID, loggedUserID string) (projectIntegrationResponse, error) { projectUUID, err := uuid.Parse(projectID) if err != nil { return projectIntegrationResponse{}, codeerror.New(codeerror.ProjectNotFound, "Project not found") } + project, err := s.repo.GetProjectById(ctx, pgtype.UUID{Bytes: projectUUID, Valid: true}) + if err != nil { + return projectIntegrationResponse{}, codeerror.New(codeerror.ProjectNotFound, "Project not found") + } + + if err := s.ensureWorkspaceAccess(ctx, project.WorkspaceID.String(), loggedUserID); err != nil { + return projectIntegrationResponse{}, err + } + integration, err := s.repo.GetProjectIntegrationByProjectID(ctx, pgtype.UUID{Bytes: projectUUID, Valid: true}) if err != nil { return projectIntegrationResponse{}, codeerror.New(codeerror.ProjectNotFound, "No integration found for this project") @@ -131,17 +140,21 @@ func (s *svc) GetProjectIntegration(ctx context.Context, projectID string) (proj }, nil } -func (s *svc) GetProjectIntegrationTasks(ctx context.Context, projectID string, page, pageSize int) (getProjectIntegrationTasksResponse, error) { +func (s *svc) GetProjectIntegrationTasks(ctx context.Context, projectID string, loggedUserID string, page, pageSize int) (getProjectIntegrationTasksResponse, error) { projectUUID, err := uuid.Parse(projectID) if err != nil { return getProjectIntegrationTasksResponse{}, codeerror.New(codeerror.InvalidUUID, "Invalid project ID") } - _, err = s.repo.GetProjectById(ctx, pgtype.UUID{Bytes: projectUUID, Valid: true}) + project, err := s.repo.GetProjectById(ctx, pgtype.UUID{Bytes: projectUUID, Valid: true}) if err != nil { return getProjectIntegrationTasksResponse{}, codeerror.New(codeerror.ProjectNotFound, "Project not found") } + if err := s.ensureWorkspaceAccess(ctx, project.WorkspaceID.String(), loggedUserID); err != nil { + return getProjectIntegrationTasksResponse{}, err + } + offset := (page - 1) * pageSize rows, err := s.repo.GetProjectIntegrationTasks(ctx, repo.GetProjectIntegrationTasksParams{ ProjectID: pgtype.UUID{Bytes: projectUUID, Valid: true}, @@ -233,6 +246,28 @@ func (s *svc) RegenerateSecret(ctx context.Context, projectID, loggedUserID stri }, nil } +func (s *svc) ensureWorkspaceAccess(ctx context.Context, workspaceID, loggedUserID string) error { + workspaceUUID, err := uuid.Parse(workspaceID) + if err != nil { + return codeerror.New(codeerror.InvalidUUID, "Invalid workspace ID") + } + + userUUID, err := uuid.Parse(loggedUserID) + if err != nil { + return codeerror.New(codeerror.UserNotFound, "User not found") + } + + _, err = s.repo.GetAccessibleWorkspaceByID(ctx, repo.GetAccessibleWorkspaceByIDParams{ + ID: pgtype.UUID{Bytes: workspaceUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + }) + if err != nil { + return codeerror.New(codeerror.WorkspaceNotFound, "Workspace not found") + } + + return nil +} + func (s *svc) ensureAdminMember(ctx context.Context, workspaceID, loggedUserID string) error { workspaceUUID, err := uuid.Parse(workspaceID) if err != nil { diff --git a/internal/projects/handlers.go b/internal/projects/handlers.go index d0170b7..39cb568 100644 --- a/internal/projects/handlers.go +++ b/internal/projects/handlers.go @@ -44,6 +44,7 @@ func (h *handler) CreateProject(c *gin.Context) { func (h *handler) GetWorkspaceProjects(c *gin.Context) { workspaceID := c.Param("id") + loggedUser := c.MustGet("user").(auth.UserResponse) page, err := strconv.Atoi(c.DefaultQuery("page", "1")) if err != nil || page < 1 { @@ -55,7 +56,7 @@ func (h *handler) GetWorkspaceProjects(c *gin.Context) { pageSize = DefaultPageSize } - response, err := h.service.GetWorkspaceProjects(c, workspaceID, page, pageSize) + response, err := h.service.GetWorkspaceProjects(c, workspaceID, loggedUser.ID, page, pageSize) if err != nil { codeerror.HandleError(c, err) return @@ -66,8 +67,9 @@ func (h *handler) GetWorkspaceProjects(c *gin.Context) { func (h *handler) GetProjectByID(c *gin.Context) { projectID := c.Param("projectID") + loggedUser := c.MustGet("user").(auth.UserResponse) - project, err := h.service.GetProjectByID(c, projectID) + project, err := h.service.GetProjectByID(c, projectID, loggedUser.ID) if err != nil { codeerror.HandleError(c, err) return diff --git a/internal/projects/service_test.go b/internal/projects/service_test.go new file mode 100644 index 0000000..8d8f045 --- /dev/null +++ b/internal/projects/service_test.go @@ -0,0 +1,315 @@ +package projects + +import ( + "context" + "errors" + repo "gin-api-1/internal/adapters/postgresql/sqlc" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type mockProjectRepository struct { + getAccessibleWorkspaceByIDFunc func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) + getProjectByIdFunc func(ctx context.Context, id pgtype.UUID) (repo.Project, error) + getWorkspaceProjectsFunc func(ctx context.Context, arg repo.GetWorkspaceProjectsParams) ([]repo.GetWorkspaceProjectsRow, error) +} + +func (m *mockProjectRepository) CountWorkspaceProjects(ctx context.Context, workspaceID pgtype.UUID) (int64, error) { + return 0, nil +} + +func (m *mockProjectRepository) CreateProject(ctx context.Context, arg repo.CreateProjectParams) (repo.Project, error) { + return repo.Project{}, nil +} + +func (m *mockProjectRepository) DeleteProject(ctx context.Context, id pgtype.UUID) error { + return nil +} + +func (m *mockProjectRepository) GetAccessibleWorkspaceByID(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + if m.getAccessibleWorkspaceByIDFunc == nil { + return repo.Workspace{}, nil + } + return m.getAccessibleWorkspaceByIDFunc(ctx, arg) +} + +func (m *mockProjectRepository) GetMemberFromWorkspace(ctx context.Context, arg repo.GetMemberFromWorkspaceParams) (repo.WorkspaceMember, error) { + return repo.WorkspaceMember{}, nil +} + +func (m *mockProjectRepository) GetProjectById(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + if m.getProjectByIdFunc == nil { + return repo.Project{}, nil + } + return m.getProjectByIdFunc(ctx, id) +} + +func (m *mockProjectRepository) GetUserActiveSubscription(ctx context.Context, userID pgtype.UUID) (repo.Subscription, error) { + return repo.Subscription{}, pgx.ErrNoRows +} + +func (m *mockProjectRepository) GetWorkspaceByID(ctx context.Context, id pgtype.UUID) (repo.Workspace, error) { + return repo.Workspace{}, nil +} + +func (m *mockProjectRepository) GetWorkspaceProjects(ctx context.Context, arg repo.GetWorkspaceProjectsParams) ([]repo.GetWorkspaceProjectsRow, error) { + if m.getWorkspaceProjectsFunc == nil { + return nil, nil + } + return m.getWorkspaceProjectsFunc(ctx, arg) +} + +func (m *mockProjectRepository) UpdateProject(ctx context.Context, arg repo.UpdateProjectParams) (repo.Project, error) { + return repo.Project{}, nil +} + +func assertProjectServiceErrorCode(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func newTestProject(id, workspaceID uuid.UUID) repo.Project { + return repo.Project{ + ID: pgtype.UUID{Bytes: id, Valid: true}, + Name: "Test Project", + Description: "A project", + WorkspaceID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + } +} + +func TestGetWorkspaceProjects(t *testing.T) { + ctx := context.Background() + ownerID := uuid.New() + memberID := uuid.New() + nonMemberID := uuid.New() + workspaceID := uuid.New() + projectID := uuid.New() + + projectRows := []repo.GetWorkspaceProjectsRow{ + { + TotalCount: 1, + ID: pgtype.UUID{Bytes: projectID, Valid: true}, + Name: "Test Project", + Description: "A project", + WorkspaceID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + }, + } + + workspace := repo.Workspace{ + ID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + UserID: pgtype.UUID{Bytes: ownerID, Valid: true}, + } + + tests := []struct { + name string + workspaceID string + loggedUser string + repo *mockProjectRepository + wantErr bool + wantLen int + }{ + { + name: "owner can list workspace projects", + workspaceID: workspaceID.String(), + loggedUser: ownerID.String(), + repo: &mockProjectRepository{ + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return workspace, nil + }, + getWorkspaceProjectsFunc: func(ctx context.Context, arg repo.GetWorkspaceProjectsParams) ([]repo.GetWorkspaceProjectsRow, error) { + if arg.WorkspaceID.Bytes != workspaceID { + t.Errorf("workspace ID = %v, want %v", arg.WorkspaceID.Bytes, workspaceID) + } + return projectRows, nil + }, + }, + wantErr: false, + wantLen: 1, + }, + { + name: "member can list workspace projects", + workspaceID: workspaceID.String(), + loggedUser: memberID.String(), + repo: &mockProjectRepository{ + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + if arg.UserID.Bytes != memberID { + t.Errorf("user ID = %v, want %v", arg.UserID.Bytes, memberID) + } + return workspace, nil + }, + getWorkspaceProjectsFunc: func(ctx context.Context, arg repo.GetWorkspaceProjectsParams) ([]repo.GetWorkspaceProjectsRow, error) { + return projectRows, nil + }, + }, + wantErr: false, + wantLen: 1, + }, + { + name: "neither owner nor member cannot list workspace projects", + workspaceID: workspaceID.String(), + loggedUser: nonMemberID.String(), + repo: &mockProjectRepository{ + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return repo.Workspace{}, pgx.ErrNoRows + }, + }, + wantErr: true, + }, + { + name: "invalid workspace id", + workspaceID: "not-a-uuid", + loggedUser: ownerID.String(), + repo: &mockProjectRepository{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &svc{repo: tt.repo} + result, err := service.GetWorkspaceProjects(ctx, tt.workspaceID, tt.loggedUser, 1, 10) + + if tt.wantErr { + assertProjectServiceErrorCode(t, err) + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Projects) != tt.wantLen { + t.Errorf("projects count = %d, want %d", len(result.Projects), tt.wantLen) + } + }) + } +} + +func TestGetProjectByID(t *testing.T) { + ctx := context.Background() + ownerID := uuid.New() + memberID := uuid.New() + nonMemberID := uuid.New() + workspaceID := uuid.New() + projectID := uuid.New() + project := newTestProject(projectID, workspaceID) + + workspace := repo.Workspace{ + ID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + UserID: pgtype.UUID{Bytes: ownerID, Valid: true}, + } + + tests := []struct { + name string + projectID string + loggedUser string + repo *mockProjectRepository + wantErr bool + }{ + { + name: "owner can fetch project", + projectID: projectID.String(), + loggedUser: ownerID.String(), + repo: &mockProjectRepository{ + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return project, nil + }, + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return workspace, nil + }, + }, + wantErr: false, + }, + { + name: "member can fetch project", + projectID: projectID.String(), + loggedUser: memberID.String(), + repo: &mockProjectRepository{ + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return project, nil + }, + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return workspace, nil + }, + }, + wantErr: false, + }, + { + name: "neither owner nor member cannot fetch project", + projectID: projectID.String(), + loggedUser: nonMemberID.String(), + repo: &mockProjectRepository{ + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return project, nil + }, + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return repo.Workspace{}, pgx.ErrNoRows + }, + }, + wantErr: true, + }, + { + name: "project not found", + projectID: projectID.String(), + loggedUser: ownerID.String(), + repo: &mockProjectRepository{ + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return repo.Project{}, pgx.ErrNoRows + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &svc{repo: tt.repo} + _, err := service.GetProjectByID(ctx, tt.projectID, tt.loggedUser) + + if tt.wantErr { + assertProjectServiceErrorCode(t, err) + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestGetWorkspaceProjectsRepositoryFailure(t *testing.T) { + ctx := context.Background() + ownerID := uuid.New() + workspaceID := uuid.New() + + workspace := repo.Workspace{ + ID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + UserID: pgtype.UUID{Bytes: ownerID, Valid: true}, + } + + mockRepo := &mockProjectRepository{ + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return workspace, nil + }, + getWorkspaceProjectsFunc: func(ctx context.Context, arg repo.GetWorkspaceProjectsParams) ([]repo.GetWorkspaceProjectsRow, error) { + return nil, errors.New("repo failure") + }, + } + + service := &svc{repo: mockRepo} + _, err := service.GetWorkspaceProjects(ctx, workspaceID.String(), ownerID.String(), 1, 10) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/projects/services.go b/internal/projects/services.go index 1674ab2..3ab7c3d 100644 --- a/internal/projects/services.go +++ b/internal/projects/services.go @@ -16,18 +16,32 @@ import ( type Service interface { WorkspaceExists(ctx context.Context, workspaceID string) error CreateProject(ctx context.Context, workspaceID string, loggedUserID string, payload createProjectPayload) (projectResponse, error) - GetWorkspaceProjects(ctx context.Context, workspaceID string, page, pageSize int) (getWorkspaceProjectsResponse, error) - GetProjectByID(ctx context.Context, projectID string) (projectResponse, error) + GetWorkspaceProjects(ctx context.Context, workspaceID string, loggedUserID string, page, pageSize int) (getWorkspaceProjectsResponse, error) + GetProjectByID(ctx context.Context, projectID string, loggedUserID string) (projectResponse, error) UpdateProject(ctx context.Context, projectID string, loggedUserID string, payload updateProjectPayload) (projectResponse, error) DeleteProject(ctx context.Context, projectID string, loggedUserID string) error } +// Interface for the database dependency. +type projectRepository interface { + CountWorkspaceProjects(ctx context.Context, workspaceID pgtype.UUID) (int64, error) + CreateProject(ctx context.Context, arg repo.CreateProjectParams) (repo.Project, error) + DeleteProject(ctx context.Context, id pgtype.UUID) error + GetAccessibleWorkspaceByID(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) + GetMemberFromWorkspace(ctx context.Context, arg repo.GetMemberFromWorkspaceParams) (repo.WorkspaceMember, error) + GetProjectById(ctx context.Context, id pgtype.UUID) (repo.Project, error) + GetUserActiveSubscription(ctx context.Context, userID pgtype.UUID) (repo.Subscription, error) + GetWorkspaceByID(ctx context.Context, id pgtype.UUID) (repo.Workspace, error) + GetWorkspaceProjects(ctx context.Context, arg repo.GetWorkspaceProjectsParams) ([]repo.GetWorkspaceProjectsRow, error) + UpdateProject(ctx context.Context, arg repo.UpdateProjectParams) (repo.Project, error) +} + type svc struct { - repo *repo.Queries + repo projectRepository db *pgxpool.Pool } -func NewProjectsService(repo *repo.Queries, db *pgxpool.Pool) Service { +func NewProjectsService(repo projectRepository, db *pgxpool.Pool) Service { return &svc{ repo: repo, db: db, @@ -76,15 +90,36 @@ func (s *svc) ensureAdminMember(ctx context.Context, workspaceID, loggedUserID, return nil } -func (s *svc) GetWorkspaceProjects(ctx context.Context, workspaceID string, page, pageSize int) (getWorkspaceProjectsResponse, error) { +func (s *svc) ensureWorkspaceAccess(ctx context.Context, workspaceID, loggedUserID string) error { workspaceUUID, err := uuid.Parse(workspaceID) if err != nil { - return getWorkspaceProjectsResponse{}, codeerror.New(codeerror.InvalidUUID, "Invalid workspace ID") + return codeerror.New(codeerror.InvalidUUID, "Invalid workspace ID") } - _, err = s.repo.GetWorkspaceByID(ctx, pgtype.UUID{Bytes: workspaceUUID, Valid: true}) + userUUID, err := uuid.Parse(loggedUserID) + if err != nil { + return codeerror.New(codeerror.UserNotFound, "User not found") + } + + _, err = s.repo.GetAccessibleWorkspaceByID(ctx, repo.GetAccessibleWorkspaceByIDParams{ + ID: pgtype.UUID{Bytes: workspaceUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + }) + if err != nil { + return codeerror.New(codeerror.WorkspaceNotFound, "Workspace not found") + } + + return nil +} + +func (s *svc) GetWorkspaceProjects(ctx context.Context, workspaceID string, loggedUserID string, page, pageSize int) (getWorkspaceProjectsResponse, error) { + workspaceUUID, err := uuid.Parse(workspaceID) if err != nil { - return getWorkspaceProjectsResponse{}, codeerror.New(codeerror.WorkspaceNotFound, "Workspace not found") + return getWorkspaceProjectsResponse{}, codeerror.New(codeerror.InvalidUUID, "Invalid workspace ID") + } + + if err := s.ensureWorkspaceAccess(ctx, workspaceID, loggedUserID); err != nil { + return getWorkspaceProjectsResponse{}, err } rows, err := s.repo.GetWorkspaceProjects(ctx, repo.GetWorkspaceProjectsParams{ @@ -204,7 +239,7 @@ func (s *svc) CreateProject(ctx context.Context, workspaceID string, loggedUserI } -func (s *svc) GetProjectByID(ctx context.Context, projectID string) (projectResponse, error) { +func (s *svc) GetProjectByID(ctx context.Context, projectID string, loggedUserID string) (projectResponse, error) { id, err := uuid.Parse(projectID) if err != nil { return projectResponse{}, codeerror.New(codeerror.ProjectNotFound, "Project not found") @@ -215,6 +250,10 @@ func (s *svc) GetProjectByID(ctx context.Context, projectID string) (projectResp return projectResponse{}, codeerror.New(codeerror.ProjectNotFound, "Project not found") } + if err := s.ensureWorkspaceAccess(ctx, project.WorkspaceID.String(), loggedUserID); err != nil { + return projectResponse{}, err + } + return toProjectResponse(project), nil } diff --git a/internal/tasks/handlers.go b/internal/tasks/handlers.go index e2f47a7..61efa9d 100644 --- a/internal/tasks/handlers.go +++ b/internal/tasks/handlers.go @@ -1,6 +1,7 @@ package tasks import ( + "gin-api-1/internal/auth" codeerror "gin-api-1/internal/codeerror" "net/http" "strconv" @@ -42,8 +43,9 @@ func (h *handler) CreateTask(c *gin.Context) { func (h *handler) GetTaskByID(c *gin.Context) { taskID := c.Param("id") + loggedUser := c.MustGet("user").(auth.UserResponse) - task, err := h.service.GetTaskByID(c, taskID) + task, err := h.service.GetTaskByID(c, taskID, loggedUser.ID) if err != nil { codeerror.HandleError(c, err) return @@ -92,6 +94,7 @@ func (h *handler) DeleteTask(c *gin.Context) { func (h *handler) GetProjectTasks(c *gin.Context) { projectID := c.Param("projectID") + loggedUser := c.MustGet("user").(auth.UserResponse) page, err := strconv.Atoi(c.DefaultQuery("page", "1")) if err != nil || page < 1 { @@ -103,7 +106,7 @@ func (h *handler) GetProjectTasks(c *gin.Context) { pageSize = DefaultPageSize } - response, err := h.service.GetProjectTasks(c, projectID, page, pageSize) + response, err := h.service.GetProjectTasks(c, projectID, loggedUser.ID, page, pageSize) if err != nil { codeerror.HandleError(c, err) return diff --git a/internal/tasks/service_test.go b/internal/tasks/service_test.go new file mode 100644 index 0000000..0b82fae --- /dev/null +++ b/internal/tasks/service_test.go @@ -0,0 +1,351 @@ +package tasks + +import ( + "context" + "errors" + repo "gin-api-1/internal/adapters/postgresql/sqlc" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type mockTaskRepository struct { + getAccessibleWorkspaceByIDFunc func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) + getProjectByIdFunc func(ctx context.Context, id pgtype.UUID) (repo.Project, error) + getProjectTasksFunc func(ctx context.Context, arg repo.GetProjectTasksParams) ([]repo.GetProjectTasksRow, error) + getTaskByIdFunc func(ctx context.Context, id pgtype.UUID) (repo.Task, error) +} + +func (m *mockTaskRepository) CreateTask(ctx context.Context, arg repo.CreateTaskParams) (repo.Task, error) { + return repo.Task{}, nil +} + +func (m *mockTaskRepository) DeleteTask(ctx context.Context, id pgtype.UUID) error { + return nil +} + +func (m *mockTaskRepository) GetAccessibleWorkspaceByID(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + if m.getAccessibleWorkspaceByIDFunc == nil { + return repo.Workspace{}, nil + } + return m.getAccessibleWorkspaceByIDFunc(ctx, arg) +} + +func (m *mockTaskRepository) GetProjectById(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + if m.getProjectByIdFunc == nil { + return repo.Project{}, nil + } + return m.getProjectByIdFunc(ctx, id) +} + +func (m *mockTaskRepository) GetProjectTasks(ctx context.Context, arg repo.GetProjectTasksParams) ([]repo.GetProjectTasksRow, error) { + if m.getProjectTasksFunc == nil { + return nil, nil + } + return m.getProjectTasksFunc(ctx, arg) +} + +func (m *mockTaskRepository) GetTaskById(ctx context.Context, id pgtype.UUID) (repo.Task, error) { + if m.getTaskByIdFunc == nil { + return repo.Task{}, nil + } + return m.getTaskByIdFunc(ctx, id) +} + +func (m *mockTaskRepository) GetUserById(ctx context.Context, id pgtype.UUID) (repo.User, error) { + return repo.User{}, nil +} + +func (m *mockTaskRepository) UpdateTask(ctx context.Context, arg repo.UpdateTaskParams) (repo.Task, error) { + return repo.Task{}, nil +} + +func assertTaskServiceError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func newTestTask(id, projectID uuid.UUID) repo.Task { + return repo.Task{ + ID: pgtype.UUID{Bytes: id, Valid: true}, + Name: "Test Task", + Description: "A task", + Status: "TODO", + Priority: "MEDIUM", + ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, + CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + } +} + +func accessibleWorkspace(workspaceID, ownerID uuid.UUID) repo.Workspace { + return repo.Workspace{ + ID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + UserID: pgtype.UUID{Bytes: ownerID, Valid: true}, + } +} + +func TestGetProjectTasks(t *testing.T) { + ctx := context.Background() + ownerID := uuid.New() + memberID := uuid.New() + nonMemberID := uuid.New() + workspaceID := uuid.New() + projectID := uuid.New() + taskID := uuid.New() + + project := newTestProject(projectID, workspaceID) + workspace := accessibleWorkspace(workspaceID, ownerID) + + taskRows := []repo.GetProjectTasksRow{ + { + TotalCount: 1, + ID: pgtype.UUID{Bytes: taskID, Valid: true}, + Name: "Test Task", + Description: "A task", + Status: "TODO", + Priority: "MEDIUM", + ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, + CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + }, + } + + tests := []struct { + name string + projectID string + loggedUser string + repo *mockTaskRepository + wantErr bool + wantLen int + }{ + { + name: "owner can list project tasks", + projectID: projectID.String(), + loggedUser: ownerID.String(), + repo: &mockTaskRepository{ + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return project, nil + }, + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return workspace, nil + }, + getProjectTasksFunc: func(ctx context.Context, arg repo.GetProjectTasksParams) ([]repo.GetProjectTasksRow, error) { + if arg.ProjectID.Bytes != projectID { + t.Errorf("project ID = %v, want %v", arg.ProjectID.Bytes, projectID) + } + return taskRows, nil + }, + }, + wantErr: false, + wantLen: 1, + }, + { + name: "member can list project tasks", + projectID: projectID.String(), + loggedUser: memberID.String(), + repo: &mockTaskRepository{ + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return project, nil + }, + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + if arg.UserID.Bytes != memberID { + t.Errorf("user ID = %v, want %v", arg.UserID.Bytes, memberID) + } + return workspace, nil + }, + getProjectTasksFunc: func(ctx context.Context, arg repo.GetProjectTasksParams) ([]repo.GetProjectTasksRow, error) { + return taskRows, nil + }, + }, + wantErr: false, + wantLen: 1, + }, + { + name: "neither owner nor member cannot list project tasks", + projectID: projectID.String(), + loggedUser: nonMemberID.String(), + repo: &mockTaskRepository{ + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return project, nil + }, + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return repo.Workspace{}, pgx.ErrNoRows + }, + }, + wantErr: true, + }, + { + name: "invalid project id", + projectID: "not-a-uuid", + loggedUser: ownerID.String(), + repo: &mockTaskRepository{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &svc{repo: tt.repo} + result, err := service.GetProjectTasks(ctx, tt.projectID, tt.loggedUser, 1, 10) + + if tt.wantErr { + assertTaskServiceError(t, err) + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Tasks) != tt.wantLen { + t.Errorf("tasks count = %d, want %d", len(result.Tasks), tt.wantLen) + } + }) + } +} + +func TestGetTaskByID(t *testing.T) { + ctx := context.Background() + ownerID := uuid.New() + memberID := uuid.New() + nonMemberID := uuid.New() + workspaceID := uuid.New() + projectID := uuid.New() + taskID := uuid.New() + + project := newTestProject(projectID, workspaceID) + workspace := accessibleWorkspace(workspaceID, ownerID) + task := newTestTask(taskID, projectID) + + tests := []struct { + name string + taskID string + loggedUser string + repo *mockTaskRepository + wantErr bool + }{ + { + name: "owner can fetch task", + taskID: taskID.String(), + loggedUser: ownerID.String(), + repo: &mockTaskRepository{ + getTaskByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Task, error) { + return task, nil + }, + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return project, nil + }, + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return workspace, nil + }, + }, + wantErr: false, + }, + { + name: "member can fetch task", + taskID: taskID.String(), + loggedUser: memberID.String(), + repo: &mockTaskRepository{ + getTaskByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Task, error) { + return task, nil + }, + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return project, nil + }, + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return workspace, nil + }, + }, + wantErr: false, + }, + { + name: "neither owner nor member cannot fetch task", + taskID: taskID.String(), + loggedUser: nonMemberID.String(), + repo: &mockTaskRepository{ + getTaskByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Task, error) { + return task, nil + }, + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return project, nil + }, + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return repo.Workspace{}, pgx.ErrNoRows + }, + }, + wantErr: true, + }, + { + name: "task not found", + taskID: taskID.String(), + loggedUser: ownerID.String(), + repo: &mockTaskRepository{ + getTaskByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Task, error) { + return repo.Task{}, pgx.ErrNoRows + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &svc{repo: tt.repo} + _, err := service.GetTaskByID(ctx, tt.taskID, tt.loggedUser) + + if tt.wantErr { + assertTaskServiceError(t, err) + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestGetProjectTasksRepositoryFailure(t *testing.T) { + ctx := context.Background() + ownerID := uuid.New() + workspaceID := uuid.New() + projectID := uuid.New() + + project := newTestProject(projectID, workspaceID) + workspace := accessibleWorkspace(workspaceID, ownerID) + + mockRepo := &mockTaskRepository{ + getProjectByIdFunc: func(ctx context.Context, id pgtype.UUID) (repo.Project, error) { + return project, nil + }, + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return workspace, nil + }, + getProjectTasksFunc: func(ctx context.Context, arg repo.GetProjectTasksParams) ([]repo.GetProjectTasksRow, error) { + return nil, errors.New("repo failure") + }, + } + + service := &svc{repo: mockRepo} + _, err := service.GetProjectTasks(ctx, projectID.String(), ownerID.String(), 1, 10) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func newTestProject(id, workspaceID uuid.UUID) repo.Project { + return repo.Project{ + ID: pgtype.UUID{Bytes: id, Valid: true}, + Name: "Test Project", + Description: "A project", + WorkspaceID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + } +} diff --git a/internal/tasks/services.go b/internal/tasks/services.go index 7cb1537..9fda25b 100644 --- a/internal/tasks/services.go +++ b/internal/tasks/services.go @@ -15,18 +15,30 @@ const dateLayout = "2006-01-02" type Service interface { CreateTask(ctx context.Context, projectID string, payload createTaskPayload) (taskResponse, error) - GetTaskByID(ctx context.Context, taskID string) (taskResponse, error) - GetProjectTasks(ctx context.Context, projectID string, page, pageSize int) (getProjectTasksResponse, error) + GetTaskByID(ctx context.Context, taskID string, loggedUserID string) (taskResponse, error) + GetProjectTasks(ctx context.Context, projectID string, loggedUserID string, page, pageSize int) (getProjectTasksResponse, error) UpdateTask(ctx context.Context, taskID string, payload updateTaskPayload) (taskResponse, error) DeleteTask(ctx context.Context, taskID string) error } +// Interface for the database dependency. +type taskRepository interface { + CreateTask(ctx context.Context, arg repo.CreateTaskParams) (repo.Task, error) + DeleteTask(ctx context.Context, id pgtype.UUID) error + GetAccessibleWorkspaceByID(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) + GetProjectById(ctx context.Context, id pgtype.UUID) (repo.Project, error) + GetProjectTasks(ctx context.Context, arg repo.GetProjectTasksParams) ([]repo.GetProjectTasksRow, error) + GetTaskById(ctx context.Context, id pgtype.UUID) (repo.Task, error) + GetUserById(ctx context.Context, id pgtype.UUID) (repo.User, error) + UpdateTask(ctx context.Context, arg repo.UpdateTaskParams) (repo.Task, error) +} + type svc struct { - repo *repo.Queries + repo taskRepository db *pgxpool.Pool } -func NewTasksService(repo *repo.Queries, db *pgxpool.Pool) Service { +func NewTasksService(repo taskRepository, db *pgxpool.Pool) Service { return &svc{ repo: repo, db: db, @@ -87,7 +99,7 @@ func (s *svc) CreateTask(ctx context.Context, projectID string, payload createTa return toTaskResponse(task), nil } -func (s *svc) GetTaskByID(ctx context.Context, taskID string) (taskResponse, error) { +func (s *svc) GetTaskByID(ctx context.Context, taskID string, loggedUserID string) (taskResponse, error) { id, err := uuid.Parse(taskID) if err != nil { return taskResponse{}, codeerror.New(codeerror.InvalidUUID, "Invalid task ID") @@ -98,6 +110,10 @@ func (s *svc) GetTaskByID(ctx context.Context, taskID string) (taskResponse, err return taskResponse{}, codeerror.New(codeerror.TaskNotFound, "Task not found") } + if err := s.ensureWorkspaceAccessForTask(ctx, task.ProjectID.String(), loggedUserID); err != nil { + return taskResponse{}, err + } + return toTaskResponse(task), nil } @@ -172,17 +188,57 @@ func (s *svc) DeleteTask(ctx context.Context, taskID string) error { return nil } -func (s *svc) GetProjectTasks(ctx context.Context, projectID string, page, pageSize int) (getProjectTasksResponse, error) { +func (s *svc) ensureWorkspaceAccessForTask(ctx context.Context, projectID, loggedUserID string) error { + projectUUID, err := uuid.Parse(projectID) + if err != nil { + return codeerror.New(codeerror.InvalidUUID, "Invalid project ID") + } + + project, err := s.repo.GetProjectById(ctx, pgtype.UUID{Bytes: projectUUID, Valid: true}) + if err != nil { + return codeerror.New(codeerror.ProjectNotFound, "Project not found") + } + + return s.ensureWorkspaceAccess(ctx, project.WorkspaceID.String(), loggedUserID) +} + +func (s *svc) ensureWorkspaceAccess(ctx context.Context, workspaceID, loggedUserID string) error { + workspaceUUID, err := uuid.Parse(workspaceID) + if err != nil { + return codeerror.New(codeerror.InvalidUUID, "Invalid workspace ID") + } + + userUUID, err := uuid.Parse(loggedUserID) + if err != nil { + return codeerror.New(codeerror.UserNotFound, "User not found") + } + + _, err = s.repo.GetAccessibleWorkspaceByID(ctx, repo.GetAccessibleWorkspaceByIDParams{ + ID: pgtype.UUID{Bytes: workspaceUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + }) + if err != nil { + return codeerror.New(codeerror.WorkspaceNotFound, "Workspace not found") + } + + return nil +} + +func (s *svc) GetProjectTasks(ctx context.Context, projectID string, loggedUserID string, page, pageSize int) (getProjectTasksResponse, error) { id, err := uuid.Parse(projectID) if err != nil { return getProjectTasksResponse{}, codeerror.New(codeerror.InvalidUUID, "Invalid project ID") } - _, err = s.repo.GetProjectById(ctx, pgtype.UUID{Bytes: id, Valid: true}) + project, err := s.repo.GetProjectById(ctx, pgtype.UUID{Bytes: id, Valid: true}) if err != nil { return getProjectTasksResponse{}, codeerror.New(codeerror.ProjectNotFound, "Project not found") } + if err := s.ensureWorkspaceAccess(ctx, project.WorkspaceID.String(), loggedUserID); err != nil { + return getProjectTasksResponse{}, err + } + rows, err := s.repo.GetProjectTasks(ctx, repo.GetProjectTasksParams{ ProjectID: pgtype.UUID{Bytes: id, Valid: true}, Limit: int32(pageSize), diff --git a/internal/workspace/service_test.go b/internal/workspace/service_test.go new file mode 100644 index 0000000..98b219f --- /dev/null +++ b/internal/workspace/service_test.go @@ -0,0 +1,270 @@ +package workspace + +import ( + "context" + "errors" + repo "gin-api-1/internal/adapters/postgresql/sqlc" + codeerror "gin-api-1/internal/codeerror" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type mockWorkspaceRepository struct { + getAccessibleWorkspaceByIDFunc func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) + getUserWorkspacesFunc func(ctx context.Context, arg repo.GetUserWorkspacesParams) ([]repo.GetUserWorkspacesRow, error) +} + +func (m *mockWorkspaceRepository) AddWorkspaceMember(ctx context.Context, arg repo.AddWorkspaceMemberParams) (repo.WorkspaceMember, error) { + return repo.WorkspaceMember{}, nil +} + +func (m *mockWorkspaceRepository) CountUserWorkspaces(ctx context.Context, userID pgtype.UUID) (int64, error) { + return 0, nil +} + +func (m *mockWorkspaceRepository) CreateWorkspace(ctx context.Context, arg repo.CreateWorkspaceParams) (repo.Workspace, error) { + return repo.Workspace{}, nil +} + +func (m *mockWorkspaceRepository) DeleteWorkspace(ctx context.Context, arg repo.DeleteWorkspaceParams) error { + return nil +} + +func (m *mockWorkspaceRepository) GetAccessibleWorkspaceByID(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + if m.getAccessibleWorkspaceByIDFunc == nil { + return repo.Workspace{}, nil + } + return m.getAccessibleWorkspaceByIDFunc(ctx, arg) +} + +func (m *mockWorkspaceRepository) GetUserActiveProSubscription(ctx context.Context, userID pgtype.UUID) (repo.Subscription, error) { + return repo.Subscription{}, pgx.ErrNoRows +} + +func (m *mockWorkspaceRepository) GetUserById(ctx context.Context, id pgtype.UUID) (repo.User, error) { + return repo.User{}, nil +} + +func (m *mockWorkspaceRepository) GetUserWorkspaceByID(ctx context.Context, arg repo.GetUserWorkspaceByIDParams) (repo.Workspace, error) { + return repo.Workspace{}, nil +} + +func (m *mockWorkspaceRepository) GetUserWorkspaces(ctx context.Context, arg repo.GetUserWorkspacesParams) ([]repo.GetUserWorkspacesRow, error) { + if m.getUserWorkspacesFunc == nil { + return nil, nil + } + return m.getUserWorkspacesFunc(ctx, arg) +} + +func (m *mockWorkspaceRepository) UpdateUserStripeCustomer(ctx context.Context, arg repo.UpdateUserStripeCustomerParams) (repo.User, error) { + return repo.User{}, nil +} + +func (m *mockWorkspaceRepository) UpdateWorkspace(ctx context.Context, arg repo.UpdateWorkspaceParams) (repo.Workspace, error) { + return repo.Workspace{}, nil +} + +func (m *mockWorkspaceRepository) WithTx(tx pgx.Tx) *repo.Queries { + return nil +} + +func assertErrorCode(t *testing.T, err error, code string) { + t.Helper() + if err == nil { + t.Fatal("expected error, got nil") + } + + var appErr *codeerror.Error + if !errors.As(err, &appErr) { + t.Fatalf("expected *codeerror.Error, got %T", err) + } + + if appErr.Code != code { + t.Errorf("error code = %s, want %s", appErr.Code, code) + } +} + +func newTestWorkspace(id, userID uuid.UUID) repo.Workspace { + return repo.Workspace{ + ID: pgtype.UUID{Bytes: id, Valid: true}, + WorkspaceName: "Test Workspace", + Description: "A workspace", + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + } +} + +func TestGetUserWorkspaceByID(t *testing.T) { + ctx := context.Background() + ownerID := uuid.New() + memberID := uuid.New() + workspaceID := uuid.New() + workspace := newTestWorkspace(workspaceID, ownerID) + + tests := []struct { + name string + arg repo.GetUserWorkspaceByIDParams + repo *mockWorkspaceRepository + wantErr bool + wantCode string + wantMatchID bool + }{ + { + name: "owner can access workspace", + arg: repo.GetUserWorkspaceByIDParams{ + ID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + UserID: pgtype.UUID{Bytes: ownerID, Valid: true}, + }, + repo: &mockWorkspaceRepository{ + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + if arg.ID.Bytes != workspaceID { + t.Errorf("workspace ID = %v, want %v", arg.ID.Bytes, workspaceID) + } + if arg.UserID.Bytes != ownerID { + t.Errorf("user ID = %v, want %v", arg.UserID.Bytes, ownerID) + } + return workspace, nil + }, + }, + wantErr: false, + wantMatchID: true, + }, + { + name: "member can access workspace", + arg: repo.GetUserWorkspaceByIDParams{ + ID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + UserID: pgtype.UUID{Bytes: memberID, Valid: true}, + }, + repo: &mockWorkspaceRepository{ + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + if arg.UserID.Bytes != memberID { + t.Errorf("user ID = %v, want %v", arg.UserID.Bytes, memberID) + } + return workspace, nil + }, + }, + wantErr: false, + wantMatchID: true, + }, + { + name: "non-member cannot access workspace", + arg: repo.GetUserWorkspaceByIDParams{ + ID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + UserID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + }, + repo: &mockWorkspaceRepository{ + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return repo.Workspace{}, pgx.ErrNoRows + }, + }, + wantErr: true, + wantCode: codeerror.WorkspaceNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &svc{repo: tt.repo} + result, err := service.GetUserWorkspaceByID(ctx, tt.arg) + + if tt.wantErr { + assertErrorCode(t, err, tt.wantCode) + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.wantMatchID && result.ID.String() != workspaceID.String() { + t.Errorf("workspace ID = %s, want %s", result.ID, workspaceID) + } + }) + } +} + +func TestGetUserWorkspaces(t *testing.T) { + ctx := context.Background() + userID := uuid.New() + ownedID := uuid.New() + memberID := uuid.New() + now := time.Now() + + rows := []repo.GetUserWorkspacesRow{ + { + TotalCount: 2, + ID: pgtype.UUID{Bytes: ownedID, Valid: true}, + WorkspaceName: "Owned Workspace", + Description: "Owned", + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + CreatedAt: pgtype.Timestamptz{Time: now, Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: now, Valid: true}, + }, + { + TotalCount: 2, + ID: pgtype.UUID{Bytes: memberID, Valid: true}, + WorkspaceName: "Member Workspace", + Description: "Member", + UserID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + CreatedAt: pgtype.Timestamptz{Time: now, Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: now, Valid: true}, + }, + } + + mockRepo := &mockWorkspaceRepository{ + getUserWorkspacesFunc: func(ctx context.Context, arg repo.GetUserWorkspacesParams) ([]repo.GetUserWorkspacesRow, error) { + if arg.UserID.Bytes != userID { + t.Errorf("user ID = %v, want %v", arg.UserID.Bytes, userID) + } + if arg.Limit != 10 || arg.Offset != 0 { + t.Errorf("limit/offset = %d/%d, want 10/0", arg.Limit, arg.Offset) + } + return rows, nil + }, + } + + service := &svc{repo: mockRepo} + result, err := service.GetUserWorkspaces(ctx, pgtype.UUID{Bytes: userID, Valid: true}, 1, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Workspaces) != 2 { + t.Fatalf("workspaces count = %d, want 2", len(result.Workspaces)) + } + + // Owned workspaces and member workspaces are returned together. + if result.Workspaces[0].ID != ownedID.String() { + t.Errorf("first workspace ID = %s, want %s", result.Workspaces[0].ID, ownedID) + } + + if result.Workspaces[1].ID != memberID.String() { + t.Errorf("second workspace ID = %s, want %s", result.Workspaces[1].ID, memberID) + } + + if result.Pagination.Total != 2 || result.Pagination.TotalPages != 1 { + t.Errorf("pagination = %+v, want total=2 totalPages=1", result.Pagination) + } +} + +func TestGetUserWorkspacesRepositoryFailure(t *testing.T) { + ctx := context.Background() + userID := uuid.New() + + mockRepo := &mockWorkspaceRepository{ + getUserWorkspacesFunc: func(ctx context.Context, arg repo.GetUserWorkspacesParams) ([]repo.GetUserWorkspacesRow, error) { + return nil, errors.New("repo failure") + }, + } + + service := &svc{repo: mockRepo} + _, err := service.GetUserWorkspaces(ctx, pgtype.UUID{Bytes: userID, Valid: true}, 1, 10) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/workspace/services.go b/internal/workspace/services.go index e621d20..83e507c 100644 --- a/internal/workspace/services.go +++ b/internal/workspace/services.go @@ -24,13 +24,29 @@ type Service interface { CreateCheckoutSession(ctx context.Context, userID string) (*stripe.CheckoutSession, error) } +// Interface for the database dependency. +type workspaceRepository interface { + AddWorkspaceMember(ctx context.Context, arg repo.AddWorkspaceMemberParams) (repo.WorkspaceMember, error) + CountUserWorkspaces(ctx context.Context, userID pgtype.UUID) (int64, error) + CreateWorkspace(ctx context.Context, arg repo.CreateWorkspaceParams) (repo.Workspace, error) + DeleteWorkspace(ctx context.Context, arg repo.DeleteWorkspaceParams) error + GetAccessibleWorkspaceByID(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) + GetUserActiveProSubscription(ctx context.Context, userID pgtype.UUID) (repo.Subscription, error) + GetUserById(ctx context.Context, id pgtype.UUID) (repo.User, error) + GetUserWorkspaceByID(ctx context.Context, arg repo.GetUserWorkspaceByIDParams) (repo.Workspace, error) + GetUserWorkspaces(ctx context.Context, arg repo.GetUserWorkspacesParams) ([]repo.GetUserWorkspacesRow, error) + UpdateUserStripeCustomer(ctx context.Context, arg repo.UpdateUserStripeCustomerParams) (repo.User, error) + UpdateWorkspace(ctx context.Context, arg repo.UpdateWorkspaceParams) (repo.Workspace, error) + WithTx(tx pgx.Tx) *repo.Queries +} + type svc struct { - repo *repo.Queries + repo workspaceRepository db *pgxpool.Pool stripe payment.Svc } -func NewWorkspaceService(repo *repo.Queries, db *pgxpool.Pool, stripe payment.Svc) Service { +func NewWorkspaceService(repo workspaceRepository, db *pgxpool.Pool, stripe payment.Svc) Service { return &svc{ repo: repo, db: db, @@ -39,7 +55,10 @@ func NewWorkspaceService(repo *repo.Queries, db *pgxpool.Pool, stripe payment.Sv } func (s *svc) GetUserWorkspaceByID(ctx context.Context, arg repo.GetUserWorkspaceByIDParams) (repo.Workspace, error) { - workspace, err := s.repo.GetUserWorkspaceByID(ctx, arg) + workspace, err := s.repo.GetAccessibleWorkspaceByID(ctx, repo.GetAccessibleWorkspaceByIDParams{ + ID: arg.ID, + UserID: arg.UserID, + }) if err != nil { return repo.Workspace{}, codeerror.New(codeerror.WorkspaceNotFound, "Workspace not found") } diff --git a/internal/workspacemembers/handlers.go b/internal/workspacemembers/handlers.go index 6ccb0dd..b6a0d98 100644 --- a/internal/workspacemembers/handlers.go +++ b/internal/workspacemembers/handlers.go @@ -1,6 +1,7 @@ package workspacemembers import ( + "gin-api-1/internal/auth" codeerror "gin-api-1/internal/codeerror" "net/http" "strconv" @@ -42,6 +43,7 @@ func (h *handler) AddWorkspaceMember(c *gin.Context) { func (h *handler) GetWorkspaceMembers(c *gin.Context) { id := c.Param("id") + loggedUser := c.MustGet("user").(auth.UserResponse) page, err := strconv.Atoi(c.DefaultQuery("page", "1")) if err != nil || page < 1 { @@ -53,7 +55,7 @@ func (h *handler) GetWorkspaceMembers(c *gin.Context) { pageSize = DefaultPageSize } - response, err := h.service.GetWorkspaceMembers(c, id, page, pageSize) + response, err := h.service.GetWorkspaceMembers(c, id, loggedUser.ID, page, pageSize) if err != nil { codeerror.HandleError(c, err) return diff --git a/internal/workspacemembers/service_test.go b/internal/workspacemembers/service_test.go new file mode 100644 index 0000000..7c87b94 --- /dev/null +++ b/internal/workspacemembers/service_test.go @@ -0,0 +1,165 @@ +package workspacemembers + +import ( + "context" + repo "gin-api-1/internal/adapters/postgresql/sqlc" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type mockWorkspaceMemberRepository struct { + getAccessibleWorkspaceByIDFunc func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) + getWorkspaceMembersFunc func(ctx context.Context, arg repo.GetWorkspaceMembersParams) ([]repo.GetWorkspaceMembersRow, error) +} + +func (m *mockWorkspaceMemberRepository) AddWorkspaceMember(ctx context.Context, arg repo.AddWorkspaceMemberParams) (repo.WorkspaceMember, error) { + return repo.WorkspaceMember{}, nil +} + +func (m *mockWorkspaceMemberRepository) DeleteMemberFromWorkspace(ctx context.Context, arg repo.DeleteMemberFromWorkspaceParams) error { + return nil +} + +func (m *mockWorkspaceMemberRepository) GetAccessibleWorkspaceByID(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + if m.getAccessibleWorkspaceByIDFunc == nil { + return repo.Workspace{}, nil + } + return m.getAccessibleWorkspaceByIDFunc(ctx, arg) +} + +func (m *mockWorkspaceMemberRepository) GetMemberFromWorkspace(ctx context.Context, arg repo.GetMemberFromWorkspaceParams) (repo.WorkspaceMember, error) { + return repo.WorkspaceMember{}, nil +} + +func (m *mockWorkspaceMemberRepository) GetUserById(ctx context.Context, id pgtype.UUID) (repo.User, error) { + return repo.User{}, nil +} + +func (m *mockWorkspaceMemberRepository) GetWorkspaceByID(ctx context.Context, id pgtype.UUID) (repo.Workspace, error) { + return repo.Workspace{}, nil +} + +func (m *mockWorkspaceMemberRepository) GetWorkspaceMembers(ctx context.Context, arg repo.GetWorkspaceMembersParams) ([]repo.GetWorkspaceMembersRow, error) { + if m.getWorkspaceMembersFunc == nil { + return nil, nil + } + return m.getWorkspaceMembersFunc(ctx, arg) +} + +func TestGetWorkspaceMembers(t *testing.T) { + ctx := context.Background() + ownerID := uuid.New() + memberID := uuid.New() + nonMemberID := uuid.New() + workspaceID := uuid.New() + memberRowUserID := uuid.New() + + workspace := repo.Workspace{ + ID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + WorkspaceName: "Test Workspace", + Description: "A workspace", + UserID: pgtype.UUID{Bytes: ownerID, Valid: true}, + CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + } + + memberRows := []repo.GetWorkspaceMembersRow{ + { + TotalCount: 1, + MemberID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + WorkspaceID: pgtype.UUID{Bytes: workspaceID, Valid: true}, + UserRole: "MEMBER", + MemberCreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + UserID: pgtype.UUID{Bytes: memberRowUserID, Valid: true}, + FirstName: "Jane", + LastName: "Doe", + Email: "jane@example.com", + CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + }, + } + + tests := []struct { + name string + workspaceID string + loggedUser string + repo *mockWorkspaceMemberRepository + wantErr bool + wantLen int + }{ + { + name: "owner can list workspace members", + workspaceID: workspaceID.String(), + loggedUser: ownerID.String(), + repo: &mockWorkspaceMemberRepository{ + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return workspace, nil + }, + getWorkspaceMembersFunc: func(ctx context.Context, arg repo.GetWorkspaceMembersParams) ([]repo.GetWorkspaceMembersRow, error) { + return memberRows, nil + }, + }, + wantErr: false, + wantLen: 1, + }, + { + name: "member can list workspace members", + workspaceID: workspaceID.String(), + loggedUser: memberID.String(), + repo: &mockWorkspaceMemberRepository{ + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + if arg.UserID.Bytes != memberID { + t.Errorf("user ID = %v, want %v", arg.UserID.Bytes, memberID) + } + return workspace, nil + }, + getWorkspaceMembersFunc: func(ctx context.Context, arg repo.GetWorkspaceMembersParams) ([]repo.GetWorkspaceMembersRow, error) { + return memberRows, nil + }, + }, + wantErr: false, + wantLen: 1, + }, + { + name: "neither owner nor member cannot list workspace members", + workspaceID: workspaceID.String(), + loggedUser: nonMemberID.String(), + repo: &mockWorkspaceMemberRepository{ + getAccessibleWorkspaceByIDFunc: func(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) { + return repo.Workspace{}, pgx.ErrNoRows + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &svc{repo: tt.repo} + result, err := service.GetWorkspaceMembers(ctx, tt.workspaceID, tt.loggedUser, 1, 10) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Members) != tt.wantLen { + t.Errorf("members count = %d, want %d", len(result.Members), tt.wantLen) + } + + if result.Workspace.ID != workspaceID.String() { + t.Errorf("workspace ID = %s, want %s", result.Workspace.ID, workspaceID) + } + }) + } +} diff --git a/internal/workspacemembers/services.go b/internal/workspacemembers/services.go index 2c36ed3..6addefc 100644 --- a/internal/workspacemembers/services.go +++ b/internal/workspacemembers/services.go @@ -12,16 +12,27 @@ import ( type Service interface { AddWorkspaceMember(ctx context.Context, workspaceID string, payload addWorkspaceMemberPayload) (repo.WorkspaceMember, error) - GetWorkspaceMembers(ctx context.Context, workspaceID string, page, pageSize int) (getWorkspaceMembersResponse, error) + GetWorkspaceMembers(ctx context.Context, workspaceID string, loggedUserID string, page, pageSize int) (getWorkspaceMembersResponse, error) RemoveWorkspaceMember(ctx context.Context, workspaceID, userID string) error } +// Interface for the database dependency. +type workspaceMemberRepository interface { + AddWorkspaceMember(ctx context.Context, arg repo.AddWorkspaceMemberParams) (repo.WorkspaceMember, error) + DeleteMemberFromWorkspace(ctx context.Context, arg repo.DeleteMemberFromWorkspaceParams) error + GetAccessibleWorkspaceByID(ctx context.Context, arg repo.GetAccessibleWorkspaceByIDParams) (repo.Workspace, error) + GetMemberFromWorkspace(ctx context.Context, arg repo.GetMemberFromWorkspaceParams) (repo.WorkspaceMember, error) + GetUserById(ctx context.Context, id pgtype.UUID) (repo.User, error) + GetWorkspaceByID(ctx context.Context, id pgtype.UUID) (repo.Workspace, error) + GetWorkspaceMembers(ctx context.Context, arg repo.GetWorkspaceMembersParams) ([]repo.GetWorkspaceMembersRow, error) +} + type svc struct { - repo *repo.Queries + repo workspaceMemberRepository db *pgxpool.Pool } -func NewWorkspaceMembersService(repo *repo.Queries, db *pgxpool.Pool) Service { +func NewWorkspaceMembersService(repo workspaceMemberRepository, db *pgxpool.Pool) Service { return &svc{ repo: repo, db: db, @@ -95,13 +106,21 @@ func (s *svc) RemoveWorkspaceMember(ctx context.Context, workspaceID, userID str }) } -func (s *svc) GetWorkspaceMembers(ctx context.Context, workspaceID string, page, pageSize int) (getWorkspaceMembersResponse, error) { +func (s *svc) GetWorkspaceMembers(ctx context.Context, workspaceID string, loggedUserID string, page, pageSize int) (getWorkspaceMembersResponse, error) { workspaceUUID, err := uuid.Parse(workspaceID) if err != nil { return getWorkspaceMembersResponse{}, codeerror.New(codeerror.InvalidUUID, "Invalid workspace ID") } - workspace, err := s.repo.GetWorkspaceByID(ctx, pgtype.UUID{Bytes: workspaceUUID, Valid: true}) + userUUID, err := uuid.Parse(loggedUserID) + if err != nil { + return getWorkspaceMembersResponse{}, codeerror.New(codeerror.UserNotFound, "User not found") + } + + workspace, err := s.repo.GetAccessibleWorkspaceByID(ctx, repo.GetAccessibleWorkspaceByIDParams{ + ID: pgtype.UUID{Bytes: workspaceUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + }) if err != nil { return getWorkspaceMembersResponse{}, codeerror.New(codeerror.WorkspaceNotFound, "Workspace not found") }