Forms in Notes — MVP Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Allow notes to declare a form in YAML frontmatter; the page embeds a JSON form spec, a GraphQL mutation accepts submissions, admins see them in the admin panel, and an email is sent on each submit.

Architecture: Form spec is parsed from RawMeta["form"] (or resolved via form_ref) at render time. Submissions are stored in EAV tables (form_submits + 3 typed value tables). A background job sends email to all admins after each submit. A new AdminQuery field exposes submissions per note.

Tech Stack: Go, SQLite, sqlc, gqlgen, quicktemplate, $mol (admin UI)


File Map

New:

  • db/migrations/20260513120000_create_form_tables.sql — 4 EAV tables
  • internal/formspec/spec.go — types + parse from RawMeta + form_ref resolution
  • internal/formspec/spec_test.go
  • internal/case/submitform/resolve.go — use case
  • internal/case/submitform/resolve_test.go
  • internal/case/submitform/mocks_test.go — generated by moq
  • internal/case/backjob/sendformsubmit/resolve.go
  • internal/case/backjob/sendformsubmit/plain.qtpl — email template
  • internal/case/backjob/sendformsubmit/plain.qtpl.go — generated

Modified:

  • db/schema.sql — add 4 form tables
  • queries.write.sql — InsertFormSubmit + 3 InsertFormXxxValue queries
  • queries.read.sql — GetFormSubmitsByNotePathID + GetFormXxxValuesBySubmitID queries
  • internal/graph/schema.graphqls — FormSubmit types + submitForm mutation + admin query
  • internal/graph/schema.resolvers.go — resolver implementations
  • cmd/server/main.go — wire Env methods + register job
  • internal/defaulttemplate/views.html — inject <script id="form-spec">
  • internal/defaulttemplate/views.html.go — regenerated

Task 1: DB Migration + Schema

Files:

  • Create: db/migrations/20260513120000_create_form_tables.sql

  • Modify: db/schema.sql

  • Step 1: Create migration file

-- db/migrations/20260513120000_create_form_tables.sql
create table form_submits (
    id              integer primary key,
    note_version_id integer not null references note_versions(id),
    form_id         text not null default '',
    user_id         integer references users(id),
    ip              text not null default '',
    status          text not null default 'visible',
    created_at      datetime not null default current_timestamp
);

create index form_submits_note_version_id on form_submits(note_version_id);

create table form_string_values (
    submit_id  integer not null references form_submits(id) on delete cascade,
    field_name text not null,
    value      text not null,
    primary key (submit_id, field_name)
);

create table form_int_values (
    submit_id  integer not null references form_submits(id) on delete cascade,
    field_name text not null,
    value      integer not null,
    primary key (submit_id, field_name)
);

create table form_bool_values (
    submit_id  integer not null references form_submits(id) on delete cascade,
    field_name text not null,
    value      integer not null,
    primary key (submit_id, field_name)
);
  • Step 2: Add tables to db/schema.sql

Append the same 4 CREATE TABLE statements (without create index) to db/schema.sql after the existing tables. sqlc uses this file for type generation.

  • Step 3: Apply migration to dev DB
# Find dev DB path from config or .env, then:
sqlite3 <path-to-dev.db> < db/migrations/20260513120000_create_form_tables.sql
  • Step 4: Commit
git add db/migrations/20260513120000_create_form_tables.sql db/schema.sql
git commit -m "feat(forms): add form_submits EAV tables migration"

Task 2: sqlc Queries + Regenerate

Files:

  • Modify: queries.write.sql

  • Modify: queries.read.sql

  • Generated: internal/db/queries.write.sql.go, internal/db/queries.read.sql.go

  • Step 1: Add write queries to queries.write.sql

Append to the end of queries.write.sql:

-- name: InsertFormSubmit :one
insert into form_submits (note_version_id, form_id, user_id, ip)
values (?, ?, ?, ?)
returning id;

-- name: InsertFormStringValue :exec
insert into form_string_values (submit_id, field_name, value)
values (?, ?, ?);

-- name: InsertFormIntValue :exec
insert into form_int_values (submit_id, field_name, value)
values (?, ?, ?);

-- name: InsertFormBoolValue :exec
insert into form_bool_values (submit_id, field_name, value)
values (?, ?, ?);
  • Step 2: Add read queries to queries.read.sql

Append to the end of queries.read.sql:

-- name: GetFormSubmitsByNoteVersionID :many
select id, note_version_id, form_id, user_id, ip, status, created_at
from form_submits
where note_version_id = ?
order by created_at desc;

-- name: GetFormSubmitsByNotePathID :many
select fs.id, fs.note_version_id, fs.form_id, fs.user_id, fs.ip, fs.status, fs.created_at
from form_submits fs
join note_versions nv on nv.id = fs.note_version_id
where nv.path_id = ?
order by fs.created_at desc;

-- name: GetFormStringValuesBySubmitID :many
select field_name, value from form_string_values where submit_id = ?;

-- name: GetFormIntValuesBySubmitID :many
select field_name, value from form_int_values where submit_id = ?;

-- name: GetFormBoolValuesBySubmitID :many
select field_name, value from form_bool_values where submit_id = ?;
  • Step 3: Regenerate sqlc
make sqlc

Expected: internal/db/queries.write.sql.go and internal/db/queries.read.sql.go updated with new functions. No errors.

  • Step 4: Commit
git add queries.write.sql queries.read.sql internal/db/queries.write.sql.go internal/db/queries.read.sql.go
git commit -m "feat(forms): add sqlc queries for form submits"

Task 3: formspec Package

Files:

  • Create: internal/formspec/spec.go

  • Create: internal/formspec/spec_test.go

  • Step 1: Write the failing test

// internal/formspec/spec_test.go
package formspec_test

import (
	"testing"

	"github.com/stretchr/testify/require"
	"trip2g/internal/formspec"
)

func TestParseFromRawMeta_nil_when_no_form_key(t *testing.T) {
	spec, err := formspec.ParseFromRawMeta(map[string]interface{}{
		"title": "Hello",
	})
	require.NoError(t, err)
	require.Nil(t, spec)
}

func TestParseFromRawMeta_basic_fields(t *testing.T) {
	rawMeta := map[string]interface{}{
		"form": map[string]interface{}{
			"can_submit": "guest",
			"fields": []interface{}{
				map[string]interface{}{
					"name":     "email",
					"type":     "email",
					"required": true,
				},
				map[string]interface{}{
					"name": "rating",
					"type": "int",
					"min":  1,
					"max":  5,
				},
			},
		},
	}
	spec, err := formspec.ParseFromRawMeta(rawMeta)
	require.NoError(t, err)
	require.NotNil(t, spec)
	require.Equal(t, formspec.CanSubmitGuest, spec.CanSubmit)
	require.Len(t, spec.Fields, 2)
	require.Equal(t, "email", spec.Fields[0].Name)
	require.True(t, spec.Fields[0].Required)
	require.Equal(t, formspec.FieldTypeInt, spec.Fields[1].Type)
}

func TestParseFromRawMeta_enum_validation(t *testing.T) {
	rawMeta := map[string]interface{}{
		"form": map[string]interface{}{
			"can_submit": "admin",
			"fields": []interface{}{
				map[string]interface{}{
					"name": "deadline",
					"type": "text",
					"enum": []interface{}{"yesterday", "month", "quarter"},
				},
			},
		},
	}
	spec, err := formspec.ParseFromRawMeta(rawMeta)
	require.NoError(t, err)
	require.NotNil(t, spec)
	require.Equal(t, []string{"yesterday", "month", "quarter"}, spec.Fields[0].StringEnum)
}
  • Step 2: Run test to verify it fails
go test ./internal/formspec/... 2>&1 | head -20

Expected: compile error — package does not exist.

  • Step 3: Write implementation
// internal/formspec/spec.go
package formspec

import (
	"fmt"

	"gopkg.in/yaml.v3"
)

type CanSubmit string

const (
	CanSubmitGuest    CanSubmit = "guest"
	CanSubmitPaidUser CanSubmit = "paid_user"
	CanSubmitAdmin    CanSubmit = "admin"
)

type FieldType string

const (
	FieldTypeText  FieldType = "text"
	FieldTypeEmail FieldType = "email"
	FieldTypeInt   FieldType = "int"
	FieldTypeBool  FieldType = "bool"
	FieldTypeFile  FieldType = "file" // v2
)

type FormField struct {
	Name      string    `yaml:"name"       json:"name"`
	Type      FieldType `yaml:"type"       json:"type"`
	Required  bool      `yaml:"required"   json:"required"`
	MinLength *int      `yaml:"min_length" json:"min_length,omitempty"`
	MaxLength *int      `yaml:"max_length" json:"max_length,omitempty"`
	Min       *int      `yaml:"min"        json:"min,omitempty"`
	Max       *int      `yaml:"max"        json:"max,omitempty"`
	// Parsed enums per type — only one will be non-nil
	StringEnum []string  `yaml:"-" json:"string_enum,omitempty"`
	IntEnum    []int     `yaml:"-" json:"int_enum,omitempty"`
	BoolEnum   []bool    `yaml:"-" json:"bool_enum,omitempty"`
	// Raw enum from YAML — used during parsing only
	rawEnum interface{} `yaml:"enum" json:"-"`
}

type FormSpec struct {
	CanSubmit CanSubmit   `yaml:"can_submit" json:"can_submit"`
	Turnstile bool        `yaml:"turnstile"  json:"turnstile"` // v2, ignored in MVP
	Fields    []FormField `yaml:"fields"     json:"fields"`
}

// ParseFromRawMeta extracts FormSpec from a note's RawMeta.
// Returns nil, nil if no form key is present.
func ParseFromRawMeta(rawMeta map[string]interface{}) (*FormSpec, error) {
	formRaw, ok := rawMeta["form"]
	if !ok {
		return nil, nil
	}
	b, err := yaml.Marshal(formRaw)
	if err != nil {
		return nil, fmt.Errorf("formspec: marshal: %w", err)
	}
	// Use a raw struct to capture enum before post-processing
	type rawField struct {
		Name      string      `yaml:"name"`
		Type      FieldType   `yaml:"type"`
		Required  bool        `yaml:"required"`
		MinLength *int        `yaml:"min_length"`
		MaxLength *int        `yaml:"max_length"`
		Min       *int        `yaml:"min"`
		Max       *int        `yaml:"max"`
		Enum      interface{} `yaml:"enum"`
	}
	type rawSpec struct {
		CanSubmit CanSubmit  `yaml:"can_submit"`
		Turnstile bool       `yaml:"turnstile"`
		Fields    []rawField `yaml:"fields"`
	}
	var raw rawSpec
	if err := yaml.Unmarshal(b, &raw); err != nil {
		return nil, fmt.Errorf("formspec: unmarshal: %w", err)
	}
	spec := &FormSpec{
		CanSubmit: raw.CanSubmit,
		Turnstile: raw.Turnstile,
		Fields:    make([]FormField, len(raw.Fields)),
	}
	for i, rf := range raw.Fields {
		f := FormField{
			Name:      rf.Name,
			Type:      rf.Type,
			Required:  rf.Required,
			MinLength: rf.MinLength,
			MaxLength: rf.MaxLength,
			Min:       rf.Min,
			Max:       rf.Max,
		}
		f.StringEnum, f.IntEnum, f.BoolEnum = parseEnum(rf.Enum)
		spec.Fields[i] = f
	}
	return spec, nil
}

func parseEnum(raw interface{}) (strings []string, ints []int, bools []bool) {
	items, ok := raw.([]interface{})
	if !ok || len(items) == 0 {
		return nil, nil, nil
	}
	switch items[0].(type) {
	case string:
		for _, v := range items {
			if s, ok := v.(string); ok {
				strings = append(strings, s)
			}
		}
	case int, int64, float64:
		for _, v := range items {
			switch n := v.(type) {
			case int:
				ints = append(ints, n)
			case int64:
				ints = append(ints, int(n))
			case float64:
				ints = append(ints, int(n))
			}
		}
	case bool:
		for _, v := range items {
			if b, ok := v.(bool); ok {
				bools = append(bools, b)
			}
		}
	}
	return
}
  • Step 4: Add form_ref resolution function

Append to internal/formspec/spec.go:

import "strings"

// ParseFormRef extracts a form_ref value from rawMeta.
// Returns kind ("wikilink" or "path"), value, and whether form_ref was present.
func ParseFormRef(rawMeta map[string]interface{}) (kind, value string, ok bool) {
	refRaw, exists := rawMeta["form_ref"]
	if !exists {
		return "", "", false
	}
	s, isStr := refRaw.(string)
	if !isStr {
		return "", "", false
	}
	s = strings.TrimSpace(s)
	if strings.HasPrefix(s, "[[") && strings.HasSuffix(s, "]]") {
		title := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(s, "[["), "]]"))
		return "wikilink", title, true
	}
	return "path", s, true
}
  • Step 5: Run tests
go test ./internal/formspec/... -v

Expected: all tests PASS.

  • Step 6: Commit
git add internal/formspec/
git commit -m "feat(forms): add formspec package — parse form: block from RawMeta"

Task 4: submitForm Use Case

Files:

  • Create: internal/case/submitform/resolve.go

  • Create: internal/case/submitform/resolve_test.go

  • Create: internal/case/submitform/mocks_test.go (generated)

  • Step 1: Write the failing test

// internal/case/submitform/resolve_test.go
package submitform_test

import (
	"context"
	"testing"

	"github.com/stretchr/testify/require"
	"trip2g/internal/case/submitform"
	"trip2g/internal/formspec"
)

func TestResolve_unknown_note_returns_error(t *testing.T) {
	env := &MockEnv{
		GetFormSpecFunc: func(ctx context.Context, noteVersionID int64, formID string) (*formspec.FormSpec, error) {
			return nil, nil // note not found
		},
	}
	_, err := submitform.Resolve(context.Background(), env, submitform.Input{
		NoteVersionID: 1,
		Fields:        nil,
	})
	require.NoError(t, err)
	payload, _ := submitform.Resolve(context.Background(), env, submitform.Input{
		NoteVersionID: 1,
	})
	errP, ok := payload.(*submitform.ErrorResult)
	require.True(t, ok)
	require.Equal(t, "form_not_found", errP.Message)
}

func TestResolve_required_field_missing(t *testing.T) {
	spec := &formspec.FormSpec{
		CanSubmit: formspec.CanSubmitGuest,
		Fields: []formspec.FormField{
			{Name: "email", Type: formspec.FieldTypeEmail, Required: true},
		},
	}
	env := &MockEnv{
		GetFormSpecFunc: func(ctx context.Context, noteVersionID int64, formID string) (*formspec.FormSpec, error) {
			return spec, nil
		},
	}
	payload, err := submitform.Resolve(context.Background(), env, submitform.Input{
		NoteVersionID: 42,
		Fields:        []submitform.FieldValue{{Name: "email", StringValue: nil}},
	})
	require.NoError(t, err)
	errP, ok := payload.(*submitform.ErrorResult)
	require.True(t, ok)
	require.Contains(t, errP.Message, "email")
}

func TestResolve_file_type_returns_not_supported(t *testing.T) {
	spec := &formspec.FormSpec{
		CanSubmit: formspec.CanSubmitGuest,
		Fields:    []formspec.FormField{{Name: "attachment", Type: formspec.FieldTypeFile}},
	}
	env := &MockEnv{
		GetFormSpecFunc: func(ctx context.Context, noteVersionID int64, formID string) (*formspec.FormSpec, error) {
			return spec, nil
		},
	}
	input := submitform.Input{
		NoteVersionID: 1,
		Fields:        []submitform.FieldValue{{Name: "attachment", FilePresent: true}},
	}
	payload, err := submitform.Resolve(context.Background(), env, input)
	require.NoError(t, err)
	errP, ok := payload.(*submitform.ErrorResult)
	require.True(t, ok)
	require.Equal(t, "file_upload_not_supported", errP.Message)
}

func TestResolve_success_inserts_submit_and_enqueues_email(t *testing.T) {
	spec := &formspec.FormSpec{
		CanSubmit: formspec.CanSubmitGuest,
		Fields: []formspec.FormField{
			{Name: "name", Type: formspec.FieldTypeText, Required: true},
			{Name: "score", Type: formspec.FieldTypeInt},
			{Name: "agree", Type: formspec.FieldTypeBool},
		},
	}
	var insertedSubmitPathID int64
	var insertedStrings []string
	var enqueuedEmail bool

	strPtr := func(s string) *string { return &s }
	intPtr := func(n int) *int { return &n }
	boolPtr := func(b bool) *bool { return &b }

	env := &MockEnv{
		GetFormSpecFunc: func(ctx context.Context, noteVersionID int64, formID string) (*formspec.FormSpec, error) {
			return spec, nil
		},
		InsertFormSubmitFunc: func(ctx context.Context, noteVersionID int64, formID string, userID *int64, ip string) (int64, error) {
			insertedSubmitPathID = noteVersionID
			return 99, nil
		},
		InsertFormStringValueFunc: func(ctx context.Context, submitID int64, fieldName, value string) error {
			insertedStrings = append(insertedStrings, fieldName+":"+value)
			return nil
		},
		InsertFormIntValueFunc: func(ctx context.Context, submitID int64, fieldName string, value int64) error {
			return nil
		},
		InsertFormBoolValueFunc: func(ctx context.Context, submitID int64, fieldName string, value bool) error {
			return nil
		},
		EnqueueSendFormSubmitEmailFunc: func(ctx context.Context, submitID int64) error {
			enqueuedEmail = true
			return nil
		},
		RequestIPFunc: func(ctx context.Context) string { return "1.2.3.4" },
		UserIDFunc:    func(ctx context.Context) *int64 { return nil },
	}
	_ = strPtr
	_ = intPtr
	_ = boolPtr

	nameVal := "Alice"
	scoreVal := 5
	agreeVal := true
	payload, err := submitform.Resolve(context.Background(), env, submitform.Input{
		NoteVersionID: 7,
		Fields: []submitform.FieldValue{
			{Name: "name", StringValue: &nameVal},
			{Name: "score", IntValue: &scoreVal},
			{Name: "agree", BoolValue: &agreeVal},
		},
	})
	require.NoError(t, err)
	_, ok := payload.(*submitform.SuccessResult)
	require.True(t, ok, "expected SuccessResult, got %T", payload)
	require.Equal(t, int64(7), insertedSubmitPathID)
	require.Contains(t, insertedStrings, "name:Alice")
	require.True(t, enqueuedEmail)
}
  • Step 2: Generate moq mock
// internal/case/submitform/resolve.go — add Env interface first (minimal, just to run moq)
package submitform

import "context"

//go:generate go tool github.com/matryer/moq -out mocks_test.go . Env

type Env interface {
	GetFormSpec(ctx context.Context, noteVersionID int64, formID string) (*formspec.FormSpec, error)
	InsertFormSubmit(ctx context.Context, noteVersionID int64, formID string, userID *int64, ip string) (int64, error)
	InsertFormStringValue(ctx context.Context, submitID int64, fieldName, value string) error
	InsertFormIntValue(ctx context.Context, submitID int64, fieldName string, value int64) error
	InsertFormBoolValue(ctx context.Context, submitID int64, fieldName string, value bool) error
	EnqueueSendFormSubmitEmail(ctx context.Context, submitID int64) error
	RequestIP(ctx context.Context) string
	UserID(ctx context.Context) *int64
}
go generate ./internal/case/submitform/...
  • Step 3: Run test to verify it fails
go test ./internal/case/submitform/... 2>&1 | head -20

Expected: compile error — Resolve not defined.

  • Step 4: Write implementation
// internal/case/submitform/resolve.go
package submitform

import (
	"context"
	"fmt"
	"net/mail"
	"strings"

	"trip2g/internal/formspec"
)

//go:generate go tool github.com/matryer/moq -out mocks_test.go . Env

type Env interface {
	GetFormSpec(ctx context.Context, noteVersionID int64, formID string) (*formspec.FormSpec, error)
	InsertFormSubmit(ctx context.Context, noteVersionID int64, formID string, userID *int64, ip string) (int64, error)
	InsertFormStringValue(ctx context.Context, submitID int64, fieldName, value string) error
	InsertFormIntValue(ctx context.Context, submitID int64, fieldName string, value int64) error
	InsertFormBoolValue(ctx context.Context, submitID int64, fieldName string, value bool) error
	EnqueueSendFormSubmitEmail(ctx context.Context, submitID int64) error
	RequestIP(ctx context.Context) string
	UserID(ctx context.Context) *int64
}

type FieldValue struct {
	Name        string
	StringValue *string
	IntValue    *int
	BoolValue   *bool
	FilePresent bool // v2: always returns error
}

type Input struct {
	NoteVersionID  int64
	FormID         string // "" for single form
	TurnstileToken string // ignored in MVP
	Fields         []FieldValue
}

type Payload interface{ isSubmitFormPayload() }

type SuccessResult struct{ SubmitID int64 }
type ErrorResult struct{ Message string }

func (s *SuccessResult) isSubmitFormPayload() {}
func (e *ErrorResult) isSubmitFormPayload()   {}

func Resolve(ctx context.Context, env Env, input Input) (Payload, error) {
	spec, err := env.GetFormSpec(ctx, input.NoteVersionID, input.FormID)
	if err != nil {
		return nil, fmt.Errorf("submitform: get form spec: %w", err)
	}
	if spec == nil {
		return &ErrorResult{Message: "form_not_found"}, nil
	}

	if err := validateFields(spec, input.Fields); err != nil {
		return &ErrorResult{Message: err.Error()}, nil
	}

	ip := env.RequestIP(ctx)
	userID := env.UserID(ctx)

	submitID, err := env.InsertFormSubmit(ctx, input.NoteVersionID, input.FormID, userID, ip)
	if err != nil {
		return nil, fmt.Errorf("submitform: insert submit: %w", err)
	}

	for _, fv := range input.Fields {
		field := findField(spec, fv.Name)
		if field == nil {
			continue
		}
		switch field.Type {
		case formspec.FieldTypeText, formspec.FieldTypeEmail:
			if fv.StringValue != nil {
				if err := env.InsertFormStringValue(ctx, submitID, fv.Name, *fv.StringValue); err != nil {
					return nil, fmt.Errorf("submitform: insert string value %q: %w", fv.Name, err)
				}
			}
		case formspec.FieldTypeInt:
			if fv.IntValue != nil {
				if err := env.InsertFormIntValue(ctx, submitID, fv.Name, int64(*fv.IntValue)); err != nil {
					return nil, fmt.Errorf("submitform: insert int value %q: %w", fv.Name, err)
				}
			}
		case formspec.FieldTypeBool:
			if fv.BoolValue != nil {
				if err := env.InsertFormBoolValue(ctx, submitID, fv.Name, *fv.BoolValue); err != nil {
					return nil, fmt.Errorf("submitform: insert bool value %q: %w", fv.Name, err)
				}
			}
		case formspec.FieldTypeFile:
			if fv.FilePresent {
				return &ErrorResult{Message: "file_upload_not_supported"}, nil
			}
		}
	}

	if err := env.EnqueueSendFormSubmitEmail(ctx, submitID); err != nil {
		// non-fatal: log but don't fail the submit
		_ = err
	}

	return &SuccessResult{SubmitID: submitID}, nil
}

func validateFields(spec *formspec.FormSpec, values []FieldValue) error {
	valueMap := make(map[string]FieldValue, len(values))
	for _, fv := range values {
		valueMap[fv.Name] = fv
	}
	for _, field := range spec.Fields {
		fv, present := valueMap[field.Name]
		if !present {
			if field.Required {
				return fmt.Errorf("%s: required", field.Name)
			}
			continue
		}
		switch field.Type {
		case formspec.FieldTypeText:
			if fv.StringValue == nil {
				if field.Required {
					return fmt.Errorf("%s: required", field.Name)
				}
				continue
			}
			v := *fv.StringValue
			if field.MinLength != nil && len(v) < *field.MinLength {
				return fmt.Errorf("%s: too short", field.Name)
			}
			if field.MaxLength != nil && len(v) > *field.MaxLength {
				return fmt.Errorf("%s: too long", field.Name)
			}
			if len(field.StringEnum) > 0 && !containsString(field.StringEnum, v) {
				return fmt.Errorf("%s: invalid value", field.Name)
			}
		case formspec.FieldTypeEmail:
			if fv.StringValue == nil {
				if field.Required {
					return fmt.Errorf("%s: required", field.Name)
				}
				continue
			}
			if _, err := mail.ParseAddress(*fv.StringValue); err != nil {
				return fmt.Errorf("%s: invalid email", field.Name)
			}
		case formspec.FieldTypeInt:
			if fv.IntValue == nil {
				if field.Required {
					return fmt.Errorf("%s: required", field.Name)
				}
				continue
			}
			v := *fv.IntValue
			if field.Min != nil && v < *field.Min {
				return fmt.Errorf("%s: too small", field.Name)
			}
			if field.Max != nil && v > *field.Max {
				return fmt.Errorf("%s: too large", field.Name)
			}
			if len(field.IntEnum) > 0 && !containsInt(field.IntEnum, v) {
				return fmt.Errorf("%s: invalid value", field.Name)
			}
		case formspec.FieldTypeBool:
			if fv.BoolValue == nil {
				if field.Required {
					return fmt.Errorf("%s: required", field.Name)
				}
				continue
			}
			if len(field.BoolEnum) > 0 && !containsBool(field.BoolEnum, *fv.BoolValue) {
				return fmt.Errorf("%s: invalid value", field.Name)
			}
		}
		_ = strings.Contains // used via containsString
	}
	return nil
}

func findField(spec *formspec.FormSpec, name string) *formspec.FormField {
	for i := range spec.Fields {
		if spec.Fields[i].Name == name {
			return &spec.Fields[i]
		}
	}
	return nil
}

func containsString(ss []string, s string) bool {
	for _, v := range ss {
		if v == s {
			return true
		}
	}
	return false
}

func containsInt(ns []int, n int) bool {
	for _, v := range ns {
		if v == n {
			return true
		}
	}
	return false
}

func containsBool(bs []bool, b bool) bool {
	for _, v := range bs {
		if v == b {
			return true
		}
	}
	return false
}
  • Step 5: Run tests
go test ./internal/case/submitform/... -v

Expected: all tests PASS.

  • Step 6: Commit
git add internal/case/submitform/
git commit -m "feat(forms): add submitForm use case with field validation"

Task 5: Email Job

Files:

  • Create: internal/case/backjob/sendformsubmit/resolve.go

  • Create: internal/case/backjob/sendformsubmit/plain.qtpl

  • Step 1: Create email template

{%- func PlainView(p *Params) -%}
New form submission on {%s p.NotePath %}

Submitted: {%s p.SubmittedAt %}
User: {%s p.UserInfo %}
IP: {%s p.IP %}

Fields:
{%- for _, f := range p.Fields %}
  {%s f.Name %}: {%s f.Value %}
{%- endfor %}

View in admin: {%s p.AdminURL %}
{%- endfunc -%}

Save as internal/case/backjob/sendformsubmit/plain.qtpl.

  • Step 2: Generate quicktemplate
go generate ./internal/case/backjob/sendformsubmit/...

(Add //go:generate go tool github.com/valyala/quicktemplate/qtc -dir=. to resolve.go first)

  • Step 3: Write resolve.go
// internal/case/backjob/sendformsubmit/resolve.go
package sendformsubmit

import (
	"bytes"
	"context"
	"fmt"
	"time"

	"trip2g/internal/logger"
	"trip2g/internal/model"
)

//go:generate go tool github.com/valyala/quicktemplate/qtc -dir=.

type FieldEntry struct {
	Name  string
	Value string
}

type Params struct {
	SubmitID    int64
	NotePath    string
	SubmittedAt string
	UserInfo    string
	IP          string
	Fields      []FieldEntry
	AdminURL    string
}

type Env interface {
	Logger() logger.Logger
	SendMail(ctx context.Context, data model.Mail) error
	GetAdminEmails(ctx context.Context) ([]string, error)
	GetFormSubmitForEmail(ctx context.Context, submitID int64) (*Params, error)
}

func Resolve(ctx context.Context, env Env, params Params) error {
	full, err := env.GetFormSubmitForEmail(ctx, params.SubmitID)
	if err != nil {
		return fmt.Errorf("sendformsubmit: get submit: %w", err)
	}
	if full == nil {
		env.Logger().Warn("form submit not found for email", "submit_id", params.SubmitID)
		return nil
	}

	emails, err := env.GetAdminEmails(ctx)
	if err != nil {
		return fmt.Errorf("sendformsubmit: get admin emails: %w", err)
	}

	var buf bytes.Buffer
	WritePlainView(&buf, full)
	body := buf.Bytes()

	subject := fmt.Sprintf("New form submission: %s | %s", full.NotePath, time.Now().Format("2006-01-02"))

	for _, email := range emails {
		if err := env.SendMail(ctx, model.Mail{
			To:      email,
			Subject: subject,
			Plain:   body,
		}); err != nil {
			env.Logger().Error("sendformsubmit: send mail failed", "email", email, "error", err)
		}
	}
	return nil
}
  • Step 4: Build check
go build ./internal/case/backjob/sendformsubmit/...

Expected: no errors.

  • Step 5: Commit
git add internal/case/backjob/sendformsubmit/
git commit -m "feat(forms): add sendformsubmit email background job"

Task 6: GraphQL Schema + Codegen

Files:

  • Modify: internal/graph/schema.graphqls

  • Generated: gqlgen files

  • Step 1: Add types and mutation to schema.graphqls

Find the type Mutation { block and add submitForm before the closing }. Also add to type AdminQuery {. Add the following sections anywhere before the Mutation type (follow existing grouping convention with # comments):

scalar Upload

# ─── Forms ───────────────────────────────────────────────────────────────────

type FormSubmit {
  id: Int!
  notePathId: Int64!
  user: User
  ip: String!
  status: FormSubmitStatus!
  createdAt: Time!
  fields: [FormSubmitField!]!
}

type FormSubmitField {
  name: String!
  stringValue: String
  intValue: Int
  boolValue: Boolean
}

enum FormSubmitStatus {
  pending
  visible
  hidden
}

type AdminFormSubmitsConnection {
  nodes: [FormSubmit!]! @goField(forceResolver: true)
}

input SubmitFormInput {
  noteVersionId: Int64!
  formId: String
  turnstileToken: String
  fields: [FormFieldValueInput!]!
}

input FormFieldValueInput {
  name: String!
  stringValue: String
  intValue: Int
  boolValue: Boolean
  fileValue: Upload
}

type SubmitFormPayload {
  submitId: Int!
}

union SubmitFormOrErrorPayload = SubmitFormPayload | ErrorPayload

In type Mutation { add:

  submitForm(input: SubmitFormInput!): SubmitFormOrErrorPayload!

In type AdminQuery { add:

  formSubmits(notePathId: Int64!): AdminFormSubmitsConnection!

In type AdminMutation { add:

  # Form submit moderation — v2
  • Step 2: Run gqlgen
make gqlgen

Expected: new resolver stubs appear in schema.resolvers.go. No errors.

  • Step 3: Commit
git add internal/graph/schema.graphqls internal/graph/
git commit -m "feat(forms): add GraphQL schema for submitForm and admin formSubmits"

Task 7: Resolver + main.go Wiring

Files:

  • Modify: internal/graph/schema.resolvers.go

  • Modify: cmd/server/main.go

  • Step 1: Implement submitForm resolver

Find the generated SubmitForm method stub in schema.resolvers.go and replace with:

func (r *mutationResolver) SubmitForm(ctx context.Context, input model.SubmitFormInput) (model.SubmitFormOrErrorPayload, error) {
	fields := make([]submitform.FieldValue, len(input.Fields))
	for i, f := range input.Fields {
		fields[i] = submitform.FieldValue{
			Name:        f.Name,
			StringValue: f.StringValue,
			IntValue:    f.IntValue,
			BoolValue:   f.BoolValue,
			FilePresent: f.FileValue != nil,
		}
	}
	var token string
	if input.TurnstileToken != nil {
		token = *input.TurnstileToken
	}
	formID := ""
	if input.FormID != nil {
		formID = *input.FormID
	}
	result, err := submitform.Resolve(ctx, r.env(ctx), submitform.Input{
		NoteVersionID:  int64(input.NoteVersionID),
		FormID:         formID,
		TurnstileToken: token,
		Fields:         fields,
	})
	if err != nil {
		return nil, err
	}
	switch p := result.(type) {
	case *submitform.SuccessResult:
		return &model.SubmitFormPayload{SubmitID: int(p.SubmitID)}, nil
	case *submitform.ErrorResult:
		return &model.ErrorPayload{Message: p.Message}, nil
	default:
		return nil, fmt.Errorf("submitform: unexpected payload type %T", result)
	}
}

Add import: "trip2g/internal/case/submitform"

  • Step 2: Implement adminQuery formSubmits resolver
func (r *adminQueryResolver) FormSubmits(ctx context.Context, obj *appmodel.AdminQuery, notePathID int64) (*model.AdminFormSubmitsConnection, error) {
	return &model.AdminFormSubmitsConnection{NotePathID: notePathID}, nil
}

func (r *adminFormSubmitsConnectionResolver) Nodes(ctx context.Context, obj *model.AdminFormSubmitsConnection) ([]model.FormSubmit, error) {
	submits, err := r.env(ctx).GetFormSubmitsByNotePathID(ctx, obj.NotePathID)
	if err != nil {
		return nil, err
	}
	result := make([]model.FormSubmit, len(submits))
	for i, s := range submits {
		fields, err := loadFormSubmitFields(ctx, r.env(ctx), s.ID)
		if err != nil {
			return nil, err
		}
		result[i] = model.FormSubmit{
			ID:          int(s.ID),
			NotePathID:  s.NotePathID,
			IP:          s.Ip,
			Status:      model.FormSubmitStatus(s.Status),
			CreatedAt:   s.CreatedAt,
			Fields:      fields,
		}
		if s.UserID != nil {
			result[i].UserID = s.UserID
		}
	}
	return result, nil
}

func loadFormSubmitFields(ctx context.Context, env interface {
	GetFormStringValuesBySubmitID(context.Context, int64) ([]db.GetFormStringValuesBySubmitIDRow, error)
	GetFormIntValuesBySubmitID(context.Context, int64) ([]db.GetFormIntValuesBySubmitIDRow, error)
	GetFormBoolValuesBySubmitID(context.Context, int64) ([]db.GetFormBoolValuesBySubmitIDRow, error)
}, submitID int64) ([]model.FormSubmitField, error) {
	var fields []model.FormSubmitField
	strs, err := env.GetFormStringValuesBySubmitID(ctx, submitID)
	if err != nil {
		return nil, err
	}
	for _, s := range strs {
		v := s.Value
		fields = append(fields, model.FormSubmitField{Name: s.FieldName, StringValue: &v})
	}
	ints, err := env.GetFormIntValuesBySubmitID(ctx, submitID)
	if err != nil {
		return nil, err
	}
	for _, n := range ints {
		v := int(n.Value)
		fields = append(fields, model.FormSubmitField{Name: n.FieldName, IntValue: &v})
	}
	bools, err := env.GetFormBoolValuesBySubmitID(ctx, submitID)
	if err != nil {
		return nil, err
	}
	for _, b := range bools {
		v := b.Value != 0
		fields = append(fields, model.FormSubmitField{Name: b.FieldName, BoolValue: &v})
	}
	return fields, nil
}

Add type adminFormSubmitsConnectionResolver struct{ *Resolver } near the other connection resolver structs. Register it in the Resolvers() method.

  • Step 3: Add Env methods to main.go app struct

In cmd/server/main.go, add methods on *app to satisfy the new Env interfaces. Follow existing patterns for DB delegation:

// submitform.Env
func (a *app) GetFormSpec(ctx context.Context, noteVersionID int64, formID string) (*formspec.FormSpec, error) {
	notes := a.loadedNotes(ctx)
	note := notes.ByVersionID(noteVersionID)
	if note == nil {
		return nil, nil
	}
	return resolveFormSpec(note, notes, formID)
}

func resolveFormSpec(note *model.NoteView, notes model.Notes, formID string) (*formspec.FormSpec, error) {
	rawMeta := note.RawMeta
	if kind, value, ok := formspec.ParseFormRef(rawMeta); ok {
		var ref *model.NoteView
		switch kind {
		case "wikilink":
			ref = notes.ByPermalink(value)
		case "path":
			ref = notes.ByPath(value)
		}
		if ref == nil {
			return nil, nil
		}
		rawMeta = ref.RawMeta
	}
	// Multiple forms via forms: map
	if formsRaw, ok := rawMeta["forms"]; ok {
		formsMap, ok := formsRaw.(map[string]interface{})
		if !ok {
			return nil, nil
		}
		formRaw, ok := formsMap[formID]
		if !ok {
			return nil, nil
		}
		return formspec.ParseFromRawMeta(map[string]interface{}{"form": formRaw})
	}
	// Single form via form:
	return formspec.ParseFromRawMeta(rawMeta)
}

func resolveFormSpec(note *model.NoteView, notes model.Notes) (*formspec.FormSpec, error) {
	if kind, value, ok := formspec.ParseFormRef(note.RawMeta); ok {
		var ref *model.NoteView
		switch kind {
		case "wikilink":
			ref = notes.ByPermalink(value)
		case "path":
			ref = notes.ByPath(value)
		}
		if ref == nil {
			return nil, nil
		}
		return formspec.ParseFromRawMeta(ref.RawMeta)
	}
	return formspec.ParseFromRawMeta(note.RawMeta)
}

func (a *app) InsertFormSubmit(ctx context.Context, noteVersionID int64, formID string, userID *int64, ip string) (int64, error) {
	return a.db.write.InsertFormSubmit(ctx, db.InsertFormSubmitParams{
		NoteVersionID: noteVersionID,
		FormID:        formID,
		UserID:        userID,
		Ip:            ip,
	})
}

func (a *app) InsertFormStringValue(ctx context.Context, submitID int64, fieldName, value string) error {
	return a.db.write.InsertFormStringValue(ctx, db.InsertFormStringValueParams{
		SubmitID: submitID, FieldName: fieldName, Value: value,
	})
}

func (a *app) InsertFormIntValue(ctx context.Context, submitID int64, fieldName string, value int64) error {
	return a.db.write.InsertFormIntValue(ctx, db.InsertFormIntValueParams{
		SubmitID: submitID, FieldName: fieldName, Value: value,
	})
}

func (a *app) InsertFormBoolValue(ctx context.Context, submitID int64, fieldName string, value bool) error {
	v := int64(0)
	if value {
		v = 1
	}
	return a.db.write.InsertFormBoolValue(ctx, db.InsertFormBoolValueParams{
		SubmitID: submitID, FieldName: fieldName, Value: v,
	})
}

func (a *app) EnqueueSendFormSubmitEmail(ctx context.Context, submitID int64) error {
	return a.SendFormSubmitEmailJob.Enqueue(ctx, sendformsubmit.Params{SubmitID: submitID})
}

func (a *app) GetFormSubmitsByNotePathID(ctx context.Context, notePathID int64) ([]db.GetFormSubmitsByNotePathIDRow, error) {
	return a.db.read.GetFormSubmitsByNotePathID(ctx, notePathID)
}

func (a *app) GetFormStringValuesBySubmitID(ctx context.Context, submitID int64) ([]db.GetFormStringValuesBySubmitIDRow, error) {
	return a.db.read.GetFormStringValuesBySubmitID(ctx, submitID)
}

func (a *app) GetFormIntValuesBySubmitID(ctx context.Context, submitID int64) ([]db.GetFormIntValuesBySubmitIDRow, error) {
	return a.db.read.GetFormIntValuesBySubmitID(ctx, submitID)
}

func (a *app) GetFormBoolValuesBySubmitID(ctx context.Context, submitID int64) ([]db.GetFormBoolValuesBySubmitIDRow, error) {
	return a.db.read.GetFormBoolValuesBySubmitID(ctx, submitID)
}

func (a *app) GetAdminEmails(ctx context.Context) ([]string, error) {
	admins, err := a.db.read.AllAdmins(ctx)
	if err != nil {
		return nil, err
	}
	emails := make([]string, 0, len(admins))
	for _, ad := range admins {
		emails = append(emails, ad.Email)
	}
	return emails, nil
}

func (a *app) GetFormSubmitForEmail(ctx context.Context, submitID int64) (*sendformsubmit.Params, error) {
	// Load submit + all values, build Params for email template
	// Find the submit row via GetFormSubmitsByNotePathID is not suitable here.
	// Add a new sqlc query: GetFormSubmitByID (see note below).
	...
}

Note: You'll need to add GetFormSubmitByID to queries.read.sql and re-run make sqlc:

-- name: GetFormSubmitByID :one
select id, note_path_id, user_id, ip, status, created_at
from form_submits where id = ?;
  • Step 4: Register the email job in app init

Find where other jobs like SendSignInCodeJob are initialized in cmd/server/main.go and add:

a.SendFormSubmitEmailJob = backjob.NewJob(sendformsubmit.Resolve, a, jobQueue)

Declare the field on the app struct:

SendFormSubmitEmailJob *backjob.Job[sendformsubmit.Params]
  • Step 5: Build check
go build ./...

Expected: no errors.

  • Step 6: Commit
git add internal/graph/schema.resolvers.go cmd/server/main.go
git commit -m "feat(forms): wire submitForm resolver and admin formSubmits query"

Task 8: Form Spec Embedding in Page

Files:

  • Modify: internal/defaulttemplate/views.html

  • Regenerated: internal/defaulttemplate/views.html.go

  • Step 1: Add FormSpec helper to template Ctx

In internal/defaulttemplate/template.go, add a method on Ctx:

func (ctx *Ctx) FormSpec() *formspec.FormSpec {
	if ctx.Note == nil {
		return nil
	}
	spec, _ := formspec.ParseFromRawMeta(ctx.Note.RawMeta())
	if spec != nil {
		return spec
	}
	// form_ref resolution
	kind, value, ok := formspec.ParseFormRef(ctx.Note.RawMeta())
	if !ok {
		return nil
	}
	var ref *templateviews.Note
	switch kind {
	case "wikilink":
		ref = ctx.Notes.ByPermalink(value)
	case "path":
		ref = ctx.Notes.ByPath(value)
	}
	if ref == nil {
		return nil
	}
	resolved, _ := formspec.ParseFromRawMeta(ref.RawMeta())
	return resolved
}

Add import "trip2g/internal/formspec" to template.go.

  • Step 2: Inject form spec JSON in views.html

Find the end of the <article> content section (before </main> or </article>) and add:

{% if fs := ctx.FormSpec(); fs != nil %}
{% code formSpecJSON, _ := json.Marshal(fs) %}
<script id="form-spec" type="application/json">{%z= formSpecJSON %}</script>
{% endif %}

({%z= %} outputs raw bytes without escaping.)

  • Step 3: Regenerate views.html.go
go generate ./internal/defaulttemplate/...

Expected: views.html.go updated. No errors.

  • Step 4: Build check
go build ./...
  • Step 5: Commit
git add internal/defaulttemplate/views.html internal/defaulttemplate/views.html.go internal/defaulttemplate/template.go
git commit -m "feat(forms): embed form-spec JSON in note page HTML"

Task 9: Admin UI

Files:

  • Create: assets/ui/form_submits/form_submits.view.tree

  • Create: assets/ui/form_submits/form_submits.view.ts

  • Step 1: Create the view tree

$trip2g_form_submits $mol_list
	note_path_id?val 0
	rows /
		<= Title $mol_dimmer
			needle <= search \
			haystack <= title \Form Submissions
		<= Submits $mol_list
			rows <= submit_rows /

$trip2g_form_submits_row $mol_view
	submit *
	sub /
		<= Meta $mol_dimmer
			needle <= search \
			haystack <= meta \
		<= Fields $mol_list
			rows <= field_rows /

$trip2g_form_submits_field $mol_view
	name \
	value \
	sub /
		<= Label $mol_dimmer
			needle <= search \
			haystack <= label \
		<= Value $mol_dimmer
			needle <= search \
			haystack <= value_text \
  • Step 2: Create the view TypeScript
// assets/ui/form_submits/form_submits.view.ts
namespace $ {
	export class $trip2g_form_submits extends $.$trip2g_form_submits {
		note_path_id(val = 0) { return val }

		@ $mol_mem
		submits() {
			const res = $trip2g_graphql_request({
				query: `query formSubmits($notePathId: Int64!) {
					admin { formSubmits(notePathId: $notePathId) { nodes {
						id notePathId ip status createdAt
						fields { name stringValue intValue boolValue }
					}}}
				}`,
				variables: { notePathId: this.note_path_id() },
			})
			return res?.data?.admin?.formSubmits?.nodes ?? []
		}

		submit_rows() {
			return this.submits().map((_: any, i: number) => {
				const row = new $trip2g_form_submits_row()
				row.submit = () => this.submits()[i]
				return row
			})
		}
	}

	export class $trip2g_form_submits_row extends $.$trip2g_form_submits_row {
		meta() {
			const s = this.submit()
			return `${s.createdAt} · IP: ${s.ip} · ${s.status}`
		}

		field_rows() {
			return (this.submit()?.fields ?? []).map((f: any) => {
				const fld = new $trip2g_form_submits_field()
				fld.name = () => f.name
				fld.value = () => String(f.stringValue ?? f.intValue ?? f.boolValue ?? '')
				return fld
			})
		}
	}

	export class $trip2g_form_submits_field extends $.$trip2g_form_submits_field {
		label() { return this.name() + ':' }
		value_text() { return this.value() }
	}
}
  • Step 3: Wire into admin note view

Find the admin note detail page component (look for an existing admin note view in assets/ui/) and add $trip2g_form_submits as a sub-component, passing note_path_id.

  • Step 4: Build frontend
npm run build

Expected: no TypeScript errors.

  • Step 5: Commit
git add assets/ui/form_submits/
git commit -m "feat(forms): add admin form submits UI"

Final Verification

  • Start dev server: make air
  • Create a test note with a form: block (e.g., form:\n can_submit: guest\n fields:\n - name: email\n type: email\n required: true)
  • Open note page, verify <script id="form-spec"> is present in page source
  • Run mutation submitForm with a valid email field via GraphQL playground
  • Verify row appears in form_submits table in SQLite
  • Verify admin email was sent (check dev logs)
  • Open admin panel, navigate to note, verify form submits list shows the submission
git add -A
git commit -m "feat(forms): forms in notes MVP complete"