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

@@ -46,8 +46,23 @@ func (rosterPerson) Rules() map[string]string { return map[string]string{"name":
// rosterSpy records what each registered action's Run receives.
type rosterSpy struct {
mu sync.Mutex
bulk []pact.AdminBulkActionInput
mu sync.Mutex
bulk []pact.AdminBulkActionInput
record []pact.AdminRecordActionInput
}
func (s *rosterSpy) recordOne(in pact.AdminRecordActionInput) {
s.mu.Lock()
defer s.mu.Unlock()
s.record = append(s.record, in)
}
func (s *rosterSpy) takeRecord() []pact.AdminRecordActionInput {
s.mu.Lock()
defer s.mu.Unlock()
out := s.record
s.record = nil
return out
}
func (s *rosterSpy) recordBulk(in pact.AdminBulkActionInput) {
@@ -64,7 +79,12 @@ func (s *rosterSpy) takeBulk() []pact.AdminBulkActionInput {
return out
}
type rosterPlugin struct{ spy *rosterSpy }
// rosterPlugin is the acme.roster fixture plugin. fsys, when set, replaces
// the fixture tree (boot-error tests).
type rosterPlugin struct {
spy *rosterSpy
fsys fs.FS
}
func (rosterPlugin) ID() string { return "acme.roster" }
func (rosterPlugin) Requires() []string { return nil }
@@ -76,7 +96,12 @@ func (p rosterPlugin) AdminControllers() []pact.AdminController {
func (rosterPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}}
}
func (rosterPlugin) AdminFS() fs.FS { return os.DirFS(rosterDir) }
func (p rosterPlugin) AdminFS() fs.FS {
if p.fsys != nil {
return p.fsys
}
return os.DirFS(rosterDir)
}
// LangFS serves only the fixture's lang/ tree.
func (rosterPlugin) LangFS() fs.FS {
@@ -153,6 +178,46 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
}}
}
// AdminRecordActions: activate needs acme.roster.manage and applies to a
// person who is not active; reinstate applies to a banned person and lifts
// the ban.
func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
return []pact.AdminRecordAction{{
Name: "activate", Label: "acme.roster::lang.people.activate",
Permissions: []string{"acme.roster.manage"},
Applies: func(_ context.Context, record any) (bool, error) {
return !record.(*rosterPerson).Active, nil
},
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
c.spy.recordOne(in)
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return pact.AdminRecordActionResult{}, fmt.Errorf("no transaction on the context")
}
if err := tx.Model(in.Record).Update("active", true).Error; err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: "acme.roster::lang.people.activated"}, nil
},
}, {
Name: "reinstate", Label: "acme.roster::lang.people.reinstate", Confirm: "acme.roster::lang.people.reinstate_confirm",
Applies: func(_ context.Context, record any) (bool, error) {
return record.(*rosterPerson).Banned, nil
},
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
c.spy.recordOne(in)
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return pact.AdminRecordActionResult{}, fmt.Errorf("no transaction on the context")
}
if err := tx.Model(in.Record).Update("banned", false).Error; err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{}, nil
},
}}
}
// rosterEnv is the assembled admin API over the roster fixture. The embedded
// actEnv supplies call and expect with the four auth modes: bearer (developer
// token), limited (acme.roster.access only), cookie and cookie-only.