feat(12.1-01): declared record actions with an applicability rule

- pact.HasAdminRecordActions with AdminRecordAction (Applies, Run)
- config_form.yaml recordActions, compiled fail-loud
- show response meta.actions lists the permitted actions that apply
- POST .../{controller}/{id}/actions/{action}: record loaded and locked
  through the form scope; 404 out of scope, 409 when it does not apply
- RecordActions.vue with confirm and request flow (mounted by plan 02)
- roster fixture, smoke tests, OpenAPI, TS types, READMEs, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:37:30 +02:00
parent a879d6388c
commit e0ccced76a
34 changed files with 1350 additions and 24 deletions

View File

@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
@@ -346,6 +347,7 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
if ctx == nil {
ctx = context.Background()
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
var result RecordResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
@@ -361,6 +363,10 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
return err
}
result, err = projectFullRecord(ctx, tx, cc, target)
if err != nil {
return err
}
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
return err
})
if err != nil {
@@ -369,6 +375,102 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
return result, nil
}
// offeredRecordActions lists the declared record actions the principal on ctx
// may run and that apply to the loaded record, in declared order, with
// localized label and confirm text (D-10). It is nil when none is offered.
// Applies runs with the read transaction on ctx; its error fails the read.
func (s CRUDService) offeredRecordActions(ctx context.Context, cc *CompiledController, record any) ([]RecordAction, error) {
if cc == nil || cc.Form == nil || len(cc.Form.recordActions) == 0 {
return nil, nil
}
principal, _ := bouncer.User(ctx)
var out []RecordAction
for _, name := range cc.Form.recordActions {
action, ok := cc.RecordActions[name]
if !ok || !Allows(principal, action.Permissions) {
continue
}
if action.Applies != nil {
applies, err := action.Applies(ctx, record)
if err != nil {
return nil, actionFailure(cc, "record action", name, err)
}
if !applies {
continue
}
}
out = append(out, RecordAction{
Name: name,
Label: translateKey(ctx, s.tr, action.Label),
Confirm: translateKey(ctx, s.tr, action.Confirm),
})
}
return out, nil
}
// RecordAction runs the declared record action name on one record in a
// transaction. The record is loaded through the controller's FormExtendQuery
// scope with a row lock: a missing or out-of-scope id is not found. The
// action's Applies is checked again inside the transaction, and an action
// that does not apply to the record's current state conflicts. A name the
// form does not declare, or the controller does not register, is not found.
func (s CRUDService) RecordAction(ctx context.Context, cc *CompiledController, id any, name string) (AdminActionResult, error) {
if s.DB == nil {
return AdminActionResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
action, ok := recordActionOf(cc, name)
if !ok {
return AdminActionResult{}, recordNotFound{}
}
if _, err := newWritableModel(cc); err != nil {
return AdminActionResult{}, err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
result := AdminActionResult{Fill: map[string]any{}}
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
return err
}
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
return err
}
if action.Applies != nil {
applies, err := action.Applies(ctx, target)
if err != nil {
return actionFailure(cc, "record action", name, err)
}
if !applies {
return actionConflict{}
}
}
out, err := action.Run(ctx, pact.AdminRecordActionInput{RecordID: uint64(pkUint(target)), Record: target})
if err != nil {
return actionFailure(cc, "record action", name, err)
}
result.Message = translateKey(ctx, s.tr, out.Message)
return nil
})
if err != nil {
return AdminActionResult{}, err
}
return result, nil
}
// actionConflict is a record action that does not apply to the record's
// current state (409).
type actionConflict struct{}
func (actionConflict) Error() string { return "cabana: action does not apply" }
// projectFullRecord is the D-18 record shape: scalar writable fields, relation
// values keyed by field name, and their labels.
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
@@ -513,6 +615,11 @@ func writeCRUDError(w http.ResponseWriter, err error) {
WriteError(w, http.StatusConflict, "conflict", "Conflict")
return
}
var stale actionConflict
if errors.As(err, &stale) {
WriteError(w, http.StatusConflict, "conflict", "Conflict")
return
}
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
}
@@ -598,6 +705,10 @@ func lifecycleFailure(cc *CompiledController, err error) error {
if errors.As(err, &partial) {
return err
}
var stale actionConflict
if errors.As(err, &stale) {
return err
}
var life *lifecycleError
if errors.As(err, &life) {
return err