From ffab4f22adb37853c45c3aabe2b84070ef5a66af Mon Sep 17 00:00:00 2001 From: bagas Date: Sun, 22 Feb 2026 14:13:59 +0700 Subject: [PATCH] feat: add form response submition endpoint and forms filtering --- .../migrations/000003_init_responses.down.sql | 8 + .../migrations/000003_init_responses.up.sql | 22 ++ internal/db/sqlc/queries/responses.sql | 24 ++ internal/db/sqlc/repository/models.go | 15 + internal/db/sqlc/repository/responses.sql.go | 143 ++++++++++ internal/handler/form.go | 263 ++++++++++++++++++ internal/server/server.go | 3 + 7 files changed, 478 insertions(+) create mode 100644 internal/db/sqlc/migrations/000003_init_responses.down.sql create mode 100644 internal/db/sqlc/migrations/000003_init_responses.up.sql create mode 100644 internal/db/sqlc/queries/responses.sql create mode 100644 internal/db/sqlc/repository/responses.sql.go diff --git a/internal/db/sqlc/migrations/000003_init_responses.down.sql b/internal/db/sqlc/migrations/000003_init_responses.down.sql new file mode 100644 index 0000000..8608e58 --- /dev/null +++ b/internal/db/sqlc/migrations/000003_init_responses.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_response_answers_response_id; +DROP INDEX IF EXISTS idx_form_responses_form_id; +DROP TABLE IF EXISTS response_answers; +DROP TABLE IF EXISTS form_responses; + +COMMIT; diff --git a/internal/db/sqlc/migrations/000003_init_responses.up.sql b/internal/db/sqlc/migrations/000003_init_responses.up.sql new file mode 100644 index 0000000..3e5965d --- /dev/null +++ b/internal/db/sqlc/migrations/000003_init_responses.up.sql @@ -0,0 +1,22 @@ +BEGIN; + +CREATE TABLE form_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + form_id UUID NOT NULL REFERENCES forms(id) ON DELETE RESTRICT, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE response_answers ( + id BIGSERIAL PRIMARY KEY, + response_id UUID NOT NULL REFERENCES form_responses(id) ON DELETE CASCADE, + question_id UUID NOT NULL, + form_id UUID NOT NULL, + answer_text TEXT, + FOREIGN KEY (form_id, question_id) REFERENCES questions(form_id, id) ON DELETE CASCADE +); + +CREATE INDEX idx_form_responses_form_id ON form_responses(form_id); +CREATE INDEX idx_response_answers_response_id ON response_answers(response_id); + +COMMIT; diff --git a/internal/db/sqlc/queries/responses.sql b/internal/db/sqlc/queries/responses.sql new file mode 100644 index 0000000..c186c52 --- /dev/null +++ b/internal/db/sqlc/queries/responses.sql @@ -0,0 +1,24 @@ +-- name: CreateFormResponse :one +INSERT INTO form_responses (form_id, user_id) +VALUES ($1, $2) + RETURNING *; + +-- name: GetFormResponsesByFormID :many +SELECT * FROM form_responses +WHERE form_id = $1 +ORDER BY submitted_at DESC; + +-- name: FormHasResponses :one +SELECT EXISTS ( + SELECT 1 FROM form_responses WHERE form_id = $1 +) AS has_responses; + +-- name: CreateResponseAnswer :one +INSERT INTO response_answers (response_id, question_id, form_id, answer_text) +VALUES ($1, $2, $3, $4) + RETURNING *; + +-- name: GetAnswersByResponseID :many +SELECT * FROM response_answers +WHERE response_id = $1 +ORDER BY id ASC; diff --git a/internal/db/sqlc/repository/models.go b/internal/db/sqlc/repository/models.go index 13453d9..3c36998 100644 --- a/internal/db/sqlc/repository/models.go +++ b/internal/db/sqlc/repository/models.go @@ -69,6 +69,13 @@ type Form struct { UpdatedAt pgtype.Timestamptz } +type FormResponse struct { + ID uuid.UUID + FormID uuid.UUID + UserID *uuid.UUID + SubmittedAt pgtype.Timestamptz +} + type Question struct { ID uuid.UUID FormID uuid.UUID @@ -94,6 +101,14 @@ type RefreshToken struct { CreatedAt pgtype.Timestamptz } +type ResponseAnswer struct { + ID int64 + ResponseID uuid.UUID + QuestionID uuid.UUID + FormID uuid.UUID + AnswerText pgtype.Text +} + type User struct { ID uuid.UUID Email string diff --git a/internal/db/sqlc/repository/responses.sql.go b/internal/db/sqlc/repository/responses.sql.go new file mode 100644 index 0000000..d1829da --- /dev/null +++ b/internal/db/sqlc/repository/responses.sql.go @@ -0,0 +1,143 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: responses.sql + +package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createFormResponse = `-- name: CreateFormResponse :one +INSERT INTO form_responses (form_id, user_id) +VALUES ($1, $2) + RETURNING id, form_id, user_id, submitted_at +` + +type CreateFormResponseParams struct { + FormID uuid.UUID + UserID *uuid.UUID +} + +func (q *Queries) CreateFormResponse(ctx context.Context, arg CreateFormResponseParams) (FormResponse, error) { + row := q.db.QueryRow(ctx, createFormResponse, arg.FormID, arg.UserID) + var i FormResponse + err := row.Scan( + &i.ID, + &i.FormID, + &i.UserID, + &i.SubmittedAt, + ) + return i, err +} + +const createResponseAnswer = `-- name: CreateResponseAnswer :one +INSERT INTO response_answers (response_id, question_id, form_id, answer_text) +VALUES ($1, $2, $3, $4) + RETURNING id, response_id, question_id, form_id, answer_text +` + +type CreateResponseAnswerParams struct { + ResponseID uuid.UUID + QuestionID uuid.UUID + FormID uuid.UUID + AnswerText pgtype.Text +} + +func (q *Queries) CreateResponseAnswer(ctx context.Context, arg CreateResponseAnswerParams) (ResponseAnswer, error) { + row := q.db.QueryRow(ctx, createResponseAnswer, + arg.ResponseID, + arg.QuestionID, + arg.FormID, + arg.AnswerText, + ) + var i ResponseAnswer + err := row.Scan( + &i.ID, + &i.ResponseID, + &i.QuestionID, + &i.FormID, + &i.AnswerText, + ) + return i, err +} + +const formHasResponses = `-- name: FormHasResponses :one +SELECT EXISTS ( + SELECT 1 FROM form_responses WHERE form_id = $1 +) AS has_responses +` + +func (q *Queries) FormHasResponses(ctx context.Context, formID uuid.UUID) (bool, error) { + row := q.db.QueryRow(ctx, formHasResponses, formID) + var has_responses bool + err := row.Scan(&has_responses) + return has_responses, err +} + +const getAnswersByResponseID = `-- name: GetAnswersByResponseID :many +SELECT id, response_id, question_id, form_id, answer_text FROM response_answers +WHERE response_id = $1 +ORDER BY id ASC +` + +func (q *Queries) GetAnswersByResponseID(ctx context.Context, responseID uuid.UUID) ([]ResponseAnswer, error) { + rows, err := q.db.Query(ctx, getAnswersByResponseID, responseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ResponseAnswer + for rows.Next() { + var i ResponseAnswer + if err := rows.Scan( + &i.ID, + &i.ResponseID, + &i.QuestionID, + &i.FormID, + &i.AnswerText, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getFormResponsesByFormID = `-- name: GetFormResponsesByFormID :many +SELECT id, form_id, user_id, submitted_at FROM form_responses +WHERE form_id = $1 +ORDER BY submitted_at DESC +` + +func (q *Queries) GetFormResponsesByFormID(ctx context.Context, formID uuid.UUID) ([]FormResponse, error) { + rows, err := q.db.Query(ctx, getFormResponsesByFormID, formID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FormResponse + for rows.Next() { + var i FormResponse + if err := rows.Scan( + &i.ID, + &i.FormID, + &i.UserID, + &i.SubmittedAt, + ); 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/handler/form.go b/internal/handler/form.go index 95ac1b9..d3b5df9 100644 --- a/internal/handler/form.go +++ b/internal/handler/form.go @@ -9,6 +9,8 @@ import ( "net/http" "ristek-task-be/internal/db/sqlc/repository" "ristek-task-be/internal/middleware" + "sort" + "strings" "time" "github.com/google/uuid" @@ -142,6 +144,22 @@ func (h *Handler) FormsGet(w http.ResponseWriter, r *http.Request) { return } + q := r.URL.Query() + search := strings.TrimSpace(q.Get("search")) + status := strings.ToLower(strings.TrimSpace(q.Get("status"))) + sortBy := strings.ToLower(strings.TrimSpace(q.Get("sort_by"))) + sortDir := strings.ToLower(strings.TrimSpace(q.Get("sort_dir"))) + + if status != "has_responses" && status != "no_responses" { + status = "" + } + if sortBy != "updated_at" { + sortBy = "created_at" + } + if sortDir != "oldest" { + sortDir = "newest" + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() @@ -163,6 +181,16 @@ func (h *Handler) FormsGet(w http.ResponseWriter, r *http.Request) { items := make([]listItem, 0, len(forms)) for _, f := range forms { + if search != "" && !strings.Contains(strings.ToLower(f.Title), strings.ToLower(search)) { + continue + } + if status == "has_responses" && f.ResponseCount == 0 { + continue + } + if status == "no_responses" && f.ResponseCount > 0 { + continue + } + desc := "" if f.Description.Valid { desc = f.Description.String @@ -177,6 +205,19 @@ func (h *Handler) FormsGet(w http.ResponseWriter, r *http.Request) { }) } + sort.Slice(items, func(i, j int) bool { + var ti, tj time.Time + if sortBy == "updated_at" { + ti, tj = items[i].UpdatedAt, items[j].UpdatedAt + } else { + ti, tj = items[i].CreatedAt, items[j].CreatedAt + } + if sortDir == "oldest" { + return ti.Before(tj) + } + return ti.After(tj) + }) + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(items) @@ -379,6 +420,18 @@ func (h *Handler) FormDelete(w http.ResponseWriter, r *http.Request) { return } + hasResponses, err := h.repository.FormHasResponses(ctx, formID) + if err != nil { + internalServerError(w, err) + log.Printf("failed to check responses: %s", err) + return + } + if hasResponses { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte("form already has responses and cannot be deleted")) + return + } + if err := h.repository.DeleteForm(ctx, formID); err != nil { internalServerError(w, err) log.Printf("failed to delete form: %s", err) @@ -433,3 +486,213 @@ func (h *Handler) saveQuestions(ctx context.Context, formID uuid.UUID, inputs [] return questions, options, nil } + +type AnswerInput struct { + QuestionID string `json:"question_id"` + Answer string `json:"answer"` +} + +type SubmitResponseRequest struct { + Answers []AnswerInput `json:"answers"` +} + +type AnswerResponse struct { + QuestionID string `json:"question_id"` + Answer string `json:"answer"` +} + +type SubmitResponseResponse struct { + ID string `json:"id"` + FormID string `json:"form_id"` + SubmittedAt string `json:"submitted_at"` + Answers []AnswerResponse `json:"answers"` +} + +func (h *Handler) FormResponsesPost(w http.ResponseWriter, r *http.Request) { + formID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + badRequest(w, errors.New("invalid form id")) + return + } + + var req SubmitResponseRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + badRequest(w, err) + return + } + + if len(req.Answers) == 0 { + badRequest(w, errors.New("answers are 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) + _, _ = w.Write([]byte("form not found")) + 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 + } + + questionMap := make(map[uuid.UUID]repository.Question, len(questions)) + for _, q := range questions { + questionMap[q.ID] = q + } + + answerMap := make(map[uuid.UUID]string, len(req.Answers)) + for _, a := range req.Answers { + qid, err := uuid.Parse(a.QuestionID) + if err != nil { + badRequest(w, errors.New("invalid question_id: "+a.QuestionID)) + return + } + if _, exists := questionMap[qid]; !exists { + badRequest(w, errors.New("question not found in form: "+a.QuestionID)) + return + } + answerMap[qid] = a.Answer + } + + for _, q := range questions { + if q.Required { + if ans, ok := answerMap[q.ID]; !ok || ans == "" { + badRequest(w, errors.New("required question not answered: "+q.Title)) + return + } + } + } + + var userID *uuid.UUID + if uid, ok := h.currentUserID(r); ok { + userID = &uid + } + + formResp, err := h.repository.CreateFormResponse(ctx, repository.CreateFormResponseParams{ + FormID: form.ID, + UserID: userID, + }) + if err != nil { + internalServerError(w, err) + log.Printf("failed to create form response: %s", err) + return + } + + if _, err := h.repository.IncrementResponseCount(ctx, formID); err != nil { + log.Printf("failed to increment response count: %s", err) + } + + var answerResponses []AnswerResponse + for qid, answerText := range answerMap { + _, err := h.repository.CreateResponseAnswer(ctx, repository.CreateResponseAnswerParams{ + ResponseID: formResp.ID, + QuestionID: qid, + FormID: formID, + AnswerText: pgtype.Text{String: answerText, Valid: answerText != ""}, + }) + if err != nil { + internalServerError(w, err) + log.Printf("failed to create response answer: %s", err) + return + } + answerResponses = append(answerResponses, AnswerResponse{ + QuestionID: qid.String(), + Answer: answerText, + }) + } + if answerResponses == nil { + answerResponses = []AnswerResponse{} + } + + submittedAt := "" + if formResp.SubmittedAt.Valid { + submittedAt = formResp.SubmittedAt.Time.String() + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(SubmitResponseResponse{ + ID: formResp.ID.String(), + FormID: formResp.FormID.String(), + SubmittedAt: submittedAt, + Answers: answerResponses, + }) +} + +func (h *Handler) FormResponsesGet(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 + } + + responses, err := h.repository.GetFormResponsesByFormID(ctx, formID) + if err != nil { + internalServerError(w, err) + log.Printf("failed to get responses: %s", err) + return + } + + type responseItem struct { + ID string `json:"id"` + SubmittedAt string `json:"submitted_at"` + UserID string `json:"user_id,omitempty"` + } + items := make([]responseItem, 0, len(responses)) + for _, resp := range responses { + uid := "" + if resp.UserID != nil { + uid = resp.UserID.String() + } + sat := "" + if resp.SubmittedAt.Valid { + sat = resp.SubmittedAt.Time.String() + } + items = append(items, responseItem{ + ID: resp.ID.String(), + SubmittedAt: sat, + UserID: uid, + }) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(items) +} diff --git a/internal/server/server.go b/internal/server/server.go index f5c379a..1a4264b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -49,6 +49,9 @@ func router(repository *repository.Queries, jwt *jwt.JWT) *http.ServeMux { formRoute.Handle("PUT /{id}", middleware.Auth(jwt)(http.HandlerFunc(h.FormPut))) formRoute.Handle("DELETE /{id}", middleware.Auth(jwt)(http.HandlerFunc(h.FormDelete))) + formRoute.HandleFunc("POST /{id}/response", h.FormResponsesPost) + formRoute.Handle("GET /{id}/responses", middleware.Auth(jwt)(http.HandlerFunc(h.FormResponsesGet))) + return r }