diff --git a/backend/.sqlc/migrations/20241215194302_initial_schema.sql b/backend/.sqlc/migrations/20241215194302_initial_schema.sql index be5d78ef..1ca5289e 100644 --- a/backend/.sqlc/migrations/20241215194302_initial_schema.sql +++ b/backend/.sqlc/migrations/20241215194302_initial_schema.sql @@ -103,8 +103,9 @@ CREATE TABLE IF NOT EXISTS project_comments ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, target_id uuid NOT NULL, - comment uuid NOT NULL, + comment text NOT NULL, commenter_id uuid NOT NULL REFERENCES users(id), + resolved boolean NOT NULL DEFAULT false, created_at bigint NOT NULL DEFAULT extract(epoch from now()), updated_at bigint NOT NULL DEFAULT extract(epoch from now()) ); diff --git a/backend/.sqlc/queries/projects.sql b/backend/.sqlc/queries/projects.sql index 803bd36d..4d19317e 100644 --- a/backend/.sqlc/queries/projects.sql +++ b/backend/.sqlc/queries/projects.sql @@ -133,4 +133,59 @@ INSERT INTO project_answers ( $1, -- project_id $2, -- question_id $3 -- answer -) RETURNING *; \ No newline at end of file +) RETURNING *; + +-- name: GetProjectComments :many +SELECT * FROM project_comments +WHERE project_id = $1 +ORDER BY created_at DESC; + +-- name: GetProjectComment :one +SELECT * FROM project_comments +WHERE id = $1 AND project_id = $2 +LIMIT 1; + +-- name: CreateProjectComment :one +INSERT INTO project_comments ( + project_id, + target_id, + comment, + commenter_id +) VALUES ( + $1, -- project_id + $2, -- target_id + $3, -- comment + $4 -- commenter_id +) RETURNING *; + +-- name: UpdateProjectComment :one +UPDATE project_comments +SET comment = $2, + updated_at = extract(epoch from now()) +WHERE id = $1 +RETURNING *; + +-- name: DeleteProjectComment :exec +DELETE FROM project_comments +WHERE id = $1; + +-- name: GetProjectByIDAdmin :one +SELECT * FROM projects +WHERE id = $1 +LIMIT 1; + +-- name: ResolveProjectComment :one +UPDATE project_comments +SET + resolved = true, + updated_at = extract(epoch from now()) +WHERE id = $1 AND project_id = $2 +RETURNING *; + +-- name: UnresolveProjectComment :one +UPDATE project_comments +SET + resolved = false, + updated_at = extract(epoch from now()) +WHERE id = $1 AND project_id = $2 +RETURNING *; \ No newline at end of file diff --git a/backend/.sqlc/queries/users.sql b/backend/.sqlc/queries/users.sql index 172040de..513d5476 100644 --- a/backend/.sqlc/queries/users.sql +++ b/backend/.sqlc/queries/users.sql @@ -19,4 +19,4 @@ VALUES SELECT * FROM users WHERE email = $1 LIMIT 1; -- name: UpdateUserEmailVerifiedStatus :exec -UPDATE users SET email_verified = $1 WHERE id = $2; +UPDATE users SET email_verified = $1 WHERE id = $2; \ No newline at end of file diff --git a/backend/db/models.go b/backend/db/models.go index 8481bd6b..aa959199 100644 --- a/backend/db/models.go +++ b/backend/db/models.go @@ -174,6 +174,7 @@ type ProjectComment struct { TargetID string Comment string CommenterID string + Resolved bool CreatedAt int64 UpdatedAt int64 } diff --git a/backend/db/projects.sql.go b/backend/db/projects.sql.go index 1e31c7f5..dbed0ad9 100644 --- a/backend/db/projects.sql.go +++ b/backend/db/projects.sql.go @@ -123,6 +123,48 @@ func (q *Queries) CreateProjectAnswers(ctx context.Context, projectID string) ([ return items, nil } +const createProjectComment = `-- name: CreateProjectComment :one +INSERT INTO project_comments ( + project_id, + target_id, + comment, + commenter_id +) VALUES ( + $1, -- project_id + $2, -- target_id + $3, -- comment + $4 -- commenter_id +) RETURNING id, project_id, target_id, comment, commenter_id, resolved, created_at, updated_at +` + +type CreateProjectCommentParams struct { + ProjectID string + TargetID string + Comment string + CommenterID string +} + +func (q *Queries) CreateProjectComment(ctx context.Context, arg CreateProjectCommentParams) (ProjectComment, error) { + row := q.db.QueryRow(ctx, createProjectComment, + arg.ProjectID, + arg.TargetID, + arg.Comment, + arg.CommenterID, + ) + var i ProjectComment + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.TargetID, + &i.Comment, + &i.CommenterID, + &i.Resolved, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const createProjectDocument = `-- name: CreateProjectDocument :one INSERT INTO project_documents ( id, @@ -170,6 +212,16 @@ func (q *Queries) CreateProjectDocument(ctx context.Context, arg CreateProjectDo return i, err } +const deleteProjectComment = `-- name: DeleteProjectComment :exec +DELETE FROM project_comments +WHERE id = $1 +` + +func (q *Queries) DeleteProjectComment(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, deleteProjectComment, id) + return err +} + const deleteProjectDocument = `-- name: DeleteProjectDocument :one DELETE FROM project_documents WHERE project_documents.id = $1 @@ -289,6 +341,89 @@ func (q *Queries) GetProjectByID(ctx context.Context, arg GetProjectByIDParams) return i, err } +const getProjectByIDAdmin = `-- name: GetProjectByIDAdmin :one +SELECT id, company_id, title, description, status, created_at, updated_at FROM projects +WHERE id = $1 +LIMIT 1 +` + +func (q *Queries) GetProjectByIDAdmin(ctx context.Context, id string) (Project, error) { + row := q.db.QueryRow(ctx, getProjectByIDAdmin, id) + var i Project + err := row.Scan( + &i.ID, + &i.CompanyID, + &i.Title, + &i.Description, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getProjectComment = `-- name: GetProjectComment :one +SELECT id, project_id, target_id, comment, commenter_id, resolved, created_at, updated_at FROM project_comments +WHERE id = $1 AND project_id = $2 +LIMIT 1 +` + +type GetProjectCommentParams struct { + ID string + ProjectID string +} + +func (q *Queries) GetProjectComment(ctx context.Context, arg GetProjectCommentParams) (ProjectComment, error) { + row := q.db.QueryRow(ctx, getProjectComment, arg.ID, arg.ProjectID) + var i ProjectComment + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.TargetID, + &i.Comment, + &i.CommenterID, + &i.Resolved, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getProjectComments = `-- name: GetProjectComments :many +SELECT id, project_id, target_id, comment, commenter_id, resolved, created_at, updated_at FROM project_comments +WHERE project_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) GetProjectComments(ctx context.Context, projectID string) ([]ProjectComment, error) { + rows, err := q.db.Query(ctx, getProjectComments, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectComment + for rows.Next() { + var i ProjectComment + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.TargetID, + &i.Comment, + &i.CommenterID, + &i.Resolved, + &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 getProjectDocument = `-- name: GetProjectDocument :one SELECT project_documents.id, project_documents.project_id, project_documents.name, project_documents.url, project_documents.section, project_documents.created_at, project_documents.updated_at FROM project_documents JOIN projects ON project_documents.project_id = projects.id @@ -500,6 +635,66 @@ func (q *Queries) ListCompanyProjects(ctx context.Context, companyID string) ([] return items, nil } +const resolveProjectComment = `-- name: ResolveProjectComment :one +UPDATE project_comments +SET + resolved = true, + updated_at = extract(epoch from now()) +WHERE id = $1 AND project_id = $2 +RETURNING id, project_id, target_id, comment, commenter_id, resolved, created_at, updated_at +` + +type ResolveProjectCommentParams struct { + ID string + ProjectID string +} + +func (q *Queries) ResolveProjectComment(ctx context.Context, arg ResolveProjectCommentParams) (ProjectComment, error) { + row := q.db.QueryRow(ctx, resolveProjectComment, arg.ID, arg.ProjectID) + var i ProjectComment + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.TargetID, + &i.Comment, + &i.CommenterID, + &i.Resolved, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const unresolveProjectComment = `-- name: UnresolveProjectComment :one +UPDATE project_comments +SET + resolved = false, + updated_at = extract(epoch from now()) +WHERE id = $1 AND project_id = $2 +RETURNING id, project_id, target_id, comment, commenter_id, resolved, created_at, updated_at +` + +type UnresolveProjectCommentParams struct { + ID string + ProjectID string +} + +func (q *Queries) UnresolveProjectComment(ctx context.Context, arg UnresolveProjectCommentParams) (ProjectComment, error) { + row := q.db.QueryRow(ctx, unresolveProjectComment, arg.ID, arg.ProjectID) + var i ProjectComment + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.TargetID, + &i.Comment, + &i.CommenterID, + &i.Resolved, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const updateProjectAnswer = `-- name: UpdateProjectAnswer :one UPDATE project_answers SET @@ -531,6 +726,35 @@ func (q *Queries) UpdateProjectAnswer(ctx context.Context, arg UpdateProjectAnsw return i, err } +const updateProjectComment = `-- name: UpdateProjectComment :one +UPDATE project_comments +SET comment = $2, + updated_at = extract(epoch from now()) +WHERE id = $1 +RETURNING id, project_id, target_id, comment, commenter_id, resolved, created_at, updated_at +` + +type UpdateProjectCommentParams struct { + ID string + Comment string +} + +func (q *Queries) UpdateProjectComment(ctx context.Context, arg UpdateProjectCommentParams) (ProjectComment, error) { + row := q.db.QueryRow(ctx, updateProjectComment, arg.ID, arg.Comment) + var i ProjectComment + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.TargetID, + &i.Comment, + &i.CommenterID, + &i.Resolved, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const updateProjectStatus = `-- name: UpdateProjectStatus :exec UPDATE projects SET diff --git a/backend/internal/tests/comments_test.go b/backend/internal/tests/comments_test.go new file mode 100644 index 00000000..6654acaa --- /dev/null +++ b/backend/internal/tests/comments_test.go @@ -0,0 +1,332 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommentEndpoints(t *testing.T) { + // Setup test environment + setupEnv() + s := setupTestServer(t) + require.NotNil(t, s) + + ctx := context.Background() + + // Create test user and get auth token + userID, email, password, err := createTestUser(ctx, s) + require.NoError(t, err) + + // Update user role to admin and verify email + _, err = s.GetDB().Exec(ctx, ` + UPDATE users + SET role = 'admin', + email_verified = true + WHERE id = $1 + `, userID) + require.NoError(t, err) + + // Create test company with the user as owner + companyID := uuid.New() + _, err = s.GetDB().Exec(ctx, ` + INSERT INTO companies ( + id, + owner_id, + name, + linkedin_url, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, extract(epoch from now()), extract(epoch from now())) + `, companyID, userID, "Test Company", "https://linkedin.com/company/test-company") + require.NoError(t, err) + + // Create test project owned by the company + projectID := uuid.New() + _, err = s.GetDB().Exec(ctx, ` + INSERT INTO projects ( + id, + company_id, + title, + status, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, extract(epoch from now()), extract(epoch from now())) + `, projectID, companyID, "Test Project", "draft") + require.NoError(t, err) + + // Login to get access token + loginBody := map[string]string{ + "email": email, + "password": password, + } + jsonBody, err := json.Marshal(loginBody) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(jsonBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var loginResp map[string]interface{} + err = json.Unmarshal(rec.Body.Bytes(), &loginResp) + require.NoError(t, err) + accessToken := loginResp["access_token"].(string) + + // Create test comment + commentID := uuid.New() + targetID := uuid.New() // Store target_id for reuse + _, err = s.GetDB().Exec(ctx, ` + INSERT INTO project_comments ( + id, + project_id, + target_id, + comment, + commenter_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, extract(epoch from now()), extract(epoch from now())) + `, commentID, projectID, targetID, "Test comment", userID) + require.NoError(t, err) + + t.Run("Create Comment", func(t *testing.T) { + testCases := []struct { + name string + body map[string]interface{} + projectID string + expectedCode int + expectError bool + errorMessage string + }{ + { + name: "valid comment", + body: map[string]interface{}{ + "comment": "This is a test comment", + "target_id": targetID.String(), + }, + expectedCode: http.StatusCreated, + expectError: false, + }, + { + name: "empty comment", + body: map[string]interface{}{ + "comment": "", + "target_id": targetID.String(), + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorMessage: "Invalid request data", + }, + { + name: "invalid project ID", + body: map[string]interface{}{ + "comment": "Test comment", + "target_id": targetID.String(), + }, + projectID: uuid.New().String(), + expectedCode: http.StatusNotFound, + expectError: true, + errorMessage: "Project not found", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + jsonBody, err := json.Marshal(tc.body) + require.NoError(t, err) + + testProjectID := projectID.String() + if tc.projectID != "" { + testProjectID = tc.projectID + } + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/api/v1/project/%s/comments", testProjectID), + bytes.NewReader(jsonBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+accessToken) + + rec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + assert.Equal(t, tc.expectedCode, rec.Code) + + if tc.expectError { + var errResp map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &errResp) + assert.NoError(t, err) + assert.Contains(t, errResp["message"], tc.errorMessage) + } else { + var response map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, tc.body["comment"], response["comment"]) + } + }) + } + }) + + t.Run("Get Project Comments", func(t *testing.T) { + // Clear existing comments before test + _, err = s.GetDB().Exec(ctx, `DELETE FROM project_comments WHERE project_id = $1`, projectID) + require.NoError(t, err) + + // Create exactly 3 test comments + for i := 1; i <= 3; i++ { + commentID := uuid.New() + _, err := s.GetDB().Exec(ctx, ` + INSERT INTO project_comments ( + id, + project_id, + target_id, + comment, + commenter_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $6) + `, commentID, projectID, targetID, fmt.Sprintf("Test comment %d", i), userID, time.Now().Unix()) + require.NoError(t, err) + } + + req := httptest.NewRequest(http.MethodGet, + fmt.Sprintf("/api/v1/project/%s/comments", projectID), + nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+accessToken) + rec := httptest.NewRecorder() + + s.GetEcho().ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + var response struct { + Comments []struct { + ID string `json:"id"` + Comment string `json:"comment"` + CommenterID string `json:"commenter_id"` + CreatedAt int64 `json:"created_at"` + } `json:"comments"` + } + err := json.Unmarshal(rec.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Len(t, response.Comments, 3) + }) + + t.Run("Update Comment", func(t *testing.T) { + // Create a test comment + commentID := uuid.New() + targetID := uuid.New() // Add target_id + _, err := s.GetDB().Exec(ctx, ` + INSERT INTO project_comments ( + id, + project_id, + target_id, + comment, + commenter_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, extract(epoch from now()), extract(epoch from now())) + `, commentID, projectID, targetID, "Original comment", userID) + require.NoError(t, err) + + tests := []struct { + name string + commentID string + payload map[string]interface{} + expectedCode int + expectError bool + errorMessage string + }{ + { + name: "valid_update", + commentID: commentID.String(), + payload: map[string]interface{}{ + "comment": "Updated comment", + }, + expectedCode: http.StatusOK, + expectError: false, + }, + { + name: "empty_comment", + commentID: commentID.String(), + payload: map[string]interface{}{ + "comment": "", + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorMessage: "Invalid request", + }, + { + name: "non-existent_comment", + commentID: uuid.New().String(), + payload: map[string]interface{}{ + "comment": "Updated comment", + }, + expectedCode: http.StatusInternalServerError, + expectError: true, + errorMessage: "Failed to update comment", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + jsonBody, _ := json.Marshal(tc.payload) + req := httptest.NewRequest( + http.MethodPut, + fmt.Sprintf("/api/v1/project/%s/comments/%s", projectID, tc.commentID), + bytes.NewBuffer(jsonBody), + ) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+accessToken) + rec := httptest.NewRecorder() + + s.GetEcho().ServeHTTP(rec, req) + assert.Equal(t, tc.expectedCode, rec.Code) + + if tc.expectError { + var errResp map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &errResp) + assert.NoError(t, err) + assert.Contains(t, errResp["message"], tc.errorMessage) + } else { + var response map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Comment updated successfully", response["message"]) + } + }) + } + }) + + // Cleanup + defer func() { + // Delete comments first + _, err = s.GetDB().Exec(ctx, `DELETE FROM project_comments WHERE project_id = $1`, projectID) + require.NoError(t, err) + + // Delete project + _, err = s.GetDB().Exec(ctx, `DELETE FROM projects WHERE id = $1`, projectID) + require.NoError(t, err) + + // Delete company + _, err = s.GetDB().Exec(ctx, `DELETE FROM companies WHERE id = $1`, companyID) + require.NoError(t, err) + + // Delete user last + _, err = s.GetDB().Exec(ctx, `DELETE FROM users WHERE id = $1`, userID) + require.NoError(t, err) + }() +} \ No newline at end of file diff --git a/backend/internal/tests/helpers.go b/backend/internal/tests/helpers.go index ee9ceaac..57218d63 100644 --- a/backend/internal/tests/helpers.go +++ b/backend/internal/tests/helpers.go @@ -6,8 +6,14 @@ import ( "context" "time" "fmt" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "golang.org/x/crypto/bcrypt" ) @@ -115,3 +121,54 @@ func removeTestCompany(ctx context.Context, companyID string, s *server.Server) _, err := s.DBPool.Exec(ctx, "DELETE FROM companies WHERE id = $1", companyID) return err } + +func createTestAdminUser(ctx context.Context, s *server.Server) (string, string, string, error) { + userID := uuid.New().String() + email := fmt.Sprintf("admin_%s@test.com", uuid.New().String()) + password := "Test1234!" + + // Hash the password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", "", "", err + } + + // Create admin user directly in database + _, err = s.DBPool.Exec(ctx, ` + INSERT INTO users ( + id, + email, + password, + role, + email_verified, + token_salt + ) + VALUES ($1, $2, $3, $4, $5, gen_random_bytes(32))`, + userID, email, string(hashedPassword), db.UserRoleAdmin, true) + if err != nil { + return "", "", "", err + } + + return userID, email, password, nil +} + +func loginAndGetToken(t *testing.T, s *server.Server, email, password string) string { + loginBody := fmt.Sprintf(`{"email":"%s","password":"%s"}`, email, password) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Login should succeed") + + var loginResp map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&loginResp) + assert.NoError(t, err, "Should decode login response") + + accessToken, ok := loginResp["access_token"].(string) + assert.True(t, ok, "Response should contain access_token") + assert.NotEmpty(t, accessToken, "Access token should not be empty") + + return accessToken +} diff --git a/backend/internal/tests/projects_test.go b/backend/internal/tests/projects_test.go index e7f85ec2..015f2360 100644 --- a/backend/internal/tests/projects_test.go +++ b/backend/internal/tests/projects_test.go @@ -175,7 +175,7 @@ func TestProjectEndpoints(t *testing.T) { * 5. Verifies project status changes to 'pending' */ // First get the available questions - req := httptest.NewRequest(http.MethodGet, "/api/v1/questions", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/project/questions", nil) req.Header.Set("Authorization", "Bearer "+accessToken) rec := httptest.NewRecorder() s.GetEcho().ServeHTTP(rec, req) @@ -221,7 +221,7 @@ func TestProjectEndpoints(t *testing.T) { createReq := httptest.NewRequest( http.MethodPost, - fmt.Sprintf("/api/v1/project/%s/answer", projectID), + fmt.Sprintf("/api/v1/project/%s/answers", projectID), bytes.NewReader(createJSON), ) createReq.Header.Set("Authorization", "Bearer "+accessToken) @@ -355,7 +355,7 @@ func TestProjectEndpoints(t *testing.T) { { name: "Create Answer Without Question", method: http.MethodPost, - path: fmt.Sprintf("/api/v1/project/%s/answer", projectID), + path: fmt.Sprintf("/api/v1/project/%s/answers", projectID), body: `{"content": "some answer"}`, // Missing question_id setupAuth: func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+accessToken) @@ -366,7 +366,7 @@ func TestProjectEndpoints(t *testing.T) { { name: "Create Answer For Invalid Question", method: http.MethodPost, - path: fmt.Sprintf("/api/v1/project/%s/answer", projectID), + path: fmt.Sprintf("/api/v1/project/%s/answers", projectID), body: `{"content": "some answer", "question_id": "invalid-id"}`, setupAuth: func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+accessToken) @@ -412,4 +412,66 @@ func TestProjectEndpoints(t *testing.T) { }) } }) + + t.Run("Comment Resolution", func(t *testing.T) { + // Create an admin user for testing + _, adminEmail, adminPassword, err := createTestAdminUser(ctx, s) + assert.NoError(t, err) + defer removeTestUser(ctx, adminEmail, s) + + // Login as admin + adminToken := loginAndGetToken(t, s, adminEmail, adminPassword) + + // Create a test comment first + commentBody := fmt.Sprintf(`{ + "comment": "Test comment", + "target_id": "%s" + }`, projectID) + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/api/v1/project/%s/comments", projectID), + strings.NewReader(commentBody)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusCreated, rec.Code) + + var commentResp map[string]interface{} + err = json.NewDecoder(rec.Body).Decode(&commentResp) + assert.NoError(t, err) + + commentID := commentResp["id"].(string) + + // Test resolving the comment + req = httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/api/v1/project/%s/comments/%s/resolve", projectID, commentID), + nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec = httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + // Test unresolving the comment + req = httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/api/v1/project/%s/comments/%s/unresolve", projectID, commentID), + nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec = httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + // Test non-admin cannot resolve/unresolve + req = httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/api/v1/project/%s/comments/%s/resolve", projectID, commentID), + nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + rec = httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code) + }) } \ No newline at end of file diff --git a/backend/internal/v1/v1_projects/comments.go b/backend/internal/v1/v1_projects/comments.go index 93c02c8d..ec839122 100644 --- a/backend/internal/v1/v1_projects/comments.go +++ b/backend/internal/v1/v1_projects/comments.go @@ -1 +1,245 @@ package v1_projects + +import ( + "KonferCA/SPUR/db" + "KonferCA/SPUR/internal/v1/v1_common" + "github.com/labstack/echo/v4" + "net/http" + "database/sql" +) + +/* + * handleGetProjectComments retrieves all comments for a project. + * + * Security: + * - Verifies project belongs to user's company + * - Returns 404 if project not found + */ +func (h *Handler) handleGetProjectComments(c echo.Context) error { + // Get project ID from URL + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, http.StatusBadRequest, "Project ID is required", nil) + } + + // Verify project exists using admin query + project, err := h.server.GetQueries().GetProjectByIDAdmin(c.Request().Context(), projectID) + if err != nil { + return v1_common.Fail(c, http.StatusNotFound, "Project not found", err) + } + + comments, err := h.server.GetQueries().GetProjectComments(c.Request().Context(), project.ID) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to get project comments", err) + } + + // Convert to response format + response := make([]CommentResponse, len(comments)) + for i, comment := range comments { + response[i] = CommentResponse{ + ID: comment.ID, + ProjectID: comment.ProjectID, + TargetID: comment.TargetID, + Comment: comment.Comment, + CommenterID: comment.CommenterID, + CreatedAt: comment.CreatedAt, + UpdatedAt: comment.UpdatedAt, + } + } + + return c.JSON(http.StatusOK, CommentsResponse{Comments: response}) +} + +/* + * handleGetProjectComment retrieves a single comment by ID. + * + * Security: + * - Verifies project belongs to user's company + * - Returns 404 if comment not found + */ +func (h *Handler) handleGetProjectComment(c echo.Context) error { + // Get project and comment IDs from URL + projectID := c.Param("id") + commentID := c.Param("comment_id") + if projectID == "" || commentID == "" { + return v1_common.Fail(c, http.StatusBadRequest, "Project ID and Comment ID are required", nil) + } + + // Verify project exists using admin query + project, err := h.server.GetQueries().GetProjectByIDAdmin(c.Request().Context(), projectID) + if err != nil { + return v1_common.Fail(c, http.StatusNotFound, "Project not found", err) + } + + // Get specific comment + comment, err := h.server.GetQueries().GetProjectComment(c.Request().Context(), db.GetProjectCommentParams{ + ID: commentID, + ProjectID: project.ID, + }) + if err != nil { + return v1_common.Fail(c, http.StatusNotFound, "Comment not found", err) + } + + response := CommentResponse{ + ID: comment.ID, + ProjectID: comment.ProjectID, + TargetID: comment.TargetID, + Comment: comment.Comment, + CommenterID: comment.CommenterID, + CreatedAt: comment.CreatedAt, + UpdatedAt: comment.UpdatedAt, + } + + return c.JSON(http.StatusOK, response) +} + +func (h *Handler) handleCreateProjectComment(c echo.Context) error { + // Get authenticated admin + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get project ID from URL + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, http.StatusBadRequest, "Project ID is required", nil) + } + + // Verify project exists using admin query + project, err := h.server.GetQueries().GetProjectByIDAdmin(c.Request().Context(), projectID) + if err != nil { + return v1_common.Fail(c, http.StatusNotFound, "Project not found", err) + } + + // Parse request body + var req CreateCommentRequest + if err := c.Bind(&req); err != nil { + return v1_common.Fail(c, http.StatusBadRequest, "Invalid request body", err) + } + + // Validate request + if err := c.Validate(&req); err != nil { + return v1_common.Fail(c, http.StatusBadRequest, "Invalid request data", err) + } + + // Create comment + comment, err := h.server.GetQueries().CreateProjectComment(c.Request().Context(), db.CreateProjectCommentParams{ + ProjectID: project.ID, + TargetID: req.TargetID, + Comment: req.Comment, + CommenterID: user.ID, + }) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to create comment", err) + } + + response := CommentResponse{ + ID: comment.ID, + ProjectID: comment.ProjectID, + TargetID: comment.TargetID, + Comment: comment.Comment, + CommenterID: comment.CommenterID, + CreatedAt: comment.CreatedAt, + UpdatedAt: comment.UpdatedAt, + } + + return c.JSON(http.StatusCreated, response) +} + +func (h *Handler) handleUpdateProjectComment(c echo.Context) error { + var req UpdateCommentRequest + if err := v1_common.BindandValidate(c, &req); err != nil { + return v1_common.Fail(c, http.StatusBadRequest, "Invalid request", err) + } + + // Get IDs from URL + projectID := c.Param("id") + commentID := c.Param("comment_id") + if projectID == "" || commentID == "" { + return v1_common.Fail(c, http.StatusBadRequest, "Project ID and Comment ID are required", nil) + } + + // Verify project exists using admin query + _, err := h.server.GetQueries().GetProjectByIDAdmin(c.Request().Context(), projectID) + if err != nil { + return v1_common.Fail(c, http.StatusNotFound, "Project not found", err) + } + + // Update the comment + _, err = h.server.GetQueries().UpdateProjectComment(c.Request().Context(), db.UpdateProjectCommentParams{ + ID: commentID, + Comment: req.Comment, + }) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to update comment", err) + } + + return c.JSON(http.StatusOK, map[string]string{ + "message": "Comment updated successfully", + }) +} + +func (h *Handler) handleResolveComment(c echo.Context) error { + // Get IDs from URL + projectID := c.Param("id") + commentID := c.Param("comment_id") + if projectID == "" || commentID == "" { + return v1_common.Fail(c, http.StatusBadRequest, "Project ID and Comment ID are required", nil) + } + + // Resolve the comment + comment, err := h.server.GetQueries().ResolveProjectComment(c.Request().Context(), db.ResolveProjectCommentParams{ + ID: commentID, + ProjectID: projectID, + }) + if err != nil { + if err == sql.ErrNoRows { + return v1_common.Fail(c, http.StatusNotFound, "Comment not found", err) + } + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to resolve comment", err) + } + + return c.JSON(http.StatusOK, CommentResponse{ + ID: comment.ID, + ProjectID: comment.ProjectID, + TargetID: comment.TargetID, + Comment: comment.Comment, + CommenterID: comment.CommenterID, + Resolved: comment.Resolved, + CreatedAt: comment.CreatedAt, + UpdatedAt: comment.UpdatedAt, + }) +} + +func (h *Handler) handleUnresolveComment(c echo.Context) error { + // Get IDs from URL + projectID := c.Param("id") + commentID := c.Param("comment_id") + if projectID == "" || commentID == "" { + return v1_common.Fail(c, http.StatusBadRequest, "Project ID and Comment ID are required", nil) + } + + // Unresolve the comment + comment, err := h.server.GetQueries().UnresolveProjectComment(c.Request().Context(), db.UnresolveProjectCommentParams{ + ID: commentID, + ProjectID: projectID, + }) + if err != nil { + if err == sql.ErrNoRows { + return v1_common.Fail(c, http.StatusNotFound, "Comment not found", err) + } + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to unresolve comment", err) + } + + return c.JSON(http.StatusOK, CommentResponse{ + ID: comment.ID, + ProjectID: comment.ProjectID, + TargetID: comment.TargetID, + Comment: comment.Comment, + CommenterID: comment.CommenterID, + Resolved: comment.Resolved, + CreatedAt: comment.CreatedAt, + UpdatedAt: comment.UpdatedAt, + }) +} \ No newline at end of file diff --git a/backend/internal/v1/v1_projects/routes.go b/backend/internal/v1/v1_projects/routes.go index 835f34ed..d306bdaa 100644 --- a/backend/internal/v1/v1_projects/routes.go +++ b/backend/internal/v1/v1_projects/routes.go @@ -10,22 +10,25 @@ import ( func SetupRoutes(g *echo.Group, s interfaces.CoreServer) { h := &Handler{server: s} - // Base project routes + // Base project routes with auth projects := g.Group("/project", middleware.AuthWithConfig(middleware.AuthConfig{ AcceptTokenType: "access_token", AcceptUserRoles: []db.UserRole{db.UserRoleStartupOwner, db.UserRoleAdmin}, }, s.GetDB())) - // Project management + // Static routes + projects.GET("/questions", h.handleGetQuestions) projects.POST("/new", h.handleCreateProject) projects.GET("", h.handleListCompanyProjects) + + // Dynamic :id routes projects.GET("/:id", h.handleGetProject) projects.POST("/:id/submit", h.handleSubmitProject) // Project answers answers := projects.Group("/:id/answers") answers.GET("", h.handleGetProjectAnswers) - projects.POST("/:id/answer", h.handleCreateAnswer) + answers.POST("", h.handleCreateAnswer) answers.PATCH("", h.handlePatchProjectAnswer) // Project documents @@ -47,5 +50,16 @@ func SetupRoutes(g *echo.Group, s interfaces.CoreServer) { docs.GET("", h.handleGetProjectDocuments) docs.DELETE("/:document_id", h.handleDeleteProjectDocument) - g.GET("/questions", h.handleGetQuestions) + // Project comments + comments := projects.Group("/:id/comments", middleware.AuthWithConfig(middleware.AuthConfig{ + AcceptTokenType: "access_token", + AcceptUserRoles: []db.UserRole{db.UserRoleAdmin}, + }, s.GetDB())) + + comments.GET("", h.handleGetProjectComments) + comments.GET("/:comment_id", h.handleGetProjectComment) + comments.POST("", h.handleCreateProjectComment) + comments.PUT("/:comment_id", h.handleUpdateProjectComment) + comments.POST("/:comment_id/resolve", h.handleResolveComment) + comments.POST("/:comment_id/unresolve", h.handleUnresolveComment) } diff --git a/backend/internal/v1/v1_projects/types.go b/backend/internal/v1/v1_projects/types.go index 7152d989..73e6f057 100644 --- a/backend/internal/v1/v1_projects/types.go +++ b/backend/internal/v1/v1_projects/types.go @@ -89,4 +89,28 @@ type AnswerResponse struct { Answer string `json:"answer"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` -} \ No newline at end of file +} + +type CommentResponse struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + TargetID string `json:"target_id"` + Comment string `json:"comment"` + CommenterID string `json:"commenter_id"` + Resolved bool `json:"resolved"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type CommentsResponse struct { + Comments []CommentResponse `json:"comments"` +} + +type CreateCommentRequest struct { + Comment string `json:"comment" validate:"required"` + TargetID string `json:"target_id" validate:"required,uuid"` +} + +type UpdateCommentRequest struct { + Comment string `json:"comment" validate:"required"` +} diff --git a/bruno/projects/create-project-comment.bru b/bruno/projects/create-project-comment.bru new file mode 100644 index 00000000..e2ba1ab7 --- /dev/null +++ b/bruno/projects/create-project-comment.bru @@ -0,0 +1,45 @@ +meta { + name: Create Project Comment + type: http + seq: 11 +} + +post { + url: {{baseUrl}}/project/{{project_id}}/comments + body: json + auth: none +} + +headers { + Authorization: Bearer {{access_token}} + Content-Type: application/json +} + +body:json { + { + "target_id": "{{project_id}}", + "comment": "This is a test comment" + } +} + +tests { + test("should create project comment", function() { + expect(res.status).to.equal(201); + + // Check all required fields + expect(res.body).to.have.property("id"); + expect(res.body).to.have.property("project_id"); + expect(res.body).to.have.property("target_id"); + expect(res.body).to.have.property("comment"); + expect(res.body).to.have.property("commenter_id"); + expect(res.body).to.have.property("created_at"); + expect(res.body).to.have.property("updated_at"); + + // Verify data matches request + expect(res.body.project_id).to.equal(bru.getVar("project_id")); + expect(res.body.comment).to.equal("This is a test comment"); + + // Save comment ID for update test + bru.setVar("comment_id", res.body.id); + }); +} diff --git a/bruno/projects/get-project-comment.bru b/bruno/projects/get-project-comment.bru new file mode 100644 index 00000000..076a600c --- /dev/null +++ b/bruno/projects/get-project-comment.bru @@ -0,0 +1,32 @@ +meta { + name: Get Project Comment + type: http + seq: 10 +} + +get { + url: {{baseUrl}}/project/{{project_id}}/comments/{{comment_id}} +} + +headers { + Authorization: Bearer {{access_token}} +} + +tests { + test("should get specific project comment", function() { + expect(res.status).to.equal(200); + + // Check all required fields + expect(res.body).to.have.property("id"); + expect(res.body).to.have.property("project_id"); + expect(res.body).to.have.property("target_id"); + expect(res.body).to.have.property("comment"); + expect(res.body).to.have.property("commenter_id"); + expect(res.body).to.have.property("created_at"); + expect(res.body).to.have.property("updated_at"); + + // Verify it's the comment we expect + expect(res.body.id).to.equal(bru.getVar("comment_id")); + expect(res.body.project_id).to.equal(bru.getVar("project_id")); + }); +} \ No newline at end of file diff --git a/bruno/projects/get-project-comments.bru b/bruno/projects/get-project-comments.bru new file mode 100644 index 00000000..65cd5f7a --- /dev/null +++ b/bruno/projects/get-project-comments.bru @@ -0,0 +1,38 @@ +meta { + name: Get Project Comments + type: http + seq: 9 +} + +get { + url: {{baseUrl}}/project/{{project_id}}/comments +} + +headers { + Authorization: Bearer {{access_token}} +} + +tests { + test("should get project comments", function() { + expect(res.status).to.equal(200); + + // Check response structure + expect(res.body).to.have.property("comments"); + expect(res.body.comments).to.be.an("array"); + + // If comments exist, verify structure of first comment + if (res.body.comments.length > 0) { + const comment = res.body.comments[0]; + expect(comment).to.have.property("id"); + expect(comment).to.have.property("project_id"); + expect(comment).to.have.property("target_id"); + expect(comment).to.have.property("comment"); + expect(comment).to.have.property("commenter_id"); + expect(comment).to.have.property("created_at"); + expect(comment).to.have.property("updated_at"); + + // Save first comment ID for single comment test + bru.setVar("comment_id", comment.id); + } + }); +} \ No newline at end of file diff --git a/bruno/projects/get-project-questions.bru b/bruno/projects/get-project-questions.bru new file mode 100644 index 00000000..5898d3f4 --- /dev/null +++ b/bruno/projects/get-project-questions.bru @@ -0,0 +1,32 @@ +meta { + name: Get Project Questions + type: http + seq: 12 +} + +get { + url: {{baseUrl}}/project/questions + body: none + auth: none +} + +headers { + Authorization: Bearer {{access_token}} +} + +tests { + test("should return project questions", function() { + expect(res.status).to.equal(200); + expect(res.body).to.have.property("questions"); + expect(res.body.questions).to.be.an("array"); + + // Verify question structure if any exist + if (res.body.questions.length > 0) { + const question = res.body.questions[0]; + expect(question).to.have.property("id"); + expect(question).to.have.property("text"); + expect(question).to.have.property("required"); + expect(question).to.have.property("order"); + } + }); +} diff --git a/bruno/projects/update-project-comment.bru b/bruno/projects/update-project-comment.bru new file mode 100644 index 00000000..49e02961 --- /dev/null +++ b/bruno/projects/update-project-comment.bru @@ -0,0 +1,42 @@ +meta { + name: Update Project Comment + type: http + seq: 12 +} + +put { + url: {{baseUrl}}/project/{{project_id}}/comments/{{comment_id}} + body: json + auth: none +} + +headers { + Authorization: Bearer {{access_token}} + Content-Type: application/json +} + +body:json { + { + "comment": "This is an updated comment" + } +} + +tests { + test("should update project comment", function() { + expect(res.status).to.equal(200); + + // Check all required fields + expect(res.body).to.have.property("id"); + expect(res.body).to.have.property("project_id"); + expect(res.body).to.have.property("target_id"); + expect(res.body).to.have.property("comment"); + expect(res.body).to.have.property("commenter_id"); + expect(res.body).to.have.property("created_at"); + expect(res.body).to.have.property("updated_at"); + + // Verify data matches request + expect(res.body.id).to.equal(bru.getVar("comment_id")); + expect(res.body.project_id).to.equal(bru.getVar("project_id")); + expect(res.body.comment).to.equal("This is an updated comment"); + }); +}