562 lines
14 KiB
Go
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
|
|
}
|
|
}
|