Files
warmbly/internal/models/form.go
T

558 lines
20 KiB
Go

package models
import (
"fmt"
"net/url"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
)
// Form is a hosted lead-capture form: an ordered field list plus a design
// theme, published at a public URL and embeddable on any website. A
// submission creates or updates a contact, files it under the form's
// categories and optionally adds it to a campaign.
type Form struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
// PublicID is the unguessable token in the public URL and embed codes.
PublicID string `json:"public_id"`
Name string `json:"name"`
Status FormStatus `json:"status"`
Fields []FormField `json:"fields"`
Design FormDesign `json:"design"`
SuccessMessage string `json:"success_message"`
RedirectURL string `json:"redirect_url"`
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
CategoryIDs []uuid.UUID `json:"category_ids"`
AllowedDomains []string `json:"allowed_domains"`
CaptchaEnabled bool `json:"captcha_enabled"`
// LogoURL, CoverURL and BackgroundURL are uploaded brand assets (public
// object URLs).
LogoURL string `json:"logo_url"`
CoverURL string `json:"cover_url"`
BackgroundURL string `json:"background_url"`
ViewsCount int64 `json:"views_count"`
SubmissionsCount int64 `json:"submissions_count"`
LastSubmissionAt *time.Time `json:"last_submission_at,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
// Event-derived aggregates, populated by List only (nil Trend elsewhere).
StartsCount int64 `json:"starts_count"`
IdentifiedCount int64 `json:"identified_count"`
Trend []int64 `json:"trend,omitempty"`
// ShareURL is the hosted page URL, derived from config on read.
ShareURL string `json:"share_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// FormStatus is the lifecycle of a form. Only published forms render and
// accept submissions; archiving keeps the data but takes the page offline.
type FormStatus string
const (
FormStatusDraft FormStatus = "draft"
FormStatusPublished FormStatus = "published"
FormStatusArchived FormStatus = "archived"
)
// Valid reports whether the value is one the database accepts.
func (s FormStatus) Valid() bool {
switch s {
case FormStatusDraft, FormStatusPublished, FormStatusArchived:
return true
}
return false
}
// FormFieldType is one kind of builder block. Input types collect a value;
// layout types (heading, paragraph, divider) only render.
type FormFieldType string
const (
FormFieldText FormFieldType = "text"
FormFieldEmail FormFieldType = "email"
FormFieldPhone FormFieldType = "phone"
FormFieldTextarea FormFieldType = "textarea"
FormFieldNumber FormFieldType = "number"
FormFieldSelect FormFieldType = "select"
FormFieldRadio FormFieldType = "radio"
FormFieldCheckboxes FormFieldType = "checkboxes"
FormFieldCheckbox FormFieldType = "checkbox"
FormFieldDate FormFieldType = "date"
FormFieldHidden FormFieldType = "hidden"
FormFieldHeading FormFieldType = "heading"
FormFieldParagraph FormFieldType = "paragraph"
FormFieldDivider FormFieldType = "divider"
// FormFieldPageBreak splits the form into pages; its label is the page title.
FormFieldPageBreak FormFieldType = "page_break"
)
// Valid reports whether the type is a known block.
func (t FormFieldType) Valid() bool {
switch t {
case FormFieldText, FormFieldEmail, FormFieldPhone, FormFieldTextarea, FormFieldNumber,
FormFieldSelect, FormFieldRadio, FormFieldCheckboxes, FormFieldCheckbox, FormFieldDate,
FormFieldHidden, FormFieldHeading, FormFieldParagraph, FormFieldDivider, FormFieldPageBreak:
return true
}
return false
}
// IsInput reports whether the block collects a value on submit.
func (t FormFieldType) IsInput() bool {
switch t {
case FormFieldHeading, FormFieldParagraph, FormFieldDivider, FormFieldPageBreak:
return false
}
return true
}
// HasOptions reports whether the block needs an options list.
func (t FormFieldType) HasOptions() bool {
return t == FormFieldSelect || t == FormFieldRadio || t == FormFieldCheckboxes
}
// FormField is one block on the form. ID is a builder-generated slug, stable
// across edits; submissions key their answers by it.
type FormField struct {
ID string `json:"id"`
Type FormFieldType `json:"type"`
Label string `json:"label"`
Placeholder string `json:"placeholder,omitempty"`
HelpText string `json:"help_text,omitempty"`
Required bool `json:"required"`
Options []string `json:"options,omitempty"`
// MapTo names the contact column the answer fills (first_name, last_name,
// email, company, phone). Empty means the answer lands in the contact's
// custom fields under the field label.
MapTo string `json:"map_to,omitempty"`
// Value is the constant a hidden field submits, or the body text of a
// paragraph block.
Value string `json:"value,omitempty"`
// Width is full or half; two half fields share a row.
Width string `json:"width,omitempty"`
Rows int `json:"rows,omitempty"`
}
// FormDesign is the theme the builder's Design panel edits. Unset values fall
// back to the defaults in NormalizeFormDesign, so an older row still renders.
type FormDesign struct {
FontFamily string `json:"font_family,omitempty"`
PageBackground string `json:"page_background,omitempty"`
FormBackground string `json:"form_background,omitempty"`
TextColor string `json:"text_color,omitempty"`
LabelColor string `json:"label_color,omitempty"`
InputBackground string `json:"input_background,omitempty"`
InputBorderColor string `json:"input_border_color,omitempty"`
InputTextColor string `json:"input_text_color,omitempty"`
PlaceholderColor string `json:"placeholder_color,omitempty"`
AccentColor string `json:"accent_color,omitempty"`
ButtonBackground string `json:"button_background,omitempty"`
ButtonTextColor string `json:"button_text_color,omitempty"`
ButtonText string `json:"button_text,omitempty"`
ButtonSize string `json:"button_size,omitempty"`
ButtonFullWidth bool `json:"button_full_width,omitempty"`
BorderRadius *int `json:"border_radius,omitempty"`
MaxWidth *int `json:"max_width,omitempty"`
Spacing string `json:"spacing,omitempty"`
Shadow *bool `json:"shadow,omitempty"`
// Theme records which preset seeded the current colors; renderers never
// read it, so new presets need no backend release.
Theme string `json:"theme,omitempty"`
// Layout is card (centered box), wide (no box) or split (cover panel).
Layout string `json:"layout,omitempty"`
// Mode is classic (pages of fields) or focus (one question per screen).
Mode string `json:"mode,omitempty"`
// PageBackgroundEnd turns the page background into a vertical gradient.
PageBackgroundEnd string `json:"page_background_end,omitempty"`
Align string `json:"align,omitempty"`
ShowProgress *bool `json:"show_progress,omitempty"`
// BackgroundSize and BackgroundOverlay style the uploaded background_url:
// the overlay (0-100) veils the image with the page color so text on top
// stays legible.
BackgroundSize string `json:"background_size,omitempty"`
BackgroundOverlay *int `json:"background_overlay,omitempty"`
// A bar carrying the logo and a title: whether it spans the page or sits
// with the form, how its contents align, and whether it sticks on scroll.
HeaderEnabled *bool `json:"header_enabled,omitempty"`
HeaderTitle string `json:"header_title,omitempty"`
HeaderBackground string `json:"header_background,omitempty"`
HeaderPlacement string `json:"header_placement,omitempty"`
HeaderAlign string `json:"header_align,omitempty"`
HeaderSticky *bool `json:"header_sticky,omitempty"`
HeaderShowLogo *bool `json:"header_show_logo,omitempty"`
// Copy over the split layout's cover panel.
CoverTitle string `json:"cover_title,omitempty"`
CoverSubtitle string `json:"cover_subtitle,omitempty"`
// How the uploaded logo renders: its height, and whether it sits on the
// form surface or above the card on the page background.
LogoSize string `json:"logo_size,omitempty"`
LogoPosition string `json:"logo_position,omitempty"`
}
// FormWrite is the PATCH payload; every field is optional.
type FormWrite struct {
Name *string `json:"name,omitempty"`
Status *FormStatus `json:"status,omitempty"`
Fields *[]FormField `json:"fields,omitempty"`
Design *FormDesign `json:"design,omitempty"`
SuccessMessage *string `json:"success_message,omitempty"`
RedirectURL *string `json:"redirect_url,omitempty"`
CampaignID NullableUUID `json:"campaign_id"`
CategoryIDs *[]uuid.UUID `json:"category_ids,omitempty"`
AllowedDomains *[]string `json:"allowed_domains,omitempty"`
CaptchaEnabled *bool `json:"captcha_enabled,omitempty"`
}
// FormSubmission is one public submit, kept verbatim. Data is keyed by field
// id; checkbox groups store a string slice, everything else a string.
type FormSubmission struct {
ID uuid.UUID `json:"id"`
FormID uuid.UUID `json:"form_id"`
OrganizationID uuid.UUID `json:"organization_id"`
ContactID *uuid.UUID `json:"contact_id,omitempty"`
// CampaignID is the campaign whose email carried the personalized link
// the visitor arrived through, when there was one.
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
Data map[string]any `json:"data"`
SourceURL string `json:"source_url"`
CreatedAt time.Time `json:"created_at"`
// Contact and campaign summaries for the submissions table, populated by
// reads.
ContactEmail string `json:"contact_email,omitempty"`
ContactName string `json:"contact_name,omitempty"`
CampaignName string `json:"campaign_name,omitempty"`
}
// Form limits.
const (
FormMaxNameLen = 120
FormsPerOrgMax = 100
FormMaxFields = 60
FormMaxLabelLen = 200
FormMaxTextLen = 2000
FormMaxOptions = 50
FormMaxOptionLen = 100
FormMaxDomains = 20
FormMaxAnswerLen = 5000
FormMaxCategories = 20
FormMinBorderRadius = 0
FormMaxBorderRadius = 24
FormMinWidth = 320
FormMaxWidth = 960
FormDefaultWidth = 560
FormDefaultSuccessMsg = "Thanks! Your submission has been received."
)
var (
formFieldIDRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`)
formHexRE = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
formThemeRE = regexp.MustCompile(`^[a-z0-9-]{0,40}$`)
)
// FormContactColumns is every contact column a field may map to.
var FormContactColumns = map[string]bool{
"first_name": true,
"last_name": true,
"email": true,
"company": true,
"phone": true,
}
// ValidateFormName trims and bounds the internal name.
func ValidateFormName(name string) (string, *errx.Error) {
name = strings.TrimSpace(name)
if name == "" {
return "", errx.New(errx.BadRequest, "form name is required")
}
if len(name) > FormMaxNameLen {
return "", errx.New(errx.BadRequest, fmt.Sprintf("form name must be at most %d characters", FormMaxNameLen))
}
return name, nil
}
// ValidateFormFields checks and normalizes the field list in place.
func ValidateFormFields(fields []FormField) *errx.Error {
if len(fields) > FormMaxFields {
return errx.New(errx.BadRequest, fmt.Sprintf("at most %d fields per form", FormMaxFields))
}
seenID := make(map[string]bool, len(fields))
seenMap := make(map[string]bool, len(fields))
for i := range fields {
f := &fields[i]
f.ID = strings.TrimSpace(f.ID)
f.Label = strings.TrimSpace(f.Label)
f.Placeholder = strings.TrimSpace(f.Placeholder)
f.HelpText = strings.TrimSpace(f.HelpText)
f.MapTo = strings.TrimSpace(f.MapTo)
f.Value = strings.TrimSpace(f.Value)
at := fmt.Sprintf("field %d", i+1)
if !formFieldIDRE.MatchString(f.ID) {
return errx.New(errx.BadRequest, at+": id must be a short lowercase slug")
}
if seenID[f.ID] {
return errx.New(errx.BadRequest, at+": duplicate id "+f.ID)
}
seenID[f.ID] = true
if !f.Type.Valid() {
return errx.New(errx.BadRequest, fmt.Sprintf("%s: unknown type %q", at, f.Type))
}
if len(f.Label) > FormMaxLabelLen {
return errx.New(errx.BadRequest, fmt.Sprintf("%s: label must be at most %d characters", at, FormMaxLabelLen))
}
if len(f.Placeholder) > FormMaxLabelLen || len(f.HelpText) > FormMaxLabelLen {
return errx.New(errx.BadRequest, fmt.Sprintf("%s: placeholder and help text must be at most %d characters", at, FormMaxLabelLen))
}
if len(f.Value) > FormMaxTextLen {
return errx.New(errx.BadRequest, fmt.Sprintf("%s: text must be at most %d characters", at, FormMaxTextLen))
}
if f.Type.IsInput() && f.Type != FormFieldHidden && f.Label == "" {
return errx.New(errx.BadRequest, at+": a label is required")
}
if f.Type.HasOptions() {
if len(f.Options) == 0 {
return errx.New(errx.BadRequest, at+": at least one option is required")
}
if len(f.Options) > FormMaxOptions {
return errx.New(errx.BadRequest, fmt.Sprintf("%s: at most %d options", at, FormMaxOptions))
}
seenOpt := make(map[string]bool, len(f.Options))
for j, opt := range f.Options {
opt = strings.TrimSpace(opt)
f.Options[j] = opt
if opt == "" || len(opt) > FormMaxOptionLen {
return errx.New(errx.BadRequest, fmt.Sprintf("%s: options must be 1 to %d characters", at, FormMaxOptionLen))
}
if seenOpt[opt] {
return errx.New(errx.BadRequest, at+": duplicate option "+opt)
}
seenOpt[opt] = true
}
} else {
f.Options = nil
}
if f.MapTo != "" {
if !f.Type.IsInput() {
return errx.New(errx.BadRequest, at+": only input fields can map to a contact column")
}
if !FormContactColumns[f.MapTo] {
return errx.New(errx.BadRequest, fmt.Sprintf("%s: unknown contact column %q", at, f.MapTo))
}
if seenMap[f.MapTo] {
return errx.New(errx.BadRequest, fmt.Sprintf("%s: another field already maps to %s", at, f.MapTo))
}
seenMap[f.MapTo] = true
}
if f.Type == FormFieldEmail {
f.MapTo = "email"
}
if f.Width != "" && f.Width != "full" && f.Width != "half" {
return errx.New(errx.BadRequest, at+": width must be full or half")
}
if f.Rows < 0 || f.Rows > 20 {
return errx.New(errx.BadRequest, at+": rows must be between 0 and 20")
}
}
return nil
}
// FormEmailField returns the field that fills the contact email, if any.
func FormEmailField(fields []FormField) *FormField {
for i := range fields {
if fields[i].MapTo == "email" || fields[i].Type == FormFieldEmail {
return &fields[i]
}
}
return nil
}
// ValidateFormDesign checks colors and enums; NormalizeFormDesign clamps the
// numeric knobs and fills defaults.
func ValidateFormDesign(d *FormDesign) *errx.Error {
for _, c := range []string{
d.PageBackground, d.FormBackground, d.TextColor, d.LabelColor, d.InputBackground,
d.InputBorderColor, d.InputTextColor, d.PlaceholderColor, d.AccentColor,
d.ButtonBackground, d.ButtonTextColor, d.PageBackgroundEnd, d.HeaderBackground,
} {
if c != "" && !formHexRE.MatchString(c) && c != "transparent" {
return errx.New(errx.BadRequest, "design colors must be #rrggbb values")
}
}
// The list must match FONT_CATALOG in the mirrored designCore.ts.
switch d.FontFamily {
case "", "system", "inter", "serif", "mono", "manrope", "sora", "fraunces", "space-grotesk":
default:
return errx.New(errx.BadRequest, "font_family is not a supported font")
}
switch d.Layout {
case "", "card", "wide", "split":
default:
return errx.New(errx.BadRequest, "layout must be card, wide or split")
}
switch d.Mode {
case "", "classic", "focus":
default:
return errx.New(errx.BadRequest, "mode must be classic or focus")
}
switch d.Align {
case "", "left", "center":
default:
return errx.New(errx.BadRequest, "align must be left or center")
}
switch d.HeaderPlacement {
case "", "page", "inline":
default:
return errx.New(errx.BadRequest, "header_placement must be page or inline")
}
switch d.HeaderAlign {
case "", "left", "center", "between":
default:
return errx.New(errx.BadRequest, "header_align must be left, center or between")
}
switch d.LogoSize {
case "", "sm", "md", "lg":
default:
return errx.New(errx.BadRequest, "logo_size must be sm, md or lg")
}
switch d.LogoPosition {
case "", "card", "page":
default:
return errx.New(errx.BadRequest, "logo_position must be card or page")
}
switch d.BackgroundSize {
case "", "cover", "contain", "tile":
default:
return errx.New(errx.BadRequest, "background_size must be cover, contain or tile")
}
if d.BackgroundOverlay != nil && (*d.BackgroundOverlay < 0 || *d.BackgroundOverlay > 100) {
return errx.New(errx.BadRequest, "background_overlay must be between 0 and 100")
}
for _, t := range []string{d.HeaderTitle, d.CoverTitle} {
if len(t) > FormMaxLabelLen {
return errx.New(errx.BadRequest, "title is too long")
}
}
if len(d.CoverSubtitle) > FormMaxTextLen {
return errx.New(errx.BadRequest, "cover subtitle is too long")
}
if !formThemeRE.MatchString(d.Theme) {
return errx.New(errx.BadRequest, "theme must be a short lowercase slug")
}
switch d.ButtonSize {
case "", "sm", "md", "lg":
default:
return errx.New(errx.BadRequest, "button_size must be sm, md or lg")
}
switch d.Spacing {
case "", "compact", "normal", "relaxed":
default:
return errx.New(errx.BadRequest, "spacing must be compact, normal or relaxed")
}
if len(d.ButtonText) > FormMaxLabelLen {
return errx.New(errx.BadRequest, "button text is too long")
}
return nil
}
// NormalizeFormDesign clamps numbers into range. String defaults are applied
// at render time so a stored row stays sparse.
func NormalizeFormDesign(d *FormDesign) {
if d.BorderRadius != nil {
v := clampInt(*d.BorderRadius, FormMinBorderRadius, FormMaxBorderRadius)
d.BorderRadius = &v
}
if d.MaxWidth != nil {
v := clampInt(*d.MaxWidth, FormMinWidth, FormMaxWidth)
d.MaxWidth = &v
}
d.ButtonText = strings.TrimSpace(d.ButtonText)
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// ValidateFormRedirectURL accepts an empty value or an absolute http(s) URL.
func ValidateFormRedirectURL(raw string) (string, *errx.Error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", errx.New(errx.BadRequest, "redirect URL must be an absolute http(s) URL")
}
if len(raw) > 2048 {
return "", errx.New(errx.BadRequest, "redirect URL is too long")
}
return raw, nil
}
// ValidateFormDomains normalizes the embed allowlist to bare lowercase hosts.
// A host admits its subdomains, mirroring website tracking.
func ValidateFormDomains(domains []string) ([]string, *errx.Error) {
if len(domains) > FormMaxDomains {
return nil, errx.New(errx.BadRequest, fmt.Sprintf("at most %d allowed domains", FormMaxDomains))
}
out := make([]string, 0, len(domains))
seen := make(map[string]bool, len(domains))
for _, d := range domains {
h := strings.ToLower(strings.TrimSpace(d))
h = strings.TrimPrefix(h, "https://")
h = strings.TrimPrefix(h, "http://")
if i := strings.IndexAny(h, "/?#"); i >= 0 {
h = h[:i]
}
h = strings.TrimSuffix(h, ".")
if h == "" {
continue
}
if len(h) > 253 || strings.ContainsAny(h, " \t@") {
return nil, errx.New(errx.BadRequest, fmt.Sprintf("%q is not a valid domain", d))
}
if !seen[h] {
seen[h] = true
out = append(out, h)
}
}
return out, nil
}
// FormDomainAllowed reports whether host is covered by the allowlist. An
// empty list allows everything.
func FormDomainAllowed(allowed []string, host string) bool {
if len(allowed) == 0 {
return true
}
host = strings.ToLower(strings.TrimSuffix(host, "."))
if h, _, ok := strings.Cut(host, ":"); ok {
host = h
}
for _, a := range allowed {
if host == a || strings.HasSuffix(host, "."+a) {
return true
}
}
return false
}