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

@@ -171,6 +171,72 @@ func bulkActionOf(cc *CompiledController, name string) (pact.AdminBulkAction, bo
return action, ok
}
// recordAction serves POST .../{controller}/{id}/actions/{action} (D-10): a
// registered record action the form's recordActions declares. The record is
// loaded through the controller's form scope with a row lock inside the
// action's transaction, so a missing or out-of-scope id is one 404, and an
// action that no longer applies to the record is a 409.
func (s *service) recordAction(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
name := r.PathValue("action")
action, ok := recordActionOf(cc, name)
if !ok {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !s.allowAction(w, r, action.Permissions) {
return
}
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
in, err := decodeActionRequest(r)
if err != nil {
writeCRUDError(w, err)
return
}
if in.RecordID != nil || in.Values != nil {
writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A record action takes no record_id or values."}}})
return
}
svc, err := s.crud()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := svc.RecordAction(r.Context(), cc, id, name)
if err != nil {
writeCRUDError(w, err)
return
}
var adminID uint
if principal, _ := bouncer.User(r.Context()); principal != nil {
adminID = principal.ID
}
slog.Info("cabana: admin record action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "record_id", id)
WriteData(w, http.StatusOK, result, nil)
})
}
// recordActionOf returns the registered record action a form declares under
// name in recordActions.
func recordActionOf(cc *CompiledController, name string) (pact.AdminRecordAction, bool) {
if cc == nil || cc.Form == nil || builtinToolbarActions[name] {
return pact.AdminRecordAction{}, false
}
declared := false
for _, entry := range cc.Form.recordActions {
declared = declared || entry == name
}
if !declared {
return pact.AdminRecordAction{}, false
}
action, ok := cc.RecordActions[name]
return action, ok
}
// allowAction applies an action's own permissions on top of the controller's
// (already checked by protect). Every action kind shares it: a denial is
// logged and answered 403.