From 871ef0ef1bd04fcca36620a7d31805eaa848a1a1 Mon Sep 17 00:00:00 2001 From: Adriano Caloiaro Date: Thu, 23 Jan 2025 07:31:12 -0700 Subject: [PATCH] support form submission receiver --- cmd/dev_server/main.go | 5 +++ ...20250110190628_add_form_submissions.up.sql | 2 +- frm.go | 39 +++++++++++-------- go.mod | 10 +++-- go.sum | 32 +++++++-------- handlers/collector.go | 14 +++++-- internal/default.go | 19 ++++++++- internal/models.go | 2 +- internal/queries.sql.go | 2 +- types.go | 3 ++ 10 files changed, 82 insertions(+), 46 deletions(-) diff --git a/cmd/dev_server/main.go b/cmd/dev_server/main.go index dcac11b..532b99f 100644 --- a/cmd/dev_server/main.go +++ b/cmd/dev_server/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "log" "log/slog" "net/http" "os" @@ -72,6 +73,10 @@ func main() { WorkspaceIDUrlParam: workspaceParamName, // name of the chi URL parameter name BuilderMountPoint: fmt.Sprintf("/frm/{%s}/build", 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 { panic(err) diff --git a/db/migrations/20250110190628_add_form_submissions.up.sql b/db/migrations/20250110190628_add_form_submissions.up.sql index b982481..e9c2098 100644 --- a/db/migrations/20250110190628_add_form_submissions.up.sql +++ b/db/migrations/20250110190628_add_form_submissions.up.sql @@ -10,7 +10,7 @@ CREATE TYPE submission_status AS ENUM ( -- submissions of forms/fields to the collector CREATE TABLE IF NOT EXISTS form_submissions ( 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, subject_id TEXT DEFAULT NULL, fields jsonb default '{}' NOT NULL, diff --git a/frm.go b/frm.go index 1ff237b..539df0b 100644 --- a/frm.go +++ b/frm.go @@ -19,24 +19,29 @@ var ErrNoInstanceAvailable = errors.New("no frm instance is available on the con // Frm is the primary API into frm type Frm struct { - 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 - DBArgs internal.DBArgs // database arguments - WorkspaceID string // ID of the workspace that frm acts on behalf of - WorkspaceIDUrlParam string // name of the URL parameter that provides your workspace ID + 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 + DBArgs internal.DBArgs // database arguments + Receiver FormSubmissionReceiver // function that processes incoming form submissions + 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 type Args struct { - BuilderMountPoint string // path on the router to mount frm's builder - CollectorMountPoint string // path on the router to mount frm's collector - PostgresDisableSSL bool // disable ssl when connecting to postgres - PostgresURL string // postgres database URL - PostgresSchema string // postgres schema where frm stores data - 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" + BuilderMountPoint string // path on the router to mount frm's builder + CollectorMountPoint string // path on the router to mount frm's collector + Reciever FormSubmissionReceiver // function that processes incoming form submissions + PostgresDisableSSL bool // disable ssl when connecting to postgres + PostgresURL string // postgres database URL + PostgresSchema string // postgres schema where frm stores data + 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 // // - Published forms are available to be used @@ -54,17 +59,17 @@ func New(args Args) (f *Frm, err error) { if args.WorkspaceID == "" && args.WorkspaceIDUrlParam == "" { return nil, ErrCannotDetermineWorkspace } - f = &Frm{ BuilderMountPoint: strings.TrimSuffix(args.BuilderMountPoint, "/"), CollectorMountPoint: strings.TrimSuffix(args.CollectorMountPoint, "/"), - WorkspaceID: args.WorkspaceID, - WorkspaceIDUrlParam: args.WorkspaceIDUrlParam, DBArgs: internal.DBArgs{ URL: args.PostgresURL, DisableSSL: args.PostgresDisableSSL, Schema: args.PostgresSchema, }, + Receiver: args.Reciever, + WorkspaceID: args.WorkspaceID, + WorkspaceIDUrlParam: args.WorkspaceIDUrlParam, } return } @@ -116,7 +121,7 @@ func (f *Frm) ListForms(ctx context.Context, args ListFormsArgs) (forms []Form, 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) { var ok bool 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{ WorkspaceID: f.WorkspaceID, FormID: &args.FormID, - ShortCode: internal.GenCode(), + ShortCode: internal.GenShortCode(), SubjectID: args.SubjectID, }) return (ShortCode)(s), err diff --git a/go.mod b/go.mod index 984aae1..012100b 100644 --- a/go.mod +++ b/go.mod @@ -18,8 +18,10 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lib/pq v1.10.9 // indirect - go.uber.org/atomic v1.7.0 // indirect - golang.org/x/crypto v0.27.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/text v0.18.0 // indirect + go.uber.org/atomic v1.10.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.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 ) diff --git a/go.sum b/go.sum index df50897..07c9021 100644 --- a/go.sum +++ b/go.sum @@ -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/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 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.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= -golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= -golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= +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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/handlers/collector.go b/handlers/collector.go index edabac8..00006b4 100644 --- a/handlers/collector.go +++ b/handlers/collector.go @@ -89,7 +89,7 @@ func Collect(w http.ResponseWriter, r *http.Request) { return } formID, err := formID(ctx, i) - if err != nil { + if err != nil || formID == nil { w.WriteHeader(http.StatusNotFound) return } @@ -157,9 +157,10 @@ func Collect(w http.ResponseWriter, r *http.Request) { 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 - FormID: formID, + FormID: *formID, WorkspaceID: i.WorkspaceID, SubjectID: &shortCode.SubjectID, Status: internal.SubmissionStatusPartial, @@ -170,7 +171,12 @@ func Collect(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) 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) if err != nil { slog.Error("[collector] unable to render thank you page", "error", err) diff --git a/internal/default.go b/internal/default.go index 63a922d..cecb4fe 100644 --- a/internal/default.go +++ b/internal/default.go @@ -239,8 +239,8 @@ func (f Form) JSON() string { return string(b) } -// GenCode generates new shortcodes -func GenCode() string { +// GenShortCode generates new shortcodes +func GenShortCode() string { b := make([]rune, DefaultShortcodeLen) chsLen := len(shortcodeCharset) for i := range b { @@ -248,3 +248,18 @@ func GenCode() string { } 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 +} diff --git a/internal/models.go b/internal/models.go index f8eeaaf..5b5dec1 100644 --- a/internal/models.go +++ b/internal/models.go @@ -113,7 +113,7 @@ type Form struct { // Respondants submit forms/fields to the collector as form_submissions type FormSubmission struct { ID int64 `json:"id"` - FormID *int64 `json:"form_id"` + FormID int64 `json:"form_id"` WorkspaceID string `json:"workspace_id"` // identifies the subject/respondent who filled out the form SubjectID *string `json:"subject_id"` diff --git a/internal/queries.sql.go b/internal/queries.sql.go index f691e15..a6a2493 100644 --- a/internal/queries.sql.go +++ b/internal/queries.sql.go @@ -417,7 +417,7 @@ SET updated_at = timezone('utc', now()), type SaveSubmissionParams struct { ID interface{} `json:"id"` - FormID *int64 `json:"form_id"` + FormID int64 `json:"form_id"` WorkspaceID string `json:"workspace_id"` SubjectID *string `json:"subject_id"` Fields types.FormFieldValues `json:"fields"` diff --git a/types.go b/types.go index 59cf9f3..01a7b89 100644 --- a/types.go +++ b/types.go @@ -10,5 +10,8 @@ type Form internal.Form // Forms is a slice of forms type Forms []internal.Form +// FormSubmission is a form submission +type FormSubmission internal.FormSubmission + // ShortCode is a short code type ShortCode internal.ShortCode