mirror of
https://github.com/acaloiaro/frm
synced 2026-07-21 18:29:12 +00:00
feat: randomize question options
This commit is contained in:
parent
1ac064b933
commit
fe99ee9275
5 changed files with 124 additions and 4 deletions
|
|
@ -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
16
frm.go
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue