diff --git a/backend/.sqlc/migrations/20241215194302_initial_schema.sql b/backend/.sqlc/migrations/20241215194302_initial_schema.sql index b7592654..be5d78ef 100644 --- a/backend/.sqlc/migrations/20241215194302_initial_schema.sql +++ b/backend/.sqlc/migrations/20241215194302_initial_schema.sql @@ -69,13 +69,15 @@ CREATE TABLE IF NOT EXISTS projects ( updated_at bigint NOT NULL DEFAULT extract(epoch from now()) ); -CREATE TABLE IF NOT EXISTS project_questions ( +CREATE TABLE project_questions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), question varchar NOT NULL, section varchar NOT NULL DEFAULT 'overall', + required boolean NOT NULL DEFAULT false, + validations varchar, created_at bigint NOT NULL DEFAULT extract(epoch from now()), updated_at bigint NOT NULL DEFAULT extract(epoch from now()) -); +); CREATE TABLE IF NOT EXISTS project_answers ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/backend/.sqlc/migrations/20241226195458_add_default_questions.sql b/backend/.sqlc/migrations/20241226195458_add_default_questions.sql new file mode 100644 index 00000000..b1b711cb --- /dev/null +++ b/backend/.sqlc/migrations/20241226195458_add_default_questions.sql @@ -0,0 +1,26 @@ +-- +goose Up +INSERT INTO project_questions (id, question, section, required, validations) VALUES + ( + gen_random_uuid(), + 'What is the core product or service, and what problem does it solve?', + 'business_overview', + true, + 'min=100' -- Warning if less than 100 chars + ), + ( + gen_random_uuid(), + 'What is the unique value proposition?', + 'business_overview', + true, + 'min=50' -- Warning if less than 50 chars + ), + ( + gen_random_uuid(), + 'Company website', + 'business_overview', + true, + 'url' -- Error if not valid URL + ); + +-- +goose Down +DELETE FROM project_questions WHERE section = 'business_overview'; \ No newline at end of file diff --git a/backend/.sqlc/queries/projects.sql b/backend/.sqlc/queries/projects.sql new file mode 100644 index 00000000..803bd36d --- /dev/null +++ b/backend/.sqlc/queries/projects.sql @@ -0,0 +1,136 @@ +-- name: GetCompanyByUserID :one +SELECT * FROM companies +WHERE owner_id = $1 +LIMIT 1; + +-- name: CreateProject :one +INSERT INTO projects ( + company_id, + title, + description, + status, + created_at, + updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6 +) RETURNING *; + +-- name: GetProjectsByCompanyID :many +SELECT * FROM projects +WHERE company_id = $1 +ORDER BY created_at DESC; + +-- name: GetProjectByID :one +SELECT * FROM projects +WHERE id = $1 AND company_id = $2 +LIMIT 1; + +-- name: UpdateProjectAnswer :one +UPDATE project_answers +SET + answer = $1, + updated_at = extract(epoch from now()) +WHERE + project_answers.id = $2 + AND project_id = $3 +RETURNING *; + +-- name: GetProjectAnswers :many +SELECT + pa.id as answer_id, + pa.answer, + pq.id as question_id, + pq.question, + pq.section +FROM project_answers pa +JOIN project_questions pq ON pa.question_id = pq.id +WHERE pa.project_id = $1 +ORDER BY pq.section, pq.id; + +-- name: CreateProjectAnswers :many +INSERT INTO project_answers (id, project_id, question_id, answer) +SELECT + gen_random_uuid(), + $1, -- project_id + pq.id, + '' -- empty default answer +FROM project_questions pq +RETURNING *; + +-- name: CreateProjectDocument :one +INSERT INTO project_documents ( + id, + project_id, + name, + url, + section, + created_at, + updated_at +) VALUES ( + gen_random_uuid(), + $1, -- project_id + $2, -- name + $3, -- url + $4, -- section + extract(epoch from now()), + extract(epoch from now()) +) RETURNING *; + +-- name: GetProjectDocuments :many +SELECT * FROM project_documents +WHERE project_id = $1 +ORDER BY created_at DESC; + +-- name: DeleteProjectDocument :one +DELETE FROM project_documents +WHERE project_documents.id = $1 +AND project_documents.project_id = $2 +AND project_documents.project_id IN ( + SELECT projects.id + FROM projects + WHERE projects.company_id = $3 +) +RETURNING id; + +-- name: GetProjectDocument :one +SELECT project_documents.* FROM project_documents +JOIN projects ON project_documents.project_id = projects.id +WHERE project_documents.id = $1 +AND project_documents.project_id = $2 +AND projects.company_id = $3; + +-- name: ListCompanyProjects :many +SELECT projects.* FROM projects +WHERE company_id = $1 +ORDER BY created_at DESC; + +-- name: GetProjectQuestions :many +SELECT id, question, section, required, validations FROM project_questions; + +-- name: UpdateProjectStatus :exec +UPDATE projects +SET + status = $1, + updated_at = extract(epoch from now()) +WHERE id = $2; + +-- name: GetQuestionByAnswerID :one +SELECT q.* FROM project_questions q +JOIN project_answers a ON a.question_id = q.id +WHERE a.id = $1; + +-- name: GetProjectQuestion :one +SELECT * FROM project_questions +WHERE id = $1 +LIMIT 1; + +-- name: CreateProjectAnswer :one +INSERT INTO project_answers ( + project_id, + question_id, + answer +) VALUES ( + $1, -- project_id + $2, -- question_id + $3 -- answer +) RETURNING *; \ No newline at end of file diff --git a/backend/db/models.go b/backend/db/models.go index f265a02b..8481bd6b 100644 --- a/backend/db/models.go +++ b/backend/db/models.go @@ -189,11 +189,13 @@ type ProjectDocument struct { } type ProjectQuestion struct { - ID string - Question string - Section string - CreatedAt int64 - UpdatedAt int64 + ID string + Question string + Section string + Required bool + Validations *string + CreatedAt int64 + UpdatedAt int64 } type TeamMember struct { diff --git a/backend/db/projects.sql.go b/backend/db/projects.sql.go new file mode 100644 index 00000000..1e31c7f5 --- /dev/null +++ b/backend/db/projects.sql.go @@ -0,0 +1,550 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: projects.sql + +package db + +import ( + "context" +) + +const createProject = `-- name: CreateProject :one +INSERT INTO projects ( + company_id, + title, + description, + status, + created_at, + updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6 +) RETURNING id, company_id, title, description, status, created_at, updated_at +` + +type CreateProjectParams struct { + CompanyID string + Title string + Description *string + Status ProjectStatus + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) { + row := q.db.QueryRow(ctx, createProject, + arg.CompanyID, + arg.Title, + arg.Description, + arg.Status, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Project + err := row.Scan( + &i.ID, + &i.CompanyID, + &i.Title, + &i.Description, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createProjectAnswer = `-- name: CreateProjectAnswer :one +INSERT INTO project_answers ( + project_id, + question_id, + answer +) VALUES ( + $1, -- project_id + $2, -- question_id + $3 -- answer +) RETURNING id, project_id, question_id, answer, created_at, updated_at +` + +type CreateProjectAnswerParams struct { + ProjectID string + QuestionID string + Answer string +} + +func (q *Queries) CreateProjectAnswer(ctx context.Context, arg CreateProjectAnswerParams) (ProjectAnswer, error) { + row := q.db.QueryRow(ctx, createProjectAnswer, arg.ProjectID, arg.QuestionID, arg.Answer) + var i ProjectAnswer + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.QuestionID, + &i.Answer, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createProjectAnswers = `-- name: CreateProjectAnswers :many +INSERT INTO project_answers (id, project_id, question_id, answer) +SELECT + gen_random_uuid(), + $1, -- project_id + pq.id, + '' -- empty default answer +FROM project_questions pq +RETURNING id, project_id, question_id, answer, created_at, updated_at +` + +func (q *Queries) CreateProjectAnswers(ctx context.Context, projectID string) ([]ProjectAnswer, error) { + rows, err := q.db.Query(ctx, createProjectAnswers, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectAnswer + for rows.Next() { + var i ProjectAnswer + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.QuestionID, + &i.Answer, + &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 createProjectDocument = `-- name: CreateProjectDocument :one +INSERT INTO project_documents ( + id, + project_id, + name, + url, + section, + created_at, + updated_at +) VALUES ( + gen_random_uuid(), + $1, -- project_id + $2, -- name + $3, -- url + $4, -- section + extract(epoch from now()), + extract(epoch from now()) +) RETURNING id, project_id, name, url, section, created_at, updated_at +` + +type CreateProjectDocumentParams struct { + ProjectID string + Name string + Url string + Section string +} + +func (q *Queries) CreateProjectDocument(ctx context.Context, arg CreateProjectDocumentParams) (ProjectDocument, error) { + row := q.db.QueryRow(ctx, createProjectDocument, + arg.ProjectID, + arg.Name, + arg.Url, + arg.Section, + ) + var i ProjectDocument + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Url, + &i.Section, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteProjectDocument = `-- name: DeleteProjectDocument :one +DELETE FROM project_documents +WHERE project_documents.id = $1 +AND project_documents.project_id = $2 +AND project_documents.project_id IN ( + SELECT projects.id + FROM projects + WHERE projects.company_id = $3 +) +RETURNING id +` + +type DeleteProjectDocumentParams struct { + ID string + ProjectID string + CompanyID string +} + +func (q *Queries) DeleteProjectDocument(ctx context.Context, arg DeleteProjectDocumentParams) (string, error) { + row := q.db.QueryRow(ctx, deleteProjectDocument, arg.ID, arg.ProjectID, arg.CompanyID) + var id string + err := row.Scan(&id) + return id, err +} + +const getCompanyByUserID = `-- name: GetCompanyByUserID :one +SELECT id, owner_id, name, wallet_address, linkedin_url, created_at, updated_at FROM companies +WHERE owner_id = $1 +LIMIT 1 +` + +func (q *Queries) GetCompanyByUserID(ctx context.Context, ownerID string) (Company, error) { + row := q.db.QueryRow(ctx, getCompanyByUserID, ownerID) + var i Company + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.Name, + &i.WalletAddress, + &i.LinkedinUrl, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getProjectAnswers = `-- name: GetProjectAnswers :many +SELECT + pa.id as answer_id, + pa.answer, + pq.id as question_id, + pq.question, + pq.section +FROM project_answers pa +JOIN project_questions pq ON pa.question_id = pq.id +WHERE pa.project_id = $1 +ORDER BY pq.section, pq.id +` + +type GetProjectAnswersRow struct { + AnswerID string + Answer string + QuestionID string + Question string + Section string +} + +func (q *Queries) GetProjectAnswers(ctx context.Context, projectID string) ([]GetProjectAnswersRow, error) { + rows, err := q.db.Query(ctx, getProjectAnswers, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetProjectAnswersRow + for rows.Next() { + var i GetProjectAnswersRow + if err := rows.Scan( + &i.AnswerID, + &i.Answer, + &i.QuestionID, + &i.Question, + &i.Section, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getProjectByID = `-- name: GetProjectByID :one +SELECT id, company_id, title, description, status, created_at, updated_at FROM projects +WHERE id = $1 AND company_id = $2 +LIMIT 1 +` + +type GetProjectByIDParams struct { + ID string + CompanyID string +} + +func (q *Queries) GetProjectByID(ctx context.Context, arg GetProjectByIDParams) (Project, error) { + row := q.db.QueryRow(ctx, getProjectByID, arg.ID, arg.CompanyID) + var i Project + err := row.Scan( + &i.ID, + &i.CompanyID, + &i.Title, + &i.Description, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +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 +WHERE project_documents.id = $1 +AND project_documents.project_id = $2 +AND projects.company_id = $3 +` + +type GetProjectDocumentParams struct { + ID string + ProjectID string + CompanyID string +} + +func (q *Queries) GetProjectDocument(ctx context.Context, arg GetProjectDocumentParams) (ProjectDocument, error) { + row := q.db.QueryRow(ctx, getProjectDocument, arg.ID, arg.ProjectID, arg.CompanyID) + var i ProjectDocument + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Url, + &i.Section, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getProjectDocuments = `-- name: GetProjectDocuments :many +SELECT id, project_id, name, url, section, created_at, updated_at FROM project_documents +WHERE project_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) GetProjectDocuments(ctx context.Context, projectID string) ([]ProjectDocument, error) { + rows, err := q.db.Query(ctx, getProjectDocuments, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectDocument + for rows.Next() { + var i ProjectDocument + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Url, + &i.Section, + &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 getProjectQuestion = `-- name: GetProjectQuestion :one +SELECT id, question, section, required, validations, created_at, updated_at FROM project_questions +WHERE id = $1 +LIMIT 1 +` + +func (q *Queries) GetProjectQuestion(ctx context.Context, id string) (ProjectQuestion, error) { + row := q.db.QueryRow(ctx, getProjectQuestion, id) + var i ProjectQuestion + err := row.Scan( + &i.ID, + &i.Question, + &i.Section, + &i.Required, + &i.Validations, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getProjectQuestions = `-- name: GetProjectQuestions :many +SELECT id, question, section, required, validations FROM project_questions +` + +type GetProjectQuestionsRow struct { + ID string + Question string + Section string + Required bool + Validations *string +} + +func (q *Queries) GetProjectQuestions(ctx context.Context) ([]GetProjectQuestionsRow, error) { + rows, err := q.db.Query(ctx, getProjectQuestions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetProjectQuestionsRow + for rows.Next() { + var i GetProjectQuestionsRow + if err := rows.Scan( + &i.ID, + &i.Question, + &i.Section, + &i.Required, + &i.Validations, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getProjectsByCompanyID = `-- name: GetProjectsByCompanyID :many +SELECT id, company_id, title, description, status, created_at, updated_at FROM projects +WHERE company_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) GetProjectsByCompanyID(ctx context.Context, companyID string) ([]Project, error) { + rows, err := q.db.Query(ctx, getProjectsByCompanyID, companyID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Project + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.CompanyID, + &i.Title, + &i.Description, + &i.Status, + &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 getQuestionByAnswerID = `-- name: GetQuestionByAnswerID :one +SELECT q.id, q.question, q.section, q.required, q.validations, q.created_at, q.updated_at FROM project_questions q +JOIN project_answers a ON a.question_id = q.id +WHERE a.id = $1 +` + +func (q *Queries) GetQuestionByAnswerID(ctx context.Context, id string) (ProjectQuestion, error) { + row := q.db.QueryRow(ctx, getQuestionByAnswerID, id) + var i ProjectQuestion + err := row.Scan( + &i.ID, + &i.Question, + &i.Section, + &i.Required, + &i.Validations, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listCompanyProjects = `-- name: ListCompanyProjects :many +SELECT projects.id, projects.company_id, projects.title, projects.description, projects.status, projects.created_at, projects.updated_at FROM projects +WHERE company_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListCompanyProjects(ctx context.Context, companyID string) ([]Project, error) { + rows, err := q.db.Query(ctx, listCompanyProjects, companyID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Project + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.CompanyID, + &i.Title, + &i.Description, + &i.Status, + &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 updateProjectAnswer = `-- name: UpdateProjectAnswer :one +UPDATE project_answers +SET + answer = $1, + updated_at = extract(epoch from now()) +WHERE + project_answers.id = $2 + AND project_id = $3 +RETURNING id, project_id, question_id, answer, created_at, updated_at +` + +type UpdateProjectAnswerParams struct { + Answer string + ID string + ProjectID string +} + +func (q *Queries) UpdateProjectAnswer(ctx context.Context, arg UpdateProjectAnswerParams) (ProjectAnswer, error) { + row := q.db.QueryRow(ctx, updateProjectAnswer, arg.Answer, arg.ID, arg.ProjectID) + var i ProjectAnswer + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.QuestionID, + &i.Answer, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateProjectStatus = `-- name: UpdateProjectStatus :exec +UPDATE projects +SET + status = $1, + updated_at = extract(epoch from now()) +WHERE id = $2 +` + +type UpdateProjectStatusParams struct { + Status ProjectStatus + ID string +} + +func (q *Queries) UpdateProjectStatus(ctx context.Context, arg UpdateProjectStatusParams) error { + _, err := q.db.Exec(ctx, updateProjectStatus, arg.Status, arg.ID) + return err +} diff --git a/backend/internal/middleware/jwt.go b/backend/internal/middleware/jwt.go index ff62155b..60926efa 100644 --- a/backend/internal/middleware/jwt.go +++ b/backend/internal/middleware/jwt.go @@ -37,41 +37,43 @@ func AuthWithConfig(config AuthConfig, dbPool *pgxpool.Pool) echo.MiddlewareFunc return v1_common.Fail(c, http.StatusUnauthorized, "invalid authorization format", nil) } - // get user salt from db using claims + // Parse claims without verification first claims, err := jwt.ParseUnverifiedClaims(parts[1]) if err != nil { return v1_common.Fail(c, http.StatusUnauthorized, "invalid token", err) } - // validate token type - if claims.TokenType != config.AcceptTokenType { - return v1_common.Fail(c, http.StatusUnauthorized, "invalid token type", nil) - } - - // check if user role is allowed - roleValid := false - for _, role := range config.AcceptUserRoles { - if claims.Role.Valid() && claims.Role == role { - roleValid = true - break - } - } - if !roleValid { - return v1_common.Fail(c, http.StatusForbidden, "insufficient permissions", nil) - } - - // get user's token salt and user data from db + // Get user's salt from database user, err := queries.GetUserByID(c.Request().Context(), claims.UserID) if err != nil { return v1_common.Fail(c, http.StatusUnauthorized, "invalid token", nil) } - // verify token with user's salt + // Verify token with user's salt claims, err = jwt.VerifyTokenWithSalt(parts[1], user.TokenSalt) if err != nil { return v1_common.Fail(c, http.StatusUnauthorized, "invalid token", nil) } + // Verify token type + if claims.TokenType != config.AcceptTokenType { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid token type") + } + + // Verify user role if roles specified + if len(config.AcceptUserRoles) > 0 { + validRole := false + for _, role := range config.AcceptUserRoles { + if claims.Role == role { + validRole = true + break + } + } + if !validRole { + return echo.NewHTTPError(http.StatusForbidden, "insufficient permissions") + } + } + // store claims and user in context for handlers c.Set("claims", &AuthClaims{ JWTClaims: claims, diff --git a/backend/internal/tests/helpers.go b/backend/internal/tests/helpers.go index 9edf1179..ee9ceaac 100644 --- a/backend/internal/tests/helpers.go +++ b/backend/internal/tests/helpers.go @@ -5,8 +5,10 @@ import ( "KonferCA/SPUR/internal/server" "context" "time" + "fmt" "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" ) /* @@ -16,19 +18,27 @@ The function returns userID, email, password, error */ func createTestUser(ctx context.Context, s *server.Server) (string, string, string, error) { userID := uuid.New().String() - email := "test@mail.com" + email := fmt.Sprintf("test-%s@mail.com", uuid.New().String()) password := "password" - _, 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, "hashedpassword", db.UserRoleStartupOwner, false) + + // Hash the password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", "", "", err + } + + _, 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.UserRoleStartupOwner, false) + return userID, email, password, err } @@ -57,11 +67,11 @@ if the test doesn't remove it by default, such as the verify email handler. */ func createTestEmailToken(ctx context.Context, userID string, exp time.Time, s *server.Server) (string, error) { row := s.DBPool.QueryRow(ctx, ` - INSERT INTO verify_email_tokens ( - user_id, - expires_at - ) - VALUES ($1, $2) RETURNING id;`, + INSERT INTO verify_email_tokens ( + user_id, + expires_at + ) + VALUES ($1, $2) RETURNING id;`, userID, exp.Unix()) var tokenID string err := row.Scan(&tokenID) @@ -76,3 +86,32 @@ func removeEmailToken(ctx context.Context, tokenID string, s *server.Server) err _, err := s.DBPool.Exec(ctx, "DELETE FROM verify_email_tokens WHERE id = $1", tokenID) return err } + +/* +Creates a test company for the given user. Remember to clean up after tests. +Returns companyID, error +*/ +func createTestCompany(ctx context.Context, s *server.Server, userID string) (string, error) { + companyID := uuid.New().String() + + _, err := s.DBPool.Exec(ctx, ` + INSERT INTO companies ( + id, + name, + wallet_address, + linkedin_url, + owner_id + ) + VALUES ($1, $2, $3, $4, $5)`, + companyID, "Test Company", "0x123", "https://linkedin.com/test", userID) + + return companyID, err +} + +/* +Removes a test company from the database. +*/ +func removeTestCompany(ctx context.Context, companyID string, s *server.Server) error { + _, err := s.DBPool.Exec(ctx, "DELETE FROM companies WHERE id = $1", companyID) + return err +} diff --git a/backend/internal/tests/projects_test.go b/backend/internal/tests/projects_test.go new file mode 100644 index 00000000..e7f85ec2 --- /dev/null +++ b/backend/internal/tests/projects_test.go @@ -0,0 +1,415 @@ +package tests + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + "bytes" + + "KonferCA/SPUR/db" + "KonferCA/SPUR/internal/server" + "github.com/stretchr/testify/assert" +) + +/* + * TestProjectEndpoints tests the complete project lifecycle and error cases + * for the project-related API endpoints. It covers: + * - Project creation + * - Project listing + * - Project retrieval + * - Project submission including answering questions + * - Error handling for various invalid scenarios + * + * The test creates a verified user and company first, then runs + * through the project workflows using that test data. + */ +func TestProjectEndpoints(t *testing.T) { + // Setup test environment + setupEnv() + + s, err := server.New() + assert.NoError(t, err) + + // Create test user and get auth token + ctx := context.Background() + userID, email, password, err := createTestUser(ctx, s) + assert.NoError(t, err) + t.Logf("Created test user - ID: %s, Email: %s, Password: %s", userID, email, password) + defer removeTestUser(ctx, email, s) + + + // Verify the user exists and check their status + user, err := s.GetQueries().GetUserByEmail(ctx, email) + assert.NoError(t, err, "Should find user in database") + t.Logf("User from DB - ID: %s, Email: %s, EmailVerified: %v", user.ID, user.Email, user.EmailVerified) + + // Directly verify email in database + err = s.GetQueries().UpdateUserEmailVerifiedStatus(ctx, db.UpdateUserEmailVerifiedStatusParams{ + ID: userID, + EmailVerified: true, + }) + assert.NoError(t, err, "Should update email verification status") + + // Verify the update worked + user, err = s.GetQueries().GetUserByEmail(ctx, email) + assert.NoError(t, err) + assert.True(t, user.EmailVerified, "User's email should be verified") + t.Logf("User after verification - ID: %s, Email: %s, EmailVerified: %v", user.ID, user.Email, user.EmailVerified) + + // Wait a moment to ensure DB updates are complete + time.Sleep(100 * time.Millisecond) + + // Login + loginBody := fmt.Sprintf(`{"email":"%s","password":"%s"}`, email, password) + t.Logf("Attempting login with body: %s", loginBody) + + 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) + + if !assert.Equal(t, http.StatusOK, rec.Code, "Login should succeed") { + t.Logf("Login response body: %s", rec.Body.String()) + t.FailNow() + } + + // Parse login response + 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") + + // Create a company for the user + companyID, err := createTestCompany(ctx, s, userID) + assert.NoError(t, err, "Should create test company") + defer removeTestCompany(ctx, companyID, s) + t.Logf("Created test company - ID: %s", companyID) + + // Variable to store project ID for subsequent tests + var projectID string + + t.Run("Create Project", func(t *testing.T) { + /* + * "Create Project" test verifies: + * - Project creation with valid data + * - Response contains valid project ID + * - Project is associated with correct company + */ + projectBody := fmt.Sprintf(`{ + "company_id": "%s", + "title": "Test Project", + "description": "A test project", + "name": "Test Project" + }`, companyID) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/project/new", strings.NewReader(projectBody)) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + if !assert.Equal(t, http.StatusOK, rec.Code) { + t.Logf("Create project response: %s", rec.Body.String()) + t.FailNow() + } + + var resp map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&resp) + assert.NoError(t, err) + + var ok bool + projectID, ok = resp["id"].(string) + assert.True(t, ok, "Response should contain project ID") + assert.NotEmpty(t, projectID, "Project ID should not be empty") + }) + + t.Run("List Projects", func(t *testing.T) { + /* + * "List Projects" test verifies: + * - Endpoint returns 200 OK + * - User can see their projects + */ + req := httptest.NewRequest(http.MethodGet, "/api/v1/project", nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + rec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("Get Project", func(t *testing.T) { + /* + * "Get Project" test verifies: + * - Single project retrieval works + * - Project details are accessible + */ + path := fmt.Sprintf("/api/v1/project/%s", projectID) + t.Logf("Getting project at path: %s", path) + + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + rec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + if !assert.Equal(t, http.StatusOK, rec.Code) { + t.Logf("Get project response: %s", rec.Body.String()) + } + }) + + t.Run("Submit Project", func(t *testing.T) { + /* + * "Submit Project" test verifies the complete submission flow: + * 1. Creates initial project + * 2. Creates answers for required questions + * 3. Updates each answer with valid data + * 4. Submits the completed project + * 5. Verifies project status changes to 'pending' + */ + // First get the available questions + req := httptest.NewRequest(http.MethodGet, "/api/v1/questions", nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + rec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + if !assert.Equal(t, http.StatusOK, rec.Code) { + t.Logf("Get questions response: %s", rec.Body.String()) + t.FailNow() + } + + var questionsResp struct { + Questions []struct { + ID string `json:"id"` + Question string `json:"question"` + Section string `json:"section"` + } `json:"questions"` + } + err := json.NewDecoder(rec.Body).Decode(&questionsResp) + assert.NoError(t, err) + assert.NotEmpty(t, questionsResp.Questions, "Should have questions available") + + // Create answers for each question + for _, q := range questionsResp.Questions { + var answer string + switch q.Question { + case "Company website": + answer = "https://example.com" + case "What is the core product or service, and what problem does it solve?": + answer = "Our product is a revolutionary blockchain-based authentication system that solves critical identity verification issues in the digital age. We provide a secure, scalable solution that eliminates fraud while maintaining user privacy and compliance with international regulations." + case "What is the unique value proposition?": + answer = "Our product is a revolutionary blockchain-based authentication system that solves critical identity verification issues in the digital age. We provide a secure, scalable solution that eliminates fraud while maintaining user privacy and compliance with international regulations." + default: + continue // Skip non-required questions + } + + // Create the answer + createBody := map[string]interface{}{ + "content": answer, + "project_id": projectID, + "question_id": q.ID, + } + createJSON, err := json.Marshal(createBody) + assert.NoError(t, err) + + createReq := httptest.NewRequest( + http.MethodPost, + fmt.Sprintf("/api/v1/project/%s/answer", projectID), + bytes.NewReader(createJSON), + ) + createReq.Header.Set("Authorization", "Bearer "+accessToken) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(createRec, createReq) + + if !assert.Equal(t, http.StatusOK, createRec.Code) { + t.Logf("Create answer response: %s", createRec.Body.String()) + } + } + + // Now submit the project + submitPath := fmt.Sprintf("/api/v1/project/%s/submit", projectID) + req = httptest.NewRequest(http.MethodPost, submitPath, nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + rec = httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + t.Logf("Submit response: %s", rec.Body.String()) + + if !assert.Equal(t, http.StatusOK, rec.Code) { + t.Logf("Submit project response: %s", rec.Body.String()) + } + + var submitResp struct { + Message string `json:"message"` + Status string `json:"status"` + } + err = json.NewDecoder(rec.Body).Decode(&submitResp) + assert.NoError(t, err) + assert.Equal(t, "Project submitted successfully", submitResp.Message) + assert.Equal(t, "pending", submitResp.Status) + }) + + /* + * "Error Cases" test suite verifies proper error handling: + * - Invalid project ID returns 404 + * - Unauthorized access returns 401 + * - Short answers fail validation + * - Invalid URL format fails validation + * + * Uses real question/answer IDs from the project to ensure + * accurate validation testing. + */ + t.Run("Error Cases", func(t *testing.T) { + // First get the questions/answers to get real IDs + path := fmt.Sprintf("/api/v1/project/%s/answers", projectID) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + rec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(rec, req) + + var answersResp struct { + Answers []struct { + ID string `json:"id"` + QuestionID string `json:"question_id"` + Question string `json:"question"` + } `json:"answers"` + } + err := json.NewDecoder(rec.Body).Decode(&answersResp) + assert.NoError(t, err) + + // Find answer ID for the core product question (which has min length validation) + var coreQuestionAnswerID string + var websiteQuestionAnswerID string + for _, a := range answersResp.Answers { + if strings.Contains(a.Question, "core product") { + coreQuestionAnswerID = a.ID + } + if strings.Contains(a.Question, "website") { + websiteQuestionAnswerID = a.ID + } + } + + // Ensure we found the questions we need + assert.NotEmpty(t, coreQuestionAnswerID, "Should find core product question") + assert.NotEmpty(t, websiteQuestionAnswerID, "Should find website question") + + tests := []struct { + name string + method string + path string + body string + setupAuth func(*http.Request) + expectedCode int + expectedError string + }{ + { + name: "Get Invalid Project", + method: http.MethodGet, + path: "/api/v1/project/invalid-id", + setupAuth: func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+accessToken) + }, + expectedCode: http.StatusNotFound, + expectedError: "Project not found", + }, + { + name: "Unauthorized Access", + method: http.MethodGet, + path: fmt.Sprintf("/api/v1/project/%s", projectID), + setupAuth: func(req *http.Request) { + // No auth header + }, + expectedCode: http.StatusUnauthorized, + expectedError: "missing authorization header", + }, + { + name: "Invalid Answer Length", + method: http.MethodPatch, + path: fmt.Sprintf("/api/v1/project/%s/answers", projectID), + body: fmt.Sprintf(`{"content": "too short", "answer_id": "%s"}`, coreQuestionAnswerID), + setupAuth: func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+accessToken) + }, + expectedCode: http.StatusBadRequest, + expectedError: "Must be at least", + }, + { + name: "Invalid URL Format", + method: http.MethodPatch, + path: fmt.Sprintf("/api/v1/project/%s/answers", projectID), + body: fmt.Sprintf(`{"content": "not-a-url", "answer_id": "%s"}`, websiteQuestionAnswerID), + setupAuth: func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+accessToken) + }, + expectedCode: http.StatusBadRequest, + expectedError: "Must be a valid URL", + }, + { + name: "Create Answer Without Question", + method: http.MethodPost, + path: fmt.Sprintf("/api/v1/project/%s/answer", projectID), + body: `{"content": "some answer"}`, // Missing question_id + setupAuth: func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+accessToken) + }, + expectedCode: http.StatusBadRequest, + expectedError: "Question ID is required", + }, + { + name: "Create Answer For Invalid Question", + method: http.MethodPost, + path: fmt.Sprintf("/api/v1/project/%s/answer", projectID), + body: `{"content": "some answer", "question_id": "invalid-id"}`, + setupAuth: func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+accessToken) + }, + expectedCode: http.StatusNotFound, + expectedError: "Question not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var body io.Reader + if tc.body != "" { + body = strings.NewReader(tc.body) + } + + req := httptest.NewRequest(tc.method, tc.path, body) + tc.setupAuth(req) + if tc.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + + s.GetEcho().ServeHTTP(rec, req) + + assert.Equal(t, tc.expectedCode, rec.Code) + + var errResp struct { + Message string `json:"message"` + ValidationErrors []struct { + Question string `json:"question"` + Message string `json:"message"` + } `json:"validation_errors"` + } + err := json.NewDecoder(rec.Body).Decode(&errResp) + assert.NoError(t, err) + + if len(errResp.ValidationErrors) > 0 { + assert.Contains(t, errResp.ValidationErrors[0].Message, tc.expectedError) + } else { + assert.Contains(t, errResp.Message, tc.expectedError) + } + }) + } + }) +} \ No newline at end of file diff --git a/backend/internal/tests/server_test.go b/backend/internal/tests/server_test.go index 26c93cfa..3946f049 100644 --- a/backend/internal/tests/server_test.go +++ b/backend/internal/tests/server_test.go @@ -17,11 +17,11 @@ import ( "testing" "time" - "github.com/PuerkitoBio/goquery" golangJWT "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" + "github.com/PuerkitoBio/goquery" ) /* @@ -36,6 +36,17 @@ func TestServer(t *testing.T) { s, err := server.New() assert.Nil(t, err) + // Add cleanup after all tests + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + // Remove any test users that might be left + _, err := s.DBPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test-%@mail.com'") + if err != nil { + t.Logf("Failed to cleanup test users: %v", err) + } + }) + t.Run("Test API V1 Health Check Route", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) rec := httptest.NewRecorder() @@ -54,7 +65,7 @@ func TestServer(t *testing.T) { t.Run("Test API V1 Auth Routes", func(t *testing.T) { t.Run("/auth/ami-verified - 200 OK", func(t *testing.T) { - email := "test@mail.com" + email := fmt.Sprintf("test-ami-verified-%s@mail.com", uuid.New().String()) ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() @@ -111,8 +122,8 @@ func TestServer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - // create request body - email := "test@mail.com" + // create request body with unique email + email := fmt.Sprintf("test-register-%s@mail.com", uuid.New().String()) password := "mypassword" reqBody := map[string]string{ "email": email, @@ -124,6 +135,7 @@ func TestServer(t *testing.T) { reader := bytes.NewReader(reqBodyBytes) req := httptest.NewRequest(http.MethodPost, url, reader) req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() s.Echo.ServeHTTP(rec, req) @@ -205,8 +217,8 @@ func TestServer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - // create request body - email := "test@mail.com" + // create request body with unique email + email := fmt.Sprintf("test-verify-%s@mail.com", uuid.New().String()) password := "mypassword" reqBody := map[string]string{ @@ -276,19 +288,35 @@ func TestServer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - // create new user to generate jwt - userID, email, _, err := createTestUser(ctx, s) - defer removeTestUser(ctx, email, s) + // Create unique email + email := fmt.Sprintf("test-verify-expire-%s@mail.com", uuid.New().String()) + password := "testpassword123" + + // Register user first + reqBody := map[string]string{ + "email": email, + "password": password, + } + reqBodyBytes, err := json.Marshal(reqBody) + assert.NoError(t, err) + + reader := bytes.NewReader(reqBodyBytes) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", reader) + req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + s.Echo.ServeHTTP(rec, req) + assert.Equal(t, http.StatusCreated, rec.Code) - salt, err := getTestUserTokenSalt(ctx, email, s) + // Get user from database + var user db.User + err = s.DBPool.QueryRow(ctx, "SELECT id, role, token_salt FROM users WHERE email = $1", email).Scan(&user.ID, &user.Role, &user.TokenSalt) assert.NoError(t, err) - // this way we can control the exp since the inner function is not exported - // set the time to expired in two days + // Create about-to-expire refresh token exp := time.Now().UTC().Add(2 * 24 * time.Hour) claims := jwt.JWTClaims{ - UserID: userID, - Role: db.UserRoleStartupOwner, + UserID: user.ID, + Role: user.Role, TokenType: jwt.REFRESH_TOKEN_TYPE, RegisteredClaims: golangJWT.RegisteredClaims{ ExpiresAt: golangJWT.NewNumericDate(exp), @@ -297,8 +325,7 @@ func TestServer(t *testing.T) { } token := golangJWT.NewWithClaims(golangJWT.SigningMethodHS256, claims) - // combine base secret with user's salt - secret := append([]byte(os.Getenv("JWT_SECRET")), salt...) + secret := append([]byte(os.Getenv("JWT_SECRET")), user.TokenSalt...) signed, err := token.SignedString(secret) assert.NoError(t, err) @@ -307,18 +334,17 @@ func TestServer(t *testing.T) { Value: signed, } - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify", nil) + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify", nil) req.AddCookie(&cookie) - rec := httptest.NewRecorder() + rec = httptest.NewRecorder() s.Echo.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - // should return an access token + // Verify response var resBody v1_auth.AuthResponse err = json.Unmarshal(rec.Body.Bytes(), &resBody) assert.NoError(t, err) - assert.NotEmpty(t, resBody.AccessToken) // should include a new refresh token cookie @@ -333,6 +359,10 @@ func TestServer(t *testing.T) { assert.NotNil(t, refreshCookie) assert.Equal(t, refreshCookie.Name, v1_auth.COOKIE_REFRESH_TOKEN) + + // Cleanup + err = removeTestUser(ctx, email, s) + assert.NoError(t, err) }) t.Run("/api/v1/auth/verify - 401 UNAUTHORIZED - missing cookie in request", func(t *testing.T) { @@ -383,19 +413,40 @@ func TestServer(t *testing.T) { t.Run("/auth/verify-email - 200 OK - valid email token", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() - userID, email, _, err := createTestUser(ctx, s) - assert.Nil(t, err) - defer removeTestUser(ctx, email, s) - // generate a test email token + // Create user with unique email + email := fmt.Sprintf("test-verify-email-%s@mail.com", uuid.New().String()) + password := "testpassword123" + + // Register user first + reqBody := map[string]string{ + "email": email, + "password": password, + } + reqBodyBytes, err := json.Marshal(reqBody) + assert.NoError(t, err) + + reader := bytes.NewReader(reqBodyBytes) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", reader) + req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + s.Echo.ServeHTTP(rec, req) + assert.Equal(t, http.StatusCreated, rec.Code) + + // Get user from database + var user db.User + err = s.DBPool.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&user.ID) + assert.NoError(t, err) + + // Generate test email token exp := time.Now().Add(time.Minute * 30).UTC() - tokenID, err := createTestEmailToken(ctx, userID, exp, s) + tokenID, err := createTestEmailToken(ctx, user.ID, exp, s) assert.Nil(t, err) tokenStr, err := jwt.GenerateVerifyEmailToken(email, tokenID, exp) assert.Nil(t, err) - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/auth/verify-email?token=%s", tokenStr), nil) - rec := httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/auth/verify-email?token=%s", tokenStr), nil) + rec = httptest.NewRecorder() s.Echo.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) @@ -410,6 +461,10 @@ func TestServer(t *testing.T) { assert.Equal(t, 1, icon.Length()) button := doc.Find(`[data-testid="go-to-dashboard"]`) assert.Equal(t, 1, button.Length()) + + // Cleanup + err = removeTestUser(ctx, email, s) + assert.NoError(t, err) }) t.Run("/auth/verify-email - missing token query parameter", func(t *testing.T) { @@ -417,7 +472,6 @@ func TestServer(t *testing.T) { rec := httptest.NewRecorder() s.Echo.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, rec.Code) doc, err := goquery.NewDocumentFromReader(rec.Body) @@ -435,19 +489,20 @@ func TestServer(t *testing.T) { t.Run("/auth/verify-email - deny expired email token", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() + userID, email, _, err := createTestUser(ctx, s) - assert.Nil(t, err) - defer removeTestUser(ctx, email, s) + assert.NoError(t, err) + // Generate expired email token using helper exp := time.Now().Add(-(time.Minute * 30)).UTC() tokenID, err := createTestEmailToken(ctx, userID, exp, s) assert.Nil(t, err) tokenStr, err := jwt.GenerateVerifyEmailToken(email, tokenID, exp) assert.Nil(t, err) + // Test the expired token req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/auth/verify-email?token=%s", tokenStr), nil) rec := httptest.NewRecorder() - s.Echo.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) @@ -461,15 +516,16 @@ func TestServer(t *testing.T) { assert.Equal(t, 1, icon.Length()) button := doc.Find(`[data-testid="go-to-dashboard"]`) assert.Equal(t, 1, button.Length()) + + // Cleanup + err = removeTestUser(ctx, email, s) + assert.NoError(t, err) }) t.Run("/api/v1/auth/logout - 200 OK - successfully logout", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - - // register user - email := "test@mail.com" - password := "mypassword123" + // Register user with unique email + email := fmt.Sprintf("test-logout-%s@mail.com", uuid.New().String()) + password := "testpassword123" authReq := v1_auth.AuthRequest{ Email: email, Password: password, @@ -482,7 +538,6 @@ func TestServer(t *testing.T) { s.Echo.ServeHTTP(rec, req) assert.Equal(t, http.StatusCreated, rec.Code) - defer removeTestUser(ctx, email, s) // get the cookie cookies := rec.Result().Cookies() diff --git a/backend/internal/v1/setup.go b/backend/internal/v1/setup.go index fd8695bd..c77314fd 100644 --- a/backend/internal/v1/setup.go +++ b/backend/internal/v1/setup.go @@ -4,6 +4,7 @@ import ( "KonferCA/SPUR/internal/interfaces" "KonferCA/SPUR/internal/v1/v1_auth" "KonferCA/SPUR/internal/v1/v1_health" + "KonferCA/SPUR/internal/v1/v1_projects" "KonferCA/SPUR/internal/v1/v1_teams" ) @@ -12,5 +13,6 @@ func SetupRoutes(s interfaces.CoreServer) { g := e.Group("/api/v1") v1_health.SetupHealthcheckRoutes(g, s) v1_auth.SetupAuthRoutes(g, s) + v1_projects.SetupRoutes(g, s) v1_teams.SetupRoutes(g, s) } diff --git a/backend/internal/v1/v1_projects/answers.go b/backend/internal/v1/v1_projects/answers.go index 542e564d..93c02c8d 100644 --- a/backend/internal/v1/v1_projects/answers.go +++ b/backend/internal/v1/v1_projects/answers.go @@ -1 +1 @@ -package v1projects +package v1_projects diff --git a/backend/internal/v1/v1_projects/comments.go b/backend/internal/v1/v1_projects/comments.go index 542e564d..93c02c8d 100644 --- a/backend/internal/v1/v1_projects/comments.go +++ b/backend/internal/v1/v1_projects/comments.go @@ -1 +1 @@ -package v1projects +package v1_projects diff --git a/backend/internal/v1/v1_projects/documents.go b/backend/internal/v1/v1_projects/documents.go index 542e564d..93c02c8d 100644 --- a/backend/internal/v1/v1_projects/documents.go +++ b/backend/internal/v1/v1_projects/documents.go @@ -1 +1 @@ -package v1projects +package v1_projects diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index 542e564d..4f89263a 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -1 +1,788 @@ -package v1projects +package v1_projects + +import ( + "KonferCA/SPUR/db" + "KonferCA/SPUR/internal/v1/v1_common" + "github.com/labstack/echo/v4" + "github.com/google/uuid" + "time" + "database/sql" + "io" + "fmt" + "path/filepath" + "strings" + "os" + "net/http" +) + +/* + * Package v1_projects implements the project management endpoints for the SPUR API. + * It handles project creation, retrieval, document management, and submission workflows. + */ + +// Helper function to get validated user from context +func getUserFromContext(c echo.Context) (*db.GetUserByIDRow, error) { + userVal := c.Get("user") + if userVal == nil { + return nil, fmt.Errorf("user not found in context") + } + + user, ok := userVal.(*db.GetUserByIDRow) + if !ok { + return nil, fmt.Errorf("invalid user type in context") + } + + return user, nil +} + +func (h *Handler) handleCreateProject(c echo.Context) error { + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + var req CreateProjectRequest + if err := v1_common.BindandValidate(c, &req); err != nil { + return v1_common.Fail(c, 400, "Invalid request", err) + } + + // Create project + now := time.Now().Unix() + description := req.Description + project, err := h.server.GetQueries().CreateProject(c.Request().Context(), db.CreateProjectParams{ + CompanyID: company.ID, + Title: req.Title, + Description: &description, + Status: db.ProjectStatusDraft, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return v1_common.Fail(c, 500, "Failed to create project", err) + } + + return c.JSON(200, ProjectResponse{ + ID: project.ID, + Title: project.Title, + Description: description, + Status: project.Status, + CreatedAt: project.CreatedAt, + UpdatedAt: project.UpdatedAt, + }) +} + +/* + * handleGetProjects retrieves all projects for a company. + * + * Security: + * - Requires authenticated user + * - Only returns projects for user's company + * + * Returns array of ProjectResponse with basic project details + */ +func (h *Handler) handleGetProjects(c echo.Context) error { + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // Get all projects for this company + projects, err := h.server.GetQueries().GetProjectsByCompanyID(c.Request().Context(), company.ID) + if err != nil { + return v1_common.Fail(c, 500, "Failed to fetch projects", err) + } + + // Convert to response format + response := make([]ProjectResponse, len(projects)) + for i, project := range projects { + description := "" + if project.Description != nil { + description = *project.Description + } + + response[i] = ProjectResponse{ + ID: project.ID, + Title: project.Title, + Description: description, + Status: project.Status, + CreatedAt: project.CreatedAt, + UpdatedAt: project.UpdatedAt, + } + } + + return c.JSON(200, response) +} + +/* + * handleGetProject retrieves a single project by ID. + * + * Security: + * - Verifies project belongs to user's company + * - Returns 404 if project not found or unauthorized + */ +func (h *Handler) handleGetProject(c echo.Context) error { + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // Get project ID from URL + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, 400, "Project ID is required", nil) + } + + // Get project (with company ID check for security) + project, err := h.server.GetQueries().GetProjectByID(c.Request().Context(), db.GetProjectByIDParams{ + ID: projectID, + CompanyID: company.ID, + }) + if err != nil { + return v1_common.Fail(c, 404, "Project not found", err) + } + + // Convert to response format + description := "" + if project.Description != nil { + description = *project.Description + } + + return c.JSON(200, ProjectResponse{ + ID: project.ID, + Title: project.Title, + Description: description, + Status: project.Status, + CreatedAt: project.CreatedAt, + UpdatedAt: project.UpdatedAt, + }) +} + +/* + * handlePatchProjectAnswer updates an answer for a project question. + * + * Validation: + * - Validates answer content against question rules + * - Returns validation errors if content invalid + * + * Security: + * - Verifies project belongs to user's company + */ +func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { + // Validate static parameters first + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, http.StatusBadRequest, "Project ID is required", nil) + } + + // Parse and validate request body + var req PatchAnswerRequest + if err := c.Bind(&req); err != nil { + return v1_common.Fail(c, 400, "Invalid request body", err) + } + + // Get authenticated user + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get the question for this answer to check validations + question, err := h.server.GetQueries().GetQuestionByAnswerID(c.Request().Context(), req.AnswerID) + if err != nil { + return v1_common.Fail(c, 404, "Question not found", err) + } + + // Validate the answer content + if question.Validations != nil && *question.Validations != "" { + if !isValidAnswer(req.Content, *question.Validations) { + return c.JSON(http.StatusBadRequest, map[string]interface{}{ + "validation_errors": []ValidationError{ + { + Question: question.Question, + Message: getValidationMessage(*question.Validations), + }, + }, + }) + } + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // Get project and verify status + project, err := h.server.GetQueries().GetProjectByID(c.Request().Context(), db.GetProjectByIDParams{ + ID: projectID, + CompanyID: company.ID, + }) + if err != nil { + return v1_common.Fail(c, 404, "Project not found", err) + } + + // Only allow updates if project is in draft status + if project.Status != db.ProjectStatusDraft { + return v1_common.Fail(c, 400, "Project answers can only be updated while in draft status", nil) + } + + // Update the answer + _, err = h.server.GetQueries().UpdateProjectAnswer(c.Request().Context(), db.UpdateProjectAnswerParams{ + Answer: req.Content, + ID: req.AnswerID, + ProjectID: projectID, + }) + if err != nil { + if err == sql.ErrNoRows { + return v1_common.Fail(c, 404, "Answer not found", err) + } + return v1_common.Fail(c, 500, "Failed to update answer", err) + } + + return c.JSON(200, map[string]string{ + "message": "Answer updated successfully", + }) +} + +/* + * handleGetProjectAnswers retrieves all answers for a project. + * + * Returns: + * - Question ID and content + * - Current answer text + * - Question section + * + * Security: + * - Verifies project belongs to user's company + */ +func (h *Handler) handleGetProjectAnswers(c echo.Context) error { + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // Get project ID from URL + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, 400, "Project ID is required", nil) + } + + // Get project answers + answers, err := h.server.GetQueries().GetProjectAnswers(c.Request().Context(), projectID) + if err != nil { + return v1_common.Fail(c, 500, "Failed to get project answers", err) + } + + // Verify project belongs to company + _, err = h.server.GetQueries().GetProjectByID(c.Request().Context(), db.GetProjectByIDParams{ + ID: projectID, + CompanyID: company.ID, + }) + if err != nil { + return v1_common.Fail(c, 404, "Project not found", err) + } + + // Convert to response format + response := make([]ProjectAnswerResponse, len(answers)) + for i, a := range answers { + response[i] = ProjectAnswerResponse{ + ID: a.AnswerID, + QuestionID: a.QuestionID, + Question: a.Question, + Answer: a.Answer, + Section: a.Section, + } + } + + return c.JSON(200, map[string]interface{}{ + "answers": response, + }) +} + +/* + * handleUploadProjectDocument handles file uploads for a project. + * + * Flow: + * 1. Validates file presence + * 2. Verifies project ownership + * 3. Uploads file to S3 + * 4. Creates document record in database + * 5. Returns document details + * + * Cleanup: + * - Deletes S3 file if database insert fails + */ +func (h *Handler) handleUploadProjectDocument(c echo.Context) error { + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get file from request + file, err := c.FormFile("file") + if err != nil { + return v1_common.Fail(c, http.StatusBadRequest, "No file provided", err) + } + + // Get project ID from URL + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, 400, "Project ID is required", nil) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // Verify project belongs to company + _, err = h.server.GetQueries().GetProjectByID(c.Request().Context(), db.GetProjectByIDParams{ + ID: projectID, + CompanyID: company.ID, + }) + if err != nil { + return v1_common.Fail(c, 404, "Project not found", err) + } + + // Open the file + src, err := file.Open() + if err != nil { + return v1_common.Fail(c, 500, "Failed to open file", err) + } + defer src.Close() + + // Read file content + fileContent, err := io.ReadAll(src) + if err != nil { + return v1_common.Fail(c, 500, "Failed to read file", err) + } + + // Generate S3 key + fileExt := filepath.Ext(file.Filename) + s3Key := fmt.Sprintf("projects/%s/documents/%s%s", projectID, uuid.New().String(), fileExt) + + // Upload to S3 + fileURL, err := h.server.GetStorage().UploadFile(c.Request().Context(), s3Key, fileContent) + if err != nil { + return v1_common.Fail(c, 500, "Failed to upload file", err) + } + + // Save document record in database + doc, err := h.server.GetQueries().CreateProjectDocument(c.Request().Context(), db.CreateProjectDocumentParams{ + ProjectID: projectID, + Name: c.FormValue("name"), + Url: fileURL, + Section: c.FormValue("section"), + }) + if err != nil { + // Try to cleanup the uploaded file if database insert fails + _ = h.server.GetStorage().DeleteFile(c.Request().Context(), s3Key) + return v1_common.Fail(c, 500, "Failed to save document record", err) + } + + return c.JSON(201, DocumentResponse{ + ID: doc.ID, + Name: doc.Name, + URL: doc.Url, + Section: doc.Section, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + }) +} + +/* + * handleGetProjectDocuments retrieves all documents for a project. + * + * Returns: + * - Document ID, name, URL + * - Section assignment + * - Creation/update timestamps + * + * Security: + * - Verifies project belongs to user's company + */ +func (h *Handler) handleGetProjectDocuments(c echo.Context) error { + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // Get project ID from URL + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, 400, "Project ID is required", nil) + } + + // Verify project belongs to company + _, err = h.server.GetQueries().GetProjectByID(c.Request().Context(), db.GetProjectByIDParams{ + ID: projectID, + CompanyID: company.ID, + }) + if err != nil { + return v1_common.Fail(c, 404, "Project not found", err) + } + + // Get documents for this project + docs, err := h.server.GetQueries().GetProjectDocuments(c.Request().Context(), projectID) + if err != nil { + return v1_common.Fail(c, 500, "Failed to get documents", err) + } + + // Convert to response format + response := make([]DocumentResponse, len(docs)) + for i, doc := range docs { + response[i] = DocumentResponse{ + ID: doc.ID, + Name: doc.Name, + URL: doc.Url, + Section: doc.Section, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + } + } + + return c.JSON(200, map[string]interface{}{ + "documents": response, + }) +} + +/* + * handleDeleteProjectDocument removes a document from a project. + * + * Flow: + * 1. Verifies document ownership + * 2. Deletes file from S3 + * 3. Removes database record + * + * Security: + * - Verifies document belongs to user's project + */ +func (h *Handler) handleDeleteProjectDocument(c echo.Context) error { + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get project ID and document ID from URL + projectID := c.Param("id") + documentID := c.Param("document_id") + if projectID == "" || documentID == "" { + return v1_common.Fail(c, 400, "Project ID and Document ID are required", nil) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", nil) + } + + // First get the document to get its S3 URL + doc, err := h.server.GetQueries().GetProjectDocument(c.Request().Context(), db.GetProjectDocumentParams{ + ID: documentID, + ProjectID: projectID, + CompanyID: company.ID, + }) + if err != nil { + if err.Error() == "no rows in result set" { + return v1_common.Fail(c, 404, "Document not found", nil) + } + return v1_common.Fail(c, 500, "Failed to get document", nil) + } + + // Delete from S3 first + s3Key := strings.TrimPrefix(doc.Url, "https://"+os.Getenv("AWS_S3_BUCKET")+".s3.us-east-1.amazonaws.com/") + err = h.server.GetStorage().DeleteFile(c.Request().Context(), s3Key) + if err != nil { + return v1_common.Fail(c, 500, "Failed to delete file from storage", nil) + } + + // Then delete from database + deletedID, err := h.server.GetQueries().DeleteProjectDocument(c.Request().Context(), db.DeleteProjectDocumentParams{ + ID: documentID, + ProjectID: projectID, + CompanyID: company.ID, + }) + if err != nil { + if err.Error() == "no rows in result set" { + return v1_common.Fail(c, 404, "Document not found or already deleted", nil) + } + return v1_common.Fail(c, 500, "Failed to delete document", nil) + } + + if deletedID == "" { + return v1_common.Fail(c, 404, "Document not found or already deleted", nil) + } + + return c.JSON(200, map[string]string{ + "message": "Document deleted successfully", + }) +} + +/* + * handleListCompanyProjects lists all projects for a company. + * Similar to handleGetProjects but with different response format. + * + * Returns: + * - Array of projects under "projects" key + * - Basic project details including status + */ +func (h *Handler) handleListCompanyProjects(c echo.Context) error { + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // Get all projects for this company + projects, err := h.server.GetQueries().ListCompanyProjects(c.Request().Context(), company.ID) + if err != nil { + return v1_common.Fail(c, 500, "Failed to fetch projects", nil) + } + + // Convert to response format + response := make([]ProjectResponse, len(projects)) + for i, project := range projects { + description := "" + if project.Description != nil { + description = *project.Description + } + + response[i] = ProjectResponse{ + ID: project.ID, + Title: project.Title, + Description: description, + Status: project.Status, + CreatedAt: project.CreatedAt, + UpdatedAt: project.UpdatedAt, + } + } + + return c.JSON(200, map[string]interface{}{ + "projects": response, + }) +} + +/* + * handleSubmitProject handles project submission for review. + * + * Validation: + * 1. Verifies all required questions answered + * 2. Validates all answers against rules + * 3. Returns validation errors if any fail + * + * Flow: + * 1. Collects all project answers + * 2. Validates against question rules + * 3. Updates project status to 'pending' + * 4. Returns success with new status + */ +func (h *Handler) handleSubmitProject(c echo.Context) error { + user, err := getUserFromContext(c) + if err != nil { + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, http.StatusBadRequest, "Project ID is required", nil) + } + + // Verify project belongs to company and check status + project, err := h.server.GetQueries().GetProjectByID(c.Request().Context(), db.GetProjectByIDParams{ + ID: projectID, + CompanyID: company.ID, + }) + if err != nil { + return v1_common.Fail(c, http.StatusNotFound, "Project not found", err) + } + + // Only allow submission if project is in draft status + if project.Status != db.ProjectStatusDraft { + return v1_common.Fail(c, http.StatusBadRequest, "Only draft projects can be submitted", nil) + } + + // Get all questions and answers for this project + answers, err := h.server.GetQueries().GetProjectAnswers(c.Request().Context(), projectID) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to get project answers", err) + } + + // Get all questions + questions, err := h.server.GetQueries().GetProjectQuestions(c.Request().Context()) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to get project questions", err) + } + + var validationErrors []ValidationError + + // Create a map of question IDs to answers for easy lookup + answerMap := make(map[string]string) + for _, answer := range answers { + answerMap[answer.QuestionID] = answer.Answer + } + + // Validate each question + for _, question := range questions { + answer, exists := answerMap[question.ID] + + // Check if required question is answered + if question.Required && (!exists || answer == "") { + validationErrors = append(validationErrors, ValidationError{ + Question: question.Question, + Message: "This question requires an answer", + }) + continue + } + + // Skip validation if answer is empty and question is not required + if !exists || answer == "" { + continue + } + + // Validate answer against rules if validations exist + if question.Validations != nil && *question.Validations != "" { + if !isValidAnswer(answer, *question.Validations) { + validationErrors = append(validationErrors, ValidationError{ + Question: question.Question, + Message: getValidationMessage(*question.Validations), + }) + } + } + } + + // If there are any validation errors, return them + if len(validationErrors) > 0 { + return c.JSON(http.StatusBadRequest, map[string]interface{}{ + "message": "Project validation failed", + "validation_errors": validationErrors, + }) + } + + // Update project status to pending + err = h.server.GetQueries().UpdateProjectStatus(c.Request().Context(), db.UpdateProjectStatusParams{ + ID: projectID, + Status: db.ProjectStatusPending, + }) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to update project status", err) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "message": "Project submitted successfully", + "status": "pending", + }) +} + +/* + * handleGetQuestions returns all available project questions. + * Used by the frontend to: + * - Show all questions that need to be answered + * - Display which questions are required + * - Show validation rules for each question + * + * Returns: + * - Array of questions with their details + * - Each question includes: ID, text, section, required flag, validation rules + */ +func (h *Handler) handleGetQuestions(c echo.Context) error { + // Get all questions from database + questions, err := h.server.GetQueries().GetProjectQuestions(c.Request().Context()) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to get questions", err) + } + + // Return questions array + return c.JSON(http.StatusOK, map[string]interface{}{ + "questions": questions, + }) +} + +func (h *Handler) handleCreateAnswer(c echo.Context) error { + var req CreateAnswerRequest + + if err := v1_common.BindandValidate(c, &req); err != nil { + if strings.Contains(err.Error(), "required") { + return v1_common.Fail(c, http.StatusBadRequest, "Question ID is required", err) + } + return v1_common.Fail(c, http.StatusNotFound, "Question not found", 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 question exists and validate answer + question, err := h.server.GetQueries().GetProjectQuestion(c.Request().Context(), req.QuestionID) + if err != nil { + return v1_common.Fail(c, http.StatusNotFound, "Question not found", err) + } + + if question.Validations != nil { + if !isValidAnswer(req.Content, *question.Validations) { + return c.JSON(http.StatusBadRequest, map[string]interface{}{ + "validation_errors": []ValidationError{ + { + Question: question.Question, + Message: getValidationMessage(*question.Validations), + }, + }, + }) + } + } + + // Create the answer + answer, err := h.server.GetQueries().CreateProjectAnswer(c.Request().Context(), db.CreateProjectAnswerParams{ + ProjectID: projectID, + QuestionID: req.QuestionID, + Answer: req.Content, + }) + if err != nil { + return v1_common.Fail(c, http.StatusInternalServerError, "Failed to create answer", err) + } + + return c.JSON(http.StatusOK, answer) +} diff --git a/backend/internal/v1/v1_projects/questions.go b/backend/internal/v1/v1_projects/questions.go index 542e564d..93c02c8d 100644 --- a/backend/internal/v1/v1_projects/questions.go +++ b/backend/internal/v1/v1_projects/questions.go @@ -1 +1 @@ -package v1projects +package v1_projects diff --git a/backend/internal/v1/v1_projects/routes.go b/backend/internal/v1/v1_projects/routes.go index 542e564d..835f34ed 100644 --- a/backend/internal/v1/v1_projects/routes.go +++ b/backend/internal/v1/v1_projects/routes.go @@ -1 +1,51 @@ -package v1projects +package v1_projects + +import ( + "KonferCA/SPUR/db" + "KonferCA/SPUR/internal/interfaces" + "KonferCA/SPUR/internal/middleware" + "github.com/labstack/echo/v4" +) + +func SetupRoutes(g *echo.Group, s interfaces.CoreServer) { + h := &Handler{server: s} + + // Base project routes + projects := g.Group("/project", middleware.AuthWithConfig(middleware.AuthConfig{ + AcceptTokenType: "access_token", + AcceptUserRoles: []db.UserRole{db.UserRoleStartupOwner, db.UserRoleAdmin}, + }, s.GetDB())) + + // Project management + projects.POST("/new", h.handleCreateProject) + projects.GET("", h.handleListCompanyProjects) + 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.PATCH("", h.handlePatchProjectAnswer) + + // Project documents + docs := projects.Group("/:id/documents") + docs.POST("", h.handleUploadProjectDocument, middleware.FileCheck(middleware.FileConfig{ + MinSize: 1024, // 1KB minimum + MaxSize: 10 * 1024 * 1024, // 10MB maximum + AllowedTypes: []string{ + "application/pdf", + "application/msword", // .doc + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx + "application/vnd.ms-excel", // .xls + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx + "image/jpeg", + "image/png", + }, + StrictValidation: true, + })) + docs.GET("", h.handleGetProjectDocuments) + docs.DELETE("/:document_id", h.handleDeleteProjectDocument) + + g.GET("/questions", h.handleGetQuestions) +} diff --git a/backend/internal/v1/v1_projects/types.go b/backend/internal/v1/v1_projects/types.go index 542e564d..7152d989 100644 --- a/backend/internal/v1/v1_projects/types.go +++ b/backend/internal/v1/v1_projects/types.go @@ -1 +1,92 @@ -package v1projects +package v1_projects + +import ( + "KonferCA/SPUR/internal/interfaces" + "KonferCA/SPUR/db" +) + +type Handler struct { + server interfaces.CoreServer +} + +type CreateProjectRequest struct { + Title string `json:"title" validate:"required"` + Description string `json:"description" validate:"required"` +} + +type ProjectResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status db.ProjectStatus `json:"status"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type ProjectAnswerResponse struct { + ID string `json:"id"` + QuestionID string `json:"question_id"` + Question string `json:"question"` + Answer string `json:"answer"` + Section string `json:"section"` +} + +type PatchAnswerRequest struct { + Content string `json:"content" validate:"required"` + AnswerID string `json:"answer_id" validate:"required,uuid"` +} + +type UploadDocumentRequest struct { + Name string `json:"name" validate:"required"` + Section string `json:"section" validate:"required"` +} + +type DocumentResponse struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Section string `json:"section"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type ValidationResult struct { + IsValid bool `json:"is_valid"` + Level string `json:"level"` // "error" or "warning" + Message string `json:"message"` +} + +type SubmitProjectRequest struct { + Answers []AnswerSubmission `json:"answers" validate:"required,dive"` +} + +type AnswerSubmission struct { + QuestionID string `json:"question_id" validate:"required"` + Answer string `json:"answer" validate:"required"` +} + +type SubmitProjectResponse struct { + Message string `json:"message"` + Status db.ProjectStatus `json:"status"` +} + +// Request Types +type CreateAnswerRequest struct { + Content string `json:"content" validate:"required"` + QuestionID string `json:"question_id" validate:"required,uuid"` +} + +type ValidationError struct { + Question string `json:"question"` + Message string `json:"message"` +} + +// Response Types +type AnswerResponse struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + QuestionID string `json:"question_id"` + Answer string `json:"answer"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} \ No newline at end of file diff --git a/backend/internal/v1/v1_projects/validation.go b/backend/internal/v1/v1_projects/validation.go new file mode 100644 index 00000000..e8d6648e --- /dev/null +++ b/backend/internal/v1/v1_projects/validation.go @@ -0,0 +1,176 @@ +package v1_projects + +/* + * Package v1_projects provides validation utilities for project answers. + * This file implements the validation rules and message formatting + * for project question answers. + */ + +import ( + "net/url" + "strings" + "strconv" + "regexp" +) + +/* + * validationType defines a single validation rule. + * Each validation has: + * - Name: Rule identifier (e.g., "url", "email") + * - Validate: Function to check if answer meets rule + * - Message: Human-readable error message + */ +type validationType struct { + Name string + Validate func(string, string) bool // (answer, param) + Message string +} + +/* + * validationTypes defines all available validation rules. + * Each rule implements specific validation logic: + * + * url: Validates URL format using url.ParseRequestURI + * email: Checks for @ and . characters + * phone: Verifies at least 10 numeric digits + * min: Enforces minimum string length + * max: Enforces maximum string length + * regex: Matches against custom pattern + */ +var validationTypes = []validationType{ + { + Name: "url", + Validate: func(answer string, _ string) bool { + _, err := url.ParseRequestURI(answer) + return err == nil + }, + Message: "Must be a valid URL", + }, + { + Name: "email", + Validate: func(answer string, _ string) bool { + return strings.Contains(answer, "@") && strings.Contains(answer, ".") + }, + Message: "Must be a valid email address", + }, + { + Name: "phone", + Validate: func(answer string, _ string) bool { + cleaned := strings.Map(func(r rune) rune { + if r >= '0' && r <= '9' { + return r + } + return -1 + }, answer) + return len(cleaned) >= 10 + }, + Message: "Must be a valid phone number", + }, + { + Name: "min", + Validate: func(answer string, param string) bool { + minLen, err := strconv.Atoi(param) + if err != nil { + return false + } + return len(answer) >= minLen + }, + Message: "Must be at least %s characters long", + }, + { + Name: "max", + Validate: func(answer string, param string) bool { + maxLen, err := strconv.Atoi(param) + if err != nil { + return false + } + return len(answer) <= maxLen + }, + Message: "Must be at most %s characters long", + }, + { + Name: "regex", + Validate: func(answer string, pattern string) bool { + re, err := regexp.Compile(pattern) + if err != nil { + return false + } + return re.MatchString(answer) + }, + Message: "Must match the required format", + }, +} + +/* + * parseValidationRule splits a validation rule string into name and parameter. + * + * Examples: + * - "min=100" returns ("min", "100") + * - "url" returns ("url", "") + * - "regex=^[0-9]+$" returns ("regex", "^[0-9]+$") + */ +func parseValidationRule(rule string) (name string, param string) { + parts := strings.SplitN(rule, "=", 2) + name = strings.TrimSpace(parts[0]) + if len(parts) > 1 { + param = strings.TrimSpace(parts[1]) + } + return +} + +/* + * isValidAnswer checks if an answer meets all validation rules. + * + * Parameters: + * - answer: The user's answer text + * - validations: Comma-separated list of rules (e.g., "min=100,url") + * + * Returns: + * - true if answer passes all validations + * - false if any validation fails + */ +func isValidAnswer(answer string, validations string) bool { + rules := strings.Split(validations, ",") + + for _, rule := range rules { + name, param := parseValidationRule(rule) + for _, vType := range validationTypes { + if name == vType.Name && !vType.Validate(answer, param) { + return false + } + } + } + + return true +} + +/* + * getValidationMessage returns human-readable error for failed validation. + * + * Parameters: + * - validations: Comma-separated list of rules + * + * Returns: + * - Formatted error message with parameters substituted + * - Generic "Invalid input" if validation type not found + * + * Example: + * For "min=100", returns "Must be at least 100 characters long" + */ +func getValidationMessage(validations string) string { + rules := strings.Split(validations, ",") + + for _, rule := range rules { + name, param := parseValidationRule(rule) + for _, vType := range validationTypes { + if name == vType.Name { + if strings.Contains(vType.Message, "%s") { + return strings.Replace(vType.Message, "%s", param, 1) + } + return vType.Message + } + } + } + + return "Invalid input" +} \ No newline at end of file diff --git a/bruno/Register.bru b/bruno/auth/Register.bru similarity index 74% rename from bruno/Register.bru rename to bruno/auth/Register.bru index e0d3ef30..35a702bd 100644 --- a/bruno/Register.bru +++ b/bruno/auth/Register.bru @@ -5,14 +5,14 @@ meta { } post { - url: http://localhost:3000/user + url: {{baseUrl}}/auth/register body: json auth: none } body:json { { - "email": "my@mail.com", + "email": "test5@example.com", "username": "name", "password": "mysecurepassword", "role": "startup_owner" diff --git a/bruno/auth/login.bru b/bruno/auth/login.bru new file mode 100644 index 00000000..e92add83 --- /dev/null +++ b/bruno/auth/login.bru @@ -0,0 +1,35 @@ +meta { + name: Login + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/auth/login + body: json + auth: none +} + +headers { + Content-Type: application/json +} + +body:json { + { + "email": "test5@example.com", + "password": "mysecurepassword" + } +} + +tests { + test("should return success or unauthorized", function() { + expect([200, 401]).to.include(res.status); + + if (res.status === 200) { + expect(res.body.access_token).to.exist; + bru.setVar("access_token", res.body.access_token); + } else { + expect(res.body.message).to.exist; + } + }); +} diff --git a/bruno/bruno.json b/bruno/bruno.json index 7f26d936..149f873c 100644 --- a/bruno/bruno.json +++ b/bruno/bruno.json @@ -1,7 +1,8 @@ { "version": "1", - "name": "SPUR", + "name": "Spur", "type": "collection", + "folders": ["auth", "projects"], "ignore": [ "node_modules", ".git" diff --git a/bruno/environments/Dev.bru b/bruno/environments/Dev.bru index 17e2a83a..7753b1c9 100644 --- a/bruno/environments/Dev.bru +++ b/bruno/environments/Dev.bru @@ -1,3 +1,3 @@ vars { - URL: http://localhost:8080/api/v1 + baseUrl: http://localhost:8080/api/v1 } diff --git a/bruno/projects/create.bru b/bruno/projects/create.bru new file mode 100644 index 00000000..7f71813b --- /dev/null +++ b/bruno/projects/create.bru @@ -0,0 +1,47 @@ +meta { + name: Create Project + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/project/new + body: json + auth: none +} + +headers { + Content-Type: application/json + Authorization: Bearer {{access_token}} +} + +body:json { + { + "title": "Test Project via Bruno", + "description": "This is a test project created through Bruno automation" + } +} + +tests { + test("should create project successfully", function() { + expect(res.status).to.equal(200); + + // Direct property checks on response body + expect(res.body).to.have.property("id"); + expect(res.body).to.have.property("title"); + expect(res.body).to.have.property("description"); + expect(res.body).to.have.property("status"); + expect(res.body).to.have.property("created_at"); + expect(res.body).to.have.property("updated_at"); + + // Verify values + expect(res.body.title).to.equal("Test Project via Bruno"); + expect(res.body.description).to.equal("This is a test project created through Bruno automation"); + expect(res.body.status).to.equal("draft"); + + // Save project ID for future requests + if (res.body && res.body.id) { + bru.setVar("project_id", res.body.id); + } + }); +} \ No newline at end of file diff --git a/bruno/projects/delete-document.bru b/bruno/projects/delete-document.bru new file mode 100644 index 00000000..ac3790c6 --- /dev/null +++ b/bruno/projects/delete-document.bru @@ -0,0 +1,21 @@ +meta { + name: Delete Project Document + type: http + seq: 7 +} + +delete { + url: {{baseUrl}}/project/{{project_id}}/document/{{document_id}} +} + +headers { + Authorization: Bearer {{access_token}} +} + +tests { + // Check status code + res.status === 200 + + // Check response message + res.body.message === "Document deleted successfully" +} \ No newline at end of file diff --git a/bruno/projects/get-documents.bru b/bruno/projects/get-documents.bru new file mode 100644 index 00000000..44d53be3 --- /dev/null +++ b/bruno/projects/get-documents.bru @@ -0,0 +1,33 @@ +meta { + name: Get Project Documents + type: http + seq: 6 +} + +get { + url: {{baseUrl}}/project/{{project_id}}/documents +} + +headers { + Authorization: Bearer {{access_token}} +} + +tests { + // Check status code + res.status === 200 + + // Check response structure + res.body.documents !== undefined + Array.isArray(res.body.documents) + + // If documents exist, verify first document structure + if (res.body.documents.length > 0) { + const doc = res.body.documents[0] + doc.id !== undefined + doc.name !== undefined + doc.url !== undefined + doc.section !== undefined + doc.created_at !== undefined + doc.updated_at !== undefined + } +} \ No newline at end of file diff --git a/bruno/projects/get-project-answers.bru b/bruno/projects/get-project-answers.bru new file mode 100644 index 00000000..08c4a0d3 --- /dev/null +++ b/bruno/projects/get-project-answers.bru @@ -0,0 +1,30 @@ +meta { + name: Get Project Answers + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/project/{{project_id}}/answers + auth: none +} + +headers { + Content-Type: application/json + Authorization: Bearer {{access_token}} +} + +tests { + test("should get project answers", function() { + expect(res.status).to.equal(200); + + // Check answers array exists + expect(res.body).to.have.property("answers"); + expect(res.body.answers).to.be.an("array"); + + // Save first answer ID for patch test + if (res.body.answers && res.body.answers.length > 0) { + bru.setVar("answer_id", res.body.answers[0].id); + } + }); +} \ No newline at end of file diff --git a/bruno/projects/get-project.bru b/bruno/projects/get-project.bru new file mode 100644 index 00000000..e50d8fd2 --- /dev/null +++ b/bruno/projects/get-project.bru @@ -0,0 +1,33 @@ +meta { + name: Get Project + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/project/{{project_id}} + auth: none +} + +headers { + Content-Type: application/json + Authorization: Bearer {{access_token}} +} + +tests { + test("should get project successfully", function() { + expect(res.status).to.equal(200); + + // Check all required fields + expect(res.body).to.have.property("id"); + expect(res.body).to.have.property("title"); + expect(res.body).to.have.property("description"); + expect(res.body).to.have.property("status"); + expect(res.body).to.have.property("created_at"); + expect(res.body).to.have.property("updated_at"); + + // Verify it's the project we created + expect(res.body.id).to.equal(bru.getVar("project_id")); + expect(res.body.title).to.equal("Test Project via Bruno"); + }); +} \ No newline at end of file diff --git a/bruno/projects/list-projects.bru b/bruno/projects/list-projects.bru new file mode 100644 index 00000000..c7afce85 --- /dev/null +++ b/bruno/projects/list-projects.bru @@ -0,0 +1,33 @@ +meta { + name: List Company Projects + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/project +} + +headers { + Authorization: Bearer {{access_token}} +} + +tests { + // Check status code + res.status === 200 + + // Check response structure + res.body.projects !== undefined + Array.isArray(res.body.projects) + + // If there are projects, check their structure + if (res.body.projects.length > 0) { + const project = res.body.projects[0] + project.id !== undefined + project.title !== undefined + project.description !== undefined + project.status !== undefined + project.created_at !== undefined + project.updated_at !== undefined + } +} \ No newline at end of file diff --git a/bruno/projects/patch-answer.bru b/bruno/projects/patch-answer.bru new file mode 100644 index 00000000..9e04af36 --- /dev/null +++ b/bruno/projects/patch-answer.bru @@ -0,0 +1,33 @@ +meta { + name: Patch Project Answer + type: http + seq: 4 +} + +patch { + url: {{baseUrl}}/project/{{project_id}}/answer + body: json + auth: none +} + +headers { + Content-Type: application/json + Authorization: Bearer {{access_token}} +} + +body:json { + { + "content": "This is my updated answer", + "answer_id": "{{answer_id}}" + } +} + +tests { + test("should update answer successfully", function() { + expect(res.status).to.equal(200); + + // Check success message + expect(res.body).to.have.property("message"); + expect(res.body.message).to.equal("Answer updated successfully"); + }); +} diff --git a/bruno/projects/submit_project.bru b/bruno/projects/submit_project.bru new file mode 100644 index 00000000..751529b0 --- /dev/null +++ b/bruno/projects/submit_project.bru @@ -0,0 +1,33 @@ +meta { + name: Submit Project + type: http + seq: 8 +} + +post { + url: {{baseUrl}}/project/{{project_id}}/submit + auth: none +} + +headers { + Content-Type: application/json + Authorization: Bearer {{access_token}} +} + +tests { + test("should submit project successfully", function() { + expect(res.status).to.equal(200); + + // Check response properties + expect(res.body).to.have.property("message"); + expect(res.body).to.have.property("status"); + + // Verify values + expect(res.body.message).to.equal("Project submitted successfully"); + expect(res.body.status).to.equal("pending"); + + // Check warnings array exists (might be empty) + expect(res.body).to.have.property("warnings"); + expect(res.body.warnings).to.be.an("array"); + }); +} diff --git a/bruno/projects/upload-document.bru b/bruno/projects/upload-document.bru new file mode 100644 index 00000000..56cd92ec --- /dev/null +++ b/bruno/projects/upload-document.bru @@ -0,0 +1,42 @@ +meta { + name: Upload Project Document + type: http + seq: 5 +} + +post { + url: {{baseUrl}}/project/{{project_id}}/document + body: multipartForm + auth: none +} + +headers { + Authorization: Bearer {{access_token}} +} + +body:multipart-form { + file: @file(test-files/sample.pdf) + name: Business Plan + section: business_overview +} + +tests { + // Check status code + res.status === 201 + + // Check response structure + res.body.id !== undefined + res.body.name !== undefined + res.body.url !== undefined + res.body.section !== undefined + res.body.created_at !== undefined + res.body.updated_at !== undefined + + // Verify values + res.body.name === "Business Plan" + res.body.section === "business_overview" + res.body.url.includes(".s3.us-east-1.amazonaws.com/") + + // Save document ID for future requests + bru.setEnvVar("document_id", res.body.id) +} diff --git a/bruno/test-files/sample.pdf b/bruno/test-files/sample.pdf new file mode 100644 index 00000000..90509c55 Binary files /dev/null and b/bruno/test-files/sample.pdf differ