package services // AuditService produces a unified, paginated, filterable timeline across // every audit source we keep in the paliad schema. There is no single // audit_log table — instead we union four existing sources: // // - paliad.project_events — per-project audit (creates, updates, etc.) // - paliad.caldav_sync_log — CalDAV push/pull outcomes per user // - paliad.reminder_log — bundled-digest reminder sends // - paliad.partner_unit_events — partner-unit CRUD + membership changes // // The union happens in SQL (one round-trip, server-side ordering) and is // keyset-paginated on (timestamp, id) DESC so the cursor stays stable across // page boundaries even when new rows arrive at the head. // // Rendering decisions (event_type → human label, value-only → narrative) // stay on the frontend so the i18n logic the dashboard already uses // (translateEvent) can be reused. We send the raw event_type plus the raw // stored title/description columns and let the client localise. import ( "context" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) // Audit source discriminators. Stable strings — exposed in the JSON payload, // referenced in the i18n keys, and used as filter values. const ( AuditSourceProjectEvents = "project_events" AuditSourceCalDAVLog = "caldav_sync_log" AuditSourceReminderLog = "reminder_log" AuditSourcePartnerUnitEvents = "partner_unit_events" ) // MaxAuditPageLimit caps a single ListEntries page. const MaxAuditPageLimit = 200 // DefaultAuditPageLimit is the page size when the caller doesn't specify. const DefaultAuditPageLimit = 50 // AuditEntry is one unified row in the global audit timeline. type AuditEntry struct { Timestamp time.Time `db:"ts" json:"timestamp"` ID uuid.UUID `db:"id" json:"id"` Source string `db:"source" json:"source"` EventType string `db:"event_type" json:"event_type"` Actor string `db:"actor" json:"actor"` Subject string `db:"subject" json:"subject"` ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"` Title *string `db:"title" json:"title,omitempty"` Description *string `db:"description" json:"description,omitempty"` } // AuditFilter is the input to ListEntries. type AuditFilter struct { // Source restricts to one source ("" = all). Source string // From / To restrict to an inclusive timestamp range. Zero values mean // "no bound on that side" — pass time.Time{} to skip. From time.Time To time.Time // Search is a case-insensitive ILIKE match against subject + description // + title. Empty means no text filter. Search string // BeforeTS / BeforeID are the keyset cursor — return rows strictly older // than (BeforeTS, BeforeID). Both must be set to use cursor pagination; // if either is zero/nil the cursor is ignored. BeforeTS *time.Time BeforeID *uuid.UUID // Limit caps the page size. 0 → DefaultAuditPageLimit, capped at // MaxAuditPageLimit. Limit int } // AuditService unions paliad.project_events / caldav_sync_log / reminder_log // into one timeline. Read-only — the rows themselves are written by the // services that own each source. type AuditService struct { db *sqlx.DB } func NewAuditService(db *sqlx.DB) *AuditService { return &AuditService{db: db} } // auditUnionSQL is the keyset-paginated UNION of all three audit sources. // // Each subquery normalises its row into the AuditEntry shape so the outer // SELECT can ORDER BY a single (ts, id) tuple. Filters that are agnostic // across sources (search, cursor) live in the outer query; per-source // filters (date range, source restriction) live in each subquery so PG can // prune sources via the WHERE before the union materialises. // // Parameters (positional): // // $1 source (text) — filter to one source, or "" for all // $2 from (timestamptz) — inclusive lower bound, NULL = unbounded // $3 to (timestamptz) — inclusive upper bound, NULL = unbounded // $4 search (text) — ILIKE substring against subject/description/title, NULL = none // $5 cursor_ts (timestamptz) — keyset upper bound, NULL = first page // $6 cursor_id (uuid) — keyset tiebreaker, NULL = first page // $7 limit (int) const auditUnionSQL = ` WITH unioned AS ( SELECT 'project_events'::text AS source, e.id AS id, COALESCE(e.event_date, e.created_at) AS ts, COALESCE(e.event_type, 'event') AS event_type, COALESCE(u.email, 'system') AS actor, COALESCE(p.title, '—') AS subject, e.project_id AS project_id, e.title AS title, e.description AS description FROM paliad.project_events e LEFT JOIN paliad.users u ON u.id = e.created_by LEFT JOIN paliad.projects p ON p.id = e.project_id WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'project_events') AND ($2::timestamptz IS NULL OR COALESCE(e.event_date, e.created_at) >= $2) AND ($3::timestamptz IS NULL OR COALESCE(e.event_date, e.created_at) <= $3) UNION ALL SELECT 'caldav_sync_log'::text AS source, c.id AS id, c.occurred_at AS ts, CASE WHEN c.error IS NOT NULL THEN 'caldav_sync_error' ELSE 'caldav_synced' END AS event_type, 'system'::text AS actor, COALESCE(u.email, c.user_id::text) AS subject, NULL::uuid AS project_id, NULL::text AS title, COALESCE( c.error, format('direction=%s pushed=%s pulled=%s duration_ms=%s', c.direction, c.items_pushed, c.items_pulled, COALESCE(c.duration_ms, 0)) ) AS description FROM paliad.caldav_sync_log c LEFT JOIN paliad.users u ON u.id = c.user_id WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'caldav_sync_log') AND ($2::timestamptz IS NULL OR c.occurred_at >= $2) AND ($3::timestamptz IS NULL OR c.occurred_at <= $3) UNION ALL SELECT 'reminder_log'::text AS source, r.id AS id, r.sent_at AS ts, r.reminder_type AS event_type, 'system'::text AS actor, COALESCE(u.email, r.user_id::text) AS subject, NULL::uuid AS project_id, NULL::text AS title, format('slot=%s slot_date=%s', COALESCE(r.slot, ''), COALESCE(r.slot_date::text, '')) AS description FROM paliad.reminder_log r LEFT JOIN paliad.users u ON u.id = r.user_id WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'reminder_log') AND ($2::timestamptz IS NULL OR r.sent_at >= $2) AND ($3::timestamptz IS NULL OR r.sent_at <= $3) UNION ALL SELECT 'partner_unit_events'::text AS source, pue.id AS id, pue.created_at AS ts, pue.event_type AS event_type, COALESCE(au.email, pue.actor_id::text) AS actor, pue.unit_name AS subject, NULL::uuid AS project_id, NULL::text AS title, pue.payload::text AS description FROM paliad.partner_unit_events pue LEFT JOIN paliad.users au ON au.id = pue.actor_id WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'partner_unit_events') AND ($2::timestamptz IS NULL OR pue.created_at >= $2) AND ($3::timestamptz IS NULL OR pue.created_at <= $3) ) SELECT source, id, ts, event_type, actor, subject, project_id, title, description FROM unioned WHERE ($4::text IS NULL OR ( subject ILIKE '%' || $4 || '%' OR COALESCE(description, '') ILIKE '%' || $4 || '%' OR COALESCE(title, '') ILIKE '%' || $4 || '%' OR event_type ILIKE '%' || $4 || '%' OR actor ILIKE '%' || $4 || '%' )) AND ($5::timestamptz IS NULL OR (ts, id) < ($5, $6::uuid)) ORDER BY ts DESC, id DESC LIMIT $7 ` // ListEntries returns one page of audit rows matching the filter. Caller is // expected to have already verified the user is a global_admin — the service // itself does no role enforcement (the gate happens at the route layer via // auth.RequireAdminFunc). func (s *AuditService) ListEntries(ctx context.Context, f AuditFilter) ([]AuditEntry, error) { limit := f.Limit if limit <= 0 { limit = DefaultAuditPageLimit } if limit > MaxAuditPageLimit { limit = MaxAuditPageLimit } // Convert zero-values to typed nulls so the SQL's COALESCE-style guards // behave correctly. database/sql treats nil interface{} as NULL, but a // zero time.Time would be sent as '0001-01-01' which is a valid value // PG would actually compare against — silently shifting all results. var sourceArg, searchArg any if f.Source != "" { sourceArg = f.Source } if s := strings.TrimSpace(f.Search); s != "" { // Escape SQL LIKE metacharacters so a search for "100%" doesn't act // as a wildcard. Backslash is the default LIKE escape in PG. escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) searchArg = escaped } var fromArg, toArg any if !f.From.IsZero() { fromArg = f.From } if !f.To.IsZero() { toArg = f.To } var cursorTS, cursorID any if f.BeforeTS != nil && f.BeforeID != nil { cursorTS = *f.BeforeTS cursorID = *f.BeforeID } var rows []AuditEntry if err := s.db.SelectContext(ctx, &rows, auditUnionSQL, sourceArg, fromArg, toArg, searchArg, cursorTS, cursorID, limit, ); err != nil { return nil, fmt.Errorf("audit list: %w", err) } return rows, nil }