fix: add error logging to empty catch blocks

This commit is contained in:
Kirill
2026-05-27 20:17:05 +05:00
parent 8f3bd7aa3b
commit f6414adf2f
26 changed files with 1590 additions and 34 deletions
+2 -2
View File
@@ -53,8 +53,8 @@ export function SseProvider() {
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
break
}
} catch {
// ignore parse errors (e.g. heartbit comments)
} catch (err) {
console.warn('[sse] Failed to parse event data', err)
}
}
}
@@ -29,7 +29,8 @@ function readStoredTheme(): ThemeSettings | null {
const schemeOk = scheme === 'craft' || scheme === 'forest' || scheme === 'ocean' || scheme === 'berry'
if (!modeOk || !schemeOk) return null
return { mode, scheme }
} catch {
} catch (err) {
console.warn('[theme] Failed to read stored theme', err)
return null
}
}
@@ -80,8 +81,8 @@ export function ThemeControllerProvider({ children }: PropsWithChildren) {
useEffect(() => {
try {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(settings))
} catch {
// ignore
} catch (err) {
console.warn('[theme] Failed to persist theme setting', err)
}
}, [settings])
@@ -42,8 +42,8 @@ export function AddressMapPicker(props: {
setHint(addr)
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
} catch {
// ignore
} catch (err) {
console.warn('[address-map-picker] Failed to reverse geocode', err)
}
}
@@ -52,8 +52,8 @@ export function MapPickerMap({ value, onChange, center }: MapPickerMapProps) {
if (addr) {
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
} catch {
// ignore
} catch (err) {
console.warn('[map-picker] Failed to reverse geocode', err)
}
}
@@ -35,7 +35,8 @@ function getApiErrorMessage(error: unknown): string | null {
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch {
} catch (err) {
console.warn('[gallery] Failed to format date', err)
return ''
}
}
@@ -35,7 +35,8 @@ function formatDt(v: string) {
const d = new Date(v)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleString()
} catch {
} catch (err) {
console.warn('[admin-users] Failed to format date', err)
return '—'
}
}
+2 -1
View File
@@ -23,7 +23,8 @@ function readStoredScheme(): ColorScheme {
const parsed = JSON.parse(raw)
const scheme = parsed?.scheme
return scheme === 'forest' || scheme === 'ocean' || scheme === 'berry' ? scheme : 'craft'
} catch {
} catch (err) {
console.warn('[auth] Failed to read stored theme scheme', err)
return 'craft'
}
}
+2 -1
View File
@@ -17,7 +17,8 @@ apiClient.interceptors.request.use((config) => {
config.headers.delete('content-type')
}
return config
} catch {
} catch (err) {
console.warn('[api-client] Failed to set auth token', err)
return config
}
})
@@ -12,7 +12,8 @@ export function parseOrderAddressSnapshot(json: string | null | undefined): Orde
if (!json) return null
try {
return JSON.parse(json) as OrderAddressSnapshot
} catch {
} catch (err) {
console.warn('[order-address-snapshot] Failed to parse address snapshot', err)
return null
}
}
+6 -5
View File
@@ -3,7 +3,8 @@ const TOKEN_KEY = 'craftshop_auth_token'
export function readStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY)
} catch {
} catch (err) {
console.warn('[persist-token] Failed to read from localStorage', err)
return null
}
}
@@ -12,15 +13,15 @@ export function persistToken(token: string | null): void {
try {
if (!token) localStorage.removeItem(TOKEN_KEY)
else localStorage.setItem(TOKEN_KEY, token)
} catch {
// ignore
} catch (err) {
console.warn('[persist-token] Failed to write to localStorage', err)
}
}
export function removeStoredToken(): void {
try {
localStorage.removeItem(TOKEN_KEY)
} catch {
// ignore
} catch (err) {
console.warn('[persist-token] Failed to remove from localStorage', err)
}
}
+4 -3
View File
@@ -10,7 +10,8 @@ const STORAGE_KEY = 'cookie-consent-accepted'
function wasAccepted(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === '1'
} catch {
} catch (err) {
console.warn('[cookie-consent] Failed to read cookie consent', err)
return false
}
}
@@ -18,8 +19,8 @@ function wasAccepted(): boolean {
function markAccepted() {
try {
localStorage.setItem(STORAGE_KEY, '1')
} catch {
// ignore
} catch (err) {
console.warn('[cookie-consent] Failed to persist cookie consent', err)
}
}
@@ -22,8 +22,8 @@ export function RichTextMessageContent({ value, tone = 'default' }: RichTextMess
const normalizedValue = value.trim() ? value : '<p></p>'
try {
if (editor.getHTML() === normalizedValue) return
} catch {
// editor schema not ready yet
} catch (err) {
console.warn('[tiptap] Failed to get editor HTML', err)
return
}
editor.commands.setContent(normalizedValue, { emitUpdate: false })
@@ -0,0 +1,289 @@
# API Error Handling — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** Создать `useMutationWithToast` обёртку, улучшить `getApiErrorMessage`, исправить CheckoutPage error display.
**Architecture:** Обёртка над `useMutation` с автоматическим показом toast (через notification store из подпроекта 1), улучшенный парсинг ошибок API.
**Tech Stack:** TypeScript, TanStack React Query, Vitest
**Depends on:** Subproject 1 (toast notifications store)
---
### Task 1: Улучшить getApiErrorMessage
**Files:**
- Modify: `client/src/shared/lib/get-api-error-message.ts`
- Test: modify existing or add new tests
- [ ] **Step 1: Write/update tests**
```ts
// client/src/shared/lib/__tests__/get-api-error-message.test.ts
import { describe, it, expect } from 'vitest'
import { getApiErrorMessage } from '../get-api-error-message'
describe('getApiErrorMessage', () => {
it('returns server error message when axios error has response.data.error', () => {
const err = { response: { data: { error: 'Email already taken' } } }
expect(getApiErrorMessage(err)).toBe('Email already taken')
})
it('returns network error message when no response', () => {
const err = { message: 'Network Error', code: 'ERR_NETWORK' }
expect(getApiErrorMessage(err)).toBe('Нет соединения с сервером. Проверьте подключение к интернету.')
})
it('returns generic 500 message', () => {
const err = { response: { status: 500, data: {} } }
expect(getApiErrorMessage(err)).toBe('Произошла ошибка. Попробуйте повторить позже.')
})
it('falls back to error message', () => {
const err = new Error('Something went wrong')
expect(getApiErrorMessage(err)).toBe('Something went wrong')
})
it('handles unknown error shape', () => {
expect(getApiErrorMessage({})).toBe('Произошла неизвестная ошибка')
})
})
```
- [ ] **Step 2: Run to see failure**
Run: `cd client && npx vitest run shared/lib/__tests__/get-api-error-message.test.ts`
Expected: FAIL (existing tests may not cover new cases)
- [ ] **Step 3: Implement improved getApiErrorMessage**
```ts
// client/src/shared/lib/get-api-error-message.ts
import { isAxiosError } from 'axios'
export function getApiErrorMessage(error: unknown): string {
if (!error) return 'Произошла неизвестная ошибка'
if (isAxiosError(error)) {
if (error.response?.data?.error && typeof error.response.data.error === 'string') {
return error.response.data.error
}
if (!error.response) {
return 'Нет соединения с сервером. Проверьте подключение к интернету.'
}
if (error.response.status >= 500) {
return 'Произошла ошибка. Попробуйте повторить позже.'
}
}
if (error instanceof Error) {
return error.message
}
return 'Произошла неизвестная ошибка'
}
```
- [ ] **Step 4: Run tests**
Run: `cd client && npx vitest run shared/lib/__tests__/get-api-error-message.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add client/src/shared/lib/get-api-error-message.ts client/src/shared/lib/__tests__/get-api-error-message.test.ts
git commit -m "feat: improve getApiErrorMessage with user-friendly messages"
```
---
### Task 2: useMutationWithToast обёртка
**Files:**
- Create: `client/src/shared/lib/use-mutation-with-toast.ts`
- Test: `client/src/shared/lib/__tests__/use-mutation-with-toast.test.tsx`
- [ ] **Step 1: Write failing tests**
```tsx
// client/src/shared/lib/__tests__/use-mutation-with-toast.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useMutationWithToast } from '../use-mutation-with-toast'
import { addNotification } from '../../model/notification'
vi.mock('../../model/notification', () => ({
addNotification: vi.fn(),
}))
function createWrapper() {
const qc = new QueryClient()
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
)
}
describe('useMutationWithToast', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows success toast on success', async () => {
const mutationFn = vi.fn().mockResolvedValue({ ok: true })
const { result } = renderHook(
() => useMutationWithToast({ mutationFn, successMessage: 'Done!' }),
{ wrapper: createWrapper() },
)
result.current.mutate()
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(addNotification).toHaveBeenCalledWith({ type: 'success', message: 'Done!' })
})
it('shows error toast on error', async () => {
const mutationFn = vi.fn().mockRejectedValue(new Error('Boom'))
const { result } = renderHook(
() => useMutationWithToast({ mutationFn }),
{ wrapper: createWrapper() },
)
result.current.mutate()
await waitFor(() => expect(result.current.isError).toBe(true))
expect(addNotification).toHaveBeenCalledWith({ type: 'error', message: 'Boom' })
})
it('calls user-provided onSuccess', async () => {
const mutationFn = vi.fn().mockResolvedValue({ ok: true })
const onSuccess = vi.fn()
const { result } = renderHook(
() => useMutationWithToast({ mutationFn, onSuccess, successMessage: 'Done!' }),
{ wrapper: createWrapper() },
)
result.current.mutate()
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(onSuccess).toHaveBeenCalled()
})
it('does not show success toast if no successMessage', async () => {
const mutationFn = vi.fn().mockResolvedValue({ ok: true })
const { result } = renderHook(
() => useMutationWithToast({ mutationFn }),
{ wrapper: createWrapper() },
)
result.current.mutate()
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(addNotification).not.toHaveBeenCalled()
})
})
```
- [ ] **Step 2: Run to verify failure**
Run: `cd client && npx vitest run shared/lib/__tests__/use-mutation-with-toast.test.tsx`
Expected: FAIL
- [ ] **Step 3: Implement useMutationWithToast**
```ts
// client/src/shared/lib/use-mutation-with-toast.ts
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
import { addNotification } from '../model/notification'
import { getApiErrorMessage } from './get-api-error-message'
type MutationWithToastOptions<TData, TError, TVariables, TContext> =
UseMutationOptions<TData, TError, TVariables, TContext> & {
successMessage?: string
}
export function useMutationWithToast<TData = unknown, TError = unknown, TVariables = void, TContext = unknown>(
options: MutationWithToastOptions<TData, TError, TVariables, TContext>,
) {
const { successMessage, onSuccess, onError, ...mutationOptions } = options
return useMutation({
...mutationOptions,
onSuccess: (data, variables, context) => {
if (successMessage) {
addNotification({ type: 'success', message: successMessage })
}
onSuccess?.(data, variables, context)
},
onError: (error, variables, context) => {
addNotification({ type: 'error', message: getApiErrorMessage(error) })
onError?.(error, variables, context)
},
})
}
```
- [ ] **Step 4: Run tests**
Run: `cd client && npx vitest run shared/lib/__tests__/use-mutation-with-toast.test.tsx`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add client/src/shared/lib/use-mutation-with-toast.ts client/src/shared/lib/__tests__/use-mutation-with-toast.test.tsx
git commit -m "feat: add useMutationWithToast wrapper"
```
---
### Task 3: Исправить CheckoutPage error display
**Files:**
- Modify: `client/src/pages/checkout/CheckoutPage.tsx`
- [ ] **Step 1: Найти прямой вызов error.message**
Search for: `(createMut.error as Error).message`
- [ ] **Step 2: Заменить на getApiErrorMessage**
Before:
```tsx
{createMut.isError && (
<Alert severity="error">
{(createMut.error as Error).message}
</Alert>
)}
```
After:
```tsx
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
{createMut.isError && (
<Alert severity="error">
{getApiErrorMessage(createMut.error)}
</Alert>
)}
```
- [ ] **Step 3: Run lint + test + build**
```bash
cd client && npm run lint && npm test && npm run build
```
- [ ] **Step 4: Commit**
```bash
git add client/src/pages/checkout/CheckoutPage.tsx
git commit -m "fix: use getApiErrorMessage in CheckoutPage for user-friendly error display"
```
@@ -0,0 +1,175 @@
# Client Duplication — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** Устранить дублирование `useQuery` для корзины (4 копии → 1 хук), устранить дублирование статусов заказа.
**Architecture:** Кастомный хук `useCartQuery` в `entities/cart/lib/`, единый источник `ORDER_STATUS_DATA`.
**Tech Stack:** TypeScript, TanStack React Query, Vitest
**Depends on:** none
---
### Task 1: useCartQuery хук + тесты
**Files:**
- Create: `client/src/entities/cart/lib/use-cart-query.ts`
- Test: `client/src/entities/cart/lib/use-cart-query.test.tsx`
- Modify: `client/src/widgets/catalog-slider/ui/AppHeader.tsx`
- Modify: `client/src/pages/cart/CartPage.tsx`
- Modify: `client/src/pages/checkout/CheckoutPage.tsx`
- Modify: `client/src/features/cart/ui/ToggleCartIcon/ToggleCartIcon.tsx`
- [ ] **Step 1: Write failing tests**
```tsx
// client/src/entities/cart/lib/use-cart-query.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useCartQuery } from './use-cart-query'
import { fetchMyCart } from '../../api'
vi.mock('../../api', () => ({
fetchMyCart: vi.fn(),
}))
vi.mock('@/shared/model/auth', () => ({
useAuthUser: vi.fn(),
}))
import { useAuthUser } from '@/shared/model/auth'
function createWrapper() {
const qc = new QueryClient()
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
)
}
describe('useCartQuery', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns query with correct key and enabled flag for authenticated user', async () => {
vi.mocked(useAuthUser).mockReturnValue({ id: '1', email: 'test@test.com' })
vi.mocked(fetchMyCart).mockResolvedValue({ items: [] })
const { result } = renderHook(() => useCartQuery(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(fetchMyCart).toHaveBeenCalled()
expect(result.current.queryKey).toEqual(['me', 'cart'])
})
it('does not fetch when user is not authenticated', () => {
vi.mocked(useAuthUser).mockReturnValue(null)
const { result } = renderHook(() => useCartQuery(), { wrapper: createWrapper() })
expect(fetchMyCart).not.toHaveBeenCalled()
expect(result.current.fetchStatus).toBe('idle')
})
})
```
- [ ] **Step 2: Run to verify failure**
Run: `cd client && npx vitest run entities/cart/lib/use-cart-query.test.tsx`
Expected: FAIL
- [ ] **Step 3: Implement useCartQuery**
```ts
// client/src/entities/cart/lib/use-cart-query.ts
import { useQuery } from '@tanstack/react-query'
import { useAuthUser } from '@/shared/model/auth'
import { fetchMyCart } from '../../api'
export function useCartQuery() {
const user = useAuthUser()
return useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
}
```
- [ ] **Step 4: Run tests**
Run: `cd client && npx vitest run entities/cart/lib/use-cart-query.test.tsx`
Expected: PASS
- [ ] **Step 5: Apply to AppHeader.tsx**
Before:
```tsx
const { data: cart } = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
```
After:
```tsx
import { useCartQuery } from '@/entities/cart/lib/use-cart-query'
const { data: cart } = useCartQuery()
```
- [ ] **Step 6: Apply to CartPage.tsx, CheckoutPage.tsx, ToggleCartIcon.tsx**
Same replacement in each file.
- [ ] **Step 7: Run lint + test + build**
```bash
cd client && npm run lint && npm test && npm run build
```
- [ ] **Step 8: Commit**
```bash
git add client/src/entities/cart/lib/use-cart-query.ts client/src/entities/cart/lib/use-cart-query.test.tsx client/src/widgets/catalog-slider/ui/AppHeader.tsx client/src/pages/cart/CartPage.tsx client/src/pages/checkout/CheckoutPage.tsx client/src/features/cart/ui/ToggleCartIcon/ToggleCartIcon.tsx
git commit -m "refactor: extract useCartQuery hook"
```
---
### Task 2: Устранить дублирование статусов заказа
**Files:**
- Read: `client/src/shared/lib/order-status-data.ts`
- Read: `client/src/shared/lib/order-status-labels.ts`
- Delete: `client/src/shared/lib/order-status-labels.ts`
- Modify: all files importing from `order-status-labels`
- [ ] **Step 1: Найти все импорты orderStatusLabelRu**
Run: `rg 'orderStatusLabelRu' client/src/ --include '*.ts' --include '*.tsx'`
- [ ] **Step 2: Заменить импорты на ORDER_STATUS_DATA**
Each file importing `{ orderStatusLabelRu }` from `order-status-labels`:
- Change to import `{ ORDER_STATUS_DATA }` from `order-status-data`
- Replace `orderStatusLabelRu(status)` with `ORDER_STATUS_DATA[status].label`
- [ ] **Step 3: Delete order-status-labels.ts**
- [ ] **Step 4: Run lint + test + build**
```bash
cd client && npm run lint && npm test && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add client/src/shared/lib/order-status-labels.ts client/src/shared/lib/order-status-data.ts
git commit -m "refactor: remove duplicate order status labels, use ORDER_STATUS_DATA as single source"
```
@@ -0,0 +1,178 @@
# Empty Catch Blocks — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** Добавить логирование во все пустые catch-блоки (17 мест), убрать `// ignore` комментарии.
**Architecture:** В каждом catch добавить `console.warn` (клиент) или `request.log.error` (сервер) с контекстным сообщением. Минимальные, безопасные изменения.
**Tech Stack:** TypeScript, JavaScript, ESLint (no-console разрешает warn/error/info)
**Depends on:** none
---
### Task 1: Пустые catch на клиенте — persist-token.ts (3 места)
**Files:**
- Modify: `client/src/shared/model/persist-token.ts`
- [ ] **Step 1: Read file to find empty catch blocks**
Run: read `client/src/shared/model/persist-token.ts`
- [ ] **Step 2: Add console.warn to each catch**
Each `catch { /* ignore */ }` becomes:
```ts
catch (err) {
console.warn('[persist-token] Failed to ...', err)
}
```
Specific messages based on context:
- localStorage.getItem error → `'[persist-token] Failed to read from localStorage'`
- localStorage.setItem error → `'[persist-token] Failed to write to localStorage'`
- JSON.parse error → `'[persist-token] Failed to parse stored value'`
- [ ] **Step 3: Run lint**
Run: `cd client && npm run lint`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add client/src/shared/model/persist-token.ts
git commit -m "fix: add error logging to persist-token catch blocks"
```
---
### Task 2: Пустые catch на клиенте — theme-controller.tsx (2 места)
**Files:**
- Modify: `client/src/shared/model/theme-controller.tsx`
- [ ] **Step 1: Read file**
Run: read `client/src/shared/model/theme-controller.tsx`
- [ ] **Step 2: Add console.warn to each catch**
```ts
catch (err) {
console.warn('[theme] Failed to ...', err)
}
```
- [ ] **Step 3: Run lint**
- [ ] **Step 4: Commit**
```bash
git add client/src/shared/model/theme-controller.tsx
git commit -m "fix: add error logging to theme-controller catch blocks"
```
---
### Task 3: Пустые catch на клиенте — CookieConsentBanner (2 места)
**Files:**
- Modify: `client/src/widgets/navigation-drawer/ui/CookieConsentBanner.tsx`
- [ ] **Step 1: Read file**
- [ ] **Step 2: Add console.warn**
```ts
catch (err) {
console.warn('[cookie-consent] Failed to ...', err)
}
```
- [ ] **Step 3: Run lint**
- [ ] **Step 4: Commit**
```bash
git add client/src/widgets/navigation-drawer/ui/CookieConsentBanner.tsx
git commit -m "fix: add error logging to CookieConsentBanner catch blocks"
```
---
### Task 4: Пустые catch на клиенте — SseProvider.tsx
**Files:**
- Modify: `client/src/app/SseProvider.tsx`
- [ ] **Step 1: Read file, find empty catch**
- [ ] **Step 2: Add console.warn**
```ts
catch (err) {
console.warn('[sse] Connection error:', err)
}
```
- [ ] **Step 3: Run lint**
- [ ] **Step 4: Commit**
```bash
git add client/src/app/SseProvider.tsx
git commit -m "fix: add error logging to SseProvider catch block"
```
---
### Task 5: Пустые catch на сервере
**Files:** (find exact paths from exploration, likely in admin routes)
- [ ] **Step 1: Find all empty catch blocks in server/src/**
Run: `rg 'catch\s*\{' server/src/ --include '*.js'`
- [ ] **Step 2: Add request.log.error to each**
```js
catch (err) {
request.log.error({ err }, 'Failed to [operation description]')
}
```
- [ ] **Step 3: Run lint**
Run: `cd server && npm run lint`
Expected: PASS
- [ ] **Step 4: Run tests**
Run: `cd server && npm test`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/src/
git commit -m "fix: add error logging to server catch blocks"
```
---
### Task 6: Финальная проверка
- [ ] **Step 1: Run all lints + tests**
```bash
cd client && npm run lint && npm test
cd server && npm run lint && npm test
```
- [ ] **Step 2: Verify no new ESLint errors**
Expected: PASS all
@@ -0,0 +1,318 @@
# Server Duplication — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** Устранить дублирование в серверных роутах: asyncHandler декоратор, validateGalleryImages, findUserOrder.
**Architecture:** Вынос повторяющихся паттернов в shared-хелперы (`server/src/lib/`), замена ручного try/catch на декоратор.
**Tech Stack:** JavaScript, Fastify, Prisma, Vitest
**Depends on:** none
---
### Task 1: asyncHandler декоратор + тесты
**Files:**
- Create: `server/src/lib/async-handler.js`
- Test: `server/src/lib/__tests__/async-handler.test.js`
- [ ] **Step 1: Write failing tests**
```js
// server/src/lib/__tests__/async-handler.test.js
import { describe, it, expect, vi } from 'vitest'
import { asyncHandler } from '../async-handler.js'
describe('asyncHandler', () => {
it('calls the handler and returns result on success', async () => {
const handler = vi.fn().mockResolvedValue({ hello: 'world' })
const request = {}
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
await asyncHandler(handler)(request, reply)
expect(handler).toHaveBeenCalledWith(request, reply)
expect(reply.send).toHaveBeenCalledWith({ hello: 'world' })
})
it('catches errors and sends 500 with message', async () => {
const handler = vi.fn().mockRejectedValue(new Error('boom'))
const request = { log: { error: vi.fn() } }
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
await asyncHandler(handler)(request, reply)
expect(reply.code).toHaveBeenCalledWith(500)
expect(reply.send).toHaveBeenCalledWith({ error: 'Internal server error' })
})
it('uses statusCode from error if present', async () => {
const err = new Error('Not found')
err.statusCode = 404
const handler = vi.fn().mockRejectedValue(err)
const request = { log: { error: vi.fn() } }
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
await asyncHandler(handler)(request, reply)
expect(reply.code).toHaveBeenCalledWith(404)
expect(reply.send).toHaveBeenCalledWith({ error: 'Not found' })
})
})
```
- [ ] **Step 2: Run to verify failure**
Run: `cd server && npx vitest run lib/__tests__/async-handler.test.js`
Expected: FAIL
- [ ] **Step 3: Implement asyncHandler**
```js
// server/src/lib/async-handler.js
export function asyncHandler(fn) {
return async (request, reply) => {
try {
return await fn(request, reply)
} catch (err) {
request.log.error(err)
const statusCode = err.statusCode || 500
const message = err.statusCode ? err.message : 'Internal server error'
return reply.code(statusCode).send({ error: message })
}
}
}
```
- [ ] **Step 4: Run tests**
Run: `cd server && npx vitest run lib/__tests__/async-handler.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/src/lib/async-handler.js server/src/lib/__tests__/async-handler.test.js
git commit -m "feat: add asyncHandler decorator for route error handling"
```
---
### Task 2: Применить asyncHandler в роутах
**Files:**
- Modify: `server/src/routes/user-orders.js`
- Modify: `server/src/routes/user-messages.js`
- Modify: `server/src/routes/user-payments.js`
- Modify: `server/src/routes/admin-gallery.js`
- Modify: all other route files with manual try/catch
- [ ] **Step 1: Найти все ручные try/catch в роутах**
Run: `rg 'try\s*\{' server/src/routes/ --include '*.js'`
- [ ] **Step 2: Заменить каждый try/catch на asyncHandler**
Before:
```js
fastify.get('/orders', async (request, reply) => {
try {
const orders = await prisma.order.findMany(...)
return reply.send(orders)
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Internal server error' })
}
})
```
After:
```js
import { asyncHandler } from '../../lib/async-handler.js'
fastify.get('/orders', asyncHandler(async (request, reply) => {
const orders = await prisma.order.findMany(...)
return reply.send(orders)
}))
```
- [ ] **Step 3: Run lint + tests**
```bash
cd server && npm run lint && npm test
```
- [ ] **Step 4: Commit**
```bash
git add server/src/routes/
git commit -m "refactor: apply asyncHandler to all route handlers"
```
---
### Task 3: validateGalleryImages хелпер
**Files:**
- Create: `server/src/lib/validate-gallery-images.js`
- Test: `server/src/lib/__tests__/validate-gallery-images.test.js`
- Modify: `server/src/routes/api/admin-products.js`
- [ ] **Step 1: Write tests**
```js
// server/src/lib/__tests__/validate-gallery-images.test.js
import { describe, it, expect, vi } from 'vitest'
import { validateGalleryImages } from '../validate-gallery-images.js'
describe('validateGalleryImages', () => {
it('returns null when galleryImages is empty', async () => {
const prisma = { galleryImage: { findMany: vi.fn() } }
const result = await validateGalleryImages(prisma, [])
expect(result).toBeNull()
})
it('throws when image not found', async () => {
const prisma = { galleryImage: { findMany: vi.fn().mockResolvedValue([]) } }
await expect(validateGalleryImages(prisma, [1])).rejects.toThrow('not found')
})
})
```
- [ ] **Step 2: Implement**
```js
// server/src/lib/validate-gallery-images.js
export async function validateGalleryImages(prisma, galleryImages) {
if (!galleryImages || galleryImages.length === 0) return null
const existing = await prisma.galleryImage.findMany({
where: { id: { in: galleryImages } },
select: { id: true, resized: true },
})
const foundIds = new Set(existing.map((img) => img.id))
const missing = galleryImages.filter((id) => !foundIds.has(id))
if (missing.length > 0) {
throw Object.assign(new Error(`Gallery images not found: ${missing.join(', ')}`), { statusCode: 404 })
}
const notResized = existing.filter((img) => !img.resized)
if (notResized.length > 0) {
throw Object.assign(new Error('Some gallery images have not been processed yet. Please try again later.'), { statusCode: 400 })
}
return existing
}
```
- [ ] **Step 3: Заменить дублирующийся код в admin-products.js**
Before (in POST /admin/products):
```js
// validate gallery images
if (galleryImages && galleryImages.length > 0) {
const existingImages = await prisma.galleryImage.findMany({ ... })
// ... duplicate validation logic
}
```
After:
```js
import { validateGalleryImages } from '../../lib/validate-gallery-images.js'
// single call
await validateGalleryImages(prisma, galleryImages)
```
Same replacement for PATCH /admin/products/:id.
- [ ] **Step 4: Run tests**
```bash
cd server && npm run lint && npm test
```
- [ ] **Step 5: Commit**
```bash
git add server/src/lib/validate-gallery-images.js server/src/lib/__tests__/validate-gallery-images.test.js server/src/routes/api/admin-products.js
git commit -m "refactor: extract validateGalleryImages helper"
```
---
### Task 4: findUserOrder хелпер
**Files:**
- Create: `server/src/lib/find-user-order.js`
- Test: `server/src/lib/__tests__/find-user-order.test.js`
- Modify: `server/src/routes/user-orders.js`
- Modify: `server/src/routes/user-messages.js`
- Modify: `server/src/routes/user-payments.js`
- [ ] **Step 1: Write tests**
```js
// server/src/lib/__tests__/find-user-order.test.js
import { describe, it, expect, vi } from 'vitest'
import { findUserOrder } from '../find-user-order.js'
describe('findUserOrder', () => {
it('returns order when found', async () => {
const mockOrder = { id: '1', userId: 'user1' }
const prisma = { order: { findFirst: vi.fn().mockResolvedValue(mockOrder) } }
const result = await findUserOrder(prisma, '1', 'user1')
expect(result).toEqual(mockOrder)
expect(prisma.order.findFirst).toHaveBeenCalledWith(expect.objectContaining({
where: { id: '1', userId: 'user1' },
}))
})
it('throws 404 when not found', async () => {
const prisma = { order: { findFirst: vi.fn().mockResolvedValue(null) } }
await expect(findUserOrder(prisma, '999', 'user1')).rejects.toMatchObject({ statusCode: 404 })
})
})
```
- [ ] **Step 2: Implement**
```js
// server/src/lib/find-user-order.js
export async function findUserOrder(prisma, orderId, userId, include = {}) {
const order = await prisma.order.findFirst({
where: { id: orderId, userId },
include,
})
if (!order) {
throw Object.assign(new Error('Order not found'), { statusCode: 404 })
}
return order
}
```
- [ ] **Step 3: Заменить дублирующийся код в роутах**
Each instance of:
```js
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Order not found' })
```
Becomes:
```js
import { findUserOrder } from '../../lib/find-user-order.js'
const order = await findUserOrder(prisma, id, userId)
```
- [ ] **Step 4: Run tests**
```bash
cd server && npm run lint && npm test
```
- [ ] **Step 5: Commit**
```bash
git add server/src/lib/find-user-order.js server/src/lib/__tests__/find-user-order.test.js server/src/routes/user-{orders,messages,payments}.js
git commit -m "refactor: extract findUserOrder helper"
```
@@ -0,0 +1,378 @@
# Toast Notifications System — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** Создать общую систему toast-уведомлений на Effector + MUI, мигрировать CartSnackbar, удалить старый код.
**Architecture:** Effector-стор `$notifications` с очередью (макс 3 видимых), компонент `NotificationStack` с MUI Snackbar + Alert, интеграция через события `addNotification`/`dismissNotification`.
**Tech Stack:** Effector, MUI Snackbar + Alert, TypeScript, Vitest + @testing-library/react
**Depends on:** none
---
### Task 1: Effector-стор уведомлений
**Files:**
- Create: `client/src/shared/model/notification.ts`
- Test: `client/src/shared/model/notification.test.ts`
- [ ] **Step 1: Write failing tests for notification store**
```ts
// client/src/shared/model/notification.test.ts
import { describe, it, expect } from 'vitest'
import {
$notifications,
addNotification,
dismissNotification,
dismissAll,
} from './notification'
describe('notification store', () => {
it('starts empty', () => {
expect($notifications.getState()).toEqual([])
})
it('adds a notification', () => {
addNotification({ type: 'success', message: 'OK' })
const state = $notifications.getState()
expect(state).toHaveLength(1)
expect(state[0]).toMatchObject({ type: 'success', message: 'OK' })
expect(state[0].id).toBeDefined()
})
it('caps at 3 visible notifications, queues extras', () => {
addNotification({ type: 'info', message: 'A' })
addNotification({ type: 'info', message: 'B' })
addNotification({ type: 'info', message: 'C' })
addNotification({ type: 'info', message: 'D' })
expect($notifications.getState()).toHaveLength(3)
// wait for idle and check — actually effector stores are sync
// but we need to verify only 3 are visible and 4th is queued
})
it('dismisses a notification by id', () => {
const id = 'test-id'
// manually set state
// add dismiss and check
})
it('dismisses all notifications', () => {
// add several, dismissAll, check empty
})
it('auto-dismisses after autoHideDuration', () => {
// use fake timers
})
})
```
- [ ] **Step 2: Run to verify failure**
Run: `cd client && npx vitest run shared/model/notification.test.ts`
Expected: FAIL, module not found
- [ ] **Step 3: Implement notification store**
```ts
// client/src/shared/model/notification.ts
import { createEvent, createStore, sample } from 'effector'
type NotificationType = 'success' | 'error' | 'info' | 'warning'
interface Notification {
id: string
type: NotificationType
message: string
autoHideDuration?: number
}
const MAX_VISIBLE = 3
let nextId = 1
export const addNotification = createEvent<{
type: NotificationType
message: string
autoHideDuration?: number
}>()
export const dismissNotification = createEvent<string>()
export const dismissAll = createEvent()
export const $notifications = createStore<Notification[]>([])
.on(addNotification, (state, { type, message, autoHideDuration }) => {
const notification: Notification = {
id: String(nextId++),
type,
message,
autoHideDuration: autoHideDuration ?? (type === 'error' ? 6000 : 4000),
}
return [...state, notification].slice(-MAX_VISIBLE)
})
.on(dismissNotification, (state, id) =>
state.filter((n) => n.id !== id),
)
.reset(dismissAll)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd client && npx vitest run shared/model/notification.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add client/src/shared/model/notification.ts client/src/shared/model/notification.test.ts
git commit -m "feat: add notification store (Effector)"
```
---
### Task 2: NotificationStack component
**Files:**
- Create: `client/src/shared/ui/NotificationStack/NotificationStack.tsx`
- Create: `client/src/shared/ui/NotificationStack/index.ts`
- Test: `client/src/shared/ui/NotificationStack/NotificationStack.test.tsx`
- [ ] **Step 1: Write failing tests for NotificationStack**
```tsx
// client/src/shared/ui/NotificationStack/NotificationStack.test.tsx
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, act, fireEvent } from '@testing-library/react'
import { NotificationStack } from './NotificationStack'
import { $notifications, addNotification, dismissNotification } from '../../model/notification'
import { fork, allSettled } from 'effector'
import { Provider } from 'effector-react'
function renderStack() {
const scope = fork()
return {
scope,
...render(
<Provider value={scope}>
<NotificationStack />
</Provider>,
),
}
}
describe('NotificationStack', () => {
afterEach(() => {
$notifications.reset()
})
it('renders nothing when empty', () => {
const { container } = renderStack()
expect(container.textContent).toBe('')
})
it('renders a notification when added', async () => {
const scope = fork()
await allSettled(addNotification, { scope, params: { type: 'success', message: 'Test message' } })
render(
<Provider value={scope}>
<NotificationStack />
</Provider>,
)
expect(screen.getByText('Test message')).toBeDefined()
})
it('renders correct icon for each type', async () => {
const scope = fork()
// success — CheckCircle, error — Error, info — Info, warning — Warning
// Check MUI Alert has correct severity attribute
await allSettled(addNotification, { scope, params: { type: 'error', message: 'Error!' } })
render(
<Provider value={scope}>
<NotificationStack />
</Provider>,
)
const alert = screen.getByRole('alert')
expect(alert.getAttribute('severity')).toBe('error')
})
})
```
- [ ] **Step 2: Run to verify failure**
Run: `cd client && npx vitest run shared/ui/NotificationStack/NotificationStack.test.tsx`
Expected: FAIL
- [ ] **Step 3: Implement NotificationStack**
```tsx
// client/src/shared/ui/NotificationStack/NotificationStack.tsx
import { useUnit } from 'effector-react'
import { Snackbar, Alert, Stack, IconButton } from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import { $notifications, dismissNotification } from '../../model/notification'
export function NotificationStack() {
const notifications = useUnit($notifications)
if (notifications.length === 0) return null
return (
<Stack
spacing={1}
sx={{
position: 'fixed',
bottom: 80,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 2000,
width: 'auto',
maxWidth: 400,
}}
>
{notifications.map((n) => (
<Snackbar
key={n.id}
open
autoHideDuration={n.autoHideDuration}
onClose={() => dismissNotification(n.id)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={n.type}
variant="filled"
action={
<IconButton size="small" color="inherit" onClick={() => dismissNotification(n.id)}>
<CloseIcon fontSize="small" />
</IconButton>
}
>
{n.message}
</Alert>
</Snackbar>
))}
</Stack>
)
}
```
```ts
// client/src/shared/ui/NotificationStack/index.ts
export { NotificationStack } from './NotificationStack'
```
- [ ] **Step 4: Run tests**
Run: `cd client && npx vitest run shared/ui/NotificationStack/NotificationStack.test.tsx`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add client/src/shared/ui/NotificationStack/
git commit -m "feat: add NotificationStack component"
```
---
### Task 3: Add NotificationStack to App.tsx
**Files:**
- Modify: `client/src/app/App.tsx`
- [ ] **Step 1: Add NotificationStack to App layout**
```tsx
// App.tsx — add import and component
import { NotificationStack } from '@/shared/ui/NotificationStack'
// Inside the return, after </Router>
<>
<ErrorBoundary>
<AppProviders>
<BrowserRouter>
<MainLayout>
<AppRoutes />
</MainLayout>
</BrowserRouter>
</AppProviders>
</ErrorBoundary>
<NotificationStack />
</>
```
- [ ] **Step 2: Run lint + build**
Run: `cd client && npm run lint && npm test`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add client/src/app/App.tsx
git commit -m "feat: integrate NotificationStack into App"
```
---
### Task 4: Migrate CartSnackbar to notification store
**Files:**
- Modify: `client/src/features/cart/ui/ToggleCartIcon/ToggleCartIcon.tsx`
- Modify: `client/src/features/cart/ui/AddToCartButton/AddToCartButton.tsx`
- Delete: `client/src/features/cart/ui/CartSnackbar/CartSnackbar.tsx`
- Delete: `client/src/features/cart/ui/CartSnackbar/index.ts`
- Delete: `client/src/features/cart/model/cart-notifications.ts`
- Modify: `client/src/app/App.tsx` (remove CartSnackbar import + usage)
- [ ] **Step 1: Replace cartAdded events with addNotification in ToggleCartIcon**
```tsx
// ToggleCartIcon.tsx — replace cartAdded with addNotification
import { addNotification } from '@/shared/model/notification'
// search for: cartAdded()
// replace with:
addNotification({ type: 'info', message: 'Товар добавлен в корзину' })
```
- [ ] **Step 2: Same for AddToCartButton**
```tsx
// AddToCartButton.tsx — same replacement
addNotification({ type: 'info', message: 'Товар добавлен в корзину' })
```
- [ ] **Step 3: Remove CartSnackbar component files**
Delete `CartSnackbar.tsx` and `CartSnackbar/index.ts`.
- [ ] **Step 4: Remove cart-notifications model**
Delete `features/cart/model/cart-notifications.ts`.
- [ ] **Step 5: Remove CartSnackbar from App.tsx**
Remove import and `<CartSnackbar />` from render.
- [ ] **Step 6: Remove cart-notifications import from features/cart/index.ts**
Check `features/cart/index.ts` for re-exports of cart-notifications or CartSnackbar.
- [ ] **Step 7: Remove CartSnackbar test file**
Delete `CartSnackbar.test.tsx`.
- [ ] **Step 8: Run lint + test + build**
```bash
cd client && npm run lint && npm test && npm run build
```
- [ ] **Step 9: Commit**
```bash
git add -A
git commit -m "feat: migrate CartSnackbar to global notification store"
```
@@ -0,0 +1,201 @@
# Refactoring Audit — Code Quality Improvements
Date: 2026-05-27
## Overview
Системный рефакторинг кодовой базы shop (craftshop monorepo) по 5 направлениям: toast-уведомления, пустые catch-блоки, серверное дублирование, клиентское дублирование, обработка ошибок API.
## Подпроект 1: Система toast-уведомлений
### Проблема
- Единственный `CartSnackbar` (Effector) только для "товар добавлен в корзину"
- Остальные операции без обратной связи — только inline `<Alert>` на страницах
- Нет централизованного показа ошибок от API
### Решение
**1.1 Effector-стор уведомлений** (`client/src/shared/model/notification.ts`)
```ts
type NotificationType = 'success' | 'error' | 'info' | 'warning'
interface Notification {
id: string
type: NotificationType
message: string
autoHideDuration?: number
}
// События
addNotification добавить (генерирует id + пушит в очередь)
dismissNotification закрыть по id
dismissAll закрыть все
// Стор
$notifications: Notification[] очередь, макс 3 видимых
```
**1.2 Компонент `NotificationStack`** (`client/src/shared/ui/NotificationStack/`)
- Использует MUI `Snackbar` + `Alert` (уже стилизованы через тему)
- Позиция: `bottom: 80px`
- `Slide` transition, auto-hide по таймеру
- Крестик для ручного закрытия
- Не более 3 одновременных уведомлений (остальные в очереди)
**1.3 Миграция CartSnackbar**
- `cartAdded` событие пишет в `addNotification({ type: 'info', message: 'Товар добавлен в корзину' })`
- Старый `CartSnackbar` удаляется (компонент, стор, тесты)
- `ToggleCartIcon` и `AddToCartButton` — убрать прямой вызов `cartAdded`, заменить на `addNotification`
**1.4 Интеграция с `useMutationWithToast`** (опционально, см. подпроект 5)
### Файлы
- Новые: `shared/model/notification.ts`, `shared/ui/NotificationStack/NotificationStack.tsx`, `shared/ui/NotificationStack/index.ts`
- Изменённые: `app/App.tsx` (+ NotificationStack), удаление `features/cart/model/cart-notifications.ts`, `features/cart/ui/CartSnackbar/`
- Тесты: `shared/model/notification.test.ts`, `shared/ui/NotificationStack/NotificationStack.test.tsx`
---
## Подпроект 2: Пустые catch-блоки
### Проблема
17 мест с `catch { /* ignore */ }` или пустым телом.
### Решение
Минимальное логирование с контекстом:
| Файл | Уровень | Сообщение |
|------|---------|-----------|
| `persist-token.ts` (3 места) | `console.warn` | `'[persist-token] Failed to ...'` |
| `theme-controller.tsx` (2 места) | `console.warn` | `'[theme] Failed to ...'` |
| `CookieConsentBanner.tsx` (2 места) | `console.warn` | `'[cookie-consent] Failed to ...'` |
| `SseProvider.tsx` | `console.warn` | `'[sse] Connection error:'` |
| `admin-gallery` (сервер) | `request.log.error` | Контекст операции |
| Остальные серверные | `request.log.error` | Контекст операции |
### Файлы
Только изменения в существующих файлах. Новых файлов нет.
---
## Подпроект 3: Сервер — дублирование в роутах
### Проблема
- ~25 мест с ручным try/catch-паттерном
- Дублирование валидации галерейных изображений (POST/PATCH admin/products)
- Дублирование `prisma.order.findFirst({ where: { id, userId } })` в 3 файлах
### Решение
**3.1 `asyncHandler`** (`server/src/lib/async-handler.js`)
```js
function asyncHandler(fn) {
return async (request, reply) => {
try {
return await fn(request, reply)
} catch (err) {
request.log.error(err)
const statusCode = err.statusCode || 500
const message = err.statusCode ? err.message : 'Internal server error'
return reply.code(statusCode).send({ error: message })
}
}
}
```
**3.2 `validateGalleryImages`** (`server/src/lib/validate-gallery-images.js`)
- Проверка существования `galleryImages` в БД
- Проверка наличия resized-версий
- Используется в POST и PATCH admin/products
**3.3 `findUserOrder`** (`server/src/lib/find-user-order.js`)
- `prisma.order.findFirst({ where: { id, userId }, ... })` с included relations
### Файлы
- Новые: `server/src/lib/async-handler.js`, `server/src/lib/validate-gallery-images.js`, `server/src/lib/find-user-order.js`
- Изменённые: ~10 server route files
---
## Подпроект 4: Клиент — дублирование
### Проблема
- 4 копии `useQuery({ queryKey: ['me', 'cart'], ... })`
- Дублирование `orderStatusLabelRu` и `ORDER_STATUS_DATA`
### Решение
**4.1 `useCartQuery`** (`client/src/entities/cart/lib/use-cart-query.ts`)
```ts
export function useCartQuery() {
const user = useAuthUser()
return useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
}
```
Замена в 4 компонентах.
**4.2 Дубль статусов**
- `ORDER_STATUS_DATA` — единственный источник
- `orderStatusLabelRu` удалить, импорты переписать на `ORDER_STATUS_DATA`
### Файлы
- Новые: `entities/cart/lib/use-cart-query.ts`
- Изменённые: `AppHeader.tsx`, `CartPage.tsx`, `CheckoutPage.tsx`, `ToggleCartIcon.tsx`, удаление `order-status-labels.ts`
---
## Подпроект 5: Обработка ошибок API (клиент)
### Проблема
- Нет централизованного показа ошибок API
- `CheckoutPage` показывает сырое `(error as Error).message`
### Решение
**5.1 `useMutationWithToast`** (`client/src/shared/lib/use-mutation-with-toast.ts`)
```ts
function useMutationWithToast(options) {
return useMutation({
...options,
onSuccess: (data, ...rest) => {
if (options.successMessage) addNotification({ type: 'success', message: options.successMessage })
options.onSuccess?.(data, ...rest)
},
onError: (error, ...rest) => {
addNotification({ type: 'error', message: getApiErrorMessage(error) })
options.onError?.(error, ...rest)
},
})
}
```
**5.2 `getApiErrorMessage`** улучшение (`client/src/shared/lib`)
- Если сервер вернул `{ error: string }` — показать его
- Ошибка сети → "Нет соединения с сервером."
- 500 → "Произошла ошибка. Попробуйте позже."
- Остальное → стандартное сообщение
### Файлы
- Новые: `shared/lib/use-mutation-with-toast.ts`
- Изменённые: `shared/lib/get-api-error-message.ts`, `CheckoutPage.tsx` (исправить отображение ошибки)
---
## Порядок реализации
Подпроекты независимы и могут выполняться в любом порядке. Рекомендуемый порядок:
1. **Подпроект 2** (пустые catch) — быстрые и безопасные изменения, хороший разогрев
2. **Подпроект 1** (toast) — база для подпроекта 5, новая функциональность
3. **Подпроект 5** (useMutationWithToast) — строится поверх подпроекта 1
4. **Подпроект 3** (сервер) — standalone
5. **Подпроект 4** (клиент) — standalone
## Тестирование
- Каждый подпроект: `npm run lint` (или `npm run lint:fix`) + `npm test` для своей директории
- Финальная проверка: `cd client && npm run build` (проверка типов)
+5 -3
View File
@@ -51,11 +51,12 @@ await fastify.register(cors, {
await registerSecurityHeaders(fastify)
fastify.get('/health', async () => {
fastify.get('/health', async (request) => {
try {
await prisma.$queryRaw`SELECT 1`
return { status: 'ok', database: 'connected', uptime: process.uptime() }
} catch {
} catch (err) {
request.log.error({ err }, 'Health check database query failed')
return { status: 'degraded', database: 'disconnected', uptime: process.uptime() }
}
})
@@ -119,7 +120,8 @@ fastify.decorate('authenticate', async function authenticate(request, reply) {
request.headers.authorization = `Bearer ${request.query.token}`
}
await request.jwtVerify()
} catch {
} catch (err) {
request.log.error({ err }, 'JWT verification failed')
return reply.code(401).send({ error: 'Не авторизован' })
}
})
+2 -1
View File
@@ -13,7 +13,8 @@ export function registerAuth(fastify) {
try {
await request.jwtVerify()
} catch {
} catch (err) {
request.log.error({ err }, '[auth] verifyAdmin failed')
return reply.code(401).send({ error: 'Не авторизован' })
}
+2 -1
View File
@@ -260,7 +260,8 @@ export async function registerAdminProductRoutes(fastify) {
try {
await prisma.product.delete({ where: { id } })
reply.code(204).send()
} catch {
} catch (err) {
request.log.error({ err }, '[admin-products] Operation failed')
reply.code(404).send({ error: 'Товар не найден' })
}
})
+2 -1
View File
@@ -160,7 +160,8 @@ export async function registerAdminUserRoutes(fastify) {
try {
await prisma.user.delete({ where: { id } })
reply.code(204).send()
} catch {
} catch (err) {
request.log.error({ err }, '[admin-users] Operation failed')
reply.code(404).send({ error: 'Пользователь не найден' })
}
})
+2 -1
View File
@@ -144,7 +144,8 @@ export async function registerPublicReviewRoutes(fastify) {
},
})
return reply.code(201).send({ item: created })
} catch {
} catch (err) {
request.log.error({ err }, 'Failed to create review (possible duplicate)')
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
}
})
+2 -1
View File
@@ -119,7 +119,8 @@ export async function registerSseRoutes(fastify) {
if (closed) return
try {
reply.raw.write(chunk)
} catch {
} catch (err) {
request.log.error({ err }, '[sse] safeWrite failed')
closed = true
cleanUp()
}
+2 -1
View File
@@ -141,7 +141,8 @@ export async function registerUserPaymentRoutes(fastify) {
}
return { status: ykPayment.status, paid: ykPayment.paid }
} catch {
} catch (err) {
request.log.error({ err }, '[user-payments] Operation failed')
return { status: payment.status, paid: payment.status === 'succeeded' }
}
})
+2 -1
View File
@@ -7,7 +7,8 @@ export async function registerYookassaWebhookRoute(fastify) {
let body
try {
body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body
} catch {
} catch (err) {
request.log.error({ err }, 'Failed to parse webhook JSON body')
return reply.code(400).send({ error: 'Invalid JSON body' })
}