mirror of
https://github.com/acaloiaro/frm
synced 2026-07-21 10:12:23 +00:00
feat: periodic draft cleanup
This commit is contained in:
parent
1ac064b933
commit
170e9bc31c
5 changed files with 127 additions and 4 deletions
|
|
@ -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
16
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue