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()))})
}
}