diff --git a/client/src/app/providers/SseProvider.tsx b/client/src/app/providers/SseProvider.tsx index b13c74f..c0258ad 100644 --- a/client/src/app/providers/SseProvider.tsx +++ b/client/src/app/providers/SseProvider.tsx @@ -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) } } } diff --git a/client/src/app/providers/theme-controller.tsx b/client/src/app/providers/theme-controller.tsx index 9537648..7dd2fe2 100644 --- a/client/src/app/providers/theme-controller.tsx +++ b/client/src/app/providers/theme-controller.tsx @@ -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]) diff --git a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx index bdacf86..4a2b769 100644 --- a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx +++ b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx @@ -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) } } diff --git a/client/src/features/address-map-picker/ui/MapPickerMap.tsx b/client/src/features/address-map-picker/ui/MapPickerMap.tsx index bee9f3b..e11cfb2 100644 --- a/client/src/features/address-map-picker/ui/MapPickerMap.tsx +++ b/client/src/features/address-map-picker/ui/MapPickerMap.tsx @@ -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) } } diff --git a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx index 1c7ffe1..f84b0f2 100644 --- a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx +++ b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx @@ -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 '' } } diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index 4c2a506..c6d3975 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -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 '—' } } diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index d7b679a..fc6df61 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -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' } } diff --git a/client/src/shared/api/client.ts b/client/src/shared/api/client.ts index 2c6be99..def7a5a 100644 --- a/client/src/shared/api/client.ts +++ b/client/src/shared/api/client.ts @@ -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 } }) diff --git a/client/src/shared/lib/order-address-snapshot.ts b/client/src/shared/lib/order-address-snapshot.ts index d25a4bc..b94cf7c 100644 --- a/client/src/shared/lib/order-address-snapshot.ts +++ b/client/src/shared/lib/order-address-snapshot.ts @@ -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 } } diff --git a/client/src/shared/lib/persist-token.ts b/client/src/shared/lib/persist-token.ts index 62635b6..ed6dea3 100644 --- a/client/src/shared/lib/persist-token.ts +++ b/client/src/shared/lib/persist-token.ts @@ -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) } } diff --git a/client/src/shared/ui/CookieConsentBanner.tsx b/client/src/shared/ui/CookieConsentBanner.tsx index 2e7196e..cef36d3 100644 --- a/client/src/shared/ui/CookieConsentBanner.tsx +++ b/client/src/shared/ui/CookieConsentBanner.tsx @@ -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) } } diff --git a/client/src/shared/ui/RichTextMessageContent.tsx b/client/src/shared/ui/RichTextMessageContent.tsx index 34d6ad2..5cc6f8c 100644 --- a/client/src/shared/ui/RichTextMessageContent.tsx +++ b/client/src/shared/ui/RichTextMessageContent.tsx @@ -22,8 +22,8 @@ export function RichTextMessageContent({ value, tone = 'default' }: RichTextMess const normalizedValue = value.trim() ? value : '

' 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 }) diff --git a/docs/superpowers/plans/2026-05-27-api-error-handling.md b/docs/superpowers/plans/2026-05-27-api-error-handling.md new file mode 100644 index 0000000..6975986 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-api-error-handling.md @@ -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 }) => ( + {children} + ) +} + +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 = + UseMutationOptions & { + successMessage?: string + } + +export function useMutationWithToast( + options: MutationWithToastOptions, +) { + 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 && ( + + {(createMut.error as Error).message} + +)} +``` + +After: +```tsx +import { getApiErrorMessage } from '@/shared/lib/get-api-error-message' + +{createMut.isError && ( + + {getApiErrorMessage(createMut.error)} + +)} +``` + +- [ ] **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" +``` diff --git a/docs/superpowers/plans/2026-05-27-client-duplication.md b/docs/superpowers/plans/2026-05-27-client-duplication.md new file mode 100644 index 0000000..97209ad --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-client-duplication.md @@ -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 }) => ( + {children} + ) +} + +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" +``` diff --git a/docs/superpowers/plans/2026-05-27-empty-catch-blocks.md b/docs/superpowers/plans/2026-05-27-empty-catch-blocks.md new file mode 100644 index 0000000..cb7a57c --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-empty-catch-blocks.md @@ -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 diff --git a/docs/superpowers/plans/2026-05-27-server-duplication.md b/docs/superpowers/plans/2026-05-27-server-duplication.md new file mode 100644 index 0000000..5dba1a2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-server-duplication.md @@ -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" +``` diff --git a/docs/superpowers/plans/2026-05-27-toast-notifications.md b/docs/superpowers/plans/2026-05-27-toast-notifications.md new file mode 100644 index 0000000..b9a78d7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-toast-notifications.md @@ -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() +export const dismissAll = createEvent() + +export const $notifications = createStore([]) + .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( + + + , + ), + } +} + +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( + + + , + ) + 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( + + + , + ) + 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 ( + + {notifications.map((n) => ( + dismissNotification(n.id)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + dismissNotification(n.id)}> + + + } + > + {n.message} + + + ))} + + ) +} +``` + +```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 +<> + + + + + + + + + + + +``` + +- [ ] **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 `` 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" +``` diff --git a/docs/superpowers/specs/2026-05-27-refactoring-audit-design.md b/docs/superpowers/specs/2026-05-27-refactoring-audit-design.md new file mode 100644 index 0000000..d9a1361 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-refactoring-audit-design.md @@ -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 `` на страницах +- Нет централизованного показа ошибок от 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` (проверка типов) diff --git a/server/src/index.js b/server/src/index.js index eb50e3f..175e33f 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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: 'Не авторизован' }) } }) diff --git a/server/src/plugins/auth.js b/server/src/plugins/auth.js index 7c746d4..794e925 100644 --- a/server/src/plugins/auth.js +++ b/server/src/plugins/auth.js @@ -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: 'Не авторизован' }) } diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js index 8ac1893..3992e43 100644 --- a/server/src/routes/api/admin-products.js +++ b/server/src/routes/api/admin-products.js @@ -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: 'Товар не найден' }) } }) diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js index 9dd717b..46f4abf 100644 --- a/server/src/routes/api/admin-users.js +++ b/server/src/routes/api/admin-users.js @@ -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: 'Пользователь не найден' }) } }) diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js index ebf81c5..ddd9701 100644 --- a/server/src/routes/api/public-reviews.js +++ b/server/src/routes/api/public-reviews.js @@ -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: 'Вы уже оставляли отзыв на этот товар' }) } }) diff --git a/server/src/routes/sse.js b/server/src/routes/sse.js index ac6785d..33eb514 100644 --- a/server/src/routes/sse.js +++ b/server/src/routes/sse.js @@ -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() } diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index 643d661..3f3a59f 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -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' } } }) diff --git a/server/src/routes/webhook-yookassa.js b/server/src/routes/webhook-yookassa.js index 6362de5..4d0a705 100644 --- a/server/src/routes/webhook-yookassa.js +++ b/server/src/routes/webhook-yookassa.js @@ -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' }) }