forked from mirrors/pronouns.cc
feat: add working POST /v2/admin/users/{id}/actions
This commit is contained in:
parent
9726827706
commit
2dd37f15bf
8 changed files with 447 additions and 27 deletions
|
@ -37,3 +37,15 @@ func (id FlagID) Increment() uint16 { return Snowflake(id).Increment() }
|
|||
|
||||
func (id FlagID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
|
||||
func (id *FlagID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
|
||||
|
||||
type AuditLogID Snowflake
|
||||
|
||||
func (id AuditLogID) String() string { return Snowflake(id).String() }
|
||||
func (id AuditLogID) Time() time.Time { return Snowflake(id).Time() }
|
||||
func (id AuditLogID) IsValid() bool { return Snowflake(id).IsValid() }
|
||||
func (id AuditLogID) Worker() uint8 { return Snowflake(id).Worker() }
|
||||
func (id AuditLogID) PID() uint8 { return Snowflake(id).PID() }
|
||||
func (id AuditLogID) Increment() uint16 { return Snowflake(id).Increment() }
|
||||
|
||||
func (id AuditLogID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
|
||||
func (id *AuditLogID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
|
||||
|
|
73
backend/db/audit_log.go
Normal file
73
backend/db/audit_log.go
Normal file
|
@ -0,0 +1,73 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||
"emperror.dev/errors"
|
||||
"github.com/georgysavva/scany/v2/pgxscan"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type AuditLogEntry struct {
|
||||
ID common.AuditLogID
|
||||
TargetUserID common.UserID
|
||||
TargetMemberID *common.MemberID
|
||||
ModeratorID common.UserID
|
||||
ReportID *int64
|
||||
|
||||
Reason string
|
||||
ActionTaken string
|
||||
ClearedData *AuditLogClearedData
|
||||
}
|
||||
|
||||
type AuditLogClearedData struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
Links []string `json:"links,omitempty"`
|
||||
Names []FieldEntry `json:"names,omitempty"`
|
||||
Pronouns []PronounEntry `json:"pronouns,omitempty"`
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
CustomPreferences []CustomPreference `json:"custom_preferences"`
|
||||
}
|
||||
|
||||
// Returns a max of 100 audit log entries created before the time in `before`.
|
||||
// If `before` is 0, returns the latest entries.
|
||||
func (db *DB) AuditLog(ctx context.Context, before common.AuditLogID) (es []AuditLogEntry, err error) {
|
||||
b := sq.Select("*").From("audit_log").Limit(100).OrderBy("id DESC")
|
||||
if before.IsValid() {
|
||||
b = b.Where("id < ?", before)
|
||||
}
|
||||
sql, args, err := b.ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "building query")
|
||||
}
|
||||
|
||||
err = pgxscan.Select(ctx, db, &es, sql, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return NotNull(es), nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateAuditLogEntry(ctx context.Context, tx pgx.Tx, data AuditLogEntry) (e AuditLogEntry, err error) {
|
||||
sql, args, err := sq.Insert("audit_log").SetMap(map[string]any{
|
||||
"id": common.GenerateID(),
|
||||
"target_user_id": data.TargetUserID,
|
||||
"target_member_id": data.TargetMemberID,
|
||||
"moderator_id": data.ModeratorID,
|
||||
"report_id": data.ReportID,
|
||||
"reason": data.Reason,
|
||||
"action_taken": data.ActionTaken,
|
||||
"cleared_data": data.ClearedData,
|
||||
}).Suffix("RETURNING *").ToSql()
|
||||
if err != nil {
|
||||
return e, errors.Wrap(err, "building query")
|
||||
}
|
||||
|
||||
err = pgxscan.Get(ctx, tx, &e, sql, args...)
|
||||
if err != nil {
|
||||
return e, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return e, nil
|
||||
}
|
|
@ -174,7 +174,7 @@ func (db *DB) CreateWarning(ctx context.Context, tx pgx.Tx, userID xid.ID, reaso
|
|||
sql, args, err := sq.Insert("warnings").SetMap(map[string]any{
|
||||
"user_id": userID,
|
||||
"reason": reason,
|
||||
}).ToSql()
|
||||
}).Suffix("RETURNING *").ToSql()
|
||||
if err != nil {
|
||||
return w, errors.Wrap(err, "building sql")
|
||||
}
|
||||
|
|
|
@ -8,6 +8,12 @@ import (
|
|||
"github.com/go-chi/render"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionTypeIgnore = "ignore"
|
||||
ActionTypeWarn = "warn"
|
||||
ActionTypeSuspend = "suspend"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
*server.Server
|
||||
}
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||
"emperror.dev/errors"
|
||||
"github.com/aarondl/opt/omit"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
@ -28,13 +35,11 @@ type UserActionRequest struct {
|
|||
} `json:"clear"`
|
||||
}
|
||||
|
||||
const (
|
||||
ActionTypeIgnore = "ignore"
|
||||
ActionTypeWarn = "warn"
|
||||
ActionTypeSuspend = "suspend"
|
||||
)
|
||||
type UserActionResponse struct {
|
||||
AuditLogID common.AuditLogID `json:"audit_log_id"`
|
||||
}
|
||||
|
||||
func (req UserActionRequest) Validate() (errs []server.ModelParseError) {
|
||||
func (req UserActionRequest) Validate(u db.User, fields []db.Field) (errs []server.ModelParseError) {
|
||||
switch req.Type {
|
||||
case ActionTypeIgnore, ActionTypeWarn, ActionTypeSuspend:
|
||||
default:
|
||||
|
@ -58,22 +63,313 @@ func (req UserActionRequest) Validate() (errs []server.ModelParseError) {
|
|||
errs = append(errs, server.NewModelParseError("clear", "clear cannot be set if report is being ignored"))
|
||||
}
|
||||
|
||||
// check names
|
||||
if len(req.Clear.Names) > len(u.Names) {
|
||||
errs = append(errs, server.NewModelParseError("clear.names", "cannot have more indexes than there are names"))
|
||||
}
|
||||
if !allUnique(req.Clear.Names) {
|
||||
errs = append(errs, server.NewModelParseError("clear.names", "all indexes in clear.names must be unique"))
|
||||
}
|
||||
namesOutOfRange := false
|
||||
for _, i := range req.Clear.Names {
|
||||
if i >= len(u.Names) {
|
||||
namesOutOfRange = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if namesOutOfRange {
|
||||
errs = append(errs, server.NewModelParseError("clear.names", "one or more indexes is out of range"))
|
||||
}
|
||||
|
||||
// check pronouns
|
||||
if len(req.Clear.Pronouns) > len(u.Pronouns) {
|
||||
errs = append(errs, server.NewModelParseError("clear.pronouns", "cannot have more indexes than there are pronouns"))
|
||||
}
|
||||
if !allUnique(req.Clear.Pronouns) {
|
||||
errs = append(errs, server.NewModelParseError("clear.pronouns", "all indexes in clear.pronouns must be unique"))
|
||||
}
|
||||
pronounsOutOfRange := false
|
||||
for _, i := range req.Clear.Pronouns {
|
||||
if i >= len(u.Pronouns) {
|
||||
pronounsOutOfRange = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if pronounsOutOfRange {
|
||||
errs = append(errs, server.NewModelParseError("clear.pronouns", "one or more indexes is out of range"))
|
||||
}
|
||||
|
||||
// check fields
|
||||
if len(req.Clear.Fields) > len(fields) {
|
||||
errs = append(errs, server.NewModelParseError("clear.fields", "cannot have more indexes than there are fields"))
|
||||
}
|
||||
if !allUnique(req.Clear.Fields) {
|
||||
errs = append(errs, server.NewModelParseError("clear.fields", "all indexes in clear.fields must be unique"))
|
||||
}
|
||||
fieldsOutOfRange := false
|
||||
for _, i := range req.Clear.Fields {
|
||||
if i >= len(fields) {
|
||||
fieldsOutOfRange = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if fieldsOutOfRange {
|
||||
errs = append(errs, server.NewModelParseError("clear.fields", "one or more indexes is out of range"))
|
||||
}
|
||||
|
||||
// check custom preferences
|
||||
if !allUnique(req.Clear.CustomPreferences) {
|
||||
errs = append(errs, server.NewModelParseError("clear.custom_preferences", "all IDs in clear.custom_preferences must be unique"))
|
||||
}
|
||||
invalidIDs := false
|
||||
for _, id := range req.Clear.CustomPreferences {
|
||||
if _, ok := u.CustomPreferences[id.String()]; !ok {
|
||||
invalidIDs = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalidIDs {
|
||||
errs = append(errs, server.NewModelParseError("clear.custom_preferences", "unknown ID specified"))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func (req UserActionRequest) HasClear() bool {
|
||||
return req.Clear.DisplayName || req.Clear.Bio || req.Clear.Links ||
|
||||
len(req.Clear.Names) != 0 || len(req.Clear.Pronouns) != 0 ||
|
||||
len(req.Clear.Fields) != 0 || len(req.Clear.CustomPreferences) != 0
|
||||
}
|
||||
|
||||
func (s *Server) UserAction(w http.ResponseWriter, r *http.Request) (err error) {
|
||||
ctx := r.Context()
|
||||
claims, _ := server.ClaimsFromContext(ctx)
|
||||
mod, err := s.DB.User(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting moderator data")
|
||||
}
|
||||
|
||||
// Parse the user we're taking action against
|
||||
target, err := s.ParseUser(ctx, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
return server.NewV2Error(server.ErrUserNotFound, "")
|
||||
}
|
||||
|
||||
fields, err := s.DB.UserFields(ctx, target.ID)
|
||||
if err != nil {
|
||||
log.Errorf("getting fields for user %v: %v", target.SnowflakeID, fields)
|
||||
return server.NewV2Error(server.ErrInternalServerError, "")
|
||||
}
|
||||
|
||||
req, err := server.Decode[UserActionRequest](r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// validate input
|
||||
if errs := req.Validate(); len(errs) != 0 {
|
||||
if errs := req.Validate(target, fields); len(errs) != 0 {
|
||||
return server.NewV2Error(server.ErrBadRequest, "", errs...)
|
||||
}
|
||||
|
||||
render.JSON(w, r, claims)
|
||||
// Store removed names for the audit log
|
||||
var (
|
||||
deletedNames []db.FieldEntry
|
||||
deletedPronouns []db.PronounEntry
|
||||
deletedFields []db.Field
|
||||
deletedPreferences []db.CustomPreference
|
||||
)
|
||||
|
||||
// Remove names
|
||||
// Sort to reverse order, so elements don't shift indexes before we remove them
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(req.Clear.Names)))
|
||||
for _, i := range req.Clear.Names {
|
||||
var deleted db.FieldEntry
|
||||
target.Names, deleted = deleteIndex(target.Names, i)
|
||||
deletedNames = append(deletedNames, deleted)
|
||||
}
|
||||
// Remove pronouns
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(req.Clear.Pronouns)))
|
||||
for _, i := range req.Clear.Pronouns {
|
||||
var deleted db.PronounEntry
|
||||
target.Pronouns, deleted = deleteIndex(target.Pronouns, i)
|
||||
deletedPronouns = append(deletedPronouns, deleted)
|
||||
}
|
||||
// Remove fields
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(req.Clear.Fields)))
|
||||
for _, i := range req.Clear.Fields {
|
||||
var deleted db.Field
|
||||
fields, deleted = deleteIndex(fields, i)
|
||||
deletedFields = append(deletedFields, deleted)
|
||||
}
|
||||
// Remove custom preferences
|
||||
for _, k := range req.Clear.CustomPreferences {
|
||||
deletedPreferences = append(deletedPreferences, target.CustomPreferences[k.String()])
|
||||
delete(target.CustomPreferences, k.String())
|
||||
}
|
||||
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("starting transaction: %v", err)
|
||||
return errors.Wrap(err, "starting transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// If report_id is set, check whether the report exists.
|
||||
var reportID *int64
|
||||
if id, ok := req.ReportID.Get(); ok {
|
||||
reportID = &id
|
||||
_, err := s.DB.Report(ctx, tx, *reportID)
|
||||
if err != nil {
|
||||
if err == db.ErrReportNotFound {
|
||||
return server.NewV2Error(server.ErrBadRequest, "", server.NewModelParseError("report_id", "invalid report ID"))
|
||||
}
|
||||
log.Errorf("getting report: %v", err)
|
||||
return errors.Wrap(err, "getting report")
|
||||
}
|
||||
}
|
||||
|
||||
// Build the cleared data object
|
||||
var clearedData *db.AuditLogClearedData
|
||||
if req.HasClear() {
|
||||
clearedData = &db.AuditLogClearedData{
|
||||
Names: deletedNames,
|
||||
Pronouns: deletedPronouns,
|
||||
Fields: deletedFields,
|
||||
CustomPreferences: deletedPreferences,
|
||||
}
|
||||
if req.Clear.DisplayName {
|
||||
clearedData.DisplayName = target.DisplayName
|
||||
}
|
||||
if req.Clear.Bio {
|
||||
clearedData.Bio = target.Bio
|
||||
}
|
||||
if req.Clear.Links {
|
||||
clearedData.Links = target.Links
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure there's a non-empty reason
|
||||
reason := req.Reason
|
||||
if req.Reason == "" {
|
||||
reason = "None"
|
||||
}
|
||||
|
||||
// Create audit log entry
|
||||
auditLogEntry, err := s.DB.CreateAuditLogEntry(ctx, tx, db.AuditLogEntry{
|
||||
TargetUserID: target.SnowflakeID,
|
||||
TargetMemberID: nil,
|
||||
ModeratorID: mod.SnowflakeID,
|
||||
ReportID: reportID,
|
||||
Reason: reason,
|
||||
ActionTaken: req.Type,
|
||||
ClearedData: clearedData,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("creating audit log entry: %v", err)
|
||||
return errors.Wrap(err, "creating audit log entry")
|
||||
}
|
||||
|
||||
// Resolve report, if an ID was given
|
||||
if req.ReportID.IsSet() {
|
||||
err = s.DB.ResolveReport(ctx, tx, req.ReportID.GetOrZero(), mod.ID, req.Reason)
|
||||
if err != nil {
|
||||
log.Errorf("resolving report: %v", err)
|
||||
return errors.Wrap(err, "resolving report")
|
||||
}
|
||||
}
|
||||
|
||||
// Create user warning
|
||||
if req.Type == ActionTypeWarn || req.Type == ActionTypeSuspend {
|
||||
_, err = s.DB.CreateWarning(ctx, tx, target.ID, req.Reason)
|
||||
if err != nil {
|
||||
log.Errorf("creating warning: %v", err)
|
||||
return errors.Wrap(err, "creating warning")
|
||||
}
|
||||
}
|
||||
|
||||
if req.Type == ActionTypeSuspend {
|
||||
err = s.DB.DeleteUser(ctx, tx, target.ID, false, req.Reason)
|
||||
if err != nil {
|
||||
log.Errorf("suspending user: %v", err)
|
||||
return errors.Wrap(err, "suspending user")
|
||||
}
|
||||
}
|
||||
|
||||
// If we're clearing some data, do that
|
||||
if req.HasClear() {
|
||||
var displayName *string
|
||||
if req.Clear.DisplayName {
|
||||
displayName = ptr("")
|
||||
}
|
||||
var bio *string
|
||||
if req.Clear.Bio {
|
||||
bio = ptr("")
|
||||
}
|
||||
var links *[]string
|
||||
if req.Clear.Links {
|
||||
links = &[]string{}
|
||||
}
|
||||
|
||||
// Update most columns
|
||||
_, err = s.DB.UpdateUser(ctx, tx, target.ID, displayName, bio, nil, nil, links, nil, nil, &target.CustomPreferences)
|
||||
if err != nil {
|
||||
log.Errorf("updating user: %v", err)
|
||||
return errors.Wrap(err, "updating user")
|
||||
}
|
||||
|
||||
// Set fields
|
||||
err = s.DB.SetUserFields(ctx, tx, target.ID, fields)
|
||||
if err != nil {
|
||||
log.Errorf("updating fields: %v", err)
|
||||
return errors.Wrap(err, "updating fields")
|
||||
}
|
||||
|
||||
// Set names/pronouns
|
||||
err = s.DB.SetUserNamesPronouns(ctx, tx, target.ID, target.Names, target.Pronouns)
|
||||
if err != nil {
|
||||
log.Errorf("updating names/pronouns: %v", err)
|
||||
return errors.Wrap(err, "updating names/pronouns")
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction and save everything
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("committing transaction: %v", err)
|
||||
return errors.Wrap(err, "committing transaction")
|
||||
}
|
||||
|
||||
render.JSON(w, r, UserActionResponse{
|
||||
AuditLogID: auditLogEntry.ID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) ParseUser(ctx context.Context, id string) (u db.User, err error) {
|
||||
sf, err := common.ParseSnowflake(id)
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
|
||||
return s.DB.UserBySnowflake(ctx, common.UserID(sf))
|
||||
}
|
||||
|
||||
func allUnique[T comparable](slice []T) bool {
|
||||
m := make(map[T]struct{})
|
||||
for _, entry := range slice {
|
||||
_, ok := m[entry]
|
||||
if ok {
|
||||
return false
|
||||
}
|
||||
m[entry] = struct{}{}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func deleteIndex[T any](slice []T, idx int) ([]T, T) {
|
||||
deleted := slice[idx]
|
||||
return append(slice[:idx], slice[idx+1:]...), deleted
|
||||
}
|
||||
|
||||
func ptr[T any](t T) *T {
|
||||
return &t
|
||||
}
|
||||
|
|
|
@ -3,8 +3,6 @@ package server
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
json2 "github.com/aarondl/json"
|
||||
"github.com/getsentry/sentry-go"
|
||||
|
@ -104,6 +102,8 @@ func ParseJSONError(err error) *ModelParseError {
|
|||
ActualType: pe.Value,
|
||||
}
|
||||
case *json2.UnmarshalTypeError:
|
||||
// TODO: remove the need for this, somehow
|
||||
// This seems to be a bug in the
|
||||
key := pe.Field
|
||||
if key == "" {
|
||||
key = "parse"
|
||||
|
@ -111,6 +111,7 @@ func ParseJSONError(err error) *ModelParseError {
|
|||
|
||||
return &ModelParseError{
|
||||
Key: key,
|
||||
Message: "Invalid type passed for a field. Make sure your types match those in the documentation.",
|
||||
ExpectedType: pe.Type.String(),
|
||||
ActualType: pe.Value,
|
||||
}
|
||||
|
@ -118,19 +119,3 @@ func ParseJSONError(err error) *ModelParseError {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Decode[T any](r *http.Request) (T, error) {
|
||||
var t T
|
||||
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
err := json.Unmarshal(b, &t)
|
||||
// err := render.Decode(r, &t)
|
||||
if err != nil {
|
||||
pe := ParseJSONError(err)
|
||||
if pe != nil {
|
||||
return t, NewV2Error(ErrBadRequest, "", *pe)
|
||||
}
|
||||
return t, NewV2Error(ErrBadRequest, "")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
|
|
@ -154,3 +154,21 @@ type ctxKey int
|
|||
const (
|
||||
ctxKeyClaims ctxKey = 1
|
||||
)
|
||||
|
||||
// Decodes a JSON request into a value of T.
|
||||
// If an error is returned, transform that into a ModelParseError.
|
||||
// Known issue: parse errors in `github.com/aarondl/opt` types don't return a field name,
|
||||
// so those are put in the `parse` key of the returned error.
|
||||
func Decode[T any](r *http.Request) (T, error) {
|
||||
var t T
|
||||
|
||||
err := render.Decode(r, &t)
|
||||
if err != nil {
|
||||
pe := ParseJSONError(err)
|
||||
if pe != nil {
|
||||
return t, NewV2Error(ErrBadRequest, "", *pe)
|
||||
}
|
||||
return t, NewV2Error(ErrBadRequest, "")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
|
30
scripts/migrate/023_mod_log.sql
Normal file
30
scripts/migrate/023_mod_log.sql
Normal file
|
@ -0,0 +1,30 @@
|
|||
-- 2023-12-27: Add moderation log
|
||||
|
||||
-- +migrate Up
|
||||
|
||||
create table audit_log (
|
||||
id bigint primary key,
|
||||
-- the target user of this action
|
||||
-- this is *not* a foreign key. if the user is deleted the audit log entry should stay, just showing as "deleted user <id>"
|
||||
target_user_id bigint not null,
|
||||
-- the target member of this action
|
||||
target_member_id bigint,
|
||||
-- the moderator that took this action
|
||||
-- as with target_user_id, the audit log entry should not be deleted if the moderator's account is.
|
||||
moderator_id bigint not null,
|
||||
-- the report this was in response to. may be null if this action was taken by a moderator not responding to a report.
|
||||
report_id integer references reports (id) on delete set null,
|
||||
|
||||
-- the reason for this action. always set, but may be 'None' if the action taken was 'ignore'.
|
||||
reason text not null default 'None',
|
||||
action_taken text not null, -- enum, currently: 'ignore', 'warn', 'suspend'
|
||||
cleared_data jsonb null -- backup of the cleared data. may be null if no data was cleared
|
||||
);
|
||||
|
||||
create index audit_log_target_user_idx on audit_log (target_user_id);
|
||||
create index audit_log_target_member_idx on audit_log (target_member_id);
|
||||
create index audit_log_moderator_idx on audit_log (moderator_id);
|
||||
|
||||
-- +migrate Down
|
||||
|
||||
drop table audit_log;
|
Loading…
Reference in a new issue