think
16px
820px

Notification Preferences + Ops KPI Dashboard — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. T6 is controller-driven — do NOT delegate it to a subagent.

Goal: Two CORE (un-gated) features. (1) Per-user notification preferences — a notification_prefs row per user (email on/off, instant/daily/off frequency, muted categories) enforced at the single notify.Service.Notify choke point plus a new daily-digest sweep, with a self-service /profile panel. (2) An Ops KPI dashboard — one read-only GET /api/v1/admin/stats/ops?days=30 aggregate endpoint over existing tables, surfaced as an "Operations" section on the existing Reports page.

Architecture:
- Prefs enforcement is a choke-point design. Every notification in the app flows through notify.Service.Notify (2 outbox-relay cases in go/cmd/obscura-server/wire.go:496-570; 3 scheduled sweeps in go/cmd/obscura-server/jobs.go). We add a best-effort PrefsReader to that Service, so all five emitters automatically respect prefs with zero changes to the emitters themselves. The only new sweep is the daily digest.
- Category = the notification Kind. The Message.Kind string already carries the category (workflow, document, document_expiry, records_disposition, meterai_quota); muting is a direct prefs.Muted(msg.Kind) check. No new taxonomy.
- Ops KPIs live in the existing reporting context — a deliberate cross-context READ-ONLY read model (go/internal/reporting) that already aggregates over documents/workflow tables. We add one OpsStats(days) method; empty/unlicensed areas return JSON null so the UI hides those cards.

Tech Stack: Go modular monolith (go/, chi router, pgx v5, goose migrations), React + Carbon (@carbon/react) SPA (web/), TanStack Query, openapi-typescript-generated client (web/src/api/schema.ts). No charts library — the Reports page uses CSS primitives, so the trend is CSS bar sparklines.

Spec: docs/superpowers/specs/2026-07-04-notif-prefs-ops-kpi-design.md


Global Constraints (every task)

  • NEVER go test — the test DSN (:55432) IS the live demo Postgres (deploy-postgres-1). Go verify is cd go && go build ./... && go vet ./... only (vet compiles test files too, so keep existing tests compiling).
  • Web verify: cd web && npx tsc --noEmit && npx vite build.
  • npm run gen:api after ANY api/openapi.yaml edit (regenerates web/src/api/schema.ts). Run it from web/. Commit the regenerated schema.ts with the task.
  • NO new npm dependencies (npm install is broken: npm11/node25 arborist crash).
  • Deploy ONLY from repo root: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. After deploy assert /me enabled_modules == [ai, correspondence, esign, semantic, watermarking] (dev-login director@obscura.local, host port 38080).
  • Commit per task on main, do NOT push unless asked. NEVER git add -A (go/obscura-server is a tracked ELF binary) — always git add explicit paths.
  • i18n en/id parity (tsc-enforced): feature-co-located web/src/features/{profile,reports}/i18n.ts slices (merged by web/src/i18n/locales/{en,id}.ts), nested groups, en and id identical in shape. Smart-quote gotcha: the Edit tool can mangle /“”; after editing an i18n file verify npx tsc --noEmit and if quotes broke, rewrite the whole file with Write.
  • Prefs read MUST be best-effort in dispatch: any error (or no prefs port wired) → DefaultPrefs() (everything on, instant). Notification delivery must NEVER break on a prefs outage.
  • Clean up all e2e artifacts (reset prefs to defaults, delete+purge any docs/workflows created).

File Map

File Task Role
go/migrations/00081_notification_prefs.sql (new) T1 notification_prefs table (00080 is taken)
go/internal/notify/domain/prefs.go (new) T1 Prefs value, category + frequency constants, validation helpers
go/internal/notify/app/prefs.go (new) T1 PrefsRepository port + PrefsService (Get/Put/DailyDigestUserIDs)
go/internal/notify/app/service.go T1 PrefsReader port; WithPrefs; prefs enforcement in Notify; SendEmailOnly; ListForUserSince
go/internal/notify/adapters/pg.go T1 ListForUserSince on Store
go/internal/notify/adapters/prefs_pg.go (new) T1 PrefsStore (Postgres notification_prefs)
go/cmd/obscura-server/jobs.go T1 runNotificationDigest sweep
go/cmd/obscura-server/wire.go T1/T2 construct prefs store/service, WithPrefs, register notify.daily_digest, pass NotifyPrefs to httpapi Deps
go/internal/httpapi/handlers_notify.go T2 GetNotificationPrefs + PutNotificationPrefs + view
go/internal/httpapi/server.go T2/T4 Deps.NotifyPrefs; /me/notification-prefs routes; /admin/stats/ops route
api/openapi.yaml T2/T4 prefs + ops paths & schemas
web/src/api/schema.ts T2/T4 regenerated via gen:api
web/src/features/profile/data.ts T3 useNotifPrefs, useUpdateNotifPrefs, NOTIF_CATEGORIES
web/src/features/profile/ProfilePage.tsx T3 Notifications <section> + NotificationsPanel
web/src/features/profile/i18n.ts T3 profile.notif.* (en/id)
go/internal/reporting/app/service.go T4 Ops types + Repository.OpsStats + Service.OpsStats
go/internal/reporting/adapters/pg.go T4 OpsStats SQL
go/internal/httpapi/handlers_admin.go T4 ReportOps handler
web/src/features/reports/data.ts T5 useOps, view types, fmtDuration
web/src/features/reports/OpsSection.tsx (new) T5 KPI tiles + 12-week bar sparkline
web/src/features/reports/ReportsPage.tsx T5 render <OpsSection />
web/src/features/reports/i18n.ts T5 reports.ops.* (en/id)
web/src/styles/app.css T5 .opsbars* sparkline styles

Scouted anchors (verified this session)

  • Dispatch choke point: notify.Service.Notify (go/internal/notify/app/service.go:72-91) — in-app Insert first (source of truth), then best-effort channel fan-out. Only channel today is SMTPChannel (adapters/smtp.go, Name()=="email").
  • The 5 real category slugs (= Message.Kind values passed to Notify):
  • workflowwire.go:517 (workflow.task_assigned)
  • documentwire.go:560 (document.version_added, followed-doc)
  • document_expiryjobs.go:96 (daily expiry reminder)
  • records_dispositionjobs.go:177 (daily records-due sweep)
  • meterai_quotajobs.go:326 (daily e-Meterai low-quota warning)
  • Scheduler pattern: schedulerSvc.Register(name, interval, fn) (wire.go:296-481); daily jobs use 24*time.Hour and no TriggerNow (e.g. ai.chat_retention at wire.go:479).
  • NewService callers: production wire.go:243; tests notify/adapters/pg_test.go:75,118,144,169 call app.NewService(store, ch) / app.NewService(store)must keep this signature (use a .WithPrefs(...) wither instead).
  • Reporting: perm gate reporting.read (server.go:647-650), adapter uses s.db.Exec(ctx) (reporting/adapters/pg.go), only implementor of app.Repository is *Store (no test fake → safe to extend the interface).
  • Reports page: web/src/features/reports/ReportsPage.tsx uses StatTile (@/components/StatTile) + .stat-grid + .breakdown__track/__fill bars (no chart lib). Route /reports exists (App.tsx:68).
  • Profile: web/src/features/profile/ProfilePage.tsx (sections + local sub-components like EditNameModal), own i18n.ts slice, data.ts uses api.GET/PUT + ok(). Route /profile exists (App.tsx:72).
  • /me/* routes are session-authed, no perm (server.go:277-287). Handler body decode idiom: json.NewDecoder(r.Body).Decode(&body) + writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, ...}) + writeJSON(w, code, v) + p, _ := PrincipalFrom(r.Context()); string(p.UserID) (handlers_profile.go).
  • Tables (verified column names):
  • users(id uuid PK, email text, ...) (00005_auth.sql).
  • letters(id, type, classification, subject, status, number, created_by, created_at, updated_at, direction, sender_type, sender_name, received_date, agenda_no); status IN ('draft','in_review','approved','rejected','numbered','registered'); direction IN ('inbound','outbound') (00009 + 00051 + 00053).
  • letter_assignments(letter_id, assignee_position_id uuid, role, status, assigned_at, ...) (disposisi target = a position) (00052).
  • workflow_instances(id, subject_type, subject_id, state, initiator, created_at, updated_at); workflow_transitions(instance_id, from_state, to_state, action, created_at); StateApproved="approved" (workflow/domain/workflow.go:20) (00007).
  • workflow_tasks(id, instance_id, assignee_user_id, action_required, state DEFAULT 'pending', created_at, completed_at, sla_deadline, escalated); workflow_escalations(task_id, instance_id, ..., created_at) (00007 + 00025).
  • esign_sign_envelopes(id, provider, ..., status DEFAULT 'pending', signed_version, created_at, updated_at, completed_at); EnvelopeStatusCompleted="completed" (esign/domain/envelope.go:63) (00062).
  • documents(... created_at, deleted_at) (00006).
  • org_units(id, parent_id, path, name); positions(id, org_unit_id, title); position_assignments(user_id, position_id, valid_from, valid_to) (00003).
  • pgx no-rows idiom: errors.Is(err, pgx.ErrNoRows) with "github.com/jackc/pgx/v5" (auth/adapters/pg.go, gatekeeper/adapters/pg.go). pgx v5 scans text[][]string natively; a Go string param binds to a uuid column via the protocol.

Task 1: Prefs table, store, delivery-time enforcement, daily digest

Files: go/migrations/00081_notification_prefs.sql (new), go/internal/notify/domain/prefs.go (new), go/internal/notify/app/prefs.go (new), go/internal/notify/app/service.go, go/internal/notify/adapters/pg.go, go/internal/notify/adapters/prefs_pg.go (new), go/cmd/obscura-server/jobs.go, go/cmd/obscura-server/wire.go.

Interfaces produced (consumed by T2 handlers + the digest sweep):
- domain.Prefs{EmailEnabled bool; Frequency string; MutedCategories []string} + domain.DefaultPrefs() + domain.KnownCategories() + (Prefs).Muted(cat).
- app.PrefsService with Get(ctx, userID) (domain.Prefs, error) (absent → defaults), Put(ctx, userID, domain.Prefs) error (validates), DailyDigestUserIDs(ctx) ([]string, error).
- (*Service).WithPrefs(PrefsReader, *slog.Logger) *Service; (*Service).SendEmailOnly(...); (*Service).ListForUserSince(...).

Enforcement contract (spec §1): absent row = defaults; muted category → skip in-app + email; email_enabled=falsein-app only; frequency='daily'suppress instant email, in-app row kept, digest emails it; frequency='off'no emails ever, in-app untouched (unless muted).

  • [ ] Step 1 — Migration. Create go/migrations/00081_notification_prefs.sql:
-- +goose Up
-- Per-user notification preferences. An ABSENT row means all defaults (email on,
-- instant frequency, nothing muted) — the migration seeds nothing, so existing behavior
-- is unchanged until a user opts out. notify.Service reads this BEST-EFFORT at delivery
-- time; a read error falls back to defaults so notification delivery never breaks.
--
-- muted_categories holds notification Kind slugs (workflow, document, document_expiry,
-- records_disposition, meterai_quota); a muted category is suppressed on BOTH the in-app
-- inbox and email. frequency gates ONLY the email channel (in-app is always the source of
-- truth for a non-muted category): 'instant' emails now, 'daily' defers to the digest
-- sweep, 'off' never emails.
CREATE TABLE notification_prefs (
    user_id          uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    email_enabled    boolean NOT NULL DEFAULT true,
    frequency        text NOT NULL DEFAULT 'instant',
    muted_categories text[] NOT NULL DEFAULT '{}',
    updated_at       timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT notification_prefs_frequency_chk CHECK (frequency IN ('instant', 'daily', 'off'))
);

-- +goose Down
DROP TABLE notification_prefs;
  • [ ] Step 2 — Domain. Create go/internal/notify/domain/prefs.go:
package domain

// Notification frequency values, controlling the EMAIL channel only (the in-app inbox is
// never affected by frequency — only by muting).
const (
    FrequencyInstant = "instant"
    FrequencyDaily   = "daily"
    FrequencyOff     = "off"
)

// Notification categories == the Message.Kind strings passed to Notify across the app. A
// user mutes a category to stop BOTH in-app and email for it. These mirror, exactly, the
// Kinds emitted by the outbox relay (cmd/obscura-server/wire.go) and the scheduled sweeps
// (cmd/obscura-server/jobs.go):
//   workflow            — workflow.task_assigned (an approval task was assigned to you)
//   document            — document.version_added (a document you follow got a new version)
//   document_expiry     — the daily expiry-reminder sweep
//   records_disposition — the daily records-due-for-disposition sweep
//   meterai_quota       — the daily e-Meterai low-quota warning
const (
    CategoryWorkflow           = "workflow"
    CategoryDocument           = "document"
    CategoryDocumentExpiry     = "document_expiry"
    CategoryRecordsDisposition = "records_disposition"
    CategoryMeteraiQuota       = "meterai_quota"
)

// KnownCategories is the canonical, closed set of muteable category slugs. The PUT prefs
// endpoint validates muted_categories against it.
func KnownCategories() []string {
    return []string{
        CategoryWorkflow, CategoryDocument, CategoryDocumentExpiry,
        CategoryRecordsDisposition, CategoryMeteraiQuota,
    }
}

// IsKnownCategory reports whether slug is a recognised notification category.
func IsKnownCategory(slug string) bool {
    switch slug {
    case CategoryWorkflow, CategoryDocument, CategoryDocumentExpiry,
        CategoryRecordsDisposition, CategoryMeteraiQuota:
        return true
    default:
        return false
    }
}

// IsValidFrequency reports whether f is one of the three allowed frequencies.
func IsValidFrequency(f string) bool {
    return f == FrequencyInstant || f == FrequencyDaily || f == FrequencyOff
}

// Prefs is a user's effective notification preferences. The zero value is NOT the default
// (email would read as disabled) — use DefaultPrefs for an absent row.
type Prefs struct {
    EmailEnabled    bool
    Frequency       string
    MutedCategories []string
}

// DefaultPrefs is the behavior for a user with no stored row: everything on, instant email,
// nothing muted. This preserves today's behavior until a user opts out.
func DefaultPrefs() Prefs {
    return Prefs{EmailEnabled: true, Frequency: FrequencyInstant, MutedCategories: []string{}}
}

// Muted reports whether category is in the muted set.
func (p Prefs) Muted(category string) bool {
    for _, c := range p.MutedCategories {
        if c == category {
            return true
        }
    }
    return false
}
  • [ ] Step 3 — Prefs use-case. Create go/internal/notify/app/prefs.go:
package app

import (
    "context"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/notify/domain"
)

// PrefsRepository is the persistence port for per-user notification preferences.
type PrefsRepository interface {
    // Get returns a user's stored prefs. found=false (with a zero Prefs and nil error)
    // when the user has no row — the caller substitutes DefaultPrefs.
    Get(ctx context.Context, userID string) (domain.Prefs, bool, error)
    // Upsert inserts or replaces a user's prefs (stamping updated_at).
    Upsert(ctx context.Context, userID string, p domain.Prefs) error
    // DailyDigestUserIDs returns the ids of users who opted into a daily email digest
    // (frequency='daily' AND email_enabled=true). Used by the digest sweep.
    DailyDigestUserIDs(ctx context.Context) ([]string, error)
}

// PrefsService is the notification-preferences use-case layer: the self-service read/write
// behind GET/PUT /me/notification-prefs, and the daily-digest recipient query. It also
// satisfies the Service's PrefsReader port (Get) for delivery-time enforcement.
type PrefsService struct {
    repo PrefsRepository
}

// NewPrefsService wires the prefs service over its repository.
func NewPrefsService(repo PrefsRepository) *PrefsService { return &PrefsService{repo: repo} }

// Get returns a user's effective prefs — the stored row, or DefaultPrefs when absent. This
// is the method notify.Service calls at delivery time (via PrefsReader), so an absent row
// transparently yields the everything-on default.
func (s *PrefsService) Get(ctx context.Context, userID string) (domain.Prefs, error) {
    p, found, err := s.repo.Get(ctx, userID)
    if err != nil {
        return domain.Prefs{}, err
    }
    if !found {
        return domain.DefaultPrefs(), nil
    }
    return p, nil
}

// Put validates and stores a user's prefs (self-service). Frequency must be one of the three
// enum values; every muted category must be a known slug (unknown input is a validation
// error, never silently dropped). Duplicates are de-duped.
func (s *PrefsService) Put(ctx context.Context, userID string, p domain.Prefs) error {
    if userID == "" {
        return &kernel.Error{Kind: kernel.ErrValidation, Code: "notify.prefs.user_required", Message: "user id is required"}
    }
    if !domain.IsValidFrequency(p.Frequency) {
        return &kernel.Error{Kind: kernel.ErrValidation, Code: "notify.prefs.frequency_invalid", Message: "frequency must be instant, daily, or off"}
    }
    seen := map[string]bool{}
    clean := make([]string, 0, len(p.MutedCategories))
    for _, c := range p.MutedCategories {
        if !domain.IsKnownCategory(c) {
            return &kernel.Error{Kind: kernel.ErrValidation, Code: "notify.prefs.category_unknown", Message: "unknown notification category: " + c}
        }
        if !seen[c] {
            seen[c] = true
            clean = append(clean, c)
        }
    }
    p.MutedCategories = clean
    return s.repo.Upsert(ctx, userID, p)
}

// DailyDigestUserIDs passes through to the repository (used by the digest sweep).
func (s *PrefsService) DailyDigestUserIDs(ctx context.Context) ([]string, error) {
    return s.repo.DailyDigestUserIDs(ctx)
}

// PrefsService satisfies the Service's delivery-time reader port.
var _ PrefsReader = (*PrefsService)(nil)
  • [ ] Step 4 — Enforce in Notify. Edit go/internal/notify/app/service.go:

4a. Add imports "log/slog" and "time" to the import block (keep "context", kernel, domain).

4b. Add the ListForUserSince method to the Repository interface (right after CountUnread):

    // ListForUserSince returns a user's notifications created at/after `since`, newest-first.
    // The daily digest sweep uses it to recap the window's in-app rows.
    ListForUserSince(ctx context.Context, userID string, since time.Time) ([]domain.Notification, error)

4c. Add the reader port + extend the Service struct + a wither. Insert after the Repository interface block:

// PrefsReader supplies a user's effective preferences to Notify at delivery time.
// Implemented by *PrefsService. Reads are best-effort: Notify treats any error as defaults
// so a prefs outage never breaks delivery.
type PrefsReader interface {
    Get(ctx context.Context, userID string) (domain.Prefs, error)
}

Change the struct to:

type Service struct {
    repo     Repository
    channels []Channel
    prefs    PrefsReader  // optional; nil → everyone gets everything, instant
    logger   *slog.Logger // optional; used only to log best-effort prefs read failures
}

Add the wither (leave NewService signature unchanged so the existing tests compile):

// WithPrefs attaches the per-user preference reader (and a logger for best-effort read
// failures) used to gate delivery. Returns the same Service for chaining. A Service without
// prefs behaves exactly as before (everyone gets everything, instant email).
func (s *Service) WithPrefs(prefs PrefsReader, logger *slog.Logger) *Service {
    s.prefs = prefs
    s.logger = logger
    return s
}

4d. Replace the body of Notify with the prefs-gated version:

func (s *Service) Notify(ctx context.Context, to domain.Recipient, msg domain.Message) error {
    if to.UserID == "" {
        return &kernel.Error{Kind: kernel.ErrValidation, Code: "notify.recipient.user_required", Message: "recipient user id is required"}
    }

    // Best-effort preference read. A prefs outage MUST NOT break delivery: any error (or no
    // prefs port wired) yields defaults — everything on, instant.
    prefs := domain.DefaultPrefs()
    if s.prefs != nil {
        if p, err := s.prefs.Get(ctx, to.UserID); err != nil {
            if s.logger != nil {
                s.logger.Warn("notify prefs read failed; using defaults", "user", to.UserID, "err", err)
            }
        } else {
            prefs = p
        }
    }

    // A muted category is suppressed entirely — no in-app row, no email. Unmuting later
    // restores delivery; subscriptions/data flow are untouched.
    if prefs.Muted(msg.Kind) {
        return nil
    }

    // In-app is the source of truth: always persisted for a non-muted category, regardless of
    // the email/frequency settings below.
    n := domain.NewNotification(kernel.NewID(), to, msg, kernel.SystemClock().Now())
    if err := s.repo.Insert(ctx, n); err != nil {
        return err
    }

    // Out-of-band fan-out, best-effort. The email channel is gated by the user's prefs:
    //   email_enabled=false → no email (in-app only)
    //   frequency='off'     → no email ever
    //   frequency='daily'   → suppress the instant email; the daily digest sweep sends it
    //   frequency='instant' → send now
    // Non-email channels (none today) are unaffected by the frequency/email controls.
    for _, ch := range s.channels {
        if ch.Name() == "email" {
            if to.Email == "" || !prefs.EmailEnabled || prefs.Frequency != domain.FrequencyInstant {
                continue
            }
        }
        _ = ch.Send(ctx, to, msg)
    }
    return nil
}

4e. Add two methods at the end of the file:

// ListForUserSince returns a user's notifications created at/after `since` (newest-first).
func (s *Service) ListForUserSince(ctx context.Context, userID string, since time.Time) ([]domain.Notification, error) {
    return s.repo.ListForUserSince(ctx, userID, since)
}

// SendEmailOnly delivers msg via out-of-band email channels WITHOUT persisting an in-app row
// and WITHOUT consulting prefs. It exists for the daily digest sweep, whose recipients have
// already opted into a daily email; the digest body recaps in-app rows that were recorded
// (unmuted) during the window. Best-effort — channel errors are dropped.
func (s *Service) SendEmailOnly(ctx context.Context, to domain.Recipient, msg domain.Message) {
    if to.Email == "" {
        return
    }
    for _, ch := range s.channels {
        if ch.Name() == "email" {
            _ = ch.Send(ctx, to, msg)
        }
    }
}
  • [ ] Step 5 — ListForUserSince on Store. Edit go/internal/notify/adapters/pg.go: add "time" to imports, then append:
// ListForUserSince returns a user's notifications created at/after `since`, newest-first
// (the daily digest window). No LIMIT — a day's notifications for one user is bounded.
func (s *Store) ListForUserSince(ctx context.Context, userID string, since time.Time) ([]domain.Notification, error) {
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT id, user_id, kind, title, body, read, created_at
           FROM notifications
          WHERE user_id = $1 AND created_at >= $2
          ORDER BY created_at DESC`, userID, since)
    if err != nil {
        return nil, fmt.Errorf("notify list for user since: %w", err)
    }
    defer rows.Close()

    var out []domain.Notification
    for rows.Next() {
        var n domain.Notification
        if err := rows.Scan(&n.ID, &n.UserID, &n.Kind, &n.Title, &n.Body, &n.Read, &n.CreatedAt); err != nil {
            return nil, fmt.Errorf("notify scan notification (since): %w", err)
        }
        out = append(out, n)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("notify list since rows: %w", err)
    }
    return out, nil
}
  • [ ] Step 6 — Prefs store. Create go/internal/notify/adapters/prefs_pg.go:
package adapters

import (
    "context"
    "errors"
    "fmt"

    "github.com/jackc/pgx/v5"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/notify/app"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/notify/domain"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/db"
)

// PrefsStore implements app.PrefsRepository over the notification_prefs table. Like the
// inbox Store it runs every statement through s.db.Exec(ctx) and never opens its own tx.
type PrefsStore struct {
    db *db.DB
}

// NewPrefsStore constructs the notification-preferences repository.
func NewPrefsStore(d *db.DB) *PrefsStore { return &PrefsStore{db: d} }

// Get loads a user's prefs. A missing row is reported found=false (nil error) so the caller
// substitutes DefaultPrefs.
func (s *PrefsStore) Get(ctx context.Context, userID string) (domain.Prefs, bool, error) {
    var p domain.Prefs
    err := s.db.Exec(ctx).QueryRow(ctx,
        `SELECT email_enabled, frequency, muted_categories
           FROM notification_prefs WHERE user_id = $1`, userID).
        Scan(&p.EmailEnabled, &p.Frequency, &p.MutedCategories)
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.Prefs{}, false, nil
    }
    if err != nil {
        return domain.Prefs{}, false, fmt.Errorf("notify prefs get: %w", err)
    }
    return p, true, nil
}

// Upsert inserts or replaces a user's prefs (stamping updated_at server-side).
func (s *PrefsStore) Upsert(ctx context.Context, userID string, p domain.Prefs) error {
    muted := p.MutedCategories
    if muted == nil {
        muted = []string{} // never write SQL NULL into a NOT NULL text[] column
    }
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO notification_prefs (user_id, email_enabled, frequency, muted_categories, updated_at)
         VALUES ($1, $2, $3, $4, now())
         ON CONFLICT (user_id) DO UPDATE SET
             email_enabled = EXCLUDED.email_enabled,
             frequency = EXCLUDED.frequency,
             muted_categories = EXCLUDED.muted_categories,
             updated_at = now()`,
        userID, p.EmailEnabled, p.Frequency, muted); err != nil {
        return fmt.Errorf("notify prefs upsert: %w", err)
    }
    return nil
}

// DailyDigestUserIDs returns users who want a daily email digest (frequency='daily' AND
// email_enabled=true). 'off' users and email-disabled users are excluded.
func (s *PrefsStore) DailyDigestUserIDs(ctx context.Context) ([]string, error) {
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT user_id FROM notification_prefs
          WHERE frequency = 'daily' AND email_enabled = true`)
    if err != nil {
        return nil, fmt.Errorf("notify prefs daily digest users: %w", err)
    }
    defer rows.Close()
    var out []string
    for rows.Next() {
        var id string
        if err := rows.Scan(&id); err != nil {
            return nil, fmt.Errorf("notify prefs daily digest scan: %w", err)
        }
        out = append(out, id)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("notify prefs daily digest rows: %w", err)
    }
    return out, nil
}

var _ app.PrefsRepository = (*PrefsStore)(nil)
  • [ ] Step 7 — Daily digest sweep. Edit go/cmd/obscura-server/jobs.go. Append this function (it uses the already-imported context, fmt, log/slog, time, notifyapp, notifydomain, authapp):
// runNotificationDigest emails a once-a-day recap to every user who chose the 'daily'
// frequency (with email on). The recap lists the in-app notifications recorded for them in
// the last 24h — muted categories never created an in-app row, so they are already excluded.
// A user with no notifications in the window is skipped. Best-effort: a per-user failure is
// logged and does not abort the sweep. Runs daily (no boot TriggerNow — a boot digest is
// never urgent). The 24h window aligns with the 24h cadence so each notification appears in
// exactly one digest.
func runNotificationDigest(
    ctx context.Context,
    notify *notifyapp.Service,
    prefs *notifyapp.PrefsService,
    auth *authapp.Service,
    logger *slog.Logger,
) error {
    userIDs, err := prefs.DailyDigestUserIDs(ctx)
    if err != nil {
        return err
    }
    since := time.Now().Add(-24 * time.Hour)
    for _, uid := range userIDs {
        if uid == "" {
            continue
        }
        items, lerr := notify.ListForUserSince(ctx, uid, since)
        if lerr != nil {
            logger.Error("daily digest list failed", "user", uid, "err", lerr)
            continue
        }
        if len(items) == 0 {
            continue
        }
        u, gerr := auth.GetUser(ctx, uid)
        if gerr != nil || u.Email == "" {
            continue // no address; the in-app rows are still in their inbox
        }
        body := fmt.Sprintf("You have %d new notification(s) from the last 24 hours:\n\n", len(items))
        for _, n := range items {
            body += "• " + n.Title + "\n"
        }
        notify.SendEmailOnly(ctx,
            notifydomain.Recipient{UserID: uid, Email: u.Email},
            notifydomain.Message{Kind: "digest", Title: "Your daily notification digest", Body: body})
    }
    return nil
}
  • [ ] Step 8 — Wire it. Edit go/cmd/obscura-server/wire.go:

8a. Replace the notifySvc := notifyapp.NewService(...) block (~line 243) with:

    notifyPrefsSvc := notifyapp.NewPrefsService(notifyadapters.NewPrefsStore(database))
    notifySvc := notifyapp.NewService(
        notifyadapters.NewStore(database),
        notifyadapters.NewSMTPChannel(notifyadapters.SMTPConfig{Host: cfg.SMTP.Host, Port: cfg.SMTP.Port, From: cfg.SMTP.From}),
    ).WithPrefs(notifyPrefsSvc, logger)

8b. Register the digest sweep next to ai.chat_retention (~line 479, before EnsureRegistered):

    // Daily notification digest: one recap email to each user on the 'daily' frequency
    // (email on). No TriggerNow — a boot digest is never urgent.
    schedulerSvc.Register("notify.daily_digest", 24*time.Hour, func(ctx context.Context) error {
        return runNotificationDigest(ctx, notifySvc, notifyPrefsSvc, authSvc, logger)
    })

8c. Pass the prefs service into the httpapi Deps (the Deps{... Notify: notifySvc, ...} literal, ~line 407): add NotifyPrefs: notifyPrefsSvc,. (The Deps.NotifyPrefs field is added in T2 Step 2 — do 8c together with T2 so the struct field exists, or add the field now.)

  • [ ] Step 9 — Verify. cd go && go build ./... && go vet ./.... Confirm the 4 existing notify/adapters/pg_test.go NewService(...) calls still compile (they do — signature unchanged; prefs stays nil → default path).
  • [ ] Step 10 — Commit:
git add go/migrations/00081_notification_prefs.sql go/internal/notify/domain/prefs.go go/internal/notify/app/prefs.go go/internal/notify/app/service.go go/internal/notify/adapters/pg.go go/internal/notify/adapters/prefs_pg.go go/cmd/obscura-server/jobs.go go/cmd/obscura-server/wire.go
git commit -m "feat(notify): per-user notification prefs — store, delivery-time enforcement, daily digest sweep"

Task 2: GET/PUT /api/v1/me/notification-prefs + OpenAPI + gen:api

Files: go/internal/httpapi/handlers_notify.go, go/internal/httpapi/server.go, api/openapi.yaml, web/src/api/schema.ts (regenerated).

Interface produced: session-authed self-only GET/PUT /api/v1/me/notification-prefs. Body {email_enabled: bool, frequency: 'instant'|'daily'|'off', muted_categories: string[]}. PUT validates the enum + that every slug ∈ {workflow, document, document_expiry, records_disposition, meterai_quota}; a bad slug → 400.

  • [ ] Step 1 — Handlers. Edit go/internal/httpapi/handlers_notify.go. Replace the import block with:
import (
    "encoding/json"
    "net/http"

    "github.com/go-chi/chi/v5"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
    notifydomain "github.com/Virtue-Digital-Indonesia/obscura/internal/notify/domain"
)

Then append (keep the existing ListNotifications/MarkNotificationRead):

// notifPrefsView is the JSON shape for a user's notification preferences.
type notifPrefsView struct {
    EmailEnabled    bool     `json:"email_enabled"`
    Frequency       string   `json:"frequency"`
    MutedCategories []string `json:"muted_categories"`
}

func toNotifPrefsView(p notifydomain.Prefs) notifPrefsView {
    muted := p.MutedCategories
    if muted == nil {
        muted = []string{} // JSON [] not null, so the client always gets an array
    }
    return notifPrefsView{EmailEnabled: p.EmailEnabled, Frequency: p.Frequency, MutedCategories: muted}
}

// GetNotificationPrefs returns the caller's notification preferences (self only). An absent
// row yields the defaults (everything on, instant).
func (s *Server) GetNotificationPrefs(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    prefs, err := s.notifyPrefs.Get(r.Context(), string(p.UserID))
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, toNotifPrefsView(prefs))
}

// PutNotificationPrefs replaces the caller's notification preferences (self only). Validates
// the frequency enum and that every muted category is a known slug; returns the normalized,
// stored value.
func (s *Server) PutNotificationPrefs(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    var body struct {
        EmailEnabled    bool     `json:"email_enabled"`
        Frequency       string   `json:"frequency"`
        MutedCategories []string `json:"muted_categories"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_json", Message: "invalid request body"})
        return
    }
    prefs := notifydomain.Prefs{
        EmailEnabled:    body.EmailEnabled,
        Frequency:       body.Frequency,
        MutedCategories: body.MutedCategories,
    }
    if err := s.notifyPrefs.Put(r.Context(), string(p.UserID), prefs); err != nil {
        writeProblem(w, err)
        return
    }
    // Re-read so the response reflects the normalized (de-duped) stored value.
    stored, err := s.notifyPrefs.Get(r.Context(), string(p.UserID))
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, toNotifPrefsView(stored))
}
  • [ ] Step 2 — Server wiring. Edit go/internal/httpapi/server.go:
  • In the Deps struct (near Notify *notifyapp.Service, ~line 79) add: NotifyPrefs *notifyapp.PrefsService.
  • In the server struct (near notify *notifyapp.Service, ~line 114) add: notifyPrefs *notifyapp.PrefsService.
  • In the constructor mapping (near notify: d.Notify, ~line 161) add: notifyPrefs: d.NotifyPrefs,.
  • Register routes in the /me group (after r.Post("/me/logout-all", s.LogoutAll), ~line 283):
            // Self-service notification preferences (per-category mutes, email on/off, frequency).
            r.Get("/me/notification-prefs", s.GetNotificationPrefs)
            r.Put("/me/notification-prefs", s.PutNotificationPrefs)

(Ensure wire.go Deps literal now passes NotifyPrefs: notifyPrefsSvc — T1 Step 8c.)

  • [ ] Step 3 — OpenAPI path. Edit api/openapi.yaml. Add under paths: next to the /api/v1/me/signatures block (~line 297):
  /api/v1/me/notification-prefs:
    get:
      operationId: getNotificationPrefs
      summary: Get my notification preferences
      description: >-
        Returns the caller's notification preferences. An absent row yields the defaults
        (email on, instant frequency, nothing muted). Self only.
      tags: [me]
      responses:
        '200':
          description: The caller's notification preferences.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationPrefs'
        '401':
          $ref: '#/components/responses/Problem'
    put:
      operationId: putNotificationPrefs
      summary: Replace my notification preferences
      description: >-
        Replaces the caller's notification preferences. `frequency` must be one of
        instant|daily|off; every entry in `muted_categories` must be a known category slug
        (workflow, document, document_expiry, records_disposition, meterai_quota). Self only.
      tags: [me]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotificationPrefs'
      responses:
        '200':
          description: The stored (normalized) preferences.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationPrefs'
        '400':
          $ref: '#/components/responses/Problem'
        '401':
          $ref: '#/components/responses/Problem'
  • [ ] Step 4 — OpenAPI schema. Add under components: schemas: (e.g. next to ReportingOverview):
    NotificationPrefs:
      type: object
      description: A user's notification preferences.
      properties:
        email_enabled:
          type: boolean
          description: When false, no emails are sent (in-app inbox is unaffected).
        frequency:
          type: string
          enum: [instant, daily, off]
          description: >-
            Email cadence. instant = email immediately; daily = suppress the instant email,
            include the item in the once-daily digest; off = never email. In-app is unaffected.
        muted_categories:
          type: array
          items:
            type: string
            enum: [workflow, document, document_expiry, records_disposition, meterai_quota]
          description: Categories suppressed on BOTH in-app inbox and email.
      required: [email_enabled, frequency, muted_categories]
  • [ ] Step 5 — Regenerate + verify. cd web && npm run gen:api && npx tsc --noEmit. (gen:api runs openapi-typescript ../api/openapi.yaml -o src/api/schema.ts.)
  • [ ] Step 6 — Go verify: cd go && go build ./... && go vet ./....
  • [ ] Step 7 — Commit:
git add go/internal/httpapi/handlers_notify.go go/internal/httpapi/server.go go/cmd/obscura-server/wire.go api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(api): GET/PUT /me/notification-prefs (self-service, validated) + OpenAPI"

Task 3: /profile Notifications panel

Files: web/src/features/profile/data.ts, web/src/features/profile/ProfilePage.tsx, web/src/features/profile/i18n.ts.

Interface produced: a "Notifications" <section> on /profile with per-category toggles (5), an email on/off toggle, and a frequency select, saved via one Save button. en/id.

  • [ ] Step 1 — Data hooks. Edit web/src/features/profile/data.ts. Add (mirrors the existing useSignatures/useCreateSignature pattern with api.GET/PUT + ok + useQueryClient):
export type NotifFrequency = 'instant' | 'daily' | 'off'

export interface NotifPrefs {
  emailEnabled: boolean
  frequency: NotifFrequency
  mutedCategories: string[]
}

// The five real notification categories (== server Message.Kind slugs). Order = display order.
export const NOTIF_CATEGORIES = [
  'workflow',
  'document',
  'document_expiry',
  'records_disposition',
  'meterai_quota',
] as const

export function useNotifPrefs() {
  return useQuery<NotifPrefs>({
    queryKey: ['notif-prefs'],
    queryFn: async () => {
      const r = ok(await api.GET('/api/v1/me/notification-prefs', {}))
      return {
        emailEnabled: r.email_enabled ?? true,
        frequency: (r.frequency ?? 'instant') as NotifFrequency,
        mutedCategories: r.muted_categories ?? [],
      }
    },
  })
}

export function useUpdateNotifPrefs() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (p: NotifPrefs) =>
      api
        .PUT('/api/v1/me/notification-prefs', {
          body: { email_enabled: p.emailEnabled, frequency: p.frequency, muted_categories: p.mutedCategories },
        })
        .then(ok),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['notif-prefs'] }),
  })
}

(useMutation, useQuery, useQueryClient are already imported at the top of data.ts; api, ok too.)

  • [ ] Step 2 — Panel component. Edit web/src/features/profile/ProfilePage.tsx:
  • Extend the top @carbon/react import to include Toggle, Select, SelectItem (append to the existing named import list).
  • Add to the top-of-file imports from ./data: useNotifPrefs, useUpdateNotifPrefs, NOTIF_CATEGORIES, type NotifFrequency.
  • Render the section between the Security </section> and the Signatures <section> (i.e. after ~line 124): <NotificationsPanel />.
  • Append the component (alongside EditNameModal etc.):
function NotificationsPanel() {
  const { t } = useTranslation()
  const { data, isLoading } = useNotifPrefs()
  const update = useUpdateNotifPrefs()
  const [emailEnabled, setEmailEnabled] = useState(true)
  const [frequency, setFrequency] = useState<NotifFrequency>('instant')
  const [muted, setMuted] = useState<Set<string>>(new Set())
  const [loaded, setLoaded] = useState(false)

  // Seed local state once, from the loaded prefs.
  if (data && !loaded) {
    setEmailEnabled(data.emailEnabled)
    setFrequency(data.frequency)
    setMuted(new Set(data.mutedCategories))
    setLoaded(true)
  }

  const toggleCategory = (cat: string, on: boolean) => {
    setMuted((prev) => {
      const next = new Set(prev)
      // Toggle ON = "receive this category" = NOT muted.
      if (on) next.delete(cat)
      else next.add(cat)
      return next
    })
  }

  const save = () => {
    update.mutate({ emailEnabled, frequency, mutedCategories: [...muted] })
  }

  return (
    <section className="page__section">
      <h2 className="page__section-title">{t('profile.notif.title')}</h2>
      <p className="muted">{t('profile.notif.meta')}</p>
      {isLoading ? (
        <p className="muted">{t('profile.notif.loading')}</p>
      ) : (
        <div className="notif-prefs">
          <Toggle
            id="notif-email"
            size="sm"
            labelText={t('profile.notif.emailLabel')}
            labelA={t('profile.notif.off')}
            labelB={t('profile.notif.on')}
            toggled={emailEnabled}
            onToggle={setEmailEnabled}
          />
          <Select
            id="notif-frequency"
            labelText={t('profile.notif.frequencyLabel')}
            value={frequency}
            onChange={(e) => setFrequency(e.target.value as NotifFrequency)}
            disabled={!emailEnabled}
          >
            <SelectItem value="instant" text={t('profile.notif.freq.instant')} />
            <SelectItem value="daily" text={t('profile.notif.freq.daily')} />
            <SelectItem value="off" text={t('profile.notif.freq.off')} />
          </Select>

          <h3 className="notif-prefs__subtitle">{t('profile.notif.categoriesTitle')}</h3>
          <div className="notif-prefs__cats">
            {NOTIF_CATEGORIES.map((cat) => (
              <Toggle
                key={cat}
                id={`notif-cat-${cat}`}
                size="sm"
                labelText={t(`profile.notif.cat.${cat}`)}
                labelA={t('profile.notif.muted')}
                labelB={t('profile.notif.on')}
                toggled={!muted.has(cat)}
                onToggle={(on) => toggleCategory(cat, on)}
              />
            ))}
          </div>

          <div className="profile-actions">
            <Button kind="tertiary" size="sm" onClick={save} disabled={update.isPending || !loaded}>
              {t('profile.notif.save')}
            </Button>
          </div>
          {update.isSuccess && <p className="muted">{t('profile.notif.saved')}</p>}
          {update.isError && <p className="edit-attrs__error">{t('profile.notif.error')}</p>}
        </div>
      )}
    </section>
  )
}
  • [ ] Step 3 — i18n. Edit web/src/features/profile/i18n.ts. Add a notif group inside profile: in BOTH en and id (keep shapes identical):

en:

    notif: {
      title: 'Notifications',
      meta: 'Choose which notifications you receive and how you’re emailed.',
      loading: 'Loading preferences…',
      emailLabel: 'Email notifications',
      on: 'On',
      off: 'Off',
      muted: 'Muted',
      frequencyLabel: 'Email frequency',
      freq: { instant: 'Instant', daily: 'Daily digest', off: 'Never' },
      categoriesTitle: 'Categories',
      cat: {
        workflow: 'Approval tasks',
        document: 'Document updates you follow',
        document_expiry: 'Document expiry reminders',
        records_disposition: 'Records due for disposition',
        meterai_quota: 'e-Meterai quota warnings',
      },
      save: 'Save preferences',
      saved: 'Preferences saved.',
      error: 'Couldn’t save preferences — try again.',
    },

id:

    notif: {
      title: 'Notifikasi',
      meta: 'Pilih notifikasi yang Anda terima dan cara pengirimannya lewat email.',
      loading: 'Memuat preferensi…',
      emailLabel: 'Notifikasi email',
      on: 'Aktif',
      off: 'Nonaktif',
      muted: 'Dibisukan',
      frequencyLabel: 'Frekuensi email',
      freq: { instant: 'Langsung', daily: 'Ringkasan harian', off: 'Tidak pernah' },
      categoriesTitle: 'Kategori',
      cat: {
        workflow: 'Tugas persetujuan',
        document: 'Pembaruan dokumen yang Anda ikuti',
        document_expiry: 'Pengingat kedaluwarsa dokumen',
        records_disposition: 'Arsip jatuh tempo penyusutan',
        meterai_quota: 'Peringatan kuota e-Meterai',
      },
      save: 'Simpan preferensi',
      saved: 'Preferensi tersimpan.',
      error: 'Gagal menyimpan preferensi — coba lagi.',
    },
  • [ ] Step 4 — Styles. In web/src/styles/app.css add a small block (dual-theme via tokens; the section reuses .page__section):
/* notification preferences (profile) */
.notif-prefs { display: flex; flex-direction: column; gap: 1rem; max-width: 32rem; }
.notif-prefs__subtitle { font-size: 0.875rem; font-weight: 600; margin-top: 0.5rem; color: var(--cds-text-secondary); }
.notif-prefs__cats { display: flex; flex-direction: column; gap: 0.75rem; }
  • [ ] Step 5 — Verify: cd web && npx tsc --noEmit && npx vite build. If smart quotes in i18n.ts broke tsc, rewrite the whole file with Write.
  • [ ] Step 6 — Commit:
git add web/src/features/profile/data.ts web/src/features/profile/ProfilePage.tsx web/src/features/profile/i18n.ts web/src/styles/app.css
git commit -m "feat(web): /profile Notifications panel (category mutes, email toggle, frequency)"

Task 4: GET /api/v1/admin/stats/ops?days=30 + OpenAPI + gen:api

Files: go/internal/reporting/app/service.go, go/internal/reporting/adapters/pg.go, go/internal/httpapi/handlers_admin.go, go/internal/httpapi/server.go, api/openapi.yaml, web/src/api/schema.ts.

Interface produced: GET /api/v1/admin/stats/ops?days=30 gated on reporting.read (same as the other reports). Returns {generated_at, window_days, metrics:{...}}. Nullable metrics (letter_processing, workflow_approval, esign_completion) are null when their area is empty/unlicensed → UI hides the card. weekly_volume always has 12 points.

  • [ ] Step 1 — Types + Service. Edit go/internal/reporting/app/service.go:
  • Add "time" to the imports (currently only "context").
  • Append the Ops types:
// DurationStat is an average + 90th-percentile elapsed time (seconds) over a sample set.
type DurationStat struct {
    AvgSeconds float64 `json:"avg_seconds"`
    P90Seconds float64 `json:"p90_seconds"`
    Samples    int     `json:"samples"`
}

// UnitVolume is one org unit's letter volume in the window.
type UnitVolume struct {
    Unit  string `json:"unit"`
    Count int    `json:"count"`
}

// WeeklyPoint is one ISO week's document + letter creation volume. WeekStart is the Monday
// (YYYY-MM-DD).
type WeeklyPoint struct {
    WeekStart string `json:"week_start"`
    Documents int    `json:"documents"`
    Letters   int    `json:"letters"`
}

// CompletionRate is a completed/total ratio (Rate in [0,1]) over the window.
type CompletionRate struct {
    Total     int     `json:"total"`
    Completed int     `json:"completed"`
    Rate      float64 `json:"rate"`
}

// OpsMetrics bundles the operational KPIs. Pointer fields are nil (JSON null) when their
// source area is empty/unlicensed so the UI hides the card. Slices are [] when empty;
// weekly_volume always has 12 points; overdue/escalations are plain counts (workflow is
// core, so 0 is a meaningful "healthy" answer).
type OpsMetrics struct {
    LetterProcessing *DurationStat   `json:"letter_processing"`
    WorkflowApproval *DurationStat   `json:"workflow_approval"`
    TopSenderUnits   []UnitVolume    `json:"top_sender_units"`
    TopTargetUnits   []UnitVolume    `json:"top_target_units"`
    WeeklyVolume     []WeeklyPoint   `json:"weekly_volume"`
    OpenOverdueTasks int             `json:"open_overdue_tasks"`
    Escalations      int             `json:"escalations"`
    EsignCompletion  *CompletionRate `json:"esign_completion"`
}

// OpsStats is the operational KPI dashboard payload for a trailing window of days.
type OpsStats struct {
    GeneratedAt time.Time  `json:"generated_at"`
    WindowDays  int        `json:"window_days"`
    Metrics     OpsMetrics `json:"metrics"`
}
  • Add to the Repository interface: OpsStats(ctx context.Context, days int) (OpsStats, error).
  • Append the Service method:
// OpsStats returns the operational KPI aggregates over the trailing window. The generated-at
// stamp is applied here (the repo is a pure aggregator).
func (s *Service) OpsStats(ctx context.Context, days int) (OpsStats, error) {
    stats, err := s.repo.OpsStats(ctx, days)
    if err != nil {
        return OpsStats{}, err
    }
    stats.GeneratedAt = time.Now().UTC()
    return stats, nil
}
  • [ ] Step 2 — SQL. Edit go/internal/reporting/adapters/pg.go. Append the method (uses only the already-imported context, fmt, app, db):
// OpsStats computes the operational KPI aggregates over a trailing window of `days` days.
// Every query is READ-ONLY. Empty/unlicensed areas (correspondence, esign) leave the
// corresponding pointer nil (→ JSON null) so the UI hides that card. The weekly trend always
// returns 12 zero-filled buckets. Interval arithmetic uses make_interval(days => $1) so the
// window is a single bind parameter. Sized for demo data (<500ms); no caching (v1).
func (s *Store) OpsStats(ctx context.Context, days int) (app.OpsStats, error) {
    ex := s.db.Exec(ctx)
    out := app.OpsStats{
        WindowDays: days,
        Metrics: app.OpsMetrics{
            TopSenderUnits: []app.UnitVolume{},
            TopTargetUnits: []app.UnitVolume{},
            WeeklyVolume:   []app.WeeklyPoint{},
        },
    }

    // 1. Letter processing time (created → completed). A letter is "completed" when it reaches
    // a finalized status ('numbered' outbound / 'registered' inbound); updated_at is its last
    // state change — the completion instant (there is no dedicated completed_at column). Nil
    // when no completed letters in the window.
    {
        var avg, p90 float64
        var n int
        if err := ex.QueryRow(ctx,
            `SELECT
                 COALESCE(AVG(EXTRACT(EPOCH FROM (updated_at - created_at))), 0)::float8,
                 COALESCE(PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (updated_at - created_at))), 0)::float8,
                 COUNT(*)
               FROM letters
              WHERE status IN ('numbered', 'registered')
                AND updated_at >= created_at
                AND created_at >= now() - make_interval(days => $1)`, days).
            Scan(&avg, &p90, &n); err != nil {
            return app.OpsStats{}, fmt.Errorf("reporting ops letter processing: %w", err)
        }
        if n > 0 {
            out.Metrics.LetterProcessing = &app.DurationStat{AvgSeconds: avg, P90Seconds: p90, Samples: n}
        }
    }

    // 2. Workflow approval time (instance created → the 'approved' transition). The append-only
    // transition log pins the exact approval instant (instance.updated_at would drift if the
    // instance later moved on, e.g. approved → archived). Nil when no approvals in the window.
    {
        var avg, p90 float64
        var n int
        if err := ex.QueryRow(ctx,
            `SELECT
                 COALESCE(AVG(EXTRACT(EPOCH FROM (t.created_at - i.created_at))), 0)::float8,
                 COALESCE(PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (t.created_at - i.created_at))), 0)::float8,
                 COUNT(*)
               FROM workflow_transitions t
               JOIN workflow_instances i ON i.id = t.instance_id
              WHERE t.to_state = 'approved'
                AND t.created_at >= now() - make_interval(days => $1)`, days).
            Scan(&avg, &p90, &n); err != nil {
            return app.OpsStats{}, fmt.Errorf("reporting ops workflow approval: %w", err)
        }
        if n > 0 {
            out.Metrics.WorkflowApproval = &app.DurationStat{AvgSeconds: avg, P90Seconds: p90, Samples: n}
        }
    }

    // 3. Top 5 sender units — the org unit of each OUTBOUND letter's author, resolved through
    // the author's position assignment active now. LATERAL LIMIT 1 (earliest assignment) makes
    // a multi-position author deterministic; an author with no resolvable unit drops out.
    senderRows, err := ex.Query(ctx,
        `SELECT ou.name, COUNT(*) AS n
           FROM letters l
           JOIN LATERAL (
                SELECT p.org_unit_id
                  FROM position_assignments pa
                  JOIN positions p ON p.id = pa.position_id
                 WHERE pa.user_id = l.created_by
                   AND pa.valid_from <= now()
                   AND (pa.valid_to IS NULL OR pa.valid_to > now())
                 ORDER BY pa.valid_from
                 LIMIT 1
           ) sp ON true
           JOIN org_units ou ON ou.id = sp.org_unit_id
          WHERE l.direction = 'outbound'
            AND l.created_at >= now() - make_interval(days => $1)
          GROUP BY ou.name
          ORDER BY n DESC
          LIMIT 5`, days)
    if err != nil {
        return app.OpsStats{}, fmt.Errorf("reporting ops sender units: %w", err)
    }
    for senderRows.Next() {
        var uv app.UnitVolume
        if err := senderRows.Scan(&uv.Unit, &uv.Count); err != nil {
            senderRows.Close()
            return app.OpsStats{}, fmt.Errorf("reporting ops sender scan: %w", err)
        }
        out.Metrics.TopSenderUnits = append(out.Metrics.TopSenderUnits, uv)
    }
    if err := senderRows.Err(); err != nil {
        senderRows.Close()
        return app.OpsStats{}, err
    }
    senderRows.Close()

    // 4. Top 5 target units — the org unit each letter was dispositioned TO (disposisi targets
    // a position; the position belongs to an org unit). COUNT(DISTINCT letter_id) so several
    // assignments of one letter to one unit count once.
    targetRows, err := ex.Query(ctx,
        `SELECT ou.name, COUNT(DISTINCT la.letter_id) AS n
           FROM letter_assignments la
           JOIN positions p ON p.id = la.assignee_position_id
           JOIN org_units ou ON ou.id = p.org_unit_id
          WHERE la.assigned_at >= now() - make_interval(days => $1)
          GROUP BY ou.name
          ORDER BY n DESC
          LIMIT 5`, days)
    if err != nil {
        return app.OpsStats{}, fmt.Errorf("reporting ops target units: %w", err)
    }
    for targetRows.Next() {
        var uv app.UnitVolume
        if err := targetRows.Scan(&uv.Unit, &uv.Count); err != nil {
            targetRows.Close()
            return app.OpsStats{}, fmt.Errorf("reporting ops target scan: %w", err)
        }
        out.Metrics.TopTargetUnits = append(out.Metrics.TopTargetUnits, uv)
    }
    if err := targetRows.Err(); err != nil {
        targetRows.Close()
        return app.OpsStats{}, err
    }
    targetRows.Close()

    // 5. Weekly volume trend — 12 ISO weeks, zero-filled via generate_series so empty weeks
    // still return a bucket. documents + letters created per week (creation volume includes
    // since-deleted rows — an honest count of what was filed).
    weekRows, err := ex.Query(ctx,
        `WITH weeks AS (
             SELECT generate_series(
                 date_trunc('week', now()) - interval '11 weeks',
                 date_trunc('week', now()),
                 interval '1 week'
             ) AS wk
         )
         SELECT to_char(w.wk, 'YYYY-MM-DD'),
                (SELECT COUNT(*) FROM documents d WHERE date_trunc('week', d.created_at) = w.wk),
                (SELECT COUNT(*) FROM letters   l WHERE date_trunc('week', l.created_at) = w.wk)
           FROM weeks w
          ORDER BY w.wk`)
    if err != nil {
        return app.OpsStats{}, fmt.Errorf("reporting ops weekly volume: %w", err)
    }
    for weekRows.Next() {
        var wp app.WeeklyPoint
        if err := weekRows.Scan(&wp.WeekStart, &wp.Documents, &wp.Letters); err != nil {
            weekRows.Close()
            return app.OpsStats{}, fmt.Errorf("reporting ops weekly scan: %w", err)
        }
        out.Metrics.WeeklyVolume = append(out.Metrics.WeeklyVolume, wp)
    }
    if err := weekRows.Err(); err != nil {
        weekRows.Close()
        return app.OpsStats{}, err
    }
    weekRows.Close()

    // 6. Open overdue tasks (current state) + escalations in window. Workflow is core, so these
    // are always meaningful (0 = healthy). Overdue = still-pending, SLA set and passed.
    if err := ex.QueryRow(ctx,
        `SELECT COUNT(*) FROM workflow_tasks
          WHERE state = 'pending' AND sla_deadline IS NOT NULL AND sla_deadline < now()`).
        Scan(&out.Metrics.OpenOverdueTasks); err != nil {
        return app.OpsStats{}, fmt.Errorf("reporting ops overdue tasks: %w", err)
    }
    if err := ex.QueryRow(ctx,
        `SELECT COUNT(*) FROM workflow_escalations
          WHERE created_at >= now() - make_interval(days => $1)`, days).
        Scan(&out.Metrics.Escalations); err != nil {
        return app.OpsStats{}, fmt.Errorf("reporting ops escalations: %w", err)
    }

    // 7. E-sign envelope completion rate over the window. completed_at is set on finalize
    // (equivalent to status='completed'). Nil when no envelopes were created in the window.
    {
        var total, completed int
        if err := ex.QueryRow(ctx,
            `SELECT COUNT(*), COUNT(*) FILTER (WHERE completed_at IS NOT NULL)
               FROM esign_sign_envelopes
              WHERE created_at >= now() - make_interval(days => $1)`, days).
            Scan(&total, &completed); err != nil {
            return app.OpsStats{}, fmt.Errorf("reporting ops esign completion: %w", err)
        }
        if total > 0 {
            out.Metrics.EsignCompletion = &app.CompletionRate{
                Total: total, Completed: completed, Rate: float64(completed) / float64(total),
            }
        }
    }

    return out, nil
}
  • [ ] Step 3 — Handler. Edit go/internal/httpapi/handlers_admin.go. Add "strconv" to the import block, then append (next to ReportOverview):
// ReportOps returns the operational KPI dashboard aggregates over a trailing window
// (?days=, default 30, clamped to [1,365]). Same reporting.read gate as the other reports.
// Areas whose module is unlicensed/empty return JSON null (the UI hides the card).
func (s *Server) ReportOps(w http.ResponseWriter, r *http.Request) {
    days := 30
    if q := r.URL.Query().Get("days"); q != "" {
        if n, err := strconv.Atoi(q); err == nil && n > 0 {
            days = n
        }
    }
    if days < 1 {
        days = 1
    }
    if days > 365 {
        days = 365
    }
    stats, err := s.reporting.OpsStats(r.Context(), days)
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, stats)
}
  • [ ] Step 4 — Route. Edit go/internal/httpapi/server.go, after the /reports/overview line (~line 650):
            r.With(s.requirePerm("reporting.read")).Get("/admin/stats/ops", s.ReportOps)
  • [ ] Step 5 — OpenAPI path. Add under paths: (next to /api/v1/reports/overview):
  /api/v1/admin/stats/ops:
    get:
      operationId: reportOps
      summary: Operational KPI dashboard
      description: >-
        Returns operational KPIs over a trailing window (letter processing + workflow
        approval avg/p90, top sender/target units, 12-week volume trend, open overdue tasks,
        escalations, e-sign completion rate). Nullable metrics are null when their area is
        empty/unlicensed. Requires `reporting.read`.
      tags: [reporting]
      parameters:
        - name: days
          in: query
          required: false
          schema:
            type: integer
            default: 30
            minimum: 1
            maximum: 365
          description: Trailing window in days (default 30, clamped to 1..365).
      responses:
        '200':
          description: The operational KPIs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OpsStats'
        '401':
          $ref: '#/components/responses/Problem'
        '403':
          $ref: '#/components/responses/Problem'
  • [ ] Step 6 — OpenAPI schemas. Add under components: schemas::
    OpsDurationStat:
      type: object
      properties:
        avg_seconds: { type: number }
        p90_seconds: { type: number }
        samples: { type: integer }
      required: [avg_seconds, p90_seconds, samples]

    OpsUnitVolume:
      type: object
      properties:
        unit: { type: string }
        count: { type: integer }
      required: [unit, count]

    OpsWeeklyPoint:
      type: object
      properties:
        week_start: { type: string, description: Monday of the ISO week (YYYY-MM-DD). }
        documents: { type: integer }
        letters: { type: integer }
      required: [week_start, documents, letters]

    OpsCompletionRate:
      type: object
      properties:
        total: { type: integer }
        completed: { type: integer }
        rate: { type: number, description: completed / total, in [0,1]. }
      required: [total, completed, rate]

    OpsMetrics:
      type: object
      properties:
        letter_processing:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/OpsDurationStat' }]
          description: null when no completed letters in window (or correspondence unlicensed).
        workflow_approval:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/OpsDurationStat' }]
          description: null when no approvals in window.
        top_sender_units:
          type: array
          items: { $ref: '#/components/schemas/OpsUnitVolume' }
        top_target_units:
          type: array
          items: { $ref: '#/components/schemas/OpsUnitVolume' }
        weekly_volume:
          type: array
          items: { $ref: '#/components/schemas/OpsWeeklyPoint' }
          description: Always 12 points, oldest first.
        open_overdue_tasks: { type: integer }
        escalations: { type: integer }
        esign_completion:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/OpsCompletionRate' }]
          description: null when no envelopes in window (or esign unlicensed).
      required: [top_sender_units, top_target_units, weekly_volume, open_overdue_tasks, escalations]

    OpsStats:
      type: object
      properties:
        generated_at: { type: string, format: date-time }
        window_days: { type: integer }
        metrics: { $ref: '#/components/schemas/OpsMetrics' }
      required: [generated_at, window_days, metrics]
  • [ ] Step 7 — Regenerate + verify. cd web && npm run gen:api && npx tsc --noEmit; then cd go && go build ./... && go vet ./....
  • [ ] Step 8 — Commit:
git add go/internal/reporting/app/service.go go/internal/reporting/adapters/pg.go go/internal/httpapi/handlers_admin.go go/internal/httpapi/server.go api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(reporting): GET /admin/stats/ops KPI aggregate endpoint + OpenAPI"

Task 5: "Operations" section on the Reports page

Files: web/src/features/reports/data.ts, web/src/features/reports/OpsSection.tsx (new), web/src/features/reports/ReportsPage.tsx, web/src/features/reports/i18n.ts, web/src/styles/app.css.

Interface produced: an "Operations" section on /reports: KPI tiles (reusing StatTile), top-unit breakdown lists (reusing .breakdown), and a 12-week grouped-bar sparkline (CSS, no chart lib). Null cards are hidden. en/id.

  • [ ] Step 1 — Data hook. Edit web/src/features/reports/data.ts. Append:
export interface OpsDuration { avgSeconds: number; p90Seconds: number; samples: number }
export interface OpsUnit { unit: string; count: number }
export interface OpsWeek { weekStart: string; documents: number; letters: number }
export interface OpsCompletion { total: number; completed: number; rate: number }

export interface OpsData {
  generatedAt: string
  windowDays: number
  letterProcessing: OpsDuration | null
  workflowApproval: OpsDuration | null
  topSenderUnits: OpsUnit[]
  topTargetUnits: OpsUnit[]
  weeklyVolume: OpsWeek[]
  openOverdueTasks: number
  escalations: number
  esignCompletion: OpsCompletion | null
}

// Humanize a duration in seconds: "45m", "3.2h", "2d 4h".
export function fmtDuration(secs: number): string {
  if (secs < 60) return `${Math.round(secs)}s`
  const mins = secs / 60
  if (mins < 60) return `${Math.round(mins)}m`
  const hours = mins / 60
  if (hours < 24) return `${hours.toFixed(1)}h`
  const days = Math.floor(hours / 24)
  const remH = Math.round(hours - days * 24)
  return remH > 0 ? `${days}d ${remH}h` : `${days}d`
}

function dur(d: { avg_seconds?: number; p90_seconds?: number; samples?: number } | null | undefined): OpsDuration | null {
  return d ? { avgSeconds: d.avg_seconds ?? 0, p90Seconds: d.p90_seconds ?? 0, samples: d.samples ?? 0 } : null
}

export function useOps(days = 30) {
  return useQuery({
    queryKey: ['ops-stats', days],
    queryFn: async (): Promise<OpsData> => {
      const o = ok(await api.GET('/api/v1/admin/stats/ops', { params: { query: { days } } }))
      const m = o.metrics ?? {}
      const comp = m.esign_completion
      return {
        generatedAt: o.generated_at ?? '',
        windowDays: o.window_days ?? days,
        letterProcessing: dur(m.letter_processing),
        workflowApproval: dur(m.workflow_approval),
        topSenderUnits: (m.top_sender_units ?? []).map((u) => ({ unit: u.unit, count: u.count })),
        topTargetUnits: (m.top_target_units ?? []).map((u) => ({ unit: u.unit, count: u.count })),
        weeklyVolume: (m.weekly_volume ?? []).map((w) => ({ weekStart: w.week_start, documents: w.documents, letters: w.letters })),
        openOverdueTasks: m.open_overdue_tasks ?? 0,
        escalations: m.escalations ?? 0,
        esignCompletion: comp ? { total: comp.total, completed: comp.completed, rate: comp.rate } : null,
      }
    },
  })
}

(useQuery, api, ok are already imported at the top of data.ts.)

  • [ ] Step 2 — Section component. Create web/src/features/reports/OpsSection.tsx:
// The Operations KPI section of the Reports page: processing-time + completion tiles, top
// sender/target unit breakdowns, and a 12-week grouped-bar sparkline. All CSS primitives —
// no chart library. Cards with null metrics are hidden (core-only installs see only the doc
// trend + workflow health).
import { useTranslation } from 'react-i18next'
import { Time, CheckmarkOutline, WarningAlt, Send } from '@carbon/icons-react'
import { StatTile } from '@/components/StatTile'
import { useOps, fmtDuration, type OpsUnit } from './data'

function UnitList({ title, units }: { title: string; units: OpsUnit[] }) {
  if (units.length === 0) return null
  const max = Math.max(...units.map((u) => u.count), 1)
  return (
    <section className="page__section">
      <h2 className="page__section-title">{title}</h2>
      <div className="breakdown">
        {units.map((u) => (
          <div className="breakdown__row" key={u.unit}>
            <span>{u.unit}</span>
            <div className="breakdown__track">
              <div className="breakdown__fill" style={{ width: `${Math.round((u.count / max) * 100)}%` }} />
            </div>
            <span className="breakdown__value mono">{u.count}</span>
          </div>
        ))}
      </div>
    </section>
  )
}

export function OpsSection() {
  const { t } = useTranslation()
  const { data, isLoading, isError } = useOps(30)
  if (isError) return null
  if (isLoading || !data) return <p className="page__lead muted">{t('reports.ops.loading')}</p>

  const lp = data.letterProcessing
  const wa = data.workflowApproval
  const ec = data.esignCompletion
  const maxWeekly = Math.max(1, ...data.weeklyVolume.flatMap((w) => [w.documents, w.letters]))

  return (
    <>
      <section className="page__section">
        <h2 className="page__section-title">{t('reports.ops.title')}</h2>
        <p className="muted">{t('reports.ops.window', { days: data.windowDays })}</p>
        <div className="stat-grid">
          {lp && (
            <StatTile
              label={t('reports.ops.tile.letterAvg')}
              value={fmtDuration(lp.avgSeconds)}
              sub={t('reports.ops.tile.p90', { v: fmtDuration(lp.p90Seconds) })}
              icon={<Time size={16} />}
            />
          )}
          {wa && (
            <StatTile
              label={t('reports.ops.tile.approvalAvg')}
              value={fmtDuration(wa.avgSeconds)}
              sub={t('reports.ops.tile.p90', { v: fmtDuration(wa.p90Seconds) })}
              icon={<CheckmarkOutline size={16} />}
            />
          )}
          {ec && (
            <StatTile
              label={t('reports.ops.tile.esign')}
              value={`${Math.round(ec.rate * 100)}%`}
              sub={t('reports.ops.tile.esignSub', { completed: ec.completed, total: ec.total })}
              icon={<Send size={16} />}
            />
          )}
          <StatTile
            label={t('reports.ops.tile.overdue')}
            value={data.openOverdueTasks}
            sub={t('reports.ops.tile.escalations', { count: data.escalations })}
            icon={<WarningAlt size={16} />}
          />
        </div>
      </section>

      <section className="page__section">
        <h2 className="page__section-title">{t('reports.ops.trendTitle')}</h2>
        <div className="opsbars">
          {data.weeklyVolume.map((w) => (
            <div className="opsbars__col" key={w.weekStart} title={`${w.weekStart}: ${w.documents} / ${w.letters}`}>
              <div className="opsbars__bars">
                <div className="opsbars__bar opsbars__bar--docs" style={{ height: `${Math.round((w.documents / maxWeekly) * 100)}%` }} />
                <div className="opsbars__bar opsbars__bar--letters" style={{ height: `${Math.round((w.letters / maxWeekly) * 100)}%` }} />
              </div>
              <span className="opsbars__label mono">{w.weekStart.slice(5)}</span>
            </div>
          ))}
        </div>
        <div className="opsbars__legend">
          <span><i className="opsbars__swatch opsbars__swatch--docs" /> {t('reports.ops.legendDocs')}</span>
          <span><i className="opsbars__swatch opsbars__swatch--letters" /> {t('reports.ops.legendLetters')}</span>
        </div>
      </section>

      <UnitList title={t('reports.ops.senderTitle')} units={data.topSenderUnits} />
      <UnitList title={t('reports.ops.targetTitle')} units={data.topTargetUnits} />
    </>
  )
}

(Before implementing, confirm the icon names Time, WarningAlt, Send exist in @carbon/icons-react — if any is missing, substitute a present sibling, e.g. Timer, Warning, SendAlt.)

  • [ ] Step 3 — Mount it. Edit web/src/features/reports/ReportsPage.tsx: import import { OpsSection } from './OpsSection' and render <OpsSection /> just before the final closing </div> of the page (after the recent-activity section).

  • [ ] Step 4 — i18n. Edit web/src/features/reports/i18n.ts. Add an ops group inside reports: in BOTH en and id:

en:

    ops: {
      title: 'Operations',
      window: 'Last {{days}} days',
      loading: 'Loading operations…',
      tile: {
        letterAvg: 'Avg letter processing',
        approvalAvg: 'Avg approval time',
        p90: 'p90 {{v}}',
        esign: 'e-Sign completion',
        esignSub: '{{completed}} of {{total}} envelopes',
        overdue: 'Open overdue tasks',
        escalations: '{{count}} escalations',
      },
      trendTitle: 'Volume — last 12 weeks',
      legendDocs: 'Documents',
      legendLetters: 'Letters',
      senderTitle: 'Top sender units',
      targetTitle: 'Top target units',
    },

id:

    ops: {
      title: 'Operasional',
      window: '{{days}} hari terakhir',
      loading: 'Memuat operasional…',
      tile: {
        letterAvg: 'Rata-rata proses surat',
        approvalAvg: 'Rata-rata waktu persetujuan',
        p90: 'p90 {{v}}',
        esign: 'Penyelesaian e-Tanda tangan',
        esignSub: '{{completed}} dari {{total}} amplop',
        overdue: 'Tugas jatuh tempo terbuka',
        escalations: '{{count}} eskalasi',
      },
      trendTitle: 'Volume — 12 minggu terakhir',
      legendDocs: 'Dokumen',
      legendLetters: 'Surat',
      senderTitle: 'Unit pengirim teratas',
      targetTitle: 'Unit tujuan teratas',
    },
  • [ ] Step 5 — Styles. In web/src/styles/app.css add (dual-theme via var(--cds-*)):
/* ops KPI 12-week sparkline (reports) */
.opsbars { display: flex; align-items: flex-end; gap: 0.5rem; height: 8rem; padding-top: 0.5rem; }
.opsbars__col { flex: 1 1 0; display: flex; flex-direction: column; align-items: center; gap: 0.25rem; min-width: 0; }
.opsbars__bars { flex: 1; width: 100%; display: flex; align-items: flex-end; justify-content: center; gap: 2px; }
.opsbars__bar { width: 42%; min-height: 2px; }
.opsbars__bar--docs { background: var(--cds-interactive); }
.opsbars__bar--letters { background: var(--cds-support-info, #4589ff); opacity: 0.7; }
.opsbars__label { font-size: 0.625rem; color: var(--cds-text-secondary); white-space: nowrap; }
.opsbars__legend { display: flex; gap: 1rem; margin-top: 0.75rem; font-size: 0.75rem; color: var(--cds-text-secondary); }
.opsbars__swatch { display: inline-block; width: 0.75rem; height: 0.75rem; vertical-align: middle; margin-right: 0.25rem; }
.opsbars__swatch--docs { background: var(--cds-interactive); }
.opsbars__swatch--letters { background: var(--cds-support-info, #4589ff); opacity: 0.7; }
  • [ ] Step 6 — Verify: cd web && npx tsc --noEmit && npx vite build. Fix any missing-icon or smart-quote issues (rewrite i18n.ts whole with Write if quotes mangled).
  • [ ] Step 7 — Commit:
git add web/src/features/reports/data.ts web/src/features/reports/OpsSection.tsx web/src/features/reports/ReportsPage.tsx web/src/features/reports/i18n.ts web/src/styles/app.css
git commit -m "feat(web): Operations KPI section on Reports (tiles, 12-week sparkline, top units)"

Task 6: Deploy + curl e2e (controller drives this personally — NOT a subagent)

Prereqs: everything above merged to main and building.

  • [ ] Step 1 — Deploy: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. Dev-login director@obscura.local (host 38080) → TOKEN; GET /api/v1/me → assert enabled_modules == [ai, correspondence, esign, semantic, watermarking], capture the director user_id.

  • [ ] Step 2 — Prefs e2e (scratchpad dir; mailpit web API at http://localhost:8025/api/v1/messages, .../api/v1/search?query=). Pick ONE easy notification trigger and stay with it (recommended: document-follow — the emitter is document.version_added, category document):

  • (a) Baseline default (instant): ensure the director has no prefs row (or PUT defaults). Subscribe the director to a scratch document, then perform the trigger action (add a new version). Wait ~5s for the outbox-relay worker. Assert: a new in-app row appears (GET /api/v1/notifications shows a document-kind entry) and a new email lands in mailpit.
  • (b) Muted category: PUT /api/v1/me/notification-prefs {email_enabled:true, frequency:"instant", muted_categories:["document"]}. Note baseline in-app + mailpit counts. Trigger again, wait ~5s. Assert: NO new document in-app row and NO new mailpit email (muted → skipped on both channels).
  • (c) Daily suppresses instant email: PUT {email_enabled:true, frequency:"daily", muted_categories:[]}. Trigger again, wait ~5s. Assert: a new in-app row DOES appear, but NO new mailpit email (instant suppressed; the daily digest sweep would email it on the 24h tick — not asserted inline, no manual-trigger endpoint exists).
  • (d) Validation: PUT with frequency:"weekly" → expect 400; PUT with muted_categories:["bogus"] → expect 400.

  • [ ] Step 3 — Ops e2e: GET /api/v1/admin/stats/ops?days=30200. Assert: window_days == 30, metrics.weekly_volume length == 12, and values sane vs demo data (open_overdue_tasks/escalations are integers ≥ 0; letter_processing/esign_completion are either null or objects with samples/total ≥ 0). Spot-check ?days=1 and ?days=999 (clamped to 365) return 200.

  • [ ] Step 4 — Cleanup: reset the director prefs to defaults (PUT {email_enabled:true, frequency:"instant", muted_categories:[]} — or delete the row directly in Postgres if a reset must be pristine); delete + purge the scratch document and remove the subscription; SELECT count(*) confirms zero e2e residue. Confirm 5 modules still intact.

  • [ ] Step 5 — Final commit of any e2e-driven fixes; report the commit list + assertions that passed.


Self-review notes (done at plan time)

  • Spec coverage:
  • §1 table (00081_notification_prefs, exact columns + frequency CHECK + muted_categories text[] default '{}' + FK to users(id) cascade) → T1 Step 1. Absent row = defaults → DefaultPrefs() in PrefsService.Get + Notify (T1 Steps 2-4).
  • §1 categories enumerated from real notify usage → the 5 real slugs (workflow, document, document_expiry, records_disposition, meterai_quota), documented in domain/prefs.go (T1 Step 2) and the OpenAPI enum (T2 Step 4).
  • §1 enforcement (muted → skip both; email off → in-app only; daily → suppress instant, digest keeps it; off → no email) → single choke point Notify (T1 Step 4d) + the new notify.daily_digest sweep (T1 Steps 7-8). All 3 existing sweeps + 2 relay cases inherit enforcement free (they call Notify).
  • §1 API GET/PUT /me/notification-prefs, self-only, PUT validates enum + slugs, OpenAPI + gen:api → T2.
  • §1 /profile "Notifications" panel, per-category toggles + email toggle + frequency select, en/id → T3.
  • §1 best-effort read posture → Notify swallows prefs errors → defaults, logs (T1 Step 4d).
  • §2 endpoint GET /admin/stats/ops?days=30, reporting.read gate, all 7 aggregates, {generated_at, window_days, metrics{...}}, explicit nulls, real SQL → T4. UI Operations section on the existing Reports page, tiles + trend (CSS sparkline, no dep), hide null cards, en/id → T5. <500ms/no-cache honored (single indexed statements; correlated week subqueries acceptable at demo scale — noted).
  • Type consistency: Go OpsStats/OpsMetrics/DurationStat/UnitVolume/WeeklyPoint/CompletionRate defined once (T4 Step 1), produced by the adapter (T4 Step 2), serialized snake_case, mirrored by the OpenAPI schemas (T4 Step 6) → regenerated schema.ts → consumed by useOps (T5 Step 1). domain.Prefs defined once (T1 Step 2), produced by PrefsService/PrefsStore (T1 Steps 3, 6), surfaced by handlers (T2 Step 1) ↔ NotificationPrefs OpenAPI schema (T2 Step 4) ↔ useNotifPrefs (T3 Step 1). NOTIF_CATEGORIES (web) mirrors domain.KnownCategories() (go) — both list the same 5 slugs.
  • Placeholder scan: no TBD/TODO/placeholder — full Go for prefs store, Notify enforcement, digest sweep, and all 7 KPI SQL statements; full TSX for both panels. Two flagged "confirm-at-implementation" items are library-name checks, not gaps: (i) @carbon/icons-react icon names in OpsSection (Time/WarningAlt/Send); (ii) that analyzeUpload-style truthiness handles JSON null (it does — optional props are T | null).
  • Known judgment calls / spec assumptions (see report): letter "completed" = status IN ('numbered','registered') with updated_at as the completion instant (no dedicated column); workflow approval time measured from the append-only workflow_transitions row to_state='approved' (successful approvals only); sender unit = author's current position→org_unit (outbound only), target unit = disposisi target position→org_unit (letters carry no direct org-unit column); notification_prefs.user_id is uuid FK→users(id) cascade per the explicit PK/FK instruction (the sibling notifications table is text/no-FK, but the team lead specified FK); the daily digest is a NEW sweep (notify.daily_digest, 24h, 24h lookback window aligned to cadence).