first commit

This commit is contained in:
2026-02-22 00:15:25 +07:00
commit 39fc9e9e3f
25 changed files with 2116 additions and 0 deletions
+376
View File
@@ -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(&register); 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,
})
}
+435
View File
@@ -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
}
+37
View File
@@ -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"))
}