mirror of
https://github.com/acaloiaro/frm
synced 2026-07-21 10:12:23 +00:00
feat: complete shortcode support
This commit is contained in:
parent
7c39dc7d0f
commit
3b6ef7cc8c
10 changed files with 209 additions and 42 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
29
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 {
|
||||
|
|
|
|||
50
frm_test.go
Normal file
50
frm_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
15
types.go
15
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue