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

16
frm.go
View file

@ -6,12 +6,14 @@ import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/acaloiaro/frm/internal"
)
const (
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
)
@ -23,6 +25,7 @@ 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
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
Receiver FormSubmissionReceiver // function that processes incoming form submissions
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
CollectorMountPoint string // path on the router to mount frm's collector
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
PostgresSchema string // postgres schema where frm stores data
PostgresURL string // postgres database URL
@ -69,6 +73,7 @@ func New(args Args) (f *Frm, err error) {
BuilderMountPoint: strings.TrimSuffix(args.BuilderMountPoint, "/"),
CollectorMountPoint: strings.TrimSuffix(args.CollectorMountPoint, "/"),
CollectorFooter: args.CollectorFooter,
DraftMaxAge: args.DraftMaxAge,
DBArgs: internal.DBArgs{
URL: args.PostgresURL,
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
func (f *Frm) Init(ctx context.Context) (err error) {
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
}
@ -130,6 +145,7 @@ func (f *Frm) CopyForm(ctx context.Context, args CopyFormArgs) (form Form, err e
FormID: &of.ID,
Name: copiedFormName,
Fields: of.Fields,
Status: FormStatusDraft,
}
if args.ForgetParentForm {
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)
}
@ -696,13 +702,33 @@ func PublishDraft(w http.ResponseWriter, r *http.Request) {
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 {
slog.Error("unable to publish draft", "error", err)
w.WriteHeader(http.StatusInternalServerError)
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)
}

View file

@ -8,6 +8,7 @@ import (
"log/slog"
"math/rand"
"strings"
"time"
"github.com/acaloiaro/frm/db/migrations"
"github.com/golang-migrate/migrate/v4"
@ -115,6 +116,20 @@ func getPool(ctx context.Context, args DBArgs) (p *pgxpool.Pool, err error) {
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
func Q(ctx context.Context, args DBArgs) *Queries {
p, err := getPool(ctx, args)
@ -144,6 +159,36 @@ func InitializeDB(ctx context.Context, args DBArgs) (err error) {
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
func createIfNotExist(ctx context.Context, args DBArgs) (err error) {
var cfg *pgx.ConnConfig

View file

@ -9,8 +9,28 @@ import (
"context"
"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
DELETE
@ -183,6 +203,7 @@ FROM forms
WHERE workspace_id = $1
AND form_id = $2
AND status = 'draft'
ORDER BY created_at DESC
`
type ListDraftsParams struct {
@ -197,6 +218,7 @@ type ListDraftsParams struct {
// WHERE workspace_id = $1
// AND form_id = $2
// AND status = 'draft'
// ORDER BY created_at DESC
func (q *Queries) ListDrafts(ctx context.Context, arg ListDraftsParams) ([]Form, error) {
rows, err := q.db.Query(ctx, listDrafts, arg.WorkspaceID, arg.FormID)
if err != nil {
@ -235,6 +257,7 @@ WHERE workspace_id = $1
WHEN cardinality($2::form_status[]) > 0 THEN $2::form_status[]
ELSE enum_range(NULL::form_status)::form_status[]
END::form_status[])
ORDER BY created_at DESC
`
type ListFormsParams struct {
@ -251,6 +274,7 @@ type ListFormsParams struct {
// WHEN cardinality($2::form_status[]) > 0 THEN $2::form_status[]
// ELSE enum_range(NULL::form_status)::form_status[]
// END::form_status[])
// ORDER BY created_at DESC
func (q *Queries) ListForms(ctx context.Context, arg ListFormsParams) ([]Form, error) {
rows, err := q.db.Query(ctx, listForms, arg.WorkspaceID, arg.Statuses)
if err != nil {