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