- hooks and bulk, record, toolbar and widget actions may return it - 403 forbidden with the localized message and field details; the write's transaction is rolled back; other errors stay the opaque 500 - form shows a refused save as a persistent banner and keeps the values; a refused delete is a toast - smoke tests, OpenAPI notes, dist, README, docs
433 lines
22 KiB
Markdown
433 lines
22 KiB
Markdown
---
|
|
title: Admin controllers
|
|
description: Declare admin controllers with pact.AdminController and WinterCMS-shaped YAML, and let the generic JSON admin API list, show, create, update and delete records.
|
|
section: backend
|
|
order: 10
|
|
---
|
|
# Admin controllers
|
|
|
|
A WinterCMS backend controller extends `Backend\Classes\Controller`, implements the List, Form and Relation behaviours, and describes its screens in `config_list.yaml`, `config_form.yaml` and the model's `columns.yaml` and `fields.yaml`. SummerCMS keeps the YAML, and drops the controller class: [cabana](../../modules/cabana/README.md) compiles the YAML at boot and serves one generic JSON admin API for every controller, and the admin SPA renders the screens from the compiled schemas.
|
|
|
|
## Declaring a controller
|
|
|
|
A controller is a small Go type that implements `pact.AdminController`: its ID, the model name that `modelClass` in the YAML must match, and the directory that holds its YAML. It usually also implements `pact.AdminRecordSource`, which returns the GORM model to query, and `pact.AdminPermissioned`, the permissions an administrator needs. The plugin returns its controllers from `pact.HasAdminControllers` and its embedded YAML tree from `pact.AdminAssets`:
|
|
|
|
```go src=modules/cabana/example_controller_test.go
|
|
package cabana_test
|
|
|
|
import (
|
|
"io/fs"
|
|
"os"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
)
|
|
|
|
// Post is the model behind the acme.blog posts controller.
|
|
type Post struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Title string `gorm:"column:title"`
|
|
Slug string `gorm:"column:slug"`
|
|
Status string `gorm:"column:status"`
|
|
Published bool `gorm:"column:published"`
|
|
PublishedAt *time.Time `gorm:"column:published_at"`
|
|
Content string `gorm:"column:content"`
|
|
}
|
|
|
|
func (Post) TableName() string { return "acme_blog_posts" }
|
|
|
|
// Fillable lists the columns the admin form may write.
|
|
func (Post) Fillable() []string {
|
|
return []string{"title", "slug", "status", "published", "content"}
|
|
}
|
|
|
|
// PostsController is the admin controller for posts: the Go form of a
|
|
// WinterCMS controller with the List and Form behaviours.
|
|
type PostsController struct{}
|
|
|
|
var (
|
|
_ pact.AdminController = PostsController{}
|
|
_ pact.AdminRecordSource = PostsController{}
|
|
_ pact.AdminPermissioned = PostsController{}
|
|
_ pact.HasAdminControllers = (*BlogPlugin)(nil)
|
|
)
|
|
|
|
// ID is the controller ID; its admin API path is /acme/blog/posts.
|
|
func (PostsController) ID() string { return "acme.blog.posts" }
|
|
|
|
// ModelName must equal modelClass in the YAML.
|
|
func (PostsController) ModelName() string { return "Post" }
|
|
|
|
// ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml.
|
|
func (PostsController) ConfigDir() string { return "controllers/posts" }
|
|
|
|
// NewRecord returns the model the generic admin handlers query.
|
|
func (PostsController) NewRecord() any { return &Post{} }
|
|
|
|
// RequiredPermissions are checked before any schema or query.
|
|
func (PostsController) RequiredPermissions() []string {
|
|
return []string{"acme.blog.access_posts"}
|
|
}
|
|
|
|
// BlogSettings is the singleton row behind the plugin's settings page.
|
|
type BlogSettings struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
PostsPerPage int `gorm:"column:posts_per_page"`
|
|
CommentsEnabled bool `gorm:"column:comments_enabled"`
|
|
}
|
|
|
|
func (BlogSettings) TableName() string { return "acme_blog_settings" }
|
|
|
|
func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} }
|
|
|
|
// Rules validates a settings save, as a WinterCMS settings model's $rules.
|
|
func (BlogSettings) Rules() map[string]string {
|
|
return map[string]string{"posts_per_page": "required|integer|between:1,100"}
|
|
}
|
|
|
|
// BlogPlugin is the acme.blog plugin; only its backend surface is shown.
|
|
type BlogPlugin struct{}
|
|
|
|
var (
|
|
_ pact.AdminAssets = (*BlogPlugin)(nil)
|
|
_ pact.HasPermissions = (*BlogPlugin)(nil)
|
|
_ pact.HasNavigation = (*BlogPlugin)(nil)
|
|
_ pact.HasSettings = (*BlogPlugin)(nil)
|
|
)
|
|
|
|
func (p *BlogPlugin) ID() string { return "acme.blog" }
|
|
func (p *BlogPlugin) Requires() []string { return nil }
|
|
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
|
|
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
|
|
|
|
// AdminControllers registers the plugin's admin controllers.
|
|
func (p *BlogPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{PostsController{}}
|
|
}
|
|
|
|
// AdminFS is the plugin's embedded controllers/ and models/ tree. A real
|
|
// plugin returns an embed.FS; the example reads the same files from testdata.
|
|
func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") }
|
|
|
|
// Permissions replaces registerPermissions().
|
|
func (p *BlogPlugin) Permissions() []pact.Permission {
|
|
return []pact.Permission{
|
|
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
|
|
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
|
|
}
|
|
}
|
|
|
|
// Navigation replaces registerNavigation().
|
|
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
|
|
return []pact.NavigationItem{{
|
|
Code: "blog",
|
|
Label: "acme.blog::lang.plugin.name",
|
|
Icon: "icon-pencil",
|
|
Permissions: []string{"acme.blog.access_posts"},
|
|
Controller: "acme.blog.posts",
|
|
SideMenu: []pact.NavigationItem{
|
|
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
|
|
},
|
|
}}
|
|
}
|
|
|
|
// Settings replaces registerSettings().
|
|
func (p *BlogPlugin) Settings() []pact.SettingsItem {
|
|
return []pact.SettingsItem{{
|
|
Code: "blog",
|
|
Label: "acme.blog::lang.settings.label",
|
|
Description: "acme.blog::lang.settings.description",
|
|
Category: "acme.blog::lang.plugin.name",
|
|
Icon: "icon-pencil",
|
|
Model: "BlogSettings",
|
|
Permissions: []string{"acme.blog.access_settings"},
|
|
Form: "models/settings/fields.yaml",
|
|
NewModel: func() any { return &BlogSettings{} },
|
|
}}
|
|
}
|
|
```
|
|
|
|
`summer make:admin-controller` writes the controller type and its four YAML files:
|
|
|
|
```sh
|
|
summer make:admin-controller acme.blog Posts
|
|
```
|
|
|
|
The generated controller requires the permission `acme.blog.access_posts`, so declare it in the plugin's `pact.HasPermissions` or the admin API refuses to start with an unknown-permission error. Its `NewRecord` returns `nil` until you return the model, and the list and every write answer 500 until then. The controller ID maps to the admin API path: `acme.blog.posts` is served under `<prefix>/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md). The admin finds a model's columns through its struct fields, including those of an embedded struct such as `gorm.Model`. A field with a `gorm:"column:..."` tag is known by that column alone; an untagged field is known by GORM's default column name.
|
|
|
|
## Compilation at boot
|
|
|
|
At start-up `cabana.Activate` compiles every plugin's controllers, settings, navigation and permissions once. Any schema mistake stops the start-up with an error that names the plugin, the controller and the file: an unknown YAML key, a `modelClass` that does not match `pact.AdminController.ModelName`, a list column the model does not have, an unknown field type. Nothing is parsed per request. The example below activates the admin for the plugin above:
|
|
|
|
```go src=modules/cabana/example_test.go#ExampleActivate
|
|
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
// Set through SUMMER_ADMIN__JWT__SECRET in a real deployment.
|
|
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
|
|
_ = cfg.Set("backend.uri", "/admin")
|
|
|
|
routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
fmt.Println(routes.Prefix)
|
|
|
|
editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}}
|
|
fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions()))
|
|
fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"}))
|
|
// Output:
|
|
// /admin
|
|
// true
|
|
// false
|
|
```
|
|
|
|
`admin.jwt.secret` is required as soon as any plugin registers an admin controller. With no admin controllers, no admin routes exist and no secret is needed.
|
|
|
|
## The admin API
|
|
|
|
Every controller gets the same routes, relative to `<prefix>/api/v1/{vendor}/{plugin}/{controller}`:
|
|
|
|
| Method and path | Does |
|
|
|-----------------|------|
|
|
| `GET /schema/list`, `GET /schema/form` | The list and form schemas, translated into the request locale. |
|
|
| `GET /` | Lists records with search, sort, filters and pagination, allowed only on columns the schema declares. |
|
|
| `POST /` | Creates a record. |
|
|
| `GET /{id}`, `PUT /{id}`, `DELETE /{id}` | Shows, updates and deletes a record. |
|
|
| `POST /bulk-delete` | Deletes a set of records in one transaction. |
|
|
|
|
The cabana README lists the full route table, including relation, options, widget, toolbar and partial routes. Every response uses one JSON envelope (`cabana.Envelope`), and a validation failure is a 422 `validation_failed` error with messages per field.
|
|
|
|
Request parameters never reach SQL directly: search, sort and filters apply only to declared columns, and a write passes only the form's writable fields, filled through `lagoon.Fill` and validated through `lagoon.Validate` in a transaction.
|
|
|
|
## Hooks
|
|
|
|
Behaviour overrides such as `formBeforeCreate` or `listExtendQuery` become optional interfaces on the controller. cabana checks for each one and calls it at the matching point:
|
|
|
|
| Interface | Runs |
|
|
|-----------|------|
|
|
| `pact.ListExtendQuery` | Scopes every list query, for example to the administrator's own records. |
|
|
| `pact.FormExtendQuery` | Scopes every show, update and delete lookup, so a record outside the scope is a 404. |
|
|
| `pact.FormBeforeCreate`, `pact.FormAfterCreate` | Around a create, inside its transaction. |
|
|
| `pact.FormBeforeUpdate`, `pact.FormAfterUpdate` | Around an update, inside its transaction. |
|
|
| `pact.FormBeforeDelete`, `pact.FormAfterDelete` | Around a delete, inside its transaction. |
|
|
| `pact.DropdownOptionsProvider` | Supplies the options of a `dropdown` field that names a method. |
|
|
|
|
A hook or scope that has to read the database during a write should use the transaction the write runs in, `cabana.TxFromContext(ctx)`, rather than the application pool: it then sees the write's own snapshot and takes no second connection while the transaction holds row locks. The handle is valid only until the hook returns, and the list route, which runs no transaction, reports none.
|
|
|
|
Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes.
|
|
|
|
## Refusing a write
|
|
|
|
A hook or an action stops a write by returning an error. Which error decides what the administrator sees:
|
|
|
|
| Error | Answer |
|
|
|-------|--------|
|
|
| `cabana.ValidationError` | 422 `validation_failed`, with its `Details` as messages per field. |
|
|
| `cabana.ForbiddenError` | 403 `forbidden`, with its `Message` and its `Details`. |
|
|
| any other error | The opaque 500. The error is logged on the server and its text never reaches the client. |
|
|
|
|
Return a `cabana.ForbiddenError` when the signed-in administrator may open the screen but may not make this particular change, for example editing a record that needs a higher permission. `Message` is a translation key or text; cabana translates it, and every `Details` message, in the request locale. `Details` maps a field name to a list of messages, as a `cabana.ValidationError` does, and may be left out. With an empty `Message` the admin shows its own text.
|
|
|
|
Every one of these errors rolls the write's transaction back, so a refused write changes nothing: a bulk action that refuses on its third record leaves the first two untouched. The admin SPA shows a refused save as a banner above the form and keeps what the administrator typed; a refused delete or action is a toast. A `cabana.ForbiddenError` works the same from the form hooks, the relation hooks, and bulk, record, toolbar and widget actions.
|
|
|
|
## Toolbar actions
|
|
|
|
`toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` and any action the controller registers through `pact.HasAdminActions`. The declarations are enforced by the server, not only shown by the SPA. `POST /{controller}` needs a `config_form.yaml` and `create` in `toolbar.buttons`; `PUT` and `DELETE /{controller}/{id}` need a form (the form screen carries the delete button, as in WinterCMS); `POST /{controller}/bulk-delete` needs `delete` in `toolbar.buttons`, which in turn needs `showCheckboxes: true`. A write the controller does not declare answers 403 `forbidden`. See [Partials and widgets](partials-and-widgets.md) for actions and the rest of the extension points.
|
|
|
|
## Bulk actions
|
|
|
|
A bulk action runs on the rows an administrator selected in the list, as the `onBulkAction` handlers of a WinterCMS list toolbar do. The controller registers its bulk actions through `pact.HasAdminBulkActions`, and `bulkActions` in `config_list.yaml` lists the ones the list offers, in menu order:
|
|
|
|
```go src=modules/cabana/example_actions_test.go
|
|
package cabana_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
)
|
|
|
|
// Person is the model behind the acme.roster people controller.
|
|
type Person struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Name string `gorm:"column:name"`
|
|
Email string `gorm:"column:email"`
|
|
Active bool `gorm:"column:active"`
|
|
Banned bool `gorm:"column:banned"`
|
|
}
|
|
|
|
func (Person) TableName() string { return "acme_roster_people" }
|
|
|
|
// Fillable lists the columns the admin form may write.
|
|
func (Person) Fillable() []string { return []string{"name", "email"} }
|
|
|
|
// PeopleController is an admin controller with bulk and record actions.
|
|
type PeopleController struct{}
|
|
|
|
var (
|
|
_ pact.AdminController = PeopleController{}
|
|
_ pact.AdminRecordSource = PeopleController{}
|
|
_ pact.HasAdminBulkActions = PeopleController{}
|
|
_ pact.HasAdminRecordActions = PeopleController{}
|
|
)
|
|
|
|
func (PeopleController) ID() string { return "acme.roster.people" }
|
|
func (PeopleController) ModelName() string { return "Person" }
|
|
func (PeopleController) ConfigDir() string { return "controllers/people" }
|
|
func (PeopleController) NewRecord() any { return &Person{} }
|
|
|
|
func (PeopleController) RequiredPermissions() []string {
|
|
return []string{"acme.roster.access"}
|
|
}
|
|
|
|
// AdminBulkActions registers the actions config_list.yaml offers under
|
|
// bulkActions. Run receives the selected records, already loaded and locked
|
|
// through the list scope, and writes through the request's transaction.
|
|
func (PeopleController) AdminBulkActions() []pact.AdminBulkAction {
|
|
return []pact.AdminBulkAction{{
|
|
Name: "activate",
|
|
Label: "acme.roster::lang.people.activate",
|
|
Confirm: "acme.roster::lang.people.activate_confirm",
|
|
Permissions: []string{"acme.roster.manage"},
|
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return pact.AdminBulkActionResult{}, errors.New("no transaction")
|
|
}
|
|
changed := 0
|
|
for _, record := range in.Records {
|
|
person := record.(*Person)
|
|
if person.Active {
|
|
continue
|
|
}
|
|
if err := tx.Model(person).Update("active", true).Error; err != nil {
|
|
return pact.AdminBulkActionResult{}, err
|
|
}
|
|
changed++
|
|
}
|
|
return pact.AdminBulkActionResult{Affected: changed}, nil
|
|
},
|
|
}, {
|
|
Name: "archive",
|
|
Label: "acme.roster::lang.people.archive",
|
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return pact.AdminBulkActionResult{}, errors.New("no transaction")
|
|
}
|
|
for _, record := range in.Records {
|
|
if err := tx.Delete(record).Error; err != nil {
|
|
return pact.AdminBulkActionResult{}, err
|
|
}
|
|
}
|
|
return pact.AdminBulkActionResult{
|
|
Message: "acme.roster::lang.people.archived",
|
|
Affected: len(in.Records),
|
|
}, nil
|
|
},
|
|
}}
|
|
}
|
|
|
|
// AdminRecordActions registers the actions config_form.yaml offers under
|
|
// recordActions. Applies decides whether an action fits the record's current
|
|
// state; Run receives the record loaded and locked through the form scope.
|
|
func (PeopleController) 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.(*Person).Active, nil
|
|
},
|
|
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return pact.AdminRecordActionResult{}, errors.New("no transaction")
|
|
}
|
|
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.(*Person).Banned, nil
|
|
},
|
|
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return pact.AdminRecordActionResult{}, errors.New("no transaction")
|
|
}
|
|
if err := tx.Model(in.Record).Update("banned", false).Error; err != nil {
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
return pact.AdminRecordActionResult{}, nil
|
|
},
|
|
}}
|
|
}
|
|
```
|
|
|
|
```yaml src=modules/cabana/testdata/roster/controllers/people/config_list.yaml
|
|
list: ~/plugins/acme/roster/models/person/columns.yaml
|
|
modelClass: Person
|
|
title: acme.roster::lang.people.title
|
|
recordUrl: acme/roster/people/update/:id
|
|
recordsPerPage: 20
|
|
showCheckboxes: true
|
|
toolbar:
|
|
buttons: [create, delete]
|
|
search:
|
|
prompt: backend::lang.list.search_prompt
|
|
bulkActions: [activate, archive]
|
|
messages:
|
|
create: acme.roster::lang.people.create
|
|
rowStateDisabled: acme.roster::lang.people.state_inactive
|
|
```
|
|
|
|
The admin SPA shows the declared actions in a "Bulk actions" menu next to the selection, asks for confirmation (the action's `Confirm` text, or a default one), and posts the selected ids to `POST /{controller}/bulk/{action}`. cabana owns that route:
|
|
|
|
- The ids are resolved and row-locked through the controller's `pact.ListExtendQuery` scope inside one transaction. `Run` receives the loaded records in `pact.AdminBulkActionInput`, never the ids, so an id outside the scope cannot reach plugin code.
|
|
- A selection that matches no row in the scope answers `affected: 0` without calling `Run`. A selection of which only a part is in the scope answers 409 `conflict` and changes nothing.
|
|
- `Run` writes through `cabana.TxFromContext(ctx)`. Any error rolls every row back; a `cabana.ValidationError` is a 422 and any other error the opaque 500.
|
|
- The action's `Permissions` are checked on top of the controller's. An administrator who may not run an action does not get it in the list schema, and posting it answers 403.
|
|
- `pact.AdminBulkActionResult` carries an optional `Message` (a phrase key or text) and `Affected`, the number of records the action changed. The answer is a `cabana.BulkActionResult`.
|
|
|
|
Bulk actions have their own namespace next to the toolbar and widget actions: a name is unique among the controller's bulk actions, and `create` and `delete` stay reserved. `bulkActions` needs `showCheckboxes: true`. A name the controller does not register, a duplicate, or an action without a label stops the start-up. The built-in bulk delete is not a declared action and keeps its own route and toolbar button.
|
|
|
|
## Record actions
|
|
|
|
A record action runs on one record, as a button on its screen: activating an account, lifting a ban. The controller registers its record actions through `pact.HasAdminRecordActions` (`PeopleController` above registers two), and `recordActions` in `config_form.yaml` lists the ones the form offers, in display order:
|
|
|
|
```yaml src=modules/cabana/testdata/roster/controllers/people/config_form.yaml
|
|
name: acme.roster::lang.people.form
|
|
form: ~/plugins/acme/roster/models/person/fields.yaml
|
|
modelClass: Person
|
|
defaultRedirect: acme/roster/people
|
|
create:
|
|
redirect: acme/roster/people/update/:id
|
|
redirectClose: acme/roster/people
|
|
update:
|
|
redirect: acme/roster/people
|
|
redirectClose: acme/roster/people
|
|
recordActions: [activate, reinstate]
|
|
```
|
|
|
|
Each `pact.AdminRecordAction` has a `Name`, a `Label`, an optional `Confirm` text, its own `Permissions`, an optional `Applies` function and `Run`:
|
|
|
|
- `Applies` reports whether the action fits the record's current state; without one the action always applies. It must only read, because it runs in two places: when a record is shown, to decide which actions to offer, and again inside the action's transaction, right before `Run`.
|
|
- The show response (`GET /{controller}/{id}`) lists the offered actions in `meta.actions` as `cabana.RecordAction` entries: only those the administrator may run and that apply to the record. The key is absent when none is offered, and create and update responses never carry it.
|
|
- `POST /{controller}/{id}/actions/{action}` takes an empty `{}` body. cabana loads the record through `pact.FormExtendQuery` with a row lock in one transaction and hands it to `Run` in `pact.AdminRecordActionInput`. A missing record and a record outside the scope are the same 404; an action whose `Applies` reports false answers 409 `conflict`; an administrator without the action's permissions gets 403.
|
|
- `Run` writes through `cabana.TxFromContext(ctx)` and returns a `pact.AdminRecordActionResult` with an optional `Message`. Any error rolls the transaction back.
|
|
|
|
Record actions have their own namespace: a record action and a bulk action may share a name, such as `activate` here. `create` and `delete` are reserved. A name in `recordActions` that the controller does not register, a duplicate, or an action without a label stops the start-up.
|