package main import ( "context" "errors" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" ) const ( formStatusDraft = "draft" formStatusPublished = "published" ) var formPublishingPolicyInstance = formPublishingPolicy{} type formPublishingPolicy struct{} var allowedFormFieldTypes = map[string]struct{}{ "text": {}, "textarea": {}, "email": {}, "number": {}, "select": {}, "checkbox": {}, } func (a *app) formsAdminMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { writeError(w, http.StatusUnauthorized, "no authenticated user") return } if !hasRole(claims, "admin") { writeError(w, http.StatusForbidden, "insufficient role for form management") return } next.ServeHTTP(w, r) }) } func (a *app) listForms(w http.ResponseWriter, r *http.Request) { a.listCollection("forms", w) } func (a *app) createForm(w http.ResponseWriter, r *http.Request) { var payload formUpsertRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := prepareFormPayload(payload.Doc(), false, nil) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc, err = a.create("forms", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create resource") return } writeJSON(w, http.StatusCreated, doc) } func (a *app) getForm(w http.ResponseWriter, r *http.Request) { a.getCollection("forms", w, r) } func (a *app) patchForm(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var payload formUpsertRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } existing, err := a.getByID("forms", id) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to load resource") return } patch, err := prepareFormPayload(payload.Doc(), true, existing) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc, err := a.patch("forms", id, patch) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update resource") return } writeJSON(w, http.StatusOK, doc) } func (a *app) deleteForm(w http.ResponseWriter, r *http.Request) { a.deleteCollection("forms", w, r) } func (a *app) listFormSubmissions(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } formID := strings.TrimSpace(chi.URLParam(r, "id")) if formID == "" { writeError(w, http.StatusBadRequest, "form id is required") return } docs, err := a.list("form_submissions", Doc{"formId": formID}, Sort{{Key: "submittedAt", Value: -1}, {Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list form submissions") return } writeJSON(w, http.StatusOK, docs) } func (a *app) getPublicForm(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } formID := strings.TrimSpace(chi.URLParam(r, "id")) if formID == "" { writeError(w, http.StatusBadRequest, "form id is required") return } ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() var form Doc if err := a.db.Collection("forms").FindOne(ctx, Doc{"id": formID}).Decode(&form); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "form not found") return } writeError(w, http.StatusInternalServerError, "failed to load form") return } normalizeDocument(form) status, _ := asString(form["status"]) if status != formStatusPublished { writeError(w, http.StatusNotFound, "form not found") return } schema, _, err := normalizeFormSchema(form["schema"]) if err != nil { writeError(w, http.StatusInternalServerError, "form schema is invalid") return } writeJSON(w, http.StatusOK, publicFormResponse{ ID: stringValue(form["id"]), Name: stringValue(form["name"]), Description: stringValue(form["description"]), Schema: schema, }) } func (a *app) createPublicFormSubmission(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } formID := strings.TrimSpace(chi.URLParam(r, "id")) if formID == "" { writeError(w, http.StatusBadRequest, "form id is required") return } ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() var form Doc if err := a.db.Collection("forms").FindOne(ctx, Doc{"id": formID}).Decode(&form); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "form not found") return } writeError(w, http.StatusInternalServerError, "failed to load form") return } normalizeDocument(form) status, _ := asString(form["status"]) if status != formStatusPublished { writeError(w, http.StatusNotFound, "form not found") return } schema, _, err := normalizeFormSchema(form["schema"]) if err != nil { writeError(w, http.StatusInternalServerError, "form schema is invalid") return } var payload struct { Values map[string]any `json:"values"` } if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } if payload.Values == nil { payload.Values = map[string]any{} } normalizedValues, err := validateFormSubmission(schema, payload.Values) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } submission := Doc{ "formId": formID, "values": normalizedValues, "submittedAt": time.Now().UnixMilli(), } submission, err = a.create("form_submissions", submission) if err != nil { writeError(w, http.StatusInternalServerError, "failed to store form submission") return } submittedAt := int64(0) switch value := submission["submittedAt"].(type) { case int64: submittedAt = value case int32: submittedAt = int64(value) case int: submittedAt = int64(value) case float64: submittedAt = int64(value) } writeJSON(w, http.StatusCreated, publicFormSubmissionCreateResponse{ ID: stringValue(submission["id"]), FormID: stringValue(submission["formId"]), SubmittedAt: submittedAt, }) } func prepareFormPayload(payload Doc, partial bool, existing Doc) (Doc, error) { if payload == nil { payload = Doc{} } name, hasName, err := getOptionalString(payload, "name") if err != nil { return nil, errors.New("name must be a string") } if !partial && (!hasName || name == "") { return nil, errors.New("name is required") } if hasName { payload["name"] = name } if err := applyPublishingPolicy(payload, partial, existing, formPublishingPolicyInstance); err != nil { return nil, err } description, hasDescription, err := getOptionalString(payload, "description") if err != nil { return nil, errors.New("description must be a string") } if hasDescription { payload["description"] = description } schema, hasSchema, err := normalizeFormSchema(payload["schema"]) if err != nil { return nil, err } if hasSchema { payload["schema"] = schema } return payload, nil } func (formPublishingPolicy) defaultStatus() string { return formStatusDraft } func (formPublishingPolicy) isAllowedStatus(status string) bool { return status == formStatusDraft || status == formStatusPublished } func (formPublishingPolicy) invalidStatusMessage() string { return "status must be one of: draft, published" } func (formPublishingPolicy) validate(payload Doc, existing Doc) error { status, ok := mergedPublishingString(payload, existing, "status") if !ok || status != formStatusPublished { return nil } schemaValues, has := mergedPublishingValue(payload, existing, "schema") if !has { return errors.New("schema is required when status is published") } normalized, _, err := normalizeFormSchema(schemaValues) if err != nil { return err } if len(normalized) == 0 { return errors.New("schema is required when status is published") } return nil } func normalizeFormSchema(raw any) ([]Doc, bool, error) { if raw == nil { return nil, false, nil } var values []any switch source := raw.(type) { case []any: values = source case List: values = []any(source) case []Doc: values = make([]any, 0, len(source)) for _, field := range source { values = append(values, field) } case []map[string]any: values = make([]any, 0, len(source)) for _, field := range source { values = append(values, field) } default: return nil, false, errors.New("schema must be an array") } normalized := make([]Doc, 0, len(values)) for _, item := range values { fieldMap, ok := item.(map[string]any) if !ok { bsonField, docOK := item.(Doc) if !docOK { return nil, false, errors.New("schema fields must be objects") } fieldMap = map[string]any(bsonField) } nameRaw, ok := fieldMap["name"] if !ok { return nil, false, errors.New("schema field name is required") } name, ok := nameRaw.(string) if !ok || strings.TrimSpace(name) == "" { return nil, false, errors.New("schema field name must be a string") } name = strings.TrimSpace(name) typeRaw, ok := fieldMap["type"] if !ok { return nil, false, errors.New("schema field type is required") } fieldType, ok := typeRaw.(string) if !ok { return nil, false, errors.New("schema field type must be a string") } fieldType = strings.ToLower(strings.TrimSpace(fieldType)) if _, valid := allowedFormFieldTypes[fieldType]; !valid { return nil, false, errors.New("schema field type is invalid") } normalizedField := Doc{ "name": name, "type": fieldType, } if labelRaw, has := fieldMap["label"]; has { label, ok := labelRaw.(string) if !ok { return nil, false, errors.New("schema field label must be a string") } normalizedField["label"] = strings.TrimSpace(label) } if requiredRaw, has := fieldMap["required"]; has { required, ok := requiredRaw.(bool) if !ok { return nil, false, errors.New("schema field required must be a boolean") } normalizedField["required"] = required } else { normalizedField["required"] = false } if optionsRaw, has := fieldMap["options"]; has { options, _, err := normalizeStringSlice(optionsRaw) if err != nil { return nil, false, errors.New("schema field options must be an array of strings") } normalizedField["options"] = options } normalized = append(normalized, normalizedField) } return normalized, true, nil } func validateFormSubmission(schema []Doc, values map[string]any) (Doc, error) { schemaByName := map[string]Doc{} for _, field := range schema { name, _ := field["name"].(string) schemaByName[name] = field } normalized := Doc{} for key, value := range values { field, exists := schemaByName[key] if !exists { return nil, errors.New("unknown field: " + key) } fieldType, _ := field["type"].(string) normalizedValue, err := normalizeSubmissionValue(fieldType, value) if err != nil { return nil, errors.New(key + ": " + err.Error()) } if fieldType == "select" { if err := validateSelectSubmissionOption(field, normalizedValue); err != nil { return nil, errors.New(key + ": " + err.Error()) } } normalized[key] = normalizedValue } for _, field := range schema { name, _ := field["name"].(string) required, _ := field["required"].(bool) if !required { continue } value, exists := normalized[name] if !exists || isEmptySubmissionValue(value) { return nil, errors.New("missing required field: " + name) } } return normalized, nil } func validateSelectSubmissionOption(field Doc, value any) error { selected, ok := value.(string) if !ok { return errors.New("must be a string") } optionsRaw, hasOptions := field["options"] if !hasOptions || optionsRaw == nil { return nil } options, hasNormalizedOptions, err := normalizeStringSlice(optionsRaw) if err != nil { return errors.New("select options are invalid") } if !hasNormalizedOptions { return nil } normalizedSelected := strings.ToLower(strings.TrimSpace(selected)) for _, option := range options { if strings.ToLower(strings.TrimSpace(option)) == normalizedSelected { return nil } } return errors.New("must be one of the configured options") } func normalizeSubmissionValue(fieldType string, value any) (any, error) { switch fieldType { case "text", "textarea", "email", "select": text, ok := value.(string) if !ok { return nil, errors.New("must be a string") } trimmed := strings.TrimSpace(text) if fieldType == "email" && trimmed != "" && !strings.Contains(trimmed, "@") { return nil, errors.New("must be a valid email") } return trimmed, nil case "number": switch typed := value.(type) { case float64: return typed, nil case float32: return float64(typed), nil case int: return float64(typed), nil case int64: return float64(typed), nil case string: if strings.TrimSpace(typed) == "" { return "", nil } parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) if err != nil { return nil, errors.New("must be a number") } return parsed, nil default: return nil, errors.New("must be a number") } case "checkbox": boolean, ok := value.(bool) if !ok { return nil, errors.New("must be a boolean") } return boolean, nil default: return nil, errors.New("unsupported field type") } } func isEmptySubmissionValue(value any) bool { switch typed := value.(type) { case string: return strings.TrimSpace(typed) == "" default: return value == nil } }