support form submission receiver

This commit is contained in:
Adriano Caloiaro 2025-01-23 07:31:12 -07:00
parent d1d5804d11
commit 871ef0ef1b
No known key found for this signature in database
GPG key ID: C2BC56DE73CE3F75
10 changed files with 82 additions and 46 deletions

View file

@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@ -72,6 +73,10 @@ func main() {
WorkspaceIDUrlParam: workspaceParamName, // name of the chi URL parameter name WorkspaceIDUrlParam: workspaceParamName, // name of the chi URL parameter name
BuilderMountPoint: fmt.Sprintf("/frm/{%s}/build", workspaceParamName), BuilderMountPoint: fmt.Sprintf("/frm/{%s}/build", workspaceParamName),
CollectorMountPoint: fmt.Sprintf("/frm/{%s}/collect", workspaceParamName), CollectorMountPoint: fmt.Sprintf("/frm/{%s}/collect", workspaceParamName),
Reciever: func(ctx context.Context, submission frm.FormSubmission) (err error) {
log.Println("GOT SUBMISSION!", submission)
return
},
}) })
if err != nil { if err != nil {
panic(err) panic(err)

View file

@ -10,7 +10,7 @@ CREATE TYPE submission_status AS ENUM (
-- submissions of forms/fields to the collector -- submissions of forms/fields to the collector
CREATE TABLE IF NOT EXISTS form_submissions ( CREATE TABLE IF NOT EXISTS form_submissions (
id BIGINT PRIMARY KEY DEFAULT nextval('submission_ids'), id BIGINT PRIMARY KEY DEFAULT nextval('submission_ids'),
form_id BIGINT REFERENCES forms(id) ON DELETE CASCADE, form_id BIGINT REFERENCES forms(id) ON DELETE CASCADE NOT NULL ,
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
subject_id TEXT DEFAULT NULL, subject_id TEXT DEFAULT NULL,
fields jsonb default '{}' NOT NULL, fields jsonb default '{}' NOT NULL,

39
frm.go
View file

@ -19,24 +19,29 @@ var ErrNoInstanceAvailable = errors.New("no frm instance is available on the con
// Frm is the primary API into frm // Frm is the primary API into frm
type Frm struct { type Frm struct {
BuilderMountPoint string // relative URL path where frm mounts the builder to your app's router BuilderMountPoint string // relative URL path where frm mounts the builder to your app's router
CollectorMountPoint string // relative URL path where frm mounts the collector to your app's router CollectorMountPoint string // relative URL path where frm mounts the collector to your app's router
DBArgs internal.DBArgs // database arguments DBArgs internal.DBArgs // database arguments
WorkspaceID string // ID of the workspace that frm acts on behalf of Receiver FormSubmissionReceiver // function that processes incoming form submissions
WorkspaceIDUrlParam string // name of the URL parameter that provides your workspace ID WorkspaceID string // ID of the workspace that frm acts on behalf of
WorkspaceIDUrlParam string // name of the URL parameter that provides your workspace ID
} }
// Args are arguments passed to Frm // Args are arguments passed to Frm
type Args struct { type Args struct {
BuilderMountPoint string // path on the router to mount frm's builder BuilderMountPoint string // path on the router to mount frm's builder
CollectorMountPoint string // path on the router to mount frm's collector CollectorMountPoint string // path on the router to mount frm's collector
PostgresDisableSSL bool // disable ssl when connecting to postgres Reciever FormSubmissionReceiver // function that processes incoming form submissions
PostgresURL string // postgres database URL PostgresDisableSSL bool // disable ssl when connecting to postgres
PostgresSchema string // postgres schema where frm stores data PostgresURL string // postgres database URL
WorkspaceID string // ID of the workspace for which frm is being initialized PostgresSchema string // postgres schema where frm stores data
WorkspaceIDUrlParam string // named URL parameter that identifies the workspace, e.g. for route /{workspace_id}, the value would be "workspace_id" WorkspaceID string // ID of the workspace for which frm is being initialized
WorkspaceIDUrlParam string // named URL parameter that identifies the workspace, e.g. for route /{workspace_id}, the value would be "workspace_id"
} }
// FormSubmissionReceiver processes form submissions
type FormSubmissionReceiver = func(ctx context.Context, submission FormSubmission) (err error)
// FormStatus is the status of a Form // FormStatus is the status of a Form
// //
// - Published forms are available to be used // - Published forms are available to be used
@ -54,17 +59,17 @@ func New(args Args) (f *Frm, err error) {
if args.WorkspaceID == "" && args.WorkspaceIDUrlParam == "" { if args.WorkspaceID == "" && args.WorkspaceIDUrlParam == "" {
return nil, ErrCannotDetermineWorkspace return nil, ErrCannotDetermineWorkspace
} }
f = &Frm{ f = &Frm{
BuilderMountPoint: strings.TrimSuffix(args.BuilderMountPoint, "/"), BuilderMountPoint: strings.TrimSuffix(args.BuilderMountPoint, "/"),
CollectorMountPoint: strings.TrimSuffix(args.CollectorMountPoint, "/"), CollectorMountPoint: strings.TrimSuffix(args.CollectorMountPoint, "/"),
WorkspaceID: args.WorkspaceID,
WorkspaceIDUrlParam: args.WorkspaceIDUrlParam,
DBArgs: internal.DBArgs{ DBArgs: internal.DBArgs{
URL: args.PostgresURL, URL: args.PostgresURL,
DisableSSL: args.PostgresDisableSSL, DisableSSL: args.PostgresDisableSSL,
Schema: args.PostgresSchema, Schema: args.PostgresSchema,
}, },
Receiver: args.Reciever,
WorkspaceID: args.WorkspaceID,
WorkspaceIDUrlParam: args.WorkspaceIDUrlParam,
} }
return return
} }
@ -116,7 +121,7 @@ func (f *Frm) ListForms(ctx context.Context, args ListFormsArgs) (forms []Form,
return return
} }
// Instance returns the frm instance from the request context // Instance returns the frm instance from the request context (if available)
func Instance(ctx context.Context) (i *Frm, err error) { func Instance(ctx context.Context) (i *Frm, err error) {
var ok bool var ok bool
i, ok = ctx.Value(internal.FrmContextKey).(*Frm) i, ok = ctx.Value(internal.FrmContextKey).(*Frm)
@ -137,7 +142,7 @@ func (f *Frm) CreateShortCode(ctx context.Context, args CreateShortCodeArgs) (sc
s, err = internal.Q(ctx, f.DBArgs).SaveShortCode(ctx, internal.SaveShortCodeParams{ s, err = internal.Q(ctx, f.DBArgs).SaveShortCode(ctx, internal.SaveShortCodeParams{
WorkspaceID: f.WorkspaceID, WorkspaceID: f.WorkspaceID,
FormID: &args.FormID, FormID: &args.FormID,
ShortCode: internal.GenCode(), ShortCode: internal.GenShortCode(),
SubjectID: args.SubjectID, SubjectID: args.SubjectID,
}) })
return (ShortCode)(s), err return (ShortCode)(s), err

10
go.mod
View file

@ -18,8 +18,10 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9 // indirect
go.uber.org/atomic v1.7.0 // indirect go.uber.org/atomic v1.10.0 // indirect
golang.org/x/crypto v0.27.0 // indirect golang.org/x/crypto v0.32.0 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/net v0.34.0 // indirect
golang.org/x/text v0.18.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.29.0 // indirect
) )

32
go.sum
View file

@ -77,22 +77,22 @@ go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -89,7 +89,7 @@ func Collect(w http.ResponseWriter, r *http.Request) {
return return
} }
formID, err := formID(ctx, i) formID, err := formID(ctx, i)
if err != nil { if err != nil || formID == nil {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }
@ -157,9 +157,10 @@ func Collect(w http.ResponseWriter, r *http.Request) {
Value: fieldValue, Value: fieldValue,
} }
} }
_, err = internal.Q(ctx, i.DBArgs).SaveSubmission(ctx, internal.SaveSubmissionParams{ var s internal.FormSubmission
s, err = internal.Q(ctx, i.DBArgs).SaveSubmission(ctx, internal.SaveSubmissionParams{
// ID: submissionID, TODO save submission id // ID: submissionID, TODO save submission id
FormID: formID, FormID: *formID,
WorkspaceID: i.WorkspaceID, WorkspaceID: i.WorkspaceID,
SubjectID: &shortCode.SubjectID, SubjectID: &shortCode.SubjectID,
Status: internal.SubmissionStatusPartial, Status: internal.SubmissionStatusPartial,
@ -170,7 +171,12 @@ func Collect(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
if i.Receiver != nil {
err = i.Receiver(ctx, (frm.FormSubmission)(s))
if err != nil {
slog.Error("[collector] unable to execute submission receiver", "error", err)
}
}
err = ui.ThankYou().Render(ctx, w) err = ui.ThankYou().Render(ctx, w)
if err != nil { if err != nil {
slog.Error("[collector] unable to render thank you page", "error", err) slog.Error("[collector] unable to render thank you page", "error", err)

View file

@ -239,8 +239,8 @@ func (f Form) JSON() string {
return string(b) return string(b)
} }
// GenCode generates new shortcodes // GenShortCode generates new shortcodes
func GenCode() string { func GenShortCode() string {
b := make([]rune, DefaultShortcodeLen) b := make([]rune, DefaultShortcodeLen)
chsLen := len(shortcodeCharset) chsLen := len(shortcodeCharset)
for i := range b { for i := range b {
@ -248,3 +248,18 @@ func GenCode() string {
} }
return string(b) return string(b)
} }
// FormSubmissionMap converts a FormSubmission to its map[string]any representation (for queueing webhooks)
func FormSubmissionMap(s FormSubmission) (m map[string]any) {
m = map[string]any{}
m["id"] = s.ID
m["form_id"] = s.FormID
m["workspace_id"] = s.WorkspaceID
if s.SubjectID != nil {
m["subject_id"] = *s.SubjectID
}
m["fields"] = s.Fields
m["created_at"] = s.CreatedAt
m["updated_at"] = s.UpdatedAt
return
}

View file

@ -113,7 +113,7 @@ type Form struct {
// Respondants submit forms/fields to the collector as form_submissions // Respondants submit forms/fields to the collector as form_submissions
type FormSubmission struct { type FormSubmission struct {
ID int64 `json:"id"` ID int64 `json:"id"`
FormID *int64 `json:"form_id"` FormID int64 `json:"form_id"`
WorkspaceID string `json:"workspace_id"` WorkspaceID string `json:"workspace_id"`
// identifies the subject/respondent who filled out the form // identifies the subject/respondent who filled out the form
SubjectID *string `json:"subject_id"` SubjectID *string `json:"subject_id"`

View file

@ -417,7 +417,7 @@ SET updated_at = timezone('utc', now()),
type SaveSubmissionParams struct { type SaveSubmissionParams struct {
ID interface{} `json:"id"` ID interface{} `json:"id"`
FormID *int64 `json:"form_id"` FormID int64 `json:"form_id"`
WorkspaceID string `json:"workspace_id"` WorkspaceID string `json:"workspace_id"`
SubjectID *string `json:"subject_id"` SubjectID *string `json:"subject_id"`
Fields types.FormFieldValues `json:"fields"` Fields types.FormFieldValues `json:"fields"`

View file

@ -10,5 +10,8 @@ type Form internal.Form
// Forms is a slice of forms // Forms is a slice of forms
type Forms []internal.Form type Forms []internal.Form
// FormSubmission is a form submission
type FormSubmission internal.FormSubmission
// ShortCode is a short code // ShortCode is a short code
type ShortCode internal.ShortCode type ShortCode internal.ShortCode