882 lines
24 KiB
Go
882 lines
24 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"ristek-task-be/internal/db/sqlc/repository"
|
|
"ristek-task-be/internal/middleware"
|
|
"sort"
|
|
"strings"
|
|
"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
|
|
}
|
|
|
|
// FormsGet returns all forms owned by the authenticated user
|
|
//
|
|
// @Summary List forms
|
|
// @Description Retrieve all forms belonging to the authenticated user, with optional search, status filter, and sort options
|
|
// @Tags forms
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param search query string false "Filter by title (case-insensitive)"
|
|
// @Param status query string false "Filter by response status: has_responses | no_responses"
|
|
// @Param sort_by query string false "Sort field: created_at (default) | updated_at"
|
|
// @Param sort_dir query string false "Sort direction: newest (default) | oldest"
|
|
// @Success 200 {array} FormResponse "List of forms"
|
|
// @Failure 401 {string} string "Unauthorized"
|
|
// @Failure 500 {string} string "Internal server error"
|
|
// @Router /api/forms [get]
|
|
func (h *Handler) FormsGet(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.currentUserID(r)
|
|
if !ok {
|
|
unauthorized(w)
|
|
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()
|
|
|
|
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 {
|
|
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
|
|
}
|
|
items = append(items, listItem{
|
|
ID: f.ID.String(),
|
|
Title: f.Title,
|
|
Description: desc,
|
|
ResponseCount: f.ResponseCount,
|
|
CreatedAt: f.CreatedAt.Time,
|
|
UpdatedAt: f.UpdatedAt.Time,
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// FormsPost creates a new form
|
|
//
|
|
// @Summary Create a form
|
|
// @Description Create a new form with title, description, and questions for the authenticated user
|
|
// @Tags forms
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param body body CreateFormRequest true "Form creation payload"
|
|
// @Success 201 {object} FormResponse "Created form"
|
|
// @Failure 400 {string} string "Bad request (e.g. title is required)"
|
|
// @Failure 401 {string} string "Unauthorized"
|
|
// @Failure 500 {string} string "Internal server error"
|
|
// @Router /api/form [post]
|
|
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))
|
|
}
|
|
|
|
// FormGet retrieves a single form by ID
|
|
//
|
|
// @Summary Get a form
|
|
// @Description Retrieve a form and its questions by form ID (publicly accessible)
|
|
// @Tags forms
|
|
// @Produce json
|
|
// @Param id path string true "Form ID (UUID)"
|
|
// @Success 200 {object} FormResponse "Form details"
|
|
// @Failure 400 {string} string "Invalid form ID"
|
|
// @Failure 404 {string} string "Form not found"
|
|
// @Failure 500 {string} string "Internal server error"
|
|
// @Router /api/form/{id} [get]
|
|
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))
|
|
}
|
|
|
|
// FormPut updates an existing form
|
|
//
|
|
// @Summary Update a form
|
|
// @Description Replace a form's title, description, and questions. Only the form owner can update it
|
|
// @Tags forms
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param id path string true "Form ID (UUID)"
|
|
// @Param body body UpdateFormRequest true "Form update payload"
|
|
// @Success 200 {object} FormResponse "Updated form"
|
|
// @Failure 400 {string} string "Bad request (e.g. title is required)"
|
|
// @Failure 401 {string} string "Unauthorized"
|
|
// @Failure 403 {string} string "Forbidden (not the form owner)"
|
|
// @Failure 404 {string} string "Form not found"
|
|
// @Failure 500 {string} string "Internal server error"
|
|
// @Router /api/form/{id} [put]
|
|
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))
|
|
}
|
|
|
|
// FormDelete deletes a form
|
|
//
|
|
// @Summary Delete a form
|
|
// @Description Delete a form by ID. Only the form owner can delete it. Deletion is blocked if the form already has responses
|
|
// @Tags forms
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param id path string true "Form ID (UUID)"
|
|
// @Success 204
|
|
// @Failure 400 {string} string "Invalid form ID"
|
|
// @Failure 401 {string} string "Unauthorized"
|
|
// @Failure 403 {string} string "Forbidden (not the form owner)"
|
|
// @Failure 404 {string} string "Form not found"
|
|
// @Failure 409 {string} string "Conflict (form already has responses)"
|
|
// @Failure 500 {string} string "Internal server error"
|
|
// @Router /api/form/{id} [delete]
|
|
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
|
|
}
|
|
|
|
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)
|
|
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
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// FormResponsesPost submits a response to a form
|
|
//
|
|
// @Summary Submit a form response
|
|
// @Description Submit answers to a form's questions. Authentication is optional (anonymous submissions allowed). Required questions must be answered. Choice answers must match valid options
|
|
// @Tags forms
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param id path string true "Form ID (UUID)"
|
|
// @Param body body SubmitResponseRequest true "Response answers payload"
|
|
// @Success 201 {object} SubmitResponseResponse "Submitted response"
|
|
// @Failure 400 {string} string "Bad request (e.g. invalid answer, missing required question)"
|
|
// @Failure 404 {string} string "Form not found"
|
|
// @Failure 500 {string} string "Internal server error"
|
|
// @Router /api/form/{id}/response [post]
|
|
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
|
|
}
|
|
|
|
allOptions, err := h.repository.GetOptionsByFormID(ctx, formID)
|
|
if err != nil {
|
|
internalServerError(w, err)
|
|
log.Printf("failed to get options: %s", err)
|
|
return
|
|
}
|
|
optionLabels := make(map[uuid.UUID]map[string]struct{})
|
|
for _, o := range allOptions {
|
|
if _, ok := optionLabels[o.QuestionID]; !ok {
|
|
optionLabels[o.QuestionID] = make(map[string]struct{})
|
|
}
|
|
optionLabels[o.QuestionID][o.Label] = struct{}{}
|
|
}
|
|
|
|
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
|
|
}
|
|
q, exists := questionMap[qid]
|
|
if !exists {
|
|
badRequest(w, errors.New("question not found in form: "+a.QuestionID))
|
|
return
|
|
}
|
|
|
|
if a.Answer != "" && isChoiceType(q.Type) {
|
|
validLabels := optionLabels[qid]
|
|
switch q.Type {
|
|
case repository.QuestionTypeMultipleChoice, repository.QuestionTypeDropdown:
|
|
if _, ok := validLabels[a.Answer]; !ok {
|
|
badRequest(w, errors.New("invalid answer for question \""+q.Title+"\": \""+a.Answer+"\" is not a valid option"))
|
|
return
|
|
}
|
|
case repository.QuestionTypeCheckbox:
|
|
parts := strings.Split(a.Answer, ",")
|
|
for _, part := range parts {
|
|
label := strings.TrimSpace(part)
|
|
if _, ok := validLabels[label]; !ok {
|
|
badRequest(w, errors.New("invalid answer for question \""+q.Title+"\": \""+label+"\" is not a valid option"))
|
|
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,
|
|
})
|
|
}
|
|
|
|
// FormResponsesGet retrieves all responses for a form
|
|
//
|
|
// @Summary Get form responses
|
|
// @Description Retrieve all submitted responses for a form. Only the form owner can access this
|
|
// @Tags forms
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param id path string true "Form ID (UUID)"
|
|
// @Success 200 {array} SubmitResponseResponse "List of form responses with answers"
|
|
// @Failure 400 {string} string "Invalid form ID"
|
|
// @Failure 401 {string} string "Unauthorized"
|
|
// @Failure 403 {string} string "Forbidden (not the form owner)"
|
|
// @Failure 404 {string} string "Form not found"
|
|
// @Failure 500 {string} string "Internal server error"
|
|
// @Router /api/form/{id}/responses [get]
|
|
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(), 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
|
|
}
|
|
|
|
questions, err := h.repository.GetQuestionsByFormID(ctx, formID)
|
|
if err != nil {
|
|
internalServerError(w, err)
|
|
log.Printf("failed to get questions: %s", err)
|
|
return
|
|
}
|
|
questionMeta := make(map[uuid.UUID]repository.Question, len(questions))
|
|
for _, q := range questions {
|
|
questionMeta[q.ID] = q
|
|
}
|
|
|
|
responses, err := h.repository.GetFormResponsesByFormID(ctx, formID)
|
|
if err != nil {
|
|
internalServerError(w, err)
|
|
log.Printf("failed to get responses: %s", err)
|
|
return
|
|
}
|
|
|
|
type answerItem struct {
|
|
QuestionID string `json:"question_id"`
|
|
QuestionTitle string `json:"question_title"`
|
|
QuestionType string `json:"question_type"`
|
|
Answer string `json:"answer"`
|
|
}
|
|
type responseItem struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
SubmittedAt time.Time `json:"submitted_at"`
|
|
Answers []answerItem `json:"answers"`
|
|
}
|
|
|
|
items := make([]responseItem, 0, len(responses))
|
|
for _, resp := range responses {
|
|
answers, err := h.repository.GetAnswersByResponseID(ctx, resp.ID)
|
|
if err != nil {
|
|
internalServerError(w, err)
|
|
log.Printf("failed to get answers for response %s: %s", resp.ID, err)
|
|
return
|
|
}
|
|
|
|
answerItems := make([]answerItem, 0, len(answers))
|
|
for _, a := range answers {
|
|
text := ""
|
|
if a.AnswerText.Valid {
|
|
text = a.AnswerText.String
|
|
}
|
|
title := ""
|
|
qtype := ""
|
|
if q, ok := questionMeta[a.QuestionID]; ok {
|
|
title = q.Title
|
|
qtype = string(q.Type)
|
|
}
|
|
answerItems = append(answerItems, answerItem{
|
|
QuestionID: a.QuestionID.String(),
|
|
QuestionTitle: title,
|
|
QuestionType: qtype,
|
|
Answer: text,
|
|
})
|
|
}
|
|
|
|
uid := ""
|
|
if resp.UserID != nil {
|
|
uid = resp.UserID.String()
|
|
}
|
|
items = append(items, responseItem{
|
|
ID: resp.ID.String(),
|
|
UserID: uid,
|
|
SubmittedAt: resp.SubmittedAt.Time,
|
|
Answers: answerItems,
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(items)
|
|
}
|