diff --git a/db/queries.sql b/db/queries.sql index ff8a3eb..985ce58 100644 --- a/db/queries.sql +++ b/db/queries.sql @@ -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 diff --git a/frm.go b/frm.go index 8ed6252..2574da4 100644 --- a/frm.go +++ b/frm.go @@ -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 diff --git a/handlers/builder.go b/handlers/builder.go index dfef1f9..cc5e7f7 100644 --- a/handlers/builder.go +++ b/handlers/builder.go @@ -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,36 @@ 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 + } + defer func() { + _ = tx.Rollback(ctx) + }() + + 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) } diff --git a/internal/default.go b/internal/default.go index eb85694..125cd77 100644 --- a/internal/default.go +++ b/internal/default.go @@ -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 diff --git a/internal/queries.sql.go b/internal/queries.sql.go index f83cf40..db4c26d 100644 --- a/internal/queries.sql.go +++ b/internal/queries.sql.go @@ -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 {