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:
@@ -22,3 +22,4 @@ HELP.md
|
||||
*.pem
|
||||
*.key
|
||||
.gstack/
|
||||
.claude/
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Bell size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
|
||||
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
|
||||
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
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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>
|
||||
|
||||
+29
@@ -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:}
|
||||
|
||||
+56
@@ -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>
|
||||
|
||||
+375
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
+17
@@ -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) {}
|
||||
}
|
||||
+50
@@ -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")));
|
||||
}
|
||||
}
|
||||
+337
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
mock-maker-subclass
|
||||
+213
@@ -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
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user