package api import ( "context" "errors" "net/http" "git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/sm-seo-plugin/models" "gorm.io/gorm" ) // ListHistoryInput is the query of GET /_seo/api/v1/history. type ListHistoryInput struct { Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"` PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"` Search string `json:"search,omitempty" query:"search" jsonschema:"text the description contains"` Via string `json:"via,omitempty" query:"via" jsonschema:"admin, mcp, import, auto or undo"` Kind string `json:"kind,omitempty" query:"kind" jsonschema:"entry, rule, redirect, robots, settings or notfound"` Batch string `json:"batch,omitempty" query:"batch" jsonschema:"a batch id: the changes of one batch or import"` } // HistoryIDInput names one history entry. type HistoryIDInput struct { ID uint `json:"id" path:"id" jsonschema:"the history entry id"` } // RevisionJSON is a history entry in an answer. func RevisionJSON(r models.Revision) map[string]any { batch := "" if r.BatchID != nil { batch = *r.BatchID } return map[string]any{ "id": r.ID, "created_at": r.CreatedAt, "author": r.AuthorLabel, "author_id": r.AuthorID, "via": r.Via, "kind": r.SubjectKind, "subject": r.SubjectID, "what": r.What, "before": r.Before, "after": r.After, "batch_id": batch, "undone_at": r.UndoneAt, } } // ListHistory serves GET /_seo/api/v1/history: newest first, filtered by // writer, subject kind, batch and description text (D-13). func ListHistory(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { db, err := read(ctx, svc) if err != nil { return 0, nil, err } in := listParams(r) q := db.Model(&models.Revision{}).Order("id DESC") if in.Search != "" { q = q.Where("lower(what) LIKE ?", like(in.Search)) } query := r.URL.Query() if v := query.Get("via"); v != "" { q = q.Where("via = ?", v) } if v := query.Get("kind"); v != "" { q = q.Where("subject_kind = ?", v) } if v := query.Get("batch"); v != "" { q = q.Where("batch_id = ?", v) } var rows []models.Revision total, err := paginate(q, in, &rows) if err != nil { return 0, nil, err } out := make([]map[string]any, 0, len(rows)) for _, row := range rows { out = append(out, RevisionJSON(row)) } return http.StatusOK, listBody(out, in, total), nil }) } // ShowHistory serves GET /_seo/api/v1/history/{id}. func ShowHistory(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { id, err := pathID(r, "id") if err != nil { return 0, nil, err } db, err := read(ctx, svc) if err != nil { return 0, nil, err } var rows []models.Revision if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil { return 0, nil, err } if len(rows) == 0 { return 0, nil, classes.ErrNoRecord } return http.StatusOK, map[string]any{"data": RevisionJSON(rows[0])}, nil }) } // HistoryBatchInput names one batch. type HistoryBatchInput struct { Batch string `json:"batch" path:"batch" jsonschema:"the batch id"` } // undoAnswer maps an undo refusal to 409 with its code and message. func undoAnswer(ctx context.Context, svc *classes.Service, err error) error { var refusal *classes.UndoRefusal if errors.As(err, &refusal) { return &apiError{Status: http.StatusConflict, Body: map[string]any{ "error": refusal.Code, "message": svc.PhraseCtx(ctx, refusal.Key, refusal.Params), }} } return err } // UndoChange serves POST /_seo/api/v1/history/{id}/undo (D-13): the values // before the change are restored and the undo is recorded (via undo). It // answers 409 undo_stale when a later change touched the same values, // undo_loop when the restore would create a redirect loop, undo_gone when // the record is gone and already_undone; nothing is undone then. func UndoChange(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { id, err := pathID(r, "id") if err != nil { return 0, nil, err } var ids []uint err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { var err error ids, err = svc.Undo(ctx, tx, id) return err }) if err != nil { return 0, nil, undoAnswer(ctx, svc, err) } return http.StatusOK, map[string]any{"undone": ids, "count": len(ids)}, nil }) } // UndoBatch serves POST /_seo/api/v1/history/batches/{batch}/undo: every // change of the batch not undone yet is undone in one transaction, newest // first, or none is (the same refusals as one change). 404 when nothing of // the batch is left to undo. func UndoBatch(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { batch := r.PathValue("batch") var ids []uint err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { var err error ids, err = svc.UndoBatch(ctx, tx, batch) return err }) if err != nil { return 0, nil, undoAnswer(ctx, svc, err) } return http.StatusOK, map[string]any{"undone": ids, "count": len(ids)}, nil }) }