From 8fb01126b8282ba430a92c8aaf237feda712bd79 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 17:47:22 +0500 Subject: [PATCH 01/25] =?UTF-8?q?=D0=BF=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/index.js b/server/src/index.js index 912e003..f84e2c3 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -19,7 +19,6 @@ import { prisma } from './lib/prisma.js' import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' import { registerAuth } from './plugins/auth.js' import { registerApiRoutes } from './routes/api.js' -import { registerAuthRoutes } from './routes/auth.js' import { registerOAuthSocialRoutes } from './routes/oauth-social.js' import { registerUploadsResized } from './routes/uploads-resized.js' import { registerUserNotificationRoutes } from './routes/user/notifications.js' From 76c8564e77a31f10b6a7b1a050cc96636b3771c2 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:08:24 +0500 Subject: [PATCH 02/25] docs: SSE realtime design spec --- .../specs/2026-05-22-sse-realtime-design.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-22-sse-realtime-design.md diff --git a/docs/superpowers/specs/2026-05-22-sse-realtime-design.md b/docs/superpowers/specs/2026-05-22-sse-realtime-design.md new file mode 100644 index 0000000..ca906cf --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-sse-realtime-design.md @@ -0,0 +1,146 @@ +# SSE Realtime Design + +## Goal + +Replace HTTP polling (`refetchInterval: 45_000`) with Server-Sent Events (SSE) for real-time updates: chat messages, unread counters, order status changes, admin notifications. + +## Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Technology | SSE over WebSocket | Native browser API, auto-reconnect, simpler server code. Messages still sent via HTTP POST. | +| SSE approach | Direct EventBus bridge (no recovery buffer) | Sufficient for shop scale. EventSource has built-in auto-reconnect. | +| Connection lifecycle | Connect on login, close on logout | SSE created when JWT appears in Effector `$token`, closed on `null`. | +| Auth method | JWT in query param (`?token=`) | EventSource doesn't support custom headers. Server's `authenticate` decorator already handles `request.query?.token`. | + +## Server-side + +### New file: `server/src/routes/sse.js` + +Single SSE endpoint: + +``` +GET /api/sse/stream?token=JWT +PreHandler: fastify.authenticate +``` + +**Behavior:** +1. Sets SSE headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`. +2. Sends heartbit comment every 30s: `:heartbit\n\n` (invisible to EventSource, keeps connection alive). +3. Subscribes to `request.server.eventBus`, filters events by user identity, pushes matching events through SSE. +4. On `response.raw.on('close')` — removes EventBus listeners. + +**Event mapping (EventBus → SSE):** + +| EventBus event | Who receives | SSE `event` type | Payload | +|---|---|---|---| +| `orderMessage:adminReply` | User (order.userId) | `message:new` | `{ orderId, messageId, preview }` | +| `order:statusChanged` | User (order.userId) | `order:statusChanged` | `{ orderId, newStatus }` | +| `payment:statusChanged` | User (order.userId) | `order:statusChanged` | `{ orderId, paymentStatus }` | +| `order:deliveryFeeAdjusted` | User (order.userId) | `order:updated` | `{ orderId }` | +| `orderMessage:sent` | Admin (all admins) | `message:new` | `{ orderId, messageId, preview }` | +| `order:created` | Admin | `order:new` | `{ orderId }` | + +**Admin filtering:** If `request.user` is admin (checked via email match), subscribe to all admin events without userId filtering. Currently only one admin exists, so this is straightforward. + +### Modified file: `server/src/index.js` + +Add import and registration: + +```js +import { registerSseRoutes } from './routes/sse.js' +// ... +await registerSseRoutes(fastify) +``` + +No other server changes needed. Existing `authenticate` decorator (line 75-84) already supports `request.query?.token`. + +## Client-side + +### New file: `client/src/shared/lib/sse.ts` + +Factory function: + +```ts +export function createEventStream(token: string): EventSource { + return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`) +} +``` + +### New file: `client/src/app/providers/SseProvider.tsx` + +React component that bridges SSE events to React Query cache invalidation: + +1. Subscribes to `$token` from Effector (`@/shared/model/auth`). +2. When token appears → creates `EventSource` via `createEventStream(token)`. +3. When token becomes `null` → closes `EventSource`. +4. Registers `onmessage` handlers for each SSE event type: + +| SSE event | Handler | +|---|---| +| `message:new` | User side: `invalidateQueries(['me', 'messages', 'unread-count'])`, `invalidateQueries(['me', 'conversations'])`, `invalidateQueries(['me', 'orders', orderId])`. Admin side: `invalidateQueries(['admin', 'orders', orderId])`. | +| `order:statusChanged` | `invalidateQueries(['me', 'orders', orderId])`. Admin (if viewing): `invalidateQueries(['admin', 'orders', orderId])`. | +| `order:new` | Admin: `invalidateQueries(['admin', 'orders', 'summary'])`, `invalidateQueries(['admin', 'orders'])`. | +| `order:updated` | `invalidateQueries(['me', 'orders', orderId])`. Admin (if viewing): `invalidateQueries(['admin', 'orders', orderId])`. | + +React Query's `invalidateQueries` only refetches active (currently mounted) queries. Inactive queries are just marked stale. This means SseProvider can call `invalidateQueries(['me', 'orders', orderId])` unconditionally — it will only refetch if the user has that order's chat page open. + +5. Uses `useQueryClient()` to access the query client. + +### Modified files + +**`client/src/app/providers/AppProviders.tsx`:** +- Add `` as a child of `QueryClientProvider` (or wrap it around, needs `queryClient`). + +**`client/src/pages/me/ui/MeLayoutPage.tsx`:** +- Remove `refetchInterval: 45_000` from the unread count query. +- Remove `refetchOnWindowFocus: true` (revert to global default `false`). + +**`client/src/pages/admin-layout/ui/AdminLayoutPage.tsx`:** +- Remove `refetchInterval: 45_000` from the orders summary query. +- Remove `refetchOnWindowFocus: true` (revert to global default `false`). + +## Data Flow (example: admin replies to user) + +``` +Admin → POST /api/admin/orders/:id/messages + → Prisma: creates OrderMessage + → EventBus: emit('orderMessage:adminReply', { orderId, userId, messageId, preview }) + → dispatchNotification: email/telegram to user (existing behavior, unchanged) + → SSE handler: filters by userId, formats as SSE event + +Client (SseProvider): + → SSE event 'message:new' received + → invalidateQueries(['me', 'messages', 'unread-count']) → badge updates + → invalidateQueries(['me', 'conversations']) → dialog list updates + → invalidateQueries(['me', 'orders', orderId]) → chat updates (only if that order's query is active) +``` + +## Files Summary + +| File | Status | Description | +|---|---|---| +| `server/src/routes/sse.js` | New | SSE endpoint, EventBus→SSE bridge | +| `server/src/index.js` | Modify | Import and register SSE routes | +| `client/src/shared/lib/sse.ts` | New | EventSource factory | +| `client/src/app/providers/SseProvider.tsx` | New | SSE→ReactQuery bridge component | +| `client/src/app/providers/AppProviders.tsx` | Modify | Mount SseProvider | +| `client/src/pages/me/ui/MeLayoutPage.tsx` | Modify | Remove refetchInterval | +| `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` | Modify | Remove refetchInterval | + +## Testing + +**Server (vitest):** +- SSE endpoint returns correct headers (`text/event-stream`, `no-cache`, `keep-alive`). +- SSE endpoint sends heartbit comment on connect. +- When EventBus emits an event relevant to the connected user, SSE stream contains the formatted event. +- When EventBus emits an event for a different user, SSE stream does NOT receive it. +- EventSource cleanup: listeners removed on response close. +- Admin receives all admin events regardless of userId. + +**Client (vitest + jsdom):** +- `createEventStream(token)` returns EventSource with correct URL including token. +- `SseProvider` creates EventSource when `$token` is set. +- `SseProvider` closes EventSource when `$token` becomes null. +- `SseProvider` calls `queryClient.invalidateQueries` with correct keys on each SSE event type. +- No EventSource created when `$token` is null. From 3212d6c185faf638f46d4230fdbad9f5695aaa7e Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:18:54 +0500 Subject: [PATCH 03/25] docs: SSE realtime implementation plan --- .../plans/2026-05-22-sse-realtime.md | 824 ++++++++++++++++++ 1 file changed, 824 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-22-sse-realtime.md diff --git a/docs/superpowers/plans/2026-05-22-sse-realtime.md b/docs/superpowers/plans/2026-05-22-sse-realtime.md new file mode 100644 index 0000000..4659666 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-sse-realtime.md @@ -0,0 +1,824 @@ +# SSE Realtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace HTTP polling with Server-Sent Events (SSE) for real-time updates of chat messages, unread counters, order status changes, and admin notifications. + +**Architecture:** New SSE route on server bridges existing EventBus events to SSE streams. Client-side SseProvider manages EventSource lifecycle (connect on login, close on logout) and invalidates React Query caches on incoming events. + +**Tech Stack:** Fastify (raw SSE via `reply.raw`), EventSource API, Effector (`$token` store), React Query (`invalidateQueries`), vitest. + +--- + +## File Map + +| File | Responsibility | +|---|---| +| `server/src/routes/sse.js` | SSE endpoint, EventBus listener bridge | +| `server/src/routes/__tests__/sse.test.js` | Server tests | +| `server/src/index.js` | Import and register SSE routes | +| `client/src/shared/lib/sse.ts` | EventSource factory | +| `client/src/app/providers/SseProvider.tsx` | SSE→ReactQuery invalidation bridge | +| `client/src/app/providers/__tests__/SseProvider.test.tsx` | Client tests | +| `client/src/app/providers/AppProviders.tsx` | Mount SseProvider | +| `client/src/pages/me/ui/MeLayoutPage.tsx` | Remove refetchInterval | +| `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` | Remove refetchInterval | + +--- + +### Task 1: Server — write SSE route tests (TDD red) + +**Files:** +- Create: `server/src/routes/__tests__/sse.test.js` + +- [ ] **Step 1: Write the test file** + +```js +import Fastify from 'fastify' +import { EventEmitter } from 'node:events' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js' + +describe('formatSSE', () => { + it('formats event with data', () => { + const result = formatSSE('message:new', { orderId: 'o1' }) + expect(result).toBe('event: message:new\ndata: {"orderId":"o1"}\n\n') + }) + + it('formats event without data', () => { + const result = formatSSE('heartbit') + expect(result).toBe('event: heartbit\n\n') + }) +}) + +describe('formatHeartbit', () => { + it('returns SSE comment', () => { + expect(formatHeartbit()).toBe(':heartbit\n\n') + }) +}) + +describe('isAdminUser', () => { + it('returns false for non-matching email', () => { + expect(isAdminUser({ email: 'user@test.com' })).toBe(false) + }) + + it('returns true when email matches ADMIN_EMAIL', () => { + const adminEmail = process.env.ADMIN_EMAIL + if (adminEmail) { + expect(isAdminUser({ email: adminEmail })).toBe(true) + } + }) + + it('returns false for null/undefined user', () => { + expect(isAdminUser(null)).toBe(false) + expect(isAdminUser(undefined)).toBe(false) + }) +}) + +describe('buildSseListeners', () => { + let eventBus + let write + + beforeEach(() => { + eventBus = new EventEmitter() + eventBus.setMaxListeners(50) + write = vi.fn() + }) + + it('forwards orderMessage:adminReply to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: message:new') + expect(write.mock.calls[0][0]).toContain('"orderId":"o1"') + cleanup() + }) + + it('ignores orderMessage:adminReply for non-matching userId', () => { + const cleanup = buildSseListeners('user-2', false, eventBus, write) + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('forwards orderMessage:sent to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: message:new') + cleanup() + }) + + it('ignores orderMessage:sent for non-admin', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('forwards order:statusChanged to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"') + cleanup() + }) + + it('forwards payment:statusChanged to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + cleanup() + }) + + it('forwards order:deliveryFeeAdjusted to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:updated') + cleanup() + }) + + it('forwards order:created to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:new') + cleanup() + }) + + it('forwards order:created:admin to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:created:admin', { orderId: 'o1', userId: 'user-1', userEmail: 'user@test.com' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:new') + cleanup() + }) + + it('ignores order:created for non-admin', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('cleanup removes all listeners', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + cleanup() + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).not.toHaveBeenCalled() + }) +}) + +describe('GET /api/sse/stream (integration)', () => { + let app + + beforeAll(async () => { + app = Fastify({ logger: false }) + app.decorate('authenticate', async function (request, reply) { + try { + const token = request.query?.token + if (!token) throw new Error('no token') + if (token === 'user-token') { + request.user = { sub: 'user-1', email: 'user@test.com' } + } else if (token === 'admin-token') { + request.user = { sub: 'admin-1', email: process.env.ADMIN_EMAIL || 'admin@test.com' } + } else { + throw new Error('bad token') + } + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', new EventEmitter()) + await registerSseRoutes(app) + await app.ready() + }) + + afterAll(async () => { + await app.close() + }) + + it('returns 401 without token', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream' }) + expect(res.statusCode).toBe(401) + }) + + it('returns 200 and event-stream headers for authenticated user', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe('text/event-stream') + expect(res.headers['cache-control']).toBe('no-cache') + expect(res.headers['connection']).toBe('keep-alive') + }) + + it('sends initial heartbit', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' }) + expect(res.body).toContain(':heartbit') + }) +}) +``` + +- [ ] **Step 2: Run tests — expect FAIL (sse.js does not exist)** + +Run: `cd server && npx vitest run src/routes/__tests__/sse.test.js` +Expected: FAIL — module `../sse.js` not found + +--- + +### Task 2: Server — implement SSE route (TDD green) + +**Files:** +- Create: `server/src/routes/sse.js` + +- [ ] **Step 1: Write sse.js with exported helpers** + +```js +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' + +const { + ORDER_CREATED, + ORDER_STATUS_CHANGED, + ORDER_MESSAGE_SENT, + ORDER_MESSAGE_ADMIN_REPLY, + PAYMENT_STATUS_CHANGED, + DELIVERY_FEE_ADJUSTED, +} = NOTIFICATION_EVENTS + +export function isAdminUser(user) { + return user?.email === process.env.ADMIN_EMAIL +} + +export function formatSSE(event, data) { + const lines = [`event: ${event}`] + if (data !== undefined) { + lines.push(`data: ${JSON.stringify(data)}`) + } + return lines.join('\n') + '\n\n' +} + +export function formatHeartbit() { + return ':heartbit\n\n' +} + +export function buildSseListeners(userId, admin, eventBus, write) { + const listeners = [] + + function on(eventName, filterFn, sseEvent, dataFn) { + function handler(payload) { + if (!filterFn(payload)) return + write(formatSSE(sseEvent, dataFn(payload))) + } + listeners.push({ eventName, handler }) + eventBus.on(eventName, handler) + } + + on( + ORDER_MESSAGE_ADMIN_REPLY, + (p) => p.userId === userId, + 'message:new', + (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }), + ) + + on( + ORDER_MESSAGE_SENT, + () => admin, + 'message:new', + (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }), + ) + + on( + ORDER_STATUS_CHANGED, + (p) => p.userId === userId, + 'order:statusChanged', + (p) => ({ orderId: p.orderId, newStatus: p.newStatus }), + ) + + on( + PAYMENT_STATUS_CHANGED, + (p) => p.userId === userId, + 'order:statusChanged', + (p) => ({ orderId: p.orderId }), + ) + + on( + DELIVERY_FEE_ADJUSTED, + (p) => p.userId === userId, + 'order:updated', + (p) => ({ orderId: p.orderId }), + ) + + on( + ORDER_CREATED, + () => admin, + 'order:new', + (p) => ({ orderId: p.orderId }), + ) + + on( + 'order:created:admin', + () => admin, + 'order:new', + (p) => ({ orderId: p.orderId }), + ) + + return function cleanup() { + for (const { eventName, handler } of listeners) { + eventBus.off(eventName, handler) + } + } +} + +export async function registerSseRoutes(fastify) { + fastify.get('/api/sse/stream', { preHandler: [fastify.authenticate] }, async (request, reply) => { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }) + + const userId = request.user.sub + const admin = isAdminUser(request.user) + + reply.raw.write(formatHeartbit()) + + const heartbitTimer = setInterval(() => { + reply.raw.write(formatHeartbit()) + }, 30_000) + + const cleanup = buildSseListeners(userId, admin, fastify.eventBus, (chunk) => { + reply.raw.write(chunk) + }) + + request.raw.on('close', () => { + clearInterval(heartbitTimer) + cleanup() + }) + }) +} +``` + +- [ ] **Step 2: Run tests — expect PASS** + +Run: `cd server && npx vitest run src/routes/__tests__/sse.test.js` +Expected: all PASS + +- [ ] **Step 3: Commit both files** + +```bash +git add server/src/routes/sse.js server/src/routes/__tests__/sse.test.js +git commit -m "feat: add SSE route with EventBus bridge and tests" +``` + +--- + +### Task 3: Server — register SSE routes in index.js + +**Files:** +- Modify: `server/src/index.js` + +- [ ] **Step 1: Add import** + +After line 27 (`import { registerUserMessageRoutes } from './routes/user-messages.js'`), add: + +```js +import { registerSseRoutes } from './routes/sse.js' +``` + +- [ ] **Step 2: Add registration** + +After line 94 (`await registerUserMessageRoutes(fastify)`), add: + +```js +await registerSseRoutes(fastify) +``` + +- [ ] **Step 3: Verify server starts** + +Run: `cd server && timeout 5 npm run dev 2>&1 || true` +Expected: no crash, server starts listening + +- [ ] **Step 4: Commit** + +```bash +git add server/src/index.js +git commit -m "feat: register SSE routes in server" +``` + +--- + +### Task 4: Client — EventSource factory + +**Files:** +- Create: `client/src/shared/lib/sse.ts` + +- [ ] **Step 1: Write the factory** + +```ts +export function createEventStream(token: string): EventSource { + return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add client/src/shared/lib/sse.ts +git commit -m "feat: add EventSource factory for SSE" +``` + +--- + +### Task 5: Client — write SseProvider tests (TDD red) + +**Files:** +- Create: `client/src/app/providers/__tests__/SseProvider.test.tsx` + +- [ ] **Step 1: Write the test file** + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SseProvider } from '../SseProvider' + +const mockInvalidateQueries = vi.fn() + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query') + return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) } +}) + +vi.mock('@/shared/model/auth', () => ({ + $token: { defaultState: null, subscribe: () => () => {}, getState: () => null, watch: () => () => {}, on: () => {}, reset: () => {} }, +})) + +let mockToken: string | null = null +let mockEventHandlers: Record void> = {} +let mockCloseCalls = 0 + +class MockEventSource { + url: string + constructor(url: string) { + this.url = url + mockCloseCalls = 0 + mockEventHandlers = {} + } + addEventListener(type: string, handler: (event: MessageEvent) => void) { + mockEventHandlers[type] = handler + } + removeEventListener(type: string, _handler: (event: MessageEvent) => void) { + delete mockEventHandlers[type] + } + close() { mockCloseCalls++ } +} + +vi.mock('@/shared/lib/sse', () => ({ + createEventStream: (token: string) => { + mockToken = token + return new MockEventSource(`/api/sse/stream?token=${token}`) as unknown as EventSource + }, +})) + +vi.mock('effector-react', async () => { + const actual = await vi.importActual('effector-react') + return { ...actual, useUnit: () => mockToken } +}) + +function renderSse() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render() +} + +describe('SseProvider', () => { + afterEach(() => { + mockToken = null + mockInvalidateQueries.mockReset() + mockCloseCalls = 0 + mockEventHandlers = {} + }) + + it('renders nothing (returns null)', () => { + mockToken = null + const { container } = renderSse() + expect(container.innerHTML).toBe('') + }) + + it('does not create EventSource when token is null', () => { + mockToken = null + renderSse() + expect(mockToken).toBeNull() + }) + + it('creates EventSource when token is set', () => { + mockToken = 'test-jwt' + renderSse() + expect(mockToken).toBe('test-jwt') + }) + + it('closes EventSource on unmount', () => { + mockToken = 'test-jwt' + const { unmount } = renderSse() + expect(mockCloseCalls).toBe(0) + unmount() + expect(mockCloseCalls).toBe(1) + }) + + it('invalidates unread-count and conversations on message:new', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['message:new'] + expect(handler).toBeDefined() + handler(new MessageEvent('message:new', { data: JSON.stringify({ orderId: 'o1' }) })) + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o1'] }) + }) + + it('invalidates order queries on order:statusChanged', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['order:statusChanged'] + handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o2'] }) + }) + + it('invalidates order queries on order:updated', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['order:updated'] + handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o3'] }) + }) + + it('invalidates admin queries on order:new', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['order:new'] + handler(new MessageEvent('order:new', { data: JSON.stringify({ orderId: 'o4' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] }) + }) + + it('handles invalid JSON gracefully', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['message:new'] + expect(() => { handler(new MessageEvent('message:new', { data: ':heartbit' })) }).not.toThrow() + expect(mockInvalidateQueries).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run tests — expect FAIL (SseProvider does not exist)** + +Run: `cd client && npx vitest run src/app/providers/__tests__/SseProvider.test.tsx` +Expected: FAIL — module `../SseProvider` not found + +--- + +### Task 6: Client — implement SseProvider (TDD green) + +**Files:** +- Create: `client/src/app/providers/SseProvider.tsx` + +- [ ] **Step 1: Write the SseProvider component** + +```tsx +import { useEffect, useRef } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useUnit } from 'effector-react' +import { createEventStream } from '@/shared/lib/sse' +import { $token } from '@/shared/model/auth' + +export function SseProvider() { + const token = useUnit($token) + const queryClient = useQueryClient() + const sourceRef = useRef(null) + + useEffect(() => { + if (!token) { + if (sourceRef.current) { + sourceRef.current.close() + sourceRef.current = null + } + return + } + + const es = createEventStream(token) + sourceRef.current = es + + function handleEvent(eventName: string) { + return function (event: MessageEvent) { + try { + const data = JSON.parse(event.data) + const orderId = data.orderId + + switch (eventName) { + case 'message:new': + queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] }) + queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] }) + if (orderId) { + queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] }) + } + break + case 'order:statusChanged': + if (orderId) { + queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] }) + } + break + case 'order:updated': + if (orderId) { + queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] }) + } + break + case 'order:new': + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] }) + break + } + } catch { + // ignore parse errors (e.g. heartbit comments) + } + } + } + + const messageNewHandler = handleEvent('message:new') + const orderStatusHandler = handleEvent('order:statusChanged') + const orderUpdatedHandler = handleEvent('order:updated') + const orderNewHandler = handleEvent('order:new') + + es.addEventListener('message:new', messageNewHandler) + es.addEventListener('order:statusChanged', orderStatusHandler) + es.addEventListener('order:updated', orderUpdatedHandler) + es.addEventListener('order:new', orderNewHandler) + + return () => { + es.removeEventListener('message:new', messageNewHandler) + es.removeEventListener('order:statusChanged', orderStatusHandler) + es.removeEventListener('order:updated', orderUpdatedHandler) + es.removeEventListener('order:new', orderNewHandler) + es.close() + sourceRef.current = null + } + }, [token, queryClient]) + + return null +} +``` + +- [ ] **Step 2: Run tests — expect PASS** + +Run: `cd client && npx vitest run src/app/providers/__tests__/SseProvider.test.tsx` +Expected: all PASS + +- [ ] **Step 3: Commit both files** + +```bash +git add client/src/shared/lib/sse.ts client/src/app/providers/SseProvider.tsx client/src/app/providers/__tests__/SseProvider.test.tsx +git commit -m "feat: add SseProvider — SSE to ReactQuery bridge with tests" +``` + +--- + +### Task 7: Client — mount SseProvider in AppProviders + +**Files:** +- Modify: `client/src/app/providers/AppProviders.tsx` + +- [ ] **Step 1: Add import and mount** + +```tsx +import { SseProvider } from './SseProvider' +``` + +Inside the return, add `` as first child of `QueryClientProvider`: + +```tsx + + + + {children} + + +``` + +- [ ] **Step 2: Verify build** + +Run: `cd client && npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add client/src/app/providers/AppProviders.tsx +git commit -m "feat: mount SseProvider in AppProviders" +``` + +--- + +### Task 8: Client — remove polling from MeLayoutPage + +**Files:** +- Modify: `client/src/pages/me/ui/MeLayoutPage.tsx` + +- [ ] **Step 1: Remove refetchInterval and refetchOnWindowFocus** + +Find the unread query. Remove `refetchInterval: 45_000` and `refetchOnWindowFocus: true`: + +```ts +const unreadQuery = useQuery({ + queryKey: ['me', 'messages', 'unread-count'], + queryFn: fetchUnreadMessageCount, + enabled: Boolean(user), +}) +``` + +- [ ] **Step 2: Verify TypeScript** + +Run: `cd client && npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add client/src/pages/me/ui/MeLayoutPage.tsx +git commit -m "feat: remove polling from MeLayoutPage — replaced by SSE" +``` + +--- + +### Task 9: Client — remove polling from AdminLayoutPage + +**Files:** +- Modify: `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` + +- [ ] **Step 1: Remove refetchInterval and refetchOnWindowFocus** + +Find the orders summary query. Remove `refetchInterval: 45_000` and `refetchOnWindowFocus: true`: + +```ts +const ordersSummaryQuery = useQuery({ + queryKey: ['admin', 'orders', 'summary'], + queryFn: fetchAdminOrdersSummary, + enabled: isAdmin, +}) +``` + +- [ ] **Step 2: Verify TypeScript** + +Run: `cd client && npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +git commit -m "feat: remove admin polling from AdminLayoutPage — replaced by SSE" +``` + +--- + +### Task 10: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run server tests** + +Run: `cd server && npm test -- --run` +Expected: all tests pass + +- [ ] **Step 2: Run client tests** + +Run: `cd client && npm test -- --run` +Expected: all tests pass + +- [ ] **Step 3: Run client lint** + +Run: `cd client && npm run lint` +Expected: no errors + +- [ ] **Step 4: Run server lint** + +Run: `cd server && npm run lint` +Expected: no errors + +- [ ] **Step 5: Run client format check** + +Run: `cd client && npm run format:check` +Expected: no formatting issues + +- [ ] **Step 6: Run client build** + +Run: `cd client && npm run build` +Expected: successful build From 6b89f422691cb9e267eacd2ea4c4468b322e42d5 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:23:11 +0500 Subject: [PATCH 04/25] test: add SSE route tests (TDD red) --- server/src/routes/__tests__/sse.test.js | 185 ++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 server/src/routes/__tests__/sse.test.js diff --git a/server/src/routes/__tests__/sse.test.js b/server/src/routes/__tests__/sse.test.js new file mode 100644 index 0000000..472b7a1 --- /dev/null +++ b/server/src/routes/__tests__/sse.test.js @@ -0,0 +1,185 @@ +import Fastify from 'fastify' +import { EventEmitter } from 'node:events' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js' + +describe('formatSSE', () => { + it('formats event with data', () => { + const result = formatSSE('message:new', { orderId: 'o1' }) + expect(result).toBe('event: message:new\ndata: {"orderId":"o1"}\n\n') + }) + + it('formats event without data', () => { + const result = formatSSE('heartbit') + expect(result).toBe('event: heartbit\n\n') + }) +}) + +describe('formatHeartbit', () => { + it('returns SSE comment', () => { + expect(formatHeartbit()).toBe(':heartbit\n\n') + }) +}) + +describe('isAdminUser', () => { + it('returns false for non-matching email', () => { + expect(isAdminUser({ email: 'user@test.com' })).toBe(false) + }) + + it('returns true when email matches ADMIN_EMAIL', () => { + const adminEmail = process.env.ADMIN_EMAIL + if (adminEmail) { + expect(isAdminUser({ email: adminEmail })).toBe(true) + } + }) + + it('returns false for null/undefined user', () => { + expect(isAdminUser(null)).toBe(false) + expect(isAdminUser(undefined)).toBe(false) + }) +}) + +describe('buildSseListeners', () => { + let eventBus + let write + + beforeEach(() => { + eventBus = new EventEmitter() + eventBus.setMaxListeners(50) + write = vi.fn() + }) + + it('forwards orderMessage:adminReply to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: message:new') + expect(write.mock.calls[0][0]).toContain('"orderId":"o1"') + cleanup() + }) + + it('ignores orderMessage:adminReply for non-matching userId', () => { + const cleanup = buildSseListeners('user-2', false, eventBus, write) + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('forwards orderMessage:sent to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: message:new') + cleanup() + }) + + it('ignores orderMessage:sent for non-admin', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('forwards order:statusChanged to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"') + cleanup() + }) + + it('forwards payment:statusChanged to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + cleanup() + }) + + it('forwards order:deliveryFeeAdjusted to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:updated') + cleanup() + }) + + it('forwards order:created to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:new') + cleanup() + }) + + it('forwards order:created:admin to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:created:admin', { orderId: 'o1', userId: 'user-1', userEmail: 'user@test.com' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:new') + cleanup() + }) + + it('ignores order:created for non-admin', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('cleanup removes all listeners', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + cleanup() + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).not.toHaveBeenCalled() + }) +}) + +describe('GET /api/sse/stream (integration)', () => { + let app + + beforeAll(async () => { + app = Fastify({ logger: false }) + app.decorate('authenticate', async function (request, reply) { + try { + const token = request.query?.token + if (!token) throw new Error('no token') + if (token === 'user-token') { + request.user = { sub: 'user-1', email: 'user@test.com' } + } else if (token === 'admin-token') { + request.user = { sub: 'admin-1', email: process.env.ADMIN_EMAIL || 'admin@test.com' } + } else { + throw new Error('bad token') + } + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', new EventEmitter()) + await registerSseRoutes(app) + await app.ready() + }) + + afterAll(async () => { + await app.close() + }) + + it('returns 401 without token', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream' }) + expect(res.statusCode).toBe(401) + }) + + it('returns 200 and event-stream headers for authenticated user', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe('text/event-stream') + expect(res.headers['cache-control']).toBe('no-cache') + expect(res.headers['connection']).toBe('keep-alive') + }) + + it('sends initial heartbit', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' }) + expect(res.body).toContain(':heartbit') + }) +}) From 55dc58cff88b2d77d9f8b6f9df1edd55a17efe90 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:25:22 +0500 Subject: [PATCH 05/25] fix: gate ADMIN_EMAIL test with explicit skip --- server/src/routes/__tests__/sse.test.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/routes/__tests__/sse.test.js b/server/src/routes/__tests__/sse.test.js index 472b7a1..7c0591d 100644 --- a/server/src/routes/__tests__/sse.test.js +++ b/server/src/routes/__tests__/sse.test.js @@ -28,9 +28,11 @@ describe('isAdminUser', () => { it('returns true when email matches ADMIN_EMAIL', () => { const adminEmail = process.env.ADMIN_EMAIL - if (adminEmail) { - expect(isAdminUser({ email: adminEmail })).toBe(true) + if (!adminEmail) { + console.warn('ADMIN_EMAIL not set, skipping') + return } + expect(isAdminUser({ email: adminEmail })).toBe(true) }) it('returns false for null/undefined user', () => { From 5127d4a09396e96133f142f60450062ed4546dc3 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:33:49 +0500 Subject: [PATCH 06/25] feat: add SSE route with EventBus bridge and tests --- server/src/routes/__tests__/sse.test.js | 7 +- server/src/routes/sse.js | 125 ++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 server/src/routes/sse.js diff --git a/server/src/routes/__tests__/sse.test.js b/server/src/routes/__tests__/sse.test.js index 7c0591d..082f34d 100644 --- a/server/src/routes/__tests__/sse.test.js +++ b/server/src/routes/__tests__/sse.test.js @@ -173,7 +173,7 @@ describe('GET /api/sse/stream (integration)', () => { }) it('returns 200 and event-stream headers for authenticated user', async () => { - const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' }) + const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token', payloadAsStream: true }) expect(res.statusCode).toBe(200) expect(res.headers['content-type']).toBe('text/event-stream') expect(res.headers['cache-control']).toBe('no-cache') @@ -181,7 +181,8 @@ describe('GET /api/sse/stream (integration)', () => { }) it('sends initial heartbit', async () => { - const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' }) - expect(res.body).toContain(':heartbit') + const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token', payloadAsStream: true }) + const body = res.stream().read().toString() + expect(body).toContain(':heartbit') }) }) diff --git a/server/src/routes/sse.js b/server/src/routes/sse.js new file mode 100644 index 0000000..e8b32dd --- /dev/null +++ b/server/src/routes/sse.js @@ -0,0 +1,125 @@ +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' + +const { + ORDER_CREATED, + ORDER_STATUS_CHANGED, + ORDER_MESSAGE_SENT, + ORDER_MESSAGE_ADMIN_REPLY, + PAYMENT_STATUS_CHANGED, + DELIVERY_FEE_ADJUSTED, +} = NOTIFICATION_EVENTS + +export function isAdminUser(user) { + return !!(process.env.ADMIN_EMAIL && user?.email === process.env.ADMIN_EMAIL) +} + +export function formatSSE(event, data) { + const lines = [`event: ${event}`] + if (data !== undefined) { + lines.push(`data: ${JSON.stringify(data)}`) + } + return lines.join('\n') + '\n\n' +} + +export function formatHeartbit() { + return ':heartbit\n\n' +} + +export function buildSseListeners(userId, admin, eventBus, write) { + const listeners = [] + + function on(eventName, filterFn, sseEvent, dataFn) { + function handler(payload) { + if (!filterFn(payload)) return + write(formatSSE(sseEvent, dataFn(payload))) + } + listeners.push({ eventName, handler }) + eventBus.on(eventName, handler) + } + + on( + ORDER_MESSAGE_ADMIN_REPLY, + (p) => p.userId === userId, + 'message:new', + (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }), + ) + + on( + ORDER_MESSAGE_SENT, + () => admin, + 'message:new', + (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }), + ) + + on( + ORDER_STATUS_CHANGED, + (p) => p.userId === userId, + 'order:statusChanged', + (p) => ({ orderId: p.orderId, newStatus: p.newStatus }), + ) + + on( + PAYMENT_STATUS_CHANGED, + (p) => p.userId === userId, + 'order:statusChanged', + (p) => ({ orderId: p.orderId }), + ) + + on( + DELIVERY_FEE_ADJUSTED, + (p) => p.userId === userId, + 'order:updated', + (p) => ({ orderId: p.orderId }), + ) + + on( + ORDER_CREATED, + () => admin, + 'order:new', + (p) => ({ orderId: p.orderId }), + ) + + on( + 'order:created:admin', + () => admin, + 'order:new', + (p) => ({ orderId: p.orderId }), + ) + + return function cleanup() { + for (const { eventName, handler } of listeners) { + eventBus.off(eventName, handler) + } + } +} + +export async function registerSseRoutes(fastify) { + fastify.get('/api/sse/stream', { preHandler: [fastify.authenticate] }, async (request, reply) => { + reply.hijack() + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }) + + const userId = request.user.sub + const admin = isAdminUser(request.user) + + reply.raw.write(formatHeartbit()) + + const heartbitTimer = setInterval(() => { + reply.raw.write(formatHeartbit()) + }, 30_000) + + const cleanup = buildSseListeners(userId, admin, fastify.eventBus, (chunk) => { + reply.raw.write(chunk) + }) + + request.raw.on('close', () => { + clearInterval(heartbitTimer) + cleanup() + }) + }) +} From e2a04d04a337443e86f56a413a7fce29f21d299f Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:37:55 +0500 Subject: [PATCH 07/25] fix: add safeWrite guard and error handler for SSE socket --- server/src/routes/sse.js | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/server/src/routes/sse.js b/server/src/routes/sse.js index e8b32dd..d96e125 100644 --- a/server/src/routes/sse.js +++ b/server/src/routes/sse.js @@ -104,22 +104,37 @@ export async function registerSseRoutes(fastify) { 'X-Accel-Buffering': 'no', }) + let closed = false + + function safeWrite(chunk) { + if (closed) return + try { + reply.raw.write(chunk) + } catch { + closed = true + cleanUp() + } + } + const userId = request.user.sub const admin = isAdminUser(request.user) - reply.raw.write(formatHeartbit()) + safeWrite(formatHeartbit()) const heartbitTimer = setInterval(() => { - reply.raw.write(formatHeartbit()) + safeWrite(formatHeartbit()) }, 30_000) - const cleanup = buildSseListeners(userId, admin, fastify.eventBus, (chunk) => { - reply.raw.write(chunk) - }) + const removeListeners = buildSseListeners(userId, admin, fastify.eventBus, safeWrite) - request.raw.on('close', () => { + function cleanUp() { + if (closed) return + closed = true clearInterval(heartbitTimer) - cleanup() - }) + removeListeners() + } + + request.raw.on('close', cleanUp) + request.raw.on('error', cleanUp) }) } From 4381121f2530a61b040616e4c0d9ffd63299d48b Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:38:48 +0500 Subject: [PATCH 08/25] feat: register SSE routes in server --- server/src/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/index.js b/server/src/index.js index f84e2c3..ce32fff 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -24,6 +24,7 @@ import { registerUploadsResized } from './routes/uploads-resized.js' import { registerUserNotificationRoutes } from './routes/user/notifications.js' import { registerUserAddressRoutes } from './routes/user-addresses.js' import { registerUserCartRoutes } from './routes/user-cart.js' +import { registerSseRoutes } from './routes/sse.js' import { registerUserMessageRoutes } from './routes/user-messages.js' import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserPaymentRoutes } from './routes/user-payments.js' @@ -92,6 +93,7 @@ registerAuth(fastify) await registerUserAddressRoutes(fastify) await registerUserCartRoutes(fastify) await registerUserMessageRoutes(fastify) +await registerSseRoutes(fastify) await registerUserOrderRoutes(fastify) await registerUserPaymentRoutes(fastify) await registerUserNotificationRoutes(fastify) From a84045a68dc3c5500deea769a33ad0122a4ac9a7 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:39:10 +0500 Subject: [PATCH 09/25] feat: add EventSource factory for SSE --- client/src/shared/lib/sse.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 client/src/shared/lib/sse.ts diff --git a/client/src/shared/lib/sse.ts b/client/src/shared/lib/sse.ts new file mode 100644 index 0000000..ed9282e --- /dev/null +++ b/client/src/shared/lib/sse.ts @@ -0,0 +1,3 @@ +export function createEventStream(token: string): EventSource { + return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`) +} From a5e875292d122150588e725f9b0ad1d49b4cd00f Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:40:57 +0500 Subject: [PATCH 10/25] test: add SseProvider tests (TDD red) --- .../providers/__tests__/SseProvider.test.tsx | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 client/src/app/providers/__tests__/SseProvider.test.tsx diff --git a/client/src/app/providers/__tests__/SseProvider.test.tsx b/client/src/app/providers/__tests__/SseProvider.test.tsx new file mode 100644 index 0000000..27e5e7b --- /dev/null +++ b/client/src/app/providers/__tests__/SseProvider.test.tsx @@ -0,0 +1,135 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SseProvider } from '../SseProvider' + +const mockInvalidateQueries = vi.fn() + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query') + return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) } +}) + +vi.mock('@/shared/model/auth', () => ({ + $token: { defaultState: null, subscribe: () => () => {}, getState: () => null, watch: () => () => {}, on: () => {}, reset: () => {} }, +})) + +let mockToken: string | null = null +let mockEventHandlers: Record void> = {} +let mockCloseCalls = 0 + +class MockEventSource { + url: string + constructor(url: string) { + this.url = url + mockCloseCalls = 0 + mockEventHandlers = {} + } + addEventListener(type: string, handler: (event: MessageEvent) => void) { + mockEventHandlers[type] = handler + } + removeEventListener(type: string, _handler: (event: MessageEvent) => void) { + delete mockEventHandlers[type] + } + close() { mockCloseCalls++ } +} + +vi.mock('@/shared/lib/sse', () => ({ + createEventStream: (token: string) => { + mockToken = token + return new MockEventSource(`/api/sse/stream?token=${token}`) as unknown as EventSource + }, +})) + +vi.mock('effector-react', async () => { + const actual = await vi.importActual('effector-react') + return { ...actual, useUnit: () => mockToken } +}) + +function renderSse() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render() +} + +describe('SseProvider', () => { + afterEach(() => { + mockToken = null + mockInvalidateQueries.mockReset() + mockCloseCalls = 0 + mockEventHandlers = {} + }) + + it('renders nothing (returns null)', () => { + mockToken = null + const { container } = renderSse() + expect(container.innerHTML).toBe('') + }) + + it('does not create EventSource when token is null', () => { + mockToken = null + renderSse() + expect(mockToken).toBeNull() + }) + + it('creates EventSource when token is set', () => { + mockToken = 'test-jwt' + renderSse() + expect(mockToken).toBe('test-jwt') + }) + + it('closes EventSource on unmount', () => { + mockToken = 'test-jwt' + const { unmount } = renderSse() + expect(mockCloseCalls).toBe(0) + unmount() + expect(mockCloseCalls).toBe(1) + }) + + it('invalidates unread-count and conversations on message:new', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['message:new'] + expect(handler).toBeDefined() + handler(new MessageEvent('message:new', { data: JSON.stringify({ orderId: 'o1' }) })) + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o1'] }) + }) + + it('invalidates order queries on order:statusChanged', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['order:statusChanged'] + handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o2'] }) + }) + + it('invalidates order queries on order:updated', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['order:updated'] + handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o3'] }) + }) + + it('invalidates admin queries on order:new', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['order:new'] + handler(new MessageEvent('order:new', { data: JSON.stringify({ orderId: 'o4' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] }) + }) + + it('handles invalid JSON gracefully', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['message:new'] + expect(() => { handler(new MessageEvent('message:new', { data: ':heartbit' })) }).not.toThrow() + expect(mockInvalidateQueries).not.toHaveBeenCalled() + }) +}) From 86523cda71558e4a5735009ebffc55be0b415e60 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:43:02 +0500 Subject: [PATCH 11/25] =?UTF-8?q?feat:=20add=20SseProvider=20=E2=80=94=20S?= =?UTF-8?q?SE=20to=20ReactQuery=20bridge=20with=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/providers/SseProvider.tsx | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 client/src/app/providers/SseProvider.tsx diff --git a/client/src/app/providers/SseProvider.tsx b/client/src/app/providers/SseProvider.tsx new file mode 100644 index 0000000..b13c74f --- /dev/null +++ b/client/src/app/providers/SseProvider.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useUnit } from 'effector-react' +import { createEventStream } from '@/shared/lib/sse' +import { $token } from '@/shared/model/auth' + +export function SseProvider() { + const token = useUnit($token) + const queryClient = useQueryClient() + const sourceRef = useRef(null) + + useEffect(() => { + if (!token) { + if (sourceRef.current) { + sourceRef.current.close() + sourceRef.current = null + } + return + } + + const es = createEventStream(token) + sourceRef.current = es + + function handleEvent(eventName: string) { + return function (event: MessageEvent) { + try { + const data = JSON.parse(event.data) + const orderId = data.orderId + + switch (eventName) { + case 'message:new': + queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] }) + queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] }) + if (orderId) { + queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] }) + } + break + case 'order:statusChanged': + if (orderId) { + queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] }) + } + break + case 'order:updated': + if (orderId) { + queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] }) + } + break + case 'order:new': + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] }) + break + } + } catch { + // ignore parse errors (e.g. heartbit comments) + } + } + } + + const messageNewHandler = handleEvent('message:new') + const orderStatusHandler = handleEvent('order:statusChanged') + const orderUpdatedHandler = handleEvent('order:updated') + const orderNewHandler = handleEvent('order:new') + + es.addEventListener('message:new', messageNewHandler) + es.addEventListener('order:statusChanged', orderStatusHandler) + es.addEventListener('order:updated', orderUpdatedHandler) + es.addEventListener('order:new', orderNewHandler) + + return () => { + es.removeEventListener('message:new', messageNewHandler) + es.removeEventListener('order:statusChanged', orderStatusHandler) + es.removeEventListener('order:updated', orderUpdatedHandler) + es.removeEventListener('order:new', orderNewHandler) + es.close() + sourceRef.current = null + } + }, [token, queryClient]) + + return null +} From 3b627e8e2f48485f8d949419b4c6a86ff7e13ab3 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:45:00 +0500 Subject: [PATCH 12/25] feat: mount SseProvider, remove polling from layouts --- client/src/app/providers/AppProviders.tsx | 2 ++ client/src/pages/admin-layout/ui/AdminLayoutPage.tsx | 2 -- client/src/pages/me/ui/MeLayoutPage.tsx | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/client/src/app/providers/AppProviders.tsx b/client/src/app/providers/AppProviders.tsx index 7d02222..2e26cf5 100644 --- a/client/src/app/providers/AppProviders.tsx +++ b/client/src/app/providers/AppProviders.tsx @@ -3,6 +3,7 @@ import CssBaseline from '@mui/material/CssBaseline' import { ThemeProvider, createTheme } from '@mui/material/styles' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller' +import { SseProvider } from './SseProvider' function AppThemeInner({ children }: PropsWithChildren) { const controller = useThemeController() @@ -185,6 +186,7 @@ export function AppProviders({ children }: PropsWithChildren) { return ( + {children} diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index b7e0e73..18782b1 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -49,8 +49,6 @@ export function AdminLayoutPage() { queryKey: ['admin', 'orders', 'summary'], queryFn: fetchAdminOrdersSummary, enabled: isAdmin, - refetchInterval: 45_000, - refetchOnWindowFocus: true, }) const newOrdersAttention = ordersSummaryQuery.data?.attentionCount ?? 0 diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx index 9cff739..0d76d32 100644 --- a/client/src/pages/me/ui/MeLayoutPage.tsx +++ b/client/src/pages/me/ui/MeLayoutPage.tsx @@ -47,8 +47,6 @@ export function MeLayoutPage() { queryKey: ['me', 'messages', 'unread-count'], queryFn: fetchUnreadMessageCount, enabled: Boolean(user), - refetchInterval: 45_000, - refetchOnWindowFocus: true, }) const unreadMessages = unreadQuery.data?.count ?? 0 From a96944328dd1dbf3a4724198e444692589831967 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:46:42 +0500 Subject: [PATCH 13/25] style: prettier format SseProvider test --- .../providers/__tests__/SseProvider.test.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/client/src/app/providers/__tests__/SseProvider.test.tsx b/client/src/app/providers/__tests__/SseProvider.test.tsx index 27e5e7b..c9f55c4 100644 --- a/client/src/app/providers/__tests__/SseProvider.test.tsx +++ b/client/src/app/providers/__tests__/SseProvider.test.tsx @@ -11,7 +11,14 @@ vi.mock('@tanstack/react-query', async () => { }) vi.mock('@/shared/model/auth', () => ({ - $token: { defaultState: null, subscribe: () => () => {}, getState: () => null, watch: () => () => {}, on: () => {}, reset: () => {} }, + $token: { + defaultState: null, + subscribe: () => () => {}, + getState: () => null, + watch: () => () => {}, + on: () => {}, + reset: () => {}, + }, })) let mockToken: string | null = null @@ -31,7 +38,9 @@ class MockEventSource { removeEventListener(type: string, _handler: (event: MessageEvent) => void) { delete mockEventHandlers[type] } - close() { mockCloseCalls++ } + close() { + mockCloseCalls++ + } } vi.mock('@/shared/lib/sse', () => ({ @@ -48,7 +57,11 @@ vi.mock('effector-react', async () => { function renderSse() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return render() + return render( + + + , + ) } describe('SseProvider', () => { @@ -129,7 +142,9 @@ describe('SseProvider', () => { mockToken = 'test-jwt' renderSse() const handler = mockEventHandlers['message:new'] - expect(() => { handler(new MessageEvent('message:new', { data: ':heartbit' })) }).not.toThrow() + expect(() => { + handler(new MessageEvent('message:new', { data: ':heartbit' })) + }).not.toThrow() expect(mockInvalidateQueries).not.toHaveBeenCalled() }) }) From 02c7d7ba36d07ea5cacda354ffe8a4d6002e61d1 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 19:14:22 +0500 Subject: [PATCH 14/25] fix: review avatar uses authorId instead of displayName, show reviews for hidden products --- client/src/entities/review/api/reviews-api.ts | 2 ++ .../src/features/product-review/ui/ProductReviewsList.tsx | 2 +- client/src/widgets/reviews-block/ui/ReviewsBlock.tsx | 2 +- server/src/routes/api/public-reviews.js | 8 +++++--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/client/src/entities/review/api/reviews-api.ts b/client/src/entities/review/api/reviews-api.ts index badcf9e..b7aefc5 100644 --- a/client/src/entities/review/api/reviews-api.ts +++ b/client/src/entities/review/api/reviews-api.ts @@ -25,6 +25,7 @@ export type PublicReviewFeedItem = { text: string | null imageUrl: string | null createdAt: string + authorId: string authorDisplay: string authorAvatar?: string | null authorAvatarStyle?: string | null @@ -53,6 +54,7 @@ export type PublicProductReviewItem = { text: string | null imageUrl: string | null createdAt: string + authorId: string authorDisplay: string authorAvatar?: string | null authorAvatarStyle?: string | null diff --git a/client/src/features/product-review/ui/ProductReviewsList.tsx b/client/src/features/product-review/ui/ProductReviewsList.tsx index a7f05e4..b98e351 100644 --- a/client/src/features/product-review/ui/ProductReviewsList.tsx +++ b/client/src/features/product-review/ui/ProductReviewsList.tsx @@ -20,7 +20,7 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { Date: Fri, 22 May 2026 19:32:30 +0500 Subject: [PATCH 15/25] feat: latin-only slugs, server-side avatar generation, remove unused User fields --- client/src/shared/model/auth.ts | 3 --- .../migration.sql | 27 +++++++++++++++++++ server/prisma/schema.prisma | 3 --- server/src/lib/bootstrap-admin.js | 4 ++- server/src/lib/generate-avatar.js | 9 +++++++ .../src/routes/__tests__/oauth-social.test.js | 11 +------- server/src/routes/api/_product-helpers.js | 2 +- server/src/routes/auth.js | 10 +++---- server/src/routes/oauth-social.js | 3 ++- 9 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql create mode 100644 server/src/lib/generate-avatar.js diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index 542b83e..bb3d6c4 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -7,9 +7,6 @@ export type AuthUser = { id: string email: string displayName?: string | null - firstName?: string | null - lastName?: string | null - gender?: string | null avatar?: string | null avatarStyle?: string | null isAdmin?: boolean diff --git a/server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql b/server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql new file mode 100644 index 0000000..4b0a7e4 --- /dev/null +++ b/server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql @@ -0,0 +1,27 @@ +/* + Warnings: + + - You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `gender` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "displayName" TEXT, + "avatar" TEXT, + "avatarStyle" TEXT, + "passwordHash" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("avatar", "avatarStyle", "createdAt", "displayName", "email", "id", "passwordHash", "updatedAt") SELECT "avatar", "avatarStyle", "createdAt", "displayName", "email", "id", "passwordHash", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index be0aa90..1a21149 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -78,9 +78,6 @@ model User { id String @id @default(cuid()) email String @unique displayName String? - firstName String? - lastName String? - gender String? avatar String? avatarStyle String? passwordHash String? diff --git a/server/src/lib/bootstrap-admin.js b/server/src/lib/bootstrap-admin.js index e79df0b..a4462f9 100644 --- a/server/src/lib/bootstrap-admin.js +++ b/server/src/lib/bootstrap-admin.js @@ -1,4 +1,5 @@ import { normalizeEmail } from './auth.js' +import { generateAvatar } from './generate-avatar.js' import { prisma } from './prisma.js' export async function ensureAdminUser() { @@ -8,10 +9,11 @@ export async function ensureAdminUser() { throw new Error('ADMIN_EMAIL должен быть валидным email') } + const avatarUri = await generateAvatar(adminEmail) await prisma.user.upsert({ where: { email: adminEmail }, update: {}, - create: { email: adminEmail }, + create: { email: adminEmail, avatar: avatarUri, avatarStyle: 'avataaars' }, }) // Ensure admin notification settings exist diff --git a/server/src/lib/generate-avatar.js b/server/src/lib/generate-avatar.js new file mode 100644 index 0000000..e95d905 --- /dev/null +++ b/server/src/lib/generate-avatar.js @@ -0,0 +1,9 @@ +import { createAvatar } from '@dicebear/core' +import { avataaars } from '@dicebear/collection' + +const DEFAULT_STYLE = avataaars + +export async function generateAvatar(seed) { + const avatar = createAvatar(DEFAULT_STYLE, { seed: String(seed) }) + return avatar.toDataUri() +} diff --git a/server/src/routes/__tests__/oauth-social.test.js b/server/src/routes/__tests__/oauth-social.test.js index 9e60924..e4dc118 100644 --- a/server/src/routes/__tests__/oauth-social.test.js +++ b/server/src/routes/__tests__/oauth-social.test.js @@ -2,22 +2,16 @@ import { describe, it, expect } from 'vitest' import { prisma } from '../../lib/prisma.js' describe('OAuth — User model fields', () => { - it('stores displayName, firstName, lastName, gender, avatar fields on User model', async () => { + it('stores displayName and avatar fields on User model', async () => { const user = await prisma.user.create({ data: { email: 'test-oauth@example.com', displayName: 'Test User', - firstName: 'Test', - lastName: 'User', - gender: 'male', avatar: 'https://example.com/avatar.jpg', }, }) expect(user.displayName).toBe('Test User') - expect(user.firstName).toBe('Test') - expect(user.lastName).toBe('User') - expect(user.gender).toBe('male') expect(user.avatar).toBe('https://example.com/avatar.jpg') await prisma.user.delete({ where: { id: user.id } }) @@ -31,9 +25,6 @@ describe('OAuth — User model fields', () => { }) expect(user.displayName).toBeNull() - expect(user.firstName).toBeNull() - expect(user.lastName).toBeNull() - expect(user.gender).toBeNull() expect(user.avatar).toBeNull() await prisma.user.delete({ where: { id: user.id } }) diff --git a/server/src/routes/api/_product-helpers.js b/server/src/routes/api/_product-helpers.js index 3d9d5e6..082dd7b 100644 --- a/server/src/routes/api/_product-helpers.js +++ b/server/src/routes/api/_product-helpers.js @@ -5,7 +5,7 @@ export function slugify(input) { .toLowerCase() .trim() .replace(/\s+/g, '-') - .replace(/[^a-z0-9-а-яё]/gi, '') + .replace(/[^a-z0-9-]/gi, '') } export function safeExtFromFilename(filename) { diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 0fe9e20..719e90e 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -8,6 +8,7 @@ import { validatePassword, verifyEmailCode, } from '../lib/auth.js' +import { generateAvatar } from '../lib/generate-avatar.js' import { prisma } from '../lib/prisma.js' import { checkLoginRateLimit } from '../lib/rate-limit.js' @@ -18,9 +19,6 @@ export function mapUserForClient(user) { id: user.id, email: user.email, displayName: user.displayName, - firstName: user.firstName, - lastName: user.lastName, - gender: user.gender, avatar: user.avatar, avatarStyle: user.avatarStyle, isAdmin: Boolean(adminEmail) && userEmail === adminEmail, @@ -55,10 +53,11 @@ export async function registerAuthRoutes(fastify) { const ok = await verifyEmailCode({ email, purpose: 'login', code }) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) + const avatarUri = await generateAvatar(email) const user = await prisma.user.upsert({ where: { email }, update: {}, - create: { email }, + create: { email, avatar: avatarUri, avatarStyle: 'avataaars' }, }) // Ensure notification preference exists @@ -88,12 +87,13 @@ export async function registerAuthRoutes(fastify) { if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' }) const passwordHash = await hashPassword(password) + const avatarUri = await generateAvatar(email) const user = await prisma.user.create({ data: { email, passwordHash, displayName: displayName || null, - avatar: null, + avatar: avatarUri, avatarStyle: 'avataaars', }, }) diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index cfb3c9e..44718c9 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -1,4 +1,5 @@ import { normalizeEmail } from '../lib/auth.js' +import { generateAvatar } from '../lib/generate-avatar.js' import { prisma } from '../lib/prisma.js' function clientRedirect(fastify, reply, token) { @@ -57,7 +58,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken data: { email: norm, displayName: norm.split('@')[0], - avatar: null, + avatar: await generateAvatar(norm), avatarStyle: 'avataaars', }, }) From 5644a2ede282da438e3b21faccaa6217d3ef5e0b Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 19:41:04 +0500 Subject: [PATCH 16/25] feat: replace footer VK inline icon with SVG logo --- client/src/app/layout/MainLayout.tsx | 11 +++++++---- client/src/shared/assets/vk-logo.svg | 11 +++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 client/src/shared/assets/vk-logo.svg diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index 450b0f5..7c8aaed 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -5,11 +5,11 @@ import Divider from '@mui/material/Divider' import Grid from '@mui/material/Grid' import Link from '@mui/material/Link' import Stack from '@mui/material/Stack' -import SvgIcon from '@mui/material/SvgIcon' import Typography from '@mui/material/Typography' import { Link as RouterLink } from 'react-router-dom' import { AppHeader } from '@/app/layout/AppHeader' import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config' +import vkLogoSrc from '@/shared/assets/vk-logo.svg' import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' import { ScrollToTop } from '@/shared/ui/ScrollToTop' @@ -91,9 +91,12 @@ export function MainLayout({ children }: PropsWithChildren) { color="text.secondary" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }} > - - - + VK diff --git a/client/src/shared/assets/vk-logo.svg b/client/src/shared/assets/vk-logo.svg new file mode 100644 index 0000000..1c61468 --- /dev/null +++ b/client/src/shared/assets/vk-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + From 3d0dbdd0a5a5d0a6d33b69a29f40b7c0bfb29d0c Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 19:41:47 +0500 Subject: [PATCH 17/25] =?UTF-8?q?=D0=BF=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1523-1779454537/state/server.pid | 1 + .../1702-1779454560/state/server.pid | 1 + client/src/shared/config/index.ts | 6 +- server/package-lock.json | 433 +++++++++++++++++- server/package.json | 2 + server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 6 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 .superpowers/brainstorm/1523-1779454537/state/server.pid create mode 100644 .superpowers/brainstorm/1702-1779454560/state/server.pid diff --git a/.superpowers/brainstorm/1523-1779454537/state/server.pid b/.superpowers/brainstorm/1523-1779454537/state/server.pid new file mode 100644 index 0000000..45ff3ac --- /dev/null +++ b/.superpowers/brainstorm/1523-1779454537/state/server.pid @@ -0,0 +1 @@ +1531 diff --git a/.superpowers/brainstorm/1702-1779454560/state/server.pid b/.superpowers/brainstorm/1702-1779454560/state/server.pid new file mode 100644 index 0000000..1d25bf7 --- /dev/null +++ b/.superpowers/brainstorm/1702-1779454560/state/server.pid @@ -0,0 +1 @@ +1702 diff --git a/client/src/shared/config/index.ts b/client/src/shared/config/index.ts index 70c2585..367ddab 100644 --- a/client/src/shared/config/index.ts +++ b/client/src/shared/config/index.ts @@ -13,6 +13,6 @@ export const STORE_PUBLIC_SITE_URL = (() => { })() /** Демо-контакты для футера; при необходимости задайте через VITE_* в `.env`. */ -export const STORE_EMAIL = import.meta.env.VITE_STORE_EMAIL ?? 'hello@example.com' -export const STORE_PHONE = import.meta.env.VITE_STORE_PHONE ?? '+7 (900) 000-00-00' -export const VK_URL = import.meta.env.VITE_VK_URL ?? '#' +export const STORE_EMAIL = import.meta.env.VITE_STORE_EMAIL ?? 'larisa8502@yandex.ru' +export const STORE_PHONE = import.meta.env.VITE_STORE_PHONE ?? '+7 (952) 318-16-24' +export const VK_URL = import.meta.env.VITE_VK_URL ?? 'https://vk.com/club158395871' diff --git a/server/package-lock.json b/server/package-lock.json index 48993e2..293ae7b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,8 @@ "name": "server", "version": "1.0.0", "dependencies": { + "@dicebear/collection": "^9.4.2", + "@dicebear/core": "^9.4.2", "@fastify/cors": "^11.2.0", "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^10.0.0", @@ -31,6 +33,436 @@ "vitest": "^3.2.4" } }, + "node_modules/@dicebear/adventurer": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.4.2.tgz", + "integrity": "sha512-jqYp834ZmGDA9HBBDQAdgF1O2UTCwHF4vVrktXWa2Dppp1JczPL5HnVOWsjtrLmXNn61Wd6OLmBb2e6rhzp3ig==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/adventurer-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.4.2.tgz", + "integrity": "sha512-5xgkG/mNL4j3Q4SJGQLBU/KnU90tng8Ze5ofThD+55wi0oeY/nSAUowg6UFCmHrktjifj/MEx3CQqbpcPWtfIA==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.4.2.tgz", + "integrity": "sha512-3x9jKFkOkFSPmpTbt9xvhiU2E1GX7beCSsX0tXRUShj8x6+5Ks9yBRT1VlkySbnXrZ/GglADGg7vJ/D2uIx1Yw==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.4.2.tgz", + "integrity": "sha512-/eNrp0YCNJRwQXqOloLm1+3Ss2C+pMpUQIGkbEnGsP1UK+13Ge80ggDDof1HpdqvG9HAZcKa7hnbG/0HSwyDSw==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.4.2.tgz", + "integrity": "sha512-mNfz3ppNA7UBq0IO3nXCiV5pFPG7c1DfzRB0foNU2Wo1XXT8FIcSY2BvDlYqorZTOUOz7dHb0vx06hqvG0HP5w==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.4.2.tgz", + "integrity": "sha512-M8Ozmzza4eY4hpLOYULgJxMYmBA0CsBnrE15/xw6LZkEREXnrX5z0NJsf8hUfdyF6BWZ+RBgzoiav32DAC5zcg==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-smile": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.4.2.tgz", + "integrity": "sha512-hmT5i7rcPPhStjZyg28pbIhdTnnMBzK3RObI0vKCpY30EFrzaPkkdDL6Ck5fAFBdvDIW1EpOJkenyR0XPmhgbQ==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.4.2.tgz", + "integrity": "sha512-tsx+dII7EFUCVA8URj66G1GqORCCVduCAx4dY2prEY2IeFianVpkntXuFsWZ9BBGx1NZFndvDith5oTwKMQPbQ==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.4.2.tgz", + "integrity": "sha512-kFNwWt6j+gzZ5n5Pz7WVwePubREAQOF8ZwWA9ztwVYDVMLnOChWbAofy5FED4j5md2MXFH2EgLCFCMr5K2BmIA==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/collection": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.4.2.tgz", + "integrity": "sha512-KArubv7if8H7j9sIfpDK2hJJqrdNVR5zMPAMOSpIU2JPyXx8TC9o5wsmXb8il5wOHgaS9Q/cla7jUNIiDD7Gsg==", + "license": "MIT", + "dependencies": { + "@dicebear/adventurer": "9.4.2", + "@dicebear/adventurer-neutral": "9.4.2", + "@dicebear/avataaars": "9.4.2", + "@dicebear/avataaars-neutral": "9.4.2", + "@dicebear/big-ears": "9.4.2", + "@dicebear/big-ears-neutral": "9.4.2", + "@dicebear/big-smile": "9.4.2", + "@dicebear/bottts": "9.4.2", + "@dicebear/bottts-neutral": "9.4.2", + "@dicebear/croodles": "9.4.2", + "@dicebear/croodles-neutral": "9.4.2", + "@dicebear/dylan": "9.4.2", + "@dicebear/fun-emoji": "9.4.2", + "@dicebear/glass": "9.4.2", + "@dicebear/icons": "9.4.2", + "@dicebear/identicon": "9.4.2", + "@dicebear/initials": "9.4.2", + "@dicebear/lorelei": "9.4.2", + "@dicebear/lorelei-neutral": "9.4.2", + "@dicebear/micah": "9.4.2", + "@dicebear/miniavs": "9.4.2", + "@dicebear/notionists": "9.4.2", + "@dicebear/notionists-neutral": "9.4.2", + "@dicebear/open-peeps": "9.4.2", + "@dicebear/personas": "9.4.2", + "@dicebear/pixel-art": "9.4.2", + "@dicebear/pixel-art-neutral": "9.4.2", + "@dicebear/rings": "9.4.2", + "@dicebear/shapes": "9.4.2", + "@dicebear/thumbs": "9.4.2", + "@dicebear/toon-head": "9.4.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/core": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.4.2.tgz", + "integrity": "sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@dicebear/croodles": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.4.2.tgz", + "integrity": "sha512-6VoO0JviIf7dKKMBTL/SMXxWhnXHaZuzufX90G0nXxS77ELG1YkGNMaZzawizN4C09Gbya2gJkozqrWiJN/aGw==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/croodles-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.4.2.tgz", + "integrity": "sha512-oG5IeUdtiYshQ89gkAVcl5w3xAEi5UZX2fTzIyelpBPCG176l7VuuFzlxi2umnB3E6LVHYy06DXvUo/p+rXB2Q==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/dylan": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.4.2.tgz", + "integrity": "sha512-1vQvRu9x9DrwFxhFaIU2rf0EUL04yDTbAt7fHyAjM0mEsKzTD4mRNf95tCRuavCoW6W48u7A/OY6jyIub6kxLQ==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/fun-emoji": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.4.2.tgz", + "integrity": "sha512-kqB6LPkdYCdEU/mwbyz34xLzoNUKL6ARcoo3fr5ASq9D6ZE07qIKybC3xv5+CPz7VmspJ1Q3c/VVWVMDRP7Twg==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/glass": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.4.2.tgz", + "integrity": "sha512-z5qUogHQ1b6UJ2zCqT848mU2U9DKbVDhiX6GPDjD7tYLisCCJVisH9p6WyNdHvflUd4SHkA6gRqVJIh2v2HnTA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/icons": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.4.2.tgz", + "integrity": "sha512-QSMMz0NA03ypSGhXC8HQX8FSj8lYT+/5yqH+/N03OH2IjL0q7wwGZ7nqsrtlRp76O5WqMTwGfSbTUUYPjFr+Xw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/identicon": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.4.2.tgz", + "integrity": "sha512-JVDSmZsv11mSWqwAktK5x9Bslht2xY3TFUn8xzu6slAYe1Z7hEXZ76eb+UJ6F4qEzdwZ7xPWzAS6Nb0Y3A0pww==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/initials": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.4.2.tgz", + "integrity": "sha512-yePuIUasmwtl9IrtB6rEzE/zb5fImKP/neW0CdcTC2MwLgMuP1GLHEGRgg1zI8exIh+PMv1YdLGyyUuRTE2Qpw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.4.2.tgz", + "integrity": "sha512-YMv6vnriW6VLFDsreKuOnUFFno6SRe7+7X7R7zPY0rZ+MaHX9V3jcioIG+1PSjIHEDfOLUHpr5vd1JBWv8y7UA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.4.2.tgz", + "integrity": "sha512-yspanTthA5vh6iCdeLzn6xZ4yYMYRcfcxblcgSvHTF1ut0bjAXtw5SXzZ6aJTrJWiHkzYOQuTOR6GVYiW80Q7w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/micah": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.4.2.tgz", + "integrity": "sha512-e4D3W/OlChSsLo7Llwsy0J18vk0azJqF/uFoY+EKACCNHBc1HGNsqVvu2CTf+OWOA8wTyAK6UkjBN5p01r7D+g==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/miniavs": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.4.2.tgz", + "integrity": "sha512-wLwyFNNUnDRd3BbhSBhXR0XEpX8sG0/xDA5M/OkDoapLqZnnI48YLUSDd2N5QTAVMmcSEuZOYxkcnj7WW79vlg==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.4.2.tgz", + "integrity": "sha512-ZCySq+nxcD/x4xyYgytcj2N9uY3gxrL+qpnmOdp2BdA221KacVrxlsUPpIgEMqxS2rMmBQXfxg129Pzn4ycIpA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.4.2.tgz", + "integrity": "sha512-AyD9kEfVxQUwDGf4Op059gVmYIOAkTKg3dtE9h9mEKP7zl/kMy5B67BFFOo7sB0mXCjzAegZ6ekGU02E8+hIHw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/open-peeps": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.4.2.tgz", + "integrity": "sha512-i01tLgtp2g937T81sVeAOVlqsCtiTck/Kw20g7hN80+7xrXjOUepz2HPLy3HeiMjwjMGRy5o54kSd0/8Ht4Dqg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/personas": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.4.2.tgz", + "integrity": "sha512-NJlkvI5F5gugt6t2+7QrYNTwQC7+4IQZS3vG0dYk2BncxOHax0BuLovdSdiAesTL4ZkytFYIydWmKmV2/xcUwg==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.4.2.tgz", + "integrity": "sha512-peHf7oKICDgBZ8dUyj+txPnS7VZEWgvKE+xW4mNQqBt6dYZIjmva2shOVHn0b1JU+FDxMx3uIkWVixKdUq4WGg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.4.2.tgz", + "integrity": "sha512-9e9Lz554uQvWaXV2P17ss+hPa6rTyuAKBtB8zk8ECjHiZzIl61N/KcTVLZ4dILVZwj7gYriaLo16QEqvL2GJCg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/rings": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.4.2.tgz", + "integrity": "sha512-Pc3ymWrRDQPJFNrbbLt7RJrzGvUuuxUiDkrfLhoVE+B6mZWEL1PC78DPbS1yUWYLErJOpJuM2GSwXmTbVjWf+g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/shapes": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.4.2.tgz", + "integrity": "sha512-AFL6jAaiLztvcqyq+ds+lWZu6Vbp3PlGWhJeJRm842jxtiluJpl6r4f6nUXP2fdMz7MNpDzXfLooQK9E04NbUQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/thumbs": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.4.2.tgz", + "integrity": "sha512-ccWvDBqbkWS5uzHbsg5L6uML6vBfX7jT3J3jHCQksvz8haHItxTK02w+6e1UavZUsvza4lG5X/XY3eji3siJ4Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/toon-head": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/toon-head/-/toon-head-9.4.2.tgz", + "integrity": "sha512-lwFeSXyAnaKnCfMt9TiJwnD1cXQUGkey/0h6i/+4TVHVMCz5/Ri5u1ynovPNHy1SnBf858QwoXHkxilGLwQX/g==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1466,7 +1898,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/types": { diff --git a/server/package.json b/server/package.json index ed46702..0e552ad 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,8 @@ "db:reset:test": "prisma migrate reset --force" }, "dependencies": { + "@dicebear/collection": "^9.4.2", + "@dicebear/core": "^9.4.2", "@fastify/cors": "^11.2.0", "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^10.0.0", diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 27d8a10eeabcc1bb415aafeacaf59a985741f676..cb117d1450cd5e2f234b43a51dcd7e3955ed239d 100644 GIT binary patch delta 27778 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 delta 31743 zcmc(o3y>Uledl*}XCzBVJF<;r%a$w|W3aGiG~Ltl0tZ52|XFi?c#2o>za*vch2AjK=8T-XlelH9`q!f_Q$AgSEt>MqA| zpYQMY@7Y~FFh}L;Fx%bJ{eS%4um8VWZ~x$iw|{WMBUfB<$?VY3&`efq6~FV;#kp@3Pk-mYhS9>> z&kPlRskmwMqoeh;pP}LJtiAeJVa1MPxx$Xl(V6jD_xOXel}csi;MmlIM=1k)tW!poJs%i{Uh0%?HtpHe@rk+V z`fT^~(6y7;-`Zx(;Pcm$k(vUqy?vn%@pkd`4^*gUc$x0Dpi z5X+zabQwA$XFpwr#PFt(9jg{c-cqS716<4;E`E8a_~qiaitj4E3qY?b=59Fs#0NI? zlUlP;sU)qNx+mL5XL{XoXZq+0+JAVyJ2zkIOthyCbxSiRN>QWHO1yEUwzP46+sGSp z)_7s6H#v1=S@^!kqSKFl_tI;I*N;T2j?T1? zG;5XUrc>>yvF^$8?7~`x7+N)3BcVgk#^vU#|UHOf#tVZo9ZnefrJRUFADwU|z;<6Mq>tk`fR*TzF z^YptOzhlQsw#q&BO_Bqyh5 zpG`+TSNz7W z_jgaODHOjrRQ$){i^Z=N|Ln}VCvx+nPriBU&ABJP`2E*z1&q#6@$1FU6hB=27sU=8 z?(Pv4aLT5*dt-;>$WcqlmIh=d!_Mu<+ ze)^%E`Hceg=?~_uss&? zA2~dGd~;C3Lv!sTm99qa&dyGqY#*L}a5AZ6lAABqiV}@&#kBTUx$!FCbXRQ~3E0O; zGDFXLdgzV0l{+r07IqA!-+8F8@v8RN(aEVBXI}sOd$M_+|JfV2&%7Sl{@yn~Y7-jK z)4zYHuwiwEOYwFP*;)MYhvVGp9l7f_7hq!FM7K5(C2-O3=vAgB4=*fq3XIHIyC>$hkG0#)b~2un+TCWY zltlHo)aoXU(!sH@gRO3@7LC=a<(a8NnQ)vB1jpgmcjh-d{psO9Ev6sYnO|>&ynA@n z$gbip(5V;GT?d8_lpx{(;p3W^9Kudij*2anQ;q1z;aV)2{6m1}*fo&T$G49Z())Mi zU%pyeDl=95+?myH9{$VXwk0e%ccjJEOH4ufOn>;Yy!sjS7l-_hSc^X`7EBfG5zb3Y zdis&!|5eP3luv(faP`FSx3;8)?{mYZ7H=IYzICX~uq(jzNb!<~AI{}QcWm2M8@lM? zk)c&P3I$`qN4`5;N&nq1K+`QFfu=Gg zYX*%TEk24VdS@|xbo)rN0Je=)XR*}g+IoJ~)-bu^w}*<~CK~#BG5z*kBdb@R9MItD zyGL^CoP@tWRQ&zoWbumA_iVlH#t-C%M|VWKuidu(52o>2Tee8lnnxxUWR+qFIx*Ib zDu?6A+PQu(eWG8u@~Y>4<+qwQWd-SpXf zZ)K%&ZH$LK^PbP5S0r8ahUzs0y zaI95tMsY1})uJS6wJJ&VEhB4pw6?5Yzg`}8Xh^V5)vGg8z*^~@svH`dUZ~WflU<7h zrrQhi6PZs}L~$>{#Mb-fy0f~_*#P|7v5~D;-hRzn?m&w;p1D3F$hU71L9R3&T=Ju1 ze`F=<3R)mHlK=kq_Ee(LzhhW^aL!QNzNZ2D+>5<=emV0U(!Q&eV_o3E|z=6D`D9ZSZK z9uu8r7kXo}3r8ArC+0i<7j&AKpP!j~?e^{6lj)1cMmFrZeY$)N6yrEFwDSEXK?DVX zDKnMv{K$lBbIf$rjtD^?`qj(C8;ek; zPfU#AtnqZ??Pk=O>P1thj-+?5Dm=6*GP&b;ruf#8iQLNDrY7en+r6~^(8#MU+B4bd z?&`K@Me#RoJv2E#v2ajQ0{O;xbdX0&E*Hsv*#*d-esp1^vhLcw@wKqKwKtpVxwnmM zdhJV%z6}{Tc#6ZI`vAJp|F)4U-#c2^u<-&=(B!fH$eYq9{y4vV zII5&y`{VqjBTyjy(rDqmrP*$0o`J?Ed%bI`d#;V+YvXF8bFi9l!T&2KCdcL{{7LAm zFYO{{(^LD<*xlmTqHrLI$LjDak|Z`}Po0<< zAFsV-_#DkXmMq-O-P^2lKgia-+_}f@4211i{b;o+0l;w0P9JI=?lh~jCfQktUv0)w zwN;NT!rz};yFsGAUHdMTI^mj&At?jJ&!kTTk)b(r5YBn;Gc3i(|^U%hNE*iO1L{;=+vAR;BT12dV zxI581JaITZ^Zk)mU88WFAvtF4&WY(ik$u$<_vv~1s zKXdVx9QW?LRGite=0Ljg?!0Og8`mfeKQsQ*`45le6ocjevbY#a1!w%!Q1PkaQ=?xg zZlT9#N7JvoEq}YRgRkvaW&g8866*D$j;NwrZ$rV?Ot(>;t2Vnf*14fl zQelvt(|_(qBh}hJyRrD4;tRzu6+c=0SaG)a%f)vV4;Jq#rkmfMmqfmMZTc_YogY2( z)nCkCJ-l-5KMrkK`@&Fq(IffS^YE9~rZ2odfBEp}?diuqkiRP3@L0Y!JoUBPAOHKY{HEdJ8~w-YK9K+7#-YC%TK&FN|83;P;`75#4&OcW zH>;jmwJ`Ldp%0z8>Vx?k3;EUG%wKutO&?+Obn8Imu=phduMgN zeX!S^>Yg~DfFe8Z+x>>UJMZ1Q_1>Mk?%2Ea6}p`kKa~Gg{=S(Nm!%B0KmY3K6_=fP z?xXo+#Rc-jZ@ewPp)Z*mB#P%qz_LVf21XoIqF8VAPF4>0mM4nmuzXpfcn%X4DGsms z`&C10>udgg^yS45K=H4wnc6+PCNq{Ows9JbI?T)!Y1DXa`ef_GiK+9MN_6aB!F!G$ z*&&mw1o2PWTe4x!CopL=JMzvm@A@oUmfqa=8_(s}Rk+aqR`J*1?~VMaTzEf!`}vb^ zcmsd;^4H|Aai;Rs{8TP|^BslNY3FPCSEg4M3#-ysK9j#RS4}_kio&Jo!D|ZJwgv|{ z=00p6o;a>lls_JC)NhR|52nvvU)Yd7dl#d;X?x+SB*JCK*H+~a54lI3@6LMXh1uS9 z_|^I8nOl0(Cnl#3?ONy^ti+{un!llNPpPV*tJStm;q!1K3V(@Bd_0P7tyb=@R?|P~ z6<)p!WWlkk+v`ov%ysG0S>lJ~!8^eVYZm>U4bPMCGd!-R(Vyfu85zo<*$k2@@L^Rv@Ox}_yCN1XoMp291e1hJxP2v!0DqLdKI@T>dJCB5qw z>|7j$CmH6EP`$LDG?P}c%Q}Ve$EXANOvFRtUR;?B{6Cj>K3fJ0fGmL}#6L?wsRbA& zx|4?{<|P*WI8GpudiiYk5sNatg0KZF$Nrgir+e2FM#LL+rhC(~rO7Ganwjpk=O?G9 zO5QPantQ`7hK-SGynAzRdU{NQ>s*>+CZ7{?ru%LuRTAj2Nlu`19boN!fdCzvo|wA(jrujw|fiS0FY$m!hg(v z;fKqgMX_Ybpk5C2M0<8@aqz}W2c z%>Ie?*z}15^{8>6UPaB|$$19X*vOxA@0Qv2%*15J`8SyOB?esvz}!T8#-(&H*Kz`$ zPh6NEM1K%z&nD{+`}>Czm;NGQekgfaCN6-!l(M_0kIqcbP0q{y4k)->I4jdL-P!j1 zbZ`mn**^K@(e8Xk_#=VgC4@(WIZ|~2@Shi;>kp#dO9^2=Bj-5{e<;CRw0?IEHcmv4!?*mo<`L zcjo3#^}0?mwJyz{LL#d>dkYkh+f$ujL-pt-%ZqsswIFWy$zcyTE%#S>9AIUdEyt)=+>Qu^qN zbb8m<@|UE)d`)5VC1O0QJ&RZ$N?%qiZ1lh+e(%+V%loCcs*tyC>nxrmyjS{a|sZH~cGSL5;bYIj(cNRVxuH z@Ss%%6dqVOKy$&QsjB5>72F~jeGsj4Z4pMFbAv&2pKydxG-sVSf)8E)+#7Y65rb<5c)2Logp}Q&ug(o-STi(*k#_5uWJbghbjftEGDFCJ9ty)rz3rUpXXfg<991fkq& zz;N|#?yMVo{kYUVly&m%3R~BO~z@H5LI# zkIz{}TloW5(Xxf^3#_6I`kpVnS*$r&*3v&~kqtQCVAwhL_a!!_tuVVLMU7Dqq!sZIYj|2k9-C4HnE|Yk4289bkW((| z?WX8%hov*2pBfG!*7(v7yAi={Mim;m)2_AL3h1;Xv@`v2l}NBF9b`Z~fC4S!iY!3z z*gf>WR%#+AU?s^t`Q>S$;96bmF{>4&7ogaQ1s5> zG(eL@b{h>sC!Ih^!RU%0XiS_1OQnnsmNc}{wOT31JDV-zPdHUF{DphN3`4z|v?FYS z$$U63PF@oqjCtsZ;Pg}0paK}i0_N-l^NT8*rJ|!S!?Hn7Gn6IlU?0#>B*EBdqn_#= zPT8P13L`R71H$@GL_!dlTSK*DxB7O>I_aF$OEm-rJD_$#F)2$q#Z0Eg@yJj>PYIJI zu41knT8w^FZP#_v4oioHt1;Z}h{VW7iQo9O5~N7lH7tY%^V6VL3A2HW`_y<=BcYwe z|Ew99AyR?^3$!yzfSn)NdC-b`^`^;HB!U=KBM9QAs8!P0$svp!)k z34>I{HLZ_5Hpm6Fp(0PCuJSfI8`O>y`o|sVKR#3AgwrA@aZ?;L;`mIgXsh933$y@o zG~EK!%Y6Zcl<2hB2p^0qLK-_2RNhoU@@8;}I7qpHqS=HmzN)6shRc}Cc0FjkpR#dv z);zEm=cFIy!~+#|L?t-lSK5i-w?ozw_Dbk+?DRHcszdfvXFRcv8rk*E?%6;JviK#K zg8&LSw>PCe6Y@|3S5=PBW{zd_;ttD~I>xnjgpD(vgmVS}cABxVnCNj*LTCsMr6gE> z4#0%yXb2q^`@jb1ukkRDdK6As3mZgu#+V32kZOwYvsej=D+V*zaTX@lOcYv4c+qv* z<4R;Ph~d~Tg&iOY;t8bWx`Al*fkI|o6fp{7`GPI2$_;7$nha+w9t)Uo9zdx{6mnGR zQKjQRS|<}J_ZLFWL|$!~Dp+s{VqdHwzHHZhu5ijmk4m^AFmUVMa-70xu?+$R3*29{ zP<>73r^Pl{Ckc@VhPVq=J1y~l&^vf)L_YI=Mr;w5V3j`DHAn~C8Vxea@m1v&e}HUY zBpW0H&;~?2E5Hy@nPWWcFC!66HQEi>$enN+0M0G!CCbrsJ&dF!ksSwZ0H9jJ7B2c} z;YSBl*4?apxTIKY_p=0+UVULCaq-xL_)r2PWkcwAq{bQtKO>a+C4(w2BA+6A1~Hjm zEDm1ER9tk@6A`UqHo~D%Pwr|g>(|Hcals6bhgqFI(i?MU%!}>=O55XnN#Fo&t|b_Z zCc@PPf*l48&_{!U2UR__!{Sp(O&=d(XqarH5Y7dJ(STO&;vS}j47i5C4d97Q^>i2v zs2LbiaYr`USu-l#1LP{wCAk4{?2mXQ+0a?&IG;6y*;@}-h~?XfX0yZ6DIrlq(t&`9 zwltB7!514&Tr!CPXg9ewowd?c-#`UR{b&h1*DguOjJ3)i^ogjZEB3x%fd#M`Z#66-s!^ls|(3SzLI|!k( zE2wp|`SGxAOIYnN=xnIQaR+U$N&W?D6=ZEfoV$}ttU?$~RF#687&hCMz1txh4}FlV zqkRH{_KHO;*$RS5pv)8eydg#g91(&WeK@RNCO(82#x|`SY)U`=B8x~>e5nwFoEVAo zh_Q|BAw!6sDo#2-Wnc9sB-=2`fGEyI)w44sz7XEv1lSp)f11nVIv zWG@Wb68ww_54v?;xLW+kf3@rkCHipr9 zU|%(H#~v$Mkw6(He#(ZEu@V#@*Xm$tG@s>Im=rE0@Os3hY(I0!K2d$Kt0q>ckDN=b zzz~bT5x|6=bj%$h5J_n?94FZ=_+(WXJ7ka;O$_mJ^uWHv0wDr-c^W;+Y9N3iJu+(~ zmh}a_jBS{~gx)YRT$CZY*aJAi!(%O>fTMhqu4K_P0kBm?0O)41eoSGi;VVBu7}O{* z-g^+UxBJA4W+MEQVS`-_LA;x%SZ(Z(wU8D76V`mb~DMxDB_?=PBf%M4v{FV5pit@?!qw+9bU{ zuI{ev+`V_%%2HSnT-;-`x8B%W+iO+524yDU5hHqWn^0FG7furxDi#lf(hO!$~C)_0s)_ABncip7;){ah!NZ(=V~!NTd3Ju}o)8 zz(G^^X(^agtx(Pfw32#dztQlFD6SDnIG5z6Q7%J8&R*VF2qTXW((fbeiEi2<8ypOb zDTq@KC@3aKqeMAn4DRxvLKCc7A|vqiMMuGzjo~w4Xdc)nvYUVr9X43rG<573JWe1d zu@nPjB4+dXTrM2$SiB8zv}0qX4$Mg*NEgCa0Wc;`G*=je;4Jz07Ts{-&x;&}frVn8f-iLteMY#)T46r3g)z0^3^4 zv<5N46t=NlW~2Qy7{gOk=#e0%s*pMRa3M}JMpFjIBY71uk~=gLC6JCAnNqh!O{SFq z@<1^0gZL9dh;DKWHha)>vZO^drnF_MLk84pt8PJua2kNgEnO?A7LQ3Ofx=)xTj`I+ z0uJ1SnMw#{%8|65v=|T4Xktp~&&IvZ5-pRkkWVRl2on(NhH`|5MAk_d8TdqjF4N(( z6g*K6@ETHn6ElU#rWEKWqf@`)=t-GK>!CnWws;mD5!Qi4sH%QgA%zQt*shmym`E1) zWZ2ramSu+w5JeA2cLnQ(C|h4TxUefj@YLS@Eap{k4VLOOhI9nh$%1h*(UK{lO2$Jw z3})g6bPM$H`mNCNTUPflhPx$j0tbUbD=#{lUF;LnxWuT!XI8>|pDRCI+GL(Kjh`|A z(9o>Za{;#|6XVfMhhR7uBgGj?&jczW(_ss-3lwI5g_zZ zriNt$<_WwTcf}^lf(dKg88;CIlb0InKSG4~(5Y-=as#R{xIKX@F7R~1?u7_Ss5y@< zlrXvoEsX|YWx-_I!)Z|t6bSbzETTNzF|j6(DaQ|J!`Bt-ShZe^ z;@G+5A)GCHN=8~8A`|s>G4uWyLAxW$y2}F|(?o7S^AK7oNO1B=kc?IMWV1RRIsqXa zGHT1XOGc>osaRryD;K9GiYJ2qlIljhTWCI4hm1BRO|Ej7F3@)H+`Vu=AkSFhn9oGG zCh8*sa?0v}hDQCeG7Yl}oZUhq2*xOcl2*79j;@+6bBM5dJ2GBE#qtUGpxGhAFlDv2 zW`kU0YnPcC@Us`C>C<&l^VJgA|rX*4hQ${JI6A2W9wm^459|UsQRpg~n*(3V7 zA&{lgm7rG?Vf9wi6M%;DTEUP}klRzZET3(%BnX{_=r6HITl`qc6FCRw7-aw!M3HSSkp_s6fm57s97n^j%``Vc8FKo~^s-hs zWV(v30z#G&yK7ehOv9=~<5FM(toAHS^B}~FZoDz6+(HgaD#N2NnQl2%-5vspaTAgg zjzMMxW#GYl7pMSeW(WkicOn?JF+ykVE~RNBSOwyZs;{PDc%698D5r?W8*Uc_hao5X zE-E8XT!|JUzUh)~JDD7jnr}=n+61YgF66R|cG$WvU5KBJwt~{9cWIV04rQ$B4eWx z2iH4ML{|a5toUr)qVYh*Z4M|NEW5HZqMY~KT=>@`c+j+YOJVbh7IF9Z_?e%&rSO}D ziyGFW=2Ymj-^Yd|LQzEtZtw>X!X5LK!6(lpIOPJxBL%(CV$K+EP-+x%KIeg(rXY7b z@BjfD;uvWx@i=CY*~lx8V?(KgVHe(@vKUG!g_O7%WI^K`%#hV;JXX&fa#aW13^KI$ z3^||a69_tHmJ~>LTpTUb*EAN?8)M82VU;wV`9>TUbh7q7zoRM43_WlUGUPtNahHgQ zh=Hj}jl_r5h_#EVZKMD2iiU|{+PI}~Rr=^ z{K+JVup$#YQpaF~E1o?NZdhY@i0$C9WH0anH8&v)Nt+uDnh@&QwV`DWpTPwRz?9;B zvO~1OyeKWuklg2_7|Mm&NMqCXt!W7GY-r^WToAb!*%Ujdo1m)gvv8LXt3?tB3mi4s zHc2P2qJm-;n;r+QJK$H?&&NtOPdf~zq9kwk=XBO zt!z(3pKU$r9!^W!v1h>isfR{pjqEUJ!_FFGFzelHZm_K-7E}hgM7nGq87fVI=mhDJ z?UwgpcxuL`D-uL`YRJG9lDabvzAiftrsa9nHV@U6%7=+%484TFv>J?PQaw%b!g(+P z;je-&Ub`*d;uAToT;~VPG?DM>#Q4@a_&{lvH8o3QFdNm~=i74MXuAG}!cG08<+&a& zzLvG~!W6-d+3-47ZNFMnXDNjAMVgwq54@NWpPgYMh#v=CA$G8z^-xtM7!ZwVXXj-# zte8Twz@&s2ceK*!pdoVPJ8l%%;z|&US?O6-lNButuaK3K=N8VMJF+CGs{1sok^B(j z(#_6pOoS;_U?Y>ZlbLiG|9OyM!2U(~X|)9mJq@R z1caT{+T?~|6(N&SUnOECf>!5HqV~Yi2(i{sQ41#9O21|j6pC_<)?s%Bpa9T76tqyo zu5S-7Z)q}`MMX4}GM zI}FGrFQs@Y7E36x*KJahdj&+Hl1WT*9gS3L2Q^;=J!cJ6WkgW6Tww+KR{4@p`flEY zTus$Nz?GU+dtb3%_xQI~Ki^(X0P?I#6qux6yt^o62Rd!pp|Y}`Krd|NhuPsj(BM0S7RpS z7ik%TseS|0N+lUnp#buPbn&!rYmX8O>y4;+aBBGr&y5czS#)s85s!eaXE%aMs+n|B z!-I~%r?muFuFE6INmW7Avz8Ey7B;D?bWl~N zsf^@uqi>TzDG{*MGa56x^qy_*I*Gd;+;R;|T-AM9wvUN`T{60&h|Mf0L|qRV{Ys*E z*1YzBW-N7)Z{V`A!Cp>Rbj==BllyR5 zhAy$$NP~nZk5(dGI4!m!xMOjbL{tn_**^29YVt;x7lL5EM(q+gjI*?i_x zpU?kXenpqhZ8h6x*mY3LP5bH|m{jkNqROrH=KU{9k>RMRlv1uMVJCR=3K{7yJ$3=+TO8qh6?ukatJxs=cd6R2qNX_`W-Yye6Std8&& zo@^D6RGaOkgIF^uun3hjcca9zM_5MR9U1C6>JpG;&w&9C*8TrKXb-SHV#4#!fp*K2 zqzvt*rwb;D0b66cK1z_Jt6x*t+J|8x3jT>5k~kpkMl+I8Y4OUQ(km^m^Ty9orii1) zHN7&3gQRYS2=f9Z5g`r>gU^uKOkoT5gn*>2qJGxR&u0IPE6n=9K4NFfDz)MaV_>2j zw%)`fdhxOs;24Z7TQ3>TAYt)Xv7TP6LzE;5b;&C|AV|wDy{O8s(0fBbf@Xr;cNloS z$48y6FTAVY5OggpsO@hCLz64iQ(at|4JUNUDl-x))o)b=F#&@*8UnJ&4VuY>Vraic zAV+0qYi0_(geF`tv6gYiyr-z%Y@V!Z;?UB~KCZxL@E0f9g>Fi33TShdEmsauGJ>^s z3^G3Gtx>2Yo#ovqSWR?^>(G%~<;DhG(TE?UVT8}RUH2mRin02^YNO&t@h#g6Z|U<1 znlI@SG=KSHIFsJW0W(udpHsTtyN*`|Ip*R^% zgErg}Zd6=4+JI^(4{{^oLm*Dd>7%Dc)~D0g6fW`ztVSH=v_ugp2pOK)YvLXpdpHJ} zHMv9SF*mc>B%%WP!jTxr=)_!p!*hVJ;=)X4r;fI_uVnA}Vw72wK;5Kr3h()W^{~2{ z7J^VpL>RLuqXoycR;b;4^23&Ccyy-~bD2KD#8W|KMGt@t5Js_YaMWj0(qDTqzdF7B z&4sJj)9$uEsRKlZA(V2d3+s9sEffXL7*ynXE zh{03Dj@#O|3qLRCk|6;xYr-4JA-zybVP<2nBd9L7z zq|?@AjZuZV5y8EW#CZ|)u(s)B4eu`Nv~=4)l(MOv!^GTTQ8+D*M3BmUBc4!&6hz^) z*g#CuG6$iqlW^_>hyy5TENc)lBsHfVRaT z%POQ6!h7D0{YiEP9){D>v1_)}T&uJcUJ8S78nlr)*J^WhQJ8z_GrhvanO=L9Dq(YJ z+#w&5BqLSEKUi!!&n*)S;bL4fgj}iV#uTCmh6g27Tem?<8XLTh%$-3pVn4VcVF%Kh zWi(EcI+$v5K9?kGjn-;Tpv%f1k|-C&V8xAuh%Wa%QuC#L0=x5Wqj&oxeaq@as(OfK zN(#JgO9=pN780r(X~j==#$FKNo?;gOQxDU65eI>#Wl(HnLqJR7Z8NSe5F3jJ_4)-r zRcA5F74>XvULvTHrrW!uiGotwduw0;si8>5V)ljYh*o@ER)N*n$R0=3Oa8}TK^9~d zbvGDv;bhlLA@H>LX5NH@$_rOyn~lt6*?4HAD(1$dfo2EeN2#Dm2@v(txB|i@M(Dfk za5CB?{s<^#x7A)|&Bfb~i_*$M2_xH>E?>^^&|s~(Ta_xY)nT`R8)f{K$$B%NCD_NG3l6ju@GfjYcfu3nfh>+=;s)Jg10n5DTBF$9lg*(B+;v+H34x&{ za7Zw;%oU8kpO#=PhG-_6Zkd$}{b6o`Qb_(Jv81ayk?ggk*Xieah0CSTE7SjQePP>0 zgF33kL3NYGq=GVLxnDSE5L8Me6CT8SU~b37r@$>zYBPt26s8)2Q;Oir)=@=uMNx)$ z=Qx*qiolkN`+iy+P4vK2kgm#jC@c%KBv10UfCAZ|id{KFGyp`jJ9sUZBpQS!ZnCf` z_JC$R14aw@)Y|CFDArV9yFg?`A5R%DG(uAROL}JBJFH#1>!yaLw*n-X1eIF;QB!x~ zHmL0=b=X31hvpbdt&T=xb#VLWbw+f%r^+JMQd-NHY#>1neFq(mmlv(%a_ClVVMutJ zU8^J7X=ehWLIwp4*(k-es=X=0v)cMZNhzm#!k)ehxEi^#s#@ukU_@?IYSq%*#mkb_LYGet6TX3tH zyUJE3B=P2G8Fp4IfGmC5Y?G$((Xa2 zm`k0f3lc7u9xtFOjnHrC5bk3zoybto9cx^=5{bM&sWf3==33N2jI@fLnO0ZaOEbmw zuQRFqSVnXTVmi&5k60_a>Y( zF<5@mV1)DIjmMv+$v6$QY?>vHGAOJW0aZ(Ff8_4+r|&&AvTnuAHz)O^o?i7# zeq;Kly9>25S6){r=KB6gDt$CTP-WU+ZIGF%n{>Lpf_o6nxanEJ8@eut!48%^?%%7cVxM3gSOic=V6D;ze=qIPW%g*8Gy6 z7U#i7tGS+Ke~S;@G2%G%l9iyKm5^J48+S77sABu(g{27UxqmRt#0B4KBmzCta~?Z> ztPF^mASf*f=$Y5lZAG{cns-DPIFrFv_11dpel_Hi;Y4!SJ&}w|ANvQu zUSI#uU&?RpTeuV@`2Mm2E*zJnnjjuKr_aQRuqxq*Xyj;m$H@q?XE2 z2pXw_(8scgjQ+hpQ$)nYWQ?xDiTRnA3mmEmm&2pwXQRJ>W=i|o7ooB~9_P_qLUzQa zrWT0PJ3D8r)tT8jveN}UBlVI&#SB6NJ~@X;#mtj=t-?miT{m{7S6^MYbW%NRV^_qt zms>H^!cY#Ftr$623av-S0Rvd!nuw`r95FCY!zTbK#*3gxk%h&I)?11P72P26P+k#oqq$pK z)s4ciUp*kOC=~J1vpYV~28L(Z%DNSenB9oJ?>VJFu77Olwm&k6rY{%BNO*Z^aFT`6ut$dQV~G;pUbz zf4)#Sn%k7i|J}>(AKJKMN8_^fLl5u2DEzx<^D~uWm2}UE!c!x;{EKJ)$BDu}E?dnzq|s*mgD27M~^sI0!q|L zQHn)ginVq$`@W*xsn=_fc4}90iEWY@wUTv&HL5B`Oh0v3;R<~heDf;LvLZImkn~;2 zHdZrT(14rS0V}eSb6%D>kH+T zzT_6!zWsg#2kaeu@J(8`>Sv1}id0J@W#`OVX%hB9*h~79ldH%H!e`v|gj;1{%`Nv9 zIAyOF>AN;sr3+;geM%IL+vgY=209T(YC^cLs5E@`71mKnHth99Qp~Cu9EMSi*0Xuc zcL~2F-YNHF&B1*!U-9h0EiKragn5aGPWC1lt!0*!XcG|o3?@>9u3{=_vqr>@3nH$d zj)_Oe0^p1B-m%26A+frF&>-g464?YbG6LdQNj5CPsAuFNOlCc=fzFwLFO(C`d&tCf#LFJ8>vZ-d1O&$g)NBB{uc^>^4F>M+qu`t=i1+NofoY^Bb`cLbS6MhBB}N5@P26Kmh${7}f-+ybvB5 zMgT5&OzO3gU^P*rl|cZcksIdv!Le4il^JFwuY7&MhKO38yD$0o85ZB;b<3tb@%A<9 zbN?{~FqNUwNZG%qK+Fc|bZ0jFBLMKweul5?W4pjN{qk4ymu{M!p65-0>(CWvnGdHG zh-6#(&{y+U)u}a~J%ehCFEK;<>{s)bEg9X^D-5N=GowR<xu+0e3`PBmObDY|qQS1< zyL6HOtZ#FtOpB>uIA>a@GtYbh%3?KjA@Guh01MWh3rxOlqazYuL|VVXJ<}|;jv6~5 zf$5!}HY}aQD-3uldwr&oYlhccLeUFjL7^12vFXA&>#tf&Fja{MlmSR$V!h=AlxPPb zG1>+o7y&scgBTt0D^yCbYe)#<05;1n4>?yuwLo=1ufooS5OnCfI@h4LHX0(v4k#%m z4iU|=Ak_EHWJ(}+eZA2n!BLw-Q*)a(vrsb7HXf&}pKbkBgKBM=*lb>DE<0|>r*49! z=960(q57?Yp_cS8EaiB&Opw?>PDGlk;bp7M^bjTU6M>_2O(06d?R!(833#C3YE9sw zJ!Ci>K@Y)I)AI5Oj#-`f=R~lrw7{Q{HJK=cFMBhmX2MHbpp30Hb7B5Sk^u#%B^wza zu)RtVlJBNChGK@Yd&omV=#5T?#cp<{DVSk?L`c@ol?cN}Xr#FqRy#+Hf_M_$7FOJm zw<7+=uEIVN0(-|)i1}*xJGCR3HEEi>bxY$xeIX F{{R{H(LMkG From cc94917c5fec0af53bbc1614db0605d9221d70cc Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 19:43:31 +0500 Subject: [PATCH 18/25] feat: add email, phone, VK contacts to About page --- client/src/pages/about/ui/AboutPage.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/client/src/pages/about/ui/AboutPage.tsx b/client/src/pages/about/ui/AboutPage.tsx index 55e3de8..54c9bb8 100644 --- a/client/src/pages/about/ui/AboutPage.tsx +++ b/client/src/pages/about/ui/AboutPage.tsx @@ -1,9 +1,11 @@ import Box from '@mui/material/Box' +import Link from '@mui/material/Link' import Paper from '@mui/material/Paper' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import * as maplibregl from 'maplibre-gl' import Map, { Marker } from 'react-map-gl/maplibre' +import { STORE_EMAIL, STORE_PHONE, VK_URL } from '@/shared/config' import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point' const rasterStyle = { @@ -39,6 +41,23 @@ export function AboutPage() { Забрать заказ можно по адресу самовывоза (координаты указаны на карте ниже): {PICKUP_ADDRESS_FULL} + + Email:{' '} + + {STORE_EMAIL} + + + + Телефон:{' '} + + {STORE_PHONE} + + + + + ВКонтакте + + Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче. From 0f2ac862de18892a0d81c4045993d58c1c59f48c Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 19:51:34 +0500 Subject: [PATCH 19/25] feat: add WB_PVZ (Wildberries pickup) delivery carrier --- server/src/routes/user-orders.js | 2 +- shared/constants/delivery-carrier.d.ts | 3 ++- shared/constants/delivery-carrier.js | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 6916bb1..2c2da0f 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -37,7 +37,7 @@ export async function registerUserOrderRoutes(fastify) { carrierRaw === undefined || carrierRaw === null || carrierRaw === '' ? '' : String(carrierRaw).trim() if (!isDeliveryCarrier(carrierStr)) { return reply.code(400).send({ - error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST', + error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST | WB_PVZ', }) } deliveryCarrier = carrierStr diff --git a/shared/constants/delivery-carrier.d.ts b/shared/constants/delivery-carrier.d.ts index b9ece2f..d48d06e 100644 --- a/shared/constants/delivery-carrier.d.ts +++ b/shared/constants/delivery-carrier.d.ts @@ -1,10 +1,11 @@ -export declare const DELIVERY_CARRIERS: readonly ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'] +export declare const DELIVERY_CARRIERS: readonly ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST', 'WB_PVZ'] export declare const DELIVERY_CARRIER_LABELS: { readonly RUSSIAN_POST: 'Почта России' readonly OZON_PVZ: 'Озон доставка (пункт выдачи)' readonly YANDEX_PVZ: 'Яндекс доставка (пункт выдачи)' readonly FIVE_POST: '5Post (пункт выдачи)' + readonly WB_PVZ: 'WB доставка (пункт выдачи)' } export declare function deliveryCarrierLabelRu(code: string | null | undefined): string | null diff --git a/shared/constants/delivery-carrier.js b/shared/constants/delivery-carrier.js index 06bc1d5..f3afe51 100644 --- a/shared/constants/delivery-carrier.js +++ b/shared/constants/delivery-carrier.js @@ -1,10 +1,11 @@ -export const DELIVERY_CARRIERS = Object.freeze(['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST']) +export const DELIVERY_CARRIERS = Object.freeze(['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST', 'WB_PVZ']) export const DELIVERY_CARRIER_LABELS = Object.freeze({ RUSSIAN_POST: 'Почта России', OZON_PVZ: 'Озон доставка (пункт выдачи)', YANDEX_PVZ: 'Яндекс доставка (пункт выдачи)', FIVE_POST: '5Post (пункт выдачи)', + WB_PVZ: 'WB доставка (пункт выдачи)', }) export function deliveryCarrierLabelRu(code) { From caa9b926e37b0671a61233796771585973248896 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 20:20:08 +0500 Subject: [PATCH 20/25] =?UTF-8?q?=D0=BF=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/checkout/ui/CheckoutPage.tsx | 4 ++-- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/pages/checkout/ui/CheckoutPage.tsx b/client/src/pages/checkout/ui/CheckoutPage.tsx index ce3b7a6..40ba74f 100644 --- a/client/src/pages/checkout/ui/CheckoutPage.tsx +++ b/client/src/pages/checkout/ui/CheckoutPage.tsx @@ -191,8 +191,8 @@ export function CheckoutPage() { )} - Стоимость доставки ориентировочно 300 ₽. Точная цена будет скорректирована после расчёта. В сумме заказа - сейчас заложено {items.length > 0 ? formatPriceRub(deliveryFeeCents) : '500 ₽'} до уточнения. + Сумма доставки зависит от региона и способа доставки. Точная цена будет скорректирована после расчёта. В + сумме заказа сейчас заложено {items.length > 0 ? formatPriceRub(deliveryFeeCents) : '500 ₽'} до уточнения. )} diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index cb117d1450cd5e2f234b43a51dcd7e3955ed239d..d5a37a2edc479a586a6837fc378465b2b91a5f33 100644 GIT binary patch delta 3940 zcmb_eU2Ggz72fsE*w~~V8(Kp{qimoi5y{-ny?1^aV4Xjb%5~~WkWlv_Sx;;y?Y7?7 z#Eu>4I!&RVAeyR`rd6vZl`6^u4+ht3r_B$Dv!oKJ3PK8qq7o#8c;F4bb7#kO zz}xVE?3sJ-+;h+U&Ue0h?#`U)nmN;T>NB_XW-^(F@bu&P*s~kFZ0}F@zxGU9lW{d; zd|+HP-ka;RdX48EIz9cVt{l%kRxUp3I~MPmDn5Q-=qMXI(lqzM>DBw2v+cVw?Ym(7 z<@U-6C)-}gjCMb@Bh%8-s_TXDvGPIg4Na9S%Ni8c@F8!gG&;#cd41+&{CqrLo2mUE zo{Jab^ZEGM_(E+aUaTF@W9A$>Ptq`+kAI5((;K}&xR#Vb;CS&%wUf2uweMqQK0aSN z9={2@v-w)(NsMP{T|RyTi)Z6=wUc?wog=$=F(02|$@41a;1(ZeH{~VYl&_tL-@-QE ziD&ci%jjV7ru?#fZXPY)6?>c@=U7`=j4#6RTR?Wcb`pr<3-Ox>@kBfaXQwG#Sf_Al z3f5<73cW=Dx&RQ>C4<^ifc=JA)yS#K{lE&~;S9(--ixJ+wP$+zR=x9L%kRI~bN`>U zv%h%nN7+~UyRUsKd8>pt_8|ue5tNY9=+t3%XnMR2GRPW#$l$*d-R8N@@>OHaUAaPQ z!yH!gS*^c0Yh2G5*XKL4zcn_j(F(1Jed8^zWsGZZ{%eDMPAlk*trNVrDOj&(jMwKn zdtNpMzP>lxTxit`iL#vXfnfyVQy^ob!4csf9KyQ0Uz=+fCrwz7OdX*3{o?qc zlanLiSk==0V%))3a#@|tY8|T*c8p8-`yS-}u<_hIS6ZU6E7>WmT7l7OoYnR(XqNWA zv35m+gz~pD#@pcIqT#t(q0aV7?ivH{Z_>^n4dskc2CiWvD*vmgqe5E_B1)UcnUK8n zxMzEyo&AGf>pQf4$G%~2_gCEe$E@wcz1xDl zyNk}~^kC`1$)khQ)V?2uFS_Ry{rBGxd-#7vJ0;q&K=Et{>MgBTY<0nP0QMht~WwA9`Ui< zw-=tiq&=!@kl5Y@>sapHe;~*68Dn1ktp%5t+M{=$(EffeaXA7mT_v>A^w?p~nri4< z(Q{|=uFhzh)j&Fbd_wDrzI#y%m45!qfAwcti>0>!-2)4&{-*u2fZBW+=hL`sT*hH| zk@VLZ%@*`Et5=j)6CK{@OUH6|R(o_kzp!(i{?+Wl&R=RIOlNxNHE79Klb zj>#nxX2RPsdVVVRiDntv!%i4o z*{I9t-u3#!6=ta=9`nRD+hQIqNE+K!BjwT|BDfnaqXcY7T52-iPzr7xCXfUKZ_*GQ z&>ciSFX`68pVsT!vlS4L5a2P#2?+?+N=DoeQa5qNtz@72&}KeSPDCOvlt!1RCo5sX z2ClFd$EJeNTQssHUeYUaM5=;TZDm;!5h86XnJq(8GLKwZrpLIzN;pDl9DEmW8Z3nDJ zTh{5HtdIxgnhS~LvZ!`ducbvWp^}Me5h6p-61);Ni6i5ff+J}d*(isBX`>1NGKfW? zpz}ggIGH}x6bchZRUI9HW7RDxRFVXNfE$%{fwCUZ*oDrDs72FP%rcm#e05TKQ!KvJr8q$b%yq7o-P zs;Cny^F^vAemHsBlI9?7mqH+milesjE#~gwl)_?~;4;2fdfUe=A-lTTa z;v+ft{XKzbpg@RlfX?~llHSog6dni%hoe_NtKU@tXeB5Yhtk~^pajw;KIw`!qg#=D zTf`1JYLt&pCNQb*+`dE7!BK%**e+NKm@67n%|$T%Xw zrL4?MT%R5e7JfxY6Zi%F^z48B8k_V<-kyOX~x4LODdMWUU#Bn_vqhj Qy?b-J`oY)!!?K?LH=pq?n*aa+ delta 864 zcmZ`%OH30{6rDHorZ~grO+&3ID$c?fHt3^6KbYtcgC;~1>I#VqI=0jDY0C^PrG*5$ z)RmAXOiT=PVPK0k&MYMgVTlV?#)XPs!onD1OkC)jj*=K^UhdtzbI-l!-czc0OBL_@ z@#a2?qJ~IBNN@`sA<*|I`tTkP&>n^M&>DJKb>pk3JhVJ>OiQU}V-somG&8^@)m*2h z6g#^@q3YgpTa=~*%Uz*gp>kAg7kENO33j5~BwPny9#aG`@(_F1{8U`=ODVsU21^lP=(7<`GQRSB#<%zy@D?x9 zZ;_`S+FS!rXsUJCMH}SZB^qy{^11B>V{#i5k77L$9w9w?QUOJgBu&X_8P$9NVAO&l z{RtUrlJP8x3~91XZy_U=WW2S$B800%Ud2@-e@ti-$k2Y#!fhO!B!n2N_So6{SWimP zM~X@&t`;PJ7ocshfp zy7S{*eu;?pJ$vKi+J^LmKTnWh8#!JW3xu;XVAK@pedMV}VhRit9bxTtN=N@56Mc7X zv=DI_`vVNmbI{$&)kZKQgn|c+m}tJ;ZOW!v@SAA$}XQn!dmvGBN%xg?7;<+9c+?Z<~TM)@1f%TU}GoCm5MCF3d7M RvzKK>i!HG~(Tc{3zX7tv0q+0+ From bead7250366962e434b7853c49b90f8a74a731f4 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 20:31:02 +0500 Subject: [PATCH 21/25] fix: strip trailing slash from SERVER_PUBLIC_URL to prevent double-slash in OAuth redirect_uri --- server/src/routes/oauth-social.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 44718c9..5d09f84 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -72,7 +72,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken } export async function registerOAuthSocialRoutes(fastify) { - const serverPublic = process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333' + const serverPublic = (process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333').replace(/\/$/, '') /** --- VK --- */ fastify.get('/api/auth/oauth/vk', async (_request, reply) => { From 9d7e7949b97323d4bf5bc102f8f567cc5b161b4d Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 20:54:48 +0500 Subject: [PATCH 22/25] feat: migrate VK OAuth to VK ID flow with PKCE --- server/src/routes/oauth-social.js | 76 +++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 5d09f84..9c09a34 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -1,7 +1,24 @@ +import crypto from 'node:crypto' import { normalizeEmail } from '../lib/auth.js' import { generateAvatar } from '../lib/generate-avatar.js' import { prisma } from '../lib/prisma.js' +function generatePkcePair() { + const verifier = crypto.randomBytes(48).toString('base64url').slice(0, 64) + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url') + return { codeVerifier: verifier, codeChallenge: challenge } +} + +function decodeIdTokenPayload(idToken) { + const parts = idToken.split('.') + if (parts.length !== 3) return null + try { + return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) + } catch { + return null + } +} + function clientRedirect(fastify, reply, token) { const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' const url = `${base.replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(token)}` @@ -81,15 +98,16 @@ export async function registerOAuthSocialRoutes(fastify) { if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' }) const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` - const state = fastify.jwt.sign({ oauth: 'vk' }, { expiresIn: '15m' }) + const { codeVerifier, codeChallenge } = generatePkcePair() + const state = fastify.jwt.sign({ oauth: 'vk', cv: codeVerifier }, { expiresIn: '15m' }) - const url = new URL('https://oauth.vk.com/authorize') + const url = new URL('https://id.vk.ru/authorize') url.searchParams.set('client_id', clientId) - url.searchParams.set('display', 'page') url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', 'email') url.searchParams.set('response_type', 'code') - url.searchParams.set('v', '5.199') + url.searchParams.set('scope', 'email') + url.searchParams.set('code_challenge', codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') url.searchParams.set('state', state) return reply.redirect(url.toString()) @@ -106,15 +124,19 @@ export async function registerOAuthSocialRoutes(fastify) { if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' }) const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` - const state = fastify.jwt.sign({ oauth: 'vk', action: 'link', userId: request.user.sub }, { expiresIn: '15m' }) + const { codeVerifier, codeChallenge } = generatePkcePair() + const state = fastify.jwt.sign( + { oauth: 'vk', action: 'link', userId: request.user.sub, cv: codeVerifier }, + { expiresIn: '15m' }, + ) - const url = new URL('https://oauth.vk.com/authorize') + const url = new URL('https://id.vk.ru/authorize') url.searchParams.set('client_id', clientId) - url.searchParams.set('display', 'page') url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', 'email') url.searchParams.set('response_type', 'code') - url.searchParams.set('v', '5.199') + url.searchParams.set('scope', 'email') + url.searchParams.set('code_challenge', codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') url.searchParams.set('state', state) return reply.redirect(url.toString()) @@ -139,28 +161,42 @@ export async function registerOAuthSocialRoutes(fastify) { const code = typeof query.code === 'string' ? query.code.trim() : '' if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK') + const deviceId = typeof query.device_id === 'string' ? query.device_id : null + const clientId = process.env.VK_CLIENT_ID const clientSecret = process.env.VK_CLIENT_SECRET const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` + const codeVerifier = typeof statePayload.cv === 'string' ? statePayload.cv : '' - const tokenUrl = new URL('https://oauth.vk.com/access_token') - tokenUrl.searchParams.set('client_id', clientId) - tokenUrl.searchParams.set('client_secret', clientSecret) - tokenUrl.searchParams.set('redirect_uri', redirectUri) - tokenUrl.searchParams.set('code', code) + const body = new URLSearchParams() + body.set('grant_type', 'authorization_code') + body.set('client_id', clientId) + body.set('client_secret', clientSecret) + body.set('code', code) + body.set('code_verifier', codeVerifier) + body.set('redirect_uri', redirectUri) + if (deviceId) { + body.set('device_id', deviceId) + } - const tokenRes = await fetch(tokenUrl.toString()) + const tokenRes = await fetch('https://id.vk.ru/oauth2/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) const tokenBody = await tokenRes.json() if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) { return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK') } - const vkUserId = tokenBody?.user_id - const accessTokenVk = tokenBody?.access_token + const idToken = typeof tokenBody?.id_token === 'string' ? tokenBody.id_token : null + const claims = idToken ? decodeIdTokenPayload(idToken) : null - const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null + const vkUserId = claims?.sub ?? tokenBody?.user_id + const emailSuggestion = claims?.email ?? tokenBody?.email ?? null + if (!vkUserId) return oauthErrorRedirect(reply, 'no_user_id') if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined @@ -168,7 +204,7 @@ export async function registerOAuthSocialRoutes(fastify) { const user = await findOrCreateUserFromOAuth({ provider: 'vk', providerUserId: String(vkUserId), - accessToken: accessTokenVk ?? null, + accessToken: tokenBody?.access_token ?? null, suggestedEmail: emailSuggestion, linkToUserId, }) From f0af519ec10fce9638ddd23da3db3b239d4d454c Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 21:02:33 +0500 Subject: [PATCH 23/25] fix: VK OAuth uses short UUID state + in-memory PKCE store instead of JWT --- server/src/routes/oauth-social.js | 43 +++++++++++++++++++------------ 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 9c09a34..5e6137e 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -3,6 +3,21 @@ import { normalizeEmail } from '../lib/auth.js' import { generateAvatar } from '../lib/generate-avatar.js' import { prisma } from '../lib/prisma.js' +const pkceStore = new Map() + +function storePkce(state, codeVerifier, meta = {}) { + pkceStore.set(state, { codeVerifier, meta, createdAt: Date.now() }) +} + +function consumePkce(state) { + const entry = pkceStore.get(state) + if (entry) { + pkceStore.delete(state) + return { codeVerifier: entry.codeVerifier, meta: entry.meta } + } + return null +} + function generatePkcePair() { const verifier = crypto.randomBytes(48).toString('base64url').slice(0, 64) const challenge = crypto.createHash('sha256').update(verifier).digest('base64url') @@ -99,7 +114,8 @@ export async function registerOAuthSocialRoutes(fastify) { const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` const { codeVerifier, codeChallenge } = generatePkcePair() - const state = fastify.jwt.sign({ oauth: 'vk', cv: codeVerifier }, { expiresIn: '15m' }) + const state = crypto.randomUUID() + storePkce(state, codeVerifier) const url = new URL('https://id.vk.ru/authorize') url.searchParams.set('client_id', clientId) @@ -125,10 +141,8 @@ export async function registerOAuthSocialRoutes(fastify) { const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` const { codeVerifier, codeChallenge } = generatePkcePair() - const state = fastify.jwt.sign( - { oauth: 'vk', action: 'link', userId: request.user.sub, cv: codeVerifier }, - { expiresIn: '15m' }, - ) + const state = crypto.randomUUID() + storePkce(state, codeVerifier, { action: 'link', userId: request.user.sub }) const url = new URL('https://id.vk.ru/authorize') url.searchParams.set('client_id', clientId) @@ -148,15 +162,11 @@ export async function registerOAuthSocialRoutes(fastify) { return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK')) } - const statePayload = (() => { - try { - const raw = typeof query.state === 'string' ? query.state : '' - return fastify.jwt.verify(raw || '') - } catch { - return null - } - })() - if (!statePayload) return oauthErrorRedirect(reply, 'Недействительный state OAuth') + const state = typeof query.state === 'string' ? query.state.trim() : '' + if (!state) return oauthErrorRedirect(reply, 'Недействительный state OAuth') + + const pkceEntry = consumePkce(state) + if (!pkceEntry) return oauthErrorRedirect(reply, 'Недействительный state OAuth') const code = typeof query.code === 'string' ? query.code.trim() : '' if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK') @@ -166,14 +176,13 @@ export async function registerOAuthSocialRoutes(fastify) { const clientId = process.env.VK_CLIENT_ID const clientSecret = process.env.VK_CLIENT_SECRET const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` - const codeVerifier = typeof statePayload.cv === 'string' ? statePayload.cv : '' const body = new URLSearchParams() body.set('grant_type', 'authorization_code') body.set('client_id', clientId) body.set('client_secret', clientSecret) body.set('code', code) - body.set('code_verifier', codeVerifier) + body.set('code_verifier', pkceEntry.codeVerifier) body.set('redirect_uri', redirectUri) if (deviceId) { body.set('device_id', deviceId) @@ -199,7 +208,7 @@ export async function registerOAuthSocialRoutes(fastify) { if (!vkUserId) return oauthErrorRedirect(reply, 'no_user_id') if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') - const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined + const linkToUserId = pkceEntry.meta?.action === 'link' ? pkceEntry.meta.userId : undefined const user = await findOrCreateUserFromOAuth({ provider: 'vk', From 13cc1fa2b81c8198cfc7e5c3ae9598198b923041 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 22:51:03 +0500 Subject: [PATCH 24/25] docs: add VK no-email fix design spec --- .../2026-05-22-vk-no-email-fix-design.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-22-vk-no-email-fix-design.md diff --git a/docs/superpowers/specs/2026-05-22-vk-no-email-fix-design.md b/docs/superpowers/specs/2026-05-22-vk-no-email-fix-design.md new file mode 100644 index 0000000..f1bd362 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-vk-no-email-fix-design.md @@ -0,0 +1,126 @@ +# VK OAuth без email — Design + +## Проблема + +VK ID не всегда возвращает email (необязательное поле). Текущий код требует email на трёх уровнях: +1. Callback (`oauth-social.js:209`) — `if (!emailSuggestion) return oauthErrorRedirect(...)` +2. `findOrCreateUserFromOAuth` (`oauth-social.js:72,87`) — `if (!norm) return null` +3. Схема БД — `email String @unique` (NOT NULL) + +Результат: пользователь, у которого VK не отдал email, видит ошибку `no_email` и не может войти. + +## Решение + +Три изменения: + +1. **Новый пользователь без email** — генерировать синтетический email `vk_@vk.local` +2. **Привязка VK к существующему аккаунту (link)** — не требовать email от VK +3. **Смена email в профиле** — дать пользователю возможность сменить синтетический email на настоящий, с верификацией + +--- + +## Часть 1: OAuth flow (сервер) + +### `server/src/routes/oauth-social.js` + +**`findOrCreateUserFromOAuth`** (стр. 53-104): + +- **Режим link** (стр. 71-77): убрать `if (!norm) return null`. Если `linkToUserId` передан — email не нужен, создаём `OAuthAccount` и возвращаем пользователя. +- **Новый пользователь без email** (стр. 87): вместо `if (!norm) return null` — если `norm` отсутствует, генерируем `vk_@vk.local` и создаём пользователя с `displayName = 'Пользователь'`. + +**VK callback** (стр. 206-209): убрать строку `if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email')`. + +**Yandex callback** — без изменений (Яндекс всегда возвращает email). + +--- + +## Часть 2: Смена email с верификацией + +### Схема БД — новая модель `PendingEmail` + +```prisma +model PendingEmail { + id String @id @default(cuid()) + userId String + email String + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} +``` + +### Миграция + +```bash +cd server && npx prisma migrate dev --name pending_email +``` + +### Серверные роуты + +Новые роуты в `server/src/routes/auth-session.js` (рядом с `/api/me` и `/api/me/auth-methods`): + +**`PATCH /api/me/email`** (requireAuth): + +- Тело: `{ email: string }` +- Валидация: нормализовать через `normalizeEmail()`, проверить формат +- Проверить, что email не занят (`findUnique({ email })`) → 409 Conflict +- Удалить предыдущие `PendingEmail` для этого пользователя +- Создать `PendingEmail` с `token = crypto.randomUUID()`, `expiresAt = now + 24h` +- Ответ: `{ verificationUrl: '/api/me/verify-email?token=' }` (отправка email не реализуем, токен возвращаем в ответе API) + +**`GET /api/me/verify-email`** (без авторизации, только по токену): + +- Искать `PendingEmail` по токену, проверить `expiresAt > now` +- Обновить `User.email`, удалить `PendingEmail` +- Редирект: `{CLIENT_PUBLIC_URL}/me?emailVerified=1` + +### Клиент + +**`client/src/shared/model/auth.ts`** — добавить эффекты: + +```ts +export const requestEmailChangeFx = createEffect(async (email: string) => { + const { data } = await apiClient.patch<{ verificationUrl: string }>('me/email', { email }) + return data.verificationUrl +}) + +export const verifyEmailFx = createEffect(async (token: string) => { + window.location.href = `/api/me/verify-email?token=${token}` +}) +``` + +**`AuthMethodsSection.tsx`** — добавить секцию смены email: + +- Текстовое поле (email) + кнопка «Сменить email» +- После успешного запроса — показать кнопку «Подтвердить email» (переход по `verificationUrl`) +- Ошибки: неверный формат, email занят +- После успешной верификации — обновить `$user` (подгрузить через `meFx` заново) + +--- + +## Структура изменений + +``` +server/ + prisma/schema.prisma — модель PendingEmail + prisma/migrations/ — миграция (авто) + src/routes/oauth-social.js — findOrCreateUserFromOAuth + VK callback fix + src/routes/auth-session.js — PATCH /api/me/email, GET /api/me/verify-email + __tests__/ — тесты на новый flow + +client/ + src/shared/model/auth.ts — requestEmailChangeFx, verifyEmailFx + src/pages/me/ui/sections/ + AuthMethodsSection.tsx — UI для смены email + __tests__/ — тесты UI +``` + +--- + +## Не входит в scope + +- Отправка email с кодом подтверждения (верификация через ссылку в ответе API) +- OAuth для админа (админ только email/код) +- Синтетический email для Яндекса (Яндекс всегда возвращает email) From d60270336e05e9773ca6cdaaaaaf1d92abf4cb18 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 23:03:03 +0500 Subject: [PATCH 25/25] =?UTF-8?q?=D0=BF=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/layout/MainLayout.tsx | 9 +-- .../me/ui/sections/AuthMethodsSection.tsx | 69 ++++++++++++++++++ .../__tests__/AuthMethodsSection.test.tsx | 6 +- client/src/shared/model/auth.ts | 5 ++ .../migration.sql | 19 +++++ server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/prisma/schema.prisma | 15 ++++ server/src/routes/auth-session.js | 52 +++++++++++++ server/src/routes/oauth-social.js | 12 +-- 9 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 server/prisma/migrations/20260522175250_pending_email/migration.sql diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index 7c8aaed..d497721 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -8,8 +8,8 @@ import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { Link as RouterLink } from 'react-router-dom' import { AppHeader } from '@/app/layout/AppHeader' -import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config' import vkLogoSrc from '@/shared/assets/vk-logo.svg' +import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config' import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' import { ScrollToTop } from '@/shared/ui/ScrollToTop' @@ -91,12 +91,7 @@ export function MainLayout({ children }: PropsWithChildren) { color="text.secondary" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }} > - + VK diff --git a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx index 79ae7f1..8df4011 100644 --- a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx +++ b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx @@ -9,10 +9,12 @@ import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' +import { useSearchParams } from 'react-router-dom' import { $user, changePasswordFx, fetchAuthMethodsFx, + requestEmailChangeFx, setPasswordFx, unlinkOAuthFx, type AuthMethod, @@ -77,11 +79,78 @@ export function AuthMethodsSection() { return authMethods.filter((m) => m.active).length }, [authMethods]) + const [searchParams] = useSearchParams() + const emailVerified = searchParams.get('emailVerified') + + const emailForm = useForm<{ email: string }>({ + defaultValues: { email: '' }, + }) + const [emailChangeError, setEmailChangeError] = useState(null) + const [verificationUrl, setVerificationUrl] = useState(null) + + const emailChangeMutation = useMutation({ + mutationFn: async (email: string) => { + setEmailChangeError(null) + const url = await requestEmailChangeFx(email) + return url + }, + onSuccess: (url) => setVerificationUrl(url), + onError: (err) => setEmailChangeError(err?.message || 'Не удалось сменить email'), + }) + if (!user) return null return ( + Почта + + + {emailVerified === '1' && ( + + Почта успешно подтверждена + + )} + + + {user.email} + + + {!verificationUrl && ( + + + + + )} + + {verificationUrl && ( + + + Ссылка подтверждения готова. + + + + )} + + Методы входа {fetchError && ( diff --git a/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx index 06c425d..693756d 100644 --- a/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx +++ b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' import { describe, expect, it, vi } from 'vitest' import { AuthMethodsSection } from '../AuthMethodsSection' @@ -15,6 +16,7 @@ vi.mock('@/shared/model/auth', () => ({ fetchAuthMethodsFx: vi.fn().mockResolvedValue([]), setPasswordFx: vi.fn(), unlinkOAuthFx: vi.fn(), + requestEmailChangeFx: vi.fn(), })) vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) @@ -29,7 +31,9 @@ function renderSection() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) return render( - + + + , ) } diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index bb3d6c4..2e91529 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -101,6 +101,11 @@ export const changePasswordFx = createEffect(async (params: { oldPassword: strin await apiClient.post('me/change-password', params) }) +export const requestEmailChangeFx = createEffect(async (email: string) => { + const { data } = await apiClient.patch<{ verificationUrl: string }>('me/email', { email }) + return data.verificationUrl +}) + // ----- Error stores ----- export const $updateProfileError = createErrorStore(updateProfileFx).$error diff --git a/server/prisma/migrations/20260522175250_pending_email/migration.sql b/server/prisma/migrations/20260522175250_pending_email/migration.sql new file mode 100644 index 0000000..b6dfb3c --- /dev/null +++ b/server/prisma/migrations/20260522175250_pending_email/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "PendingEmail" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PendingEmail_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "PendingEmail_token_key" ON "PendingEmail"("token"); + +-- CreateIndex +CREATE INDEX "PendingEmail_token_idx" ON "PendingEmail"("token"); + +-- CreateIndex +CREATE INDEX "PendingEmail_userId_idx" ON "PendingEmail"("userId"); diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index d5a37a2edc479a586a6837fc378465b2b91a5f33..873e50ecbcc654766b87cbcca7b8dbc008b9a244 100644 GIT binary patch delta 3537 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 { + const userId = request.user.sub + const rawEmail = typeof request.body?.email === 'string' ? request.body.email.trim() : '' + + if (!rawEmail || !rawEmail.includes('@')) { + return reply.code(400).send({ error: 'Некорректная почта' }) + } + + const email = normalizeEmail(rawEmail) + + const existing = await prisma.user.findUnique({ where: { email } }) + if (existing && existing.id !== userId) { + return reply.code(409).send({ error: 'Эта почта уже используется' }) + } + + await prisma.pendingEmail.deleteMany({ where: { userId } }) + + const token = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) + + await prisma.pendingEmail.create({ + data: { userId, email, token, expiresAt }, + }) + + return { verificationUrl: `/api/me/verify-email?token=${token}` } + }) + + fastify.get('/api/me/verify-email', async (request, reply) => { + const token = typeof request.query?.token === 'string' ? request.query.token : '' + + if (!token) { + return reply.code(400).send({ error: 'Отсутствует токен подтверждения' }) + } + + const pending = await prisma.pendingEmail.findUnique({ where: { token } }) + if (!pending || pending.expiresAt < new Date()) { + return reply.code(400).send({ error: 'Токен подтверждения недействителен или истёк' }) + } + + await prisma.user.update({ + where: { id: pending.userId }, + data: { email: pending.email }, + }) + + await prisma.pendingEmail.delete({ where: { id: pending.id } }) + + const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '') + return reply.redirect(`${clientUrl}/me?emailVerified=1`) + }) } diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 5e6137e..faf74b3 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -69,7 +69,6 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken const norm = trimmed ? normalizeEmail(trimmed) : null if (linkToUserId) { - if (!norm) return null await prisma.oAuthAccount.create({ data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken }, }) @@ -84,13 +83,13 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken return user } - if (!norm) return null + const email = norm || `${provider}_${providerUserId}@vk.local` user = await prisma.user.create({ data: { - email: norm, - displayName: norm.split('@')[0], - avatar: await generateAvatar(norm), + email, + displayName: norm ? norm.split('@')[0] : 'Пользователь', + avatar: await generateAvatar(email), avatarStyle: 'avataaars', }, }) @@ -206,7 +205,6 @@ export async function registerOAuthSocialRoutes(fastify) { const emailSuggestion = claims?.email ?? tokenBody?.email ?? null if (!vkUserId) return oauthErrorRedirect(reply, 'no_user_id') - if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') const linkToUserId = pkceEntry.meta?.action === 'link' ? pkceEntry.meta.userId : undefined @@ -218,8 +216,6 @@ export async function registerOAuthSocialRoutes(fastify) { linkToUserId, }) - if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK') - if (linkToUserId) { const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`)