fix: add error logging to empty catch blocks
This commit is contained in:
@@ -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` (проверка типов)
|
||||
Reference in New Issue
Block a user