Backend rename (frontend lands in next commit): - Migration 026: rename paliad.departments → paliad.partner_units, paliad.department_members → paliad.partner_unit_members, junction FK department_id → partner_unit_id, plus all constraints/indexes/policies. Pre-drop seed re-runs migration 019's logic to capture any users.dezernat drift, then DROP COLUMN. Adds paliad.partner_unit_events audit table with RLS (any-authenticated read, global_admin write). - models.User.Dezernat dropped. Department / DepartmentMember → PartnerUnit / PartnerUnitMember. - DepartmentService → PartnerUnitService (file renamed via git mv to preserve blame). Every mutation now opens a tx and emits a partner_unit_events row in the same tx (created/updated/deleted/ member_added/member_removed). Update emits before/after snapshots; Delete emits BEFORE the cascade so the FK still resolves, then ON DELETE SET NULL keeps the historical row. - /api/departments/* → /api/partner-units/*. Handlers renamed. - New /admin/partner-units page handler stub. - AuditService UNIONs the new partner_unit_events source as a 4th branch; handler accepts AuditSourcePartnerUnitEvents. - user_service: drop dezernat from CreateUserInput / UpdateProfileInput / AdminCreateInput / AdminUpdateInput. CreateUserInput gains PartnerUnitID *uuid.UUID — onboarding can pick an initial unit and the membership row + audit event are inserted in the same tx. - Settings tab aliases drop dezernat/department. - Legacy /dezernate and /departments now redirect to /admin/partner-units (admins only see it; non-admins land on the forbidden bounce). go build / vet / test compile clean.
253 lines
10 KiB
Go
253 lines
10 KiB
Go
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
|
|
}
|