package persistence import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/infrastructure/persistence/mappers" entity "RenewCMS/internal/infrastructure/persistence/models" "slices" "strings" "time" "gorm.io/gorm" ) const likeEscape = " ESCAPE '!'" type AuditEventRepository struct { db *gorm.DB } var _ audit.Repository = &AuditEventRepository{} func NewAuditEventRepository(db *gorm.DB) *AuditEventRepository { return &AuditEventRepository{db} } func (a *AuditEventRepository) Append(event audit.Event) (audit.Event, error) { model, err := mappers.AuditEventToModel(event) if err != nil { return audit.Event{}, err } model.ID = 0 if err := a.db.Create(&model).Error; err != nil { return audit.Event{}, err } return mappers.AuditEventToDomain(model) } func (a *AuditEventRepository) List(filter audit.Filter) (audit.Page, error) { var total int64 if err := a.filtered(filter).Count(&total).Error; err != nil { return audit.Page{}, err } query := a.filtered(filter).Order("created_at DESC, id DESC") if filter.Size > 0 { query = query.Limit(filter.Size).Offset(offsetOf(filter)) } var found []entity.AuditEvent if err := query.Find(&found).Error; err != nil { return audit.Page{}, err } items := make([]audit.Event, 0, len(found)) for _, model := range found { event, err := mappers.AuditEventToDomain(model) if err != nil { return audit.Page{}, err } items = append(items, event) } return audit.Page{Items: items, Total: int(total)}, nil } func offsetOf(filter audit.Filter) int { if filter.Page < 1 { return 0 } return (filter.Page - 1) * filter.Size } func (a *AuditEventRepository) filtered(filter audit.Filter) *gorm.DB { query := a.db.Model(&entity.AuditEvent{}) if filter.Actor != nil { query = query.Where("actor_type = ?", string(filter.Actor.Type)) if filter.Actor.ID != nil { query = query.Where("actor_id = ?", *filter.Actor.ID) } } if filter.Category != "" { query = query.Where("category = ?", string(filter.Category)) } if filter.Outcome != "" { query = query.Where("outcome = ?", string(filter.Outcome)) } if filter.Security { query = query.Where("security = ?", true) } if filter.From != nil { query = query.Where("created_at >= ?", filter.From.UTC()) } if filter.To != nil { query = query.Where("created_at < ?", filter.To.UTC()) } if filter.Query != "" { pattern := containsPattern(strings.ToLower(filter.Query)) query = query.Where("(LOWER(actor_name) LIKE ?"+likeEscape+" OR LOWER(target_name) LIKE ?"+likeEscape+")", pattern, pattern) } return query } func (a *AuditEventRepository) Summary(query audit.SummaryQuery) (audit.Summary, error) { var summary audit.Summary totals, err := a.countsBetween(query.From, query.To) if err != nil { return audit.Summary{}, err } summary.Total, summary.Failed, summary.Security = totals.Total, totals.Failed, totals.Security previous := query.Previous() previousTotals, err := a.countsBetween(previous.From, previous.To) if err != nil { return audit.Summary{}, err } summary.PreviousTotal = previousTotals.Total if summary.ActivePeople.Users, err = a.distinctActors(audit.ActorUser, query.From, query.To); err != nil { return audit.Summary{}, err } if summary.ActivePeople.APIKeys, err = a.distinctActors(audit.ActorAPIKey, query.From, query.To); err != nil { return audit.Summary{}, err } summary.Series, err = a.series(query) return summary, err } type countsRow struct { Total int Failed int Security int } func (a *AuditEventRepository) countsBetween(from time.Time, to time.Time) (countsRow, error) { var row countsRow err := a.db.Model(&entity.AuditEvent{}). Select("COUNT(*) AS total, "+ "COALESCE(SUM(CASE WHEN outcome = ? THEN 1 ELSE 0 END), 0) AS failed, "+ "COALESCE(SUM(CASE WHEN security = ? THEN 1 ELSE 0 END), 0) AS security", string(audit.OutcomeFailed), true). Where("created_at >= ? AND created_at < ?", from.UTC(), to.UTC()). Scan(&row).Error return row, err } func (a *AuditEventRepository) distinctActors(actorType audit.ActorType, from time.Time, to time.Time) (int, error) { var count int64 err := a.db.Model(&entity.AuditEvent{}). Where("actor_type = ? AND created_at >= ? AND created_at < ?", string(actorType), from.UTC(), to.UTC()). Distinct("actor_id"). Count(&count).Error return int(count), err } func (a *AuditEventRepository) series(query audit.SummaryQuery) (audit.Series, error) { series := audit.Series{ Total: make([]int, audit.SeriesBuckets), Active: make([]int, audit.SeriesBuckets), Failed: make([]int, audit.SeriesBuckets), Security: make([]int, audit.SeriesBuckets), } width := query.To.Sub(query.From) / audit.SeriesBuckets if width <= 0 { return series, nil } for i := 0; i < audit.SeriesBuckets; i++ { from := query.From.Add(time.Duration(i) * width) to := from.Add(width) if i == audit.SeriesBuckets-1 { to = query.To } counts, err := a.countsBetween(from, to) if err != nil { return audit.Series{}, err } users, err := a.distinctActors(audit.ActorUser, from, to) if err != nil { return audit.Series{}, err } keys, err := a.distinctActors(audit.ActorAPIKey, from, to) if err != nil { return audit.Series{}, err } series.Total[i], series.Failed[i], series.Security[i], series.Active[i] = counts.Total, counts.Failed, counts.Security, users+keys } return series, nil } func (a *AuditEventRepository) DeleteOlderThan(cutoff time.Time) (int, error) { result := a.db.Where("created_at < ?", cutoff.UTC()).Delete(&entity.AuditEvent{}) return int(result.RowsAffected), result.Error } const ( deleteChunkSize = 500 payloadSizeSQL = `COALESCE(SUM( OCTET_LENGTH(action) + OCTET_LENGTH(actor_name) + OCTET_LENGTH(target_type) + OCTET_LENGTH(target_id) + OCTET_LENGTH(target_name) + COALESCE(OCTET_LENGTH(failure_reason), 0) + COALESCE(OCTET_LENGTH(ip), 0) + COALESCE(OCTET_LENGTH(user_agent), 0) + OCTET_LENGTH(params) + OCTET_LENGTH(changes)), 0) AS payload, COUNT(*) AS total` ) type payloadRow struct { Payload int64 Total int64 } func (a *AuditEventRepository) SizeBytes() (int64, error) { var row payloadRow if err := a.db.Model(&entity.AuditEvent{}).Select(payloadSizeSQL).Scan(&row).Error; err != nil { return 0, err } return row.Payload + row.Total*audit.RowOverheadBytes, nil } func (a *AuditEventRepository) DeleteOldest(limit int, before time.Time) (int, error) { if limit < 1 { return 0, nil } var ids []uint64 err := a.db.Model(&entity.AuditEvent{}). Where("created_at < ?", before.UTC()). Order("created_at ASC, id ASC"). Limit(limit). Pluck("id", &ids).Error if err != nil { return 0, err } removed := 0 for chunk := range slices.Chunk(ids, deleteChunkSize) { result := a.db.Where("id IN ?", chunk).Delete(&entity.AuditEvent{}) if result.Error != nil { return removed, result.Error } removed += int(result.RowsAffected) } return removed, nil }