package repository import ( "context" "encoding/json" "strconv" "strings" "time" "github.com/google/uuid" "evobgp/internal/store" ) // AppendAudit inserts a tenant-scoped audit row. func (p *Postgres) AppendAudit(in store.AuditAppendInput) (*store.AuditEntry, error) { if strings.TrimSpace(in.TenantID) == "" || strings.TrimSpace(in.Action) == "" || strings.TrimSpace(in.Summary) == "" { return nil, store.ErrInvalidInput } sev := strings.TrimSpace(in.Severity) if sev == "" { sev = store.AuditSeverityInfo } if !store.ValidAuditSeverity(sev) { return nil, store.ErrInvalidInput } ctx := context.Background() id := uuid.NewString() eventID := "bgp-" + uuid.NewString() var detailJSON []byte if in.Details != nil { detailJSON, _ = json.Marshal(in.Details) } var createdAt time.Time err := p.pool.QueryRow(ctx, ` INSERT INTO audit_log (id, tenant_id, event_id, source_app, action, severity, actor_user_id, actor_email, actor_name, actor_api_key_prefix, target_type, target_id, summary, details_json, ip, created_at) VALUES ($1, $2, $3, 'bgp', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, now()) RETURNING created_at`, id, strings.TrimSpace(in.TenantID), eventID, strings.TrimSpace(in.Action), sev, nullIfEmpty(in.ActorUserID), nullIfEmpty(in.ActorEmail), nullIfEmpty(in.ActorName), nullIfEmpty(in.ActorAPIKeyPrefix), nullIfEmpty(in.TargetType), nullIfEmpty(in.TargetID), strings.TrimSpace(in.Summary), nullJSONBytes(detailJSON), nullIfEmpty(in.IP), ).Scan(&createdAt) if err != nil { return nil, err } return &store.AuditEntry{ ID: id, TenantID: strings.TrimSpace(in.TenantID), EventID: eventID, SourceApp: store.AuditSourceAppBGP, Action: strings.TrimSpace(in.Action), Severity: sev, ActorUserID: strings.TrimSpace(in.ActorUserID), ActorEmail: strings.TrimSpace(in.ActorEmail), ActorName: strings.TrimSpace(in.ActorName), ActorAPIKeyPrefix: strings.TrimSpace(in.ActorAPIKeyPrefix), TargetType: strings.TrimSpace(in.TargetType), TargetID: strings.TrimSpace(in.TargetID), Summary: strings.TrimSpace(in.Summary), Details: in.Details, IP: strings.TrimSpace(in.IP), CreatedAt: createdAt.UTC(), }, nil } // ListAudit returns paginated audit rows for a tenant. func (p *Postgres) ListAudit(tenantID, cursor string, limit int, filter store.AuditListFilter) ([]*store.AuditEntry, string, bool, error) { if limit <= 0 { limit = 50 } off := 0 if cursor != "" { if n, err := strconv.Atoi(cursor); err == nil && n >= 0 { off = n } } ctx := context.Background() args := []any{tenantID} where := "tenant_id = $1" argN := 2 if a := strings.TrimSpace(filter.Action); a != "" { where += " AND action = $" + strconv.Itoa(argN) args = append(args, a) argN++ } if s := strings.TrimSpace(filter.Severity); s != "" { where += " AND severity = $" + strconv.Itoa(argN) args = append(args, s) argN++ } args = append(args, limit+1, off) q := ` SELECT id, tenant_id, event_id, source_app, action, severity, actor_user_id, actor_email, actor_name, actor_api_key_prefix, target_type, target_id, summary, details_json, ip, created_at, portal_pushed_at FROM audit_log WHERE ` + where + ` ORDER BY created_at DESC, id DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1) rows, err := p.pool.Query(ctx, q, args...) if err != nil { return nil, "", false, err } defer rows.Close() var out []*store.AuditEntry for rows.Next() { row, err := scanAuditEntry(rows.Scan) if err != nil { return nil, "", false, err } out = append(out, row) } if err := rows.Err(); err != nil { return nil, "", false, err } more := len(out) > limit if more { out = out[:limit] } next := "" if more { next = strconv.Itoa(off + limit) } return out, next, more, nil } // MarkAuditPortalPushed sets portal_pushed_at for a row. func (p *Postgres) MarkAuditPortalPushed(id string) error { ctx := context.Background() tag, err := p.pool.Exec(ctx, `UPDATE audit_log SET portal_pushed_at = now() WHERE id = $1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return store.ErrNotFound } return nil } func scanAuditEntry(scan func(dest ...any) error) (*store.AuditEntry, error) { var row store.AuditEntry var actorUserID, actorEmail, actorName, actorPrefix, targetType, targetID, ip *string var detailRaw []byte var portalPushed *time.Time if err := scan( &row.ID, &row.TenantID, &row.EventID, &row.SourceApp, &row.Action, &row.Severity, &actorUserID, &actorEmail, &actorName, &actorPrefix, &targetType, &targetID, &row.Summary, &detailRaw, &ip, &row.CreatedAt, &portalPushed, ); err != nil { return nil, err } row.CreatedAt = row.CreatedAt.UTC() if actorUserID != nil { row.ActorUserID = *actorUserID } if actorEmail != nil { row.ActorEmail = *actorEmail } if actorName != nil { row.ActorName = *actorName } if actorPrefix != nil { row.ActorAPIKeyPrefix = *actorPrefix } if targetType != nil { row.TargetType = *targetType } if targetID != nil { row.TargetID = *targetID } if ip != nil { row.IP = *ip } if len(detailRaw) > 0 { _ = json.Unmarshal(detailRaw, &row.Details) } if portalPushed != nil { t := portalPushed.UTC() row.PortalPushedAt = &t } return &row, nil }