feat: randomize question options

This commit is contained in:
Adriano Caloiaro 2025-02-20 14:51:59 -07:00
parent 1ac064b933
commit fe99ee9275
No known key found for this signature in database
GPG key ID: C2BC56DE73CE3F75
5 changed files with 124 additions and 4 deletions

View file

@ -20,6 +20,13 @@ WHERE workspace_id = @workspace_id
AND id = @id AND id = @id
AND status = 'draft'; AND status = 'draft';
-- name: CleanupDrafts :exec
DELETE
FROM forms
WHERE status = 'draft'
AND updated_at < now() - @hours::interval;
-- name: ListForms :many -- name: ListForms :many
SELECT * SELECT *
@ -28,7 +35,8 @@ WHERE workspace_id = @workspace_id
AND status = any(CASE AND status = any(CASE
WHEN cardinality(@statuses::form_status[]) > 0 THEN @statuses::form_status[] WHEN cardinality(@statuses::form_status[]) > 0 THEN @statuses::form_status[]
ELSE enum_range(NULL::form_status)::form_status[] ELSE enum_range(NULL::form_status)::form_status[]
END::form_status[]); END::form_status[])
ORDER BY created_at DESC;
-- name: ListDrafts :many -- name: ListDrafts :many
@ -36,7 +44,8 @@ SELECT *
FROM forms FROM forms
WHERE workspace_id = @workspace_id WHERE workspace_id = @workspace_id
AND form_id = @form_id AND form_id = @form_id
AND status = 'draft'; AND status = 'draft'
ORDER BY created_at DESC;
-- name: SaveForm :one -- name: SaveForm :one

16
frm.go
View file

@ -6,12 +6,14 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/acaloiaro/frm/internal" "github.com/acaloiaro/frm/internal"
) )
const ( const (
EventDraftCreated = "frmDraftCreated" // htmx event sent when new drafts are created EventDraftCreated = "frmDraftCreated" // htmx event sent when new drafts are created
EventCloneCreated = "frmCloneCreated" // htmx event sent when new clones are created
DefaultCopyNameSuffix = "(COPY)" // the default suffix added to forms when they're copied DefaultCopyNameSuffix = "(COPY)" // the default suffix added to forms when they're copied
) )
@ -23,6 +25,7 @@ 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
CollectorFooter string // footer shown at the bottom of the collector page CollectorFooter string // footer shown at the bottom of the collector page
DraftMaxAge time.Duration // the duration that form drafts may remain in the draft stage before removal
DBArgs internal.DBArgs // database arguments DBArgs internal.DBArgs // database arguments
Receiver FormSubmissionReceiver // function that processes incoming form submissions Receiver FormSubmissionReceiver // function that processes incoming form submissions
WorkspaceID string // ID of the workspace that frm acts on behalf of WorkspaceID string // ID of the workspace that frm acts on behalf of
@ -34,6 +37,7 @@ 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
CollectorFooter string // footer shown at the bottom of the collector page CollectorFooter string // footer shown at the bottom of the collector page
DraftMaxAge time.Duration // the duration that form drafts may remain in the draft state before removal
PostgresDisableSSL bool // disable ssl when connecting to postgres PostgresDisableSSL bool // disable ssl when connecting to postgres
PostgresSchema string // postgres schema where frm stores data PostgresSchema string // postgres schema where frm stores data
PostgresURL string // postgres database URL PostgresURL string // postgres database URL
@ -69,6 +73,7 @@ func New(args Args) (f *Frm, err error) {
BuilderMountPoint: strings.TrimSuffix(args.BuilderMountPoint, "/"), BuilderMountPoint: strings.TrimSuffix(args.BuilderMountPoint, "/"),
CollectorMountPoint: strings.TrimSuffix(args.CollectorMountPoint, "/"), CollectorMountPoint: strings.TrimSuffix(args.CollectorMountPoint, "/"),
CollectorFooter: args.CollectorFooter, CollectorFooter: args.CollectorFooter,
DraftMaxAge: args.DraftMaxAge,
DBArgs: internal.DBArgs{ DBArgs: internal.DBArgs{
URL: args.PostgresURL, URL: args.PostgresURL,
DisableSSL: args.PostgresDisableSSL, DisableSSL: args.PostgresDisableSSL,
@ -84,6 +89,16 @@ func New(args Args) (f *Frm, err error) {
// Init initializes the frm database if it hasn't been initialized // Init initializes the frm database if it hasn't been initialized
func (f *Frm) Init(ctx context.Context) (err error) { func (f *Frm) Init(ctx context.Context) (err error) {
err = internal.InitializeDB(ctx, f.DBArgs) err = internal.InitializeDB(ctx, f.DBArgs)
if err != nil {
return
}
go func() {
err = internal.DraftMonitor(ctx, f.DBArgs, f.DraftMaxAge)
if err != nil {
return
}
}()
return return
} }
@ -130,6 +145,7 @@ func (f *Frm) CopyForm(ctx context.Context, args CopyFormArgs) (form Form, err e
FormID: &of.ID, FormID: &of.ID,
Name: copiedFormName, Name: copiedFormName,
Fields: of.Fields, Fields: of.Fields,
Status: FormStatusDraft,
} }
if args.ForgetParentForm { if args.ForgetParentForm {
p.FormID = nil p.FormID = nil

View file

@ -676,7 +676,13 @@ func NewDraft(w http.ResponseWriter, r *http.Request) {
} }
} }
w.Header().Add("HX-Trigger", fmt.Sprintf("{\"%s\": {\"draft_id\": \"%d\"}}", frm.EventDraftCreated, draft.ID)) var event string
if isClone {
event = frm.EventCloneCreated
} else {
event = frm.EventDraftCreated
}
w.Header().Add("HX-Trigger", fmt.Sprintf("{\"%s\": {\"draft_id\": \"%d\"}}", event, draft.ID))
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
} }
@ -696,13 +702,33 @@ func PublishDraft(w http.ResponseWriter, r *http.Request) {
return return
} }
_, err = internal.Q(ctx, f.DBArgs).PublishDraft(ctx, *draftID) tx, err := internal.Tx(ctx, f.DBArgs)
if err != nil {
slog.Error("unable to publish draft", "error", err)
w.WriteHeader(http.StatusNotFound)
return
}
q := internal.Q(ctx, f.DBArgs).WithTx(tx)
_, err = q.PublishDraft(ctx, *draftID)
if err != nil { if err != nil {
slog.Error("unable to publish draft", "error", err) slog.Error("unable to publish draft", "error", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
err = q.DeleteForm(ctx, internal.DeleteFormParams{
WorkspaceID: f.WorkspaceID,
ID: *draftID,
})
if err != nil {
slog.Error("unable to publish draft", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = tx.Commit(ctx)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }

View file

@ -8,6 +8,7 @@ import (
"log/slog" "log/slog"
"math/rand" "math/rand"
"strings" "strings"
"time"
"github.com/acaloiaro/frm/db/migrations" "github.com/acaloiaro/frm/db/migrations"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
@ -115,6 +116,20 @@ func getPool(ctx context.Context, args DBArgs) (p *pgxpool.Pool, err error) {
return return
} }
// Tx returns a new transaction from the pool
func Tx(ctx context.Context, args DBArgs) (tx pgx.Tx, err error) {
var p *pgxpool.Pool
p, err = getPool(ctx, args)
if err != nil {
slog.Error("[frm] unable to get database connection", "error", err)
return
}
tx, err = p.Begin(ctx)
return
}
// Q returns a DBTX instance for querying the frm database // Q returns a DBTX instance for querying the frm database
func Q(ctx context.Context, args DBArgs) *Queries { func Q(ctx context.Context, args DBArgs) *Queries {
p, err := getPool(ctx, args) p, err := getPool(ctx, args)
@ -144,6 +159,36 @@ func InitializeDB(ctx context.Context, args DBArgs) (err error) {
return return
} }
// DraftMonitor runs a periodic goroutine that cleans up drafts older than [f.DraftMaxAge]
func DraftMonitor(ctx context.Context, args DBArgs, draftMaxAge time.Duration) (err error) {
if draftMaxAge == 0 {
slog.Info("[draft_monitor] not monitoring old drafts for cleanup")
return
}
t := time.NewTicker(draftMaxAge)
defer t.Stop()
// perform the initial cleanup on start, with all successive cleanups happening according to the ticker
err = Q(ctx, args).CleanupDrafts(ctx, draftMaxAge)
if err != nil {
slog.Error("[draft_monitor] unable to clean up old drafts", slog.Any("error", err))
}
slog.Info("[draft_monitor] monitoring old drafts for cleanup with:", slog.Any("interval", draftMaxAge))
for {
select {
case <-t.C:
err = Q(ctx, args).CleanupDrafts(ctx, draftMaxAge)
if err != nil {
slog.Error("[draft_monitor] unable to clean up old drafts", slog.Any("error", err))
}
case <-ctx.Done():
err = nil
return
}
}
}
// TODO not all db credentials have permission create databases/schemas. Fail gracefully when user lacks permission // TODO not all db credentials have permission create databases/schemas. Fail gracefully when user lacks permission
func createIfNotExist(ctx context.Context, args DBArgs) (err error) { func createIfNotExist(ctx context.Context, args DBArgs) (err error) {
var cfg *pgx.ConnConfig var cfg *pgx.ConnConfig

View file

@ -9,8 +9,28 @@ import (
"context" "context"
"github.com/acaloiaro/frm/types" "github.com/acaloiaro/frm/types"
"time"
) )
const cleanupDrafts = `-- name: CleanupDrafts :exec
DELETE
FROM forms
WHERE status = 'draft'
AND updated_at < now() - $1::interval
`
// CleanupDrafts
//
// DELETE
// FROM forms
// WHERE status = 'draft'
// AND updated_at < now() - $1::interval
func (q *Queries) CleanupDrafts(ctx context.Context, hours time.Duration) error {
_, err := q.db.Exec(ctx, cleanupDrafts, hours)
return err
}
const deleteForm = `-- name: DeleteForm :exec const deleteForm = `-- name: DeleteForm :exec
DELETE DELETE
@ -183,6 +203,7 @@ FROM forms
WHERE workspace_id = $1 WHERE workspace_id = $1
AND form_id = $2 AND form_id = $2
AND status = 'draft' AND status = 'draft'
ORDER BY created_at DESC
` `
type ListDraftsParams struct { type ListDraftsParams struct {
@ -197,6 +218,7 @@ type ListDraftsParams struct {
// WHERE workspace_id = $1 // WHERE workspace_id = $1
// AND form_id = $2 // AND form_id = $2
// AND status = 'draft' // AND status = 'draft'
// ORDER BY created_at DESC
func (q *Queries) ListDrafts(ctx context.Context, arg ListDraftsParams) ([]Form, error) { func (q *Queries) ListDrafts(ctx context.Context, arg ListDraftsParams) ([]Form, error) {
rows, err := q.db.Query(ctx, listDrafts, arg.WorkspaceID, arg.FormID) rows, err := q.db.Query(ctx, listDrafts, arg.WorkspaceID, arg.FormID)
if err != nil { if err != nil {
@ -235,6 +257,7 @@ WHERE workspace_id = $1
WHEN cardinality($2::form_status[]) > 0 THEN $2::form_status[] WHEN cardinality($2::form_status[]) > 0 THEN $2::form_status[]
ELSE enum_range(NULL::form_status)::form_status[] ELSE enum_range(NULL::form_status)::form_status[]
END::form_status[]) END::form_status[])
ORDER BY created_at DESC
` `
type ListFormsParams struct { type ListFormsParams struct {
@ -251,6 +274,7 @@ type ListFormsParams struct {
// WHEN cardinality($2::form_status[]) > 0 THEN $2::form_status[] // WHEN cardinality($2::form_status[]) > 0 THEN $2::form_status[]
// ELSE enum_range(NULL::form_status)::form_status[] // ELSE enum_range(NULL::form_status)::form_status[]
// END::form_status[]) // END::form_status[])
// ORDER BY created_at DESC
func (q *Queries) ListForms(ctx context.Context, arg ListFormsParams) ([]Form, error) { func (q *Queries) ListForms(ctx context.Context, arg ListFormsParams) ([]Form, error) {
rows, err := q.db.Query(ctx, listForms, arg.WorkspaceID, arg.Statuses) rows, err := q.db.Query(ctx, listForms, arg.WorkspaceID, arg.Statuses)
if err != nil { if err != nil {