From 0fab424f82c5757e195155dbdc162b84f27ef5a2 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 26 Dec 2024 19:19:59 -0500 Subject: [PATCH 01/21] Add project endpoints --- .../20241215194302_initial_schema.sql | 6 +- .../20241226195458_add_default_questions.sql | 26 + backend/.sqlc/queries/projects.sql | 121 ++++ backend/db/models.go | 12 +- backend/db/projects.sql.go | 490 +++++++++++++++ backend/go.mod | 54 +- backend/go.sum | 270 ++++++++ backend/internal/middleware/jwt.go | 44 +- backend/internal/v1/setup.go | 2 + backend/internal/v1/v1_projects/answers.go | 2 +- backend/internal/v1/v1_projects/comments.go | 2 +- backend/internal/v1/v1_projects/documents.go | 2 +- backend/internal/v1/v1_projects/projects.go | 586 +++++++++++++++++- backend/internal/v1/v1_projects/questions.go | 2 +- backend/internal/v1/v1_projects/routes.go | 29 +- backend/internal/v1/v1_projects/types.go | 58 +- backend/internal/v1/v1_projects/validation.go | 72 +++ 17 files changed, 1743 insertions(+), 35 deletions(-) create mode 100644 backend/.sqlc/migrations/20241226195458_add_default_questions.sql create mode 100644 backend/.sqlc/queries/projects.sql create mode 100644 backend/db/projects.sql.go create mode 100644 backend/internal/v1/v1_projects/validation.go 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..33b160f1 --- /dev/null +++ b/backend/.sqlc/queries/projects.sql @@ -0,0 +1,121 @@ +-- name: GetCompanyByUserID :one +SELECT * FROM companies +WHERE owner_id = $1 +LIMIT 1; + +-- name: CreateProject :one +INSERT INTO projects ( + id, + company_id, + title, + description, + status, + created_at, + updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) 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 + AND project_id IN ( + SELECT projects.id + FROM projects + WHERE projects.company_id = $4 + ) +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; \ 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..bd828107 --- /dev/null +++ b/backend/db/projects.sql.go @@ -0,0 +1,490 @@ +// 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 ( + id, + company_id, + title, + description, + status, + created_at, + updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) RETURNING id, company_id, title, description, status, created_at, updated_at +` + +type CreateProjectParams struct { + ID string + 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.ID, + 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 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 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 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 + AND project_id IN ( + SELECT projects.id + FROM projects + WHERE projects.company_id = $4 + ) +RETURNING id, project_id, question_id, answer, created_at, updated_at +` + +type UpdateProjectAnswerParams struct { + Answer string + ID string + ProjectID string + CompanyID string +} + +func (q *Queries) UpdateProjectAnswer(ctx context.Context, arg UpdateProjectAnswerParams) (ProjectAnswer, error) { + row := q.db.QueryRow(ctx, updateProjectAnswer, + arg.Answer, + arg.ID, + arg.ProjectID, + arg.CompanyID, + ) + 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/go.mod b/backend/go.mod index 9a53add6..af71ec0f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -16,11 +16,16 @@ require ( github.com/labstack/echo/v4 v4.12.0 github.com/resend/resend-go/v2 v2.13.0 github.com/rs/zerolog v1.33.0 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.31.0 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/ClickHouse/ch-go v0.61.5 // indirect + github.com/ClickHouse/clickhouse-go/v2 v2.28.3 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.47 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect @@ -35,27 +40,74 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect + github.com/coder/websocket v1.8.12 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elastic/go-sysinfo v1.11.2 // indirect + github.com/elastic/go-windows v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 // indirect + github.com/jonboulle/clockwork v0.4.0 // indirect + github.com/klauspost/compress v1.17.7 // indirect github.com/kr/text v0.2.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/mfridman/xflag v0.0.0-20240825232106-efb77353e578 // indirect + github.com/microsoft/go-mssqldb v1.7.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/paulmach/orb v0.11.1 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pressly/goose/v3 v3.22.1 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/vertica/vertica-sql-go v1.3.3 // indirect + github.com/ydb-platform/ydb-go-genproto v0.0.0-20240528144234-5d5a685e41f7 // indirect + github.com/ydb-platform/ydb-go-sdk/v3 v3.80.2 // indirect + github.com/ziutek/mymysql v1.5.4 // indirect + go.opentelemetry.io/otel v1.26.0 // indirect + go.opentelemetry.io/otel/trace v1.26.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + howett.net/plist v1.0.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/sqlite v1.33.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 22cfb19f..9dcad25f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,19 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4= +github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg= +github.com/ClickHouse/clickhouse-go/v2 v2.28.3 h1:SkFzPULX6nzgfNZd1YD1XTECivjTMrCtD09ZPKcVLFQ= +github.com/ClickHouse/clickhouse-go/v2 v2.28.3/go.mod h1:vzn73hp+3JwxtFU4RjPCQ7r6fP2pMKVwdi8E1/Tkua8= github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4= github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8= @@ -36,13 +50,43 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HS github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8= github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elastic/go-sysinfo v1.8.1/go.mod h1:JfllUnzoQV/JRYymbH3dO1yggI3mV2oTKSXsDHM+uIM= +github.com/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn0jg4= +github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ= +github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= +github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0= +github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -51,15 +95,54 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -68,10 +151,23 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= +github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= @@ -86,45 +182,219 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mfridman/xflag v0.0.0-20240825232106-efb77353e578 h1:CRrqlUmLebb/QjzRDWE0E66+YyN/v95+w6WyH9ju8/Y= +github.com/mfridman/xflag v0.0.0-20240825232106-efb77353e578/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= +github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= +github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= +github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc= +github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/resend/resend-go/v2 v2.13.0 h1:O6Z5Z+LiBlDAm6daHHn0POQX4TJfsdGIhQJD8qGutW4= github.com/resend/resend-go/v2 v2.13.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= +github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= +github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20240528144234-5d5a685e41f7 h1:nL8XwD6fSst7xFUirkaWJmE7kM0CdWRYgu6+YQer1d4= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20240528144234-5d5a685e41f7/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.80.2 h1:qmZGJQCNx09/r0HDIT2cDDogiOvWikELy13ubM2CFS8= +github.com/ydb-platform/ydb-go-sdk/v3 v3.80.2/go.mod h1:IHwuXyolaAmGK2Dp7+dlhsnXphG1pwCoaP/OITT3+tU= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= +go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= +go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= +go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.33.0 h1:WWkA/T2G17okiLGgKAj4/RMIvgyMT19yQ038160IeYk= +modernc.org/sqlite v1.33.0/go.mod h1:9uQ9hF/pCZoYZK73D/ud5Z7cIRIILSZI8NdIemVMTX8= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/middleware/jwt.go b/backend/internal/middleware/jwt.go index ff62155b..6a79f42a 100644 --- a/backend/internal/middleware/jwt.go +++ b/backend/internal/middleware/jwt.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v4" + "github.com/google/uuid" ) // Auth creates a middleware that validates JWT access tokens with specified user roles @@ -37,47 +38,50 @@ 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, Salt: user.TokenSalt, }) c.Set("user", &user) + c.Set("user_id", uuid.MustParse(claims.UserID)) return next(c) } diff --git a/backend/internal/v1/setup.go b/backend/internal/v1/setup.go index d795ff7d..eaaccec1 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" ) func SetupRoutes(s interfaces.CoreServer) { @@ -11,4 +12,5 @@ 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) } 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..c38c944b 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -1 +1,585 @@ -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" +) + +type ValidationError struct { + Question string `json:"question"` + Message string `json:"message"` +} + +func (h *Handler) handleCreateProject(c echo.Context) error { + var req CreateProjectRequest + if err := v1_common.BindandValidate(c, &req); err != nil { + return v1_common.Fail(c, 400, "Invalid request", err) + } + + // Get user ID from context + userID, err := v1_common.GetUserID(c) + if err != nil { + return v1_common.Fail(c, 401, "Unauthorized", err) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // Start a transaction + tx, err := h.server.GetDB().Begin(c.Request().Context()) + if err != nil { + return v1_common.Fail(c, 500, "Failed to start transaction", err) + } + defer tx.Rollback(c.Request().Context()) + + // Create project + now := time.Now().Unix() + description := req.Description // Create a variable to get address of + project, err := h.server.GetQueries().CreateProject(c.Request().Context(), db.CreateProjectParams{ + ID: uuid.New().String(), + 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) + } + + // Create empty answers for all questions + _, err = h.server.GetQueries().CreateProjectAnswers(c.Request().Context(), project.ID) + if err != nil { + return v1_common.Fail(c, 500, "Failed to create project answers", err) + } + + if err := tx.Commit(c.Request().Context()); err != nil { + return v1_common.Fail(c, 500, "Failed to commit transaction", err) + } + + 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, + }) +} + +func (h *Handler) handleGetProjects(c echo.Context) error { + // Get user ID from context + userID, err := v1_common.GetUserID(c) + if err != nil { + return v1_common.Fail(c, 401, "Unauthorized", err) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + 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) +} + +func (h *Handler) handleGetProject(c echo.Context) error { + // Get user ID from context + userID, err := v1_common.GetUserID(c) + if err != nil { + return v1_common.Fail(c, 401, "Unauthorized", 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(), userID.String()) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // 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, + }) +} + + +func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { + // Get project ID from URL + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, 400, "Project ID is required", nil) + } + + // Get user ID from context + userID, err := v1_common.GetUserID(c) + if err != nil { + return v1_common.Fail(c, 401, "Unauthorized", err) + } + + // Parse request body + var req PatchAnswerRequest + if err := c.Bind(&req); err != nil { + return v1_common.Fail(c, 400, "Invalid request body", err) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // Update the answer + _, err = h.server.GetQueries().UpdateProjectAnswer(c.Request().Context(), db.UpdateProjectAnswerParams{ + Answer: req.Content, + ID: req.AnswerID, + ProjectID: projectID, + CompanyID: company.ID, + }) + 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", + }) +} + +func (h *Handler) handleGetProjectAnswers(c echo.Context) error { + // Get project ID from URL + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, 400, "Project ID is required", nil) + } + + // Get user ID from context + userID, err := v1_common.GetUserID(c) + if err != nil { + return v1_common.Fail(c, 401, "Unauthorized", err) + } + + // Verify company ownership + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + + // 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, + }) +} + +func (h *Handler) handleUploadProjectDocument(c echo.Context) error { + // Get user ID from context + userID, err := v1_common.GetUserID(c) + if err != nil { + return v1_common.Fail(c, 401, "Unauthorized", 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 to verify ownership + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + 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) + } + + // Get the file from form + file, err := c.FormFile("file") + if err != nil { + return v1_common.Fail(c, 400, "File is required", err) + } + + // Parse other form fields + var req UploadDocumentRequest + req.Name = c.FormValue("name") + req.Section = c.FormValue("section") + if req.Name == "" || req.Section == "" { + return v1_common.Fail(c, 400, "Name and section are required", nil) + } + + // 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: req.Name, + Url: fileURL, + Section: req.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, + }) +} + +func (h *Handler) handleGetProjectDocuments(c echo.Context) error { + // Get user ID from context + userID, err := v1_common.GetUserID(c) + if err != nil { + return v1_common.Fail(c, 401, "Unauthorized", 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 to verify ownership + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + 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) + } + + // 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, + }) +} + +func (h *Handler) handleDeleteProjectDocument(c echo.Context) error { + // Get user ID from context + userID, err := v1_common.GetUserID(c) + if err != nil { + return v1_common.Fail(c, 401, "Unauthorized", nil) + } + + // 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(), userID.String()) + 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", + }) +} + +func (h *Handler) handleListCompanyProjects(c echo.Context) error { + userID, err := v1_common.GetUserID(c) + if err != nil { + return v1_common.Fail(c, 401, "Unauthorized", nil) + } + + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", nil) + } + + // 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, + }) +} + +func (h *Handler) handleSubmitProject(c echo.Context) error { + projectID := c.Param("id") + + // 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", + }) +} 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..abbe5a56 100644 --- a/backend/internal/v1/v1_projects/routes.go +++ b/backend/internal/v1/v1_projects/routes.go @@ -1 +1,28 @@ -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} + + projects := g.Group("/project") + projects.Use(middleware.AuthWithConfig(middleware.AuthConfig{ + AcceptTokenType: "access_token", + AcceptUserRoles: []db.UserRole{db.UserRoleStartupOwner}, + }, s.GetDB())) + + projects.POST("/new", h.handleCreateProject) + projects.GET("", h.handleListCompanyProjects) + projects.GET("/:id", h.handleGetProject) + projects.GET("/:id/answers", h.handleGetProjectAnswers) + projects.PATCH("/:id/answer", h.handlePatchProjectAnswer) + projects.POST("/:id/document", h.handleUploadProjectDocument) + projects.GET("/:id/documents", h.handleGetProjectDocuments) + projects.DELETE("/:id/document/:document_id", h.handleDeleteProjectDocument) + projects.POST("/:id/submit", h.handleSubmitProject, middleware.Auth(s.GetDB(), db.UserRoleStartupOwner)) +} diff --git a/backend/internal/v1/v1_projects/types.go b/backend/internal/v1/v1_projects/types.go index 542e564d..1c5112ab 100644 --- a/backend/internal/v1/v1_projects/types.go +++ b/backend/internal/v1/v1_projects/types.go @@ -1 +1,57 @@ -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"` +} \ 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..e3e80ec0 --- /dev/null +++ b/backend/internal/v1/v1_projects/validation.go @@ -0,0 +1,72 @@ +package v1_projects + +import ( + "net/url" + "strings" +) + +type validationType struct { + Name string + Validate func(string) bool + Message string +} + +var validationTypes = []validationType{ + { + Name: "url", + Validate: func(answer string) bool { + _, err := url.ParseRequestURI(answer) + return err == nil + }, + Message: "Must be a valid URL", + }, + { + Name: "email", + Validate: func(answer string) bool { + return strings.Contains(answer, "@") && strings.Contains(answer, ".") + }, + Message: "Must be a valid email address", + }, + { + Name: "phone", + Validate: func(answer string) bool { + // Simple check for now - frontend will do proper formatting lol + 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", + }, +} + +func isValidAnswer(answer string, validations string) bool { + rules := strings.Split(validations, ",") + + for _, rule := range rules { + for _, vType := range validationTypes { + if rule == vType.Name && !vType.Validate(answer) { + return false + } + } + } + + return true +} + +func getValidationMessage(validations string) string { + rules := strings.Split(validations, ",") + + for _, rule := range rules { + for _, vType := range validationTypes { + if rule == vType.Name { + return vType.Message + } + } + } + + return "Invalid input" +} \ No newline at end of file From 71e45681d1cabc60286321c9c800ad7ec42f8291 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 26 Dec 2024 19:20:06 -0500 Subject: [PATCH 02/21] Add bruno files --- bruno/{ => auth}/Register.bru | 4 +-- bruno/auth/login.bru | 35 ++++++++++++++++++ bruno/bruno.json | 3 +- bruno/environments/Dev.bru | 2 +- bruno/projects/create.bru | 47 +++++++++++++++++++++++++ bruno/projects/delete-document.bru | 21 +++++++++++ bruno/projects/get-documents.bru | 33 +++++++++++++++++ bruno/projects/get-project-answers.bru | 30 ++++++++++++++++ bruno/projects/get-project.bru | 33 +++++++++++++++++ bruno/projects/list-projects.bru | 33 +++++++++++++++++ bruno/projects/patch-answer.bru | 33 +++++++++++++++++ bruno/projects/submit_project.bru | 33 +++++++++++++++++ bruno/projects/upload-document.bru | 42 ++++++++++++++++++++++ bruno/test-files/sample.pdf | Bin 0 -> 46 bytes 14 files changed, 345 insertions(+), 4 deletions(-) rename bruno/{ => auth}/Register.bru (74%) create mode 100644 bruno/auth/login.bru create mode 100644 bruno/projects/create.bru create mode 100644 bruno/projects/delete-document.bru create mode 100644 bruno/projects/get-documents.bru create mode 100644 bruno/projects/get-project-answers.bru create mode 100644 bruno/projects/get-project.bru create mode 100644 bruno/projects/list-projects.bru create mode 100644 bruno/projects/patch-answer.bru create mode 100644 bruno/projects/submit_project.bru create mode 100644 bruno/projects/upload-document.bru create mode 100644 bruno/test-files/sample.pdf 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 0000000000000000000000000000000000000000..90509c55ac25979a487856d48aa79eb433c1ee9f GIT binary patch literal 46 zcmWGZEiO?=&d)1J%_~tz%P&$04hRiWC@9L$N=+_NNXbtw%>~Jn7Ubk7rf>lOjU*7# literal 0 HcmV?d00001 From 37fc3cb54f94046758541664f9d425edd6d5e8cc Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 26 Dec 2024 20:37:17 -0500 Subject: [PATCH 03/21] Refactor project routes --- backend/internal/v1/v1_projects/routes.go | 25 +++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/backend/internal/v1/v1_projects/routes.go b/backend/internal/v1/v1_projects/routes.go index abbe5a56..83510440 100644 --- a/backend/internal/v1/v1_projects/routes.go +++ b/backend/internal/v1/v1_projects/routes.go @@ -10,19 +10,26 @@ import ( func SetupRoutes(g *echo.Group, s interfaces.CoreServer) { h := &Handler{server: s} - projects := g.Group("/project") - projects.Use(middleware.AuthWithConfig(middleware.AuthConfig{ + // Base project routes + projects := g.Group("/project", middleware.AuthWithConfig(middleware.AuthConfig{ AcceptTokenType: "access_token", AcceptUserRoles: []db.UserRole{db.UserRoleStartupOwner}, }, s.GetDB())) - + + // Project management projects.POST("/new", h.handleCreateProject) projects.GET("", h.handleListCompanyProjects) projects.GET("/:id", h.handleGetProject) - projects.GET("/:id/answers", h.handleGetProjectAnswers) - projects.PATCH("/:id/answer", h.handlePatchProjectAnswer) - projects.POST("/:id/document", h.handleUploadProjectDocument) - projects.GET("/:id/documents", h.handleGetProjectDocuments) - projects.DELETE("/:id/document/:document_id", h.handleDeleteProjectDocument) - projects.POST("/:id/submit", h.handleSubmitProject, middleware.Auth(s.GetDB(), db.UserRoleStartupOwner)) + projects.POST("/:id/submit", h.handleSubmitProject) + + // Project answers + answers := projects.Group("/:id/answers") + answers.GET("", h.handleGetProjectAnswers) + answers.PATCH("", h.handlePatchProjectAnswer) + + // Project documents + docs := projects.Group("/:id/documents") + docs.POST("", h.handleUploadProjectDocument) + docs.GET("", h.handleGetProjectDocuments) + docs.DELETE("/:document_id", h.handleDeleteProjectDocument) } From e0fa3639485f627df3bd14a1be8ab7bc3633f90f Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 26 Dec 2024 20:37:29 -0500 Subject: [PATCH 04/21] Add better security to SubmitProject --- backend/internal/v1/v1_projects/projects.go | 26 ++++++++++++++++++++- backend/internal/v1/v1_projects/types.go | 14 +++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index c38c944b..0d0a825f 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -510,8 +510,32 @@ func (h *Handler) handleListCompanyProjects(c echo.Context) error { } func (h *Handler) handleSubmitProject(c echo.Context) error { + // Get user ID and verify ownership first + userID, err := v1_common.GetUserID(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(), userID.String()) + if err != nil { + return v1_common.Fail(c, http.StatusNotFound, "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 + _, 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) + } + // Get all questions and answers for this project answers, err := h.server.GetQueries().GetProjectAnswers(c.Request().Context(), projectID) if err != nil { diff --git a/backend/internal/v1/v1_projects/types.go b/backend/internal/v1/v1_projects/types.go index 1c5112ab..cb574ce5 100644 --- a/backend/internal/v1/v1_projects/types.go +++ b/backend/internal/v1/v1_projects/types.go @@ -54,4 +54,18 @@ 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"` } \ No newline at end of file From 63717bf09eefe3834ac4b8e2a7907b2c7ea483b0 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 26 Dec 2024 20:42:16 -0500 Subject: [PATCH 05/21] Add project tests, fix server tests, helpers --- backend/internal/tests/helpers.go | 42 +++- backend/internal/tests/projects_test.go | 279 ++++++++++++++++++++++++ backend/internal/tests/server_test.go | 152 ++++++++++--- 3 files changed, 435 insertions(+), 38 deletions(-) create mode 100644 backend/internal/tests/projects_test.go diff --git a/backend/internal/tests/helpers.go b/backend/internal/tests/helpers.go index 9edf1179..87fc4144 100644 --- a/backend/internal/tests/helpers.go +++ b/backend/internal/tests/helpers.go @@ -7,6 +7,7 @@ import ( "time" "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" ) /* @@ -18,7 +19,14 @@ func createTestUser(ctx context.Context, s *server.Server) (string, string, stri userID := uuid.New().String() email := "test@mail.com" password := "password" - _, err := s.DBPool.Exec(ctx, ` + + // 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, @@ -28,7 +36,8 @@ func createTestUser(ctx context.Context, s *server.Server) (string, string, stri token_salt ) VALUES ($1, $2, $3, $4, $5, gen_random_bytes(32))`, - userID, email, "hashedpassword", db.UserRoleStartupOwner, false) + userID, email, string(hashedPassword), db.UserRoleStartupOwner, false) + return userID, email, password, err } @@ -76,3 +85,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..d2e62851 --- /dev/null +++ b/backend/internal/tests/projects_test.go @@ -0,0 +1,279 @@ +package tests + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + "bytes" + + "KonferCA/SPUR/db" + "KonferCA/SPUR/internal/server" + "KonferCA/SPUR/internal/v1/v1_common" + "github.com/stretchr/testify/assert" +) + +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) { + 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) { + 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) { + 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) { + // First get the questions/answers + 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) + + if !assert.Equal(t, http.StatusOK, rec.Code) { + t.Logf("Get answers response: %s", rec.Body.String()) + t.FailNow() + } + + var answersResp struct { + Answers []struct { + ID string `json:"id"` + QuestionID string `json:"question_id"` + Question string `json:"question"` + Answer string `json:"answer"` + Section string `json:"section"` + } `json:"answers"` + } + err := json.NewDecoder(rec.Body).Decode(&answersResp) + assert.NoError(t, err) + assert.NotEmpty(t, answersResp.Answers, "Should have questions to answer") + + // First patch each answer individually + for _, q := range answersResp.Answers { + 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 blockchain-based authentication system that solves identity verification issues." + case "What is the unique value proposition?": + answer = "We provide secure, decentralized identity verification that's faster and more reliable than traditional methods." + } + + // Patch the answer + patchBody := map[string]string{ + "content": answer, + "answer_id": q.ID, + } + patchJSON, err := json.Marshal(patchBody) + assert.NoError(t, err) + + patchReq := httptest.NewRequest(http.MethodPatch, path, bytes.NewReader(patchJSON)) + patchReq.Header.Set("Authorization", "Bearer "+accessToken) + patchReq.Header.Set("Content-Type", "application/json") + patchRec := httptest.NewRecorder() + s.GetEcho().ServeHTTP(patchRec, patchReq) + + if !assert.Equal(t, http.StatusOK, patchRec.Code) { + t.Logf("Patch answer response: %s", patchRec.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 + t.Run("Error Cases", func(t *testing.T) { + 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", + }, + } + + 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 v1_common.APIError + err := json.NewDecoder(rec.Body).Decode(&errResp) + assert.NoError(t, err) + 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 dcbfd919..bf7e0aef 100644 --- a/backend/internal/tests/server_test.go +++ b/backend/internal/tests/server_test.go @@ -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) @@ -407,12 +458,17 @@ func TestServer(t *testing.T) { err = json.Unmarshal(resBodyBytes, &resBody) assert.Nil(t, err) assert.Equal(t, resBody["verified"], true) + + // Cleanup + err = removeTestUser(ctx, email, s) + assert.NoError(t, err) }) t.Run("/auth/verify-email - 400 Bad Request - missing token query parameter", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email", nil) rec := httptest.NewRecorder() + s.Echo.ServeHTTP(rec, req) var apiErr v1_common.APIError @@ -426,18 +482,39 @@ func TestServer(t *testing.T) { t.Run("/auth/verify-email - 400 Bad Request - 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) + + // Create user with unique email + email := fmt.Sprintf("test-verify-email-expired-%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) 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) @@ -447,15 +524,19 @@ func TestServer(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Equal(t, v1_common.ErrorTypeBadRequest, apiErr.Type) assert.Equal(t, "Failed to verify email. Invalid or expired token.", apiErr.Message) + + // 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, @@ -468,7 +549,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() From 35f87816ad0cf12803ee98b28a6cfa06926afd2b Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 26 Dec 2024 20:49:37 -0500 Subject: [PATCH 06/21] Fix test --- backend/internal/tests/server_test.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/internal/tests/server_test.go b/backend/internal/tests/server_test.go index bf7e0aef..9a018f3a 100644 --- a/backend/internal/tests/server_test.go +++ b/backend/internal/tests/server_test.go @@ -480,9 +480,6 @@ func TestServer(t *testing.T) { }) t.Run("/auth/verify-email - 400 Bad Request - deny expired email token", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - // Create user with unique email email := fmt.Sprintf("test-verify-email-expired-%s@mail.com", uuid.New().String()) password := "testpassword123" @@ -502,6 +499,10 @@ func TestServer(t *testing.T) { s.Echo.ServeHTTP(rec, req) assert.Equal(t, http.StatusCreated, rec.Code) + // Create context for database operations + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + // Get user from database var user db.User err = s.DBPool.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&user.ID) @@ -531,9 +532,6 @@ func TestServer(t *testing.T) { }) 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 with unique email email := fmt.Sprintf("test-logout-%s@mail.com", uuid.New().String()) password := "testpassword123" From 7cc300ce1c014f034197ab226ddd3aa75ad935eb Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 26 Dec 2024 23:11:31 -0500 Subject: [PATCH 07/21] Add file check middleware --- backend/internal/v1/v1_projects/routes.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/internal/v1/v1_projects/routes.go b/backend/internal/v1/v1_projects/routes.go index 83510440..77cb8529 100644 --- a/backend/internal/v1/v1_projects/routes.go +++ b/backend/internal/v1/v1_projects/routes.go @@ -29,7 +29,20 @@ func SetupRoutes(g *echo.Group, s interfaces.CoreServer) { // Project documents docs := projects.Group("/:id/documents") - docs.POST("", h.handleUploadProjectDocument) + 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) } From c1671d4cdbfe064dc6f14ac20cef1b07895029b1 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 26 Dec 2024 23:11:53 -0500 Subject: [PATCH 08/21] Ensure atomic operations in project creation --- backend/internal/v1/v1_projects/projects.go | 50 +++++++++------------ 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index 0d0a825f..d8cfd6fe 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -39,16 +39,20 @@ func (h *Handler) handleCreateProject(c echo.Context) error { } // Start a transaction - tx, err := h.server.GetDB().Begin(c.Request().Context()) + ctx := c.Request().Context() + tx, err := h.server.GetDB().Begin(ctx) if err != nil { return v1_common.Fail(c, 500, "Failed to start transaction", err) } - defer tx.Rollback(c.Request().Context()) + defer tx.Rollback(ctx) // Will be no-op if committed - // Create project + // Create queries with transaction + qtx := h.server.GetQueries().WithTx(tx) + + // Create project within transaction now := time.Now().Unix() - description := req.Description // Create a variable to get address of - project, err := h.server.GetQueries().CreateProject(c.Request().Context(), db.CreateProjectParams{ + description := req.Description + project, err := qtx.CreateProject(ctx, db.CreateProjectParams{ ID: uuid.New().String(), CompanyID: company.ID, Title: req.Title, @@ -61,21 +65,18 @@ func (h *Handler) handleCreateProject(c echo.Context) error { return v1_common.Fail(c, 500, "Failed to create project", err) } - // Create empty answers for all questions - _, err = h.server.GetQueries().CreateProjectAnswers(c.Request().Context(), project.ID) + // Create empty answers within same transaction + _, err = qtx.CreateProjectAnswers(ctx, project.ID) if err != nil { return v1_common.Fail(c, 500, "Failed to create project answers", err) } - if err := tx.Commit(c.Request().Context()); err != nil { + // Commit the transaction + if err := tx.Commit(ctx); err != nil { return v1_common.Fail(c, 500, "Failed to commit transaction", err) } - description = "" - if project.Description != nil { - description = *project.Description - } - + // Return success response return c.JSON(200, ProjectResponse{ ID: project.ID, Title: project.Title, @@ -267,6 +268,11 @@ func (h *Handler) handleGetProjectAnswers(c echo.Context) error { } func (h *Handler) handleUploadProjectDocument(c echo.Context) error { + // Get file from request + file, err := c.FormFile("file") + if err != nil { + return v1_common.Fail(c, http.StatusBadRequest, "No file provided", err) + } // Get user ID from context userID, err := v1_common.GetUserID(c) if err != nil { @@ -294,20 +300,6 @@ func (h *Handler) handleUploadProjectDocument(c echo.Context) error { return v1_common.Fail(c, 404, "Project not found", err) } - // Get the file from form - file, err := c.FormFile("file") - if err != nil { - return v1_common.Fail(c, 400, "File is required", err) - } - - // Parse other form fields - var req UploadDocumentRequest - req.Name = c.FormValue("name") - req.Section = c.FormValue("section") - if req.Name == "" || req.Section == "" { - return v1_common.Fail(c, 400, "Name and section are required", nil) - } - // Open the file src, err := file.Open() if err != nil { @@ -334,9 +326,9 @@ func (h *Handler) handleUploadProjectDocument(c echo.Context) error { // Save document record in database doc, err := h.server.GetQueries().CreateProjectDocument(c.Request().Context(), db.CreateProjectDocumentParams{ ProjectID: projectID, - Name: req.Name, + Name: c.FormValue("name"), Url: fileURL, - Section: req.Section, + Section: c.FormValue("section"), }) if err != nil { // Try to cleanup the uploaded file if database insert fails From 338e434bf37d278ef8b295323ebbdee94be0ba89 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 26 Dec 2024 23:39:06 -0500 Subject: [PATCH 09/21] Fix param validation support for min/max length and regex patterns --- backend/.sqlc/queries/projects.sql | 7 +- backend/db/projects.sql.go | 21 ++++++ backend/internal/tests/projects_test.go | 75 +++++++++++++++++-- backend/internal/v1/v1_projects/projects.go | 21 ++++++ backend/internal/v1/v1_projects/validation.go | 62 +++++++++++++-- 5 files changed, 173 insertions(+), 13 deletions(-) diff --git a/backend/.sqlc/queries/projects.sql b/backend/.sqlc/queries/projects.sql index 33b160f1..887464bf 100644 --- a/backend/.sqlc/queries/projects.sql +++ b/backend/.sqlc/queries/projects.sql @@ -118,4 +118,9 @@ UPDATE projects SET status = $1, updated_at = extract(epoch from now()) -WHERE id = $2; \ No newline at end of file +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; \ No newline at end of file diff --git a/backend/db/projects.sql.go b/backend/db/projects.sql.go index bd828107..571cc5b9 100644 --- a/backend/db/projects.sql.go +++ b/backend/db/projects.sql.go @@ -395,6 +395,27 @@ func (q *Queries) GetProjectsByCompanyID(ctx context.Context, companyID string) 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 diff --git a/backend/internal/tests/projects_test.go b/backend/internal/tests/projects_test.go index d2e62851..d9a7b9da 100644 --- a/backend/internal/tests/projects_test.go +++ b/backend/internal/tests/projects_test.go @@ -14,7 +14,6 @@ import ( "KonferCA/SPUR/db" "KonferCA/SPUR/internal/server" - "KonferCA/SPUR/internal/v1/v1_common" "github.com/stretchr/testify/assert" ) @@ -171,9 +170,9 @@ func TestProjectEndpoints(t *testing.T) { 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 blockchain-based authentication system that solves identity verification issues." + 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. Our system uses advanced cryptography and distributed ledger technology to ensure tamper-proof identity verification." case "What is the unique value proposition?": - answer = "We provide secure, decentralized identity verification that's faster and more reliable than traditional methods." + answer = "We provide secure, decentralized identity verification that's faster and more reliable than traditional methods. Our solution reduces verification time by 90% while increasing security and reducing costs for businesses. We are the only solution that combines biometric verification with blockchain immutability at scale." } // Patch the answer @@ -220,6 +219,39 @@ func TestProjectEndpoints(t *testing.T) { // Error cases 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 @@ -249,6 +281,28 @@ func TestProjectEndpoints(t *testing.T) { 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", + }, } for _, tc := range tests { @@ -269,10 +323,21 @@ func TestProjectEndpoints(t *testing.T) { assert.Equal(t, tc.expectedCode, rec.Code) - var errResp v1_common.APIError + 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) - assert.Contains(t, errResp.Message, tc.expectedError) + + if len(errResp.ValidationErrors) > 0 { + assert.Contains(t, errResp.ValidationErrors[0].Message, tc.expectedError) + } else { + assert.Contains(t, errResp.Message, tc.expectedError) + } }) } }) diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index d8cfd6fe..8ea2829c 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -197,6 +197,27 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { return v1_common.Fail(c, 404, "Company not found", 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 if validations exist + if question.Validations != nil && *question.Validations != "" { + if !isValidAnswer(req.Content, *question.Validations) { + return c.JSON(http.StatusBadRequest, map[string]interface{}{ + "message": "Validation failed", + "validation_errors": []ValidationError{ + { + Question: question.Question, + Message: getValidationMessage(*question.Validations), + }, + }, + }) + } + } + // Update the answer _, err = h.server.GetQueries().UpdateProjectAnswer(c.Request().Context(), db.UpdateProjectAnswerParams{ Answer: req.Content, diff --git a/backend/internal/v1/v1_projects/validation.go b/backend/internal/v1/v1_projects/validation.go index e3e80ec0..a0a532c9 100644 --- a/backend/internal/v1/v1_projects/validation.go +++ b/backend/internal/v1/v1_projects/validation.go @@ -3,18 +3,20 @@ package v1_projects import ( "net/url" "strings" + "strconv" + "regexp" ) type validationType struct { Name string - Validate func(string) bool + Validate func(string, string) bool // (answer, param) Message string } var validationTypes = []validationType{ { Name: "url", - Validate: func(answer string) bool { + Validate: func(answer string, _ string) bool { _, err := url.ParseRequestURI(answer) return err == nil }, @@ -22,15 +24,14 @@ var validationTypes = []validationType{ }, { Name: "email", - Validate: func(answer string) bool { + 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) bool { - // Simple check for now - frontend will do proper formatting lol + Validate: func(answer string, _ string) bool { cleaned := strings.Map(func(r rune) rune { if r >= '0' && r <= '9' { return r @@ -41,14 +42,57 @@ var validationTypes = []validationType{ }, 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", + }, +} + +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 } func isValidAnswer(answer string, validations string) bool { rules := strings.Split(validations, ",") for _, rule := range rules { + name, param := parseValidationRule(rule) for _, vType := range validationTypes { - if rule == vType.Name && !vType.Validate(answer) { + if name == vType.Name && !vType.Validate(answer, param) { return false } } @@ -61,8 +105,12 @@ func getValidationMessage(validations string) string { rules := strings.Split(validations, ",") for _, rule := range rules { + name, param := parseValidationRule(rule) for _, vType := range validationTypes { - if rule == vType.Name { + if name == vType.Name { + if strings.Contains(vType.Message, "%s") { + return strings.Replace(vType.Message, "%s", param, 1) + } return vType.Message } } From 0e26ca69f6599fd189b87ca66944b0582dfa8456 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Thu, 26 Dec 2024 23:54:48 -0500 Subject: [PATCH 10/21] Add documentation --- backend/internal/tests/projects_test.go | 49 +++++++- backend/internal/v1/v1_projects/projects.go | 108 +++++++++++++++++- backend/internal/v1/v1_projects/validation.go | 56 +++++++++ 3 files changed, 209 insertions(+), 4 deletions(-) diff --git a/backend/internal/tests/projects_test.go b/backend/internal/tests/projects_test.go index d9a7b9da..c618c593 100644 --- a/backend/internal/tests/projects_test.go +++ b/backend/internal/tests/projects_test.go @@ -17,6 +17,18 @@ import ( "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() @@ -86,6 +98,12 @@ func TestProjectEndpoints(t *testing.T) { 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", @@ -115,6 +133,11 @@ func TestProjectEndpoints(t *testing.T) { }) 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() @@ -124,6 +147,11 @@ func TestProjectEndpoints(t *testing.T) { }) 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) @@ -138,6 +166,16 @@ func TestProjectEndpoints(t *testing.T) { }) t.Run("Submit Project", func(t *testing.T) { + /* + * "Submit Project" test verifies the complete submission flow: + * 1. Fetches questions/answers for the project + * 2. Updates each answer with valid data: + * - Website URL for company website + * - Detailed product description (>100 chars) + * - Value proposition description + * 3. Submits the completed project + * 4. Verifies project status changes to 'pending' + */ // First get the questions/answers path := fmt.Sprintf("/api/v1/project/%s/answers", projectID) req := httptest.NewRequest(http.MethodGet, path, nil) @@ -217,7 +255,16 @@ func TestProjectEndpoints(t *testing.T) { assert.Equal(t, "pending", submitResp.Status) }) - // Error cases + /* + * "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) diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index 8ea2829c..2a0eea54 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -15,9 +15,18 @@ import ( "net/http" ) +/* + * Package v1_projects implements the project management endpoints for the SPUR API. + * It handles project creation, retrieval, document management, and submission workflows. + */ + +/* + * ValidationError represents a validation failure for a project question. + * Used when validating project submissions and answers. + */ type ValidationError struct { - Question string `json:"question"` - Message string `json:"message"` + Question string `json:"question"` // The question that failed validation + Message string `json:"message"` // Validation error message } func (h *Handler) handleCreateProject(c echo.Context) error { @@ -87,6 +96,15 @@ func (h *Handler) handleCreateProject(c echo.Context) error { }) } +/* + * 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 { // Get user ID from context userID, err := v1_common.GetUserID(c) @@ -127,6 +145,13 @@ func (h *Handler) handleGetProjects(c echo.Context) error { 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 { // Get user ID from context userID, err := v1_common.GetUserID(c) @@ -171,7 +196,16 @@ func (h *Handler) handleGetProject(c echo.Context) error { }) } - +/* + * 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 { // Get project ID from URL projectID := c.Param("id") @@ -237,6 +271,17 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { }) } +/* + * 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 { // Get project ID from URL projectID := c.Param("id") @@ -288,6 +333,19 @@ func (h *Handler) handleGetProjectAnswers(c echo.Context) error { }) } +/* + * 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 { // Get file from request file, err := c.FormFile("file") @@ -367,6 +425,17 @@ func (h *Handler) handleUploadProjectDocument(c echo.Context) error { }) } +/* + * 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 { // Get user ID from context userID, err := v1_common.GetUserID(c) @@ -419,6 +488,17 @@ func (h *Handler) handleGetProjectDocuments(c echo.Context) error { }) } +/* + * 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 { // Get user ID from context userID, err := v1_common.GetUserID(c) @@ -481,6 +561,14 @@ func (h *Handler) handleDeleteProjectDocument(c echo.Context) error { }) } +/* + * 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 { userID, err := v1_common.GetUserID(c) if err != nil { @@ -522,6 +610,20 @@ func (h *Handler) handleListCompanyProjects(c echo.Context) error { }) } +/* + * 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 { // Get user ID and verify ownership first userID, err := v1_common.GetUserID(c) diff --git a/backend/internal/v1/v1_projects/validation.go b/backend/internal/v1/v1_projects/validation.go index a0a532c9..e8d6648e 100644 --- a/backend/internal/v1/v1_projects/validation.go +++ b/backend/internal/v1/v1_projects/validation.go @@ -1,5 +1,11 @@ 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" @@ -7,12 +13,30 @@ import ( "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", @@ -77,6 +101,14 @@ var validationTypes = []validationType{ }, } +/* + * 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]) @@ -86,6 +118,17 @@ func parseValidationRule(rule string) (name string, param string) { 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, ",") @@ -101,6 +144,19 @@ func isValidAnswer(answer string, validations string) bool { 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, ",") From d885baf61d36d6b0465312c1fffe39786173dc8d Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Fri, 27 Dec 2024 22:52:36 -0500 Subject: [PATCH 11/21] let postgres handle project uuid generation --- backend/.sqlc/queries/projects.sql | 3 +-- backend/db/projects.sql.go | 5 +---- backend/internal/v1/v1_projects/projects.go | 1 - 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/backend/.sqlc/queries/projects.sql b/backend/.sqlc/queries/projects.sql index 887464bf..1ea97d3c 100644 --- a/backend/.sqlc/queries/projects.sql +++ b/backend/.sqlc/queries/projects.sql @@ -5,7 +5,6 @@ LIMIT 1; -- name: CreateProject :one INSERT INTO projects ( - id, company_id, title, description, @@ -13,7 +12,7 @@ INSERT INTO projects ( created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7 + $1, $2, $3, $4, $5, $6 ) RETURNING *; -- name: GetProjectsByCompanyID :many diff --git a/backend/db/projects.sql.go b/backend/db/projects.sql.go index 571cc5b9..8723a23e 100644 --- a/backend/db/projects.sql.go +++ b/backend/db/projects.sql.go @@ -11,7 +11,6 @@ import ( const createProject = `-- name: CreateProject :one INSERT INTO projects ( - id, company_id, title, description, @@ -19,12 +18,11 @@ INSERT INTO projects ( created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7 + $1, $2, $3, $4, $5, $6 ) RETURNING id, company_id, title, description, status, created_at, updated_at ` type CreateProjectParams struct { - ID string CompanyID string Title string Description *string @@ -35,7 +33,6 @@ type CreateProjectParams struct { func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) { row := q.db.QueryRow(ctx, createProject, - arg.ID, arg.CompanyID, arg.Title, arg.Description, diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index 2a0eea54..bf41fd74 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -62,7 +62,6 @@ func (h *Handler) handleCreateProject(c echo.Context) error { now := time.Now().Unix() description := req.Description project, err := qtx.CreateProject(ctx, db.CreateProjectParams{ - ID: uuid.New().String(), CompanyID: company.ID, Title: req.Title, Description: &description, From ddc3099d8402ecd8b83323c6a154e43a5db0a6b4 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 30 Dec 2024 17:07:19 -0500 Subject: [PATCH 12/21] remove redundant subquery in UpdateProjectAnswer --- backend/.sqlc/queries/projects.sql | 7 +------ backend/db/projects.sql.go | 15 ++------------- backend/internal/v1/v1_projects/projects.go | 18 +++++++++++++----- 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/backend/.sqlc/queries/projects.sql b/backend/.sqlc/queries/projects.sql index 1ea97d3c..8d625354 100644 --- a/backend/.sqlc/queries/projects.sql +++ b/backend/.sqlc/queries/projects.sql @@ -32,12 +32,7 @@ SET updated_at = extract(epoch from now()) WHERE project_answers.id = $2 - AND project_id = $3 - AND project_id IN ( - SELECT projects.id - FROM projects - WHERE projects.company_id = $4 - ) + AND project_id = $3 RETURNING *; -- name: GetProjectAnswers :many diff --git a/backend/db/projects.sql.go b/backend/db/projects.sql.go index 8723a23e..64b00b10 100644 --- a/backend/db/projects.sql.go +++ b/backend/db/projects.sql.go @@ -454,12 +454,7 @@ SET updated_at = extract(epoch from now()) WHERE project_answers.id = $2 - AND project_id = $3 - AND project_id IN ( - SELECT projects.id - FROM projects - WHERE projects.company_id = $4 - ) + AND project_id = $3 RETURNING id, project_id, question_id, answer, created_at, updated_at ` @@ -467,16 +462,10 @@ type UpdateProjectAnswerParams struct { Answer string ID string ProjectID string - CompanyID string } func (q *Queries) UpdateProjectAnswer(ctx context.Context, arg UpdateProjectAnswerParams) (ProjectAnswer, error) { - row := q.db.QueryRow(ctx, updateProjectAnswer, - arg.Answer, - arg.ID, - arg.ProjectID, - arg.CompanyID, - ) + row := q.db.QueryRow(ctx, updateProjectAnswer, arg.Answer, arg.ID, arg.ProjectID) var i ProjectAnswer err := row.Scan( &i.ID, diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index bf41fd74..dc47f608 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -212,22 +212,31 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { return v1_common.Fail(c, 400, "Project ID is required", nil) } - // Get user ID from context + // Get user ID from context and get their company userID, err := v1_common.GetUserID(c) if err != nil { return v1_common.Fail(c, 401, "Unauthorized", err) } + // Get company owned by user + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + if err != nil { + return v1_common.Fail(c, 404, "Company not found", err) + } + // Parse request body var req PatchAnswerRequest if err := c.Bind(&req); err != nil { return v1_common.Fail(c, 400, "Invalid request body", err) } - // Get company owned by user - company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + // Verify project belongs to user's 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, "Company not found", err) + return v1_common.Fail(c, 404, "Project not found", err) } // Get the question for this answer to check validations @@ -256,7 +265,6 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { Answer: req.Content, ID: req.AnswerID, ProjectID: projectID, - CompanyID: company.ID, }) if err != nil { if err == sql.ErrNoRows { From 088252e65963e47b30ce2868b27a205c7d06a0e8 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 30 Dec 2024 18:12:51 -0500 Subject: [PATCH 13/21] incremental answer creation/validation --- backend/.sqlc/queries/projects.sql | 18 ++- backend/db/projects.sql.go | 53 +++++++++ backend/internal/tests/projects_test.go | 100 ++++++++++------- backend/internal/v1/v1_projects/projects.go | 115 +++++++++++++------- backend/internal/v1/v1_projects/routes.go | 3 + backend/internal/v1/v1_projects/types.go | 21 ++++ 6 files changed, 233 insertions(+), 77 deletions(-) diff --git a/backend/.sqlc/queries/projects.sql b/backend/.sqlc/queries/projects.sql index 8d625354..803bd36d 100644 --- a/backend/.sqlc/queries/projects.sql +++ b/backend/.sqlc/queries/projects.sql @@ -117,4 +117,20 @@ 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; \ No newline at end of file +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/projects.sql.go b/backend/db/projects.sql.go index 64b00b10..1e31c7f5 100644 --- a/backend/db/projects.sql.go +++ b/backend/db/projects.sql.go @@ -53,6 +53,38 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P 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 @@ -320,6 +352,27 @@ func (q *Queries) GetProjectDocuments(ctx context.Context, projectID string) ([] 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 ` diff --git a/backend/internal/tests/projects_test.go b/backend/internal/tests/projects_test.go index c618c593..e7f85ec2 100644 --- a/backend/internal/tests/projects_test.go +++ b/backend/internal/tests/projects_test.go @@ -168,67 +168,69 @@ func TestProjectEndpoints(t *testing.T) { t.Run("Submit Project", func(t *testing.T) { /* * "Submit Project" test verifies the complete submission flow: - * 1. Fetches questions/answers for the project - * 2. Updates each answer with valid data: - * - Website URL for company website - * - Detailed product description (>100 chars) - * - Value proposition description - * 3. Submits the completed project - * 4. Verifies project status changes to 'pending' + * 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 questions/answers - path := fmt.Sprintf("/api/v1/project/%s/answers", projectID) - req := httptest.NewRequest(http.MethodGet, path, nil) + // 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 answers response: %s", rec.Body.String()) + t.Logf("Get questions response: %s", rec.Body.String()) t.FailNow() } - var answersResp struct { - Answers []struct { - ID string `json:"id"` - QuestionID string `json:"question_id"` - Question string `json:"question"` - Answer string `json:"answer"` - Section string `json:"section"` - } `json:"answers"` + 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(&answersResp) + err := json.NewDecoder(rec.Body).Decode(&questionsResp) assert.NoError(t, err) - assert.NotEmpty(t, answersResp.Answers, "Should have questions to answer") + assert.NotEmpty(t, questionsResp.Questions, "Should have questions available") - // First patch each answer individually - for _, q := range answersResp.Answers { + // 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. Our system uses advanced cryptography and distributed ledger technology to ensure tamper-proof identity verification." + 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 = "We provide secure, decentralized identity verification that's faster and more reliable than traditional methods. Our solution reduces verification time by 90% while increasing security and reducing costs for businesses. We are the only solution that combines biometric verification with blockchain immutability at scale." + 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 } - // Patch the answer - patchBody := map[string]string{ - "content": answer, - "answer_id": q.ID, + // Create the answer + createBody := map[string]interface{}{ + "content": answer, + "project_id": projectID, + "question_id": q.ID, } - patchJSON, err := json.Marshal(patchBody) + createJSON, err := json.Marshal(createBody) assert.NoError(t, err) - patchReq := httptest.NewRequest(http.MethodPatch, path, bytes.NewReader(patchJSON)) - patchReq.Header.Set("Authorization", "Bearer "+accessToken) - patchReq.Header.Set("Content-Type", "application/json") - patchRec := httptest.NewRecorder() - s.GetEcho().ServeHTTP(patchRec, patchReq) - - if !assert.Equal(t, http.StatusOK, patchRec.Code) { - t.Logf("Patch answer response: %s", patchRec.Body.String()) + 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()) } } @@ -350,6 +352,28 @@ func TestProjectEndpoints(t *testing.T) { 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 { diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index dc47f608..d61e23c8 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -20,15 +20,6 @@ import ( * It handles project creation, retrieval, document management, and submission workflows. */ -/* - * ValidationError represents a validation failure for a project question. - * Used when validating project submissions and answers. - */ -type ValidationError struct { - Question string `json:"question"` // The question that failed validation - Message string `json:"message"` // Validation error message -} - func (h *Handler) handleCreateProject(c echo.Context) error { var req CreateProjectRequest if err := v1_common.BindandValidate(c, &req); err != nil { @@ -47,44 +38,21 @@ func (h *Handler) handleCreateProject(c echo.Context) error { return v1_common.Fail(c, 404, "Company not found", err) } - // Start a transaction - ctx := c.Request().Context() - tx, err := h.server.GetDB().Begin(ctx) - if err != nil { - return v1_common.Fail(c, 500, "Failed to start transaction", err) - } - defer tx.Rollback(ctx) // Will be no-op if committed - - // Create queries with transaction - qtx := h.server.GetQueries().WithTx(tx) - - // Create project within transaction + // Create project now := time.Now().Unix() description := req.Description - project, err := qtx.CreateProject(ctx, db.CreateProjectParams{ + 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, + Status: db.ProjectStatusDraft, + CreatedAt: now, + UpdatedAt: now, }) if err != nil { return v1_common.Fail(c, 500, "Failed to create project", err) } - // Create empty answers within same transaction - _, err = qtx.CreateProjectAnswers(ctx, project.ID) - if err != nil { - return v1_common.Fail(c, 500, "Failed to create project answers", err) - } - - // Commit the transaction - if err := tx.Commit(ctx); err != nil { - return v1_common.Fail(c, 500, "Failed to commit transaction", err) - } - - // Return success response return c.JSON(200, ProjectResponse{ ID: project.ID, Title: project.Title, @@ -249,7 +217,6 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { if question.Validations != nil && *question.Validations != "" { if !isValidAnswer(req.Content, *question.Validations) { return c.JSON(http.StatusBadRequest, map[string]interface{}{ - "message": "Validation failed", "validation_errors": []ValidationError{ { Question: question.Question, @@ -729,3 +696,75 @@ func (h *Handler) handleSubmitProject(c echo.Context) error { "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/routes.go b/backend/internal/v1/v1_projects/routes.go index 77cb8529..d0af1ef8 100644 --- a/backend/internal/v1/v1_projects/routes.go +++ b/backend/internal/v1/v1_projects/routes.go @@ -25,6 +25,7 @@ func SetupRoutes(g *echo.Group, s interfaces.CoreServer) { // Project answers answers := projects.Group("/:id/answers") answers.GET("", h.handleGetProjectAnswers) + projects.POST("/:id/answer", h.handleCreateAnswer) answers.PATCH("", h.handlePatchProjectAnswer) // Project documents @@ -45,4 +46,6 @@ func SetupRoutes(g *echo.Group, s interfaces.CoreServer) { })) 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 cb574ce5..7152d989 100644 --- a/backend/internal/v1/v1_projects/types.go +++ b/backend/internal/v1/v1_projects/types.go @@ -68,4 +68,25 @@ type AnswerSubmission struct { 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 From 4f3478e57050f9b1bd502ba69989bb2c70251268 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 30 Dec 2024 19:51:53 -0500 Subject: [PATCH 14/21] remove redundant user creation --- backend/internal/tests/helpers.go | 31 ++++++++++++------------ backend/internal/tests/server_test.go | 34 ++++++--------------------- 2 files changed, 23 insertions(+), 42 deletions(-) diff --git a/backend/internal/tests/helpers.go b/backend/internal/tests/helpers.go index 87fc4144..ee9ceaac 100644 --- a/backend/internal/tests/helpers.go +++ b/backend/internal/tests/helpers.go @@ -5,6 +5,7 @@ import ( "KonferCA/SPUR/internal/server" "context" "time" + "fmt" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" @@ -17,7 +18,7 @@ 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" // Hash the password @@ -27,15 +28,15 @@ func createTestUser(ctx context.Context, s *server.Server) (string, string, stri } _, 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))`, + 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 @@ -66,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) diff --git a/backend/internal/tests/server_test.go b/backend/internal/tests/server_test.go index 9a018f3a..94946e82 100644 --- a/backend/internal/tests/server_test.go +++ b/backend/internal/tests/server_test.go @@ -480,43 +480,23 @@ func TestServer(t *testing.T) { }) t.Run("/auth/verify-email - 400 Bad Request - deny expired email token", func(t *testing.T) { - // Create user with unique email - email := fmt.Sprintf("test-verify-email-expired-%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) - - // Create context for database operations ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() - // Get user from database - var user db.User - err = s.DBPool.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&user.ID) + + userID, email, _, err := createTestUser(ctx, s) assert.NoError(t, err) + // Generate expired email token using helper exp := time.Now().Add(-(time.Minute * 30)).UTC() - tokenID, err := createTestEmailToken(ctx, user.ID, exp, s) + tokenID, err := createTestEmailToken(ctx, userID, 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() - + // 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) var apiErr v1_common.APIError From d77f587d4854469efb7a8b12544fcef373e1246f Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 30 Dec 2024 20:04:30 -0500 Subject: [PATCH 15/21] remove userID to use user.ID directly instead --- backend/internal/middleware/jwt.go | 2 - backend/internal/v1/v1_projects/projects.go | 159 ++++++++++---------- 2 files changed, 82 insertions(+), 79 deletions(-) diff --git a/backend/internal/middleware/jwt.go b/backend/internal/middleware/jwt.go index 6a79f42a..60926efa 100644 --- a/backend/internal/middleware/jwt.go +++ b/backend/internal/middleware/jwt.go @@ -10,7 +10,6 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v4" - "github.com/google/uuid" ) // Auth creates a middleware that validates JWT access tokens with specified user roles @@ -81,7 +80,6 @@ func AuthWithConfig(config AuthConfig, dbPool *pgxpool.Pool) echo.MiddlewareFunc Salt: user.TokenSalt, }) c.Set("user", &user) - c.Set("user_id", uuid.MustParse(claims.UserID)) return next(c) } diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index d61e23c8..13806fd7 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -20,24 +20,38 @@ import ( * It handles project creation, retrieval, document management, and submission workflows. */ -func (h *Handler) handleCreateProject(c echo.Context) error { - var req CreateProjectRequest - if err := v1_common.BindandValidate(c, &req); err != nil { - return v1_common.Fail(c, 400, "Invalid request", err) +// 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") } - // Get user ID from context - userID, err := v1_common.GetUserID(c) + return user, nil +} + +func (h *Handler) handleCreateProject(c echo.Context) error { + user, err := getUserFromContext(c) if err != nil { - return v1_common.Fail(c, 401, "Unauthorized", err) + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) } // Get company owned by user - company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + 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 @@ -45,9 +59,9 @@ func (h *Handler) handleCreateProject(c echo.Context) error { CompanyID: company.ID, Title: req.Title, Description: &description, - Status: db.ProjectStatusDraft, - CreatedAt: now, - UpdatedAt: now, + Status: db.ProjectStatusDraft, + CreatedAt: now, + UpdatedAt: now, }) if err != nil { return v1_common.Fail(c, 500, "Failed to create project", err) @@ -73,14 +87,12 @@ func (h *Handler) handleCreateProject(c echo.Context) error { * Returns array of ProjectResponse with basic project details */ func (h *Handler) handleGetProjects(c echo.Context) error { - // Get user ID from context - userID, err := v1_common.GetUserID(c) - if err != nil { - return v1_common.Fail(c, 401, "Unauthorized", err) - } + // Get user from claims + user := c.Get("user").(db.User) + userID := user.ID // Get company owned by user - company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID) if err != nil { return v1_common.Fail(c, 404, "Company not found", err) } @@ -120,10 +132,15 @@ func (h *Handler) handleGetProjects(c echo.Context) error { * - Returns 404 if project not found or unauthorized */ func (h *Handler) handleGetProject(c echo.Context) error { - // Get user ID from context - userID, err := v1_common.GetUserID(c) + user, err := getUserFromContext(c) if err != nil { - return v1_common.Fail(c, 401, "Unauthorized", err) + 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 @@ -132,12 +149,6 @@ func (h *Handler) handleGetProject(c echo.Context) error { 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(), userID.String()) - if err != nil { - return v1_common.Fail(c, 404, "Company not found", err) - } - // Get project (with company ID check for security) project, err := h.server.GetQueries().GetProjectByID(c.Request().Context(), db.GetProjectByIDParams{ ID: projectID, @@ -174,24 +185,23 @@ func (h *Handler) handleGetProject(c echo.Context) error { * - Verifies project belongs to user's company */ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { - // Get project ID from URL - projectID := c.Param("id") - if projectID == "" { - return v1_common.Fail(c, 400, "Project ID is required", nil) - } - - // Get user ID from context and get their company - userID, err := v1_common.GetUserID(c) + user, err := getUserFromContext(c) if err != nil { - return v1_common.Fail(c, 401, "Unauthorized", err) + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) } // Get company owned by user - company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + 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) + } + // Parse request body var req PatchAnswerRequest if err := c.Bind(&req); err != nil { @@ -257,24 +267,23 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { * - Verifies project belongs to user's company */ func (h *Handler) handleGetProjectAnswers(c echo.Context) error { - // Get project ID from URL - projectID := c.Param("id") - if projectID == "" { - return v1_common.Fail(c, 400, "Project ID is required", nil) - } - - // Get user ID from context - userID, err := v1_common.GetUserID(c) + user, err := getUserFromContext(c) if err != nil { - return v1_common.Fail(c, 401, "Unauthorized", err) + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) } - // Verify company ownership - company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + // 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 { @@ -321,16 +330,16 @@ func (h *Handler) handleGetProjectAnswers(c echo.Context) error { * - 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 user ID from context - userID, err := v1_common.GetUserID(c) - if err != nil { - return v1_common.Fail(c, 401, "Unauthorized", err) - } // Get project ID from URL projectID := c.Param("id") @@ -338,8 +347,8 @@ func (h *Handler) handleUploadProjectDocument(c echo.Context) error { return v1_common.Fail(c, 400, "Project ID is required", nil) } - // Get company owned by user to verify ownership - company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + // 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) } @@ -411,10 +420,15 @@ func (h *Handler) handleUploadProjectDocument(c echo.Context) error { * - Verifies project belongs to user's company */ func (h *Handler) handleGetProjectDocuments(c echo.Context) error { - // Get user ID from context - userID, err := v1_common.GetUserID(c) + 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, 401, "Unauthorized", err) + return v1_common.Fail(c, 404, "Company not found", err) } // Get project ID from URL @@ -423,12 +437,6 @@ func (h *Handler) handleGetProjectDocuments(c echo.Context) error { return v1_common.Fail(c, 400, "Project ID is required", nil) } - // Get company owned by user to verify ownership - company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) - 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, @@ -474,11 +482,9 @@ func (h *Handler) handleGetProjectDocuments(c echo.Context) error { * - Verifies document belongs to user's project */ func (h *Handler) handleDeleteProjectDocument(c echo.Context) error { - // Get user ID from context - userID, err := v1_common.GetUserID(c) - if err != nil { - return v1_common.Fail(c, 401, "Unauthorized", nil) - } + // Get user from claims + user := c.Get("user").(db.User) + userID := user.ID // Get project ID and document ID from URL projectID := c.Param("id") @@ -488,7 +494,7 @@ func (h *Handler) handleDeleteProjectDocument(c echo.Context) error { } // Get company owned by user - company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID) if err != nil { return v1_common.Fail(c, 404, "Company not found", nil) } @@ -544,15 +550,15 @@ func (h *Handler) handleDeleteProjectDocument(c echo.Context) error { * - Basic project details including status */ func (h *Handler) handleListCompanyProjects(c echo.Context) error { - userID, err := v1_common.GetUserID(c) + user, err := getUserFromContext(c) if err != nil { - return v1_common.Fail(c, 401, "Unauthorized", nil) + return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) } // Get company owned by user - company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID.String()) + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) if err != nil { - return v1_common.Fail(c, 404, "Company not found", nil) + return v1_common.Fail(c, 404, "Company not found", err) } // Get all projects for this company @@ -599,16 +605,15 @@ func (h *Handler) handleListCompanyProjects(c echo.Context) error { * 4. Returns success with new status */ func (h *Handler) handleSubmitProject(c echo.Context) error { - // Get user ID and verify ownership first - userID, err := v1_common.GetUserID(c) + 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(), userID.String()) + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) if err != nil { - return v1_common.Fail(c, http.StatusNotFound, "Company not found", err) + return v1_common.Fail(c, 404, "Company not found", err) } projectID := c.Param("id") From daaee272b96dc989a630658b99bb7369d4e1f3d9 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 30 Dec 2024 20:12:54 -0500 Subject: [PATCH 16/21] check draft status before updating answers --- backend/internal/v1/v1_projects/projects.go | 50 +++++++++++---------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index 13806fd7..0002ede3 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -190,40 +190,19 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { 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) - } - - // Parse request body + // Parse request body first for validation var req PatchAnswerRequest if err := c.Bind(&req); err != nil { return v1_common.Fail(c, 400, "Invalid request body", err) } - // Verify project belongs to user's 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 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 if validations exist + // Validate the answer content first if question.Validations != nil && *question.Validations != "" { if !isValidAnswer(req.Content, *question.Validations) { return c.JSON(http.StatusBadRequest, map[string]interface{}{ @@ -237,6 +216,31 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { } } + // 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, 400, "Project ID is required", nil) + } + + // 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, From 3afc90738980c53a39ed3f729451dfa140585b4e Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 30 Dec 2024 20:14:28 -0500 Subject: [PATCH 17/21] change validation order in project answer updates --- backend/internal/v1/v1_projects/projects.go | 24 +++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index 0002ede3..065f6d12 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -185,24 +185,31 @@ func (h *Handler) handleGetProject(c echo.Context) error { * - Verifies project belongs to user's company */ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { - user, err := getUserFromContext(c) - if err != nil { - return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) + // Validate static parameters first + projectID := c.Param("id") + if projectID == "" { + return v1_common.Fail(c, http.StatusBadRequest, "Project ID is required", nil) } - // Parse request body first for validation + // 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 first + // Validate the answer content if question.Validations != nil && *question.Validations != "" { if !isValidAnswer(req.Content, *question.Validations) { return c.JSON(http.StatusBadRequest, map[string]interface{}{ @@ -222,15 +229,10 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { return v1_common.Fail(c, 404, "Company not found", err) } - projectID := c.Param("id") - if projectID == "" { - return v1_common.Fail(c, 400, "Project ID is required", nil) - } - // Get project and verify status project, err := h.server.GetQueries().GetProjectByID(c.Request().Context(), db.GetProjectByIDParams{ ID: projectID, - CompanyID: company.ID, + CompanyID: company.ID, }) if err != nil { return v1_common.Fail(c, 404, "Project not found", err) From fc8410401fc0288099cbecc09a84546e6ced9037 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 30 Dec 2024 20:16:22 -0500 Subject: [PATCH 18/21] add role-based authorization to project endpoints --- backend/internal/v1/v1_projects/projects.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index 065f6d12..3c520f19 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -41,6 +41,11 @@ func (h *Handler) handleCreateProject(c echo.Context) error { return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) } + // Check user role + if user.Role != db.UserRoleStartupOwner && user.Role != db.UserRoleAdmin { + return v1_common.Fail(c, http.StatusForbidden, "Only startup owners and admins can create projects", nil) + } + // Get company owned by user company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) if err != nil { @@ -137,6 +142,11 @@ func (h *Handler) handleGetProject(c echo.Context) error { return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) } + // Check user role + if user.Role != db.UserRoleStartupOwner && user.Role != db.UserRoleAdmin { + return v1_common.Fail(c, http.StatusForbidden, "Only startup owners and admins can view projects", nil) + } + // Get company owned by user company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) if err != nil { @@ -203,6 +213,11 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) } + // Check user role + if user.Role != db.UserRoleStartupOwner && user.Role != db.UserRoleAdmin { + return v1_common.Fail(c, http.StatusForbidden, "Only startup owners and admins can modify projects", nil) + } + // Get the question for this answer to check validations question, err := h.server.GetQueries().GetQuestionByAnswerID(c.Request().Context(), req.AnswerID) if err != nil { From 3e28c08064ad6e5bf76ade03bb3f2f6cce4779d6 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 30 Dec 2024 20:41:05 -0500 Subject: [PATCH 19/21] a --- backend/internal/v1/v1_projects/projects.go | 33 +++++++-------------- backend/internal/v1/v1_projects/routes.go | 2 +- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index 3c520f19..154e276a 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -41,11 +41,6 @@ func (h *Handler) handleCreateProject(c echo.Context) error { return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) } - // Check user role - if user.Role != db.UserRoleStartupOwner && user.Role != db.UserRoleAdmin { - return v1_common.Fail(c, http.StatusForbidden, "Only startup owners and admins can create projects", nil) - } - // Get company owned by user company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) if err != nil { @@ -92,12 +87,13 @@ func (h *Handler) handleCreateProject(c echo.Context) error { * Returns array of ProjectResponse with basic project details */ func (h *Handler) handleGetProjects(c echo.Context) error { - // Get user from claims - user := c.Get("user").(db.User) - userID := user.ID + 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(), userID) + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) if err != nil { return v1_common.Fail(c, 404, "Company not found", err) } @@ -142,11 +138,6 @@ func (h *Handler) handleGetProject(c echo.Context) error { return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) } - // Check user role - if user.Role != db.UserRoleStartupOwner && user.Role != db.UserRoleAdmin { - return v1_common.Fail(c, http.StatusForbidden, "Only startup owners and admins can view projects", nil) - } - // Get company owned by user company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) if err != nil { @@ -213,11 +204,6 @@ func (h *Handler) handlePatchProjectAnswer(c echo.Context) error { return v1_common.Fail(c, http.StatusUnauthorized, "Unauthorized", err) } - // Check user role - if user.Role != db.UserRoleStartupOwner && user.Role != db.UserRoleAdmin { - return v1_common.Fail(c, http.StatusForbidden, "Only startup owners and admins can modify projects", nil) - } - // Get the question for this answer to check validations question, err := h.server.GetQueries().GetQuestionByAnswerID(c.Request().Context(), req.AnswerID) if err != nil { @@ -503,9 +489,10 @@ func (h *Handler) handleGetProjectDocuments(c echo.Context) error { * - Verifies document belongs to user's project */ func (h *Handler) handleDeleteProjectDocument(c echo.Context) error { - // Get user from claims - user := c.Get("user").(db.User) - userID := user.ID + 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") @@ -515,7 +502,7 @@ func (h *Handler) handleDeleteProjectDocument(c echo.Context) error { } // Get company owned by user - company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), userID) + company, err := h.server.GetQueries().GetCompanyByUserID(c.Request().Context(), user.ID) if err != nil { return v1_common.Fail(c, 404, "Company not found", nil) } diff --git a/backend/internal/v1/v1_projects/routes.go b/backend/internal/v1/v1_projects/routes.go index d0af1ef8..835f34ed 100644 --- a/backend/internal/v1/v1_projects/routes.go +++ b/backend/internal/v1/v1_projects/routes.go @@ -13,7 +13,7 @@ func SetupRoutes(g *echo.Group, s interfaces.CoreServer) { // Base project routes projects := g.Group("/project", middleware.AuthWithConfig(middleware.AuthConfig{ AcceptTokenType: "access_token", - AcceptUserRoles: []db.UserRole{db.UserRoleStartupOwner}, + AcceptUserRoles: []db.UserRole{db.UserRoleStartupOwner, db.UserRoleAdmin}, }, s.GetDB())) // Project management From 88db7432b39c8da64b6e87773dba43d0916fd215 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 30 Dec 2024 20:42:11 -0500 Subject: [PATCH 20/21] check draft status before submitting project --- backend/internal/v1/v1_projects/projects.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/internal/v1/v1_projects/projects.go b/backend/internal/v1/v1_projects/projects.go index 154e276a..4f89263a 100644 --- a/backend/internal/v1/v1_projects/projects.go +++ b/backend/internal/v1/v1_projects/projects.go @@ -629,8 +629,8 @@ func (h *Handler) handleSubmitProject(c echo.Context) error { return v1_common.Fail(c, http.StatusBadRequest, "Project ID is required", nil) } - // Verify project belongs to company - _, err = h.server.GetQueries().GetProjectByID(c.Request().Context(), db.GetProjectByIDParams{ + // Verify project belongs to company and check status + project, err := h.server.GetQueries().GetProjectByID(c.Request().Context(), db.GetProjectByIDParams{ ID: projectID, CompanyID: company.ID, }) @@ -638,6 +638,11 @@ func (h *Handler) handleSubmitProject(c echo.Context) error { 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 { From 13a267d00d05e8bedc9abd623f539cb1c24fad68 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 30 Dec 2024 21:13:44 -0500 Subject: [PATCH 21/21] update email verification test to check HTML response --- backend/internal/tests/server_test.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/internal/tests/server_test.go b/backend/internal/tests/server_test.go index 5454cf29..3946f049 100644 --- a/backend/internal/tests/server_test.go +++ b/backend/internal/tests/server_test.go @@ -5,7 +5,6 @@ import ( "KonferCA/SPUR/internal/jwt" "KonferCA/SPUR/internal/server" "KonferCA/SPUR/internal/v1/v1_auth" - "KonferCA/SPUR/internal/v1/v1_common" "bytes" "context" @@ -468,19 +467,23 @@ func TestServer(t *testing.T) { assert.NoError(t, err) }) - t.Run("/auth/verify-email - 400 Bad Request - missing token query parameter", func(t *testing.T) { + t.Run("/auth/verify-email - missing token query parameter", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email", nil) rec := httptest.NewRecorder() - s.Echo.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) - var apiErr v1_common.APIError - err := json.NewDecoder(rec.Body).Decode(&apiErr) + doc, err := goquery.NewDocumentFromReader(rec.Body) assert.NoError(t, err) - assert.Equal(t, http.StatusBadRequest, rec.Code) - assert.Equal(t, v1_common.ErrorTypeBadRequest, apiErr.Type) - assert.Equal(t, "Missing required query parameter: 'token'", apiErr.Message) + title := doc.Find(`[data-testid="card-title"]`).Text() + assert.Equal(t, title, "Failed to Verify Email") + details := doc.Find(`[data-testid="card-details"]`).Text() + assert.Contains(t, details, "Missing validation token") + icon := doc.Find(`[data-testid="x-icon"]`) + assert.Equal(t, 1, icon.Length()) + button := doc.Find(`[data-testid="go-to-dashboard"]`) + assert.Equal(t, 1, button.Length()) }) t.Run("/auth/verify-email - deny expired email token", func(t *testing.T) {