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 tablesinternal/formspec/spec.go— types + parse from RawMeta + form_ref resolutioninternal/formspec/spec_test.gointernal/case/submitform/resolve.go— use caseinternal/case/submitform/resolve_test.gointernal/case/submitform/mocks_test.go— generated by moqinternal/case/backjob/sendformsubmit/resolve.gointernal/case/backjob/sendformsubmit/plain.qtpl— email templateinternal/case/backjob/sendformsubmit/plain.qtpl.go— generated
Modified:
db/schema.sql— add 4 form tablesqueries.write.sql— InsertFormSubmit + 3 InsertFormXxxValue queriesqueries.read.sql— GetFormSubmitsByNotePathID + GetFormXxxValuesBySubmitID queriesinternal/graph/schema.graphqls— FormSubmit types + submitForm mutation + admin queryinternal/graph/schema.resolvers.go— resolver implementationscmd/server/main.go— wire Env methods + register jobinternal/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
submitFormwith a valid email field via GraphQL playground - Verify row appears in
form_submitstable 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"