Files
LocalAI/core/http/endpoints/studio/triggers.go
T
Ettore Di Giacinto d8bd3879f2 feat(studio): let a trigger proposal carry its acts, shown before the yes
A trigger run with nobody present only reads unless its trigger holds
act.* grants, and until now only the REST API could set them. An agent
that proposes a trigger can now ask for acts too:

- propose_trigger takes an optional acts list. The names must be
  act.* grants the agent of the chat holds; anything else is refused
  and nothing is stored. A read_only proposal cannot carry acts.
- The proposal stores the list. Confirming it, in Studio, by a plain
  yes or by confirm CODE, grants exactly that list. No tool changes
  the acts of a trigger afterwards. Two proposals that differ only in
  their acts are kept apart.
- The text of a proposal in a messaging app now has a Permissions
  line, in plain words with sensitive classes marked: read only, or
  what a run may do. The confirmation repeats it. A proposal that asks
  for acts is never set up without a yes, whatever the approval mode.
- The trigger REST object gains acts_held, read_only and
  read_only_why, computed with the same rules as the run, so a client
  can show why a run only reads.

Assisted-by: Claude:claude-sonnet-5-5 go ginkgo
2026-11-03 20:53:02 +00:00

549 lines
22 KiB
Go

package studio
import (
"context"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/schema"
studiosvc "github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/triggers"
)
func (h *Handlers) triggerHost(c echo.Context) (*studiosvc.Triggers, bool) {
return feature(c, h.svc.Triggers(), func(c echo.Context) error { return failTrigger(c, studiosvc.ErrTriggersOff) })
}
func utcPtr(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
u := t.UTC()
return &u
}
func runOut(host *studiosvc.Triggers, user string, r triggers.Run) schema.ChatsTriggerRun {
file := r.File
if file != "" {
file = host.RelPath(user, file)
}
return schema.ChatsTriggerRun{ID: r.ID, Status: string(r.Status), Reason: r.Reason, ChatID: r.ChatID, File: file,
Due: r.Due.UTC(), Started: utcPtr(r.Started), Ended: utcPtr(r.Ended), Tokens: r.Tokens}
}
// costRuns is how many recent runs the average cost of a trigger reads.
const costRuns = 10
func triggerOut(ctx context.Context, host *studiosvc.Triggers, t triggers.Trigger, withLast bool) schema.ChatsTrigger {
out := schema.ChatsTrigger{ID: t.ID, Name: t.Name, Prompt: t.Prompt, Kind: string(t.Kind), Schedule: t.Schedule, TimeZone: t.TZ,
Folder: host.RelFolder(t), QuietSeconds: int(t.Quiet / time.Second), DailyCap: t.CapOrDefault(), Enabled: t.Enabled,
AgentID: t.AgentID, Model: t.Model, SourceChatID: t.SourceChatID, Summary: host.Summary(t),
Plan: schema.ChatsTriggerPlan{Reads: t.Reads, Writes: t.Writes, Widget: t.Widget},
Cost: schema.ChatsTriggerCost{EstimatedTokens: t.EstTokens, MaxRunsPerDay: host.MaxRunsPerDay(t), Remote: host.RemoteModel(t.Model)},
Acts: t.Acts}
av := host.ActsView(ctx, t)
out.ActsHeld, out.ReadOnly, out.ReadOnlyWhy = av.Held, av.ReadOnly, av.Why
if t.Kind == triggers.KindFile {
out.MinGapSeconds = int(t.GapOrDefault() / time.Second)
}
if next, ok := host.NextRun(t); ok {
out.NextRun = utcPtr(next)
}
if withLast {
if runs, err := host.TriggerRuns(ctx, t.Owner, t.ID, costRuns); err == nil && len(runs) > 0 {
r := runOut(host, t.Owner, runs[0])
out.LastRun = &r
sum := 0
for _, run := range runs {
if run.Status == triggers.StatusDone && out.LastDone == nil {
out.LastDone = utcPtr(run.Ended)
}
if run.Status.Final() && run.Tokens > 0 {
sum += run.Tokens
out.Cost.Runs++
}
}
if out.Cost.Runs > 0 {
out.Cost.AverageTokens = sum / out.Cost.Runs
}
}
}
return out
}
func triggerIn(req schema.ChatsTriggerWrite) studiosvc.TriggerInput {
in := studiosvc.TriggerInput{Name: req.Name, Prompt: req.Prompt, Kind: triggers.Kind(req.Kind), Schedule: req.Schedule, TZ: req.TimeZone,
Folder: req.Folder, Quiet: time.Duration(req.QuietSeconds) * time.Second, DailyCap: req.DailyCap,
AgentID: req.AgentID, Model: req.Model, SourceChatID: req.SourceChatID, Enabled: req.Enabled}
in.MinGap, in.EstTokens = time.Duration(req.MinGapSeconds)*time.Second, req.EstimatedTokens
if req.Acts != nil {
in.Acts = req.Acts
}
if req.Plan != nil {
in.Reads, in.Writes, in.Widget = req.Plan.Reads, req.Plan.Writes, req.Plan.Widget
}
return in
}
var triggerRules = []errRule{
fixed(http.StatusNotFound, typeNotFound, "trigger not found", triggers.ErrNotFound),
verbatim(http.StatusBadRequest, typeInvalid, studiosvc.ErrBadTrigger.Error()+": ", studiosvc.ErrBadTrigger),
verbatim(http.StatusConflict, typeConflict, "", studiosvc.ErrTooManyTriggers, studiosvc.ErrTooManyProposals),
verbatim(http.StatusConflict, typeConflict, "studio: ", studiosvc.ErrTooManyChanges, studiosvc.ErrChangeStale),
sentinelText(http.StatusConflict, typeConflict, studiosvc.ErrCannotUndo),
verbatim(http.StatusConflict, typeConflict, "studio: ", studiosvc.ErrProposalAnswered, studiosvc.ErrProposalExpired),
verbatim(http.StatusConflict, typeTriggersOff, "", studiosvc.ErrTriggersOff),
fixed(http.StatusServiceUnavailable, typeServerError, "the server is shutting down", studiosvc.ErrTriggersClosed),
}
// failTrigger maps the errors of the trigger service.
func failTrigger(c echo.Context, err error) error { return mapErr(c, err, triggerRules, failFor) }
// ListTriggers lists the triggers.
// @Summary List your triggers.
// @Description A trigger starts a chat as you, by itself, on a schedule or when a file arrives in a folder of your files. Each run is an ordinary chat, marked with its trigger, that you can open and undo.
// @Tags chats
// @Success 200 {object} schema.ChatsTriggerList "Your triggers, sorted by name"
// @Router /api/chats/triggers [get]
func (h *Handlers) ListTriggers(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
list, err := host.ListTriggers(c.Request().Context(), userID(c))
if err != nil {
return failTrigger(c, err)
}
out := schema.ChatsTriggerList{Triggers: make([]schema.ChatsTrigger, 0, len(list))}
for _, t := range list {
out.Triggers = append(out.Triggers, triggerOut(c.Request().Context(), host, t, true))
}
return c.JSON(http.StatusOK, out)
}
// CreateTrigger creates a trigger.
// @Summary Create a trigger.
// @Description kind is schedule (schedule: "daily 08:00", "weekdays 09:30", "weekly mon,thu 09:30", "monthly 15 08:00", "once 2026-12-24 18:00" or five cron fields; time_zone: an IANA name) or file (folder: a path inside your files). It runs nothing for the time before it was saved. At most 50 triggers.
// @Tags chats
// @Param request body schema.ChatsTriggerWrite true "the trigger"
// @Success 201 {object} schema.ChatsTrigger "The trigger"
// @Failure 400 {object} schema.ErrorResponse "The request is not valid"
// @Router /api/chats/triggers [post]
func (h *Handlers) CreateTrigger(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
var req schema.ChatsTriggerWrite
if err := c.Bind(&req); err != nil {
return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON")
}
t, err := host.CreateTrigger(c.Request().Context(), userID(c), triggerIn(req))
if err != nil {
return failTrigger(c, err)
}
return c.JSON(http.StatusCreated, triggerOut(c.Request().Context(), host, t, false))
}
// GetTrigger reads a trigger.
// @Summary Read a trigger.
// @Tags chats
// @Param id path string true "the trigger's id"
// @Success 200 {object} schema.ChatsTrigger "The trigger"
// @Router /api/chats/triggers/{id} [get]
func (h *Handlers) GetTrigger(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
t, err := host.GetTrigger(c.Request().Context(), userID(c), c.Param("id"))
if err != nil {
return failTrigger(c, err)
}
return c.JSON(http.StatusOK, triggerOut(c.Request().Context(), host, t, true))
}
// UpdateTrigger replaces a trigger.
// @Summary Replace a trigger.
// @Description The body replaces every field. A change of when it runs, or switching it on, means it runs nothing for the time before the change.
// @Tags chats
// @Param id path string true "the trigger's id"
// @Param request body schema.ChatsTriggerWrite true "the trigger"
// @Success 200 {object} schema.ChatsTrigger "The trigger"
// @Failure 400 {object} schema.ErrorResponse "The request is not valid"
// @Router /api/chats/triggers/{id} [put]
func (h *Handlers) UpdateTrigger(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
var req schema.ChatsTriggerWrite
if err := c.Bind(&req); err != nil {
return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON")
}
t, err := host.UpdateTrigger(c.Request().Context(), userID(c), c.Param("id"), triggerIn(req))
if err != nil {
return failTrigger(c, err)
}
return c.JSON(http.StatusOK, triggerOut(c.Request().Context(), host, t, true))
}
// DeleteTrigger deletes a trigger.
// @Summary Delete a trigger.
// @Description Its queued runs are dropped and a running run is stopped. The chats it made stay.
// @Tags chats
// @Param id path string true "the trigger's id"
// @Success 204 "Deleted"
// @Router /api/chats/triggers/{id} [delete]
func (h *Handlers) DeleteTrigger(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
if err := host.DeleteTrigger(c.Request().Context(), userID(c), c.Param("id")); err != nil {
return failTrigger(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *Handlers) setEnabled(c echo.Context, on bool) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
t, err := host.SetTriggerEnabled(c.Request().Context(), userID(c), c.Param("id"), on)
if err != nil {
return failTrigger(c, err)
}
return c.JSON(http.StatusOK, triggerOut(c.Request().Context(), host, t, true))
}
// EnableTrigger switches a trigger on.
// @Summary Switch a trigger on.
// @Tags chats
// @Param id path string true "the trigger's id"
// @Success 200 {object} schema.ChatsTrigger "The trigger"
// @Router /api/chats/triggers/{id}/enable [post]
func (h *Handlers) EnableTrigger(c echo.Context) error { return h.setEnabled(c, true) }
// DisableTrigger switches a trigger off.
// @Summary Switch a trigger off.
// @Tags chats
// @Param id path string true "the trigger's id"
// @Success 200 {object} schema.ChatsTrigger "The trigger"
// @Router /api/chats/triggers/{id}/disable [post]
func (h *Handlers) DisableTrigger(c echo.Context) error { return h.setEnabled(c, false) }
// RunTrigger starts a run now.
// @Summary Run a trigger now.
// @Description Starts a run at once, whether the trigger is on or not. The run is skipped, with its reason, when the trigger has a run going. On a cluster the run may start a few seconds later.
// @Tags chats
// @Param id path string true "the trigger's id"
// @Success 202 {object} schema.ChatsTriggerRun "The run"
// @Router /api/chats/triggers/{id}/run [post]
func (h *Handlers) RunTrigger(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
run, err := host.RunTrigger(c.Request().Context(), userID(c), c.Param("id"))
if err != nil {
return failTrigger(c, err)
}
return c.JSON(http.StatusAccepted, runOut(host, userID(c), run))
}
// TriggerRuns lists the runs of a trigger.
// @Summary List the runs of a trigger.
// @Tags chats
// @Param id path string true "the trigger's id"
// @Param limit query int false "most runs to return (default 20, at most 200)"
// @Success 200 {object} schema.ChatsTriggerRunList "Newest first"
// @Router /api/chats/triggers/{id}/runs [get]
func (h *Handlers) TriggerRuns(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
runs, err := host.TriggerRuns(c.Request().Context(), userID(c), c.Param("id"), intParam(c, "limit", 20, 200))
if err != nil {
return failTrigger(c, err)
}
out := schema.ChatsTriggerRunList{Runs: make([]schema.ChatsTriggerRun, 0, len(runs))}
for _, r := range runs {
out.Runs = append(out.Runs, runOut(host, userID(c), r))
}
return c.JSON(http.StatusOK, out)
}
// PreviewTrigger shows the next times of a schedule.
// @Summary Preview a schedule.
// @Description Returns the next times a schedule text runs in a time zone, and the schedule in plain words. A bad text is a 400 with the reason.
// @Tags chats
// @Param when query string true "the schedule text"
// @Param tz query string false "an IANA time zone name (default UTC)"
// @Param n query int false "how many times (default 3, at most 10)"
// @Success 200 {object} schema.ChatsTriggerPreview "The next times"
// @Router /api/chats/triggers/preview [get]
func (h *Handlers) PreviewTrigger(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
times, err := host.PreviewTrigger(c.QueryParam("when"), c.QueryParam("tz"), intParam(c, "n", 3, 10))
if err != nil {
return failTrigger(c, err)
}
sch, _ := triggers.ParseSchedule(c.QueryParam("when"))
out := schema.ChatsTriggerPreview{Times: make([]time.Time, len(times)), Summary: sch.String()}
for i, t := range times {
out.Times[i] = t.UTC()
}
return c.JSON(http.StatusOK, out)
}
// ListTriggerProposals lists the proposals that wait for an answer.
// @Summary List trigger proposals.
// @Description A model in a chat can propose a trigger. A proposal is not active and runs nothing; confirm it to turn it into a trigger that is on. The chat shows it as a tool line with a card of kind trigger_proposal whose id is the proposal's id, and a trigger_proposed event announces it.
// @Tags chats
// @Param chat query string false "only the proposals made in this chat"
// @Success 200 {object} schema.ChatsTriggerList "The proposals"
// @Router /api/chats/triggers/proposals [get]
func (h *Handlers) ListTriggerProposals(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
list, err := host.Proposals(c.Request().Context(), userID(c))
if err != nil {
return failTrigger(c, err)
}
out := schema.ChatsTriggerList{Triggers: make([]schema.ChatsTrigger, 0, len(list))}
chat := c.QueryParam("chat")
for _, t := range list {
if chat != "" && t.SourceChatID != chat {
continue
}
out.Triggers = append(out.Triggers, triggerOut(c.Request().Context(), host, t, false))
}
return c.JSON(http.StatusOK, out)
}
// ConfirmTriggerProposal confirms a proposal.
// @Summary Confirm a trigger proposal.
// @Description Turns the proposal into a trigger that is on. time_zone is used when the proposal names none.
// @Tags chats
// @Param id path string true "the proposal's id"
// @Param request body schema.ChatsTriggerConfirm false "the time zone of the person confirming"
// @Success 200 {object} schema.ChatsTrigger "The trigger"
// @Router /api/chats/triggers/proposals/{id}/confirm [post]
func (h *Handlers) ConfirmTriggerProposal(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
var req schema.ChatsTriggerConfirm
_ = c.Bind(&req)
t, err := host.ConfirmProposal(c.Request().Context(), userID(c), c.Param("id"), req.TimeZone)
if err != nil {
return failTrigger(c, err)
}
return c.JSON(http.StatusOK, triggerOut(c.Request().Context(), host, t, true))
}
// EditTriggerProposal changes a proposal.
// @Summary Edit a trigger proposal.
// @Description The body replaces every field of the proposal, as for a trigger. The proposal stays a proposal: it is not active until you confirm it.
// @Tags chats
// @Param id path string true "the proposal's id"
// @Param request body schema.ChatsTriggerWrite true "the new fields"
// @Success 200 {object} schema.ChatsTrigger "The proposal"
// @Router /api/chats/triggers/proposals/{id} [put]
func (h *Handlers) EditTriggerProposal(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
var req schema.ChatsTriggerWrite
if err := c.Bind(&req); err != nil {
return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON")
}
t, err := host.EditProposal(c.Request().Context(), userID(c), c.Param("id"), triggerIn(req))
if err != nil {
return failTrigger(c, err)
}
return c.JSON(http.StatusOK, triggerOut(c.Request().Context(), host, t, true))
}
// DismissTriggerProposal declines a proposal.
// @Summary Dismiss a trigger proposal.
// @Description The proposal stays as a declined record (GET .../proposals/{id}) that .../reopen restores.
// @Tags chats
// @Param id path string true "the proposal's id"
// @Success 204 "Dismissed"
// @Router /api/chats/triggers/proposals/{id} [delete]
func (h *Handlers) DismissTriggerProposal(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
if err := host.DismissProposal(c.Request().Context(), userID(c), c.Param("id")); err != nil {
return failTrigger(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func proposalOut(ctx context.Context, host *studiosvc.Triggers, v studiosvc.ProposalView) schema.ChatsTriggerProposal {
return schema.ChatsTriggerProposal{State: string(v.State), Trigger: triggerOut(ctx, host, v.Trigger, false),
AnsweredAt: utcPtr(v.At), CanUndo: v.CanUndo, UndoBlocked: v.UndoBlocked}
}
// GetTriggerProposal reads a proposal and what became of it.
// @Summary Read a trigger proposal and its answer.
// @Description Answers the state of a proposal the chat made: pending, accepted (the trigger is the one that accepting made, with the same id), declined or undone. The answer is kept on the server, so the chat shows the same receipt after a reload and on every frontend. 404 once the trigger was removed on the Triggers page.
// @Tags chats
// @Param id path string true "the proposal's id"
// @Success 200 {object} schema.ChatsTriggerProposal "The proposal"
// @Router /api/chats/triggers/proposals/{id} [get]
func (h *Handlers) GetTriggerProposal(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
v, err := host.ProposalStatus(c.Request().Context(), userID(c), c.Param("id"))
if err != nil {
return failTrigger(c, err)
}
return c.JSON(http.StatusOK, proposalOut(c.Request().Context(), host, v))
}
// UndoTriggerProposal takes back an accepted proposal.
// @Summary Undo an accepted trigger proposal.
// @Description Switches the trigger that accepting made off and removes it from the Triggers page; the proposal stays as an undone record that can be reopened. 409 when the trigger already ran or was edited or switched since (the message says so), or when the proposal is not accepted.
// @Tags chats
// @Param id path string true "the proposal's id"
// @Success 200 {object} schema.ChatsTriggerProposal "The proposal, now undone"
// @Router /api/chats/triggers/proposals/{id}/undo [post]
func (h *Handlers) UndoTriggerProposal(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
if _, err := host.UndoProposal(c.Request().Context(), userID(c), c.Param("id")); err != nil {
return failTrigger(c, err)
}
return h.GetTriggerProposal(c)
}
// ReopenTriggerProposal makes a declined or undone proposal wait again.
// @Summary Restore a declined or undone trigger proposal.
// @Tags chats
// @Param id path string true "the proposal's id"
// @Success 200 {object} schema.ChatsTriggerProposal "The proposal, waiting again"
// @Router /api/chats/triggers/proposals/{id}/reopen [post]
func (h *Handlers) ReopenTriggerProposal(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
if _, err := host.ReopenProposal(c.Request().Context(), userID(c), c.Param("id")); err != nil {
return failTrigger(c, err)
}
return h.GetTriggerProposal(c)
}
func changeOut(ctx context.Context, host *studiosvc.Triggers, v studiosvc.ChangeView) schema.ChatsTriggerChange {
out := schema.ChatsTriggerChange{ID: v.ID, Op: v.Op, State: string(v.State), TriggerID: v.Target,
Before: triggerOut(ctx, host, v.Before, false), Fields: v.Fields, AnsweredAt: utcPtr(v.At),
Stale: v.Stale, CanUndo: v.CanUndo, UndoBlocked: v.UndoBlocked}
if out.Fields == nil {
out.Fields = []string{}
}
if v.HasAfter {
a := triggerOut(ctx, host, v.After, false)
out.After = &a
}
if v.LastRun != nil {
r := runOut(host, v.Before.Owner, *v.LastRun)
out.LastRun = &r
}
return out
}
// GetTriggerChange reads a change of a trigger and what became of it.
// @Summary Read a trigger change and its answer.
// @Description A model in a chat can ask to edit, pause, resume or delete one of your triggers (update_trigger, delete_trigger). An edit or a delete waits for you; a pause or a resume applies at once. The chat carries the change as a tool card of kind trigger_change whose id is the change's id. The answer is kept on the server, so the card says the same after a reload and on every frontend. 404 once the change is no longer kept (a week after its answer).
// @Tags chats
// @Param id path string true "the change's id"
// @Success 200 {object} schema.ChatsTriggerChange "The change"
// @Router /api/chats/triggers/changes/{id} [get]
func (h *Handlers) GetTriggerChange(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
v, err := host.ChangeStatus(c.Request().Context(), userID(c), c.Param("id"))
if err != nil {
return failTrigger(c, err)
}
return c.JSON(http.StatusOK, changeOut(c.Request().Context(), host, v))
}
// ApplyTriggerChange applies a pending change.
// @Summary Apply a trigger change.
// @Description Makes the edit or deletes the trigger. 409 when the trigger was changed or removed since the change was asked for, or when the change was answered already.
// @Tags chats
// @Param id path string true "the change's id"
// @Success 200 {object} schema.ChatsTriggerChange "The change, now applied"
// @Failure 409 {object} schema.ErrorResponse "The trigger changed since, or the change was answered"
// @Router /api/chats/triggers/changes/{id}/apply [post]
func (h *Handlers) ApplyTriggerChange(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
if _, err := host.ApplyChange(c.Request().Context(), userID(c), c.Param("id")); err != nil {
return failTrigger(c, err)
}
return h.GetTriggerChange(c)
}
// DismissTriggerChange declines a pending change.
// @Summary Dismiss a trigger change.
// @Description The trigger stays as it is. The answer is kept, so the card shows it.
// @Tags chats
// @Param id path string true "the change's id"
// @Success 204 "Dismissed"
// @Router /api/chats/triggers/changes/{id} [delete]
func (h *Handlers) DismissTriggerChange(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
if err := host.DismissChange(c.Request().Context(), userID(c), c.Param("id")); err != nil {
return failTrigger(c, err)
}
return c.NoContent(http.StatusNoContent)
}
// UndoTriggerChange takes back an applied change.
// @Summary Undo a trigger change.
// @Description Puts the trigger back as it was before the change, for a day after it applied: an edit, a pause or a resume is reverted, and a deleted trigger is restored with its id (its run history is not). 409 when the time is over, the trigger was changed since (the message says so), or you have as many triggers as you may.
// @Tags chats
// @Param id path string true "the change's id"
// @Success 200 {object} schema.ChatsTriggerChange "The change, now undone"
// @Failure 409 {object} schema.ErrorResponse "The change cannot be undone"
// @Router /api/chats/triggers/changes/{id}/undo [post]
func (h *Handlers) UndoTriggerChange(c echo.Context) error {
host, ok := h.triggerHost(c)
if !ok {
return nil
}
if _, err := host.UndoChange(c.Request().Context(), userID(c), c.Param("id")); err != nil {
return failTrigger(c, err)
}
return h.GetTriggerChange(c)
}