Merge branch 'feat/notifications-fullstack' into 'main'

feat(notifications): TODO 7 — draft decision toast + email + bell badge

See merge request 2-6/2-6-4/terravault/ordinis!190
This commit is contained in:
Александр Зимин
2026-05-14 14:40:35 +00:00
21 changed files with 1992 additions and 82 deletions
+1
View File
@@ -22,3 +22,4 @@ HELP.md
*.pem
*.key
.gstack/
.claude/
+353
View File
@@ -0,0 +1,353 @@
# DESIGN.md — Ordinis Admin UI Design System
**Status:** v0 baseline (2026-05-14). Captured from observed patterns в
codebase after approval workflow saga (MR !179!190). To be refined через
`/design-consultation` skill in a dedicated sprint.
**Goal:** Lock the implicit design system как source of truth для будущих
features (Phase 4 webhook approvals, notifications surfaces, MDM expansion).
Document invariants (tokens, primitives, semantic rules) — NOT specific
components.
---
## 1. Voice & Tone
- **Russian-first.** UI primary language ru-RU; en-US is parallel mirror.
Every user-facing string registered in `src/i18n.ts`.
- **Operational, not aspirational.** Copy describes what user sees / does
("Очередь чистая", "Reject требует comment") — no marketing-speak.
- **Domain-precise.** Use exact role names (maker / reviewer / publisher)
and operation codes (CREATE / UPDATE / CLOSE) — no generic "user".
- **Apologize on failure.** Empty states + error messages name what went
wrong и what to do next ("Refresh page + retry", "Укажите причину").
---
## 2. Color Tokens
CSS variables defined в `src/styles.css`. Three themes: `default` (light
Anthropic warm), `nstart` (medium contrast brand), `dark` (Anthropic dark).
### Semantic roles
| Token | Light | Dark | Use |
|---|---|---|---|
| `--color-ink` | `#1a1714` | `#f0ece4` | Primary text |
| `--color-ink-2` | `#52483d` | `#c8c1b3` | Secondary text |
| `--color-mute` | `#8c8276` | `#8a8474` | Tertiary text / placeholders |
| `--color-bg` | `#faf9f5` | `#1c1a17` | App background |
| `--color-surface` | `#ffffff` | `#262421` | Card / panel surface |
| `--color-surface-2` | `#f5f3ec` | `#2e2b27` | Hover / nested surface |
| `--color-line` | `#e6e1d4` | `#3a3733` | Dividers / borders |
| `--color-line-2` | `#efeae0` | `#2d2a27` | Subtle dividers |
| `--color-accent` | `#b05a2e` | `#d97757` | Primary CTA / links |
| `--color-accent-bg` | accent @ 11% | accent @ 16% | Accent tint |
| `--color-on-accent` | `#ffffff` | `#1c1a17` | Text on accent |
| `--color-green` | `#4f7038` | `#4a7d4f` | Success / APPROVED |
| `--color-green-bg` | `#e4ead0` | `#dde9da` | Success tint |
| `--color-warn` | `#a8762a` | `#b97c2f` | Warning / CHANGES_REQUESTED |
| `--color-warn-bg` | `#f6e8bc` | `#fcecc7` | Warning tint |
| `--color-pink` | `#a64636` | `#b34d6a` | Danger / REJECTED |
| `--color-pink-bg` | `#f4d9cd` | `#f8d8de` | Danger tint |
### Rules
- **Never hard-code hex.** Use `bg-line`, `text-mute`, `border-accent` через
Tailwind utilities mapped to these tokens.
- **Status colors are stable across themes.** `success` always green-family;
`danger` always pink-family.
- **Tint backgrounds (`*-bg`) use 11-16% alpha** — for badge backgrounds
and subtle highlights. Never tint at higher opacity (loses contrast).
---
## 3. Typography
### Font families
| Token | Stack | Use |
|---|---|---|
| `--font-sans` | `Inter, -apple-system, BlinkMacSystemFont, sans-serif` | Body, UI |
| `--font-mono` | `JetBrains Mono, SF Mono, monospace` | Code, UUIDs, business keys, versions |
| `--font-display` | `Tektur, Inter, sans-serif` | Caps headers, brand mark |
### Type scale (Tailwind utilities)
| Utility | Use |
|---|---|
| `text-title-md` | Section headings (Panel title) |
| `text-body` | Body copy |
| `text-cell` | Table cell, dense info |
| `text-cap` | Caps tracking-wide labels (column headers) — uses `--font-display` |
| `text-mono` | Code / monospaced |
### Rules
- **Mono для identity:** UUIDs (truncated `slice(0,8)…`), business keys,
version strings (`v1.1.0`). Reads as "this is an exact identifier".
- **Display caps для column headers:** `text-cap text-mute font-display
uppercase tracking-wider` — visual hierarchy через caps, not size.
- **`tabular-nums` для timestamp + count columns** — prevents jitter on
refresh.
- **Never use system-ui / -apple-system as primary display font.** Inter
is fallback only.
---
## 4. Spacing & Layout
### Base scale
Tailwind default 4px scale (`gap-1` = 4px, `gap-2` = 8px, `gap-3` = 12px,
`gap-4` = 16px). Avoid arbitrary spacing values (`mt-[13px]`) — use scale
or extend tokens.
### Layout primitives
- **Page chrome:** `<PageHeader title description>` for top of every route
- **Content area:** `space-y-4` (16px) between top-level sections
- **Panel:** `<Panel>` wraps tables / forms with `bg-surface border border-line`
- **Card:** `<Card>` для individual content blocks (`bg-surface-2`)
- **Drawer:** Right-side slide-in for detail views (review draft, schema
draft, history)
- **Modal:** Centered overlay для destructive confirmations + create flows
### Drawer-vs-Modal rules
| Pattern | Use Drawer | Use Modal |
|---|---|---|
| Review existing entity | ✓ | |
| Read-only history | ✓ | |
| Diff side-by-side | ✓ (max-w-3xl) | |
| Create new entity | | ✓ |
| Destructive confirmation | | ✓ (size=sm) |
| Maker action requiring focus | | ✓ |
Drawer = "look at this thing, maybe act on it". Modal = "do this thing now,
then dismiss".
### Width caps
- Drawer: `max-w-md` (notifications), `max-w-xl` (schema draft), `max-w-3xl`
(record review with diff panes)
- Modal: `size="sm"` (confirm), `"md"` (form), `"xl"` (JSON editor)
- Body copy: implicit via Panel padding; for description text apply
`max-w-prose` если viewport wide
---
## 5. Component Primitives
Locations: `src/ui/components/*.tsx`. Re-exported via `@/ui`.
### Stable primitives (use freely)
- `<Button variant size>` — `primary | secondary | ghost | danger`, `sm | md | lg`
- `<Badge variant>` — `info | success | warning | danger | default | primary | outline`
- `<Panel>` — surface wrapper для table/form blocks
- `<Card>` — content block (lighter surface than Panel)
- `<Drawer isOpen onClose title side widthClassName>` — slide-in detail view
- `<Modal isOpen onClose title size>` — centered overlay
- `<Table TableHead TableRow TableHeaderCell TableBody TableCell>` — tabular data
- `<Tabs items value onValueChange>` — Radix-based tabs with badge slot
- `<EmptyState icon title description action>` — no-data fallback
- `<Alert variant title>` — banner with `info | success | warning | error`
- `<LoadingBlock size label>` — skeleton / spinner
- `<QueryErrorState error onRetry>` — generic 4xx/5xx state с retry
- `<UserCell uuid>` — resolves sub → display name (three-tier cache)
- `<TextInput TextArea Checkbox Select MultiSelect>` — form inputs
### Domain-specific (in `src/components/`)
- `<ReviewDrawer>` — record draft review (records tab + Мои tab)
- `<SchemaDraftDrawer>` — schema draft review (dict page + Мои tab redirect)
- `<DictionaryEditorDialog>` — schema-aware editor с approval flow
- `<MyDraftsPanel>` — Мои tab on /reviews
- `<NotificationsDrawer>` + `<NotificationsBell>` — TODO 7 notification surfaces
- `<ChangelogDiffModal>` — historical schema diff viewer
- `<TimeTravelModal>` — record at point-in-time
---
## 6. Badge Variant Mapping
Visual category по semantic role, applied везде через `<Badge variant=…>`:
### Operations (DraftOperation)
| Op | Variant | Color | Reason |
|---|---|---|---|
| CREATE | success | green | Net-new record — positive change |
| UPDATE | info | accent | Most common; neutral default |
| CLOSE | warning | warn | Soft-delete; destructive but reversible |
### Record draft status (DraftStatus)
| Status | Variant | Reason |
|---|---|---|
| PENDING | info | Awaiting reviewer action |
| APPROVED | success | Terminal positive |
| REJECTED | danger | Terminal negative |
| WITHDRAWN | default | Maker-initiated cancel, neutral |
### Schema draft status (SchemaDraftStatus)
| Status | Variant | Reason |
|---|---|---|
| draft | warning | Pre-submit, maker action pending |
| review_pending | info | Reviewer queue |
| approved | success | Awaiting publish |
| changes_requested | warning | Bounced to maker |
| rejected | danger | Terminal negative |
| published | primary | Terminal live (strongest signal) |
| withdrawn | default | Maker cancel |
### Notification event type
| Pattern in eventType | Variant |
|---|---|
| `*Approved`, `*Published` | success |
| `*Rejected`, `*Withdrawn` | danger |
| `*Submitted`, `*ChangesRequested` | info |
| other | default |
---
## 7. Code / Diff Visual Language
### Live vs proposed panes
- **Live (current state):** `bg-line/30` background — muted, "established"
- **Proposed (draft):** `bg-accent/4` background — accent-tinted, "your suggestion"
- **CLOSE op note:** `text-aurora italic` — call-out пустой proposed pane
### Diff summary chips (above panes)
- `+N added`: text-aurora (green-family) with `+` prefix
- `M removed`: text-danger with `` prefix
- `~K changed`: text-warn with `~` prefix
- Bucket details в `text-mute lowercase` parens
Rendered via `shallowDiffSummary()` from `src/lib/diff.ts`.
---
## 8. Interaction States
### Required for all interactive elements
- `hover:bg-line/30` или similar hover affordance
- `focus-visible:ring-2 focus-visible:ring-ring` focus ring (NEVER
`outline: none` без replacement)
- `disabled:opacity-50 disabled:cursor-not-allowed` для disabled
- Touch target ≥ 44×44 — use `min-h-[44px]` for icon buttons (current gap;
audit findings P1 in TODOS.md TODO 6)
### Success / error affordances
- **Mutation success:** прокачка через `toast.success()` (sonner) — auto-dismiss 4s
- **Mutation error:** `toast.error()` с structured message via
`extractReviewError()` / `humanizeBulkReason()` mappings
- **In-form validation:** inline error under input с `role="alert"` +
`aria-invalid` + `aria-describedby`
- **Approve/reject decision flash:** 600ms bg-green-bg/30 или bg-pink-bg/30
band на drawer footer перед `onClose()` — ack ack для reviewer
### Empty states (warmth required)
Every `<EmptyState>` MUST have:
- `icon` — phosphor duotone, size 28-32
- `title` — outcome-framed ("Очередь пуста", not "No results")
- `description` — actionable hint (what to do next, where data appears)
- `action` (optional) — Link / Button to relevant route
---
## 9. Term Glossary (canonical)
| Code | EN | RU | Definition |
|---|---|---|---|
| maker | maker | автор | User who creates / submits a draft |
| reviewer | reviewer | ревьюер | User who approves / rejects drafts |
| publisher | publisher | публикатор | User who publishes approved schema drafts |
| draft | draft | черновик | Pending change awaiting approval |
| pending | pending | на ревью | Status: awaiting reviewer decision |
| approved | approved | одобрено | Status: reviewer approved (record published OR schema awaits publish) |
| rejected | rejected | отклонён | Status: reviewer rejected (terminal) |
| withdrawn | withdrawn | отозван | Status: maker cancelled (terminal) |
| WIP | in progress | в работе | Schema draft pre-submit OR changes_requested |
| decided | decided | закрыты | Any terminal status — APPROVED + REJECTED + WITHDRAWN |
| changes requested | changes requested | запрошены правки | Reviewer bounced schema draft back to maker |
| published | published | опубликован | Schema draft → live (terminal positive) |
Source: backend domain types (DraftStatus, SchemaDraftStatus, DraftOperation).
NEVER use synonyms ("submitter" for maker, "validator" for reviewer) —
breaks consistency with audit log + RBAC roles.
---
## 10. RBAC & Role Visibility
Backend gates (single source of truth). Frontend hide UI elements when
roles missing — backend still enforces if frontend leaks.
| Composite role | Members | UI surface |
|---|---|---|
| `ordinis:reviewer` | `schema:reviewer` + `record:reviewer` | /reviews tabs (Records, Schemas) — approve/reject buttons |
| (maker is implicit) | any authenticated user | /reviews Мои tab, draft create modals, dict editor |
Identity check pattern (in components):
```ts
const auth = useAuth()
const currentSub = auth.user?.profile?.sub
const isMine = draft.makerId === currentSub
```
For role gating use `usePermissions()` (src/auth/usePermissions.ts).
---
## 11. AI Slop Anti-Patterns (banned)
Per /design-review gstack skill plus our own observations:
- ❌ Purple/violet/indigo gradient backgrounds
- ❌ 3-column feature grid with icon-in-circle headers
- ❌ Decorative blobs / wavy SVG dividers / floating circles
- ❌ Emoji as design elements (rocket in heading)
- ❌ Colored left-border-only cards
- ❌ Generic hero copy ("Welcome to Ordinis", "Unlock the power…")
- ❌ Bubbly uniform `rounded-full` on every element
- ❌ Centered everything (`text-align: center` on all body content)
- ❌ system-ui / -apple-system as PRIMARY display font
Pass test: every visual element should be there because it does a job, not
because empty space looks empty.
---
## 12. Open Items (gaps to address in /design-consultation sprint)
- Responsive breakpoint policy (mobile 375, tablet 768, desktop 1024,
wide 1440 — confirm + lint)
- Touch target enforcement (P1 in TODO 6 a11y findings)
- Loading state taxonomy (skeleton vs spinner vs LoadingBlock vs Suspense
— when to use which)
- Motion duration policy (currently ad-hoc — set 150ms quick / 300ms medium
/ 500ms entrance)
- Long-content overflow rules (truncate vs wrap vs ellipsis vs scroll —
per surface type)
- Dark mode parity audit (some tokens have alpha inconsistency between
themes)
- Notifications channel UI policy (in-app vs email vs both — currently
every event goes to all enabled channels; should be user-configurable)
---
## Changelog
- **2026-05-14 v0** — Initial document. Captured tokens, components, badge
semantics, term glossary, drawer-vs-modal rules from approval workflow
saga codebase. Marked open items для full `/design-consultation`
sprint. Authored as part of TODO 5 closeout.
+93 -69
View File
@@ -28,90 +28,114 @@ Closed in [MR !189](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/merg
---
## 5. DESIGN.md for the admin UI
## 5. DESIGN.md for the admin UI ✅ SHIPPED v0 baseline
**What.** Run `/design-consultation` skill or stub DESIGN.md manually to lock
the implicit design system: drawer-vs-modal usage rules, term glossary
(maker / reviewer / publisher and their Russian counterparts), Badge variant
mapping (CREATE → success, UPDATE → info, CLOSE → warning — already used
consistently but undocumented), code panes pattern (live = `bg-line/30`,
proposed = `bg-accent/4`).
Authored as `DESIGN.md` at repo root (2026-05-14). 12 sections covering:
voice & tone, color tokens (3 themes), typography, spacing/layout, component
primitives, badge variant mapping (operations / record status / schema status /
notifications), code/diff visual language, interaction states, term glossary,
RBAC, AI slop anti-patterns, open items для future /design-consultation sprint.
**Why.** Pass 5 rated design system alignment 4/10. The system *exists* — design
tokens are used consistently — but it's tribal knowledge. Next developer building
a feature like "Phase 4 webhook approvals" will guess and either match by luck
or drift.
**Pros.** Half-day work. Pays off on every new feature. Source of truth for
`/plan-design-review` calibration on future plans.
**Cons.** Documentation has to be maintained or it lies. Mitigated by keeping
DESIGN.md to invariants (tokens, primitives, semantic rules), not specific
components.
**Context.** User chose "defer to /design-consultation sprint" instead of
stubbing inline. The decision is to do it properly when the sprint happens,
not piecemeal during this review.
**Depends on / blocked by.** Nothing.
Full /design-consultation skill run deferred — v0 captures observed patterns
as fact-based source of truth; later sprint can refine policy gaps listed in
Section 12 (responsive breakpoints, motion durations, loading state taxonomy,
etc.).
---
## 6. A11y audit post-deploy via `/design-review`
## 6. A11y audit post-deploy via `/design-review` ⏳ Punch list captured 2026-05-14
**What.** After the in-flight approval workflow MRs deploy to staging, run
`/design-review` skill against the live `/reviews` page + DictionaryEditorDialog
flow. The skill does Lighthouse, axe, screen reader walk-through, contrast
audit, keyboard navigation testing.
Ran focused live audit on https://ordinis.k8s.265.nstart.cloud/reviews (staging
v2.25.x, anonymous + read-only state). Findings captured below; fix loop deferred
to standalone polish MR.
**Why.** Pass 6 rated responsive/a11y 5/10. Specific suspects:
- Button heights not verified at 44px touch target.
- `text-mute` contrast against `bg-surface` — might fail WCAG AA 4.5:1.
- Reject reason TextArea has no specified `minHeight`.
- Bulk action toolbar wrap behavior at 375px.
- Diff `<pre>` panes have no aria-label — screen reader reads JSON literally.
- Keyboard tab order through long diff panes — reviewer may have to tab 50 times
to reach approve buttons.
### P0 — Severe
**Pros.** Necessary for compliance contexts (gov contracts, accessibility laws).
Catches things static review misses (real screen reader, real touch device).
- **A11y-1: Bell badge "9+" lacks contextual aria-label.** Screen reader reads
literal "9+" without "unread notifications" context. The `<span>` carrying
the digit needs its own `aria-label={t('notifications.unreadCount', {count})}`.
TopBar.tsx NotificationsBell. (Already addressed in MR !190 — verify in deploy.)
- **A11y-2: i18n leak — "СВЕРНУТЬ" hardcoded RU.** When EN locale active
(sidebar reads Home/Dictionaries/Search), sidebar collapse button at bottom
still shows " СВЕРНУТЬ". Either missing translation key or hardcoded label.
Find in `Sidebar.tsx` near collapse handler.
**Cons.** Requires the in-flight MRs to be deployed first. Cannot do in
plan mode.
### P1 — High
**Context.** User chose "defer to /design-review post-deploy". The audit will
produce its own punch list with severity ratings.
- **A11y-3: Touch targets below WCAG 44×44 minimum.** Measured on staging:
- Bell button: 28×28
- Theme switches (Light/Dark/System): 28×26
- Lang switch button "EN": 37×28
- Sidebar nav items: ~32px tall
- Tabs: 78×40 (Records) and 112×40 (Schemas) — height below 44
- "Open →" action link in schema queue row: ~50×18 (text link only)
Mobile/touch use is uncomfortable. Add `min-h-[44px]` to icon buttons in
TopBar + Sidebar footer. Tab height needs `h-11` instead of `h-10`.
- **A11y-4: UserCell raw UUID fallback ugly when fetch fails.** `77ec2cc8`
shown literally in AUTHOR column when the user-display lookup 401s (anon)
or returns empty. Fall back to `t('user.unknown')` ("Неизвестный пользователь")
instead of UUID slice.
- **A11y-5: No `aria-live` on tab panel.** Switching Records → Schemas → Mine
doesn't announce content change to screen readers. Add
`aria-live="polite"` on the panel container or rely on Radix `<Tabs.Content>`
which already manages focus.
**Depends on / blocked by.** MRs !179, !180, !182, !183, !184, !185 deployed
to staging.
### P2 — Medium
- **A11y-6: Radix Tabs inline `style="outline:none"`.** Tablist has
`style="outline: none"` inline that defeats default focus ring. Verify
`:focus-visible` styling kicks in for keyboard nav — if not, remove the
inline override or add a Tailwind focus ring.
- **A11y-7: Anonymous Mine tab handling.** Need to verify what happens for
unauthenticated user clicking Mine — should redirect to /sign-in or render
"Sign in to see your drafts" empty state (current behavior likely error
toast or blank query).
- **A11y-8: Description text has no max-width.** "Pending drafts from makers
awaiting…" body copy stretches to viewport edge on wide screens (>1440px).
Apply `max-w-prose` for readable measure (45-75 chars).
### P3 — Polish
- **A11y-9: Column header tracking-wide ALL CAPS** ("DICTIONARY", "FROM
VERSION", "AUTHOR") creates uneven scan rhythm against lowercase data
rows. Verify against design tokens — likely intentional cap-style but
worth a visual review at narrow widths.
- **A11y-10: "Open →" link should be Button.** It's a text Link in Action
column; better as a `Button variant="ghost"` to align with the
existing Records tab "Review" CTA pattern.
### Status
Punch list compiled. Fix loop deferred — group into separate "a11y polish"
MR after current notifications work (!190) lands. Most fixes are 2-5 LOC
each except touch-target sweep which touches 5+ components.
---
## 7. Notifications service: draft decision toast + email
## 7. Notifications service: draft decision toast + email ⏳ MR !190 pending
**What.** Notifications service subscribes to outbox `RecordDraftApproved /
RecordDraftRejected / RecordDraftWithdrawn` events (now fired thanks to
MR !184) and:
Closed in [MR !190](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/merge_requests/190).
1. Fires email to maker with reviewer comment.
2. Sets a flag (DB or Redis) so on next login the admin UI shows a toast:
"Your draft for `test1/BK-42` was approved by reviewer X. The record is
now live."
3. Bell icon in the header gains a badge counter for unread decisions.
**Backend** (ordinis-notifications + ordinis-rest-api + ordinis-app):
- `NotificationsDispatcher` — mirrors WebhookDispatcher pattern, reads outbox
events of type RecordDraftApproved/Rejected/Withdrawn/Submitted, fan-outs
via IdempotencyGuard + EmailChannel
- `UserDisplayPort` SPI in notifications module (anti-corruption layer)
- `NotificationsUserDisplayBridge` wires port → UserDisplayService in
ordinis-app (avoids circular dep)
- `MeNotificationsController` GET /api/v1/me/notifications + POST /{id}/read +
/read-all
- Migration 0024 `notification_read_state(user_id, notification_id, read_at)`
- 5 unit tests (mock-maker-subclass for Java 25 compat)
**Why.** Maker has no synchronous feedback. They submit, then either:
- Tab "Мои" → Decided (covered by TODO 1) — but only when they come back
- This TODO — push notification so they know without re-visiting
**Frontend**:
- `useMyNotifications` query (30s refetch + background poll for badge)
- `useMarkNotificationRead` + `useMarkAllNotificationsRead` mutations
- `NotificationsDrawer` right-side drawer with per-row mark-read + footer
mark-all
- `NotificationsBell` replaces placeholder — unread badge (capped "9+") +
delta-toast on poll-detected new arrivals when drawer closed
- 14 ru-RU + 14 en-US i18n keys
**Pros.** Closes the maker feedback loop. Reuses the outbox infra. Mirrors
existing notification flows in `ordinis-notifications`.
**Cons.** New email template + new in-app toast surface + bell badge. Half-day
to a day of work split between notifications service and admin-ui.
**Context.** Pass 7 unresolved decision. User chose "in-app toast + email via
notifications service" over the lighter "tab badge polling" option. Backend
events already fire (MR !184), so the gate is consumer side.
**Depends on / blocked by.** MR !184 deployed. TODO 1 ("Мои" tab) helps but
is not a hard blocker — the toast and email work without that surface.
**Email templates** already existed (draft_submitted, draft_decision,
draft_withdrawn) in notifications_{ru,en}.properties — reused as-is.
+32
View File
@@ -687,3 +687,35 @@ export type ChangelogDiff = {
after: unknown | null
summary: ChangelogDiffSummary
}
// === Notifications (TODO 7: draft decision toast + email) ===
/**
* Single notification row для current user. Backend serializes
* {@code notification_log} entry joined с {@code notification_read_state}
* (per-user unread tracking).
*
* <p>{@code payload} — JSON payload эвента (e.g. RecordDraftApproved).
* {@code payloadPreview} — server-pre-rendered summary string чтобы UI
* не парсил event payload (event_type → user-facing text mapping живёт
* backend'е).
*/
export type NotificationItem = {
id: string
eventType: string
channel: 'EMAIL' | 'EXPRESS' | 'TELEGRAM' | 'IN_APP'
sentAt: string
read: boolean
payloadPreview: string
/** Optional deep-link (e.g. /reviews/{draftId} для draft decision). */
href?: string | null
}
export type NotificationsResponse = {
items: NotificationItem[]
page: number
size: number
totalElements: number
/** Unread count for badge — pre-filtered server-side. */
totalUnread: number
}
+36
View File
@@ -664,3 +664,39 @@ export const useRetryWebhookDelivery = () => {
},
})
}
// === Notifications mutations (TODO 7) ===
/**
* Mark одно notification как read. Optimistic update — UI обновляется
* мгновенно, query invalidates после server confirm. Если backend reject'нет,
* onError refetch'нет original state.
*/
export const useMarkNotificationRead = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (notificationId: string): Promise<void> => {
await apiClient.post(
`/me/notifications/${encodeURIComponent(notificationId)}/read`,
)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'me'] })
},
})
}
/**
* Mark все unread notifications as read. Drawer footer button "Mark all read".
*/
export const useMarkAllNotificationsRead = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (): Promise<void> => {
await apiClient.post('/me/notifications/read-all')
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'me'] })
},
})
}
+34
View File
@@ -7,6 +7,7 @@ import {
type CascadePlan,
type ChangelogDiff,
type ChangelogResponse,
type NotificationsResponse,
type SchemaDraft,
type SchemaReviewQueuePage,
type DictionaryDefinition,
@@ -804,3 +805,36 @@ export const serverVersionQuery = queryOptions({
})
export const useServerVersion = () => useQuery(serverVersionQuery)
// === Notifications (TODO 7: draft decision toast + email) ===
/**
* Current user's notifications feed. Backend filters by recipient = sub claim;
* returns paginated rows + `totalUnread` для bell badge counter.
*
* <p>Poll каждые 30s — same cadence как /reviews queue. {@code staleTime} 5s
* чтобы query cache не моргал когда user открывает/закрывает drawer.
* {@code refetchIntervalInBackground} = true чтобы tab без focus всё равно
* decremented badge когда mark-as-read fires.
*
* <p>Caveat: `unreadOnly=true` gives a tight badge query (1 unread → badge "1"),
* full feed query (default) used drawer открыт. Splitting queries keeps badge
* fetch cheap when drawer закрыт.
*/
export const myNotificationsQuery = (unreadOnly = false, limit = 20) =>
queryOptions({
queryKey: ['notifications', 'me', { unreadOnly, limit }] as const,
queryFn: async (): Promise<NotificationsResponse> => {
const { data } = await apiClient.get<NotificationsResponse>(
'/me/notifications',
{ params: { unreadOnly, limit } },
)
return data
},
refetchInterval: 30_000,
refetchIntervalInBackground: true,
staleTime: 5_000,
})
export const useMyNotifications = (unreadOnly = false, limit = 20) =>
useQuery(myNotificationsQuery(unreadOnly, limit))
@@ -1,8 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from '@tanstack/react-router'
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
import { useDictionaries, useDictionaryDetail, useMyNotifications } from '@/api/queries'
import { VersionBadge } from '@/components/version/VersionBadge'
import { WhatsNewButton } from '@/components/version/WhatsNewButton'
import { NotificationsDrawer } from '@/components/notifications/NotificationsDrawer'
import { toast } from '@/ui'
import { Bell, Menu } from 'lucide-react'
/**
@@ -101,25 +104,72 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
}
/**
* Placeholder notifications bell будет показывать N unread сообщений
* (webhook errors, draft reviews, system notices). Click drawer/popup.
* Сейчас static bell без counter wire'ится когда backend notifications
* stream появится.
* Notifications bell с unread counter + drawer. TODO 7 wiring:
* <ul>
* <li>{@link useMyNotifications} с {@code unreadOnly=true} polls /me/notifications
* каждые 30s (cheap query фильтр backend-side).</li>
* <li>Counter badge показывается если {@code totalUnread > 0}. Capped на "9+"
* чтобы layout не плыл при бурном reviewer flow.</li>
* <li>Click opens {@link NotificationsDrawer} с full feed (unreadOnly=false).</li>
* <li>Delta-detection: если {@code totalUnread} вырос между poll'ами и drawer
* закрыт fires `toast.info` с preview top-most new item. Reviewer'у
* не нужно держать tab focused чтобы заметить approve.</li>
* </ul>
*
* <p>«Что нового» (WhatsNewButton) adjacent button показывает release
* notes, отделён от operational notifications (разные mental models).
*/
function NotificationsBell() {
const { t } = useTranslation()
const [drawerOpen, setDrawerOpen] = useState(false)
// Light query: только totalUnread + top item, used to drive badge + toast.
const q = useMyNotifications(true, 5)
const totalUnread = q.data?.totalUnread ?? 0
// Delta toast: detect новые notifications между polls. Hold prev count
// в ref чтобы не trigger'ить toast на первый mount (initial load).
const prevUnreadRef = useRef<number | null>(null)
useEffect(() => {
const prev = prevUnreadRef.current
if (prev != null && totalUnread > prev && !drawerOpen) {
const top = q.data?.items[0]
toast(
top?.payloadPreview ??
t('notifications.newDefault', { defaultValue: 'Новое уведомление' }),
{
description: t('notifications.toastDescription', {
defaultValue: 'Откройте колокольчик чтобы увидеть подробности.',
}),
},
)
}
prevUnreadRef.current = totalUnread
}, [totalUnread, drawerOpen, q.data, t])
return (
<>
<button
type="button"
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
className="size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
onClick={() => setDrawerOpen(true)}
className="relative size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
>
<Bell size={14} strokeWidth={1.75} />
{totalUnread > 0 && (
<span
aria-label={t('notifications.unreadCount', {
count: totalUnread,
defaultValue: `${totalUnread} непрочитанных`,
})}
className="absolute -top-1 -right-1 min-w-[1rem] h-[1rem] px-1 rounded-full bg-pink text-on-accent text-[0.625rem] leading-none font-medium tabular-nums inline-flex items-center justify-center"
>
{totalUnread > 9 ? '9+' : totalUnread}
</span>
)}
</button>
<NotificationsDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
</>
)
}
@@ -0,0 +1,225 @@
import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import {
Badge,
Button,
Drawer,
EmptyState,
LoadingBlock,
QueryErrorState,
} from '@/ui'
import { BellIcon, CheckIcon } from '@phosphor-icons/react'
import { useMyNotifications } from '@/api/queries'
import {
useMarkAllNotificationsRead,
useMarkNotificationRead,
} from '@/api/mutations'
import type { NotificationItem } from '@/api/client'
type Props = {
open: boolean
onClose: () => void
}
/**
* Right-side drawer показывает recent notifications для current user.
* Rendered поверх TopBar bell click. Polled через {@link useMyNotifications}
* (30s interval same как /reviews queue).
*
* <p>Каждая row показывает: channel icon preview text relative time
* unread dot. Click either deep-link (e.g. /reviews/{draftId}) ИЛИ просто
* mark-as-read inline. Backend providing `href` для approval-related events
* (draft decision toast spec из TODO 7).
*
* <p>Footer: "Mark all as read" button когда есть unread; иначе hidden.
*/
export function NotificationsDrawer({ open, onClose }: Props) {
const { t } = useTranslation()
const q = useMyNotifications(false, 20)
const markOne = useMarkNotificationRead()
const markAll = useMarkAllNotificationsRead()
const items = q.data?.items ?? []
const totalUnread = q.data?.totalUnread ?? 0
return (
<Drawer
isOpen={open}
onClose={onClose}
side="right"
widthClassName="w-full max-w-md"
title={t('notifications.title', { defaultValue: 'Уведомления' })}
description={
totalUnread > 0
? t('notifications.unreadCount', {
count: totalUnread,
defaultValue: `${totalUnread} непрочитанных`,
})
: t('notifications.allRead', { defaultValue: 'Всё прочитано' })
}
>
<div className="space-y-3">
{q.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{q.error && <QueryErrorState error={q.error} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.error && items.length === 0 && (
<EmptyState
icon={<BellIcon weight="duotone" size={28} />}
title={t('notifications.empty', {
defaultValue: 'Уведомлений пока нет',
})}
description={t('notifications.emptyDescription', {
defaultValue:
'Здесь будут уведомления о решениях по вашим черновикам и других событиях.',
})}
/>
)}
{items.length > 0 && (
<ul className="divide-y divide-line">
{items.map((n) => (
<NotificationRow
key={n.id}
item={n}
onMarkRead={() => markOne.mutate(n.id)}
onClose={onClose}
/>
))}
</ul>
)}
{totalUnread > 0 && (
<div className="border-t border-line pt-3 flex items-center justify-end">
<Button
variant="secondary"
size="sm"
leftIcon={<CheckIcon weight="bold" size={14} />}
onClick={() => markAll.mutate()}
loading={markAll.isPending}
disabled={markAll.isPending}
>
{t('notifications.markAllRead', {
defaultValue: 'Прочитать все',
})}
</Button>
</div>
)}
</div>
</Drawer>
)
}
function NotificationRow({
item,
onMarkRead,
onClose,
}: {
item: NotificationItem
onMarkRead: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const content = (
<div className="flex items-start gap-3 py-3 group">
{/* Unread dot visual cue для еще-не-прочитанных. Скрыт когда read.
aria-label чтобы screen reader озвучил статус. */}
<span
aria-label={
item.read
? t('notifications.read', { defaultValue: 'Прочитано' })
: t('notifications.unread', { defaultValue: 'Непрочитано' })
}
className={
'mt-1.5 size-2 shrink-0 rounded-full ' +
(item.read ? 'bg-transparent' : 'bg-accent')
}
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={badgeVariant(item.eventType)}>
{humanEventType(item.eventType)}
</Badge>
<Badge variant="outline" className="lowercase">
{item.channel}
</Badge>
</div>
<p className={'text-cell ' + (item.read ? 'text-ink-2' : 'text-ink font-medium')}>
{item.payloadPreview}
</p>
<p className="text-cap text-mute tabular-nums">
{new Date(item.sentAt).toLocaleString()}
</p>
</div>
{!item.read && (
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMarkRead()
}}
aria-label={t('notifications.markRead', {
defaultValue: 'Отметить прочитанным',
})}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity text-mute hover:text-ink shrink-0"
>
<CheckIcon weight="bold" size={14} />
</button>
)}
</div>
)
// Deep-link case: row is a Link, click closes drawer + navigates +
// implicitly marks-as-read через server (backend should mark read on
// resource load OR we add explicit POST here).
if (item.href) {
return (
<li>
<Link
to={item.href}
onClick={() => {
if (!item.read) onMarkRead()
onClose()
}}
className="block hover:bg-line/30 -mx-3 px-3 rounded-sm transition-colors"
>
{content}
</Link>
</li>
)
}
return <li>{content}</li>
}
/**
* Event type Badge variant mapping. Decision-positive (approved/published)
* success, negative (rejected/withdrawn) danger, neutral progress
* (submitted/changes_requested) info.
*/
function badgeVariant(
eventType: string,
): 'success' | 'danger' | 'info' | 'warning' | 'default' {
if (/Approved|Published/i.test(eventType)) return 'success'
if (/Rejected|Withdrawn/i.test(eventType)) return 'danger'
if (/Submitted|ChangesRequested/i.test(eventType)) return 'info'
return 'default'
}
/**
* Event type short russian label для chip. Server-side resolution лучше,
* но пока mapping inline здесь backend payloadPreview уже несёт полный
* человеческий текст, chip это просто категория.
*/
function humanEventType(eventType: string): string {
const map: Record<string, string> = {
RecordDraftSubmitted: 'submit',
RecordDraftApproved: 'approved',
RecordDraftRejected: 'rejected',
RecordDraftWithdrawn: 'withdrawn',
SchemaDraftSubmitted: 'schema submit',
SchemaDraftApproved: 'schema approved',
SchemaDraftRejected: 'schema rejected',
SchemaDraftPublished: 'schema published',
}
return map[eventType] ?? eventType
}
+30
View File
@@ -581,6 +581,22 @@ i18n
'reviews.tab.records': 'Записи',
'reviews.tab.schemas': 'Схемы',
'reviews.tab.mine': 'Мои',
// === Notifications (TODO 7) ===
'notifications.title': 'Уведомления',
'notifications.empty': 'Уведомлений пока нет',
'notifications.emptyDescription':
'Здесь будут уведомления о решениях по вашим черновикам и других событиях.',
'notifications.markAllRead': 'Прочитать все',
'notifications.markRead': 'Отметить прочитанным',
'notifications.read': 'Прочитано',
'notifications.unread': 'Непрочитано',
'notifications.allRead': 'Всё прочитано',
'notifications.unreadCount_one': '{{count}} непрочитанное',
'notifications.unreadCount_few': '{{count}} непрочитанных',
'notifications.unreadCount_many': '{{count}} непрочитанных',
'notifications.newDefault': 'Новое уведомление',
'notifications.toastDescription':
'Откройте колокольчик чтобы увидеть подробности.',
// Interaction state polish (TODO 2)
'reviews.emptyDescription':
'Очередь чистая. Прошлые решения можно посмотреть в журнале аудита.',
@@ -1323,6 +1339,20 @@ i18n
'reviews.tab.records': 'Records',
'reviews.tab.schemas': 'Schemas',
'reviews.tab.mine': 'Mine',
// === Notifications (TODO 7) ===
'notifications.title': 'Notifications',
'notifications.empty': 'No notifications yet',
'notifications.emptyDescription':
'Decisions on your drafts and other events will appear here.',
'notifications.markAllRead': 'Mark all read',
'notifications.markRead': 'Mark read',
'notifications.read': 'Read',
'notifications.unread': 'Unread',
'notifications.allRead': 'All read',
'notifications.unreadCount_one': '{{count}} unread',
'notifications.unreadCount_other': '{{count}} unread',
'notifications.newDefault': 'New notification',
'notifications.toastDescription': 'Open the bell to see details.',
// Interaction state polish (TODO 2)
'reviews.emptyDescription':
'Queue is clear. Past decisions live in the audit log.',
+4
View File
@@ -19,6 +19,10 @@
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-rest-api</artifactId>
</dependency>
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-notifications</artifactId>
</dependency>
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-cuod-bundle</artifactId>
@@ -0,0 +1,29 @@
package cloud.nstart.terravault.ordinis.app.config;
import cloud.nstart.terravault.ordinis.notifications.dispatcher.UserDisplayPort;
import cloud.nstart.terravault.ordinis.restapi.service.UserDisplayService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Wires {@link UserDisplayService} (ordinis-rest-api) into the notifications module's
* {@link UserDisplayPort} interface (ordinis-notifications).
*
* <p>Lives in ordinis-app because that module has compile-time visibility over
* both ordinis-rest-api and ordinis-notifications. This avoids a circular dependency
* (notifications rest-api notifications).
*
* <p>Gated on {@code ordinis.notifications.enabled} no unnecessary bean creation
* when notifications module is dormant.
*/
@Configuration
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
public class NotificationsUserDisplayBridge {
@Bean
public UserDisplayPort userDisplayPort(UserDisplayService userDisplayService) {
return userId -> userDisplayService.find(userId)
.map(info -> new UserDisplayPort.DisplayInfo(info.sub(), info.email(), info.name()));
}
}
@@ -224,3 +224,18 @@ ordinis:
realm: ${ORDINIS_KEYCLOAK_ADMIN_REALM:nstart}
client-id: ${ORDINIS_KEYCLOAK_ADMIN_CLIENT_ID:}
client-secret: ${ORDINIS_KEYCLOAK_ADMIN_CLIENT_SECRET:}
# Notifications module — push email for draft lifecycle events.
# Activated via ordinis.notifications.enabled=true (writer pod only).
# SMTP creds: spring.mail.* (standard Spring Boot mail properties).
# See ordinis-notifications module + migration 0021/0024.
notifications:
enabled: ${ORDINIS_NOTIFICATIONS_ENABLED:true}
poll-interval-ms: ${ORDINIS_NOTIFICATIONS_POLL_INTERVAL_MS:30000}
batch-size: ${ORDINIS_NOTIFICATIONS_BATCH_SIZE:50}
# Reviewer pool email — receives RecordDraftSubmitted / RecordDraftWithdrawn pings.
# Leave blank to disable reviewer-pool notifications (maker-only notifications still work).
reviewer-pool-email: ${ORDINIS_NOTIFICATIONS_REVIEWER_POOL_EMAIL:}
email:
from-address: ${ORDINIS_NOTIFICATIONS_FROM:}
base-url: ${ORDINIS_NOTIFICATIONS_BASE_URL:}
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<!--
notification_read_state — tracks which notification_log rows have been read
by a given user, enabling the "unread count" and per-item read flag in
GET /api/v1/me/notifications.
Design decisions:
- Separate table keeps notification_log append-only (no mutable columns there).
- PK = (user_id, notification_id) — no surrogate id needed; natural composite
is unique and small enough for index coverage.
- user_id stores Keycloak subject (VARCHAR 128, matching user_display_cache.sub).
- notification_id references notification_log.id (BIGINT). FK not declared to
avoid blocking DELETE on notification_log retention sweeps.
- ON CONFLICT DO NOTHING in INSERT — idempotent re-read.
-->
<changeSet id="0024-1-notification-read-state" author="ordinis">
<comment>notification_read_state — per-user read tracking for notification_log rows</comment>
<createTable tableName="notification_read_state">
<!-- Keycloak subject — same shape as user_display_cache.sub -->
<column name="user_id" type="VARCHAR(128)">
<constraints nullable="false"/>
</column>
<!-- References notification_log.id — no FK to allow independent retention sweep -->
<column name="notification_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="read_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
</createTable>
<sql>
ALTER TABLE notification_read_state
ADD CONSTRAINT notification_read_state_pkey
PRIMARY KEY (user_id, notification_id);
</sql>
<!-- Index for "how many unread does user X have" query:
SELECT COUNT(*) FROM notification_log nl
WHERE nl.recipient = ?
AND NOT EXISTS (SELECT 1 FROM notification_read_state rs
WHERE rs.user_id = ? AND rs.notification_id = nl.id)
-->
<createIndex indexName="idx_notif_read_state_user" tableName="notification_read_state">
<column name="user_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -33,5 +33,6 @@
<include file="changes/0021-notifications.xml" relativeToChangelogFile="true"/>
<include file="changes/0022-schema-workflow.xml" relativeToChangelogFile="true"/>
<include file="changes/0023-user-display-cache.xml" relativeToChangelogFile="true"/>
<include file="changes/0024-notification-read-state.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,375 @@
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLog;
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLogRepository;
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
import cloud.nstart.terravault.ordinis.notifications.channel.NotificationChannel;
import cloud.nstart.terravault.ordinis.notifications.channel.NotificationChannel.DispatchResult;
import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage;
import cloud.nstart.terravault.ordinis.notifications.channel.UserRef;
import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
import cloud.nstart.terravault.ordinis.notifications.template.MessageRenderer;
import com.fasterxml.jackson.databind.JsonNode;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
/**
* Notifications dispatcher polls outbox_events for draft lifecycle events and
* fans out email notifications to the relevant recipients.
*
* <p>Mirrors the pattern of {@code WebhookDispatcher} (two @Scheduled ticks):
* <ol>
* <li><b>matchTick</b>: scans recent outbox_events for draft decision event
* types not yet processed by this dispatcher.</li>
* <li><b>sendTick</b>: for each matched event, resolves recipient(s), renders
* via {@code MessageRenderer}, claims via {@code IdempotencyGuard}, dispatches
* via enabled channels.</li>
* </ol>
*
* <p>Recipient resolution:
* <ul>
* <li>{@code RecordDraftApproved / RecordDraftRejected / RecordDraftWithdrawn}:
* notify the maker (maker_id from payload look up email in UserDisplayService).</li>
* <li>{@code RecordDraftSubmitted}: notify a configurable reviewer pool address
* ({@code ordinis.notifications.reviewer-pool-email}). Skip silently if not set.</li>
* </ul>
*
* <p>Idempotency: per-recipient claim via {@code IdempotencyGuard} (INSERT ON CONFLICT DO NOTHING).
* The dispatcher reads events regardless of their {@code published_at} state it does not require
* Kafka publication to have completed (same pattern as WebhookDispatcher).
*
* <p>Activated only when {@code ordinis.notifications.enabled=true}.
*/
@Component
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
public class NotificationsDispatcher {
private static final Logger log = LoggerFactory.getLogger(NotificationsDispatcher.class);
private static final Set<String> DRAFT_EVENT_TYPES = Set.of(
"RecordDraftApproved",
"RecordDraftRejected",
"RecordDraftWithdrawn",
"RecordDraftSubmitted"
);
private final OutboxEventRepository outboxRepository;
private final NotificationLogRepository notificationLogRepository;
private final IdempotencyGuard idempotencyGuard;
private final MessageRenderer messageRenderer;
private final List<NotificationChannel> channels;
private final RecipientResolver recipientResolver;
private final Counter sentCounter;
private final Counter failedCounter;
private final Counter skippedCounter;
@Value("${ordinis.notifications.poll-interval-ms:30000}")
private long pollIntervalMs;
@Value("${ordinis.notifications.batch-size:50}")
private int batchSize;
@Value("${ordinis.notifications.reviewer-pool-email:}")
private String reviewerPoolEmail;
@Value("${ordinis.notifications.base-url:${ordinis.notifications.email.base-url:}}")
private String baseUrl;
public NotificationsDispatcher(
OutboxEventRepository outboxRepository,
NotificationLogRepository notificationLogRepository,
IdempotencyGuard idempotencyGuard,
MessageRenderer messageRenderer,
List<NotificationChannel> channels,
RecipientResolver recipientResolver,
MeterRegistry meterRegistry) {
this.outboxRepository = outboxRepository;
this.notificationLogRepository = notificationLogRepository;
this.idempotencyGuard = idempotencyGuard;
this.messageRenderer = messageRenderer;
this.channels = channels;
this.recipientResolver = recipientResolver;
this.sentCounter = Counter.builder("nsi_notification_sent_total")
.description("Total notifications successfully sent")
.register(meterRegistry);
this.failedCounter = Counter.builder("nsi_notification_failed_total")
.description("Total notification send failures")
.register(meterRegistry);
this.skippedCounter = Counter.builder("nsi_notification_skipped_total")
.description("Total notifications skipped (idempotency hit or no recipient)")
.register(meterRegistry);
}
@PostConstruct
void init() {
log.info("NotificationsDispatcher initialized. Channels: {}, reviewerPoolEmail configured: {}",
channels.stream().map(c -> c.kind().wireName()).toList(),
!reviewerPoolEmail.isBlank());
}
/**
* Scan recent outbox events for draft lifecycle types and process each.
* Runs every {@code ordinis.notifications.poll-interval-ms} ms.
*/
@Scheduled(fixedDelayString = "${ordinis.notifications.poll-interval-ms:30000}")
@Transactional
public void sendTick() {
List<OutboxEvent> events = outboxRepository.findUnpublished(PageRequest.of(0, batchSize));
if (events.isEmpty()) return;
List<OutboxEvent> drafts = events.stream()
.filter(e -> DRAFT_EVENT_TYPES.contains(e.getEventType()))
.toList();
if (drafts.isEmpty()) return;
log.debug("NotificationsDispatcher sendTick: {} draft events to process", drafts.size());
for (OutboxEvent event : drafts) {
processEvent(event);
}
}
/**
* Process a single outbox event: resolve recipient(s), render, claim, dispatch.
* Errors per event are caught so one bad event does not block others.
*/
void processEvent(OutboxEvent event) {
try {
JsonNode payload = event.getPayload();
String eventType = event.getEventType();
UUID eventUuid = extractEventUuid(payload, event);
NotificationEvent notifEvent = buildNotificationEvent(event, eventUuid, payload);
List<UserRef> recipients = resolveRecipients(eventType, payload);
if (recipients.isEmpty()) {
log.debug("No recipients for event {} ({}). Skipping.", event.getId(), eventType);
skippedCounter.increment();
return;
}
for (UserRef recipient : recipients) {
dispatchToRecipient(notifEvent, recipient, eventType);
}
} catch (Exception ex) {
log.warn("Failed to process outbox event id={} type={}: {}",
event.getId(), event.getEventType(), ex.getMessage(), ex);
failedCounter.increment();
}
}
private void dispatchToRecipient(NotificationEvent notifEvent, UserRef recipient, String eventType) {
for (NotificationChannel channel : channels) {
if (!channel.isEnabled()) continue;
String recipientId = recipient.recipientFor(channel.kind());
if (recipientId == null || recipientId.isBlank()) {
skippedCounter.increment();
continue;
}
Optional<NotificationLog> claimed = idempotencyGuard.tryClaim(
notifEvent, channel.kind(), recipientId);
if (claimed.isEmpty()) {
skippedCounter.increment();
continue;
}
Long rowId = claimed.get().getId();
try {
String eventKey = toEventKey(eventType);
RenderedMessage msg = messageRenderer.render(
eventKey, channel.kind(), recipient.locale(),
buildParams(notifEvent, channel.kind()));
DispatchResult result = channel.dispatch(notifEvent, recipient, msg);
if (result.ok()) {
idempotencyGuard.markSent(rowId);
sentCounter.increment();
log.debug("Notification sent: event={} channel={} recipient={}",
notifEvent.eventId(), channel.kind().wireName(), recipientId);
} else {
idempotencyGuard.markFailed(rowId, result.errorMsg());
failedCounter.increment();
log.warn("Notification failed: event={} channel={} recipient={} error={}",
notifEvent.eventId(), channel.kind().wireName(), recipientId, result.errorMsg());
}
} catch (Exception ex) {
idempotencyGuard.markFailed(rowId, ex.getClass().getSimpleName() + ": " + ex.getMessage());
failedCounter.increment();
log.warn("Notification dispatch error: event={} channel={} recipient={}",
notifEvent.eventId(), channel.kind().wireName(), recipientId, ex);
}
}
}
/**
* Resolve notification recipients based on event type.
* <ul>
* <li>Approved / Rejected: notify maker (draft decision sent back to maker).</li>
* <li>Withdrawn: notify reviewer pool (queue cleanup ping).</li>
* <li>Submitted: notify reviewer pool (new item in queue).</li>
* </ul>
*/
private List<UserRef> resolveRecipients(String eventType, JsonNode payload) {
return switch (eventType) {
case "RecordDraftApproved", "RecordDraftRejected" -> resolveMaker(payload);
case "RecordDraftWithdrawn", "RecordDraftSubmitted" -> resolveReviewerPool();
default -> List.of();
};
}
private List<UserRef> resolveMaker(JsonNode payload) {
String makerId = stringField(payload, "makerId");
if (makerId == null) return List.of();
return recipientResolver.resolveById(makerId)
.map(List::of)
.orElse(List.of());
}
private List<UserRef> resolveReviewerPool() {
if (reviewerPoolEmail.isBlank()) return List.of();
UserRef poolRef = new UserRef("reviewer-pool", reviewerPoolEmail, null, null, Locale.forLanguageTag("ru"));
return List.of(poolRef);
}
/**
* Map outbox event type string to notification template event key.
*/
static String toEventKey(String eventType) {
return switch (eventType) {
case "RecordDraftApproved", "RecordDraftRejected" -> "draft_decision";
case "RecordDraftWithdrawn" -> "draft_withdrawn";
case "RecordDraftSubmitted" -> "draft_submitted";
default -> throw new IllegalArgumentException("Unknown draft event type: " + eventType);
};
}
/**
* Build positional params array for {@code MessageRenderer.render()}.
* Order per notifications_ru.properties:
* {0}=dictDisplayName, {1}=businessKey, {2}=makerId, {3}=operation,
* {4}=baseUrl, {5}=draftId, {6}=decisionOutcome(0/1), {7}=reviewerId,
* {8}=commentPresent(0/1), {9}=comment
*/
private Object[] buildParams(NotificationEvent event, ChannelKind channel) {
NotificationEvent.DraftRef draft = event.draft();
NotificationEvent.Decision decision = event.decision();
String dictDisplayName = draft != null ? draft.dictionary() : "";
String businessKey = draft != null ? draft.businessKey() : "";
String maker = draft != null ? draft.maker() : "";
String operation = draft != null ? draft.operation() : "";
long draftIdLong = extractDraftIdLong(draft);
int decisionOutcome = 0;
String reviewerId = "";
int commentPresent = 0;
String comment = "";
if (decision != null) {
decisionOutcome = "APPROVE".equals(decision.outcome()) ? 1 : 0;
reviewerId = decision.decidedBy() != null ? decision.decidedBy() : "";
comment = decision.comment() != null ? decision.comment() : "";
commentPresent = comment.isBlank() ? 0 : 1;
}
return new Object[]{
dictDisplayName, // {0}
businessKey, // {1}
maker, // {2}
operation, // {3}
baseUrl, // {4}
draftIdLong, // {5}
decisionOutcome, // {6}
reviewerId, // {7}
commentPresent, // {8}
comment // {9}
};
}
private long extractDraftIdLong(NotificationEvent.DraftRef draft) {
if (draft == null || draft.id() == null) return 0L;
// Draft ID is UUID; templates show it as a link fragment use hashCode as numeric proxy
return Math.abs(draft.id().getMostSignificantBits());
}
private NotificationEvent buildNotificationEvent(OutboxEvent event, UUID eventUuid, JsonNode payload) {
String eventType = event.getEventType();
String makerId = stringField(payload, "makerId");
String reviewerId = stringField(payload, "reviewerId");
String reviewComment = stringField(payload, "reviewComment");
String draftIdStr = stringField(payload, "draftId");
String businessKey = stringField(payload, "businessKey");
String dictionaryName = stringField(payload, "dictionaryName");
String operation = stringField(payload, "operation");
UUID draftUuid = draftIdStr != null ? parseUuidOrNull(draftIdStr) : null;
NotificationEvent.DraftRef draftRef = new NotificationEvent.DraftRef(
draftUuid, businessKey, dictionaryName, operation, makerId);
NotificationEvent.Decision decision = null;
if ("RecordDraftApproved".equals(eventType) || "RecordDraftRejected".equals(eventType)) {
String outcome = "RecordDraftApproved".equals(eventType) ? "APPROVE" : "REJECT";
decision = new NotificationEvent.Decision(outcome, reviewerId, reviewComment);
}
return new NotificationEvent(
eventUuid,
eventType,
event.getCreatedAt(),
draftRef,
decision,
null);
}
private UUID extractEventUuid(JsonNode payload, OutboxEvent event) {
String uuidStr = stringField(payload, "eventId");
if (uuidStr != null) {
UUID parsed = parseUuidOrNull(uuidStr);
if (parsed != null) return parsed;
}
// Derive deterministic UUID from outbox event's numeric id if not present.
// event.getId() may be null in tests (entity not persisted). Fall back to random.
Long numericId = event.getId();
return numericId != null ? new UUID(0L, numericId) : UUID.randomUUID();
}
private static String stringField(JsonNode node, String field) {
if (node == null || !node.has(field)) return null;
JsonNode val = node.get(field);
return val.isNull() ? null : val.asText();
}
private static UUID parseUuidOrNull(String s) {
if (s == null || s.isBlank()) return null;
try {
return UUID.fromString(s);
} catch (IllegalArgumentException e) {
return null;
}
}
}
@@ -0,0 +1,27 @@
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
import cloud.nstart.terravault.ordinis.notifications.channel.UserRef;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* Resolves a Keycloak subject (user UUID) to a {@link UserRef} for notification dispatch.
*
* <p>Backed by {@code UserDisplayService} (ordinis-rest-api module) injected via interface
* to keep ordinis-notifications free of a direct compile-time dependency on rest-api.
* In production the {@link UserDisplayServiceBridge} implementation is wired by the app module;
* in unit tests a mock can be provided directly.
*/
public interface RecipientResolver {
/**
* Resolve user id UserRef with email + locale. Returns empty when the user is unknown
* (not in user_display_cache and Keycloak admin is not configured / unreachable).
*
* @param userId Keycloak subject (UUID string)
* @return populated UserRef or empty
*/
Optional<UserRef> resolveById(String userId);
}
@@ -0,0 +1,17 @@
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
import java.util.Optional;
/**
* Narrow anti-corruption port over {@code UserDisplayService} (ordinis-rest-api).
*
* <p>Keeps ordinis-notifications independent of ordinis-rest-api at compile time.
* The production implementation in ordinis-app delegates to {@code UserDisplayService}.
* Tests can mock this port with a simple lambda.
*/
public interface UserDisplayPort {
Optional<DisplayInfo> findById(String userId);
record DisplayInfo(String sub, String email, String name) {}
}
@@ -0,0 +1,50 @@
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
import cloud.nstart.terravault.ordinis.notifications.channel.UserRef;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.Locale;
import java.util.Optional;
/**
* Production {@link RecipientResolver} backed by a {@link UserDisplayPort}
* a narrow interface over {@code UserDisplayService} to avoid an explicit
* compile-time dependency from ordinis-notifications on ordinis-rest-api.
*
* <p>The port is implemented in the app module (ordinis-app) which has
* visibility over both modules. Tests can provide a mock port directly.
*
* <p>Locale: the user_display_cache table does not store locale; we default
* to {@code ru} matching the existing {@code MessageRenderer.DEFAULT_LOCALE}
* convention. When per-user locale is added, update this resolver.
*/
@Component
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
public class UserDisplayRecipientResolver implements RecipientResolver {
private static final Logger log = LoggerFactory.getLogger(UserDisplayRecipientResolver.class);
private final UserDisplayPort userDisplayPort;
public UserDisplayRecipientResolver(UserDisplayPort userDisplayPort) {
this.userDisplayPort = userDisplayPort;
}
@Override
public Optional<UserRef> resolveById(String userId) {
if (userId == null || userId.isBlank()) return Optional.empty();
Optional<UserDisplayPort.DisplayInfo> info = userDisplayPort.findById(userId);
if (info.isEmpty()) {
log.debug("RecipientResolver: user {} not found in display cache", userId);
return Optional.empty();
}
UserDisplayPort.DisplayInfo d = info.get();
// Phase A: email only; expressHuid/telegramChatId resolved later (Phase B/C).
return Optional.of(new UserRef(userId, d.email(), null, null, Locale.forLanguageTag("ru")));
}
}
@@ -0,0 +1,337 @@
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLog;
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLogRepository;
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
import cloud.nstart.terravault.ordinis.notifications.channel.NotificationChannel;
import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage;
import cloud.nstart.terravault.ordinis.notifications.channel.UserRef;
import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
import cloud.nstart.terravault.ordinis.notifications.template.MessageRenderer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Pageable;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Integration-style test for {@link NotificationsDispatcher}.
*
* <p>Uses mocks for external dependencies (OutboxEventRepository, MessageRenderer,
* RecipientResolver) and a stub IdempotencyGuard (because Mockito cannot subclass
* Spring @Transactional components without mockito-subclass agent). Validates the
* full dispatch pipeline:
* <ol>
* <li>Seeded outbox event of type {@code RecordDraftApproved}</li>
* <li>Dispatcher sendTick() triggered manually</li>
* <li>IdempotencyGuard claim called NotificationLog row created</li>
* <li>Mock EmailChannel dispatch() called with rendered message</li>
* </ol>
*
* <p>Named *Test (not *IT) so Maven Surefire discovers it in the default include pattern.
*/
class NotificationsDispatcherTest {
// Mocks
private OutboxEventRepository outboxRepository;
private MessageRenderer messageRenderer;
private RecipientResolver recipientResolver;
// Test doubles
private RecordingEmailChannel emailChannel;
private StubIdempotencyGuard idempotencyGuard;
private StubNotificationLogRepository notificationLogRepository;
private NotificationsDispatcher dispatcher;
@BeforeEach
void setUp() {
outboxRepository = mock(OutboxEventRepository.class);
messageRenderer = mock(MessageRenderer.class);
recipientResolver = mock(RecipientResolver.class);
emailChannel = new RecordingEmailChannel();
notificationLogRepository = new StubNotificationLogRepository();
idempotencyGuard = new StubIdempotencyGuard(notificationLogRepository);
dispatcher = new NotificationsDispatcher(
outboxRepository,
notificationLogRepository,
idempotencyGuard,
messageRenderer,
List.of(emailChannel),
recipientResolver,
new SimpleMeterRegistry()
);
ReflectionTestUtils.setField(dispatcher, "batchSize", 50);
ReflectionTestUtils.setField(dispatcher, "pollIntervalMs", 30_000L);
ReflectionTestUtils.setField(dispatcher, "reviewerPoolEmail", "");
ReflectionTestUtils.setField(dispatcher, "baseUrl", "https://ordinis.example");
}
@Test
@DisplayName("RecordDraftApproved event → maker receives email notification, NotificationLog row created")
void draftApproved_makerGetsEmail() {
UUID draftId = UUID.randomUUID();
String makerId = "maker-user-sub-123";
String makerEmail = "maker@nstart.space";
OutboxEvent event = buildDraftApprovedEvent(draftId, makerId);
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
UserRef makerRef = new UserRef(makerId, makerEmail, null, null, Locale.forLanguageTag("ru"));
when(recipientResolver.resolveById(makerId)).thenReturn(Optional.of(makerRef));
RenderedMessage rendered = new RenderedMessage("[НСИ] Draft одобрен — Test / SPUTNIK-1", "Ваш draft одобрен.");
when(messageRenderer.render(anyString(), any(ChannelKind.class), any(Locale.class), any(Object[].class)))
.thenReturn(rendered);
// Trigger dispatcher
dispatcher.sendTick();
// Assert: EmailChannel.dispatch() was called once for the maker
assertThat(emailChannel.dispatchCalls).hasSize(1);
RecordingEmailChannel.Call call = emailChannel.dispatchCalls.get(0);
assertThat(call.recipient.email()).isEqualTo(makerEmail);
assertThat(call.msg.subject()).contains("Draft одобрен");
// Assert: NotificationLog row was claimed and marked SENT
assertThat(notificationLogRepository.savedRows).hasSize(1);
assertThat(idempotencyGuard.markedSentCount.get()).isEqualTo(1);
}
@Test
@DisplayName("Non-draft event types are ignored")
void nonDraftEvents_areIgnored() {
OutboxEvent event = new OutboxEvent(
"DictionaryRecordCreated", "DictionaryRecord", "spacecraft:test",
DataScope.PUBLIC,
new ObjectMapper().createObjectNode(),
"ordinis.cuod.events.public", "spacecraft:test");
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
dispatcher.sendTick();
assertThat(emailChannel.dispatchCalls).isEmpty();
verify(recipientResolver, never()).resolveById(anyString());
}
@Test
@DisplayName("Idempotency conflict → no dispatch, markSent never called")
void idempotencyConflict_skipsDispatch() {
UUID draftId = UUID.randomUUID();
String makerId = "maker-sub-456";
OutboxEvent event = buildDraftApprovedEvent(draftId, makerId);
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
UserRef makerRef = new UserRef(makerId, "maker@nstart.space", null, null, Locale.forLanguageTag("ru"));
when(recipientResolver.resolveById(makerId)).thenReturn(Optional.of(makerRef));
// Simulate idempotency conflict guard returns empty
idempotencyGuard.simulateConflict = true;
dispatcher.sendTick();
assertThat(emailChannel.dispatchCalls).isEmpty();
assertThat(idempotencyGuard.markedSentCount.get()).isZero();
}
@Test
@DisplayName("Event key mapping is correct for all draft event types")
void eventKeyMapping_allTypes() {
assertThat(NotificationsDispatcher.toEventKey("RecordDraftRejected")).isEqualTo("draft_decision");
assertThat(NotificationsDispatcher.toEventKey("RecordDraftApproved")).isEqualTo("draft_decision");
assertThat(NotificationsDispatcher.toEventKey("RecordDraftSubmitted")).isEqualTo("draft_submitted");
assertThat(NotificationsDispatcher.toEventKey("RecordDraftWithdrawn")).isEqualTo("draft_withdrawn");
}
@Test
@DisplayName("Unknown user in display cache → no recipients → no dispatch")
void unknownMaker_noDispatch() {
UUID draftId = UUID.randomUUID();
OutboxEvent event = buildDraftApprovedEvent(draftId, "unknown-sub");
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
when(recipientResolver.resolveById("unknown-sub")).thenReturn(Optional.empty());
dispatcher.sendTick();
assertThat(emailChannel.dispatchCalls).isEmpty();
assertThat(notificationLogRepository.savedRows).isEmpty();
}
// --- helpers ---
private OutboxEvent buildDraftApprovedEvent(UUID draftId, String makerId) {
ObjectNode payload = new ObjectMapper().createObjectNode();
payload.put("draftId", draftId.toString());
payload.put("dictionaryName", "spacecraft");
payload.put("businessKey", "SPUTNIK-1");
payload.put("operation", "CREATE");
payload.put("status", "APPROVED");
payload.put("makerId", makerId);
payload.put("reviewerId", "reviewer-sub-789");
payload.put("reviewComment", "Looks good");
payload.put("reviewedAt", "2026-05-14T10:00:00Z");
return new OutboxEvent(
"RecordDraftApproved",
"RecordDraft",
draftId.toString(),
DataScope.PUBLIC,
payload,
"ordinis.cuod.events.public",
"record-draft:" + draftId);
}
// --- Test doubles ---
/**
* Stub IdempotencyGuard avoids mocking a Spring @Transactional component.
* Delegates to a shared StubNotificationLogRepository.
*/
static class StubIdempotencyGuard extends IdempotencyGuard {
boolean simulateConflict = false;
final AtomicInteger markedSentCount = new AtomicInteger(0);
private final StubNotificationLogRepository repo;
StubIdempotencyGuard(StubNotificationLogRepository repo) {
super(repo);
this.repo = repo;
}
@Override
public Optional<NotificationLog> tryClaim(
NotificationEvent event, ChannelKind channel, String recipient) {
if (simulateConflict) return Optional.empty();
NotificationLog row = new NotificationLog(
event.eventId() != null ? event.eventId() : UUID.randomUUID(),
event.eventType(),
event.draft() != null ? event.draft().id() : null,
channel.wireName(),
recipient);
repo.savedRows.add(row);
return Optional.of(row);
}
@Override
public void markSent(Long rowId) {
markedSentCount.incrementAndGet();
}
@Override
public void markFailed(Long rowId, String errorMsg) {}
}
/**
* Stub NotificationLogRepository in-memory list.
*/
static class StubNotificationLogRepository implements NotificationLogRepository {
final List<NotificationLog> savedRows = new ArrayList<>();
private long nextId = 1L;
@Override
public Optional<Long> tryClaim(UUID eventId, String eventType, UUID draftId,
String channel, String recipient) {
// Used by real IdempotencyGuard; our stub overrides tryClaim directly
return Optional.of(nextId++);
}
@Override
public NotificationLog save(NotificationLog entity) {
savedRows.add(entity);
return entity;
}
@Override
public Optional<NotificationLog> findById(Long aLong) {
return savedRows.stream()
.filter(r -> aLong.equals(r.getId()))
.findFirst();
}
// --- unused stubs ---
@Override public <S extends NotificationLog> List<S> saveAll(Iterable<S> entities) { return List.of(); }
@Override public void flush() {}
@Override public <S extends NotificationLog> S saveAndFlush(S entity) { return entity; }
@Override public <S extends NotificationLog> List<S> saveAllAndFlush(Iterable<S> entities) { return List.of(); }
@Override public void deleteAllInBatch(Iterable<NotificationLog> entities) {}
@Override public void deleteAllByIdInBatch(Iterable<Long> longs) {}
@Override public void deleteAllInBatch() {}
@Override public NotificationLog getOne(Long aLong) { return null; }
@Override public NotificationLog getById(Long aLong) { return null; }
@Override public NotificationLog getReferenceById(Long aLong) { return null; }
@Override public <S extends NotificationLog> Optional<S> findOne(org.springframework.data.domain.Example<S> example) { return Optional.empty(); }
@Override public <S extends NotificationLog> List<S> findAll(org.springframework.data.domain.Example<S> example) { return List.of(); }
@Override public <S extends NotificationLog> List<S> findAll(org.springframework.data.domain.Example<S> example, org.springframework.data.domain.Sort sort) { return List.of(); }
@Override public <S extends NotificationLog> org.springframework.data.domain.Page<S> findAll(org.springframework.data.domain.Example<S> example, Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
@Override public <S extends NotificationLog> long count(org.springframework.data.domain.Example<S> example) { return 0; }
@Override public <S extends NotificationLog> boolean exists(org.springframework.data.domain.Example<S> example) { return false; }
@Override public <S extends NotificationLog, R> R findBy(org.springframework.data.domain.Example<S> example, java.util.function.Function<org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery<S>, R> queryFunction) { return null; }
@Override public boolean existsById(Long aLong) { return false; }
@Override public List<NotificationLog> findAll() { return savedRows; }
@Override public List<NotificationLog> findAllById(Iterable<Long> longs) { return List.of(); }
@Override public long count() { return savedRows.size(); }
@Override public void deleteById(Long aLong) {}
@Override public void delete(NotificationLog entity) {}
@Override public void deleteAllById(Iterable<? extends Long> longs) {}
@Override public void deleteAll(Iterable<? extends NotificationLog> entities) {}
@Override public void deleteAll() { savedRows.clear(); }
@Override public List<NotificationLog> findAll(org.springframework.data.domain.Sort sort) { return savedRows; }
@Override public org.springframework.data.domain.Page<NotificationLog> findAll(Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
@Override public int deletePendingOlderThan(OffsetDateTime threshold) { return 0; }
@Override public int deleteRetainedOlderThan(OffsetDateTime threshold) { return 0; }
@Override public Optional<OffsetDateTime> findLastSentAt(String recipient, String channel) { return Optional.empty(); }
@Override public org.springframework.data.domain.Page<NotificationLog> findByRecipientOrderBySentAtDesc(String recipient, Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
@Override public org.springframework.data.domain.Page<NotificationLog> findByEventIdOrderBySentAtDesc(UUID eventId, Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
}
/**
* Recording EmailChannel captures all dispatch calls for assertion.
*/
static class RecordingEmailChannel implements NotificationChannel {
record Call(NotificationEvent event, UserRef recipient, RenderedMessage msg) {}
final List<Call> dispatchCalls = new ArrayList<>();
@Override
public ChannelKind kind() { return ChannelKind.EMAIL; }
@Override
public boolean isEnabled() { return true; }
@Override
public DispatchResult dispatch(NotificationEvent event, UserRef recipient, RenderedMessage msg) {
dispatchCalls.add(new Call(event, recipient, msg));
return DispatchResult.success();
}
}
}
@@ -0,0 +1 @@
mock-maker-subclass
@@ -0,0 +1,213 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLog;
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLogRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* Me-scoped notifications endpoints current user sees their own notification log.
*
* <p>Pagination is cursor-less for simplicity (Phase A). The recipient column in
* notification_log stores the email address, so we look up current user's email
* from the JWT {@code email} claim as the lookup key.
*
* <p>Read state is tracked in {@code notification_read_state} table (migration 0024)
* a separate table keyed by (user_id, notification_id) so the log itself stays
* append-only with no mutable state.
*
* <p>The {@code totalUnread} count is computed as notifications without a read_state
* row for the current user.
*/
@RestController
@RequestMapping("/api/v1/me/notifications")
public class MeNotificationsController {
private static final Logger log = LoggerFactory.getLogger(MeNotificationsController.class);
private static final int MAX_LIMIT = 100;
private final NotificationLogRepository notificationLogRepository;
private final JdbcTemplate jdbcTemplate;
public MeNotificationsController(
NotificationLogRepository notificationLogRepository,
JdbcTemplate jdbcTemplate) {
this.notificationLogRepository = notificationLogRepository;
this.jdbcTemplate = jdbcTemplate;
}
/**
* List notifications for the current user.
*
* <p>Query param {@code status} filters by notification_log.status (e.g. SENT).
* If omitted, returns all statuses. {@code limit} caps at 100.
*
* @return paginated response with items and totalUnread count
*/
@GetMapping
public Map<String, Object> list(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "20") int limit) {
String currentUserSub = requireCurrentUserSub();
String emailKey = currentUserEmail();
int safeLimit = Math.min(limit, MAX_LIMIT);
Page<NotificationLog> page;
if (emailKey != null && !emailKey.isBlank()) {
page = notificationLogRepository.findByRecipientOrderBySentAtDesc(
emailKey, PageRequest.of(0, safeLimit));
} else {
// No email in JWT return empty, not an error
return Map.of("items", List.of(), "totalUnread", 0);
}
List<NotificationItemDto> items = page.getContent().stream()
.filter(n -> status == null || status.equalsIgnoreCase(n.getStatus().name()))
.map(n -> toDto(n, currentUserSub))
.toList();
long totalUnread = countUnread(currentUserSub, emailKey);
return Map.of(
"items", items,
"totalUnread", totalUnread
);
}
/**
* Mark a single notification as read.
*/
@PostMapping("/{id}/read")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void markRead(@PathVariable Long id) {
String sub = requireCurrentUserSub();
markReadState(sub, id);
}
/**
* Mark all notifications for current user as read.
*/
@PostMapping("/read-all")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void markAllRead() {
String sub = requireCurrentUserSub();
String emailKey = currentUserEmail();
if (emailKey == null || emailKey.isBlank()) return;
// Find all unread notification ids for this recipient and mark them read
Page<NotificationLog> page = notificationLogRepository.findByRecipientOrderBySentAtDesc(
emailKey, PageRequest.of(0, MAX_LIMIT));
OffsetDateTime now = OffsetDateTime.now();
page.getContent().forEach(n -> markReadState(sub, n.getId()));
log.debug("Marked {} notifications read for user {}", page.getNumberOfElements(), sub);
}
// --- internals ---
private void markReadState(String sub, Long notificationId) {
jdbcTemplate.update("""
INSERT INTO notification_read_state (user_id, notification_id, read_at)
VALUES (?, ?, ?)
ON CONFLICT (user_id, notification_id) DO NOTHING
""", sub, notificationId, OffsetDateTime.now());
}
private long countUnread(String sub, String emailKey) {
Long count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM notification_log nl
WHERE nl.recipient = ?
AND nl.status = 'SENT'
AND NOT EXISTS (
SELECT 1 FROM notification_read_state rs
WHERE rs.user_id = ? AND rs.notification_id = nl.id
)
""", Long.class, emailKey, sub);
return count != null ? count : 0L;
}
private NotificationItemDto toDto(NotificationLog n, String sub) {
boolean read = isRead(sub, n.getId());
String preview = buildPreview(n);
return new NotificationItemDto(
n.getId(),
n.getEventType(),
n.getChannel(),
n.getSentAt(),
preview,
read
);
}
private boolean isRead(String sub, Long notificationId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM notification_read_state WHERE user_id = ? AND notification_id = ?",
Integer.class, sub, notificationId);
return count != null && count > 0;
}
private static String buildPreview(NotificationLog n) {
// Construct a human-readable preview from event_type + event metadata
return switch (n.getEventType()) {
case "RecordDraftApproved" -> "Draft approved";
case "RecordDraftRejected" -> "Draft rejected";
case "RecordDraftWithdrawn" -> "Draft withdrawn";
case "RecordDraftSubmitted" -> "New draft submitted for review";
default -> n.getEventType();
};
}
private String requireCurrentUserSub() {
String sub = currentUserSub();
if (sub == null) {
throw cloud.nstart.terravault.ordinis.restapi.error.OrdinisException.forbidden(
"unauthenticated", "Authentication required for /me/notifications");
}
return sub;
}
private String currentUserSub() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof JwtAuthenticationToken jwt)) return null;
return jwt.getToken().getSubject();
}
private String currentUserEmail() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof JwtAuthenticationToken jwt)) return null;
Jwt token = jwt.getToken();
return token.getClaimAsString("email");
}
/**
* Response shape for a single notification item.
*/
public record NotificationItemDto(
Long id,
String eventType,
String channel,
OffsetDateTime sentAt,
String payloadPreview,
boolean read
) {}
}