562 lines
14 KiB
Go

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
}
}