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 iscd 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:apiafter ANYapi/openapi.yamledit (regeneratesweb/src/api/schema.ts). Run it fromweb/. Commit the regeneratedschema.tswith the task.- NO new npm dependencies (
npm installis 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-logindirector@obscura.local, host port 38080). - Commit per task on
main, do NOT push unless asked. NEVERgit add -A(go/obscura-serveris a tracked ELF binary) — alwaysgit addexplicit paths. - i18n en/id parity (tsc-enforced): feature-co-located
web/src/features/{profile,reports}/i18n.tsslices (merged byweb/src/i18n/locales/{en,id}.ts), nested groups,enandididentical in shape. Smart-quote gotcha: the Edit tool can mangle’/“”; after editing an i18n file verifynpx tsc --noEmitand 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-appInsertfirst (source of truth), then best-effort channel fan-out. Only channel today isSMTPChannel(adapters/smtp.go,Name()=="email"). - The 5 real category slugs (=
Message.Kindvalues passed toNotify): workflow—wire.go:517(workflow.task_assigned)document—wire.go:560(document.version_added, followed-doc)document_expiry—jobs.go:96(daily expiry reminder)records_disposition—jobs.go:177(daily records-due sweep)meterai_quota—jobs.go:326(daily e-Meterai low-quota warning)- Scheduler pattern:
schedulerSvc.Register(name, interval, fn)(wire.go:296-481); daily jobs use24*time.Hourand noTriggerNow(e.g.ai.chat_retentionatwire.go:479). NewServicecallers: productionwire.go:243; testsnotify/adapters/pg_test.go:75,118,144,169callapp.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 usess.db.Exec(ctx)(reporting/adapters/pg.go), only implementor ofapp.Repositoryis*Store(no test fake → safe to extend the interface). - Reports page:
web/src/features/reports/ReportsPage.tsxusesStatTile(@/components/StatTile) +.stat-grid+.breakdown__track/__fillbars (no chart lib). Route/reportsexists (App.tsx:68). - Profile:
web/src/features/profile/ProfilePage.tsx(sections + local sub-components likeEditNameModal), owni18n.tsslice,data.tsusesapi.GET/PUT+ok(). Route/profileexists (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 scanstext[]↔[]stringnatively; a Go string param binds to auuidcolumn 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=false → in-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. Editgo/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 —
ListForUserSinceonStore. Editgo/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-importedcontext,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 existingnotify/adapters/pg_test.goNewService(...)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
Depsstruct (nearNotify *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
/megroup (afterr.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 underpaths:next to the/api/v1/me/signaturesblock (~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 toReportingOverview):
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:apirunsopenapi-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 existinguseSignatures/useCreateSignaturepattern withapi.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/reactimport to includeToggle,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
EditNameModaletc.):
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 anotifgroup insideprofile:in BOTHenandid(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.cssadd 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 ini18n.tsbroke 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
Repositoryinterface: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-importedcontext,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 toReportOverview):
// 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/overviewline (~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; thencd 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: importimport { 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 anopsgroup insidereports:in BOTHenandid:
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.cssadd (dual-theme viavar(--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-logindirector@obscura.local(host38080) →TOKEN;GET /api/v1/me→ assertenabled_modules == [ai, correspondence, esign, semantic, watermarking], capture the directoruser_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 isdocument.version_added, categorydocument): - (a) Baseline default (instant): ensure the director has no prefs row (or
PUTdefaults). 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/notificationsshows adocument-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 newdocumentin-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:
PUTwithfrequency:"weekly"→ expect 400;PUTwithmuted_categories:["bogus"]→ expect 400. -
[ ] Step 3 — Ops e2e:
GET /api/v1/admin/stats/ops?days=30→ 200. Assert:window_days == 30,metrics.weekly_volumelength== 12, and values sane vs demo data (open_overdue_tasks/escalationsare integers ≥ 0;letter_processing/esign_completionare eithernullor objects withsamples/total≥ 0). Spot-check?days=1and?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 tousers(id)cascade) → T1 Step 1. Absent row = defaults →DefaultPrefs()inPrefsService.Get+Notify(T1 Steps 2-4). - §1 categories enumerated from real
notifyusage → the 5 real slugs (workflow, document, document_expiry, records_disposition, meterai_quota), documented indomain/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 newnotify.daily_digestsweep (T1 Steps 7-8). All 3 existing sweeps + 2 relay cases inherit enforcement free (they callNotify). - §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 →
Notifyswallows prefs errors → defaults, logs (T1 Step 4d). - §2 endpoint
GET /admin/stats/ops?days=30,reporting.readgate, 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/CompletionRatedefined once (T4 Step 1), produced by the adapter (T4 Step 2), serialized snake_case, mirrored by the OpenAPI schemas (T4 Step 6) → regeneratedschema.ts→ consumed byuseOps(T5 Step 1).domain.Prefsdefined once (T1 Step 2), produced byPrefsService/PrefsStore(T1 Steps 3, 6), surfaced by handlers (T2 Step 1) ↔NotificationPrefsOpenAPI schema (T2 Step 4) ↔useNotifPrefs(T3 Step 1).NOTIF_CATEGORIES(web) mirrorsdomain.KnownCategories()(go) — both list the same 5 slugs. - Placeholder scan: no TBD/TODO/placeholder — full Go for prefs store,
Notifyenforcement, 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-reacticon names inOpsSection(Time/WarningAlt/Send); (ii) thatanalyzeUpload-style truthiness handles JSONnull(it does — optional props areT | null). - Known judgment calls / spec assumptions (see report): letter "completed" =
status IN ('numbered','registered')withupdated_atas the completion instant (no dedicated column); workflow approval time measured from the append-onlyworkflow_transitionsrowto_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_idisuuidFK→users(id)cascade per the explicit PK/FK instruction (the siblingnotificationstable istext/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).