@micha.bigler/ui-core-micha 2.11.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +2 -0
- package/dist/auth/AuthContext.js +1 -0
- package/dist/auth/authApi.js +3 -1
- package/dist/components/TurnstileWidget.js +84 -0
- package/dist/index.js +1 -0
- package/dist/notifications/NotificationsProvider.js +61 -91
- package/dist/notifications/realtime.js +110 -0
- package/dist/pages/SignUpPage.js +22 -2
- package/package.json +1 -1
- package/src/auth/AuthContext.jsx +1 -0
- package/src/auth/authApi.jsx +2 -0
- package/src/components/TurnstileWidget.jsx +99 -0
- package/src/index.js +1 -0
- package/src/notifications/NotificationsProvider.jsx +67 -96
- package/src/notifications/realtime.jsx +118 -0
- package/src/pages/SignUpPage.jsx +38 -2
- package/tests/NotificationsProvider.test.jsx +1 -0
- package/tests/SignUpPage.test.jsx +138 -0
- package/tests/authApiRegistration.test.js +31 -0
- package/tests/realtime.test.jsx +192 -0
- package/work-orders/NOTIF-12.md +162 -0
- package/work-orders/NOTIF-13.md +142 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
const feedApi = vi.hoisted(() => ({
|
|
7
|
+
getNotificationFeed: vi.fn(),
|
|
8
|
+
getUnreadCount: vi.fn(),
|
|
9
|
+
markNotifications: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/notifications/feedApi', () => feedApi);
|
|
13
|
+
|
|
14
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
15
|
+
import { NotificationsProvider, useNotifications } from '../src/notifications/NotificationsProvider';
|
|
16
|
+
import { useRealtime } from '../src/notifications/realtime';
|
|
17
|
+
|
|
18
|
+
class MockWebSocket {
|
|
19
|
+
static instances = [];
|
|
20
|
+
|
|
21
|
+
constructor(url) {
|
|
22
|
+
this.url = url;
|
|
23
|
+
this.close = vi.fn(() => this.onclose?.());
|
|
24
|
+
MockWebSocket.instances.push(this);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function StreamConsumer({ envelope, testId }) {
|
|
29
|
+
const { subscribe } = useRealtime();
|
|
30
|
+
const [received, setReceived] = React.useState([]);
|
|
31
|
+
|
|
32
|
+
React.useEffect(
|
|
33
|
+
() => subscribe(envelope, (data) => setReceived((prev) => [...prev, data])),
|
|
34
|
+
[subscribe, envelope],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return <output data-testid={testId}>{JSON.stringify(received)}</output>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function NotificationsConsumer() {
|
|
41
|
+
const notifications = useNotifications();
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<output data-testid="notifications">{JSON.stringify(notifications.notifications)}</output>
|
|
45
|
+
<output data-testid="unread-count">{notifications.unreadCount}</output>
|
|
46
|
+
<output data-testid="context-keys">{JSON.stringify(Object.keys(notifications).sort())}</output>
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderWithStreams({ streams = [], user = { id: 4 } } = {}) {
|
|
52
|
+
return render(
|
|
53
|
+
<AuthContext.Provider value={{ user }}>
|
|
54
|
+
<NotificationsProvider>
|
|
55
|
+
<NotificationsConsumer />
|
|
56
|
+
{streams.map(({ envelope, testId }) => (
|
|
57
|
+
<StreamConsumer key={testId} envelope={envelope} testId={testId} />
|
|
58
|
+
))}
|
|
59
|
+
</NotificationsProvider>
|
|
60
|
+
</AuthContext.Provider>,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function received(testId) {
|
|
65
|
+
return JSON.parse(screen.getByTestId(testId).textContent);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('realtime primitive (NOTIF-13)', () => {
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
cleanup();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
MockWebSocket.instances = [];
|
|
76
|
+
vi.stubGlobal('WebSocket', MockWebSocket);
|
|
77
|
+
feedApi.getNotificationFeed.mockResolvedValue({ results: [] });
|
|
78
|
+
feedApi.getUnreadCount.mockResolvedValue({ count: 0 });
|
|
79
|
+
feedApi.markNotifications.mockResolvedValue({ updated: 1 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('ignores an unknown/unsubscribed envelope type — feed and unreadCount untouched', async () => {
|
|
83
|
+
renderWithStreams();
|
|
84
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
85
|
+
|
|
86
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
87
|
+
envelope: 'message',
|
|
88
|
+
type: 'chat.new',
|
|
89
|
+
body: 'hello',
|
|
90
|
+
}) });
|
|
91
|
+
|
|
92
|
+
expect(screen.getByTestId('unread-count').textContent).toBe('0');
|
|
93
|
+
expect(currentNotificationsAreEmpty()).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function currentNotificationsAreEmpty() {
|
|
97
|
+
return JSON.parse(screen.getByTestId('notifications').textContent).length === 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('routes two subscribers on distinct envelope types from one socket, each seeing only their own', async () => {
|
|
101
|
+
renderWithStreams({ streams: [
|
|
102
|
+
{ envelope: 'message', testId: 'message-stream' },
|
|
103
|
+
{ envelope: 'other', testId: 'other-stream' },
|
|
104
|
+
] });
|
|
105
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
106
|
+
|
|
107
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'message', body: 'a' }) });
|
|
108
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'other', body: 'b' }) });
|
|
109
|
+
|
|
110
|
+
await waitFor(() => expect(received('message-stream')).toHaveLength(1));
|
|
111
|
+
expect(received('message-stream')[0]).toEqual(expect.objectContaining({ body: 'a' }));
|
|
112
|
+
expect(received('other-stream')).toHaveLength(1);
|
|
113
|
+
expect(received('other-stream')[0]).toEqual(expect.objectContaining({ body: 'b' }));
|
|
114
|
+
expect(MockWebSocket.instances).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('unsubscribe stops delivery for that subscriber without affecting others', async () => {
|
|
118
|
+
function Harness() {
|
|
119
|
+
const { subscribe } = useRealtime();
|
|
120
|
+
const [messageReceived, setMessageReceived] = React.useState([]);
|
|
121
|
+
const [otherReceived, setOtherReceived] = React.useState([]);
|
|
122
|
+
const [messageSubscribed, setMessageSubscribed] = React.useState(true);
|
|
123
|
+
|
|
124
|
+
React.useEffect(() => {
|
|
125
|
+
if (!messageSubscribed) return undefined;
|
|
126
|
+
return subscribe('message', (data) => setMessageReceived((prev) => [...prev, data]));
|
|
127
|
+
}, [subscribe, messageSubscribed]);
|
|
128
|
+
|
|
129
|
+
React.useEffect(
|
|
130
|
+
() => subscribe('other', (data) => setOtherReceived((prev) => [...prev, data])),
|
|
131
|
+
[subscribe],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<>
|
|
136
|
+
<output data-testid="message-received">{JSON.stringify(messageReceived)}</output>
|
|
137
|
+
<output data-testid="other-received">{JSON.stringify(otherReceived)}</output>
|
|
138
|
+
<button onClick={() => setMessageSubscribed(false)}>unsubscribe-message</button>
|
|
139
|
+
</>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const { unmount } = render(
|
|
144
|
+
<AuthContext.Provider value={{ user: { id: 4 } }}>
|
|
145
|
+
<NotificationsProvider>
|
|
146
|
+
<Harness />
|
|
147
|
+
</NotificationsProvider>
|
|
148
|
+
</AuthContext.Provider>,
|
|
149
|
+
);
|
|
150
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
151
|
+
|
|
152
|
+
fireEvent.click(screen.getByRole('button', { name: 'unsubscribe-message' }));
|
|
153
|
+
|
|
154
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'message', body: 'after-unsub' }) });
|
|
155
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'other', body: 'still-alive' }) });
|
|
156
|
+
|
|
157
|
+
await waitFor(() => expect(received('other-received')).toHaveLength(1));
|
|
158
|
+
expect(received('message-received')).toHaveLength(0);
|
|
159
|
+
expect(received('other-received')[0]).toEqual(expect.objectContaining({ body: 'still-alive' }));
|
|
160
|
+
|
|
161
|
+
unmount();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('keeps a subscriber registered across a close/backoff/reopen cycle with no re-registration', async () => {
|
|
165
|
+
vi.useFakeTimers();
|
|
166
|
+
try {
|
|
167
|
+
renderWithStreams({ streams: [{ envelope: 'message', testId: 'message-stream' }] });
|
|
168
|
+
await vi.waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
169
|
+
|
|
170
|
+
const firstSocket = MockWebSocket.instances[0];
|
|
171
|
+
firstSocket.onclose();
|
|
172
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
173
|
+
|
|
174
|
+
expect(MockWebSocket.instances).toHaveLength(2);
|
|
175
|
+
MockWebSocket.instances[1].onmessage({ data: JSON.stringify({ envelope: 'message', body: 'after-reopen' }) });
|
|
176
|
+
|
|
177
|
+
await vi.waitFor(() => expect(received('message-stream')).toHaveLength(1));
|
|
178
|
+
expect(received('message-stream')[0]).toEqual(expect.objectContaining({ body: 'after-reopen' }));
|
|
179
|
+
} finally {
|
|
180
|
+
vi.useRealTimers();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("NotificationsProvider's public context value is unchanged", async () => {
|
|
185
|
+
renderWithStreams();
|
|
186
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
187
|
+
|
|
188
|
+
expect(JSON.parse(screen.getByTestId('context-keys').textContent)).toEqual(
|
|
189
|
+
['markDismissed', 'markDone', 'markSeen', 'notifications', 'refresh', 'unreadCount'].sort(),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# WORK ORDER NOTIF-12 (ui-core-micha + django-core-micha) — popup channel via the wizard renderer
|
|
2
|
+
|
|
3
|
+
**EXECUTION DIRECTIVE.** Implement through `codex exec` in the background — invoked **directly via
|
|
4
|
+
Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
|
|
5
|
+
and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
|
|
6
|
+
file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
7
|
+
|
|
8
|
+
Feature-level WO (Expertenchat envelope). Seams verified against ucm 2.12.0 / dcm 2.32.0.
|
|
9
|
+
|
|
10
|
+
## HARD GATE — do not start before NOTIF-13 has landed and published
|
|
11
|
+
NOTIF-12 consumes NOTIF-13's envelope contract and its `subscribe(type, handler)` primitive. Starting
|
|
12
|
+
earlier means building on the catch-all in `NotificationsProvider.jsx:130-132` that NOTIF-13 removes.
|
|
13
|
+
Both WOs also edit `ui-core-micha/src/notifications/` and `src/index.js`, so running them in parallel
|
|
14
|
+
violates the AGENTS.md overlap rule. **Sequence: NOTIF-13 → publish → NOTIF-12.**
|
|
15
|
+
|
|
16
|
+
## TIER
|
|
17
|
+
Tier 2 — this extracts a renderer out of `OnboardingWizard`, which **cockpit and jg run in
|
|
18
|
+
production**. The roadmap calls the popup channel "uncritical"; that refers to business urgency, not
|
|
19
|
+
blast radius. Independent `reviewer` mandatory. `sec_reviewer` only on explicit operator request.
|
|
20
|
+
|
|
21
|
+
## SCAFFOLDING NOTICE — READ FIRST
|
|
22
|
+
**This channel ships with zero producers, deliberately.** Verified 2026-07-29 across dcm, jg-ferien,
|
|
23
|
+
cockpit, hram, spesix and survey_app: **no notification type declares `popup` in `eligible_channels`**,
|
|
24
|
+
so `resolve_channels()` will never route anything to it until some app opts a type in. The operator
|
|
25
|
+
decided to build the scaffolding anyway (2026-07-29), having been shown this.
|
|
26
|
+
|
|
27
|
+
Two obligations follow, and they are part of the acceptance criteria:
|
|
28
|
+
1. **Do not overclaim.** The CHANGELOG entry and any docstring must state plainly that the popup
|
|
29
|
+
channel is wired but inactive until a notification type declares `popup`. Precedent: NOTIF-4's
|
|
30
|
+
reviewer (R1) had to soften a CHANGELOG that promised retry behaviour no shipped dispatcher could
|
|
31
|
+
reach. Do not repeat that.
|
|
32
|
+
2. **Scaffolding must be tested scaffolding.** Register a test-local notification type that declares
|
|
33
|
+
`popup` and prove the whole path end-to-end. Untested dead code is not an acceptable outcome.
|
|
34
|
+
|
|
35
|
+
## GOAL
|
|
36
|
+
Make `popup` a real delivery channel: a notification whose type opts into `popup` is rendered to the
|
|
37
|
+
recipient in a modal that reuses the onboarding **wizard renderer**, with its seen/dismissed status
|
|
38
|
+
recorded on `NotificationRecipient` — **not** in the onboarding progress store.
|
|
39
|
+
|
|
40
|
+
## EXPECTED OUTCOME
|
|
41
|
+
- dcm's `PopupDispatcher` stops being a logging stub and delivers for real.
|
|
42
|
+
- ucm exports a popup surface that renders popup-eligible notifications and marks them via the
|
|
43
|
+
existing `feed/mark/` path.
|
|
44
|
+
- Onboarding behaviour is bit-for-bit unchanged for existing consumers.
|
|
45
|
+
- With no producer registered, nothing visibly changes in any app.
|
|
46
|
+
|
|
47
|
+
## CONTEXT PACKAGE — verified current state
|
|
48
|
+
|
|
49
|
+
**dcm `notifications/dispatch.py`:**
|
|
50
|
+
- `PopupDispatcher` (`:117-121`) is a stub: logs "queued for unimplemented popup channel" and returns
|
|
51
|
+
`DeliveryResult(ok=None, error="pending")`. It is registered in `_DISPATCHERS` (`:126-133`).
|
|
52
|
+
- The model to copy is `ChipDispatcher` (`:62-74`): it calls
|
|
53
|
+
`push_to_users([recipient.user], {"type": notification.notification_type, "content": ..., "notification_id": ...})`
|
|
54
|
+
and returns `DeliveryResult(ok=True)`. **Note the `type` field carries the *domain* type** — this is
|
|
55
|
+
precisely the overload NOTIF-13 replaces with a proper envelope. Emit NOTIF-13's envelope, not this
|
|
56
|
+
legacy shape.
|
|
57
|
+
- `_render_content(content, user)` (`:44-60`) renders `title_key`/`body_key`/`params` in the
|
|
58
|
+
recipient's language — reuse it rather than re-deriving text client-side if the payload needs text.
|
|
59
|
+
- `prefs.py:22` already treats `popup` as a channel defaulting to True; `router.py:24` narrows to
|
|
60
|
+
`ntype.eligible_channels`. **No prefs or router change is needed** — only a type opting in.
|
|
61
|
+
|
|
62
|
+
**ucm `src/onboarding/OnboardingWizard.jsx` (75 LOC)** is the renderer to reuse:
|
|
63
|
+
- MUI `Dialog open fullWidth maxWidth="sm"` (`:59`), `DialogTitle` with a step counter +
|
|
64
|
+
`LinearProgress` (`:60-67`), `DialogContent` rendering
|
|
65
|
+
`<StepComponent onComplete={...} onDismiss={...} ctx={...} />` (`:69`).
|
|
66
|
+
- Step contract: `{ id, Component, blocking, persistDismissed }`. `blocking` disables escape/backdrop
|
|
67
|
+
close (`:59`).
|
|
68
|
+
- **The parts that must NOT come along** (design decision D-F7,
|
|
69
|
+
`django-core-micha/docs/design/notifications-platform.md:70-71`): `onboarding.markStepSeen` (`:35`),
|
|
70
|
+
`dismissStep` (`:54`), and the sequential progress store behind them. Popup status lives on
|
|
71
|
+
`NotificationRecipient`.
|
|
72
|
+
- Exported at `src/index.js:61`; the four existing steps at `:62-65`.
|
|
73
|
+
|
|
74
|
+
**ucm notification status API:** `useNotifications()` already returns `markSeen` / `markDismissed` /
|
|
75
|
+
`markDone`, backed by `feed/mark/`. Use `markSeen` when a popup is shown and `markDismissed` when the
|
|
76
|
+
user closes it. **Never `markDone`** — done is a todo-channel semantic.
|
|
77
|
+
|
|
78
|
+
## SCOPE
|
|
79
|
+
|
|
80
|
+
**A. ucm — extract the dialog shell.** Lift the presentational shell out of `OnboardingWizard` into a
|
|
81
|
+
reusable renderer (dialog frame, counter/progress header, the `onComplete`/`onDismiss`/`ctx` child
|
|
82
|
+
contract). `OnboardingWizard` keeps its current behaviour by composing that shell with the onboarding
|
|
83
|
+
store; the popup surface composes the same shell with notification state.
|
|
84
|
+
|
|
85
|
+
**B. ucm — the popup surface.** A component that selects popup-eligible notifications from
|
|
86
|
+
notification state, renders them through the shell, marks `seen` on display and `dismissed` on close,
|
|
87
|
+
and subscribes to NOTIF-13's popup envelope type so a popup can appear live. Export additively from
|
|
88
|
+
`src/index.js`.
|
|
89
|
+
|
|
90
|
+
**C. Coexistence rule with onboarding — specify and test it.** Both surfaces render a MUI `Dialog`, so
|
|
91
|
+
two modals can be open at once. Required behaviour: **a blocking onboarding step wins**; the popup
|
|
92
|
+
waits until no blocking onboarding step is active. Non-blocking coexistence must not stack two dialogs.
|
|
93
|
+
|
|
94
|
+
**D. dcm — implement `PopupDispatcher.deliver`.** Emit NOTIF-13's envelope over `push_to_users` and
|
|
95
|
+
return `DeliveryResult(ok=True)`. Keep the delivery-record semantics identical to `ChipDispatcher`
|
|
96
|
+
(the surrounding `dispatch()` machinery already records pending → sent/failed; do not re-implement it).
|
|
97
|
+
|
|
98
|
+
## DO NOT TOUCH
|
|
99
|
+
- **The onboarding progress store** — `markStepSeen`, `dismissStep`, `persistDismissed` persistence,
|
|
100
|
+
`OnboardingProvider`'s step-selection semantics, `stepSelection.js`.
|
|
101
|
+
- The four existing step components (`CookieConsentStep`, `CompleteNameStep`, `BrowserPushStep`,
|
|
102
|
+
`PwaInstallStep`) and their props.
|
|
103
|
+
- NOTIF-13's realtime core — **consume** `subscribe()`, do not restructure it.
|
|
104
|
+
- `feed/*` REST contract, `NotificationBell`, `NotificationSettings`.
|
|
105
|
+
- `prefs.py`, `router.py`, `resolve_channels` — popup already works there; a type opting in is an
|
|
106
|
+
app-side change, not this WO's.
|
|
107
|
+
- Schema, migrations, dependencies, CI.
|
|
108
|
+
- **Do not register a `popup` producer in any app.** This WO ships the channel only.
|
|
109
|
+
|
|
110
|
+
## RISKS
|
|
111
|
+
- **Onboarding regression** is the main risk: the shell extraction touches a wizard that cockpit and jg
|
|
112
|
+
run in production. The step counter (`totalRef`/`completed`), the session-dismissed Set, and the
|
|
113
|
+
`blocking` escape-key behaviour are easy to break silently.
|
|
114
|
+
- **Stacked modals** if C is skipped — two dialogs, neither reachable.
|
|
115
|
+
- **Dead code rot:** with no producer, only the tests exercise the path. Hence the mandatory test-local
|
|
116
|
+
producer type.
|
|
117
|
+
- **Status semantics drift:** marking a popup `done` instead of `dismissed` would pollute todo-channel
|
|
118
|
+
semantics.
|
|
119
|
+
|
|
120
|
+
## REQUIRED TESTS / ACCEPTANCE
|
|
121
|
+
ucm (`pnpm test`, vitest):
|
|
122
|
+
- a popup-eligible notification renders in the shell; `markSeen` fires once on display;
|
|
123
|
+
- closing it fires `markDismissed`, never `markDone`;
|
|
124
|
+
- **coexistence:** with a blocking onboarding step active, no popup dialog renders; once it clears, the
|
|
125
|
+
popup appears — and never two dialogs at once;
|
|
126
|
+
- **onboarding regression guard:** existing `OnboardingWizard` behaviour unchanged — step counter,
|
|
127
|
+
progress bar, session-dismissed set, `blocking` disabling escape/backdrop close, `persistDismissed`
|
|
128
|
+
still calling `dismissStep`;
|
|
129
|
+
- with no popup-eligible notification present, nothing renders.
|
|
130
|
+
|
|
131
|
+
dcm (`pytest` on the notifications package):
|
|
132
|
+
- register a **test-local** notification type declaring `eligible_channels=["popup"]`, call `notify()`,
|
|
133
|
+
and assert the dispatcher delivered (`ok=True`) with NOTIF-13's envelope shape on the wire — the
|
|
134
|
+
end-to-end proof required by the scaffolding notice;
|
|
135
|
+
- `resolve_channels` still excludes `popup` for types that do not declare it (no accidental widening);
|
|
136
|
+
- the existing `test_dispatch.py` registry assertion (`:25`) still passes.
|
|
137
|
+
|
|
138
|
+
Plus `pnpm build` (tsc) green.
|
|
139
|
+
|
|
140
|
+
## RELEASE
|
|
141
|
+
ucm minor bump + publish. dcm minor bump + publish (publish-from-main), with a **registry live-check
|
|
142
|
+
before any app pin bump**. No app pin bumps in this WO. CHANGELOG in both repos must carry the
|
|
143
|
+
inactive-until-a-producer-opts-in wording from the scaffolding notice.
|
|
144
|
+
|
|
145
|
+
## TARGET REPO / WORKING DIRECTORY
|
|
146
|
+
- Primary: `C:\Users\biglmi\Documents\webapps\ui-core-micha`
|
|
147
|
+
- Secondary (scope D): `C:\Users\biglmi\Documents\webapps\django-core-micha`
|
|
148
|
+
- Never the workspace root `…\webapps`.
|
|
149
|
+
Both are platform repos: commit directly to the trunk, no feature branches.
|
|
150
|
+
|
|
151
|
+
## PROGRESS CONTRACT
|
|
152
|
+
Emit a `PLAN: <step1> | <step2> | …` line up front, then a single-line
|
|
153
|
+
`PROGRESS: [<n>/<total>] <present-tense action>` **before every relevant action** (file opened, file
|
|
154
|
+
edited, command/test run) and `PROGRESS: [<n>/<total>] done` on step completion, spaced so no gap
|
|
155
|
+
exceeds ~2 min. stdout unbuffered. Exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
156
|
+
|
|
157
|
+
## MINI-HANDOVER (paste into a fresh Orchestrator session)
|
|
158
|
+
```
|
|
159
|
+
Orchestrator: implement work-orders/NOTIF-12.md in ui-core-micha (main; scope D also touches
|
|
160
|
+
django-core-micha). Check the hard gate first — NOTIF-13 must be landed and published. git pull,
|
|
161
|
+
read the WO, then follow orchestrate-codex (Codex-first, own independent review, commit on green).
|
|
162
|
+
```
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# WORK ORDER NOTIF-13 (ui-core-micha + django-core-micha) — Layer-1 transport extraction
|
|
2
|
+
|
|
3
|
+
**EXECUTION DIRECTIVE.** Implement through `codex exec` in the background — invoked **directly via
|
|
4
|
+
Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
|
|
5
|
+
and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
|
|
6
|
+
file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
7
|
+
|
|
8
|
+
Feature-level WO (Expertenchat envelope). The seams below are **verified against the landed code**
|
|
9
|
+
(ucm 2.12.0, dcm 2.32.0) and are the required scope. The Orchestrator fills the exact line map.
|
|
10
|
+
|
|
11
|
+
## TIER
|
|
12
|
+
Tier 2 — restructures a provider that **cockpit consumes in production**, and changes a WebSocket
|
|
13
|
+
message contract shared across apps. Independent `reviewer` mandatory. Per AGENTS.md Tier 2,
|
|
14
|
+
`sec_reviewer` is NOT auto-included; request it only if the operator asks.
|
|
15
|
+
|
|
16
|
+
## GOAL
|
|
17
|
+
Turn ucm's notification-only socket into a generic **Layer-1 realtime primitive**: one socket,
|
|
18
|
+
subscribe-by-message-type, so a second domain (messaging) can ride it without being misread as a
|
|
19
|
+
notification. **Additive**: `NotificationsProvider` remains the sole mount point and its public
|
|
20
|
+
context value is unchanged (operator decision 2026-07-29).
|
|
21
|
+
|
|
22
|
+
## EXPECTED OUTCOME
|
|
23
|
+
- ucm exports a `useRealtime()`-style subscribe primitive alongside the unchanged `useNotifications()`.
|
|
24
|
+
- An unknown/unsubscribed message type is **ignored** instead of being appended to the notification feed.
|
|
25
|
+
- No app has to change its provider tree. cockpit and jg keep working untouched.
|
|
26
|
+
- NOTIF-15 (jg bell adoption, retiring jg's local context) becomes implementable; Phase B messaging
|
|
27
|
+
gets its substrate.
|
|
28
|
+
|
|
29
|
+
## CONTEXT PACKAGE — verified current state
|
|
30
|
+
|
|
31
|
+
**ucm `src/notifications/NotificationsProvider.jsx` (232 LOC)** owns *both* the socket and the
|
|
32
|
+
notifications domain state:
|
|
33
|
+
- socket ownership: `socketRef`, `connect()`, exponential backoff `INITIAL_BACKOFF_MS` 1s →
|
|
34
|
+
`MAX_BACKOFF_MS` 30s, auth-gated effect keyed on `[authenticated, refresh, replaceNotifications, wsUrlBase]` (`:87-179`).
|
|
35
|
+
- `handleMessage` (`:103-133`) dispatches on exactly **one** envelope type, `notification.status` (`:111`).
|
|
36
|
+
- **The blocker:** everything else falls through to `normalizeNotificationPush(data)` and is appended
|
|
37
|
+
to the feed with `unreadCount + 1` (`:130-132`). A messaging payload on this socket becomes a fake
|
|
38
|
+
notification today.
|
|
39
|
+
- **Field overload — the reason a third stream cannot simply be added:** in the status branch
|
|
40
|
+
`data.type` is the *envelope* type; in the new-notification branch `normalizeNotificationPush`
|
|
41
|
+
reads `data.type` as the *domain* notification-type key (`:25`). One field, two meanings. The
|
|
42
|
+
envelope contract must resolve this without breaking either reading.
|
|
43
|
+
- WS URL hardcoded to `/ws/notifications/` (`:36`, `:40`).
|
|
44
|
+
- exports in `src/index.js:48` (`NotificationsProvider`, `useNotifications`).
|
|
45
|
+
|
|
46
|
+
**dcm transport is ALREADY generic — correct the roadmap wording here.** The roadmap §4 calls for a
|
|
47
|
+
"dcm multi-stream consumer"; that overstates the dcm work:
|
|
48
|
+
- `notifications/delivery.py::push_to_users(users, payload)` (`:20-43`) fans an **arbitrary** payload
|
|
49
|
+
into the per-user group `notifications_user_{id}`.
|
|
50
|
+
- `notifications/consumers.py::NotificationConsumer.message()` (`:21-22`) is `send_json(event["payload"])` —
|
|
51
|
+
fully stream-agnostic. jg's messaging **already** rides this same group via `push_to_users`.
|
|
52
|
+
- The only dcm-side envelope producer is `notifications/views.py:235` (`"type": "notification.status"`);
|
|
53
|
+
the contract is documented at `views.py:3`.
|
|
54
|
+
- **So the dcm work is the envelope contract + a thin authoring helper + doc — NOT a consumer rewrite.**
|
|
55
|
+
|
|
56
|
+
**Reference implementation to promote:** jg's `frontend/src/context/NotificationsContext.jsx` (91 LOC,
|
|
57
|
+
docstring "WebSocket owner only") already does exactly this multiplexing with
|
|
58
|
+
`registerMessageCallback` / `registerChatCallback`. Read it for the shape. **Do not edit jg here.**
|
|
59
|
+
|
|
60
|
+
## SCOPE
|
|
61
|
+
|
|
62
|
+
**A. ucm — extract the socket into an internal realtime core.** Single owner; preserve the existing
|
|
63
|
+
backoff, reconnect, auth-gating and cleanup semantics **verbatim**. Expose
|
|
64
|
+
`subscribe(type, handler) -> unsubscribe`.
|
|
65
|
+
|
|
66
|
+
**B. ucm — `NotificationsProvider` becomes the first subscriber.** It registers handlers for the
|
|
67
|
+
notification envelope types. Its returned context value stays **exactly**
|
|
68
|
+
`{ notifications, unreadCount, markSeen, markDismissed, markDone, refresh }`.
|
|
69
|
+
|
|
70
|
+
**C. Envelope contract.** Introduce an explicit envelope discriminator that does not collide with the
|
|
71
|
+
domain `type` field (see the field-overload note above). Unknown / unsubscribed envelope types are
|
|
72
|
+
**ignored** — the catch-all at `:130-132` dies. This is a deliberate behaviour change and must be
|
|
73
|
+
called out in the CHANGELOG.
|
|
74
|
+
|
|
75
|
+
**D. Backward compatibility in BOTH directions** — apps pin dcm and ucm independently, so mismatched
|
|
76
|
+
versions are normal:
|
|
77
|
+
- *new ucm + old dcm* (payload has no envelope field): must still be recognised as a new notification,
|
|
78
|
+
exactly as today. Without this the feed silently goes dead against an un-bumped backend.
|
|
79
|
+
- *old ucm + new dcm*: dcm must keep emitting the legacy fields so a ucm 2.12.0 client is unaffected
|
|
80
|
+
(**cockpit runs 2.12.0 in staging/prod**).
|
|
81
|
+
|
|
82
|
+
**E. dcm — formalize the contract.** Document the envelope next to `views.py:3` and add a thin
|
|
83
|
+
authoring helper so producers stop hand-rolling the dict. `push_to_users`'s signature stays unchanged.
|
|
84
|
+
|
|
85
|
+
## DO NOT TOUCH
|
|
86
|
+
- **jg-ferien** (`NotificationsContext.jsx`, `MessagingContext.jsx`) — that is NOTIF-15.
|
|
87
|
+
- The messaging domain itself — Phase B (`MSG-*`).
|
|
88
|
+
- `NotificationBell.jsx`, `NotificationSettings.jsx`, `feedApi.js`, the `feed/*` REST contract.
|
|
89
|
+
- `BaseSecureConsumer` / S112 permission model, `IsAuthenticatedWs`, the group name `notifications_user_{id}`.
|
|
90
|
+
- **No standalone `RealtimeProvider` export that apps must mount.** Operator decision 2026-07-29:
|
|
91
|
+
additive only. A standalone provider is a later, separate WO if a second consumer ever needs it.
|
|
92
|
+
- Reconnect/backoff semantics and the single-socket-owner invariant — preserve, do not "improve".
|
|
93
|
+
- Schema, migrations, dependencies, CI.
|
|
94
|
+
- **No app pin bumps** in this WO (cockpit/jg stay where they are).
|
|
95
|
+
|
|
96
|
+
## RISKS
|
|
97
|
+
- **Silent feed death** if C lands without D: an envelope-less payload from an older dcm would be
|
|
98
|
+
ignored instead of shown. D is not optional.
|
|
99
|
+
- **cockpit is a live consumer** at ucm 2.12.0 — any change to the returned context shape breaks it.
|
|
100
|
+
- **Subscription leaks / double delivery:** `subscribe` must return a stable unsubscribe, and handlers
|
|
101
|
+
must **survive reconnects** without the caller re-registering them.
|
|
102
|
+
- **Reconnect loop:** the socket effect currently depends on `refresh` / `replaceNotifications`;
|
|
103
|
+
moving the socket out must not make the socket re-create on every feed update.
|
|
104
|
+
|
|
105
|
+
## REQUIRED TESTS / ACCEPTANCE
|
|
106
|
+
ucm runs `vitest` (`pnpm test`) and has **no tests under `src/notifications/` today** — this WO adds
|
|
107
|
+
the first ones:
|
|
108
|
+
- an unknown envelope type is ignored: feed and `unreadCount` both untouched (the regression that
|
|
109
|
+
protects messaging from being misread as a notification);
|
|
110
|
+
- two subscribers on distinct types each receive only their own messages, from **one** socket —
|
|
111
|
+
assert exactly one `WebSocket` was constructed;
|
|
112
|
+
- `unsubscribe` stops delivery for that subscriber and does not affect the others;
|
|
113
|
+
- handlers keep receiving after a close → backoff → reopen cycle, with no re-registration by the caller;
|
|
114
|
+
- a legacy envelope-less payload still lands in the feed (backward-compat D);
|
|
115
|
+
- `notification.status` handling and the `unreadCount` delta are unchanged — this guards the NOTIF-6
|
|
116
|
+
R1 fix (badge went stale after a cross-device mark);
|
|
117
|
+
- `NotificationsProvider`'s public context value is unchanged.
|
|
118
|
+
|
|
119
|
+
Plus `pnpm build` (tsc) green. If E lands in dcm, run the dcm notifications `pytest` package.
|
|
120
|
+
|
|
121
|
+
## RELEASE
|
|
122
|
+
ucm minor bump + publish (publish-from-main). dcm only if E lands there — then publish-from-main with
|
|
123
|
+
a **registry live-check before any app pin bump**. No app pin bumps in this WO.
|
|
124
|
+
|
|
125
|
+
## TARGET REPO / WORKING DIRECTORY
|
|
126
|
+
- Primary: `C:\Users\biglmi\Documents\webapps\ui-core-micha`
|
|
127
|
+
- Secondary (scope E only): `C:\Users\biglmi\Documents\webapps\django-core-micha`
|
|
128
|
+
- Never the workspace root `…\webapps`.
|
|
129
|
+
Both are platform repos: commit directly to the trunk, no feature branches.
|
|
130
|
+
|
|
131
|
+
## PROGRESS CONTRACT
|
|
132
|
+
Emit a `PLAN: <step1> | <step2> | …` line up front, then a single-line
|
|
133
|
+
`PROGRESS: [<n>/<total>] <present-tense action>` **before every relevant action** (file opened, file
|
|
134
|
+
edited, command/test run) and `PROGRESS: [<n>/<total>] done` on step completion, spaced so no gap
|
|
135
|
+
exceeds ~2 min. stdout unbuffered. Exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
136
|
+
|
|
137
|
+
## MINI-HANDOVER (paste into a fresh Orchestrator session)
|
|
138
|
+
```
|
|
139
|
+
Orchestrator: implement work-orders/NOTIF-13.md in ui-core-micha (main; scope E also touches
|
|
140
|
+
django-core-micha). git pull first, read the WO, then follow orchestrate-codex (Codex-first, own
|
|
141
|
+
independent review, commit on green).
|
|
142
|
+
```
|