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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user