feat: complete shortcode support

This commit is contained in:
Adriano Caloiaro 2025-01-18 09:09:16 -07:00
parent 7c39dc7d0f
commit 3b6ef7cc8c
No known key found for this signature in database
GPG key ID: C2BC56DE73CE3F75
10 changed files with 209 additions and 42 deletions

View file

@ -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,

View file

@ -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 *;

29
frm.go
View file

@ -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 {

50
frm_test.go Normal file
View file

@ -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")
}
}

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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)

View file

@ -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
}

View file

@ -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