From 39fc9e9e3fd24e7b3ed16254d745d680f42bbe65 Mon Sep 17 00:00:00 2001 From: bagas Date: Sun, 22 Feb 2026 00:15:25 +0700 Subject: [PATCH] first commit --- .gitignore | 2 + go.mod | 30 ++ go.sum | 57 +++ .../sqlc/migrations/000001_init_auth.down.sql | 7 + .../sqlc/migrations/000001_init_auth.up.sql | 23 + .../migrations/000002_init_forms.down.sql | 11 + .../sqlc/migrations/000002_init_forms.up.sql | 47 ++ internal/db/sqlc/queries/auth.sql | 42 ++ internal/db/sqlc/queries/forms.sql | 33 ++ internal/db/sqlc/queries/question_options.sql | 18 + internal/db/sqlc/queries/questions.sql | 13 + internal/db/sqlc/repository/auth.sql.go | 166 +++++++ internal/db/sqlc/repository/db.go | 32 ++ internal/db/sqlc/repository/forms.sql.go | 158 +++++++ internal/db/sqlc/repository/models.go | 103 +++++ .../sqlc/repository/question_options.sql.go | 122 +++++ internal/db/sqlc/repository/questions.sql.go | 89 ++++ internal/db/sqlc/sqlc.yaml | 26 ++ internal/handler/auth.go | 376 +++++++++++++++ internal/handler/form.go | 435 ++++++++++++++++++ internal/handler/handler.go | 37 ++ internal/jwt/jwt.go | 59 +++ internal/middleware/middleware.go | 103 +++++ internal/server/server.go | 63 +++ main.go | 64 +++ 25 files changed, 2116 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/db/sqlc/migrations/000001_init_auth.down.sql create mode 100644 internal/db/sqlc/migrations/000001_init_auth.up.sql create mode 100644 internal/db/sqlc/migrations/000002_init_forms.down.sql create mode 100644 internal/db/sqlc/migrations/000002_init_forms.up.sql create mode 100644 internal/db/sqlc/queries/auth.sql create mode 100644 internal/db/sqlc/queries/forms.sql create mode 100644 internal/db/sqlc/queries/question_options.sql create mode 100644 internal/db/sqlc/queries/questions.sql create mode 100644 internal/db/sqlc/repository/auth.sql.go create mode 100644 internal/db/sqlc/repository/db.go create mode 100644 internal/db/sqlc/repository/forms.sql.go create mode 100644 internal/db/sqlc/repository/models.go create mode 100644 internal/db/sqlc/repository/question_options.sql.go create mode 100644 internal/db/sqlc/repository/questions.sql.go create mode 100644 internal/db/sqlc/sqlc.yaml create mode 100644 internal/handler/auth.go create mode 100644 internal/handler/form.go create mode 100644 internal/handler/handler.go create mode 100644 internal/jwt/jwt.go create mode 100644 internal/middleware/middleware.go create mode 100644 internal/server/server.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..451fb35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.idea \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d21bb0 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module ristek-task-be + +go 1.26.0 + +require ( + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.8.0 + github.com/joho/godotenv v1.5.1 + github.com/lestrrat-go/jwx/v3 v3.0.13 + golang.org/x/crypto v0.48.0 +) + +require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/goccy/go-json v0.10.3 // 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/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/valyala/fastjson v1.6.7 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..359c52f --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +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/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +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/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= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.2 h1:7u4HUaD0NQbf2/n5+fyp+T10hNCsAnwKfqn4A4Baif0= +github.com/lestrrat-go/httprc/v3 v3.0.2/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= +github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +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/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= +github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= diff --git a/internal/db/sqlc/migrations/000001_init_auth.down.sql b/internal/db/sqlc/migrations/000001_init_auth.down.sql new file mode 100644 index 0000000..382b12d --- /dev/null +++ b/internal/db/sqlc/migrations/000001_init_auth.down.sql @@ -0,0 +1,7 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_refresh_tokens_user_id; +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS users; + +COMMIT; \ No newline at end of file diff --git a/internal/db/sqlc/migrations/000001_init_auth.up.sql b/internal/db/sqlc/migrations/000001_init_auth.up.sql new file mode 100644 index 0000000..691b8b4 --- /dev/null +++ b/internal/db/sqlc/migrations/000001_init_auth.up.sql @@ -0,0 +1,23 @@ +BEGIN; + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); + +COMMIT; \ No newline at end of file diff --git a/internal/db/sqlc/migrations/000002_init_forms.down.sql b/internal/db/sqlc/migrations/000002_init_forms.down.sql new file mode 100644 index 0000000..bfbebee --- /dev/null +++ b/internal/db/sqlc/migrations/000002_init_forms.down.sql @@ -0,0 +1,11 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_question_options_question; +DROP INDEX IF EXISTS idx_questions_form_id; +DROP INDEX IF EXISTS idx_forms_user_id; +DROP TABLE IF EXISTS question_options; +DROP TABLE IF EXISTS questions; +DROP TABLE IF EXISTS forms; +DROP TYPE IF EXISTS question_type; + +COMMIT; \ No newline at end of file diff --git a/internal/db/sqlc/migrations/000002_init_forms.up.sql b/internal/db/sqlc/migrations/000002_init_forms.up.sql new file mode 100644 index 0000000..7bd96df --- /dev/null +++ b/internal/db/sqlc/migrations/000002_init_forms.up.sql @@ -0,0 +1,47 @@ +BEGIN; + +CREATE TYPE question_type AS ENUM ( + 'short_text', + 'long_text', + 'multiple_choice', + 'checkbox', + 'dropdown', + 'date', + 'rating' +); + +CREATE TABLE forms ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + response_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE questions ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + form_id UUID NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + type question_type NOT NULL, + title TEXT NOT NULL, + required BOOLEAN NOT NULL DEFAULT false, + position INTEGER NOT NULL, + PRIMARY KEY (form_id, id) +); + +CREATE TABLE question_options ( + id SERIAL PRIMARY KEY, + form_id UUID NOT NULL, + question_id UUID NOT NULL, + label TEXT NOT NULL, + position INTEGER NOT NULL, + FOREIGN KEY (form_id, question_id) + REFERENCES questions(form_id, id) ON DELETE CASCADE +); + +CREATE INDEX idx_forms_user_id ON forms(user_id); +CREATE INDEX idx_questions_form_id ON questions(form_id); +CREATE INDEX idx_question_options_question ON question_options(form_id, question_id); + +COMMIT; \ No newline at end of file diff --git a/internal/db/sqlc/queries/auth.sql b/internal/db/sqlc/queries/auth.sql new file mode 100644 index 0000000..a9b282b --- /dev/null +++ b/internal/db/sqlc/queries/auth.sql @@ -0,0 +1,42 @@ +-- name: CreateUser :exec +INSERT INTO users (email, password_hash) +VALUES ($1, $2); + +-- name: GetUserByEmail :one +SELECT * FROM users +WHERE email = $1 + LIMIT 1; + +-- name: GetUserByID :one +SELECT * FROM users +WHERE id = $1 + LIMIT 1; + +-- name: UpdateUserPassword :one +UPDATE users +SET password_hash = $2, + updated_at = now() +WHERE id = $1 + RETURNING *; + +-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (user_id, token, expires_at) +VALUES ($1, $2, $3) + RETURNING *; + +-- name: GetRefreshToken :one +SELECT * FROM refresh_tokens +WHERE token = $1 + LIMIT 1; + +-- name: DeleteRefreshToken :exec +DELETE FROM refresh_tokens +WHERE token = $1; + +-- name: DeleteUserRefreshTokens :exec +DELETE FROM refresh_tokens +WHERE user_id = $1; + +-- name: DeleteExpiredRefreshTokens :exec +DELETE FROM refresh_tokens +WHERE expires_at < now(); \ No newline at end of file diff --git a/internal/db/sqlc/queries/forms.sql b/internal/db/sqlc/queries/forms.sql new file mode 100644 index 0000000..64083df --- /dev/null +++ b/internal/db/sqlc/queries/forms.sql @@ -0,0 +1,33 @@ +-- name: ListForms :many +SELECT * FROM forms +WHERE user_id = $1 +ORDER BY created_at DESC; + +-- name: GetFormByID :one +SELECT * FROM forms +WHERE id = $1 + LIMIT 1; + +-- name: CreateForm :one +INSERT INTO forms (user_id, title, description) +VALUES ($1, $2, $3) + RETURNING *; + +-- name: UpdateForm :one +UPDATE forms +SET title = $2, + description = $3, + updated_at = now() +WHERE id = $1 + RETURNING *; + +-- name: DeleteForm :exec +DELETE FROM forms +WHERE id = $1; + +-- name: IncrementResponseCount :one +UPDATE forms +SET response_count = response_count + 1, + updated_at = now() +WHERE id = $1 + RETURNING *; \ No newline at end of file diff --git a/internal/db/sqlc/queries/question_options.sql b/internal/db/sqlc/queries/question_options.sql new file mode 100644 index 0000000..7c6e3b7 --- /dev/null +++ b/internal/db/sqlc/queries/question_options.sql @@ -0,0 +1,18 @@ +-- name: CreateQuestionOption :one +INSERT INTO question_options (form_id, question_id, label, position) +VALUES ($1, $2, $3, $4) + RETURNING *; + +-- name: GetOptionsByFormID :many +SELECT * FROM question_options +WHERE form_id = $1 +ORDER BY question_id, position ASC; + +-- name: GetOptionsByQuestionID :many +SELECT * FROM question_options +WHERE form_id = $1 AND question_id = $2 +ORDER BY position ASC; + +-- name: DeleteOptionsByFormID :exec +DELETE FROM question_options +WHERE form_id = $1; \ No newline at end of file diff --git a/internal/db/sqlc/queries/questions.sql b/internal/db/sqlc/queries/questions.sql new file mode 100644 index 0000000..d9be96b --- /dev/null +++ b/internal/db/sqlc/queries/questions.sql @@ -0,0 +1,13 @@ +-- name: CreateQuestion :one +INSERT INTO questions (form_id, type, title, required, position) +VALUES ($1, $2, $3, $4, $5) + RETURNING *; + +-- name: GetQuestionsByFormID :many +SELECT * FROM questions +WHERE form_id = $1 +ORDER BY position ASC; + +-- name: DeleteQuestionsByFormID :exec +DELETE FROM questions +WHERE form_id = $1; \ No newline at end of file diff --git a/internal/db/sqlc/repository/auth.sql.go b/internal/db/sqlc/repository/auth.sql.go new file mode 100644 index 0000000..624dae7 --- /dev/null +++ b/internal/db/sqlc/repository/auth.sql.go @@ -0,0 +1,166 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: auth.sql + +package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createRefreshToken = `-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (user_id, token, expires_at) +VALUES ($1, $2, $3) + RETURNING id, user_id, token, expires_at, created_at +` + +type CreateRefreshTokenParams struct { + UserID uuid.UUID + Token string + ExpiresAt pgtype.Timestamptz +} + +func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) { + row := q.db.QueryRow(ctx, createRefreshToken, arg.UserID, arg.Token, arg.ExpiresAt) + var i RefreshToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.Token, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const createUser = `-- name: CreateUser :exec +INSERT INTO users (email, password_hash) +VALUES ($1, $2) +` + +type CreateUserParams struct { + Email string + PasswordHash string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { + _, err := q.db.Exec(ctx, createUser, arg.Email, arg.PasswordHash) + return err +} + +const deleteExpiredRefreshTokens = `-- name: DeleteExpiredRefreshTokens :exec +DELETE FROM refresh_tokens +WHERE expires_at < now() +` + +func (q *Queries) DeleteExpiredRefreshTokens(ctx context.Context) error { + _, err := q.db.Exec(ctx, deleteExpiredRefreshTokens) + return err +} + +const deleteRefreshToken = `-- name: DeleteRefreshToken :exec +DELETE FROM refresh_tokens +WHERE token = $1 +` + +func (q *Queries) DeleteRefreshToken(ctx context.Context, token string) error { + _, err := q.db.Exec(ctx, deleteRefreshToken, token) + return err +} + +const deleteUserRefreshTokens = `-- name: DeleteUserRefreshTokens :exec +DELETE FROM refresh_tokens +WHERE user_id = $1 +` + +func (q *Queries) DeleteUserRefreshTokens(ctx context.Context, userID uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteUserRefreshTokens, userID) + return err +} + +const getRefreshToken = `-- name: GetRefreshToken :one +SELECT id, user_id, token, expires_at, created_at FROM refresh_tokens +WHERE token = $1 + LIMIT 1 +` + +func (q *Queries) GetRefreshToken(ctx context.Context, token string) (RefreshToken, error) { + row := q.db.QueryRow(ctx, getRefreshToken, token) + var i RefreshToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.Token, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, password_hash, created_at, updated_at FROM users +WHERE email = $1 + LIMIT 1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, email, password_hash, created_at, updated_at FROM users +WHERE id = $1 + LIMIT 1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateUserPassword = `-- name: UpdateUserPassword :one +UPDATE users +SET password_hash = $2, + updated_at = now() +WHERE id = $1 + RETURNING id, email, password_hash, created_at, updated_at +` + +type UpdateUserPasswordParams struct { + ID uuid.UUID + PasswordHash string +} + +func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserPassword, arg.ID, arg.PasswordHash) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/sqlc/repository/db.go b/internal/db/sqlc/repository/db.go new file mode 100644 index 0000000..b196af2 --- /dev/null +++ b/internal/db/sqlc/repository/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/sqlc/repository/forms.sql.go b/internal/db/sqlc/repository/forms.sql.go new file mode 100644 index 0000000..326ea3a --- /dev/null +++ b/internal/db/sqlc/repository/forms.sql.go @@ -0,0 +1,158 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: forms.sql + +package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createForm = `-- name: CreateForm :one +INSERT INTO forms (user_id, title, description) +VALUES ($1, $2, $3) + RETURNING id, user_id, title, description, response_count, created_at, updated_at +` + +type CreateFormParams struct { + UserID uuid.UUID + Title string + Description pgtype.Text +} + +func (q *Queries) CreateForm(ctx context.Context, arg CreateFormParams) (Form, error) { + row := q.db.QueryRow(ctx, createForm, arg.UserID, arg.Title, arg.Description) + var i Form + err := row.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.Description, + &i.ResponseCount, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteForm = `-- name: DeleteForm :exec +DELETE FROM forms +WHERE id = $1 +` + +func (q *Queries) DeleteForm(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteForm, id) + return err +} + +const getFormByID = `-- name: GetFormByID :one +SELECT id, user_id, title, description, response_count, created_at, updated_at FROM forms +WHERE id = $1 + LIMIT 1 +` + +func (q *Queries) GetFormByID(ctx context.Context, id uuid.UUID) (Form, error) { + row := q.db.QueryRow(ctx, getFormByID, id) + var i Form + err := row.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.Description, + &i.ResponseCount, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const incrementResponseCount = `-- name: IncrementResponseCount :one +UPDATE forms +SET response_count = response_count + 1, + updated_at = now() +WHERE id = $1 + RETURNING id, user_id, title, description, response_count, created_at, updated_at +` + +func (q *Queries) IncrementResponseCount(ctx context.Context, id uuid.UUID) (Form, error) { + row := q.db.QueryRow(ctx, incrementResponseCount, id) + var i Form + err := row.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.Description, + &i.ResponseCount, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listForms = `-- name: ListForms :many +SELECT id, user_id, title, description, response_count, created_at, updated_at FROM forms +WHERE user_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListForms(ctx context.Context, userID uuid.UUID) ([]Form, error) { + rows, err := q.db.Query(ctx, listForms, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Form + for rows.Next() { + var i Form + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.Description, + &i.ResponseCount, + &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 updateForm = `-- name: UpdateForm :one +UPDATE forms +SET title = $2, + description = $3, + updated_at = now() +WHERE id = $1 + RETURNING id, user_id, title, description, response_count, created_at, updated_at +` + +type UpdateFormParams struct { + ID uuid.UUID + Title string + Description pgtype.Text +} + +func (q *Queries) UpdateForm(ctx context.Context, arg UpdateFormParams) (Form, error) { + row := q.db.QueryRow(ctx, updateForm, arg.ID, arg.Title, arg.Description) + var i Form + err := row.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.Description, + &i.ResponseCount, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/sqlc/repository/models.go b/internal/db/sqlc/repository/models.go new file mode 100644 index 0000000..13453d9 --- /dev/null +++ b/internal/db/sqlc/repository/models.go @@ -0,0 +1,103 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repository + +import ( + "database/sql/driver" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type QuestionType string + +const ( + QuestionTypeShortText QuestionType = "short_text" + QuestionTypeLongText QuestionType = "long_text" + QuestionTypeMultipleChoice QuestionType = "multiple_choice" + QuestionTypeCheckbox QuestionType = "checkbox" + QuestionTypeDropdown QuestionType = "dropdown" + QuestionTypeDate QuestionType = "date" + QuestionTypeRating QuestionType = "rating" +) + +func (e *QuestionType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = QuestionType(s) + case string: + *e = QuestionType(s) + default: + return fmt.Errorf("unsupported scan type for QuestionType: %T", src) + } + return nil +} + +type NullQuestionType struct { + QuestionType QuestionType + Valid bool // Valid is true if QuestionType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullQuestionType) Scan(value interface{}) error { + if value == nil { + ns.QuestionType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.QuestionType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullQuestionType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.QuestionType), nil +} + +type Form struct { + ID uuid.UUID + UserID uuid.UUID + Title string + Description pgtype.Text + ResponseCount int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type Question struct { + ID uuid.UUID + FormID uuid.UUID + Type QuestionType + Title string + Required bool + Position int32 +} + +type QuestionOption struct { + ID int32 + FormID uuid.UUID + QuestionID uuid.UUID + Label string + Position int32 +} + +type RefreshToken struct { + ID uuid.UUID + UserID uuid.UUID + Token string + ExpiresAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz +} + +type User struct { + ID uuid.UUID + Email string + PasswordHash string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} diff --git a/internal/db/sqlc/repository/question_options.sql.go b/internal/db/sqlc/repository/question_options.sql.go new file mode 100644 index 0000000..072e969 --- /dev/null +++ b/internal/db/sqlc/repository/question_options.sql.go @@ -0,0 +1,122 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: question_options.sql + +package repository + +import ( + "context" + + "github.com/google/uuid" +) + +const createQuestionOption = `-- name: CreateQuestionOption :one +INSERT INTO question_options (form_id, question_id, label, position) +VALUES ($1, $2, $3, $4) + RETURNING id, form_id, question_id, label, position +` + +type CreateQuestionOptionParams struct { + FormID uuid.UUID + QuestionID uuid.UUID + Label string + Position int32 +} + +func (q *Queries) CreateQuestionOption(ctx context.Context, arg CreateQuestionOptionParams) (QuestionOption, error) { + row := q.db.QueryRow(ctx, createQuestionOption, + arg.FormID, + arg.QuestionID, + arg.Label, + arg.Position, + ) + var i QuestionOption + err := row.Scan( + &i.ID, + &i.FormID, + &i.QuestionID, + &i.Label, + &i.Position, + ) + return i, err +} + +const deleteOptionsByFormID = `-- name: DeleteOptionsByFormID :exec +DELETE FROM question_options +WHERE form_id = $1 +` + +func (q *Queries) DeleteOptionsByFormID(ctx context.Context, formID uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteOptionsByFormID, formID) + return err +} + +const getOptionsByFormID = `-- name: GetOptionsByFormID :many +SELECT id, form_id, question_id, label, position FROM question_options +WHERE form_id = $1 +ORDER BY question_id, position ASC +` + +func (q *Queries) GetOptionsByFormID(ctx context.Context, formID uuid.UUID) ([]QuestionOption, error) { + rows, err := q.db.Query(ctx, getOptionsByFormID, formID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QuestionOption + for rows.Next() { + var i QuestionOption + if err := rows.Scan( + &i.ID, + &i.FormID, + &i.QuestionID, + &i.Label, + &i.Position, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getOptionsByQuestionID = `-- name: GetOptionsByQuestionID :many +SELECT id, form_id, question_id, label, position FROM question_options +WHERE form_id = $1 AND question_id = $2 +ORDER BY position ASC +` + +type GetOptionsByQuestionIDParams struct { + FormID uuid.UUID + QuestionID uuid.UUID +} + +func (q *Queries) GetOptionsByQuestionID(ctx context.Context, arg GetOptionsByQuestionIDParams) ([]QuestionOption, error) { + rows, err := q.db.Query(ctx, getOptionsByQuestionID, arg.FormID, arg.QuestionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QuestionOption + for rows.Next() { + var i QuestionOption + if err := rows.Scan( + &i.ID, + &i.FormID, + &i.QuestionID, + &i.Label, + &i.Position, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/sqlc/repository/questions.sql.go b/internal/db/sqlc/repository/questions.sql.go new file mode 100644 index 0000000..29046b7 --- /dev/null +++ b/internal/db/sqlc/repository/questions.sql.go @@ -0,0 +1,89 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: questions.sql + +package repository + +import ( + "context" + + "github.com/google/uuid" +) + +const createQuestion = `-- name: CreateQuestion :one +INSERT INTO questions (form_id, type, title, required, position) +VALUES ($1, $2, $3, $4, $5) + RETURNING id, form_id, type, title, required, position +` + +type CreateQuestionParams struct { + FormID uuid.UUID + Type QuestionType + Title string + Required bool + Position int32 +} + +func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (Question, error) { + row := q.db.QueryRow(ctx, createQuestion, + arg.FormID, + arg.Type, + arg.Title, + arg.Required, + arg.Position, + ) + var i Question + err := row.Scan( + &i.ID, + &i.FormID, + &i.Type, + &i.Title, + &i.Required, + &i.Position, + ) + return i, err +} + +const deleteQuestionsByFormID = `-- name: DeleteQuestionsByFormID :exec +DELETE FROM questions +WHERE form_id = $1 +` + +func (q *Queries) DeleteQuestionsByFormID(ctx context.Context, formID uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteQuestionsByFormID, formID) + return err +} + +const getQuestionsByFormID = `-- name: GetQuestionsByFormID :many +SELECT id, form_id, type, title, required, position FROM questions +WHERE form_id = $1 +ORDER BY position ASC +` + +func (q *Queries) GetQuestionsByFormID(ctx context.Context, formID uuid.UUID) ([]Question, error) { + rows, err := q.db.Query(ctx, getQuestionsByFormID, formID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Question + for rows.Next() { + var i Question + if err := rows.Scan( + &i.ID, + &i.FormID, + &i.Type, + &i.Title, + &i.Required, + &i.Position, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/sqlc/sqlc.yaml b/internal/db/sqlc/sqlc.yaml new file mode 100644 index 0000000..675f104 --- /dev/null +++ b/internal/db/sqlc/sqlc.yaml @@ -0,0 +1,26 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "queries" + schema: "migrations" + gen: + go: + package: "repository" + out: "repository" + sql_package: "pgx/v5" + overrides: + - db_type: "uuid" + go_type: + import: "github.com/google/uuid" + type: "UUID" + - db_type: "uuid" + go_type: + import: "github.com/google/uuid" + type: "UUID" + pointer: true + nullable: true + - db_type: "timestamptz" + go_type: + type: "*time.Time" + pointer: true + nullable: true diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..f0b9ef3 --- /dev/null +++ b/internal/handler/auth.go @@ -0,0 +1,376 @@ +package handler + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "log" + "net/http" + "ristek-task-be/internal/db/sqlc/repository" + "ristek-task-be/internal/middleware" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/crypto/bcrypt" +) + +type Auth struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func isDuplicateError(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" + } + return false +} + +func (h *Handler) RegisterPost(w http.ResponseWriter, r *http.Request) { + var register Auth + if err := json.NewDecoder(r.Body).Decode(®ister); err != nil { + badRequest(w, err) + log.Printf("failed to decode request body: %s", err) + return + } + + if register.Email == "" || register.Password == "" { + badRequest(w, errors.New("email and password are required")) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword( + []byte(register.Password), + bcrypt.DefaultCost, + ) + if err != nil { + internalServerError(w, err) + log.Printf("failed to hash password: %s", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = h.repository.CreateUser(ctx, repository.CreateUserParams{ + Email: register.Email, + PasswordHash: string(hashedPassword), + }) + + if err != nil { + if isDuplicateError(err) { + badRequest(w, errors.New("email already exists")) + return + } + internalServerError(w, err) + log.Printf("failed to create user: %s", err) + return + } + + w.WriteHeader(http.StatusCreated) +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +func (h *Handler) RefreshPost(w http.ResponseWriter, r *http.Request) { + var req RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + badRequest(w, err) + log.Printf("failed to decode request body: %s", err) + return + } + + if req.RefreshToken == "" { + badRequest(w, errors.New("refresh_token is required")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + rt, err := h.repository.GetRefreshToken(ctx, req.RefreshToken) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + unauthorized(w) + return + } + internalServerError(w, err) + log.Printf("failed to get refresh token: %s", err) + return + } + + if rt.ExpiresAt.Time.Before(time.Now()) { + unauthorized(w) + return + } + + user, err := h.repository.GetUserByID(ctx, rt.UserID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + unauthorized(w) + return + } + internalServerError(w, err) + log.Printf("failed to get user: %s", err) + return + } + + if err := h.repository.DeleteRefreshToken(ctx, req.RefreshToken); err != nil { + internalServerError(w, err) + log.Printf("failed to delete old refresh token: %s", err) + return + } + + accessToken, err := h.jwt.GenerateAccessToken(user.ID.String(), user.Email) + if err != nil { + internalServerError(w, err) + log.Printf("failed to generate access token: %s", err) + return + } + + rawRefreshToken := uuid.New().String() + _, err = h.repository.CreateRefreshToken(ctx, repository.CreateRefreshTokenParams{ + UserID: user.ID, + Token: rawRefreshToken, + ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(7 * 24 * time.Hour), Valid: true}, + }) + if err != nil { + internalServerError(w, err) + log.Printf("failed to create refresh token: %s", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "refresh_token": rawRefreshToken, + "expires_in": 900, + }) +} + +type LogoutRequest struct { + RefreshToken string `json:"refresh_token"` +} + +func (h *Handler) LogoutPost(w http.ResponseWriter, r *http.Request) { + var req LogoutRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + badRequest(w, err) + log.Printf("failed to decode request body: %s", err) + return + } + + if req.RefreshToken == "" { + badRequest(w, errors.New("refresh_token is required")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := h.repository.DeleteRefreshToken(ctx, req.RefreshToken); err != nil { + internalServerError(w, err) + log.Printf("failed to delete refresh token: %s", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) LogoutAllDelete(w http.ResponseWriter, r *http.Request) { + userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string) + if !ok || userIDStr == "" { + unauthorized(w) + return + } + + userID, err := uuid.Parse(userIDStr) + if err != nil { + unauthorized(w) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := h.repository.DeleteUserRefreshTokens(ctx, userID); err != nil { + internalServerError(w, err) + log.Printf("failed to delete user refresh tokens: %s", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) MeGet(w http.ResponseWriter, r *http.Request) { + userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string) + if !ok || userIDStr == "" { + unauthorized(w) + return + } + + userID, err := uuid.Parse(userIDStr) + if err != nil { + unauthorized(w) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + user, err := h.repository.GetUserByID(ctx, userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + unauthorized(w) + return + } + internalServerError(w, err) + log.Printf("failed to get user: %s", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": user.ID.String(), + "email": user.Email, + "created_at": user.CreatedAt.Time, + "updated_at": user.UpdatedAt.Time, + }) +} + +type ChangePasswordRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +func (h *Handler) MePasswordPatch(w http.ResponseWriter, r *http.Request) { + userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string) + if !ok || userIDStr == "" { + unauthorized(w) + return + } + + userID, err := uuid.Parse(userIDStr) + if err != nil { + unauthorized(w) + return + } + + var req ChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + badRequest(w, err) + log.Printf("failed to decode request body: %s", err) + return + } + + if req.OldPassword == "" || req.NewPassword == "" { + badRequest(w, errors.New("old_password and new_password are required")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + user, err := h.repository.GetUserByID(ctx, userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + unauthorized(w) + return + } + internalServerError(w, err) + log.Printf("failed to get user: %s", err) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil { + badRequest(w, errors.New("incorrect old password")) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + internalServerError(w, err) + log.Printf("failed to hash password: %s", err) + return + } + + _, err = h.repository.UpdateUserPassword(ctx, repository.UpdateUserPasswordParams{ + ID: userID, + PasswordHash: string(hashedPassword), + }) + if err != nil { + internalServerError(w, err) + log.Printf("failed to update password: %s", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) LoginPost(w http.ResponseWriter, r *http.Request) { + var login Auth + if err := json.NewDecoder(r.Body).Decode(&login); err != nil { + badRequest(w, err) + log.Printf("failed to decode request body: %s", err) + return + } + + if login.Email == "" || login.Password == "" { + badRequest(w, errors.New("email and password are required")) + return + } + + user, err := h.repository.GetUserByEmail(r.Context(), login.Email) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + unauthorized(w) + return + } + internalServerError(w, err) + log.Printf("failed to get user by email: %s", err) + return + } + + if err := bcrypt.CompareHashAndPassword( + []byte(user.PasswordHash), + []byte(login.Password), + ); err != nil { + unauthorized(w) + return + } + + accessToken, err := h.jwt.GenerateAccessToken(user.ID.String(), user.Email) + if err != nil { + internalServerError(w, err) + log.Printf("failed to generate access token: %s", err) + return + } + + rawRefreshToken := uuid.New().String() + _, err = h.repository.CreateRefreshToken(r.Context(), repository.CreateRefreshTokenParams{ + UserID: user.ID, + Token: rawRefreshToken, + ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(7 * 24 * time.Hour), Valid: true}, + }) + if err != nil { + internalServerError(w, err) + log.Printf("failed to create refresh token: %s", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "refresh_token": rawRefreshToken, + "expires_in": 900, + }) +} diff --git a/internal/handler/form.go b/internal/handler/form.go new file mode 100644 index 0000000..95ac1b9 --- /dev/null +++ b/internal/handler/form.go @@ -0,0 +1,435 @@ +package handler + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "log" + "net/http" + "ristek-task-be/internal/db/sqlc/repository" + "ristek-task-be/internal/middleware" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type QuestionOptionInput struct { + Label string `json:"label"` + Position int32 `json:"position"` +} + +type QuestionInput struct { + Type string `json:"type"` + Title string `json:"title"` + Required bool `json:"required"` + Position int32 `json:"position"` + Options []QuestionOptionInput `json:"options"` +} + +type CreateFormRequest struct { + Title string `json:"title"` + Description string `json:"description"` + Questions []QuestionInput `json:"questions"` +} + +type UpdateFormRequest struct { + Title string `json:"title"` + Description string `json:"description"` + Questions []QuestionInput `json:"questions"` +} + +type QuestionOptionResponse struct { + ID int32 `json:"id"` + Label string `json:"label"` + Position int32 `json:"position"` +} + +type QuestionResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Required bool `json:"required"` + Position int32 `json:"position"` + Options []QuestionOptionResponse `json:"options"` +} + +type FormResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Title string `json:"title"` + Description string `json:"description"` + ResponseCount int32 `json:"response_count"` + Questions []QuestionResponse `json:"questions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func optionsByQuestion(options []repository.QuestionOption, questionID uuid.UUID) []QuestionOptionResponse { + var result []QuestionOptionResponse + for _, o := range options { + if o.QuestionID == questionID { + result = append(result, QuestionOptionResponse{ + ID: o.ID, + Label: o.Label, + Position: o.Position, + }) + } + } + if result == nil { + result = []QuestionOptionResponse{} + } + return result +} + +func buildFormResponse(form repository.Form, questions []repository.Question, options []repository.QuestionOption) FormResponse { + qs := make([]QuestionResponse, 0, len(questions)) + for _, q := range questions { + qs = append(qs, QuestionResponse{ + ID: q.ID.String(), + Type: string(q.Type), + Title: q.Title, + Required: q.Required, + Position: q.Position, + Options: optionsByQuestion(options, q.ID), + }) + } + + desc := "" + if form.Description.Valid { + desc = form.Description.String + } + + return FormResponse{ + ID: form.ID.String(), + UserID: form.UserID.String(), + Title: form.Title, + Description: desc, + ResponseCount: form.ResponseCount, + Questions: qs, + CreatedAt: form.CreatedAt.Time, + UpdatedAt: form.UpdatedAt.Time, + } +} + +func (h *Handler) currentUserID(r *http.Request) (uuid.UUID, bool) { + str, ok := r.Context().Value(middleware.UserIDKey).(string) + if !ok || str == "" { + return uuid.UUID{}, false + } + id, err := uuid.Parse(str) + if err != nil { + return uuid.UUID{}, false + } + return id, true +} + +func isChoiceType(t repository.QuestionType) bool { + switch t { + case repository.QuestionTypeMultipleChoice, + repository.QuestionTypeCheckbox, + repository.QuestionTypeDropdown: + return true + } + return false +} + +func (h *Handler) FormsGet(w http.ResponseWriter, r *http.Request) { + userID, ok := h.currentUserID(r) + if !ok { + unauthorized(w) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + forms, err := h.repository.ListForms(ctx, userID) + if err != nil { + internalServerError(w, err) + log.Printf("failed to list forms: %s", err) + return + } + + type listItem struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + ResponseCount int32 `json:"response_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + + items := make([]listItem, 0, len(forms)) + for _, f := range forms { + desc := "" + if f.Description.Valid { + desc = f.Description.String + } + items = append(items, listItem{ + ID: f.ID.String(), + Title: f.Title, + Description: desc, + ResponseCount: f.ResponseCount, + CreatedAt: f.CreatedAt.Time, + UpdatedAt: f.UpdatedAt.Time, + }) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(items) +} + +func (h *Handler) FormsPost(w http.ResponseWriter, r *http.Request) { + userID, ok := h.currentUserID(r) + if !ok { + unauthorized(w) + return + } + + var req CreateFormRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + badRequest(w, err) + return + } + + if req.Title == "" { + badRequest(w, errors.New("title is required")) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + form, err := h.repository.CreateForm(ctx, repository.CreateFormParams{ + UserID: userID, + Title: req.Title, + Description: pgtype.Text{ + String: req.Description, + Valid: req.Description != "", + }, + }) + if err != nil { + internalServerError(w, err) + log.Printf("failed to create form: %s", err) + return + } + + questions, options, err := h.saveQuestions(ctx, form.ID, req.Questions) + if err != nil { + internalServerError(w, err) + log.Printf("failed to save questions: %s", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(buildFormResponse(form, questions, options)) +} + +func (h *Handler) FormGet(w http.ResponseWriter, r *http.Request) { + formID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + badRequest(w, errors.New("invalid form id")) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + form, err := h.repository.GetFormByID(ctx, formID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + w.WriteHeader(http.StatusNotFound) + return + } + internalServerError(w, err) + log.Printf("failed to get form: %s", err) + return + } + + questions, err := h.repository.GetQuestionsByFormID(ctx, formID) + if err != nil { + internalServerError(w, err) + log.Printf("failed to get questions: %s", err) + return + } + + options, err := h.repository.GetOptionsByFormID(ctx, formID) + if err != nil { + internalServerError(w, err) + log.Printf("failed to get options: %s", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(buildFormResponse(form, questions, options)) +} + +func (h *Handler) FormPut(w http.ResponseWriter, r *http.Request) { + userID, ok := h.currentUserID(r) + if !ok { + unauthorized(w) + return + } + + formID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + badRequest(w, errors.New("invalid form id")) + return + } + + var req UpdateFormRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + badRequest(w, err) + return + } + + if req.Title == "" { + badRequest(w, errors.New("title is required")) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + form, err := h.repository.GetFormByID(ctx, formID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + w.WriteHeader(http.StatusNotFound) + return + } + internalServerError(w, err) + return + } + + if form.UserID != userID { + w.WriteHeader(http.StatusForbidden) + return + } + + updated, err := h.repository.UpdateForm(ctx, repository.UpdateFormParams{ + ID: formID, + Title: req.Title, + Description: pgtype.Text{ + String: req.Description, + Valid: req.Description != "", + }, + }) + if err != nil { + internalServerError(w, err) + log.Printf("failed to update form: %s", err) + return + } + + if err := h.repository.DeleteOptionsByFormID(ctx, formID); err != nil { + internalServerError(w, err) + log.Printf("failed to delete options: %s", err) + return + } + if err := h.repository.DeleteQuestionsByFormID(ctx, formID); err != nil { + internalServerError(w, err) + log.Printf("failed to delete questions: %s", err) + return + } + + questions, options, err := h.saveQuestions(ctx, formID, req.Questions) + if err != nil { + internalServerError(w, err) + log.Printf("failed to save questions: %s", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(buildFormResponse(updated, questions, options)) +} + +func (h *Handler) FormDelete(w http.ResponseWriter, r *http.Request) { + userID, ok := h.currentUserID(r) + if !ok { + unauthorized(w) + return + } + + formID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + badRequest(w, errors.New("invalid form id")) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + form, err := h.repository.GetFormByID(ctx, formID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + w.WriteHeader(http.StatusNotFound) + return + } + internalServerError(w, err) + return + } + + if form.UserID != userID { + w.WriteHeader(http.StatusForbidden) + return + } + + if err := h.repository.DeleteForm(ctx, formID); err != nil { + internalServerError(w, err) + log.Printf("failed to delete form: %s", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) saveQuestions(ctx context.Context, formID uuid.UUID, inputs []QuestionInput) ([]repository.Question, []repository.QuestionOption, error) { + var questions []repository.Question + var options []repository.QuestionOption + + for i, qi := range inputs { + qt := repository.QuestionType(qi.Type) + pos := qi.Position + if pos == 0 { + pos = int32(i + 1) + } + + q, err := h.repository.CreateQuestion(ctx, repository.CreateQuestionParams{ + FormID: formID, + Type: qt, + Title: qi.Title, + Required: qi.Required, + Position: pos, + }) + if err != nil { + return nil, nil, err + } + questions = append(questions, q) + + if isChoiceType(qt) { + for j, oi := range qi.Options { + optPos := oi.Position + if optPos == 0 { + optPos = int32(j + 1) + } + opt, err := h.repository.CreateQuestionOption(ctx, repository.CreateQuestionOptionParams{ + FormID: formID, + QuestionID: q.ID, + Label: oi.Label, + Position: optPos, + }) + if err != nil { + return nil, nil, err + } + options = append(options, opt) + } + } + } + + return questions, options, nil +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..db692c9 --- /dev/null +++ b/internal/handler/handler.go @@ -0,0 +1,37 @@ +package handler + +import ( + "net/http" + "ristek-task-be/internal/db/sqlc/repository" + "ristek-task-be/internal/jwt" +) + +type Handler struct { + repository *repository.Queries + jwt *jwt.JWT +} + +func New(repository *repository.Queries, jwt *jwt.JWT) *Handler { + return &Handler{ + repository: repository, + jwt: jwt, + } +} +func badRequest(w http.ResponseWriter, err error) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) +} + +func unauthorized(w http.ResponseWriter) { + w.WriteHeader(http.StatusUnauthorized) +} + +func internalServerError(w http.ResponseWriter, err error) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(err.Error())) +} + +func (h *Handler) HealthGet(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} diff --git a/internal/jwt/jwt.go b/internal/jwt/jwt.go new file mode 100644 index 0000000..cb71792 --- /dev/null +++ b/internal/jwt/jwt.go @@ -0,0 +1,59 @@ +package jwt + +import ( + "errors" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwt" +) + +type JWT struct { + secret []byte + accessTokenTTL time.Duration +} + +func New(secret string) *JWT { + return &JWT{ + secret: []byte(secret), + accessTokenTTL: 15 * time.Minute, + } +} + +func (j *JWT) GenerateAccessToken(userID, email string) (string, error) { + tok, err := jwt.NewBuilder(). + Subject(userID). + IssuedAt(time.Now()). + Expiration(time.Now().Add(j.accessTokenTTL)). + Claim("type", "access"). + Claim("email", email). + Build() + if err != nil { + return "", err + } + + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.HS256(), j.secret)) + if err != nil { + return "", err + } + + return string(signed), nil +} + +func (j *JWT) ValidateAccessToken(tokenStr string) (string, error) { + tok, err := jwt.Parse( + []byte(tokenStr), + jwt.WithKey(jwa.HS256(), j.secret), + jwt.WithValidate(true), + ) + if err != nil { + return "", err + } + + subject, ok := tok.Subject() + if !ok { + return "", errors.New("token does not contain subject") + } + + return subject, nil +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..9471641 --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,103 @@ +package middleware + +import ( + "context" + "log" + "net/http" + "ristek-task-be/internal/jwt" + "strings" +) + +type wrapper struct { + http.ResponseWriter + request *http.Request + statusCode int +} + +func (w *wrapper) WriteHeader(code int) { + w.statusCode = code + w.ResponseWriter.WriteHeader(code) + return +} + +func ClientIP(request *http.Request) string { + ip := request.Header.Get("Cf-Connecting-IP") + if ip != "" { + return ip + } + ip = request.Header.Get("X-Real-IP") + if ip == "" { + ip = request.Header.Get("X-Forwarded-For") + if ip == "" { + ip = request.RemoteAddr + } + } + + if strings.Contains(ip, ",") { + ips := strings.Split(ip, ",") + ip = strings.TrimSpace(ips[0]) + } + + if strings.Contains(ip, ":") { + ips := strings.Split(ip, ":") + ip = ips[0] + } + + return ip +} + +func Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + wrappedWriter := &wrapper{ + ResponseWriter: writer, + request: request, + statusCode: http.StatusOK, + } + + next.ServeHTTP(wrappedWriter, request) + log.Printf("%s %s %s %v", ClientIP(request), request.Method, request.RequestURI, wrappedWriter.statusCode) + }) +} + +type contextKey string + +const UserIDKey contextKey = "user_id" + +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +func Auth(j *jwt.JWT) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + + userID, err := j.ValidateAccessToken(tokenStr) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), UserIDKey, userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..2755f84 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,63 @@ +package server + +import ( + "fmt" + "net/http" + "ristek-task-be/internal/db/sqlc/repository" + "ristek-task-be/internal/handler" + "ristek-task-be/internal/jwt" + "ristek-task-be/internal/middleware" +) + +type Server struct { + addr string + port uint16 + repository *repository.Queries + jwt *jwt.JWT +} + +func New(addr string, port uint16, repository *repository.Queries, jwt *jwt.JWT) *Server { + return &Server{ + addr: addr, + port: port, + repository: repository, + jwt: jwt, + } +} + +func router(repository *repository.Queries, jwt *jwt.JWT) *http.ServeMux { + r := http.NewServeMux() + h := handler.New(repository, jwt) + + r.HandleFunc("GET /health", h.HealthGet) + + authRoute := http.NewServeMux() + r.Handle("/api/auth/", http.StripPrefix("/api/auth", authRoute)) + authRoute.HandleFunc("POST /register", h.RegisterPost) + authRoute.HandleFunc("POST /login", h.LoginPost) + authRoute.HandleFunc("POST /refresh", h.RefreshPost) + authRoute.HandleFunc("POST /logout", h.LogoutPost) + authRoute.Handle("DELETE /logout/all", middleware.Auth(jwt)(http.HandlerFunc(h.LogoutAllDelete))) + authRoute.Handle("GET /me", middleware.Auth(jwt)(http.HandlerFunc(h.MeGet))) + authRoute.Handle("PATCH /me/password", middleware.Auth(jwt)(http.HandlerFunc(h.MePasswordPatch))) + + formRoute := http.NewServeMux() + r.Handle("/api/forms", middleware.Auth(jwt)(http.HandlerFunc(h.FormsGet))) + r.Handle("/api/form/", http.StripPrefix("/api/form", formRoute)) + formRoute.HandleFunc("GET /{id}", h.FormGet) + formRoute.Handle("POST /{$}", middleware.Auth(jwt)(http.HandlerFunc(h.FormsPost))) + formRoute.Handle("PUT /{id}", middleware.Auth(jwt)(http.HandlerFunc(h.FormPut))) + formRoute.Handle("DELETE /{id}", middleware.Auth(jwt)(http.HandlerFunc(h.FormDelete))) + + return r +} + +func (s *Server) Start() error { + r := router(s.repository, s.jwt) + hs := &http.Server{ + Addr: fmt.Sprintf("%s:%d", s.addr, s.port), + Handler: middleware.Handler(middleware.CORS(r)), + } + + return hs.ListenAndServe() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e61924e --- /dev/null +++ b/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "ristek-task-be/internal/db/sqlc/repository" + "ristek-task-be/internal/jwt" + "ristek-task-be/internal/server" + "syscall" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" +) + +func main() { + log.SetOutput(os.Stdout) + log.SetFlags(log.LstdFlags | log.Lshortfile) + + if _, err := os.Stat(".env"); err == nil { + if err = godotenv.Load(".env"); err != nil { + log.Printf("Warning: Failed to load .env file: %s", err) + } + } + + errChan := make(chan error, 1) + signalChan := make(chan os.Signal, 1) + + signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) + + addr := "localhost" + port := uint16(8080) + + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + log.Fatal("DATABASE_URL is required") + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + connect, err := pgxpool.New(ctx, dbURL) + if err != nil { + panic(err) + } + defer connect.Close() + repo := repository.New(connect) + j := jwt.New("yomama") + go func() { + s := server.New(addr, port, repo, j) + err = s.Start() + errChan <- err + }() + + log.Printf("Server is running on %s:%d", addr, port) + + select { + case err = <-errChan: + log.Fatalf("service error: %w", err) + case sig := <-signalChan: + log.Printf("Received signal %s, initiating graceful shutdown", sig) + } +}