fix: write API posts in one transaction and fire RecordSaved after its commit

POST and PUT /_journal/api/v1/posts saved the row, then the categories, tags
and translations, and fired RecordSaved only when all succeeded. A failure
part-way left a committed row (possibly a new slug) with no event, so
subscribers such as an SEO plugin never created the redirect. The write now
runs in one lagoon transaction with the event emitted inside it.
This commit is contained in:
2026-10-27 15:35:07 +01:00
parent f5a0394d69
commit 62c75b1fc2
4 changed files with 55 additions and 24 deletions

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"html"
"net/http"
@@ -220,20 +221,26 @@ func Store(app *backpack.App) http.HandlerFunc {
writeAPIError(w, http.StatusForbidden, errPublishPermission)
return
}
if err := db.WithContext(r.Context()).Create(post).Error; err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
// D-92/D-102: the row, its associations and translations commit
// together, and RecordSaved fires only once they have.
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Create(post).Error; err != nil {
return err
}
if err := applyPostAssocs(ctx, tx, post, body); err != nil {
return err
}
if err := loadPostAssocs(tx, post); err != nil {
return err
}
cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, nil, post, body))
return nil
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
purgeSiteCache(r, app, post)
cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), nil, post, body))
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
}
}
@@ -278,20 +285,26 @@ func Update(app *backpack.App) http.HandlerFunc {
writeAPIError(w, http.StatusForbidden, errPublishPermission)
return
}
if err := db.WithContext(r.Context()).Save(post).Error; err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
// D-92/D-102: a slug change commits with its translations and
// associations, or not at all, and RecordSaved follows the commit.
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Save(post).Error; err != nil {
return err
}
if err := applyPostAssocs(ctx, tx, post, body); err != nil {
return err
}
if err := loadPostAssocs(tx, post); err != nil {
return err
}
cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, before, post, body))
return nil
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
purgeSiteCache(r, app, post)
cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), before, post, body))
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
}
}

View File

@@ -66,8 +66,8 @@ func snapshotPost(ctx context.Context, db *gorm.DB, post *models.Post, body map[
}
// postSavedEvent is the RecordSaved of an API write, which the handler
// fires with cabana.EmitRecordSaved once the write is committed (the API
// writes run outside a transaction, so at once). before is nil on create:
// fires with cabana.EmitRecordSaved inside the write's transaction, so it
// reaches subscribers once the write commits. before is nil on create:
// every column with a value and every submitted translation is then
// reported with a nil Old. Controller is "": the write did not go through an
// admin form.