From 3b6ef7cc8c0bc2a8553eae7e89477cb2e285f929 Mon Sep 17 00:00:00 2001 From: Adriano Caloiaro Date: Sat, 18 Jan 2025 09:09:16 -0700 Subject: [PATCH] feat: complete shortcode support --- db/create_forms_test.go | 9 ++++--- db/queries.sql | 5 ++++ frm.go | 29 +++++++++++++++++++-- frm_test.go | 50 +++++++++++++++++++++++++++++++++++ handlers/collector.go | 13 +++++++--- handlers/handlers.go | 37 ++++++++++++++------------ internal/default.go | 29 ++++++++++++++++++++- internal/queries.sql.go | 56 ++++++++++++++++++++++++++++++++++++++++ routers/frmchi/router.go | 8 +++--- types.go | 15 +++-------- 10 files changed, 209 insertions(+), 42 deletions(-) create mode 100644 frm_test.go diff --git a/db/create_forms_test.go b/db/create_forms_test.go index 5503ab0..23a6e79 100644 --- a/db/create_forms_test.go +++ b/db/create_forms_test.go @@ -63,8 +63,9 @@ func TestCreateAndUpdateForm(t *testing.T) { } ctx := context.Background() frms, err := frm.New(frm.Args{ - DBUrl: os.Getenv("POSTGRES_URL"), - WorkspaceID: workspaceID, + PostgresURL: os.Getenv("POSTGRES_URL"), + WorkspaceID: workspaceID.String(), + PostgresDisableSSL: true, }) if err != nil { t.Error(err) @@ -75,7 +76,7 @@ func TestCreateAndUpdateForm(t *testing.T) { t.Error(err) return } - f, err := internal.Q(ctx, frms.PostgresURL).SaveDraft(ctx, internal.SaveDraftParams{ + f, err := internal.Q(ctx, frms.DBArgs).SaveDraft(ctx, internal.SaveDraftParams{ Name: "hello world", Fields: fields, WorkspaceID: frms.WorkspaceID, @@ -85,7 +86,7 @@ func TestCreateAndUpdateForm(t *testing.T) { return } - f, err = internal.Q(ctx, frms.PostgresURL).SaveDraft(ctx, internal.SaveDraftParams{ + f, err = internal.Q(ctx, frms.DBArgs).SaveDraft(ctx, internal.SaveDraftParams{ ID: f.ID, Name: nameUpdate, Fields: updatedFields, diff --git a/db/queries.sql b/db/queries.sql index 6dc6d4e..3ebadef 100644 --- a/db/queries.sql +++ b/db/queries.sql @@ -85,3 +85,8 @@ SET updated_at = timezone('utc', now()), fields = @fields, status = @status RETURNING *; +-- name: GetShortCode :one +SELECT * FROM short_codes WHERE short_code = @short_code; + +-- name: SaveShortCode :one +INSERT INTO short_codes (workspace_id, form_id, subject_id, short_code) VALUES (@workspace_id, @form_id, @subject_id, @short_code) RETURNING *; diff --git a/frm.go b/frm.go index 84f7826..4f65af0 100644 --- a/frm.go +++ b/frm.go @@ -36,7 +36,15 @@ type Args struct { WorkspaceIDUrlParam string // named URL parameter that identifies the workspace, e.g. for route /{workspace_id}, the value would be "workspace_id" } -type FormStatus internal.FormStatus +// FormStatus is the status of a Form +// +// - Published forms are available to be used +// +// - Draft forms are in a draft state, yet to be published +type FormStatus = internal.FormStatus + +const FormStatusPublished = internal.FormStatusPublished +const FormStatusDraft = internal.FormStatusDraft // New initializes a new frm instance // @@ -117,7 +125,24 @@ func Instance(ctx context.Context) (i *Frm, err error) { return } -// CollectorPath returns paths to frm collector endpoints +type CreateShortCodeArgs struct { + FormID int64 + SubjectID string +} + +// CreateShortCode creates short code for a given form and subject +func (f *Frm) CreateShortCode(ctx context.Context, args CreateShortCodeArgs) (sc ShortCode, err error) { + var s internal.ShortCode + s, err = internal.Q(ctx, f.DBArgs).SaveShortCode(ctx, internal.SaveShortCodeParams{ + WorkspaceID: f.WorkspaceID, + FormID: &args.FormID, + ShortCode: internal.GenCode(), + SubjectID: args.SubjectID, + }) + return (ShortCode)(s), err +} + +// # CollectorPath returns paths to frm collector endpoints // // It uses the collector's mount point on the router to generate collector paths func CollectorPath(ctx context.Context, path string) string { diff --git a/frm_test.go b/frm_test.go new file mode 100644 index 0000000..ac004d9 --- /dev/null +++ b/frm_test.go @@ -0,0 +1,50 @@ +package frm_test + +import ( + "context" + "os" + "testing" + + "github.com/acaloiaro/frm" + "github.com/acaloiaro/frm/internal" + "github.com/acaloiaro/frm/types" +) + +func TestCreateShortCode(t *testing.T) { + ctx := context.Background() + f, err := frm.New(frm.Args{ + PostgresURL: os.Getenv("POSTGRES_URL"), + PostgresDisableSSL: true, + WorkspaceID: "1", + WorkspaceIDUrlParam: "client_id", + }) + if err != nil { + t.Error(err) + } + + draft, err := internal.Q(ctx, internal.DBArgs{ + URL: os.Getenv("POSTGRES_URL"), + DisableSSL: true, + Schema: "frm_test", + }).SaveDraft(ctx, internal.SaveDraftParams{ + Name: "hello world", + Fields: types.FormFields{}, + WorkspaceID: "1", + }) + if err != nil { + t.Error(err) + return + } + + sc, err := f.CreateShortCode(ctx, frm.CreateShortCodeArgs{ + FormID: draft.ID, + SubjectID: "foobar_idx", + }) + if err != nil { + t.Error(err) + } + + if len(sc.ShortCode) != internal.DefaultShortcodeLen { + t.Fatal("shortcode length is incorrect") + } +} diff --git a/handlers/collector.go b/handlers/collector.go index f3cf2c0..1e5f281 100644 --- a/handlers/collector.go +++ b/handlers/collector.go @@ -22,8 +22,9 @@ func View(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - formID, err := formID(ctx) + formID, err := formID(ctx, i) if err != nil { + slog.Error("unable to view form!", "error", err) w.WriteHeader(http.StatusNotFound) return } @@ -32,6 +33,7 @@ func View(w http.ResponseWriter, r *http.Request) { ID: *formID, }) if err != nil { + slog.Error("unable to view form!!", "error", err) w.WriteHeader(http.StatusNotFound) return } @@ -39,6 +41,7 @@ func View(w http.ResponseWriter, r *http.Request) { err = ui.Viewer((frm.Form)(f)).Render(ctx, w) if err != nil { w.WriteHeader(http.StatusInternalServerError) + return } } @@ -46,15 +49,16 @@ func View(w http.ResponseWriter, r *http.Request) { // // When Forms are submitted via short URL, submissions are attributed to the subject with which the short code was // generated. -func ShortURL(w http.ResponseWriter, r *http.Request) { +func ShortCode(w http.ResponseWriter, r *http.Request) { ctx := r.Context() i, err := frm.Instance(ctx) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } - formID, err := formID(ctx) + formID, err := formID(ctx, i) if err != nil { + slog.Error("unable to view form", "error", err) w.WriteHeader(http.StatusNotFound) return } @@ -63,6 +67,7 @@ func ShortURL(w http.ResponseWriter, r *http.Request) { ID: *formID, }) if err != nil { + slog.Error("unable to view form", "error", err) w.WriteHeader(http.StatusNotFound) return } @@ -81,7 +86,7 @@ func Collect(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - formID, err := formID(ctx) + formID, err := formID(ctx, i) if err != nil { w.WriteHeader(http.StatusNotFound) return diff --git a/handlers/handlers.go b/handlers/handlers.go index b4ab61e..f6a11ce 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -55,7 +55,7 @@ func DraftEditor(w http.ResponseWriter, r *http.Request) { return } - id, err := formID(ctx) + id, err := formID(ctx, f) if err != nil { w.WriteHeader(http.StatusNotFound) return @@ -89,7 +89,7 @@ func UpdateFieldOrder(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - formID, err := formID(ctx) + formID, err := formID(ctx, f) if err != nil { w.WriteHeader(http.StatusNotFound) return @@ -158,7 +158,7 @@ func LogicConfiguratorChoices(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - formID, err := formID(ctx) + formID, err := formID(ctx, f) if err != nil { w.WriteHeader(http.StatusNotFound) return @@ -216,7 +216,7 @@ func UpdateSettings(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - draftID, err := formID(ctx) + draftID, err := formID(ctx, f) if err != nil { w.WriteHeader(http.StatusNotFound) return @@ -293,7 +293,7 @@ func NewField(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - formID, err := formID(ctx) + formID, err := formID(ctx, f) if err != nil { w.WriteHeader(http.StatusNotFound) return @@ -380,7 +380,7 @@ func UpdateFields(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - formID, err := formID(ctx) + formID, err := formID(ctx, f) if err != nil { w.WriteHeader(http.StatusNotFound) return @@ -516,7 +516,7 @@ func DeleteField(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - formID, err := formID(ctx) + formID, err := formID(ctx, f) if err != nil { w.WriteHeader(http.StatusNotFound) return @@ -572,15 +572,17 @@ func DeleteField(w http.ResponseWriter, r *http.Request) { // NewDraft creates a new draft from scratch or an existing form func NewDraft(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - formID, _ := formID(ctx) f, err := frm.Instance(ctx) if err != nil { slog.Error("unable to create draft", "error", err) w.WriteHeader(http.StatusInternalServerError) return } - q := internal.Q(ctx, f.DBArgs) + // dont check for errors here, because this endpoint handles both new drafts and drafts from existing forms + formID, _ := formID(ctx, f) + + q := internal.Q(ctx, f.DBArgs) draftParams := &internal.SaveDraftParams{ WorkspaceID: f.WorkspaceID, Fields: types.FormFields{}, @@ -624,7 +626,7 @@ func PublishDraft(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - draftID, err := formID(ctx) + draftID, err := formID(ctx, f) if err != nil { slog.Error("unable to publish draft", "error", err) w.WriteHeader(http.StatusNotFound) @@ -660,7 +662,7 @@ func DeleteForm(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - formID, err := formID(ctx) + formID, err := formID(ctx, f) if err != nil { slog.Error("unable to delete form", "error", err) w.WriteHeader(http.StatusNotFound) @@ -681,14 +683,17 @@ func DeleteForm(w http.ResponseWriter, r *http.Request) { } // formID gets the form ID from the request context -func formID(ctx context.Context) (formID *int64, err error) { +func formID(ctx context.Context, f *frm.Frm) (formID *int64, err error) { var ok bool formID, ok = ctx.Value(FormIDContextKey).(*int64) if !ok { - // TODO lookup the short code from the db - // if shortCode, ok := ctx.Value(ShortCodeContextKey).(string); !ok { - - // } + if shortCode, ok := ctx.Value(ShortCodeContextKey).(*string); ok { + s, err := internal.Q(ctx, f.DBArgs).GetShortCode(ctx, *shortCode) + if err != nil { + return nil, err + } + return s.FormID, nil + } return nil, ErrFormIDNotFound } return diff --git a/internal/default.go b/internal/default.go index 5fc51c7..6d06caf 100644 --- a/internal/default.go +++ b/internal/default.go @@ -2,9 +2,11 @@ package internal import ( "context" + "encoding/json" "errors" "fmt" "log/slog" + "math/rand" "strings" "github.com/acaloiaro/frm/db/migrations" @@ -20,13 +22,18 @@ type contextKey string const ( // BuilderMountPointContextKey is the context key representing frm's builder mount point on the request context BuilderMountPointContextKey contextKey = "builder_mount_point_context_key" + // DefaultShortcodeLen is the default length for generating short codes + DefaultShortcodeLen = 6 // CollectorMountPointContextKey is the context key representing frm's collector mount point on the request context CollectorMountPointContextKey contextKey = "collector_mount_point_context_key" // FrmContextKey is the context key representing the frm instance on the request context FrmContextKey contextKey = "frm_instance" ) -var pool *pgxpool.Pool +var ( + pool *pgxpool.Pool + shortcodeCharset = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") +) type Forms []Form @@ -218,3 +225,23 @@ func pgConnectionString(args DBArgs) (connString string, err error) { connString = fmt.Sprintf("postgres://%s:%s@%s:%d/%s?%s", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database, strings.Join(options, "&")) return } + +// JSON returns the form's JSON-seralized string representation +func (f Form) JSON() string { + b, err := json.Marshal(f) + if err != nil { + return "" + } + + return string(b) +} + +// GenCode generates new shortcodes +func GenCode() string { + b := make([]rune, DefaultShortcodeLen) + chsLen := len(shortcodeCharset) + for i := range b { + b[i] = shortcodeCharset[rand.Intn(chsLen)] + } + return string(b) +} diff --git a/internal/queries.sql.go b/internal/queries.sql.go index 5b5c68a..ed528e0 100644 --- a/internal/queries.sql.go +++ b/internal/queries.sql.go @@ -107,6 +107,28 @@ func (q *Queries) GetForm(ctx context.Context, arg GetFormParams) (Form, error) return i, err } +const getShortCode = `-- name: GetShortCode :one +SELECT id, workspace_id, form_id, short_code, subject_id, created_at, updated_at FROM short_codes WHERE short_code = $1 +` + +// GetShortCode +// +// SELECT id, workspace_id, form_id, short_code, subject_id, created_at, updated_at FROM short_codes WHERE short_code = $1 +func (q *Queries) GetShortCode(ctx context.Context, shortCode string) (ShortCode, error) { + row := q.db.QueryRow(ctx, getShortCode, shortCode) + var i ShortCode + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.FormID, + &i.ShortCode, + &i.SubjectID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const listDrafts = `-- name: ListDrafts :many SELECT id, form_id, workspace_id, name, fields, status, created_at, updated_at @@ -334,6 +356,40 @@ func (q *Queries) SaveDraft(ctx context.Context, arg SaveDraftParams) (Form, err return i, err } +const saveShortCode = `-- name: SaveShortCode :one +INSERT INTO short_codes (workspace_id, form_id, subject_id, short_code) VALUES ($1, $2, $3, $4) RETURNING id, workspace_id, form_id, short_code, subject_id, created_at, updated_at +` + +type SaveShortCodeParams struct { + WorkspaceID string `json:"workspace_id"` + FormID *int64 `json:"form_id"` + SubjectID string `json:"subject_id"` + ShortCode string `json:"short_code"` +} + +// SaveShortCode +// +// INSERT INTO short_codes (workspace_id, form_id, subject_id, short_code) VALUES ($1, $2, $3, $4) RETURNING id, workspace_id, form_id, short_code, subject_id, created_at, updated_at +func (q *Queries) SaveShortCode(ctx context.Context, arg SaveShortCodeParams) (ShortCode, error) { + row := q.db.QueryRow(ctx, saveShortCode, + arg.WorkspaceID, + arg.FormID, + arg.SubjectID, + arg.ShortCode, + ) + var i ShortCode + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.FormID, + &i.ShortCode, + &i.SubjectID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const saveSubmission = `-- name: SaveSubmission :one INSERT INTO form_submissions (id, form_id, workspace_id, fields, status) diff --git a/routers/frmchi/router.go b/routers/frmchi/router.go index 5275f35..427cdab 100644 --- a/routers/frmchi/router.go +++ b/routers/frmchi/router.go @@ -53,8 +53,8 @@ func Mount(router chi.Router, f *frm.Frm) { collector := chi.NewRouter() collector.Use(Middlware(f)) collector.NotFound(handlers.StaticAssetHandler) - collector.Route("/s", func(form chi.Router) { - form.Get("/{short_code}", handlers.View) + collector.With(addRequestContext).Route(fmt.Sprintf("/s/{%s}", UrlParamShortCode), func(sc chi.Router) { + sc.Get("/", handlers.ShortCode) }) collector.Route(fmt.Sprintf("/{%s}", UrlParamFormID), func(form chi.Router) { form = form.With(addRequestContext) @@ -138,8 +138,8 @@ func addRequestContext(h http.Handler) http.Handler { } ctx = context.WithValue(ctx, handlers.FieldIDContextKey, &fieldID) case string(UrlParamShortCode): - shortCode := chi.URLParam(r, string(UrlParamFieldID)) - if shortCode != "" { + shortCode := chi.URLParam(r, string(UrlParamShortCode)) + if shortCode == "" { w.WriteHeader(http.StatusNotFound) return } diff --git a/types.go b/types.go index ce1911d..59cf9f3 100644 --- a/types.go +++ b/types.go @@ -1,21 +1,14 @@ package frm import ( - "encoding/json" - "github.com/acaloiaro/frm/internal" ) // Form is a published form type Form internal.Form + +// Forms is a slice of forms type Forms []internal.Form -// JSON returns the form's JSON-seralized string representation -func (f Form) JSON() string { - b, err := json.Marshal(f) - if err != nil { - return "" - } - - return string(b) -} +// ShortCode is a short code +type ShortCode internal.ShortCode