diff --git a/.gitignore b/.gitignore index 28c6375..62b17a0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,4 @@ uploads/.cache/ server/uploads/ # Plans and design docs -.opencode/plans/ .agents diff --git a/.opencode/plans/2026-05-15-image-processing-refactor-design.md b/.opencode/plans/2026-05-15-image-processing-refactor-design.md new file mode 100644 index 0000000..54861da --- /dev/null +++ b/.opencode/plans/2026-05-15-image-processing-refactor-design.md @@ -0,0 +1,95 @@ +# Spec: Image Processing Refactor + +## Context + +Current image handling uses on-demand resize via `/uploads-resized/` route. Admin uploads save originals as-is (jpg/png/webp), and resize happens on first request. User uploads (reviews, 2MB limit) also use on-demand resize. + +## Goals + +1. **User images (reviews, ≤2MB):** Improve size error messages to be user-friendly +2. **Admin images (products, ≤20MB):** Eager processing at upload time + - Generate all resize widths (320, 640, 1024, 1600) in AVIF + WebP + - Convert original to WebP (delete source file) + - Full-screen viewer shows original in WebP (no width limit) + - Thumbnails use resized versions from cache + +## Architecture + +### Server Changes + +#### 1. `server/src/lib/upload-images.js` +- Add `eager` parameter to `persistMultipartImages` +- When `eager: true`, after saving each file: + 1. Call `generateAllSizes(uuid, subdir, fullPath)` — generates all sizes from original + 2. Call `convertOriginalToWebp(uuid, subdir)` — converts original to WebP, deletes source + 3. Update URL to use `.webp` extension (replace original extension) + +#### 2. `server/src/lib/image-resize.js` +- Add `generateAllSizes(uuid, subdir, originalPath)`: + - For each width in [320, 640, 1024, 1600]: + - Generate AVIF and WebP in `.cache//` + - Uses original file path (before conversion to WebP) +- Add `convertOriginalToWebp(uuid, subdir)`: + - Find original file (jpg/png) + - Convert to WebP (quality 80) at same location with `.webp` extension + - Delete original jpg/png file + - Return new `.webp` path + +#### 3. `server/src/routes/api/admin-products.js` +- Pass `eager: true` to `persistMultipartImages` + +#### 4. `server/src/routes/api/public-reviews.js` +- Improve error message for file too large (413) + +### Client Changes + +#### 1. `client/src/entities/product/api/product-api.ts` +- Add pre-upload size check for review images +- Clear error message: "Файл «» слишком большой (максимум 2 МБ)" + +#### 2. `client/src/shared/ui/OptimizedImage.tsx` +- Update `buildSrcSet` to use cached AVIF/WebP directly +- Full-screen viewer: use original `.webp` URL (no `?w=`) +- Remove fallback to original format for upload URLs + +#### 3. `client/src/features/product-review/ui/ReviewDialog.tsx` +- Show user-friendly error message for oversized files + +## Data Flow + +### Admin Upload (Eager) +1. Client sends FormData to `POST /api/admin/uploads` +2. Server saves original (e.g., `uuid.jpg`) +3. Server generates all sizes in `.cache/` from original +4. Server converts original to WebP (`uuid.webp`), deletes `uuid.jpg` +5. Returns URLs with `.webp` extension (e.g., `/uploads/.webp`) +6. Client displays using OptimizedImage with srcset from cache + +### User Upload (Reviews) +1. Client validates file size ≤2MB before upload +2. Server validates and saves original +3. On-demand resize still works (existing flow) +4. Clear error messages at both client and server + +## Error Handling + +### User Upload Size Error +- **Client:** Pre-upload check with message "Файл «» слишком большой (максимум 2 МБ)" +- **Server:** 413 with "Файл слишком большой (максимум 2 МБ)" + +### Admin Upload Processing Error +- If sharp fails: return 500 with "Ошибка обработки изображения" +- If file not found after save: return 500 with "Внутренняя ошибка сервера" + +## Testing + +### Server Tests +- Test `generateAllSizes` creates all width+format combinations +- Test `convertOriginalToWebp` converts and deletes original +- Test `persistMultipartImages` with `eager: true` +- Test error messages for oversized files + +### Client Tests +- Test pre-upload size validation for reviews +- Test OptimizedImage srcset generation for WebP originals +- Test error message display in ReviewDialog diff --git a/.opencode/plans/2026-05-15-image-processing-refactor.md b/.opencode/plans/2026-05-15-image-processing-refactor.md new file mode 100644 index 0000000..b5777c6 --- /dev/null +++ b/.opencode/plans/2026-05-15-image-processing-refactor.md @@ -0,0 +1,543 @@ +# Image Processing Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor image processing to use eager generation for admin product images and improve error messages for user uploads. + +**Architecture:** Add eager processing functions to `image-resize.js`, integrate into `upload-images.js` via `eager` flag, update client-side validation and error handling. + +**Tech Stack:** Node.js, Fastify, sharp, React, TypeScript, MUI + +--- + +### Task 1: Add eager processing functions to image-resize.js + +**Files:** +- Modify: `server/src/lib/image-resize.js` +- Test: `server/src/lib/__tests__/image-resize.test.js` + +- [ ] **Step 1: Write failing tests for new functions** + +Add to `server/src/lib/__tests__/image-resize.test.js`: + +```javascript +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { generateAllSizes, convertOriginalToWebp, findOriginalFile } from '../image-resize.js' + +const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-eager') +const TEST_CACHE_DIR = path.join(TEST_UPLOADS_DIR, '.cache') + +describe('eager image processing', () => { + beforeEach(async () => { + await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true }) + }) + + afterEach(async () => { + await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true }) + }) + + it('generateAllSizes creates all width+format combinations', async () => { + // Create a test PNG image using sharp + const sharp = (await import('sharp')).default + const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid.png') + await sharp({ create: { width: 2000, height: 1500, channels: 3, background: { r: 255, g: 0, b: 0 } } }) + .png() + .toFile(testImagePath) + + await generateAllSizes('test-uuid', '', testImagePath) + + // Check all cache files exist + for (const width of [320, 640, 1024, 1600]) { + for (const format of ['avif', 'webp']) { + const cachePath = path.join(TEST_CACHE_DIR, `test-uuid_w${width}.${format}`) + const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false) + expect(exists).toBe(true) + } + } + }) + + it('convertOriginalToWebp converts and deletes original', async () => { + const sharp = (await import('sharp')).default + const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.png') + await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 0, g: 255, b: 0 } } }) + .png() + .toFile(testImagePath) + + const result = await convertOriginalToWebp('test-uuid2', '') + + expect(result).toBe('/uploads/test-uuid2.webp') + const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false) + expect(pngExists).toBe(false) + const webpPath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.webp') + const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false) + expect(webpExists).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd server && npm test -- --run image-resize.test.js` +Expected: FAIL — `generateAllSizes` and `convertOriginalToWebp` are not defined + +- [ ] **Step 3: Implement generateAllSizes and convertOriginalToWebp** + +Add to `server/src/lib/image-resize.js` before the final `export` line: + +```javascript +/** + * Generate all resize widths in AVIF + WebP for eager processing. + * @param {string} uuid - UUID without extension + * @param {string} subdir - Subdirectory (e.g., 'reviews') or empty + * @param {string} originalPath - Full path to the original file + */ +export async function generateAllSizes(uuid, subdir, originalPath) { + const cacheSubdir = subdir ? subdir : '' + const cacheDir = path.join(CACHE_DIR, cacheSubdir) + await fs.promises.mkdir(cacheDir, { recursive: true }) + + const sharp = (await import('sharp')).default + + for (const width of VALID_WIDTHS) { + for (const format of SUPPORTED_FORMATS) { + const cacheFileName = `${uuid}_w${width}.${format}` + const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName) + + const pipeline = sharp(originalPath).resize(width, null, { withoutEnlargement: true }) + const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } + await pipeline[format](options).toFile(cachePath) + } + } +} + +/** + * Convert original file to WebP and delete the source file. + * @param {string} uuid - UUID without extension + * @param {string} subdir - Subdirectory (e.g., 'reviews') or empty + * @returns {string} New URL path like `/uploads/.webp` + */ +export async function convertOriginalToWebp(uuid, subdir) { + const uploadsDir = path.join(process.cwd(), 'uploads') + const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir + + // Find original file + const originalPath = await findOriginalFile(uuid, subdir) + if (!originalPath) { + throw new Error(`Original file not found for UUID: ${uuid}`) + } + + const originalExt = path.extname(originalPath).toLowerCase() + const webpPath = path.join(targetDir, `${uuid}.webp`) + + // Convert to WebP + const sharp = (await import('sharp')).default + await sharp(originalPath).webp({ quality: 80 }).toFile(webpPath) + + // Delete original if it's not already WebP + if (originalExt !== '.webp') { + await fs.promises.unlink(originalPath) + } + + return subdir ? `/uploads/${subdir}/${uuid}.webp` : `/uploads/${uuid}.webp` +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd server && npm test -- --run image-resize.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/src/lib/image-resize.js server/src/lib/__tests__/image-resize.test.js +git commit -m "feat: add eager image processing functions (generateAllSizes, convertOriginalToWebp)" +``` + +--- + +### Task 2: Integrate eager processing into upload-images.js + +**Files:** +- Modify: `server/src/lib/upload-images.js` +- Test: `server/src/lib/__tests__/upload-images.test.js` (create if not exists) + +- [ ] **Step 1: Write failing test for eager mode** + +Create `server/src/lib/__tests__/upload-images.test.js`: + +```javascript +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { persistMultipartImages, uploadError } from '../upload-images.js' + +const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-persist') + +describe('persistMultipartImages with eager mode', () => { + beforeEach(async () => { + await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true }) + }) + + afterEach(async () => { + await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true }) + }) + + it('returns WebP URLs when eager=true', async () => { + // This test verifies the function signature accepts eager parameter + // Full integration test requires mocking multipart request + // For now, test that the function doesn't throw with eager option + const mockRequest = { + isMultipart: () => true, + parts: async function* () { + // Mock part with a small PNG buffer + const pngHeader = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + ...new Array(100).fill(0), // dummy data + ]) + yield { + file: true, + filename: 'test.png', + toBuffer: async () => pngHeader, + } + }, + } + + // Should not throw with eager option + try { + await persistMultipartImages(mockRequest, { + maxFiles: 1, + maxFileBytes: 20 * 1024 * 1024, + subdir: '', + eager: true, + }) + } catch (err) { + // If sharp is not available or PNG is invalid, that's expected in unit test + // The key is that the function accepts the eager parameter + expect(err.message).not.toContain('eager') + } + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && npm test -- --run upload-images.test.js` +Expected: FAIL — `eager` parameter is not handled + +- [ ] **Step 3: Modify persistMultipartImages to support eager mode** + +Replace the `persistMultipartImages` function in `server/src/lib/upload-images.js`: + +```javascript +export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '', eager = false }) { + if (!request.isMultipart()) { + throw uploadError('Ожидается multipart/form-data') + } + + const uploadsDir = path.join(process.cwd(), 'uploads') + const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir + await fs.promises.mkdir(targetDir, { recursive: true }) + + const urls = [] + const parts = request.parts({ + limits: { + fileSize: maxFileBytes, + files: maxFiles, + }, + }) + for await (const part of parts) { + if (!part.file) continue + if (urls.length >= maxFiles) { + throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`) + } + const ext = safeImageExt(part.filename) + if (!ext) { + throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp') + } + + const uuid = crypto.randomUUID() + const fileName = `${uuid}${ext}` + const fullPath = path.join(targetDir, fileName) + await fs.promises.writeFile(fullPath, await part.toBuffer()) + + let finalUrl = subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}` + + if (eager) { + const { generateAllSizes, convertOriginalToWebp } = await import('./image-resize.js') + await generateAllSizes(uuid, subdir, fullPath) + finalUrl = await convertOriginalToWebp(uuid, subdir) + } + + urls.push(finalUrl) + } + + if (urls.length === 0) { + throw uploadError( + 'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).', + ) + } + + return urls +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && npm test -- --run upload-images.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/src/lib/upload-images.js server/src/lib/__tests__/upload-images.test.js +git commit -m "feat: add eager mode to persistMultipartImages" +``` + +--- + +### Task 3: Enable eager mode in admin upload route + +**Files:** +- Modify: `server/src/routes/api/admin-products.js` + +- [ ] **Step 1: Update admin upload route to use eager mode** + +Modify the `POST /api/admin/uploads` route in `server/src/routes/api/admin-products.js`: + +```javascript +fastify.post( + '/api/admin/uploads', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + try { + const urls = await persistMultipartImages(request, { + maxFiles: 10, + maxFileBytes: getProductImageMaxFileBytes(), + eager: true, + }) + await upsertGalleryImagesByUrls(urls) + return { urls } + } catch (error) { + let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' + let statusCode = + error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) + ? Number(error.statusCode) + : 400 + if (isMultipartFileTooLargeError(error)) { + message = formatFileTooLargeMessage(getProductImageMaxFileBytes()) + statusCode = 413 + } + return reply.code(statusCode).send({ error: message }) + } + }, +) +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/routes/api/admin-products.js +git commit -m "feat: enable eager image processing for admin uploads" +``` + +--- + +### Task 4: Improve user upload error messages + +**Files:** +- Modify: `client/src/entities/product/api/reviews-api.ts` +- Modify: `client/src/shared/constants/upload-limits.ts` +- Modify: `client/src/features/product-review/ui/ReviewDialog.tsx` + +- [ ] **Step 1: Add client-side size validation for review images** + +Add to `client/src/shared/constants/upload-limits.ts`: + +```typescript +export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * 1024 * 1024 // 2 MB + +export function formatOtherUploadMaxSizeHint(): string { + return `${Math.round(OTHER_UPLOAD_MAX_FILE_BYTES / (1024 * 1024))} МБ` +} +``` + +- [ ] **Step 2: Add pre-upload size check in reviews-api.ts** + +Modify `uploadReviewImage` in `client/src/entities/product/api/reviews-api.ts`: + +```typescript +import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits' + +export async function uploadReviewImage(file: File): Promise<{ url: string }> { + if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) { + throw new Error( + `Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`, + ) + } + + const fd = new FormData() + fd.append('file', file, file.name) + const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd) + return data +} +``` + +- [ ] **Step 3: Update ReviewDialog to show user-friendly error message** + +Modify the uploadError display in `client/src/features/product-review/ui/ReviewDialog.tsx`: + +Replace: +```tsx +{uploadError ? ( + + Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp. + +) : null} +``` + +With: +```tsx +{uploadError ? ( + + {uploadError instanceof Error ? uploadError.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'} + +) : null} +``` + +- [ ] **Step 4: Commit** + +```bash +git add client/src/shared/constants/upload-limits.ts client/src/entities/product/api/reviews-api.ts client/src/features/product-review/ui/ReviewDialog.tsx +git commit -m "feat: improve error messages for user upload size validation" +``` + +--- + +### Task 5: Update OptimizedImage for WebP originals + +**Files:** +- Modify: `client/src/shared/ui/OptimizedImage.tsx` +- Test: `client/src/shared/ui/__tests__/OptimizedImage.test.tsx` + +- [ ] **Step 1: Update parseUploadUrl to handle .webp originals** + +Modify `parseUploadUrl` in `client/src/shared/ui/OptimizedImage.tsx`: + +```typescript +function parseUploadUrl(src: string): { uuid: string; ext: string; subdir: string } | null { + const match = src.match(/^\/uploads(?:\/(reviews))?\/([^.\\/]+)\.(png|jpe?g|webp)/i) + if (!match) return null + return { subdir: match[1] || '', uuid: match[2], ext: match[3].toLowerCase() } +} +``` + +- [ ] **Step 2: Update buildSrcSet to use cached AVIF/WebP directly** + +Modify `buildSrcSet` and `buildFallbackSrc`: + +```typescript +function buildSrcSet(src: string, widths: number[]): string | null { + const parsed = parseUploadUrl(src) + if (!parsed) return null + + const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : '' + return widths.map((w) => `/uploads-resized/${pathPrefix}${parsed.uuid}.avif?w=${w} ${w}w`).join(', ') +} + +function buildFallbackSrc(src: string, width: number): string { + const parsed = parseUploadUrl(src) + if (!parsed) return src + const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : '' + return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}` +} +``` + +- [ ] **Step 3: Add original WebP URL getter for full-screen mode** + +Add to `client/src/shared/ui/OptimizedImage.tsx`: + +```typescript +/** Get the original WebP URL for full-screen display (no resize) */ +export function getOriginalWebpUrl(src: string): string { + const parsed = parseUploadUrl(src) + if (!parsed) return src + const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : '' + return `/uploads/${pathPrefix}${parsed.uuid}.webp` +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add client/src/shared/ui/OptimizedImage.tsx +git commit -m "feat: update OptimizedImage for WebP originals and add getOriginalWebpUrl" +``` + +--- + +### Task 6: Update ProductPage full-screen viewer + +**Files:** +- Modify: `client/src/pages/product/ui/ProductPage.tsx` + +- [ ] **Step 1: Find full-screen image viewer code** + +Search for the full-screen image viewer in ProductPage.tsx. Look for where the original image URL is used. + +- [ ] **Step 2: Use getOriginalWebpUrl for full-screen display** + +Import and use `getOriginalWebpUrl`: + +```typescript +import { getOriginalWebpUrl } from '@/shared/ui/OptimizedImage' +``` + +Replace the full-screen `` src with: +```typescript +getOriginalWebpUrl(imageUrl) +``` + +- [ ] **Step 3: Commit** + +```bash +git add client/src/pages/product/ui/ProductPage.tsx +git commit -m "feat: use WebP original for full-screen product image viewer" +``` + +--- + +### Task 7: Run full test suite and lint + +- [ ] **Step 1: Run server tests** + +```bash +cd server && npm test +``` + +- [ ] **Step 2: Run client lint and format check** + +```bash +cd client && npm run lint && npm run format:check +``` + +- [ ] **Step 3: Run client tests** + +```bash +cd client && npm test +``` + +- [ ] **Step 4: Run client build** + +```bash +cd client && npm run build +``` + +- [ ] **Step 5: Commit any fixes** + +```bash +git add . +git commit -m "fix: address lint and test issues" +``` diff --git a/.opencode/plans/2026-05-15-product-redesign-design.md b/.opencode/plans/2026-05-15-product-redesign-design.md new file mode 100644 index 0000000..87322e0 --- /dev/null +++ b/.opencode/plans/2026-05-15-product-redesign-design.md @@ -0,0 +1,174 @@ +# Design: Доработка товара — удаление «под заказ», обязательные quantity и категория + +**Дата:** 2026-05-15 +**Статус:** На согласовании + +## Цель + +Упростить модель товара: убрать концепцию «под заказ», сделать количество и категорию обязательными полями. Категория «Не указано» остаётся технической заглушкой для переноса товаров при удалении категории, но не видна в каталоге и не выбирается при редактировании. + +## Архитектура изменений + +### 1. База данных (Prisma) + +**Миграция:** +- Перед удалением полей: все товары с `inStock = false` получают `quantity = 0` +- Удалить поля `inStock` и `leadTimeDays` из модели `Product` +- Статус наличия определяется исключительно по `quantity`: + - `quantity > 0` → «В наличии» + - `quantity = 0` → «Нет в наличии» + +**`server/prisma/schema.prisma`:** +```prisma +model Product { + // ... остальные поля без изменений ... + quantity Int @default(0) + // УДАЛЕНО: inStock Boolean @default(true) + // УДАЛЕНО: leadTimeDays Int? + category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict) + categoryId String + // ... +} +``` + +### 2. Сервер — валидация и CRUD + +**`server/src/routes/api/admin-products.js`:** + +**CREATE (POST):** +- `quantity` — required, `Int >= 0` (было nullable) +- `categoryId` — required (было: при пустом → авто-назначение «Не указано») +- Удалить валидацию `leadTimeDays` при `!inStock` +- Удалить принудительную установку `quantity = 1` для «под заказ» +- Вернуть 400: `'Укажите категорию'` если `categoryId` отсутствует + +**UPDATE (PATCH):** +- `quantity` — required, `Int >= 0` (было nullable) +- `categoryId` — required (было: при пустом → «Не указано») +- Удалить логику очистки `leadTimeDays` при `inStock = true` +- Удалить принудительную установку `quantity = 1` +- Вернуть 400 при отсутствии `categoryId` + +**JSON Schema:** +- `CREATE_PRODUCT_SCHEMA`: убрать `leadTimeDays`, сделать `quantity` required (убрать `nullable`) +- `PATCH_PRODUCT_SCHEMA`: убрать `leadTimeDays`, `quantity` — если передан, то `>= 0` + +**`server/src/routes/api/public-catalog.js`:** +- Удалить ветку `availability === 'in_stock'` и `availability === 'made_to_order'` +- Фильтрация «в наличии» больше не нужна — все товары в каталоге + +### 3. Клиент — админка (две страницы) + +**`client/src/pages/admin/ui/AdminPage.tsx`** и **`client/src/pages/admin-products/ui/AdminProductsPage.tsx`:** + +**FormState:** +- Удалить `inStock: boolean` и `leadTimeDays: string` +- `quantity: string` — без nullable-семантики + +**UI:** +- Удалить Switch «В наличии / Под заказ» +- Удалить TextField «Срок исполнения, дней» +- TextField «Количество»: + - Без helper «Оставьте пустым...» + - Новый helper: «0 = нет в наличии» + - Валидация: не может быть пустым, `parseInt >= 0` +- Select «Категория»: + - Удалить `` с «Не указано» + - Валидация: не даёт сохранить без выбранной категории + - Показать ошибку при попытке сохранить без категории + +**Submit-валидация:** +- Удалить проверку `leadTimeDays` при `!inStock` +- Добавить проверку: `categoryId` не пустой → blocking error +- Добавить проверку: `quantity` не пустой → blocking error + +### 4. Клиент — каталог + +**`client/src/entities/product/ui/ProductCard.tsx`:** +- Удалить логику `'Под заказ · {leadTimeDays} дн.'` +- Новый статус: + - `quantity > 0` → «В наличии» (зелёный) + - `quantity === 0` → «Нет в наличии» (серый/red) + +**`client/src/pages/product/ui/ProductPage.tsx`:** +- Удалить chip `'Под заказ · {leadTimeDays} дн.'` +- Удалить alert `'Этот товар изготавливается под заказ...'` +- Статус определяется по `quantity` + +**`client/src/pages/checkout/ui/CheckoutPage.tsx`:** +- Удалить определение made-to-order товаров в корзине +- Удалить info alert о доставке после изготовления + +### 5. Клиент — фильтры + +**`client/src/pages/home/lib/use-product-filters.ts`:** +- Удалить `availability: 'all' | 'in_stock' | 'made_to_order'` из state +- Удалить `availability` из параметров `fetchPublicProducts()` + +**`client/src/pages/home/ui/ProductFilters.tsx`:** +- Удалить `ToggleButtonGroup` с `'all'`, `'in_stock'`, `'made_to_order'` +- Удалить отображение категории «Не указано» из списка чипов (фильтр `cat.slug !== 'ne-ukazano'`) + +### 6. Категория «Не указано» — что остаётся + +| Где | Что происходит | +|---|---| +| `server/src/lib/default-category.js` | **Остаётся** — функция `getOrCreateUnspecifiedCategory()` | +| `server/src/index.js` | **Остаётся** — вызов при старте | +| `server/src/routes/api/admin-categories.js` | **Остаётся** — нельзя удалить/переименовать; при удалении категории товары переезжают в «Не указано» | +| Админка категорий | **Остаётся** — кнопка удаления заблокирована | +| Фильтры каталога | **Скрыта** — не показывается в чипах | +| Форма товара | **Скрыта** — не выбирается в Select | + +## Статус товара — новая логика + +``` +quantity > 0 → «В наличии» (зелёный chip/badge) +quantity = 0 → «Нет в наличии» (серый chip/badge) +``` + +Никаких других статусов. Поле `inStock` больше не существует. + +## Файлы для изменения + +### Сервер +| Файл | Изменения | +|---|---| +| `server/prisma/schema.prisma` | Удалить `inStock`, `leadTimeDays` | +| `server/src/routes/api/admin-products.js` | Валидация, schema, убрать логику под заказ | +| `server/src/routes/api/public-catalog.js` | Убрать фильтр availability | + +### Клиент +| Файл | Изменения | +|---|---| +| `client/src/pages/admin/ui/AdminPage.tsx` | FormState, UI, валидация | +| `client/src/pages/admin-products/ui/AdminProductsPage.tsx` | FormState, UI, валидация | +| `client/src/entities/product/ui/ProductCard.tsx` | Статус по quantity | +| `client/src/pages/product/ui/ProductPage.tsx` | Убрать под заказ UI | +| `client/src/pages/checkout/ui/CheckoutPage.tsx` | Убрать made-to-order detection | +| `client/src/pages/home/ui/ProductFilters.tsx` | Убрать availability toggle, скрыть «Не указано» | +| `client/src/pages/home/lib/use-product-filters.ts` | Убрать `availability` | + +## Миграция данных + +```javascript +// В Prisma migration: +// 1. UPDATE Product SET quantity = 0 WHERE inStock = false +// 2. ALTER TABLE Product DROP COLUMN inStock +// 3. ALTER TABLE Product DROP COLUMN leadTimeDays +``` + +## Тестирование + +**Сервер:** +- CREATE без categoryId → 400 +- CREATE без quantity → 400 +- CREATE с quantity = 0 → OK +- PATCH без categoryId → 400 +- PATCH с quantity = 0 → OK + +**Клиент:** +- Форма не сохраняется без категории +- Форма не сохраняется без количества +- Фильтры не содержат «Под заказ» и «Не указано» +- Карточка товара показывает «Нет в наличии» при quantity = 0 diff --git a/.opencode/plans/2026-05-15-product-redesign-plan.md b/.opencode/plans/2026-05-15-product-redesign-plan.md new file mode 100644 index 0000000..672871c --- /dev/null +++ b/.opencode/plans/2026-05-15-product-redesign-plan.md @@ -0,0 +1,1294 @@ +# Доработка товара — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Удалить логику «под заказ», сделать quantity и categoryId обязательными, скрыть «Не указано» из UI каталога и формы товара. + +**Architecture:** Миграция БД → серверная валидация → клиентская админка → каталог/фильтры → типы/API. Сервер-first, затем клиент. + +**Tech Stack:** Prisma (SQLite), Fastify (ajv schema), React + MUI + react-hook-form, axios + +--- + +### Task 1: Prisma migration — удалить inStock и leadTimeDays + +**Files:** +- Modify: `server/prisma/schema.prisma` + +- [ ] **Step 1: Удалить поля inStock и leadTimeDays из модели Product** + +Открыть `server/prisma/schema.prisma` и удалить строки 33-34: + +```prisma + inStock Boolean @default(true) + leadTimeDays Int? +``` + +Перед этим — миграция данных: все товары с `inStock = false` должны получить `quantity = 0`. +Создаём миграцию с raw SQL: + +```bash +cd server +npx prisma migrate dev --name remove_instock_leadtime +``` + +Prisma автоматически создаст migration файл. Нужно убедиться, что migration содержит: +```sql +-- Перед удалением колонок, установить quantity = 0 для товаров под заказ +UPDATE Product SET quantity = 0 WHERE inStock = 0; +``` + +Если Prisma не добавит это автоматически, нужно отредактировать созданный migration файл в `server/prisma/migrations/_remove_instock_leadtime/migration.sql`: + +```sql +UPDATE Product SET quantity = 0 WHERE inStock = 0; +ALTER TABLE Product DROP COLUMN inStock; +ALTER TABLE Product DROP COLUMN leadTimeDays; +``` + +- [ ] **Step 2: Применить миграцию** + +```bash +cd server +npx prisma migrate dev +``` + +Expected: Migration applied successfully, no errors. + +- [ ] **Step 3: Перегенерировать Prisma Client** + +```bash +cd server +npx prisma generate +``` + +Expected: Generated Prisma Client output. + +--- + +### Task 2: Сервер — обновить JSON Schema и валидацию CREATE + +**Files:** +- Modify: `server/src/routes/api/admin-products.js` + +- [ ] **Step 1: Обновить CREATE_PRODUCT_SCHEMA (строки 11-31)** + +Заменить schema на: + +```javascript +const CREATE_PRODUCT_SCHEMA = { + body: { + type: 'object', + required: ['title', 'priceCents', 'quantity', 'categoryId'], + properties: { + title: { type: 'string', minLength: 1 }, + slug: { type: 'string' }, + categoryId: { type: 'string', minLength: 1 }, + priceCents: { type: 'number', minimum: 0 }, + quantity: { type: 'number', minimum: 0 }, + shortDescription: { type: 'string', nullable: true }, + description: { type: 'string', nullable: true }, + materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, + imageUrl: { type: 'string', nullable: true }, + imageUrls: { type: 'array', items: { type: 'string' } }, + published: { type: 'boolean' }, + }, + }, +} +``` + +Изменения: +- Удалены: `inStock`, `leadTimeDays` +- `quantity` — убран `nullable: true` +- `categoryId` — добавлен `minLength: 1` +- `required` массив теперь включает `'quantity'` и `'categoryId'` + +- [ ] **Step 2: Обновить логику CREATE handler (строки 93-179)** + +Заменить handler на: + +```javascript +fastify.post( + '/api/admin/products', + { preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA }, + async (request, reply) => { + const body = request.body ?? {} + const title = String(body.title ?? '').trim() + if (!title) { + reply.code(400).send({ error: 'Укажите название' }) + return + } + const slug = String(body.slug ?? '').trim() || request.server.slugify(title) || `item-${Date.now()}` + const categoryId = String(body.categoryId ?? '').trim() + if (!categoryId) { + reply.code(400).send({ error: 'Укажите категорию' }) + return + } + const cat = await prisma.category.findUnique({ where: { id: categoryId } }) + if (!cat) { + reply.code(400).send({ error: 'Категория не найдена' }) + return + } + const priceCents = Number(body.priceCents) + if (!Number.isFinite(priceCents) || priceCents < 0) { + reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' }) + return + } + const exists = await prisma.product.findUnique({ where: { slug } }) + if (exists) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + + const n = Number(body.quantity) + if (!Number.isFinite(n) || n < 0) { + reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) + return + } + const quantity = Math.floor(n) + + const product = await prisma.product.create({ + data: { + title, + slug, + shortDescription: body.shortDescription ? String(body.shortDescription) : null, + description: body.description ? String(body.description) : null, + quantity, + materials: JSON.stringify(request.server.parseMaterialsInput(body.materials)), + priceCents: Math.round(priceCents), + imageUrl: body.imageUrl ? String(body.imageUrl) : null, + published: Boolean(body.published), + categoryId, + images: Array.isArray(body.imageUrls) + ? { + create: body.imageUrls + .map((u) => String(u || '').trim()) + .filter(Boolean) + .slice(0, 10) + .map((u, idx) => ({ url: u, sort: idx })), + } + : undefined, + }, + include: { category: true, images: { orderBy: { sort: 'asc' } } }, + }) + reply.code(201).send(request.server.mapProductForApi(product)) + }, +) +``` + +Удалён import `getOrCreateUnspecifiedCategory` если он больше не используется в этом файле (проверить после PATCH handler). + +--- + +### Task 3: Сервер — обновить PATCH handler + +**Files:** +- Modify: `server/src/routes/api/admin-products.js` + +- [ ] **Step 1: Обновить PATCH_PRODUCT_SCHEMA (строки 33-52)** + +Заменить на: + +```javascript +const PATCH_PRODUCT_SCHEMA = { + body: { + type: 'object', + properties: { + title: { type: 'string', minLength: 1 }, + slug: { type: 'string' }, + categoryId: { type: 'string', minLength: 1 }, + priceCents: { type: 'number', minimum: 0 }, + quantity: { type: 'number', minimum: 0 }, + shortDescription: { type: 'string', nullable: true }, + description: { type: 'string', nullable: true }, + materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, + imageUrl: { type: 'string', nullable: true }, + imageUrls: { type: 'array', items: { type: 'string' } }, + published: { type: 'boolean' }, + }, + }, +} +``` + +Изменения: +- Удалены: `inStock`, `leadTimeDays` +- `quantity` — убран `nullable: true` +- `categoryId` — добавлен `minLength: 1` + +- [ ] **Step 2: Обновить PATCH handler (строки 181-299)** + +Заменить handler на: + +```javascript +fastify.patch( + '/api/admin/products/:id', + { preHandler: [fastify.verifyAdmin], schema: PATCH_PRODUCT_SCHEMA }, + async (request, reply) => { + const { id } = request.params + const body = request.body ?? {} + const existing = await prisma.product.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Товар не найден' }) + return + } + const data = {} + if (body.title !== undefined) data.title = String(body.title).trim() + if (body.slug !== undefined) { + const s = String(body.slug).trim() + if (s && s !== existing.slug) { + const clash = await prisma.product.findFirst({ where: { slug: s, NOT: { id } } }) + if (clash) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + data.slug = s + } + } + if (body.shortDescription !== undefined) { + data.shortDescription = body.shortDescription ? String(body.shortDescription) : null + } + if (body.description !== undefined) { + data.description = body.description ? String(body.description) : null + } + if (body.quantity !== undefined) { + const n = Number(body.quantity) + if (!Number.isFinite(n) || n < 0) { + reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) + return + } + data.quantity = Math.floor(n) + } + if (body.materials !== undefined) { + data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials)) + } + if (body.priceCents !== undefined) { + const p = Number(body.priceCents) + if (!Number.isFinite(p) || p < 0) { + reply.code(400).send({ error: 'Некорректная цена' }) + return + } + data.priceCents = Math.round(p) + } + if (body.imageUrl !== undefined) { + data.imageUrl = body.imageUrl ? String(body.imageUrl) : null + } + if (body.published !== undefined) data.published = Boolean(body.published) + if (body.categoryId !== undefined) { + const cid = String(body.categoryId).trim() + if (!cid) { + reply.code(400).send({ error: 'Укажите категорию' }) + return + } + const cat = await prisma.category.findUnique({ where: { id: cid } }) + if (!cat) { + reply.code(400).send({ error: 'Категория не найдена' }) + return + } + data.categoryId = cid + } + + const imagesUpdate = + body.imageUrls !== undefined + ? { + deleteMany: {}, + create: Array.isArray(body.imageUrls) + ? body.imageUrls + .map((u) => String(u || '').trim()) + .filter(Boolean) + .slice(0, 10) + .map((u, idx) => ({ url: u, sort: idx })) + : [], + } + : undefined + + const product = await prisma.product.update({ + where: { id }, + data: { ...data, images: imagesUpdate }, + include: { category: true, images: { orderBy: { sort: 'asc' } } }, + }) + return request.server.mapProductForApi(product) + }, +) +``` + +- [ ] **Step 3: Удалить неиспользуемый import** + +В начале файла удалить строку: +```javascript +import { getOrCreateUnspecifiedCategory } from '../../lib/default-category.js' +``` + +--- + +### Task 4: Сервер — убрать фильтр availability из public-catalog + +**Files:** +- Modify: `server/src/routes/api/public-catalog.js` + +- [ ] **Step 1: Удалить availability из PUBLIC_PRODUCTS_QUERY_SCHEMA (строки 3-17)** + +Заменить schema на: + +```javascript +const PUBLIC_PRODUCTS_QUERY_SCHEMA = { + querystring: { + type: 'object', + properties: { + categorySlug: { type: 'string' }, + q: { type: 'string' }, + sort: { type: 'string', enum: ['', 'price_asc', 'price_desc'] }, + page: { type: 'integer', minimum: 1 }, + pageSize: { type: 'integer', minimum: 1, maximum: 100 }, + priceMin: { type: 'number', minimum: 0 }, + priceMax: { type: 'number', minimum: 0 }, + }, + }, +} +``` + +Удалена строка: `availability: { type: 'string', enum: ['all', 'in_stock', 'made_to_order'] }` + +- [ ] **Step 2: Удалить логику availability из GET /api/products handler (строки 82-159)** + +Удалить строки 87-88: +```javascript +const availabilityRaw = request.query?.availability +const availability = typeof availabilityRaw === 'string' ? availabilityRaw.trim() : '' +``` + +Удалить строки 116-123 (весь блок if/else if для availability): +```javascript +if (availability === 'in_stock') { + where.inStock = true + where.quantity = { gt: 0 } +} else if (availability === 'made_to_order') { + where.inStock = false +} else if (availability && availability !== 'all') { + return reply.code(400).send({ error: 'availability должен быть all | in_stock | made_to_order' }) +} +``` + +--- + +### Task 5: Клиент — обновить типы Product + +**Files:** +- Modify: `client/src/entities/product/model/types.ts` + +- [ ] **Step 1: Удалить inStock и leadTimeDays из типа Product** + +Заменить тип Product на: + +```typescript +export type Product = { + id: string + title: string + slug: string + shortDescription: string | null + description: string | null + quantity: number + materials?: string[] + priceCents: number + imageUrl: string | null + imageUrls?: string[] + published: boolean + categoryId: string + createdAt: string + updatedAt: string + category?: Category + images?: { id: string; url: string; sort: number }[] + reviewsSummary?: ProductReviewsSummary | null +} +``` + +Удалены поля: `inStock: boolean` и `leadTimeDays: number | null` + +--- + +### Task 6: Клиент — обновить API layer + +**Files:** +- Modify: `client/src/entities/product/api/product-api.ts` + +- [ ] **Step 1: Удалить availability из fetchPublicProducts** + +```typescript +export async function fetchPublicProducts(params?: { + categorySlug?: string + q?: string + sort?: 'price_asc' | 'price_desc' | '' + page?: number + pageSize?: number + priceMinCents?: number + priceMaxCents?: number +}): Promise { + const { data } = await apiClient.get('products', { + params: { + categorySlug: params?.categorySlug || undefined, + q: params?.q || undefined, + sort: params?.sort || undefined, + page: params?.page || undefined, + pageSize: params?.pageSize || undefined, + priceMin: params?.priceMinCents ?? undefined, + priceMax: params?.priceMaxCents ?? undefined, + }, + }) + return data +} +``` + +Удалены: `availability` из params type и из params object. + +- [ ] **Step 2: Удалить inStock и leadTimeDays из createProduct** + +```typescript +export async function createProduct(body: { + title: string + slug?: string + shortDescription?: string | null + description?: string | null + quantity: number + materials?: string[] + priceCents: number + imageUrl?: string | null + imageUrls?: string[] + published: boolean + categoryId: string +}): Promise { + const { data } = await apiClient.post('admin/products', body) + return data +} +``` + +- [ ] **Step 3: Удалить inStock и leadTimeDays из updateProduct** + +```typescript +export async function updateProduct( + id: string, + body: Partial<{ + title: string + slug: string + shortDescription: string | null + description: string | null + quantity: number + materials: string[] + priceCents: number + imageUrl: string | null + imageUrls: string[] + published: boolean + categoryId: string + }>, +): Promise { + const { data } = await apiClient.patch(`admin/products/${id}`, body) + return data +} +``` + +--- + +### Task 7: Клиент — AdminProductsPage (основная админка товаров) + +**Files:** +- Modify: `client/src/pages/admin-products/ui/AdminProductsPage.tsx` + +- [ ] **Step 1: Обновить FormState и emptyForm** + +Удалить import Switch (если не используется elsewhere). +Заменить FormState: + +```typescript +type FormState = { + title: string + slug: string + shortDescription: string + description: string + quantity: string + materials: string + priceRub: string + imageUrls: string[] + published: boolean + categoryId: string +} +``` + +Заменить emptyForm: + +```typescript +const emptyForm = (): FormState => ({ + title: '', + slug: '', + shortDescription: '', + description: '', + quantity: '0', + materials: '', + priceRub: '', + imageUrls: [], + published: true, + categoryId: '', +}) +``` + +- [ ] **Step 2: Удалить inStockValue watch** + +Удалить строку: +```typescript +const inStockValue = productForm.watch('inStock') +``` + +- [ ] **Step 3: Обновить openEdit** + +```typescript +const openEdit = (p: Product) => { + openEditDialog(p) + const urls = + (p.images ?? []) + .slice() + .sort((a, b) => a.sort - b.sort) + .map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : []) + productForm.reset({ + title: p.title, + slug: p.slug, + shortDescription: p.shortDescription ?? '', + description: p.description ?? '', + quantity: String(p.quantity), + materials: (p.materials ?? []).join(', '), + priceRub: String(p.priceCents / 100), + imageUrls: urls, + published: p.published, + categoryId: p.categoryId, + }) +} +``` + +- [ ] **Step 4: Обновить createMut** + +```typescript +const createMut = useMutation({ + mutationFn: async () => { + const form = productForm.getValues() + const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) + if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') + if (!form.categoryId) throw new Error('Выберите категорию') + const qty = form.quantity.trim() + if (!qty) throw new Error('Укажите количество') + const qtyNum = Number(qty) + if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') + const materials = form.materials + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + await createProduct({ + title: form.title.trim(), + slug: form.slug.trim() || undefined, + shortDescription: form.shortDescription.trim() || null, + description: form.description.trim() || null, + quantity: Math.floor(qtyNum), + materials, + priceCents, + imageUrls: form.imageUrls, + published: form.published, + categoryId: form.categoryId, + }) + }, + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) + closeDialog() + }, +}) +``` + +- [ ] **Step 5: Обновить updateMut** + +```typescript +const updateMut = useMutation({ + mutationFn: async () => { + const form = productForm.getValues() + const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) + if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') + if (!form.categoryId) throw new Error('Выберите категорию') + const qty = form.quantity.trim() + if (!qty) throw new Error('Укажите количество') + const qtyNum = Number(qty) + if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') + const materials = form.materials + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + await updateProduct(editing!.id, { + title: form.title.trim(), + slug: form.slug.trim(), + shortDescription: form.shortDescription.trim() || null, + description: form.description.trim() || null, + quantity: Math.floor(qtyNum), + materials, + priceCents, + imageUrls: form.imageUrls, + published: form.published, + categoryId: form.categoryId, + }) + }, + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) + closeDialog() + }, +}) +``` + +- [ ] **Step 6: Обновить UI — количество, категория, удалить inStock/leadTimeDays** + +TextField «Количество» (строки 363-375): + +```tsx + ( + + )} +/> +``` + +Select «Категория» (строки 472-489) — удалить MenuItem «Не указано»: + +```tsx + ( + + Категория + + {!field.value && Выберите категорию} + + )} +/> +``` + +Удалить Switch inStock (строки 501-510) и conditional leadTimeDays (строки 511-517). + +- [ ] **Step 7: Обновить disabled кнопку сохранения** + +```tsx + +``` + +- [ ] **Step 8: Удалить неиспользуемые импорты** + +Удалить: `Switch` из `@mui/material/Switch` + +--- + +### Task 8: Клиент — AdminPage (унифицированная админка) + +**Files:** +- Modify: `client/src/pages/admin/ui/AdminPage.tsx` + +Те же изменения что и в Task 7, но для AdminPage.tsx. + +- [ ] **Step 1: Обновить FormState и emptyForm** + +```typescript +type FormState = { + title: string + slug: string + shortDescription: string + description: string + quantity: string + materials: string + priceRub: string + imageUrls: string[] + published: boolean + categoryId: string +} + +const emptyForm = (): FormState => ({ + title: '', + slug: '', + shortDescription: '', + description: '', + quantity: '0', + materials: '', + priceRub: '', + imageUrls: [], + published: true, + categoryId: '', +}) +``` + +- [ ] **Step 2: Удалить inStockValue watch** + +Удалить строку: `const inStockValue = productForm.watch('inStock')` + +- [ ] **Step 3: Обновить openEdit** + +```typescript +const openEdit = (p: Product) => { + openEditDialog(p) + const urls = + (p.images ?? []) + .slice() + .sort((a, b) => a.sort - b.sort) + .map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : []) + productForm.reset({ + title: p.title, + slug: p.slug, + shortDescription: p.shortDescription ?? '', + description: p.description ?? '', + quantity: String(p.quantity), + materials: (p.materials ?? []).join(', '), + priceRub: String(p.priceCents / 100), + imageUrls: urls, + published: p.published, + categoryId: p.categoryId, + }) +} +``` + +- [ ] **Step 4: Обновить createMut** + +```typescript +const createMut = useMutation({ + mutationFn: async () => { + const form = productForm.getValues() + const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) + if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') + if (!form.categoryId) throw new Error('Выберите категорию') + const qty = form.quantity.trim() + if (!qty) throw new Error('Укажите количество') + const qtyNum = Number(qty) + if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') + const materials = form.materials + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + await createProduct({ + title: form.title.trim(), + slug: form.slug.trim() || undefined, + shortDescription: form.shortDescription.trim() || null, + description: form.description.trim() || null, + quantity: Math.floor(qtyNum), + materials, + priceCents, + imageUrls: form.imageUrls, + published: form.published, + categoryId: form.categoryId, + }) + }, + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) + closeDialog() + }, +}) +``` + +- [ ] **Step 5: Обновить updateMut** + +```typescript +const updateMut = useMutation({ + mutationFn: async () => { + const form = productForm.getValues() + const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) + if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') + if (!form.categoryId) throw new Error('Выберите категорию') + const qty = form.quantity.trim() + if (!qty) throw new Error('Укажите количество') + const qtyNum = Number(qty) + if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') + const materials = form.materials + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + await updateProduct(editing!.id, { + title: form.title.trim(), + slug: form.slug.trim(), + shortDescription: form.shortDescription.trim() || null, + description: form.description.trim() || null, + quantity: Math.floor(qtyNum), + materials, + priceCents, + imageUrls: form.imageUrls, + published: form.published, + categoryId: form.categoryId, + }) + }, + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) + closeDialog() + }, +}) +``` + +- [ ] **Step 6: Обновить UI — количество** + +```tsx + ( + + )} +/> +``` + +- [ ] **Step 7: Обновить UI — категория (удалить «Не указано»)** + +```tsx + ( + + Категория + + {!field.value && Выберите категорию} + + )} +/> +``` + +- [ ] **Step 8: Удалить Switch inStock и conditional leadTimeDays** + +Удалить строки 654-670 (Controller inStock + conditional leadTimeDays). + +- [ ] **Step 9: Обновить disabled кнопку** + +```tsx + +``` + +- [ ] **Step 10: Удалить неиспользуемые импорты** + +Удалить: `Switch` из `@mui/material/Switch` + +--- + +### Task 9: Клиент — ProductCard (статус по quantity) + +**Files:** +- Modify: `client/src/entities/product/ui/ProductCard.tsx` + +- [ ] **Step 1: Заменить stockLabel логику** + +Заменить строки 47-52: + +```typescript +const stockLabel = + product.quantity > 0 + ? null + : { label: 'Нет в наличии', color: 'default' as const } +``` + +Старая логика (удалить): +```typescript +const stockLabel = + product.inStock && product.quantity === 0 + ? { label: 'Нет в наличии', color: 'default' as const } + : !product.inStock + ? { label: `Под заказ · ${product.leadTimeDays ?? '—'} дн.`, color: 'warning' as const } + : null +``` + +--- + +### Task 10: Клиент — ProductPage (убрать «под заказ» UI) + +**Files:** +- Modify: `client/src/pages/product/ui/ProductPage.tsx` + +- [ ] **Step 1: Обновить chip статуса (строка 134)** + +Заменить: +```tsx + +``` + +На: +```tsx +{p.quantity > 0 && } +{p.quantity === 0 && } +``` + +- [ ] **Step 2: Обновить условие ToggleCartIcon (строка 157)** + +Заменить: +```tsx +{!isAdmin && !(p.inStock && p.quantity === 0) ? : null} +``` + +На: +```tsx +{!isAdmin && p.quantity > 0 ? : null} +``` + +- [ ] **Step 3: Удалить alert «под заказ» (строки 159-163)** + +Удалить: +```tsx +{!p.inStock && ( + + Этот товар изготавливается под заказ. Доставка будет после изготовления (~{p.leadTimeDays ?? '—'} дн.). + +)} +``` + +- [ ] **Step 4: Удалить неиспользуемый import Alert** + +Если Alert больше не используется в файле — удалить import. (Проверить: Alert может использоваться для других целей — оставить если используется.) + +--- + +### Task 11: Клиент — CheckoutPage (убрать made-to-order detection) + +**Files:** +- Modify: `client/src/pages/checkout/ui/CheckoutPage.tsx` + +- [ ] **Step 1: Удалить hasMadeToOrder (строка 84)** + +Удалить: +```typescript +const hasMadeToOrder = items.some((x) => !x.product.inStock) +``` + +- [ ] **Step 2: Обновить hasOverLimit (строка 83)** + +Заменить: +```typescript +const hasOverLimit = items.some((x) => x.qty > x.product.quantity) +``` + +Старая логика: +```typescript +const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1)) +``` + +- [ ] **Step 3: Обновить available в списке позиций (строка 108)** + +Заменить: +```typescript +const available = x.product.quantity +``` + +Старая логика: +```typescript +const available = x.product.inStock ? x.product.quantity : 1 +``` + +- [ ] **Step 4: Удалить alert made-to-order (строки 130-134)** + +Удалить: +```tsx +{hasMadeToOrder && ( + + В заказе есть товары «под заказ». Доставка будет после изготовления (срок указан в карточке товара). + +)} +``` + +--- + +### Task 12: Клиент — use-product-filters (убрать availability) + +**Files:** +- Modify: `client/src/pages/home/lib/use-product-filters.ts` + +- [ ] **Step 1: Удалить availability state и handler** + +```typescript +export function useProductFilters() { + const [categorySlug, setCategorySlug] = useState('') + const [qInput, setQInput] = useState('') + const [q, setQ] = useState('') + const [moreOpen, setMoreOpen] = useState(false) + const [sort, setSort] = useState<'price_asc' | 'price_desc' | ''>('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(12) + const [priceMinRub, setPriceMinRub] = useState('') + const [priceMaxRub, setPriceMaxRub] = useState('') + const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90) +``` + +Удалить: `const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')` + +- [ ] **Step 2: Удалить handleAvailabilityChange** + +Удалить функцию: +```typescript +const handleAvailabilityChange = (v: string) => { + if (v === 'all' || v === 'in_stock' || v === 'made_to_order') { + setAvailability(v) + setPage(1) + } +} +``` + +- [ ] **Step 3: Обновить resetFilters** + +```typescript +const resetFilters = () => { + setCategorySlug('') + setQInput('') + setSort('') + setPriceMinRub('') + setPriceMaxRub('') + setPageSize(12) + setCardScale(90) + setMoreOpen(false) +} +``` + +Удалить: `setAvailability('all')` + +- [ ] **Step 4: Обновить return object** + +```typescript +return { + categorySlug, + qInput, + q, + moreOpen, + sort, + page, + pageSize, + priceMinRub, + priceMaxRub, + cardScale, + setPage, + setQInput, + setMoreOpen, + handleCategoryChange, + handleSortChange, + handlePageSizeChange, + handlePriceMinChange, + handlePriceMaxChange, + handleCardScaleChange, + resetFilters, + toCents, +} +``` + +Удалить: `availability` и `handleAvailabilityChange` + +--- + +### Task 13: Клиент — ProductFilters (убрать availability toggle, скрыть «Не указано») + +**Files:** +- Modify: `client/src/pages/home/ui/ProductFilters.tsx` + +- [ ] **Step 1: Удалить availability из Props destructuring** + +```typescript +export function ProductFilters({ + categorySlug, + qInput, + moreOpen, + sort, + pageSize, + priceMinRub, + priceMaxRub, + cardScale, + categories, + categoriesLoading, + setQInput, + setMoreOpen, + handleCategoryChange, + handleSortChange, + handlePageSizeChange, + handlePriceMinChange, + handlePriceMaxChange, + handleCardScaleChange, + resetFilters, +}: Props) { +``` + +Удалить: `availability` и `handleAvailabilityChange` из destructuring. + +- [ ] **Step 2: Скрыть «Не указано» из категорий** + +Обновить categoriesForFilter (строки 50-57): + +```typescript +const categoriesForFilter = useMemo(() => { + const list = (categories ?? []).filter((c) => c.slug !== 'ne-ukazano') + return [...list].sort((a, b) => a.sort - b.sort || a.name.localeCompare(b.name, 'ru')) +}, [categories]) +``` + +- [ ] **Step 3: Удалить ToggleButtonGroup availability (строки 128-146)** + +Удалить весь блок: +```tsx + handleAvailabilityChange(v)} + sx={{ ... }} +> + Все + В наличии + Под заказ + +``` + +- [ ] **Step 4: Удалить неиспользуемые импорты** + +Удалить: `ToggleButton`, `ToggleButtonGroup` из `@mui/material` + +--- + +### Task 14: Клиент — HomePage (убрать availability из query) + +**Files:** +- Modify: `client/src/pages/home/ui/HomePage.tsx` + +- [ ] **Step 1: Обновить queryKey и fetchPublicProducts вызов** + +```typescript +const productsQuery = useQuery({ + queryKey: [ + 'products', + 'public', + { + categorySlug: filters.categorySlug || 'all', + q: filters.q, + sort: filters.sort, + page: filters.page, + pageSize: filters.pageSize, + priceMinRub: filters.priceMinRub, + priceMaxRub: filters.priceMaxRub, + }, + ], + queryFn: () => + fetchPublicProducts({ + categorySlug: filters.categorySlug || undefined, + q: filters.q || undefined, + sort: filters.sort || '', + page: filters.page, + pageSize: filters.pageSize, + priceMinCents: filters.toCents(filters.priceMinRub), + priceMaxCents: filters.toCents(filters.priceMaxRub), + }), +}) +``` + +Удалить: `availability: filters.availability` из queryKey и `availability` из fetchPublicProducts params. + +- [ ] **Step 2: Обновить ToggleCartIcon в ProductCard actions** + +```tsx +actions={ + !isAdmin && p.quantity > 0 ? : undefined +} +``` + +--- + +### Task 15: Запустить серверные тесты + +**Files:** +- Test: `server/` tests + +- [ ] **Step 1: Запустить серверные тесты** + +```bash +cd server && npm test +``` + +Expected: All tests pass. Если есть тесты, проверяющие inStock/leadTimeDays — обновить их. + +--- + +### Task 16: Запустить клиентские линт и тесты + +**Files:** +- Test: `client/` tests + +- [ ] **Step 1: Запустить линт** + +```bash +cd client && npm run lint +``` + +Expected: No errors. + +- [ ] **Step 2: Запустить форматирование** + +```bash +cd client && npm run format:check +``` + +Expected: All files formatted correctly. + +- [ ] **Step 3: Запустить тесты** + +```bash +cd client && npm test +``` + +Expected: All tests pass. + +--- + +### Task 17: Сборка клиента + +**Files:** +- Build: `client/` + +- [ ] **Step 1: Запустить сборку** + +```bash +cd client && npm run build +``` + +Expected: Build succeeds with no TypeScript errors. + +--- diff --git a/AGENTS.md b/AGENTS.md index 42a54e7..f09b314 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,10 +25,14 @@ | Command | What it does | |---|---| -| `npm run dev` | `node --env-file=.dev_env --watch src/index.js` (requires Node 20.6+) | +| `npm run dev` | `node --env-file=.env --watch src/index.js` (requires Node 20.6+) | | `npm run dev:classic` | `node --watch src/index.js` (loads `.env` via dotenv) | +| `npm run lint` | ESLint (flat config) | +| `npm run lint:fix` | ESLint with `--fix` | +| `npm run format` | Prettier write all | +| `npm run format:check` | Prettier check only | | `npm test` | vitest run | -| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses `.dev_env`) | +| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses `.env`) | ### Build order (when changing both packages) @@ -65,7 +69,7 @@ cd client && npm run build # full typecheck + build ## Notable quirks -- `.env` is gitignored. Use `.dev_env` in the server repo for local dev (it is committed). Copy `.env.example` to `.env` for custom config. +- `.env` is gitignored. Copy `.env.example` to `.env` for local dev. - Vite dev server (client) relies on backend running at `127.0.0.1:3333`. Start server first. - Rich text rendering uses `shared/ui/RichTextMessageContent` (TipTap). Pass `tone="review"`, `tone="chat"`, or `tone="default"`. - `db:reset:test` runs `prisma migrate reset --force`, which destroys all data. diff --git a/README.md b/README.md index 88e7efe..62a6c32 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,13 @@ npx prisma db seed # опционально: тестовые категор npm run dev:classic # загрузка из `.env` ``` -**Вариант B — файл [`server/.dev_env`](server/.dev_env)** (то, что уже лежит в репозитории для локального стенда; нужен **Node.js 20.6+** из‑за `node --env-file`): +**Вариант B — `.env` файл** (нужен **Node.js 20.6+** из‑за `node --env-file`): ```bash cd server +cp .env.example .env # укажите ADMIN_EMAIL и другие настройки npm install -npm run dev # переменные из `.dev_env` +npm run dev # переменные из `.env` ``` Очистка БД до «чистого» тестового состояния (SQLite + миграции + seed): в `server/` выполните `npm run db:reset:test`. diff --git a/client/src/entities/product/api/admin-product-api.ts b/client/src/entities/product/api/admin-product-api.ts new file mode 100644 index 0000000..7e3d1ec --- /dev/null +++ b/client/src/entities/product/api/admin-product-api.ts @@ -0,0 +1,108 @@ +import type { Category, Product } from '@/entities/product/model/types' +import { apiClient } from '@/shared/api/client' +import { apiBaseURL } from '@/shared/config' +import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' + +export async function fetchAdminProducts(): Promise { + const { data } = await apiClient.get('admin/products') + return data +} + +export async function createProduct(body: { + title: string + slug?: string + shortDescription?: string | null + description?: string | null + quantity: number + materials?: string[] + priceCents: number + imageUrl?: string | null + imageUrls?: string[] + published: boolean + categoryId: string +}): Promise { + const { data } = await apiClient.post('admin/products', body) + return data +} + +export async function updateProduct( + id: string, + body: Partial<{ + title: string + slug: string + shortDescription: string | null + description: string | null + quantity: number + materials: string[] + priceCents: number + imageUrl: string | null + imageUrls: string[] + published: boolean + categoryId: string + }>, +): Promise { + const { data } = await apiClient.patch(`admin/products/${id}`, body) + return data +} + +export async function deleteProduct(id: string): Promise { + await apiClient.delete(`admin/products/${id}`) +} + +export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise { + const { data } = await apiClient.post('admin/categories', body) + return data +} + +export async function fetchAdminCategories(): Promise { + const { data } = await apiClient.get<{ items: Category[] }>('admin/categories') + return data.items +} + +export async function updateAdminCategory( + id: string, + body: Partial<{ name: string; slug: string; sort: number }>, +): Promise { + const { data } = await apiClient.patch(`admin/categories/${id}`, body) + return data +} + +export async function deleteAdminCategory(id: string): Promise { + await apiClient.delete(`admin/categories/${id}`) +} + +/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */ +export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise { + const list = Array.from(files) + for (const f of list) { + if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) { + throw new Error( + `Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`, + ) + } + } + const fd = new FormData() + for (const f of list) { + fd.append('files', f, f.name) + } + const token = localStorage.getItem('craftshop_auth_token') + const base = apiBaseURL.replace(/\/$/, '') + const res = await fetch(`${base}/admin/uploads`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: fd, + }) + const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string } + if (!res.ok) { + if (res.status === 413) { + throw new Error( + 'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.', + ) + } + throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`) + } + if (!Array.isArray(payload.urls)) { + throw new Error('Некорректный ответ сервера') + } + return payload.urls +} diff --git a/client/src/entities/product/api/product-api.ts b/client/src/entities/product/api/product-api.ts index f317aa8..b456645 100644 --- a/client/src/entities/product/api/product-api.ts +++ b/client/src/entities/product/api/product-api.ts @@ -1,7 +1,5 @@ import type { Category, Product } from '@/entities/product/model/types' import { apiClient } from '@/shared/api/client' -import { apiBaseURL } from '@/shared/config' -import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' export type PublicProductsResponse = { items: Product[] @@ -42,107 +40,3 @@ export async function fetchCategories(): Promise { const { data } = await apiClient.get('categories') return data } - -export async function fetchAdminProducts(): Promise { - const { data } = await apiClient.get('admin/products') - return data -} - -export async function createProduct(body: { - title: string - slug?: string - shortDescription?: string | null - description?: string | null - quantity: number - materials?: string[] - priceCents: number - imageUrl?: string | null - imageUrls?: string[] - published: boolean - categoryId: string -}): Promise { - const { data } = await apiClient.post('admin/products', body) - return data -} - -export async function updateProduct( - id: string, - body: Partial<{ - title: string - slug: string - shortDescription: string | null - description: string | null - quantity: number - materials: string[] - priceCents: number - imageUrl: string | null - imageUrls: string[] - published: boolean - categoryId: string - }>, -): Promise { - const { data } = await apiClient.patch(`admin/products/${id}`, body) - return data -} - -export async function deleteProduct(id: string): Promise { - await apiClient.delete(`admin/products/${id}`) -} - -export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise { - const { data } = await apiClient.post('admin/categories', body) - return data -} - -export async function fetchAdminCategories(): Promise { - const { data } = await apiClient.get<{ items: Category[] }>('admin/categories') - return data.items -} - -export async function updateAdminCategory( - id: string, - body: Partial<{ name: string; slug: string; sort: number }>, -): Promise { - const { data } = await apiClient.patch(`admin/categories/${id}`, body) - return data -} - -export async function deleteAdminCategory(id: string): Promise { - await apiClient.delete(`admin/categories/${id}`) -} - -/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */ -export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise { - const list = Array.from(files) - for (const f of list) { - if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) { - throw new Error( - `Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`, - ) - } - } - const fd = new FormData() - for (const f of list) { - fd.append('files', f, f.name) - } - const token = localStorage.getItem('craftshop_auth_token') - const base = apiBaseURL.replace(/\/$/, '') - const res = await fetch(`${base}/admin/uploads`, { - method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, - body: fd, - }) - const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string } - if (!res.ok) { - if (res.status === 413) { - throw new Error( - 'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.', - ) - } - throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`) - } - if (!Array.isArray(payload.urls)) { - throw new Error('Некорректный ответ сервера') - } - return payload.urls -} diff --git a/client/src/entities/product/api/reviews-api.ts b/client/src/entities/review/api/reviews-api.ts similarity index 100% rename from client/src/entities/product/api/reviews-api.ts rename to client/src/entities/review/api/reviews-api.ts diff --git a/client/src/features/address-form/index.ts b/client/src/features/address-form/index.ts new file mode 100644 index 0000000..da37f3c --- /dev/null +++ b/client/src/features/address-form/index.ts @@ -0,0 +1,2 @@ +export { AddressFormDialog } from './ui/AddressFormDialog' +export type { AddressFormValues } from './ui/AddressFormDialog' diff --git a/client/src/features/address-form/ui/AddressFormDialog.tsx b/client/src/features/address-form/ui/AddressFormDialog.tsx new file mode 100644 index 0000000..4af5d36 --- /dev/null +++ b/client/src/features/address-form/ui/AddressFormDialog.tsx @@ -0,0 +1,127 @@ +import Button from '@mui/material/Button' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import FormControlLabel from '@mui/material/FormControlLabel' +import Stack from '@mui/material/Stack' +import Switch from '@mui/material/Switch' +import TextField from '@mui/material/TextField' +import { Controller, type UseFormReturn } from 'react-hook-form' +import { AddressMapPicker } from '@/features/address-map-picker' + +export type AddressFormValues = { + label: string + recipientName: string + recipientPhone: string + addressLine: string + comment: string + lat: number | null + lng: number | null + isDefault: boolean +} + +export function AddressFormDialog({ + open, + onClose, + editing, + form, + onSubmit, + isPending, +}: { + open: boolean + onClose: () => void + editing: boolean + form: UseFormReturn + onSubmit: () => void + isPending: boolean +}) { + return ( + + {editing ? 'Редактировать адрес' : 'Новый адрес'} + + + } + /> + + } + /> + } + /> + + + } + /> + } + /> + + ( + ( + { + latField.onChange(v.lat) + lngField.onChange(v.lng) + if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true }) + }} + /> + )} + /> + )} + /> + + ( + field.onChange(v)} />} + label="Адрес по умолчанию" + /> + )} + /> + + + + + + + + ) +} diff --git a/client/src/features/order-detail/index.ts b/client/src/features/order-detail/index.ts new file mode 100644 index 0000000..9e2f64c --- /dev/null +++ b/client/src/features/order-detail/index.ts @@ -0,0 +1,2 @@ +export { DeliveryFeeAdjustmentForm } from './ui/DeliveryFeeAdjustmentForm' +export { OrderDetailContent } from './ui/OrderDetailContent' diff --git a/client/src/features/order-detail/ui/DeliveryFeeAdjustmentForm.tsx b/client/src/features/order-detail/ui/DeliveryFeeAdjustmentForm.tsx new file mode 100644 index 0000000..cff8f64 --- /dev/null +++ b/client/src/features/order-detail/ui/DeliveryFeeAdjustmentForm.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react' +import Button from '@mui/material/Button' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { patchAdminOrderDeliveryFee } from '@/entities/order/api/admin-order-api' +import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' + +export function DeliveryFeeAdjustmentForm({ + orderId, + deliveryFeeCents, +}: { + orderId: string + deliveryFeeCents: number +}) { + const qc = useQueryClient() + const [rub, setRub] = useState(() => String(deliveryFeeCents / 100)) + const feeMut = useMutation({ + mutationFn: () => patchAdminOrderDeliveryFee(orderId, Math.round(Number.parseFloat(rub) * 100)), + onSuccess: async () => { + await invalidateQueryKeys(qc, [ + ['admin', 'orders'], + ['admin', 'orders', 'detail'], + ['admin', 'orders', 'summary'], + ]) + }, + }) + + return ( + + setRub(e.target.value)} + slotProps={{ htmlInput: { min: 0, step: 1 } }} + sx={{ width: { xs: '100%', sm: 200 } }} + /> + + + ) +} diff --git a/client/src/features/order-detail/ui/OrderDetailContent.tsx b/client/src/features/order-detail/ui/OrderDetailContent.tsx new file mode 100644 index 0000000..db2d20c --- /dev/null +++ b/client/src/features/order-detail/ui/OrderDetailContent.tsx @@ -0,0 +1,194 @@ +import { useMemo, useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api' +import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api' +import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier' +import { getAdminNextOrderStatuses } from '@/shared/constants/order' +import { formatPriceRub } from '@/shared/lib/format-price' +import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' +import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot' +import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' +import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' +import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' +import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' +import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm' + +export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDetailResponse['item']; orderId: string }) { + const qc = useQueryClient() + const [msg, setMsg] = useState('') + + const statusMut = useMutation({ + mutationFn: (next: string) => setAdminOrderStatus(orderId, next), + onSuccess: async () => { + await invalidateQueryKeys(qc, [ + ['admin', 'orders'], + ['admin', 'orders', 'detail'], + ['admin', 'orders', 'summary'], + ]) + }, + }) + + const msgMut = useMutation({ + mutationFn: () => postAdminOrderMessage(orderId, msg.trim()), + onSuccess: async () => { + setMsg('') + await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']]) + }, + }) + + const deliverySnapshot = useMemo( + () => (detail.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null), + [detail], + ) + + const nextStatuses = useMemo( + () => getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery'), + [detail], + ) + + const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0 + + return ( + + + #{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '} + {formatPriceRub(detail.totalCents)} + + + Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'} + {(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'} + {detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && ( + <> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)} + )} + + + {detail.deliveryType === 'delivery' && ( + + + Адрес и получатель (на момент заказа) + + {deliverySnapshot ? ( + + {deliverySnapshot.label?.trim() && ( + + Метка: {deliverySnapshot.label} + + )} + + + Адрес: + {' '} + {deliverySnapshot.addressLine ?? '—'} + + + + Получатель: + {' '} + {deliverySnapshot.recipientName ?? '—'} + + + + Телефон: + {' '} + {deliverySnapshot.recipientPhone ?? '—'} + + {deliverySnapshot.comment?.trim() && ( + + Комментарий к адресу: {deliverySnapshot.comment} + + )} + + ) : ( + + Данные адреса в заказе отсутствуют или не распознаны. + + )} + + )} + + {detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && ( + + Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой суммы. + + )} + + {detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && ( + + )} + + + + Сменить статус + + + + + + + Сообщения + + + {detail.messages.map((m) => ( + + + {m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()} + + + + ))} + {detail.messages.length === 0 && Нет сообщений.} + + + + + + + + + + + ) +} diff --git a/client/src/features/product-form/index.ts b/client/src/features/product-form/index.ts new file mode 100644 index 0000000..0a0994d --- /dev/null +++ b/client/src/features/product-form/index.ts @@ -0,0 +1,2 @@ +export type { FormState } from './model/types' +export { emptyForm } from './lib/use-product-form-helpers' diff --git a/client/src/features/product-form/lib/use-product-form-helpers.ts b/client/src/features/product-form/lib/use-product-form-helpers.ts new file mode 100644 index 0000000..75e4cbb --- /dev/null +++ b/client/src/features/product-form/lib/use-product-form-helpers.ts @@ -0,0 +1,14 @@ +import type { FormState } from '../model/types' + +export const emptyForm = (): FormState => ({ + title: '', + slug: '', + shortDescription: '', + description: '', + quantity: '0', + materials: '', + priceRub: '', + imageUrls: [], + published: true, + categoryId: '', +}) diff --git a/client/src/features/product-form/model/types.ts b/client/src/features/product-form/model/types.ts new file mode 100644 index 0000000..c762baf --- /dev/null +++ b/client/src/features/product-form/model/types.ts @@ -0,0 +1,12 @@ +export type FormState = { + title: string + slug: string + shortDescription: string + description: string + quantity: string + materials: string + priceRub: string + imageUrls: string[] + published: boolean + categoryId: string +} diff --git a/client/src/features/product-form/ui/GalleryImagePicker.tsx b/client/src/features/product-form/ui/GalleryImagePicker.tsx new file mode 100644 index 0000000..73f01d3 --- /dev/null +++ b/client/src/features/product-form/ui/GalleryImagePicker.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Checkbox from '@mui/material/Checkbox' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import FormControlLabel from '@mui/material/FormControlLabel' +import Typography from '@mui/material/Typography' +import { useQuery } from '@tanstack/react-query' +import { fetchAdminGallery } from '@/entities/gallery' +import { OptimizedImage } from '@/shared/ui/OptimizedImage' + +export function GalleryImagePicker({ + open, + onClose, + onSelect, + currentUrls, +}: { + open: boolean + onClose: () => void + onSelect: (urls: string[]) => void + currentUrls: string[] +}) { + const [selectedUrls, setSelectedUrls] = useState>(() => new Set()) + + const galleryQuery = useQuery({ + queryKey: ['admin', 'gallery'], + queryFn: fetchAdminGallery, + enabled: open, + }) + + const toggleUrl = (url: string) => { + setSelectedUrls((prev) => { + const next = new Set(prev) + if (next.has(url)) { + next.delete(url) + } else { + next.add(url) + } + return next + }) + } + + const handleApply = () => { + onSelect([...selectedUrls]) + setSelectedUrls(new Set()) + onClose() + } + + const handleClose = () => { + setSelectedUrls(new Set()) + onClose() + } + + return ( + + Изображения из галереи + + {galleryQuery.isLoading && Загрузка списка…} + {galleryQuery.isError && Не удалось загрузить галерею. Попробуйте ещё раз.} + {galleryQuery.data?.items.length === 0 && !galleryQuery.isLoading && ( + В галерее пока нет файлов. Загрузите их в разделе «Галерея». + )} + {galleryQuery.data && + galleryQuery.data.items.length > 0 && + galleryQuery.data.items.filter((i) => i.isResized).length === 0 && + !galleryQuery.isLoading && ( + + В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея». + + )} + + {(galleryQuery.data?.items ?? []) + .filter((item) => item.isResized) + .map((item) => { + const alreadyInCard = currentUrls.includes(item.url) + return ( + toggleUrl(item.url)} + /> + } + label={ + + + + } + /> + ) + })} + + + + + + + + ) +} diff --git a/client/src/features/product-form/ui/ProductFormFields.tsx b/client/src/features/product-form/ui/ProductFormFields.tsx new file mode 100644 index 0000000..b4b8b3a --- /dev/null +++ b/client/src/features/product-form/ui/ProductFormFields.tsx @@ -0,0 +1,223 @@ +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import FormControl from '@mui/material/FormControl' +import FormControlLabel from '@mui/material/FormControlLabel' +import FormHelperText from '@mui/material/FormHelperText' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import Stack from '@mui/material/Stack' +import Switch from '@mui/material/Switch' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { Controller, type UseFormReturn } from 'react-hook-form' +import type { Category } from '@/entities/product/model/types' +import { OptimizedImage } from '@/shared/ui/OptimizedImage' +import type { FormState } from '../model/types' + +export function ProductFormFields({ + form, + categories, + onRemoveImage, + onPickFromGallery, +}: { + form: UseFormReturn + categories: Category[] + onRemoveImage: (url: string) => void + onPickFromGallery: () => void +}) { + return ( + + } + /> + ( + + )} + /> + ( + + )} + /> + } + /> + ( + + )} + /> + { + const n = Number(v) + if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10' + return true + }, + }} + render={({ field, fieldState }) => ( + { + const v = e.target.value.replace(/[^0-9]/g, '') + field.onChange(v) + }} + helperText={fieldState.error?.message ?? '0 = нет в наличии'} + error={!!fieldState.error} + /> + )} + /> + { + const n = Number(v.replace(',', '.')) + if (!Number.isFinite(n) || n <= 0) return 'Цена должна быть больше 0' + if (n > 10_000) return 'Цена не может превышать 10 000 ₽' + if (!Number.isInteger(Math.round(n * 100))) return 'Не более 2 знаков после запятой' + return true + }, + }} + render={({ field, fieldState }) => ( + { + const v = e.target.value.replace(/[^0-9.,]/g, '') + field.onChange(v) + }} + helperText={fieldState.error?.message} + error={!!fieldState.error} + /> + )} + /> + + + Фото (из галереи) + + + Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл остаётся + на сервере и в галерее. + + + + + + {form.watch('imageUrls').length > 0 && ( + + {form.watch('imageUrls').map((url) => ( + + + + + ))} + + )} + + ( + + Категория + + {!field.value && Выберите категорию} + + )} + /> + ( + field.onChange(v)} />} + label="Показывать в каталоге" + /> + )} + /> + + ) +} diff --git a/client/src/features/product-review/index.ts b/client/src/features/product-review/index.ts index 6b8f6f1..ad8db94 100644 --- a/client/src/features/product-review/index.ts +++ b/client/src/features/product-review/index.ts @@ -1,2 +1,3 @@ export { ReviewSection } from './ui/ReviewSection' export { ReviewDialog } from './ui/ReviewDialog' +export { ProductReviewsList } from './ui/ProductReviewsList' diff --git a/client/src/features/product-review/ui/ProductReviewsList.tsx b/client/src/features/product-review/ui/ProductReviewsList.tsx new file mode 100644 index 0000000..fd922bb --- /dev/null +++ b/client/src/features/product-review/ui/ProductReviewsList.tsx @@ -0,0 +1,106 @@ +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Paper from '@mui/material/Paper' +import Rating from '@mui/material/Rating' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useQuery } from '@tanstack/react-query' +import { Star } from 'lucide-react' +import { fetchPublicProductReviews } from '@/entities/review/api/reviews-api' +import type { PublicProductReviewItem } from '@/entities/review/api/reviews-api' +import { reviewsCountRu } from '@/shared/lib/reviews-count-ru' +import { OptimizedImage } from '@/shared/ui/OptimizedImage' +import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' + +function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { + const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null + return ( + + + + {rv.authorDisplay} + + {new Date(rv.createdAt).toLocaleString('ru-RU')} + + + } + emptyIcon={} + /> + {body ? ( + + + + ) : ( + + Без текстового комментария. + + )} + {rv.imageUrl && ( + + + + )} + + + ) +} + +export function ProductReviewsList({ productId }: { productId: string }) { + const reviewsQuery = useQuery({ + queryKey: ['products', 'public', productId, 'reviews', { page: 1, pageSize: 30 }], + queryFn: () => fetchPublicProductReviews(productId, { page: 1, pageSize: 30 }), + enabled: Boolean(productId), + }) + + if (reviewsQuery.isLoading) return Загрузка отзывов… + if (reviewsQuery.isError) return Не удалось загрузить отзывы. + if (reviewsQuery.data && reviewsQuery.data.total === 0) { + return ( + + + Отзывов пока нет + + + Будьте первым, кто оставит отзыв на этот товар. Ваше мнение поможет улучшить качество наших изделий. + + + ) + } + if (!reviewsQuery.data || reviewsQuery.data.items.length === 0) return null + + return ( + + {reviewsQuery.data.items.map((rv) => ( + + ))} + {reviewsQuery.data.total > reviewsQuery.data.items.length && ( + + Всего {reviewsCountRu(reviewsQuery.data.total)} — ниже показаны последние {reviewsQuery.data.items.length}. + + )} + + ) +} diff --git a/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx b/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx index 4b53317..f189ea3 100644 --- a/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx +++ b/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx @@ -21,7 +21,7 @@ import { deleteAdminCategory, fetchAdminCategories, updateAdminCategory, -} from '@/entities/product/api/product-api' +} from '@/entities/product/api/admin-product-api' import type { Category } from '@/entities/product/model/types' import { getErrorMessage } from '@/shared/lib/get-error-message' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' diff --git a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx index bc10d1d..e6c3c35 100644 --- a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx +++ b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx @@ -14,76 +14,21 @@ import TableHead from '@mui/material/TableHead' import TableRow from '@mui/material/TableRow' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { - fetchAdminOrder, - fetchAdminOrders, - patchAdminOrderDeliveryFee, - postAdminOrderMessage, - setAdminOrderStatus, -} from '@/entities/order/api/admin-order-api' -import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier' -import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order' +import { useQuery } from '@tanstack/react-query' +import { fetchAdminOrder, fetchAdminOrders } from '@/entities/order/api/admin-order-api' +import { OrderDetailContent } from '@/features/order-detail/ui/OrderDetailContent' +import { ORDER_STATUSES } from '@/shared/constants/order' import { formatPriceRub } from '@/shared/lib/format-price' import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status' -import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' -import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog' -import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' -import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' -import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' - -function DeliveryFeeAdjustmentForm({ orderId, deliveryFeeCents }: { orderId: string; deliveryFeeCents: number }) { - const qc = useQueryClient() - const [rub, setRub] = useState(() => String(deliveryFeeCents / 100)) - const feeMut = useMutation({ - mutationFn: () => patchAdminOrderDeliveryFee(orderId, Math.round(Number.parseFloat(rub) * 100)), - onSuccess: async () => { - await invalidateQueryKeys(qc, [ - ['admin', 'orders'], - ['admin', 'orders', 'detail'], - ['admin', 'orders', 'summary'], - ]) - }, - }) - - return ( - - setRub(e.target.value)} - slotProps={{ htmlInput: { min: 0, step: 1 } }} - sx={{ width: { xs: '100%', sm: 200 } }} - /> - - - ) -} export function AdminOrdersPage() { - const qc = useQueryClient() const [q, setQ] = useState('') const [status, setStatus] = useState('') const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('') const [dialogOpen, setDialogOpen] = useState(false) const [selectedId, setSelectedId] = useState(null) - const [msg, setMsg] = useState('') const ordersQuery = useQuery({ queryKey: ['admin', 'orders', { q, status, deliveryType }], @@ -101,25 +46,6 @@ export function AdminOrdersPage() { enabled: Boolean(selectedId), }) - const statusMut = useMutation({ - mutationFn: (next: string) => setAdminOrderStatus(selectedId!, next), - onSuccess: async () => { - await invalidateQueryKeys(qc, [ - ['admin', 'orders'], - ['admin', 'orders', 'detail'], - ['admin', 'orders', 'summary'], - ]) - }, - }) - - const msgMut = useMutation({ - mutationFn: () => postAdminOrderMessage(selectedId!, msg.trim()), - onSuccess: async () => { - setMsg('') - await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']]) - }, - }) - const open = (id: string) => { setSelectedId(id) setDialogOpen(true) @@ -136,17 +62,6 @@ export function AdminOrdersPage() { ) const detail = orderDetailQuery.data?.item - const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0 - - const deliverySnapshot = useMemo( - () => (detail?.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null), - [detail], - ) - - const nextStatuses = useMemo(() => { - if (!detail) return [] - return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery') - }, [detail]) return ( @@ -252,146 +167,7 @@ export function AdminOrdersPage() { loading={!detail && orderDetailQuery.isLoading} error={orderDetailQuery.isError ? 'Не удалось загрузить заказ.' : null} > - {detail && ( - - - #{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '} - {formatPriceRub(detail.totalCents)} - - - Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'} - {(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'} - {detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && ( - <> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)} - )} - - - {detail.deliveryType === 'delivery' && ( - - - Адрес и получатель (на момент заказа) - - {deliverySnapshot ? ( - - {deliverySnapshot.label?.trim() && ( - - Метка: {deliverySnapshot.label} - - )} - - - Адрес: - {' '} - {deliverySnapshot.addressLine ?? '—'} - - - - Получатель: - {' '} - {deliverySnapshot.recipientName ?? '—'} - - - - Телефон: - {' '} - {deliverySnapshot.recipientPhone ?? '—'} - - {deliverySnapshot.comment?.trim() && ( - - Комментарий к адресу: {deliverySnapshot.comment} - - )} - - ) : ( - - Данные адреса в заказе отсутствуют или не распознаны. - - )} - - )} - - {detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && ( - - Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой - суммы. - - )} - - {detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && ( - - )} - - - - Сменить статус - - - - - - - Сообщения - - - {detail.messages.map((m) => ( - - - {m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '} - {new Date(m.createdAt).toLocaleString()} - - - - ))} - {detail.messages.length === 0 && Нет сообщений.} - - - - - - - - - - - )} + {detail && } ) diff --git a/client/src/pages/admin-products/ui/AdminProductsPage.tsx b/client/src/pages/admin-products/ui/AdminProductsPage.tsx index 1f73960..46c3830 100644 --- a/client/src/pages/admin-products/ui/AdminProductsPage.tsx +++ b/client/src/pages/admin-products/ui/AdminProductsPage.tsx @@ -2,75 +2,40 @@ import { useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' -import Checkbox from '@mui/material/Checkbox' import Dialog from '@mui/material/Dialog' import DialogActions from '@mui/material/DialogActions' import DialogContent from '@mui/material/DialogContent' import DialogTitle from '@mui/material/DialogTitle' -import FormControl from '@mui/material/FormControl' -import FormControlLabel from '@mui/material/FormControlLabel' -import FormHelperText from '@mui/material/FormHelperText' -import InputLabel from '@mui/material/InputLabel' -import MenuItem from '@mui/material/MenuItem' -import Select from '@mui/material/Select' import Stack from '@mui/material/Stack' -import Switch from '@mui/material/Switch' import Table from '@mui/material/Table' import TableBody from '@mui/material/TableBody' import TableCell from '@mui/material/TableCell' import TableHead from '@mui/material/TableHead' import TableRow from '@mui/material/TableRow' -import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Controller, useForm } from 'react-hook-form' -import { fetchAdminGallery } from '@/entities/gallery' +import { useForm } from 'react-hook-form' import { createProduct, deleteProduct, fetchAdminProducts, - fetchCategories, updateProduct, -} from '@/entities/product/api/product-api' -import type { Category, Product } from '@/entities/product/model/types' +} from '@/entities/product/api/admin-product-api' +import { fetchCategories } from '@/entities/product/api/product-api' +import type { Product } from '@/entities/product/model/types' +import { emptyForm, type FormState } from '@/features/product-form' +import { GalleryImagePicker } from '@/features/product-form/ui/GalleryImagePicker' +import { ProductFormFields } from '@/features/product-form/ui/ProductFormFields' import { formatPriceRub } from '@/shared/lib/format-price' import { getErrorMessage } from '@/shared/lib/get-error-message' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state' import { EntityRowActions } from '@/shared/ui/EntityRowActions' -import { OptimizedImage } from '@/shared/ui/OptimizedImage' - -type FormState = { - title: string - slug: string - shortDescription: string - description: string - quantity: string - materials: string - priceRub: string - imageUrls: string[] - published: boolean - categoryId: string -} - -const emptyForm = (): FormState => ({ - title: '', - slug: '', - shortDescription: '', - description: '', - quantity: '0', - materials: '', - priceRub: '', - imageUrls: [], - published: true, - categoryId: '', -}) export function AdminProductsPage() { const queryClient = useQueryClient() const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState() const [galleryPickOpen, setGalleryPickOpen] = useState(false) - const [gallerySelectedUrls, setGallerySelectedUrls] = useState>(() => new Set()) const productForm = useForm({ defaultValues: emptyForm(), @@ -89,12 +54,6 @@ export function AdminProductsPage() { queryFn: fetchAdminProducts, }) - const galleryForPickQuery = useQuery({ - queryKey: ['admin', 'gallery'], - queryFn: fetchAdminGallery, - enabled: galleryPickOpen, - }) - const openCreate = () => { productForm.reset(emptyForm()) openCreateDialog() @@ -212,29 +171,15 @@ export function AdminProductsPage() { ) } - const toggleGalleryPickUrl = (url: string) => { - setGallerySelectedUrls((prev) => { - const next = new Set(prev) - if (next.has(url)) { - next.delete(url) - } else { - next.add(url) - } - return next - }) - } - - const appendGalleryUrlsToForm = () => { + const handleGallerySelect = (urls: string[]) => { const current = productForm.getValues('imageUrls') const merged = [...current] - for (const url of gallerySelectedUrls) { + for (const url of urls) { if (!merged.includes(url)) { merged.push(url) } } productForm.setValue('imageUrls', merged, { shouldDirty: true }) - setGalleryPickOpen(false) - setGallerySelectedUrls(new Set()) } return ( @@ -289,204 +234,12 @@ export function AdminProductsPage() { {editing ? 'Редактировать товар' : 'Новый товар'} - - } - /> - ( - - )} - /> - ( - - )} - /> - } - /> - ( - - )} - /> - { - const n = Number(v) - if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10' - return true - }, - }} - render={({ field, fieldState }) => ( - { - const v = e.target.value.replace(/[^0-9]/g, '') - field.onChange(v) - }} - helperText={fieldState.error?.message ?? '0 = нет в наличии'} - error={!!fieldState.error} - /> - )} - /> - { - const n = Number(v.replace(',', '.')) - if (!Number.isFinite(n) || n <= 0) return 'Цена должна быть больше 0' - if (n > 10_000) return 'Цена не может превышать 10 000 ₽' - if (!Number.isInteger(Math.round(n * 100))) return 'Не более 2 знаков после запятой' - return true - }, - }} - render={({ field, fieldState }) => ( - { - const v = e.target.value.replace(/[^0-9.,]/g, '') - field.onChange(v) - }} - helperText={fieldState.error?.message} - error={!!fieldState.error} - /> - )} - /> - - - Фото (из галереи) - - - Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл - остаётся на сервере и в галерее. - - - - - - {productForm.watch('imageUrls').length > 0 && ( - - {productForm.watch('imageUrls').map((url) => ( - - - - - ))} - - )} - - ( - - Категория - - {!field.value && Выберите категорию} - - )} - /> - ( - field.onChange(v)} />} - label="Показывать в каталоге" - /> - )} - /> - + setGalleryPickOpen(true)} + /> @@ -508,89 +261,12 @@ export function AdminProductsPage() { - { - setGalleryPickOpen(false) - setGallerySelectedUrls(new Set()) - }} - fullWidth - maxWidth="sm" - > - Изображения из галереи - - {galleryForPickQuery.isLoading && Загрузка списка…} - {galleryForPickQuery.isError && ( - Не удалось загрузить галерею. Попробуйте ещё раз. - )} - {galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && ( - В галерее пока нет файлов. Загрузите их в разделе «Галерея». - )} - {galleryForPickQuery.data && - galleryForPickQuery.data.items.length > 0 && - galleryForPickQuery.data.items.filter((i) => i.isResized).length === 0 && - !galleryForPickQuery.isLoading && ( - - В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея». - - )} - - {(galleryForPickQuery.data?.items ?? []) - .filter((item) => item.isResized) - .map((item) => { - const alreadyInCard = productForm.watch('imageUrls').includes(item.url) - return ( - toggleGalleryPickUrl(item.url)} - /> - } - label={ - - - - } - /> - ) - })} - - - - - - - + onClose={() => setGalleryPickOpen(false)} + onSelect={handleGallerySelect} + currentUrls={productForm.watch('imageUrls')} + /> ) } diff --git a/client/src/pages/me/ui/sections/AddressesPage.tsx b/client/src/pages/me/ui/sections/AddressesPage.tsx index a596d04..8535e09 100644 --- a/client/src/pages/me/ui/sections/AddressesPage.tsx +++ b/client/src/pages/me/ui/sections/AddressesPage.tsx @@ -3,17 +3,10 @@ import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Chip from '@mui/material/Chip' -import Dialog from '@mui/material/Dialog' -import DialogActions from '@mui/material/DialogActions' -import DialogContent from '@mui/material/DialogContent' -import DialogTitle from '@mui/material/DialogTitle' -import FormControlLabel from '@mui/material/FormControlLabel' import Stack from '@mui/material/Stack' -import Switch from '@mui/material/Switch' -import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Controller, useForm } from 'react-hook-form' +import { useForm } from 'react-hook-form' import { createMyAddress, deleteMyAddress, @@ -22,7 +15,18 @@ import { updateMyAddress, } from '@/entities/user/api/address-api' import type { ShippingAddress } from '@/entities/user/model/types' -import { AddressMapPicker } from '@/features/address-map-picker' +import { AddressFormDialog, type AddressFormValues } from '@/features/address-form' + +const defaultAddressForm = (isDefault: boolean): AddressFormValues => ({ + label: '', + recipientName: '', + recipientPhone: '', + addressLine: '', + comment: '', + lat: null, + lng: null, + isDefault, +}) export function AddressesPage() { const queryClient = useQueryClient() @@ -34,26 +38,8 @@ export function AddressesPage() { queryFn: fetchMyAddresses, }) - const form = useForm<{ - label: string - recipientName: string - recipientPhone: string - addressLine: string - comment: string - lat: number | null - lng: number | null - isDefault: boolean - }>({ - defaultValues: { - label: '', - recipientName: '', - recipientPhone: '', - addressLine: '', - comment: '', - lat: null, - lng: null, - isDefault: false, - }, + const form = useForm({ + defaultValues: defaultAddressForm(false), mode: 'onChange', }) @@ -115,16 +101,7 @@ export function AddressesPage() { const openCreate = () => { setEditing(null) - form.reset({ - label: '', - recipientName: '', - recipientPhone: '', - addressLine: '', - comment: '', - lat: null, - lng: null, - isDefault: items.length === 0, - }) + form.reset(defaultAddressForm(items.length === 0)) setOpen(true) } @@ -143,6 +120,11 @@ export function AddressesPage() { setOpen(true) } + const handleSubmit = () => { + if (editing) updateMut.mutate() + else createMut.mutate() + } + return ( @@ -226,93 +208,14 @@ export function AddressesPage() { )} - setOpen(false)} fullWidth maxWidth="md"> - {editing ? 'Редактировать адрес' : 'Новый адрес'} - - - } - /> - - } - /> - } - /> - - - } - /> - } - /> - - ( - ( - { - latField.onChange(v.lat) - lngField.onChange(v.lng) - if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true }) - }} - /> - )} - /> - )} - /> - - ( - field.onChange(v)} />} - label="Адрес по умолчанию" - /> - )} - /> - - - - - - - + setOpen(false)} + editing={Boolean(editing)} + form={form} + onSubmit={handleSubmit} + isPending={createMut.isPending || updateMut.isPending} + /> ) } diff --git a/client/src/pages/me/ui/sections/OrderDetailPage.tsx b/client/src/pages/me/ui/sections/OrderDetailPage.tsx index ea98955..d409012 100644 --- a/client/src/pages/me/ui/sections/OrderDetailPage.tsx +++ b/client/src/pages/me/ui/sections/OrderDetailPage.tsx @@ -15,7 +15,7 @@ import { submitOrderPayment, fetchOrderReviewEligibility, } from '@/entities/order/api/order-api' -import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api' +import { postProductReview, uploadReviewImage } from '@/entities/review/api/reviews-api' import { markOrderMessagesRead } from '@/entities/user/api/messages-api' import { OrderChat } from '@/features/order-chat' import { OrderPaymentSection } from '@/features/order-payment' diff --git a/client/src/pages/product/ui/ProductPage.tsx b/client/src/pages/product/ui/ProductPage.tsx index 7b95c27..676a2d5 100644 --- a/client/src/pages/product/ui/ProductPage.tsx +++ b/client/src/pages/product/ui/ProductPage.tsx @@ -5,7 +5,6 @@ import Chip from '@mui/material/Chip' import Dialog from '@mui/material/Dialog' import Divider from '@mui/material/Divider' import IconButton from '@mui/material/IconButton' -import Paper from '@mui/material/Paper' import Rating from '@mui/material/Rating' import Skeleton from '@mui/material/Skeleton' import Stack from '@mui/material/Stack' @@ -19,14 +18,13 @@ import { Swiper, SwiperSlide } from 'swiper/react' import 'swiper/css' import 'swiper/css/navigation' import { fetchPublicProduct } from '@/entities/product/api/product-api' -import { fetchPublicProductReviews } from '@/entities/product/api/reviews-api' import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon' +import { ProductReviewsList } from '@/features/product-review' import { formatPriceRub } from '@/shared/lib/format-price' import { getOriginalWebpUrl } from '@/shared/lib/get-original-webp-url' import { reviewsCountRu } from '@/shared/lib/reviews-count-ru' import { $user } from '@/shared/model/auth' import { OptimizedImage } from '@/shared/ui/OptimizedImage' -import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' export function ProductPage() { const user = useUnit($user) @@ -41,12 +39,6 @@ export function ProductPage() { enabled: Boolean(id), }) - const reviewsQuery = useQuery({ - queryKey: ['products', 'public', id, 'reviews', { page: 1, pageSize: 30 }], - queryFn: () => fetchPublicProductReviews(id!, { page: 1, pageSize: 30 }), - enabled: Boolean(id), - }) - const imageUrls = useMemo(() => { const p = productQuery.data if (!p) return [] @@ -191,83 +183,7 @@ export function ProductPage() { )} - {reviewsQuery.isLoading && Загрузка отзывов…} - {reviewsQuery.isError && Не удалось загрузить отзывы.} - {reviewsQuery.data && reviewsQuery.data.total === 0 && ( - - - Отзывов пока нет - - - Будьте первым, кто оставит отзыв на этот товар. Ваше мнение поможет улучшить качество наших изделий. - - - )} - {reviewsQuery.data && reviewsQuery.data.items.length > 0 && ( - - {reviewsQuery.data.items.map((rv) => { - const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null - return ( - - - - {rv.authorDisplay} - - {new Date(rv.createdAt).toLocaleString('ru-RU')} - - - } - emptyIcon={} - /> - {body ? ( - - - - ) : ( - - Без текстового комментария. - - )} - {rv.imageUrl && ( - - - - )} - - - ) - })} - {reviewsQuery.data.total > reviewsQuery.data.items.length && ( - - Всего {reviewsCountRu(reviewsQuery.data.total)} — ниже показаны последние{' '} - {reviewsQuery.data.items.length}. - - )} - - )} + setViewerOpen(false)}> diff --git a/client/src/shared/constants/delivery-carrier.ts b/client/src/shared/constants/delivery-carrier.ts index a6f6cb7..8641b6d 100644 --- a/client/src/shared/constants/delivery-carrier.ts +++ b/client/src/shared/constants/delivery-carrier.ts @@ -1,21 +1,19 @@ -import { DELIVERY_CARRIERS as SHARED_DELIVERY_CARRIERS } from '@shared/constants/delivery-carrier' +import { + DELIVERY_CARRIERS as SHARED_DELIVERY_CARRIERS, + DELIVERY_CARRIER_LABELS, + deliveryCarrierLabelRu as sharedDeliveryCarrierLabelRu, +} from '@shared/constants/delivery-carrier' export const DELIVERY_CARRIER_CODES = SHARED_DELIVERY_CARRIERS as typeof SHARED_DELIVERY_CARRIERS export type DeliveryCarrierCode = (typeof DELIVERY_CARRIER_CODES)[number] -export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = [ - { code: 'RUSSIAN_POST', label: 'Почта России' }, - { code: 'OZON_PVZ', label: 'Озон доставка (пункт выдачи)' }, - { code: 'YANDEX_PVZ', label: 'Яндекс доставка (пункт выдачи)' }, - { code: 'FIVE_POST', label: '5Post (пункт выдачи)' }, -] - -const carrierLabelMap: Record = Object.fromEntries( - DELIVERY_CARRIER_OPTIONS.map((o) => [o.code, o.label]), -) as Record +export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = + DELIVERY_CARRIER_CODES.map((code) => ({ + code, + label: DELIVERY_CARRIER_LABELS[code], + })) export function deliveryCarrierLabelRu(code: string | null | undefined): string | null { - if (!code) return null - return carrierLabelMap[code as DeliveryCarrierCode] ?? code + return sharedDeliveryCarrierLabelRu(code) } diff --git a/client/src/shared/constants/order.ts b/client/src/shared/constants/order.ts index 670bf6e..92866da 100644 --- a/client/src/shared/constants/order.ts +++ b/client/src/shared/constants/order.ts @@ -1,23 +1,14 @@ -import { ORDER_STATUSES as SHARED_ORDER_STATUSES } from '@shared/constants/order-status' +import { + ORDER_STATUSES as SHARED_ORDER_STATUSES, + getNextAdminStatuses as sharedGetNextAdminStatuses, +} from '@shared/constants/order-status' export const ORDER_STATUSES = SHARED_ORDER_STATUSES as typeof SHARED_ORDER_STATUSES export type OrderStatus = (typeof ORDER_STATUSES)[number] export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] { - switch (status) { - case 'DRAFT': - return ['PENDING_PAYMENT', 'CANCELLED'] - case 'PENDING_PAYMENT': - return ['PAID', 'CANCELLED'] - case 'PAID': - return ['IN_PROGRESS', 'CANCELLED'] - case 'IN_PROGRESS': - if (deliveryType === 'delivery') return ['SHIPPED', 'CANCELLED'] - return ['READY_FOR_PICKUP', 'CANCELLED'] - default: - return [] - } + return sharedGetNextAdminStatuses(status, deliveryType) as OrderStatus[] } export function canTransitionOrderStatus(from: string, to: string): boolean { diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index 5bf0127..5d204db 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -9,7 +9,7 @@ import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { useQuery } from '@tanstack/react-query' import { Link as RouterLink } from 'react-router-dom' -import { fetchLatestApprovedReviews } from '@/entities/product/api/reviews-api' +import { fetchLatestApprovedReviews } from '@/entities/review/api/reviews-api' import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json index b2b639a..b23d30e 100644 --- a/client/tsconfig.app.json +++ b/client/tsconfig.app.json @@ -11,6 +11,7 @@ "module": "esnext", "types": ["vite/client"], "skipLibCheck": true, + "strict": true, /* Bundler mode */ "moduleResolution": "bundler", diff --git a/docs/superpowers/specs/2026-05-19-refactoring-round2-design.md b/docs/superpowers/specs/2026-05-19-refactoring-round2-design.md new file mode 100644 index 0000000..9e07f81 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-refactoring-round2-design.md @@ -0,0 +1,26 @@ +# Refactoring Round 2 — Design + +> 2026-05-19 + +## Phase 1: Toolchain & Configs + +1. Enable `strict: true` in `client/tsconfig.app.json` +2. Add ESLint + Prettier to server (copy rules from client, adapt for JS) +3. Remove dead configs: `server/vitest.config.ts` (duplicate of `.js`) +4. Fix AGENTS.md: `dev` uses `.env` not `.dev_env`; add `db:reset:test` to server +5. Clean .gitignore: remove `.opencode/plans/` reference + +## Phase 2: Deduplication & Separation + +1. Move order status transition logic to `shared/constants/order.js` as shared data +2. Split `entities/product/api/product-api.ts` into public + admin API files +3. Consolidate review API into `entities/review/` +4. Move delivery-carrier labels into `shared/constants/delivery-carrier.js` +5. Add `db:reset:test` script to `server/package.json` + +## Phase 3: Large File Decomposition + +1. `AdminProductsPage.tsx` (596 lines) → `features/product-form/` + table page +2. `AdminOrdersPage.tsx` (398 lines) → `features/order-detail/` + table page +3. `AddressesPage.tsx` (318 lines) → `features/address-form/` + list page +4. `ProductPage.tsx` (304 lines) → extend `widgets/reviews-block/` diff --git a/server/.prettierrc.json b/server/.prettierrc.json new file mode 100644 index 0000000..3e8b14b --- /dev/null +++ b/server/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "semi": false, + "printWidth": 120, + "trailingComma": "all", + "endOfLine": "lf", + "arrowParens": "always" +} diff --git a/server/eslint.config.js b/server/eslint.config.js new file mode 100644 index 0000000..0721280 --- /dev/null +++ b/server/eslint.config.js @@ -0,0 +1,64 @@ +import eslint from '@eslint/js' +import eslintConfigPrettier from 'eslint-config-prettier' +import importX from 'eslint-plugin-import-x' +import eslintPluginPrettier from 'eslint-plugin-prettier' +import globals from 'globals' + +export default [ + { + ignores: ['node_modules/**'], + }, + eslint.configs.recommended, + importX.flatConfigs.recommended, + { + files: ['**/*.{js,mjs,cjs}'], + languageOptions: { + globals: { ...globals.node, ...globals.es2021 }, + }, + rules: { + 'no-console': ['error', { allow: ['warn', 'error', 'info'] }], + quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }], + 'max-len': [ + 'warn', + { + code: 120, + ignoreStrings: true, + ignoreTrailingComments: true, + ignoreTemplateLiterals: true, + ignoreComments: true, + }, + ], + 'import-x/extensions': 'off', + 'import-x/prefer-default-export': 'off', + 'import-x/no-extraneous-dependencies': 'off', + 'import-x/no-cycle': 'warn', + 'import-x/order': [ + 'warn', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], + 'newlines-between': 'never', + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], + 'no-unused-vars': ['error', { args: 'none' }], + 'no-shadow': 'off', + 'consistent-return': 'off', + 'no-use-before-define': 'error', + 'no-empty-function': 'warn', + 'class-methods-use-this': 'warn', + }, + }, + { + plugins: { prettier: eslintPluginPrettier }, + rules: { 'prettier/prettier': ['warn', { endOfLine: 'lf' }] }, + }, + eslintConfigPrettier, + { + files: ['eslint.config.js'], + rules: { + 'import-x/no-unresolved': 'off', + 'import-x/no-named-as-default': 'off', + 'import-x/no-named-as-default-member': 'off', + }, + }, +] diff --git a/server/package-lock.json b/server/package-lock.json index 3899a8a..1186824 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -19,10 +19,28 @@ "sharp": "0.32.6" }, "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.4.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import-x": "^4.16.2", + "eslint-plugin-prettier": "^5.5.5", + "globals": "^17.6.0", + "prettier": "^3.8.3", "prisma": "5.22.0", "vitest": "^3.2.4" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -465,6 +483,134 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -727,6 +873,72 @@ "glob": "^13.0.0" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -743,12 +955,51 @@ "node": ">=8" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@package-json/types": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@package-json/types/-/types-0.0.12.tgz", + "integrity": "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==", + "dev": true, + "license": "MIT" + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@prisma/client": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", @@ -1167,6 +1418,17 @@ "win32" ] }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1185,6 +1447,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1192,6 +1461,312 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.1.tgz", + "integrity": "sha512-diBxYrhKMJWZiQMFDgKVRDV4zSRyRTR6PBg+0p6/7zAWP6fqUfl0Be0RKvjLhzfRT0Ye5TCAP04gg4rZHSTvnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.1.tgz", + "integrity": "sha512-7VQXkWRrq3zFmL1byHilfy8YjCGxf9dKMYbLIGzR6ujAu4+FB3YD8IkesmpgB9vpiitYjMPs/Dk5Sh/P9aoHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.1.tgz", + "integrity": "sha512-SJbHelGnb7hZVLCEWSkbTOpmTC63ZUweZEIPNtRD1D+UkDqYHFynwGUTG1WAjQTdTTaiJ4xab3z5Vk334WeqbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.1.tgz", + "integrity": "sha512-sCCTeB7e2L49YhjPK7IkPfWfCR+NHSfbCbDOy3LqyfkrBpK9qXRRyS1ImCHqEE1LMJxmVN5bAvioI/zTFu48xw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.1.tgz", + "integrity": "sha512-rsKJJykPydB+lA/mdeMSYqsQpdRTAjhJiwdQ+jdihPDpbN1h7PaNAo6Fz8PxqWtKd+YC3uGjjW+m+1iPwRwJuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.1.tgz", + "integrity": "sha512-D6Al5C6j9RdqjGI7Hqa/iVbh09xOEIyZScG60OJGRF0fvf9cy2FdSHG6qLG9Osv8aYe+syWId+PLRwR43soVkA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.1.tgz", + "integrity": "sha512-9+yQ/cnoapQ1G+HS6nXQ+4GZ/qKpieZuZxO8GWGJ+F2/1WC5eRzIU2BYUgT029A/y7n3qb0whuT6vvMzB9Zd0g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.1.tgz", + "integrity": "sha512-OY/REy8lJgrkZgUpiwhClBvSDLSJNxkvqV7il6I1iNBQFyIEZRpOm1ttV8iMjpcPN2Dl7kjGd7CoKoJUebn6Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.1.tgz", + "integrity": "sha512-C0nRwuMNgiGU8M5ym7eFe1qOo4oJtZ4TH6g+qAMWIR0hXgMjMs0bsggIv7Sbeia1GI8ZQHzQwrhBEawFiHQIPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.1.tgz", + "integrity": "sha512-1GrdTqRuLZMsLa9d6T1BM6WTPGMZxkDKLR4SSzWaUtWpBuOVb33DIShXadhDYrTRESEm7pRN8m7SOM2m8pPT8w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.1.tgz", + "integrity": "sha512-q9gc8/37+8jGc8RJahXtonvxgbUisjOHCaiDXrg4Nv8+pk9iKv97drJ61crkZJEms+bIr7lLc54SlZ08qVY9nA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.1.tgz", + "integrity": "sha512-kLFS/MfGFpeYUrnnsUnmZAxwXMPHZOIPHNp3d4zHnx7/etyX2SSQQ1Kj/Ycaxy4V5dN16YoXpnhrwANjywiJCg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.1.tgz", + "integrity": "sha512-vKlW4XOJUrpvMBgbIg97t6UEBsFsxGZS5Khi47XkNzC5T1obPhEYWfaGGv9oAe6xXzXib9xaH64CQV8AXN9GiA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.1.tgz", + "integrity": "sha512-e9gRaBDEraJLdeScpwBA+WqaJDXnmlHPC7aZTAp9N4BYiEs8BvDfjgeqSVygrc3NZbeMfiKygevINZ9QP271wA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.1.tgz", + "integrity": "sha512-Z7813xEacoT+WRBm1O0wgIkXRgVyTctaRPkKx7T+WgeAfGzMfgWCxhRjAAJh/2LMDPlSXOnapr3vwI1TgDEtTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.1.tgz", + "integrity": "sha512-GN5YjvnL5nGd5twW4KHWre6iOzLVsIgZwBin3jTT1Pef2Q3l0WgMYA5uo908wL+gsxSFzFXuxkO+AjpsLoOaYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.1.tgz", + "integrity": "sha512-Gue4obXW5E2223qBWqW05S9m1uPcBIEu8cJWs3YqzVVf+h6lNRofgJlhGNxmuqu+C/fSlqaW4T1JHFZdoOgGGQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.1.tgz", + "integrity": "sha512-z09l7yiDIOLDTFkW+TEroFjidYAM6JriPqMMpXpM7/EnEe6tehrJZrghlvvPyI/W4JGWAJVDaOs4rl+snJlHwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.1.tgz", + "integrity": "sha512-RZ9vu5nw+Lgf91LJIZXFx6OrbId+EN2x0HzpAdm0C9oywiPw5x7LBs4uNboZ2Taozo8SiX/7vEDWWyIpKqktgA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.1.tgz", + "integrity": "sha512-rXHMTryD4YT8wuGDhV8UevKiD02/wUrdKLyokgNQQf/AcO6BCUEkQu5WGQ9i41bA4tlSfKo02WmAcAgxuP6izA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -1313,6 +1888,30 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -1668,6 +2267,16 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/comment-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", + "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1694,6 +2303,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1746,6 +2370,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1858,6 +2489,297 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.2.tgz", + "integrity": "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@package-json/types": "^0.0.12", + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1868,6 +2790,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -1908,12 +2840,26 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stringify": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", @@ -1954,6 +2900,13 @@ "node": ">=20" } }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -2087,6 +3040,19 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/find-my-way": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", @@ -2101,6 +3067,44 @@ "node": ">=20" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -2122,6 +3126,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -2145,6 +3162,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2185,6 +3228,26 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2212,6 +3275,36 @@ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -2219,6 +3312,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -2244,6 +3344,37 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -2281,6 +3412,22 @@ ], "license": "MIT" }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2417,6 +3564,29 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -2468,6 +3638,76 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -2643,6 +3883,46 @@ "node": ">=6" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/prisma": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", @@ -2690,6 +3970,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -2743,6 +4033,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/ret": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", @@ -2940,6 +4240,29 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3029,6 +4352,16 @@ "node": ">= 10.x" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3107,6 +4440,22 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tar-fs": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", @@ -3242,6 +4591,14 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -3254,6 +4611,66 @@ "node": "*" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/unrs-resolver": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.1.tgz", + "integrity": "sha512-LmOTmcBbFqxu1rzubnqHT6EZeqDYpenlGYwyFhHj7oc1HdyZE+0cLQ+s9SDSK+KKQQKuoJhUbzHQ89Ubwg2Oxg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.1", + "@unrs/resolver-binding-android-arm64": "1.12.1", + "@unrs/resolver-binding-darwin-arm64": "1.12.1", + "@unrs/resolver-binding-darwin-x64": "1.12.1", + "@unrs/resolver-binding-freebsd-x64": "1.12.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.1", + "@unrs/resolver-binding-linux-x64-musl": "1.12.1", + "@unrs/resolver-binding-openharmony-arm64": "1.12.1", + "@unrs/resolver-binding-wasm32-wasi": "1.12.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3432,6 +4849,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3449,6 +4882,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3463,6 +4906,19 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/server/package.json b/server/package.json index dfb242e..37080f8 100644 --- a/server/package.json +++ b/server/package.json @@ -9,8 +9,13 @@ "start": "node src/index.js", "db:migrate": "prisma migrate dev", "db:studio": "prisma studio", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --write --ignore-unknown", + "format:check": "prettier . --check --ignore-unknown", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "db:reset:test": "prisma migrate reset --force" }, "dependencies": { "@fastify/cors": "^11.2.0", @@ -24,6 +29,13 @@ "sharp": "0.32.6" }, "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.4.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import-x": "^4.16.2", + "eslint-plugin-prettier": "^5.5.5", + "globals": "^17.6.0", + "prettier": "^3.8.3", "prisma": "5.22.0", "vitest": "^3.2.4" } diff --git a/server/prisma/seed-is-resized.js b/server/prisma/seed-is-resized.js index 2137f4a..936f816 100644 --- a/server/prisma/seed-is-resized.js +++ b/server/prisma/seed-is-resized.js @@ -5,7 +5,7 @@ async function main() { where: { isResized: false }, data: { isResized: true }, }) - console.log(`Marked ${count} existing images as resized`) + console.info(`Marked ${count} existing images as resized`) } main() diff --git a/server/src/index.js b/server/src/index.js index 2ff3dc3..9b57aa3 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1,110 +1,104 @@ -import "dotenv/config"; -import Fastify from "fastify"; -import cors from "@fastify/cors"; -import jwt from "@fastify/jwt"; -import multipart from "@fastify/multipart"; -import fastifyStatic from "@fastify/static"; -import path from "node:path"; -import { ensureAdminUser } from "./lib/bootstrap-admin.js"; -import { getOrCreateUnspecifiedCategory } from "./lib/default-category.js"; -import { - getMaxUploadBodyBytes, - getProductImageMaxFileBytes, -} from "./lib/upload-limits.js"; -import { createEventBus } from "./lib/notifications/event-bus.js"; -import { createNotificationQueue } from "./lib/notifications/queue.js"; -import { prisma } from "./lib/prisma.js"; +import 'dotenv/config' +import path from 'node:path' +import cors from '@fastify/cors' +import jwt from '@fastify/jwt' +import multipart from '@fastify/multipart' +import fastifyStatic from '@fastify/static' +import Fastify from 'fastify' +import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +import { ensureAdminUser } from './lib/bootstrap-admin.js' +import { getOrCreateUnspecifiedCategory } from './lib/default-category.js' +import { createEventBus } from './lib/notifications/event-bus.js' import { resolveUserNotificationTargets, resolveAdminNotificationTargets, resolveAuthCodeTargets, -} from "./lib/notifications/preferences.js"; -import { - NOTIFICATION_EVENTS, - NOTIFICATION_CHANNELS, -} from "../../shared/constants/notification-events.js"; -import { registerAuth } from "./plugins/auth.js"; -import { registerApiRoutes } from "./routes/api.js"; -import { registerAuthRoutes } from "./routes/auth.js"; -import { registerUserAddressRoutes } from "./routes/user-addresses.js"; -import { registerUserCartRoutes } from "./routes/user-cart.js"; -import { registerUserMessageRoutes } from "./routes/user-messages.js"; -import { registerUserOrderRoutes } from "./routes/user-orders.js"; -import { registerUserPaymentRoutes } from "./routes/user-payments.js"; -import { registerUserNotificationRoutes } from "./routes/user/notifications.js"; -import { registerOAuthSocialRoutes } from "./routes/oauth-social.js"; -import { registerUploadsResized } from "./routes/uploads-resized.js"; +} from './lib/notifications/preferences.js' +import { createNotificationQueue } from './lib/notifications/queue.js' +import { prisma } from './lib/prisma.js' +import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' +import { registerAuth } from './plugins/auth.js' +import { registerApiRoutes } from './routes/api.js' +import { registerAuthRoutes } from './routes/auth.js' +import { registerOAuthSocialRoutes } from './routes/oauth-social.js' +import { registerUploadsResized } from './routes/uploads-resized.js' +import { registerUserNotificationRoutes } from './routes/user/notifications.js' +import { registerUserAddressRoutes } from './routes/user-addresses.js' +import { registerUserCartRoutes } from './routes/user-cart.js' +import { registerUserMessageRoutes } from './routes/user-messages.js' +import { registerUserOrderRoutes } from './routes/user-orders.js' +import { registerUserPaymentRoutes } from './routes/user-payments.js' -const port = Number(process.env.PORT) || 3333; -const origin = (process.env.CORS_ORIGIN ?? "") - .split(",") +const port = Number(process.env.PORT) || 3333 +const origin = (process.env.CORS_ORIGIN ?? '') + .split(',') .map((s) => s.trim()) - .filter(Boolean); + .filter(Boolean) const fastify = Fastify({ logger: true, bodyLimit: getMaxUploadBodyBytes(), -}); +}) await fastify.register(cors, { origin: origin.length ? origin : true, credentials: true, -}); +}) await fastify.register(jwt, { - secret: process.env.JWT_SECRET || "dev-jwt-secret-change-me", -}); + secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me', +}) await fastify.register(multipart, { limits: { files: 10, fileSize: getProductImageMaxFileBytes(), }, -}); +}) -registerUploadsResized(fastify); +registerUploadsResized(fastify) -const uploadsDir = path.join(process.cwd(), "uploads"); +const uploadsDir = path.join(process.cwd(), 'uploads') await fastify.register(fastifyStatic, { root: uploadsDir, - prefix: "/uploads/", + prefix: '/uploads/', setHeaders(res, filePath) { - if (filePath.includes("/.cache/")) { - res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + if (filePath.includes('/.cache/')) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') } else { - res.setHeader("Cache-Control", "public, max-age=86400"); + res.setHeader('Cache-Control', 'public, max-age=86400') } }, -}); +}) -fastify.decorate("authenticate", async function authenticate(request, reply) { +fastify.decorate('authenticate', async function authenticate(request, reply) { try { - await request.jwtVerify(); + await request.jwtVerify() } catch { - return reply.code(401).send({ error: "Не авторизован" }); + return reply.code(401).send({ error: 'Не авторизован' }) } -}); +}) -const eventBus = createEventBus(); -const notificationQueue = createNotificationQueue(); -fastify.decorate("eventBus", eventBus); -fastify.decorate("notificationQueue", notificationQueue); +const eventBus = createEventBus() +const notificationQueue = createNotificationQueue() +fastify.decorate('eventBus', eventBus) +fastify.decorate('notificationQueue', notificationQueue) -registerAuth(fastify); -await registerAuthRoutes(fastify); -await registerUserAddressRoutes(fastify); -await registerUserCartRoutes(fastify); -await registerUserMessageRoutes(fastify); -await registerUserOrderRoutes(fastify); -await registerUserPaymentRoutes(fastify); -await registerUserNotificationRoutes(fastify); -await registerOAuthSocialRoutes(fastify); -await registerApiRoutes(fastify); -await ensureAdminUser(); -await getOrCreateUnspecifiedCategory(); +registerAuth(fastify) +await registerAuthRoutes(fastify) +await registerUserAddressRoutes(fastify) +await registerUserCartRoutes(fastify) +await registerUserMessageRoutes(fastify) +await registerUserOrderRoutes(fastify) +await registerUserPaymentRoutes(fastify) +await registerUserNotificationRoutes(fastify) +await registerOAuthSocialRoutes(fastify) +await registerApiRoutes(fastify) +await ensureAdminUser() +await getOrCreateUnspecifiedCategory() -await notificationQueue.flushPendingOnStartup(); -notificationQueue.start(); +await notificationQueue.flushPendingOnStartup() +notificationQueue.start() const { ORDER_CREATED, @@ -114,11 +108,11 @@ const { PAYMENT_STATUS_CHANGED, AUTH_CODE_REQUESTED, DELIVERY_FEE_ADJUSTED, -} = NOTIFICATION_EVENTS; +} = NOTIFICATION_EVENTS async function dispatchNotification(eventType, payload) { if (eventType === AUTH_CODE_REQUESTED) { - const targets = await resolveAuthCodeTargets(eventType, payload); + const targets = await resolveAuthCodeTargets(eventType, payload) for (const target of targets.filter((t) => t.channel === 'telegram')) { const log = await prisma.notificationLog.create({ data: { @@ -127,66 +121,62 @@ async function dispatchNotification(eventType, payload) { status: 'pending', payload: JSON.stringify(payload), }, - }); - notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }); + }) + notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }) } - return; + return } - const userTargets = await resolveUserNotificationTargets(eventType, payload); + const userTargets = await resolveUserNotificationTargets(eventType, payload) for (const target of userTargets) { const log = await prisma.notificationLog.create({ data: { userId: payload.userId, eventType, channel: target.channel, - status: "pending", + status: 'pending', payload: JSON.stringify(payload), }, - }); - notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }); + }) + notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }) } - const adminEventType = - eventType === "order:created:admin" ? ORDER_CREATED : eventType; - const adminTargets = await resolveAdminNotificationTargets( - adminEventType, - payload, - ); + const adminEventType = eventType === 'order:created:admin' ? ORDER_CREATED : eventType + const adminTargets = await resolveAdminNotificationTargets(adminEventType, payload) for (const target of adminTargets) { const log = await prisma.notificationLog.create({ data: { eventType, channel: target.channel, - status: "pending", + status: 'pending', payload: JSON.stringify(payload), }, - }); - notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }); + }) + notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }) } } -eventBus.on(ORDER_CREATED, (payload) => dispatchNotification(ORDER_CREATED, payload)); -eventBus.on(ORDER_STATUS_CHANGED, (payload) => dispatchNotification(ORDER_STATUS_CHANGED, payload)); -eventBus.on(ORDER_MESSAGE_SENT, (payload) => dispatchNotification(ORDER_MESSAGE_SENT, payload)); -eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, (payload) => dispatchNotification(ORDER_MESSAGE_ADMIN_REPLY, payload)); -eventBus.on(PAYMENT_STATUS_CHANGED, (payload) => dispatchNotification(PAYMENT_STATUS_CHANGED, payload)); -eventBus.on(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, payload)); -eventBus.on("order:created:admin", (payload) => dispatchNotification("order:created:admin", payload)); -eventBus.on("review:created", (payload) => dispatchNotification("review:created", payload)); -eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload)); +eventBus.on(ORDER_CREATED, (payload) => dispatchNotification(ORDER_CREATED, payload)) +eventBus.on(ORDER_STATUS_CHANGED, (payload) => dispatchNotification(ORDER_STATUS_CHANGED, payload)) +eventBus.on(ORDER_MESSAGE_SENT, (payload) => dispatchNotification(ORDER_MESSAGE_SENT, payload)) +eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, (payload) => dispatchNotification(ORDER_MESSAGE_ADMIN_REPLY, payload)) +eventBus.on(PAYMENT_STATUS_CHANGED, (payload) => dispatchNotification(PAYMENT_STATUS_CHANGED, payload)) +eventBus.on(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, payload)) +eventBus.on('order:created:admin', (payload) => dispatchNotification('order:created:admin', payload)) +eventBus.on('review:created', (payload) => dispatchNotification('review:created', payload)) +eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload)) async function shutdown() { - notificationQueue.stop(); - await fastify.close(); - process.exit(0); + notificationQueue.stop() + await fastify.close() + process.exit(0) } -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) try { - await fastify.listen({ port, host: "0.0.0.0" }); + await fastify.listen({ port, host: '0.0.0.0' }) } catch (err) { - fastify.log.error(err); - process.exit(1); + fastify.log.error(err) + process.exit(1) } diff --git a/server/src/lib/__tests__/escape-html.test.js b/server/src/lib/__tests__/escape-html.test.js index 64bb441..4e62b3f 100644 --- a/server/src/lib/__tests__/escape-html.test.js +++ b/server/src/lib/__tests__/escape-html.test.js @@ -16,8 +16,6 @@ describe('escapeHtml', () => { }) it('escapes mixed content', () => { - expect(escapeHtml('')).toBe( - '<script>alert("xss")</script>', - ) + expect(escapeHtml('')).toBe('<script>alert("xss")</script>') }) }) diff --git a/server/src/lib/__tests__/image-resize.test.js b/server/src/lib/__tests__/image-resize.test.js index 96d51de..2fc7bad 100644 --- a/server/src/lib/__tests__/image-resize.test.js +++ b/server/src/lib/__tests__/image-resize.test.js @@ -64,7 +64,10 @@ describe('image-resize', () => { expect(result.path).toContain('.cache') expect(result.path).toContain('_w100.avif') - const exists = await fs.promises.access(result.path).then(() => true).catch(() => false) + const exists = await fs.promises + .access(result.path) + .then(() => true) + .catch(() => false) expect(exists).toBe(true) // Verify it's actually AVIF (sharp reports AVIF as 'heif' in metadata) @@ -114,7 +117,10 @@ describe('eager image processing', () => { for (const width of [320, 640, 1024, 1600]) { for (const format of ['avif', 'webp']) { const cachePath = path.join(cacheDir, `${uuid}_w${width}.${format}`) - const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false) + const exists = await fs.promises + .access(cachePath) + .then(() => true) + .catch(() => false) expect(exists).toBe(true) } } @@ -145,10 +151,16 @@ describe('eager image processing', () => { const result = await convertOriginalToWebp(uuid, '') expect(result).toBe(`/uploads/${uuid}.webp`) - const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false) + const pngExists = await fs.promises + .access(testImagePath) + .then(() => true) + .catch(() => false) expect(pngExists).toBe(false) const webpPath = path.join(UPLOADS_DIR, `${uuid}.webp`) - const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false) + const webpExists = await fs.promises + .access(webpPath) + .then(() => true) + .catch(() => false) expect(webpExists).toBe(true) // Cleanup diff --git a/server/src/lib/__tests__/upload-images.test.js b/server/src/lib/__tests__/upload-images.test.js index bfea05c..f0a78da 100644 --- a/server/src/lib/__tests__/upload-images.test.js +++ b/server/src/lib/__tests__/upload-images.test.js @@ -1,6 +1,6 @@ -import { describe, it, expect, afterEach } from 'vitest' import fs from 'node:fs' import path from 'node:path' +import { describe, it, expect, afterEach } from 'vitest' import { persistMultipartImages } from '../upload-images.js' const UPLOADS_DIR = path.join(process.cwd(), 'uploads') @@ -45,5 +45,4 @@ describe('persistMultipartImages with eager=false', () => { expect(urls).toHaveLength(1) expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/) }) - }) diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js index 1191c3f..fcd6996 100644 --- a/server/src/lib/auth.js +++ b/server/src/lib/auth.js @@ -1,9 +1,11 @@ import crypto from 'node:crypto' -import { prisma } from './prisma.js' import { sendLoginCodeEmail } from './email.js' +import { prisma } from './prisma.js' export function normalizeEmail(email) { - return String(email || '').trim().toLowerCase() + return String(email || '') + .trim() + .toLowerCase() } export function randomCode6() { @@ -31,7 +33,9 @@ export async function issueEmailCode({ email, purpose, userId = null }) { } function parseEnvBool(raw) { - const v = String(raw ?? '').trim().toLowerCase() + const v = String(raw ?? '') + .trim() + .toLowerCase() return v === 'true' || v === '1' || v === 'yes' } @@ -68,5 +72,3 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) { }) return true } - - diff --git a/server/src/lib/bootstrap-admin.js b/server/src/lib/bootstrap-admin.js index 0877c6b..e79df0b 100644 --- a/server/src/lib/bootstrap-admin.js +++ b/server/src/lib/bootstrap-admin.js @@ -8,7 +8,7 @@ export async function ensureAdminUser() { throw new Error('ADMIN_EMAIL должен быть валидным email') } - const admin = await prisma.user.upsert({ + await prisma.user.upsert({ where: { email: adminEmail }, update: {}, create: { email: adminEmail }, diff --git a/server/src/lib/email.js b/server/src/lib/email.js index 242091e..60bd976 100644 --- a/server/src/lib/email.js +++ b/server/src/lib/email.js @@ -18,7 +18,7 @@ function createTransporter() { export async function sendLoginCodeEmail({ to, code }) { if (!hasSmtpEnv()) { - console.log(`[DEV] login code for ${to}: ${code}`) + console.info(`[DEV] login code for ${to}: ${code}`) return } @@ -35,7 +35,7 @@ export async function sendLoginCodeEmail({ to, code }) { export async function sendNotificationEmail({ to, subject, html }) { if (!hasSmtpEnv()) { - console.log(`[DEV] notification email to ${to}: ${subject}`) + console.info(`[DEV] notification email to ${to}: ${subject}`) return { success: true } } diff --git a/server/src/lib/image-resize.js b/server/src/lib/image-resize.js index e6378cc..f7fc4ed 100644 --- a/server/src/lib/image-resize.js +++ b/server/src/lib/image-resize.js @@ -1,4 +1,3 @@ -import crypto from 'node:crypto' import fs from 'node:fs' import path from 'node:path' diff --git a/server/src/lib/notifications/__tests__/preferences.test.js b/server/src/lib/notifications/__tests__/preferences.test.js index 6986fe4..34afa03 100644 --- a/server/src/lib/notifications/__tests__/preferences.test.js +++ b/server/src/lib/notifications/__tests__/preferences.test.js @@ -65,7 +65,7 @@ describe('preferences', () => { }) it('returns admin targets when settings enabled', async () => { - const admin = await prisma.user.create({ data: { email: 'admin@test.com' } }) + await prisma.user.create({ data: { email: 'admin@test.com' } }) const origAdminEmail = process.env.ADMIN_EMAIL process.env.ADMIN_EMAIL = 'admin@test.com' diff --git a/server/src/lib/notifications/channels/telegram-channel.js b/server/src/lib/notifications/channels/telegram-channel.js index dafcbe6..4303743 100644 --- a/server/src/lib/notifications/channels/telegram-channel.js +++ b/server/src/lib/notifications/channels/telegram-channel.js @@ -25,7 +25,7 @@ const templateRenderers = { async function postToTelegram(chatId, text) { if (!TELEGRAM_BOT_TOKEN) { - console.log(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`) + console.info(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`) return { success: true } } diff --git a/server/src/lib/notifications/preferences.js b/server/src/lib/notifications/preferences.js index fab4f7c..2bd70ff 100644 --- a/server/src/lib/notifications/preferences.js +++ b/server/src/lib/notifications/preferences.js @@ -1,5 +1,5 @@ -import { prisma } from "../prisma.js"; -import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js"; +import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' +import { prisma } from '../prisma.js' const { ORDER_CREATED, @@ -7,105 +7,99 @@ const { ORDER_MESSAGE_SENT, ORDER_MESSAGE_ADMIN_REPLY, PAYMENT_STATUS_CHANGED, - AUTH_CODE_REQUESTED, DELIVERY_FEE_ADJUSTED, -} = NOTIFICATION_EVENTS; +} = NOTIFICATION_EVENTS const userEventFieldMap = { - [ORDER_CREATED]: "orderCreated", - [ORDER_STATUS_CHANGED]: "orderStatusChanged", - [ORDER_MESSAGE_ADMIN_REPLY]: "orderMessageReceived", - [PAYMENT_STATUS_CHANGED]: "paymentStatusChanged", - [DELIVERY_FEE_ADJUSTED]: "deliveryFeeAdjusted", -}; + [ORDER_CREATED]: 'orderCreated', + [ORDER_STATUS_CHANGED]: 'orderStatusChanged', + [ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived', + [PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged', + [DELIVERY_FEE_ADJUSTED]: 'deliveryFeeAdjusted', +} const adminEventFieldMap = { - [ORDER_MESSAGE_SENT]: "newOrderMessage", - "review:created": "newReview", -}; + [ORDER_MESSAGE_SENT]: 'newOrderMessage', + 'review:created': 'newReview', +} export async function resolveUserNotificationTargets(eventType, payload) { - const targets = []; + const targets = [] if (payload.userId) { const prefs = await prisma.notificationPreference.findUnique({ where: { userId: payload.userId }, - }); + }) if (prefs && prefs.globalEnabled) { - const field = userEventFieldMap[eventType]; + const field = userEventFieldMap[eventType] if (field && prefs[field]) { const user = await prisma.user.findUnique({ where: { id: payload.userId }, select: { email: true }, - }); + }) if (user) { - targets.push({ channel: "email", recipient: user.email }); + targets.push({ channel: 'email', recipient: user.email }) } } } } - return targets; + return targets } export async function resolveAdminNotificationTargets(eventType, payload) { - const targets = []; - const settings = await prisma.adminNotificationSettings.findFirst(); - if (!settings) return targets; + const targets = [] + const settings = await prisma.adminNotificationSettings.findFirst() + if (!settings) return targets - const field = adminEventFieldMap[eventType]; - if (field === "newReview") { - if (!settings.newReview) return targets; + const field = adminEventFieldMap[eventType] + if (field === 'newReview') { + if (!settings.newReview) return targets } else if (field && !settings[field]) { - return targets; + return targets } if (settings.emailEnabled) { const admin = await prisma.user.findFirst({ where: { email: process.env.ADMIN_EMAIL }, select: { email: true }, - }); + }) if (admin) { - targets.push({ channel: "email", recipient: admin.email }); + targets.push({ channel: 'email', recipient: admin.email }) } } if (settings.telegramEnabled && settings.telegramChatId) { - targets.push({ channel: "telegram", recipient: settings.telegramChatId }); + targets.push({ channel: 'telegram', recipient: settings.telegramChatId }) } - return targets; + return targets } export async function resolveAuthCodeTargets(eventType, payload) { - const targets = []; + const targets = [] if (payload.email) { - targets.push({ channel: "email", recipient: payload.email }); + targets.push({ channel: 'email', recipient: payload.email }) } if (payload.isAdmin) { - const settings = await prisma.adminNotificationSettings.findFirst(); - if ( - settings && - settings.telegramEnabled && - settings.telegramChatId && - settings.authCodeDuplicate - ) { - targets.push({ channel: "telegram", recipient: settings.telegramChatId }); + const settings = await prisma.adminNotificationSettings.findFirst() + if (settings && settings.telegramEnabled && settings.telegramChatId && settings.authCodeDuplicate) { + targets.push({ channel: 'telegram', recipient: settings.telegramChatId }) } } - return targets; + return targets } export async function ensureUserNotificationPreference(userId) { const existing = await prisma.notificationPreference.findUnique({ where: { userId }, - }); - if (existing) return existing; + }) + if (existing) return existing return prisma.notificationPreference.create({ data: { userId, globalEnabled: true }, - }); + }) } diff --git a/server/src/lib/notifications/queue.js b/server/src/lib/notifications/queue.js index c8cd011..c8b88f2 100644 --- a/server/src/lib/notifications/queue.js +++ b/server/src/lib/notifications/queue.js @@ -1,5 +1,9 @@ +import { + NOTIFICATION_STATUSES, + MAX_RETRY_ATTEMPTS, + RETRY_DELAYS_MS, +} from '../../../../shared/constants/notification-events.js' import { prisma } from '../prisma.js' -import { NOTIFICATION_STATUSES, MAX_RETRY_ATTEMPTS, RETRY_DELAYS_MS } from '../../../../shared/constants/notification-events.js' import { emailChannel } from './channels/email-channel.js' import { telegramChannel } from './channels/telegram-channel.js' @@ -120,7 +124,7 @@ class NotificationQueue { }) } if (pending.length > 0) { - console.log(`[notifications] Marked ${pending.length} pending notifications as failed on startup`) + console.info(`[notifications] Marked ${pending.length} pending notifications as failed on startup`) } } } diff --git a/server/src/lib/notifications/templates/email-templates.js b/server/src/lib/notifications/templates/email-templates.js index de66674..36d6722 100644 --- a/server/src/lib/notifications/templates/email-templates.js +++ b/server/src/lib/notifications/templates/email-templates.js @@ -11,132 +11,113 @@ function baseLayout(title, body) {

Любимый Креатив — магазин handmade изделий

-`; +` } export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount, deliveryType }) { - const total = (totalCents / 100).toLocaleString("ru-RU"); - const nextAction = deliveryType === "delivery" - ? "Оплата будет доступна после уточнения стоимости доставки." - : "Ожидает оплаты."; + const total = (totalCents / 100).toLocaleString('ru-RU') + const nextAction = + deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.' const body = `

Ваш заказ #${orderId.slice(0, 8)} успешно создан.

Товаров: ${itemsCount} | Сумма: ${total} ₽

${nextAction}

- `; - return { subject: "Заказ создан", html: baseLayout("Заказ создан", body) }; + ` + return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) } } -export function renderOrderStatusChangedEmail({ - orderId, - oldStatus, - newStatus, -}) { +export function renderOrderStatusChangedEmail({ orderId, oldStatus, newStatus }) { const statusLabels = { - DRAFT: "Черновик", - PENDING_PAYMENT: "Ожидает оплаты", - PAID: "Оплачен", - IN_PROGRESS: "В работе", - READY_FOR_PICKUP: "Готов к выдаче", - SHIPPED: "Отправлен", - DONE: "Выполнен", - CANCELLED: "Отменён", - }; - const oldLabel = statusLabels[oldStatus] || oldStatus; - const newLabel = statusLabels[newStatus] || newStatus; + DRAFT: 'Черновик', + PENDING_PAYMENT: 'Ожидает оплаты', + PAID: 'Оплачен', + IN_PROGRESS: 'В работе', + READY_FOR_PICKUP: 'Готов к выдаче', + SHIPPED: 'Отправлен', + DONE: 'Выполнен', + CANCELLED: 'Отменён', + } + const oldLabel = statusLabels[oldStatus] || oldStatus + const newLabel = statusLabels[newStatus] || newStatus const body = `

Статус заказа #${orderId.slice(0, 8)} изменён.

${oldLabel}${newLabel}

- `; + ` return { subject: `Статус заказа изменён — ${newLabel}`, - html: baseLayout("Статус заказа изменён", body), - }; + html: baseLayout('Статус заказа изменён', body), + } } export function renderOrderMessageEmail({ orderId, preview }) { - const truncated = - preview.length > 200 ? preview.slice(0, 197) + "..." : preview; + const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview const body = `

Новое сообщение к заказу #${orderId.slice(0, 8)}:

${truncated}

Ответьте в личном кабинете.

- `; + ` return { - subject: "Новое сообщение к заказу", - html: baseLayout("Новое сообщение", body), - }; + subject: 'Новое сообщение к заказу', + html: baseLayout('Новое сообщение', body), + } } export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) { const statusLabels = { - pending: "Ожидает", - confirmed: "Подтверждён", - rejected: "Отклонён", - }; - const label = statusLabels[paymentStatus] || paymentStatus; + pending: 'Ожидает', + confirmed: 'Подтверждён', + rejected: 'Отклонён', + } + const label = statusLabels[paymentStatus] || paymentStatus const body = `

Статус оплаты заказа #${orderId.slice(0, 8)}: ${label}.

- `; + ` return { subject: `Оплата заказа — ${label}`, - html: baseLayout("Оплата заказа", body), - }; + html: baseLayout('Оплата заказа', body), + } } -export function renderAdminOrderCreatedEmail({ - orderId, - userEmail, - totalCents, - itemsCount, - deliveryType, -}) { - const total = (totalCents / 100).toLocaleString("ru-RU"); - const note = deliveryType === "delivery" - ? '

⚠️ Скорректируйте стоимость доставки в админ-панели.

' - : ""; +export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount, deliveryType }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + const note = deliveryType === 'delivery' ? '

⚠️ Скорректируйте стоимость доставки в админ-панели.

' : '' const body = `

Новый заказ #${orderId.slice(0, 8)} от ${userEmail}.

Товаров: ${itemsCount} | Сумма: ${total} ₽

${note} - `; - return { subject: "Новый заказ", html: baseLayout("Новый заказ", body) }; + ` + return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) } } -export function renderAdminNewReviewEmail({ - rating, - text, - productTitle, - userName, -}) { - const stars = "★".repeat(rating) + "☆".repeat(5 - rating); +export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) { + const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating) const body = `

Новый отзыв ${stars} на товар ${productTitle} от ${userName}.

- ${text ? `
${text}
` : ""} + ${text ? `
${text}
` : ''}

Проверьте отзыв в админ-панели.

- `; - return { subject: "Новый отзыв", html: baseLayout("Новый отзыв", body) }; + ` + return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) } } export function renderAuthCodeEmail({ code }) { const body = `

Ваш код входа: ${code}

Если это были не вы — просто проигнорируйте письмо.

- `; - return { subject: "Код входа", html: baseLayout("Код входа", body) }; + ` + return { subject: 'Код входа', html: baseLayout('Код входа', body) } } export function renderDeliveryFeeAdjustedEmail({ orderId, totalCents }) { - const total = (totalCents / 100).toLocaleString("ru-RU"); + const total = (totalCents / 100).toLocaleString('ru-RU') const body = `

Стоимость доставки заказа #${orderId.slice(0, 8)} скорректирована.

Новая сумма: ${total} ₽

Ожидает оплаты. Проверьте статус заказа в личном кабинете.

- `; + ` return { - subject: "Стоимость доставки скорректирована", - html: baseLayout("Стоимость доставки скорректирована", body), - }; + subject: 'Стоимость доставки скорректирована', + html: baseLayout('Стоимость доставки скорректирована', body), + } } diff --git a/server/src/lib/notifications/templates/telegram-templates.js b/server/src/lib/notifications/templates/telegram-templates.js index 495c901..770efb5 100644 --- a/server/src/lib/notifications/templates/telegram-templates.js +++ b/server/src/lib/notifications/templates/telegram-templates.js @@ -1,15 +1,20 @@ export function renderOrderCreatedTg({ orderId, totalCents, itemsCount, deliveryType }) { const total = (totalCents / 100).toLocaleString('ru-RU') - const nextAction = deliveryType === 'delivery' - ? 'Оплата будет доступна после уточнения стоимости доставки.' - : 'Ожидает оплаты.' + const nextAction = + deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.' return `📦 Новый заказ #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total} ₽\n${nextAction}` } export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) { const labels = { - DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', PAID: 'Оплачен', IN_PROGRESS: 'В работе', - READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён', + DRAFT: 'Черновик', + PENDING_PAYMENT: 'Ожидает оплаты', + PAID: 'Оплачен', + IN_PROGRESS: 'В работе', + READY_FOR_PICKUP: 'Готов к выдаче', + SHIPPED: 'Отправлен', + DONE: 'Выполнен', + CANCELLED: 'Отменён', } return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → ${labels[newStatus] || newStatus}` } diff --git a/server/src/lib/order-status.js b/server/src/lib/order-status.js index 474f518..31a7de6 100644 --- a/server/src/lib/order-status.js +++ b/server/src/lib/order-status.js @@ -1,37 +1,5 @@ -export { ORDER_STATUSES } from '../../../shared/constants/order-status.js' - -/** - * Переходы, которые делает админ через PATCH /api/admin/orders/:id/status - * (подтверждение получения пользователем — отдельный эндпоинт). - */ -export function canTransitionAdminOrderStatus(order, next) { - const from = order.status - const dt = order.deliveryType - if (from === next) return true - - switch (from) { - case 'DRAFT': - return next === 'PENDING_PAYMENT' || next === 'CANCELLED' - case 'PENDING_PAYMENT': - return next === 'PAID' || next === 'CANCELLED' - case 'PAID': - return next === 'IN_PROGRESS' || next === 'CANCELLED' - case 'IN_PROGRESS': - if (next === 'CANCELLED') return true - if (dt === 'delivery') return next === 'SHIPPED' - if (dt === 'pickup') return next === 'READY_FOR_PICKUP' - return false - case 'SHIPPED': - case 'READY_FOR_PICKUP': - case 'DONE': - case 'CANCELLED': - return false - default: - return false - } -} - -/** @deprecated используйте canTransitionAdminOrderStatus */ -export function canTransitionOrderStatus(from, to) { - return canTransitionAdminOrderStatus({ status: from, deliveryType: 'delivery' }, to) -} +export { + ORDER_STATUSES, + getNextAdminStatuses, + canTransitionAdminOrderStatus, +} from '../../../shared/constants/order-status.js' diff --git a/server/src/plugins/auth.js b/server/src/plugins/auth.js index 3ae1be8..7c746d4 100644 --- a/server/src/plugins/auth.js +++ b/server/src/plugins/auth.js @@ -1,6 +1,8 @@ export function registerAuth(fastify) { function normalizeEmail(email) { - return String(email || '').trim().toLowerCase() + return String(email || '') + .trim() + .toLowerCase() } fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) { diff --git a/server/src/routes/api.js b/server/src/routes/api.js index ebeac37..07fd2f0 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -1,16 +1,12 @@ -import { - mapProductForApi, - parseMaterialsInput, - slugify, -} from './api/_product-helpers.js' -import { registerAdminGalleryRoutes } from './api/admin-gallery.js' +import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js' +import { registerAdminNotificationRoutes } from './api/admin/notifications.js' import { registerAdminCategoryRoutes } from './api/admin-categories.js' -import { registerCatalogSliderRoutes } from './api/catalog-slider.js' +import { registerAdminGalleryRoutes } from './api/admin-gallery.js' import { registerAdminOrderRoutes } from './api/admin-orders.js' import { registerAdminProductRoutes } from './api/admin-products.js' import { registerAdminReviewRoutes } from './api/admin-reviews.js' import { registerAdminUserRoutes } from './api/admin-users.js' -import { registerAdminNotificationRoutes } from './api/admin/notifications.js' +import { registerCatalogSliderRoutes } from './api/catalog-slider.js' import { registerInfoPageRoutes } from './api/info-page.js' import { registerPublicCatalogRoutes } from './api/public-catalog.js' import { registerPublicReviewRoutes } from './api/public-reviews.js' @@ -33,4 +29,3 @@ export async function registerApiRoutes(fastify) { await registerAdminUserRoutes(fastify) await registerAdminNotificationRoutes(fastify) } - diff --git a/server/src/routes/api/__tests__/admin-gallery.test.js b/server/src/routes/api/__tests__/admin-gallery.test.js index 27c0acd..5cce035 100644 --- a/server/src/routes/api/__tests__/admin-gallery.test.js +++ b/server/src/routes/api/__tests__/admin-gallery.test.js @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' import fs from 'node:fs' import path from 'node:path' +import { describe, it, expect, beforeAll, afterAll } from 'vitest' const UPLOADS_DIR = path.join(process.cwd(), 'uploads') @@ -36,7 +36,10 @@ describe('Admin gallery resize integration', () => { expect(newUrl).toBe(`/uploads/${testUuid}.webp`) // Verify original PNG is deleted - const pngExists = await fs.promises.access(testOriginalPath).then(() => true).catch(() => false) + const pngExists = await fs.promises + .access(testOriginalPath) + .then(() => true) + .catch(() => false) expect(pngExists).toBe(false) // Verify cached files exist @@ -44,13 +47,19 @@ describe('Admin gallery resize integration', () => { for (const width of [320, 640, 1024, 1600]) { for (const format of ['avif', 'webp']) { const cachePath = path.join(cacheDir, `${testUuid}_w${width}.${format}`) - const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false) + const exists = await fs.promises + .access(cachePath) + .then(() => true) + .catch(() => false) expect(exists).toBe(true) } } // Verify webp original exists - const webpExists = await fs.promises.access(path.join(UPLOADS_DIR, `${testUuid}.webp`)).then(() => true).catch(() => false) + const webpExists = await fs.promises + .access(path.join(UPLOADS_DIR, `${testUuid}.webp`)) + .then(() => true) + .catch(() => false) expect(webpExists).toBe(true) }) }) diff --git a/server/src/routes/api/_product-helpers.js b/server/src/routes/api/_product-helpers.js index 1e4c81f..3d9d5e6 100644 --- a/server/src/routes/api/_product-helpers.js +++ b/server/src/routes/api/_product-helpers.js @@ -53,4 +53,3 @@ export function mapProductForApi(p, reviewsSummary = null) { } return base } - diff --git a/server/src/routes/api/admin-categories.js b/server/src/routes/api/admin-categories.js index 32b2270..f252cbd 100644 --- a/server/src/routes/api/admin-categories.js +++ b/server/src/routes/api/admin-categories.js @@ -6,132 +6,116 @@ import { import { prisma } from '../../lib/prisma.js' export async function registerAdminCategoryRoutes(fastify) { - fastify.get( - '/api/admin/categories', - { preHandler: [fastify.verifyAdmin] }, - async () => { - const items = await prisma.category.findMany({ - orderBy: [{ sort: 'asc' }, { name: 'asc' }], - }) - return { items } - }, - ) + fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async () => { + const items = await prisma.category.findMany({ + orderBy: [{ sort: 'asc' }, { name: 'asc' }], + }) + return { items } + }) - fastify.post( - '/api/admin/categories', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const body = request.body ?? {} - const name = String(body.name ?? '').trim() - if (!name) { - reply.code(400).send({ error: 'Укажите название категории' }) - return - } - const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}` - if (isUnspecifiedCategorySlug(slug)) { - reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) - return - } - const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined - const exists = await prisma.category.findUnique({ where: { slug } }) - if (exists) { - reply.code(409).send({ error: 'Такой slug уже занят' }) - return - } - const category = await prisma.category.create({ - data: { - name, - slug, - sort: Number.isFinite(sort) ? Math.round(sort) : 0, - }, - }) - reply.code(201).send(category) - }, - ) + fastify.post('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const body = request.body ?? {} + const name = String(body.name ?? '').trim() + if (!name) { + reply.code(400).send({ error: 'Укажите название категории' }) + return + } + const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}` + if (isUnspecifiedCategorySlug(slug)) { + reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + return + } + const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined + const exists = await prisma.category.findUnique({ where: { slug } }) + if (exists) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + const category = await prisma.category.create({ + data: { + name, + slug, + sort: Number.isFinite(sort) ? Math.round(sort) : 0, + }, + }) + reply.code(201).send(category) + }) - fastify.patch( - '/api/admin/categories/:id', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params - const body = request.body ?? {} - const existing = await prisma.category.findUnique({ where: { id } }) - if (!existing) { - reply.code(404).send({ error: 'Категория не найдена' }) + fastify.patch('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const body = request.body ?? {} + const existing = await prisma.category.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Категория не найдена' }) + return + } + + const data = {} + if (body.name !== undefined) data.name = String(body.name ?? '').trim() + if (body.sort !== undefined) { + const s = Number(body.sort) + if (!Number.isFinite(s)) { + reply.code(400).send({ error: 'Некорректный sort' }) return } - - const data = {} - if (body.name !== undefined) data.name = String(body.name ?? '').trim() - if (body.sort !== undefined) { - const s = Number(body.sort) - if (!Number.isFinite(s)) { - reply.code(400).send({ error: 'Некорректный sort' }) + data.sort = Math.round(s) + } + if (body.slug !== undefined) { + const s = String(body.slug ?? '').trim() + if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) { + reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' }) + return + } + if (!s) { + reply.code(400).send({ error: 'Slug не может быть пустым' }) + return + } + if (s !== existing.slug) { + if (isUnspecifiedCategorySlug(s)) { + reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) return } - data.sort = Math.round(s) - } - if (body.slug !== undefined) { - const s = String(body.slug ?? '').trim() - if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) { - reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' }) + const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } }) + if (clash) { + reply.code(409).send({ error: 'Такой slug уже занят' }) return } - if (!s) { - reply.code(400).send({ error: 'Slug не может быть пустым' }) - return - } - if (s !== existing.slug) { - if (isUnspecifiedCategorySlug(s)) { - reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) - return - } - const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } }) - if (clash) { - reply.code(409).send({ error: 'Такой slug уже занят' }) - return - } - } - data.slug = s } + data.slug = s + } - if (Object.keys(data).length === 0) { - return existing - } - if (data.name !== undefined && !data.name) { - reply.code(400).send({ error: 'Укажите название' }) - return - } + if (Object.keys(data).length === 0) { + return existing + } + if (data.name !== undefined && !data.name) { + reply.code(400).send({ error: 'Укажите название' }) + return + } - const updated = await prisma.category.update({ where: { id }, data }) - return updated - }, - ) + const updated = await prisma.category.update({ where: { id }, data }) + return updated + }) - fastify.delete( - '/api/admin/categories/:id', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params - const existing = await prisma.category.findUnique({ where: { id } }) - if (!existing) { - reply.code(404).send({ error: 'Категория не найдена' }) - return - } - if (isUnspecifiedCategorySlug(existing.slug)) { - reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' }) - return - } + fastify.delete('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const existing = await prisma.category.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Категория не найдена' }) + return + } + if (isUnspecifiedCategorySlug(existing.slug)) { + reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' }) + return + } - const fallback = await getOrCreateUnspecifiedCategory() - await prisma.$transaction([ - prisma.product.updateMany({ - where: { categoryId: id }, - data: { categoryId: fallback.id }, - }), - prisma.category.delete({ where: { id } }), - ]) - return reply.code(204).send() - }, - ) + const fallback = await getOrCreateUnspecifiedCategory() + await prisma.$transaction([ + prisma.product.updateMany({ + where: { categoryId: id }, + data: { categoryId: fallback.id }, + }), + prisma.category.delete({ where: { id } }), + ]) + return reply.code(204).send() + }) } diff --git a/server/src/routes/api/admin-gallery.js b/server/src/routes/api/admin-gallery.js index 8f02b23..fe12e1b 100644 --- a/server/src/routes/api/admin-gallery.js +++ b/server/src/routes/api/admin-gallery.js @@ -9,114 +9,98 @@ import { } from '../../lib/upload-limits.js' export async function registerAdminGalleryRoutes(fastify) { - fastify.get( - '/api/admin/gallery', - { preHandler: [fastify.verifyAdmin] }, - async () => { - const items = await prisma.galleryImage.findMany({ - orderBy: { createdAt: 'desc' }, + fastify.get('/api/admin/gallery', { preHandler: [fastify.verifyAdmin] }, async () => { + const items = await prisma.galleryImage.findMany({ + orderBy: { createdAt: 'desc' }, + }) + return { items } + }) + + fastify.post('/api/admin/gallery/upload', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + try { + const urls = await persistMultipartImages(request, { + maxFiles: 10, + maxFileBytes: getProductImageMaxFileBytes(), + subdir: '', + eager: false, }) - return { items } - }, - ) - - fastify.post( - '/api/admin/gallery/upload', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - try { - const urls = await persistMultipartImages(request, { - maxFiles: 10, - maxFileBytes: getProductImageMaxFileBytes(), - subdir: '', - eager: false, + for (const url of urls) { + await prisma.galleryImage.create({ + data: { url, isResized: false }, }) - for (const url of urls) { - await prisma.galleryImage.create({ - data: { url, isResized: false }, - }) - } - return { urls } - } catch (error) { - let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' - let statusCode = - error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) - ? Number(error.statusCode) - : 400 - if (isMultipartFileTooLargeError(error)) { - message = formatFileTooLargeMessage(getProductImageMaxFileBytes()) - statusCode = 413 - } - return reply.code(statusCode).send({ error: message }) } - }, - ) - - fastify.post( - '/api/admin/gallery/:id/resize', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params - const row = await prisma.galleryImage.findUnique({ where: { id } }) - if (!row) { - return reply.code(404).send({ error: 'Изображение не найдено' }) + return { urls } + } catch (error) { + let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' + let statusCode = + error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) + ? Number(error.statusCode) + : 400 + if (isMultipartFileTooLargeError(error)) { + message = formatFileTooLargeMessage(getProductImageMaxFileBytes()) + statusCode = 413 } - if (row.isResized) { - return reply.code(409).send({ error: 'Изображение уже обработано' }) + return reply.code(statusCode).send({ error: message }) + } + }) + + fastify.post('/api/admin/gallery/:id/resize', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const row = await prisma.galleryImage.findUnique({ where: { id } }) + if (!row) { + return reply.code(404).send({ error: 'Изображение не найдено' }) + } + if (row.isResized) { + return reply.code(409).send({ error: 'Изображение уже обработано' }) + } + + const urlParts = row.url.replace(/^\//, '').split('/') + const fileName = urlParts[urlParts.length - 1] + const uuid = path.parse(fileName).name + + try { + const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js') + + const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName) + await generateAllSizes(uuid, '', fullPath) + const newUrl = await convertOriginalToWebp(uuid, '') + + await prisma.galleryImage.update({ + where: { id }, + data: { url: newUrl, isResized: true }, + }) + + return { url: newUrl } + } catch (error) { + request.log.error(error, 'Resize failed') + return reply.code(500).send({ error: 'Ошибка обработки изображения' }) + } + }) + + fastify.delete('/api/admin/gallery/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const row = await prisma.galleryImage.findUnique({ where: { id } }) + if (!row) { + return reply.code(404).send({ error: 'Не найдено' }) + } + + const usedInImages = await prisma.productImage.count({ where: { url: row.url } }) + const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } }) + if (usedInImages > 0 || usedAsLegacy > 0) { + return reply.code(409).send({ error: 'Изображение используется в карточке товара' }) + } + + const relative = row.url.replace(/^\//, '') + const filePath = path.join(process.cwd(), relative) + try { + await fs.unlink(filePath) + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') { + throw err } + } - const urlParts = row.url.replace(/^\//, '').split('/') - const fileName = urlParts[urlParts.length - 1] - const uuid = path.parse(fileName).name - - try { - const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js') - - const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName) - await generateAllSizes(uuid, '', fullPath) - const newUrl = await convertOriginalToWebp(uuid, '') - - await prisma.galleryImage.update({ - where: { id }, - data: { url: newUrl, isResized: true }, - }) - - return { url: newUrl } - } catch (error) { - request.log.error(error, 'Resize failed') - return reply.code(500).send({ error: 'Ошибка обработки изображения' }) - } - }, - ) - - fastify.delete( - '/api/admin/gallery/:id', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params - const row = await prisma.galleryImage.findUnique({ where: { id } }) - if (!row) { - return reply.code(404).send({ error: 'Не найдено' }) - } - - const usedInImages = await prisma.productImage.count({ where: { url: row.url } }) - const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } }) - if (usedInImages > 0 || usedAsLegacy > 0) { - return reply.code(409).send({ error: 'Изображение используется в карточке товара' }) - } - - const relative = row.url.replace(/^\//, '') - const filePath = path.join(process.cwd(), relative) - try { - await fs.unlink(filePath) - } catch (err) { - if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') { - throw err - } - } - - await prisma.galleryImage.delete({ where: { id } }) - return reply.code(204).send() - }, - ) + await prisma.galleryImage.delete({ where: { id } }) + return reply.code(204).send() + }) } diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index 06262a1..0e01e5e 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -1,236 +1,172 @@ -import { prisma } from "../../lib/prisma.js"; -import { canTransitionAdminOrderStatus } from "../../lib/order-status.js"; -import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js"; +import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' +import { canTransitionAdminOrderStatus } from '../../lib/order-status.js' +import { prisma } from '../../lib/prisma.js' export async function registerAdminOrderRoutes(fastify) { - fastify.get( - "/api/admin/orders/summary", - { preHandler: [fastify.verifyAdmin] }, - async () => { - const attentionCount = await prisma.order.count({ - where: { - status: "PENDING_PAYMENT", - }, - }); - return { attentionCount }; - }, - ); + fastify.get('/api/admin/orders/summary', { preHandler: [fastify.verifyAdmin] }, async () => { + const attentionCount = await prisma.order.count({ + where: { + status: 'PENDING_PAYMENT', + }, + }) + return { attentionCount } + }) - fastify.get( - "/api/admin/orders", - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const status = - typeof request.query?.status === "string" - ? request.query.status.trim() - : ""; - const q = - typeof request.query?.q === "string" ? request.query.q.trim() : ""; - const deliveryTypeRaw = request.query?.deliveryType; - const deliveryType = - typeof deliveryTypeRaw === "string" ? deliveryTypeRaw.trim() : ""; + fastify.get('/api/admin/orders', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const status = typeof request.query?.status === 'string' ? request.query.status.trim() : '' + const q = typeof request.query?.q === 'string' ? request.query.q.trim() : '' + const deliveryTypeRaw = request.query?.deliveryType + const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.trim() : '' - const pageRaw = request.query?.page; - const pageParsed = - typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw); - const page = - Number.isFinite(pageParsed) && pageParsed > 0 - ? Math.floor(pageParsed) - : 1; + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 - const pageSizeRaw = request.query?.pageSize; - const pageSizeParsed = - typeof pageSizeRaw === "string" - ? Number(pageSizeRaw) - : Number(pageSizeRaw); - const pageSize = - Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 - ? Math.floor(pageSizeParsed) - : 20; - if (pageSize > 100) - return reply.code(400).send({ error: "pageSize должен быть ≤ 100" }); + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 + if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) - const where = {}; - if (status) where.status = status; - if (deliveryType) { - if (deliveryType !== "delivery" && deliveryType !== "pickup") { - return reply - .code(400) - .send({ error: "deliveryType должен быть delivery | pickup" }); - } - where.deliveryType = deliveryType; - } - if (q) { - where.OR = [ - { id: { contains: q } }, - { user: { email: { contains: q } } }, - ]; + const where = {} + if (status) where.status = status + if (deliveryType) { + if (deliveryType !== 'delivery' && deliveryType !== 'pickup') { + return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' }) } + where.deliveryType = deliveryType + } + if (q) { + where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }] + } - const total = await prisma.order.count({ where }); - const items = await prisma.order.findMany({ - where, - include: { user: { select: { id: true, email: true } }, items: true }, - orderBy: { createdAt: "desc" }, - skip: (page - 1) * pageSize, - take: pageSize, - }); + const total = await prisma.order.count({ where }) + const items = await prisma.order.findMany({ + where, + include: { user: { select: { id: true, email: true } }, items: true }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) - return { - items: items.map((o) => ({ - id: o.id, - status: o.status, - deliveryType: o.deliveryType, - deliveryCarrier: o.deliveryCarrier, - paymentMethod: o.paymentMethod, - totalCents: o.totalCents, - currency: o.currency, - createdAt: o.createdAt, - updatedAt: o.updatedAt, - user: o.user, - itemsCount: o.items.reduce((s, i) => s + i.qty, 0), - })), - total, - page, - pageSize, - }; - }, - ); + return { + items: items.map((o) => ({ + id: o.id, + status: o.status, + deliveryType: o.deliveryType, + deliveryCarrier: o.deliveryCarrier, + paymentMethod: o.paymentMethod, + totalCents: o.totalCents, + currency: o.currency, + createdAt: o.createdAt, + updatedAt: o.updatedAt, + user: o.user, + itemsCount: o.items.reduce((s, i) => s + i.qty, 0), + })), + total, + page, + pageSize, + } + }) - fastify.get( - "/api/admin/orders/:id", - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params; - const order = await prisma.order.findUnique({ - where: { id }, - include: { - user: { select: { id: true, email: true, name: true, phone: true } }, - items: true, - messages: { orderBy: { createdAt: "asc" } }, - }, - }); - if (!order) return reply.code(404).send({ error: "Заказ не найден" }); - return { item: order }; - }, - ); + fastify.get('/api/admin/orders/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const order = await prisma.order.findUnique({ + where: { id }, + include: { + user: { select: { id: true, email: true, name: true, phone: true } }, + items: true, + messages: { orderBy: { createdAt: 'asc' } }, + }, + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + return { item: order } + }) - fastify.patch( - "/api/admin/orders/:id/status", - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params; - const next = String(request.body?.status || "").trim(); - if (!next) return reply.code(400).send({ error: "status обязателен" }); + fastify.patch('/api/admin/orders/:id/status', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const next = String(request.body?.status || '').trim() + if (!next) return reply.code(400).send({ error: 'status обязателен' }) - const existing = await prisma.order.findUnique({ where: { id } }); - if (!existing) return reply.code(404).send({ error: "Заказ не найден" }); - if (!canTransitionAdminOrderStatus(existing, next)) { - return reply - .code(409) - .send({ - error: `Нельзя сменить статус ${existing.status} → ${next}`, - }); - } + const existing = await prisma.order.findUnique({ where: { id } }) + if (!existing) return reply.code(404).send({ error: 'Заказ не найден' }) + if (!canTransitionAdminOrderStatus(existing, next)) { + return reply.code(409).send({ + error: `Нельзя сменить статус ${existing.status} → ${next}`, + }) + } - const updated = await prisma.order.update({ - where: { id }, - data: { status: next }, - }); + const updated = await prisma.order.update({ + where: { id }, + data: { status: next }, + }) - request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, { - orderId: updated.id, - userId: existing.userId, - oldStatus: existing.status, - newStatus: next, - }); + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, { + orderId: updated.id, + userId: existing.userId, + oldStatus: existing.status, + newStatus: next, + }) - return { item: updated }; - }, - ); + return { item: updated } + }) - fastify.patch( - "/api/admin/orders/:id/delivery-fee", - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params; - const feeRaw = request.body?.deliveryFeeCents; - const parsed = - typeof feeRaw === "string" - ? Number.parseInt(feeRaw, 10) - : typeof feeRaw === "number" - ? feeRaw - : NaN; - if (!Number.isInteger(parsed) || parsed < 0) { - return reply - .code(400) - .send({ - error: "deliveryFeeCents должно быть целым числом ≥ 0 (копейки)", - }); - } + fastify.patch('/api/admin/orders/:id/delivery-fee', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const feeRaw = request.body?.deliveryFeeCents + const parsed = typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN + if (!Number.isInteger(parsed) || parsed < 0) { + return reply.code(400).send({ + error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)', + }) + } - const existing = await prisma.order.findUnique({ where: { id } }); - if (!existing) return reply.code(404).send({ error: "Заказ не найден" }); - if ( - existing.status !== "PENDING_PAYMENT" || - existing.deliveryFeeLocked !== false - ) { - return reply - .code(409) - .send({ - error: - "Корректировка доставки доступна только пока стоимость не утверждена", - }); - } + const existing = await prisma.order.findUnique({ where: { id } }) + if (!existing) return reply.code(404).send({ error: 'Заказ не найден' }) + if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) { + return reply.code(409).send({ + error: 'Корректировка доставки доступна только пока стоимость не утверждена', + }) + } - const totalCents = existing.itemsSubtotalCents + parsed; - const updated = await prisma.order.update({ - where: { id }, - data: { - deliveryFeeCents: parsed, - totalCents, - deliveryFeeLocked: true, - }, - }); + const totalCents = existing.itemsSubtotalCents + parsed + const updated = await prisma.order.update({ + where: { id }, + data: { + deliveryFeeCents: parsed, + totalCents, + deliveryFeeLocked: true, + }, + }) - request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, { - orderId: updated.id, - userId: existing.userId, - totalCents: updated.totalCents, - }); + request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, { + orderId: updated.id, + userId: existing.userId, + totalCents: updated.totalCents, + }) - return { item: updated }; - }, - ); + return { item: updated } + }) - fastify.post( - "/api/admin/orders/:id/messages", - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params; - const text = String(request.body?.text || "").trim(); - if (!text) return reply.code(400).send({ error: "Сообщение пустое" }); - if (text.length > 2000) - return reply.code(400).send({ error: "Сообщение слишком длинное" }); + fastify.post('/api/admin/orders/:id/messages', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const text = String(request.body?.text || '').trim() + if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) + if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) - const order = await prisma.order.findUnique({ where: { id } }); - if (!order) return reply.code(404).send({ error: "Заказ не найден" }); + const order = await prisma.order.findUnique({ where: { id } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - const msg = await prisma.orderMessage.create({ - data: { orderId: id, authorType: "admin", text }, - }); + const msg = await prisma.orderMessage.create({ + data: { orderId: id, authorType: 'admin', text }, + }) - request.server.eventBus.emit( - NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, - { - orderId: id, - userId: order.userId, - messageId: msg.id, - preview: text, - }, - ); + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, { + orderId: id, + userId: order.userId, + messageId: msg.id, + preview: text, + }) - return reply.code(201).send({ item: msg }); - }, - ); + return reply.code(201).send({ item: msg }) + }) } diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js index 2294660..8ac1893 100644 --- a/server/src/routes/api/admin-products.js +++ b/server/src/routes/api/admin-products.js @@ -40,17 +40,13 @@ const PATCH_PRODUCT_SCHEMA = { } export async function registerAdminProductRoutes(fastify) { - fastify.get( - '/api/admin/products', - { preHandler: [fastify.verifyAdmin] }, - async (request) => { - const items = await prisma.product.findMany({ - include: { category: true, images: { orderBy: { sort: 'asc' } } }, - orderBy: { updatedAt: 'desc' }, - }) - return items.map((p) => request.server.mapProductForApi(p)) - }, - ) + fastify.get('/api/admin/products', { preHandler: [fastify.verifyAdmin] }, async (request) => { + const items = await prisma.product.findMany({ + include: { category: true, images: { orderBy: { sort: 'asc' } } }, + orderBy: { updatedAt: 'desc' }, + }) + return items.map((p) => request.server.mapProductForApi(p)) + }) fastify.post( '/api/admin/products', @@ -102,7 +98,9 @@ export async function registerAdminProductRoutes(fastify) { return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' }) } if (notResized.length > 0) { - return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' }) + return reply + .code(400) + .send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' }) } } } @@ -227,7 +225,9 @@ export async function registerAdminProductRoutes(fastify) { return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' }) } if (notResized.length > 0) { - return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' }) + return reply + .code(400) + .send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' }) } } } @@ -255,17 +255,13 @@ export async function registerAdminProductRoutes(fastify) { }, ) - fastify.delete( - '/api/admin/products/:id', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params - try { - await prisma.product.delete({ where: { id } }) - reply.code(204).send() - } catch { - reply.code(404).send({ error: 'Товар не найден' }) - } - }, - ) + fastify.delete('/api/admin/products/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + try { + await prisma.product.delete({ where: { id } }) + reply.code(204).send() + } catch { + reply.code(404).send({ error: 'Товар не найден' }) + } + }) } diff --git a/server/src/routes/api/admin-reviews.js b/server/src/routes/api/admin-reviews.js index a236240..aedbe98 100644 --- a/server/src/routes/api/admin-reviews.js +++ b/server/src/routes/api/admin-reviews.js @@ -1,90 +1,65 @@ -import { prisma } from "../../lib/prisma.js"; -import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js"; +import { prisma } from '../../lib/prisma.js' export async function registerAdminReviewRoutes(fastify) { - fastify.get( - "/api/admin/reviews", - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const status = - typeof request.query?.status === "string" - ? request.query.status.trim() - : "pending"; + fastify.get('/api/admin/reviews', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending' - const pageRaw = request.query?.page; - const pageParsed = - typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw); - const page = - Number.isFinite(pageParsed) && pageParsed > 0 - ? Math.floor(pageParsed) - : 1; + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 - const pageSizeRaw = request.query?.pageSize; - const pageSizeParsed = - typeof pageSizeRaw === "string" - ? Number(pageSizeRaw) - : Number(pageSizeRaw); - const pageSize = - Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 - ? Math.floor(pageSizeParsed) - : 20; - if (pageSize > 100) - return reply.code(400).send({ error: "pageSize должен быть ≤ 100" }); + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 + if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) - const where = status ? { status } : {}; - const total = await prisma.review.count({ where }); - const items = await prisma.review.findMany({ - where, - include: { - user: { select: { id: true, email: true, name: true } }, - product: { select: { id: true, title: true } }, - }, - orderBy: { createdAt: "desc" }, - skip: (page - 1) * pageSize, - take: pageSize, - }); + const where = status ? { status } : {} + const total = await prisma.review.count({ where }) + const items = await prisma.review.findMany({ + where, + include: { + user: { select: { id: true, email: true, name: true } }, + product: { select: { id: true, title: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) - return { items, total, page, pageSize }; - }, - ); + return { items, total, page, pageSize } + }) - fastify.patch( - "/api/admin/reviews/:id", - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params; - const action = String(request.body?.action || "").trim(); - if (action !== "approve" && action !== "reject") { - return reply - .code(400) - .send({ error: "action должен быть approve или reject" }); - } + fastify.patch('/api/admin/reviews/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const action = String(request.body?.action || '').trim() + if (action !== 'approve' && action !== 'reject') { + return reply.code(400).send({ error: 'action должен быть approve или reject' }) + } - const existing = await prisma.review.findUnique({ - where: { id }, - include: { - product: { select: { title: true } }, - user: { select: { name: true, email: true } }, - }, - }); - if (!existing) return reply.code(404).send({ error: "Отзыв не найден" }); + const existing = await prisma.review.findUnique({ + where: { id }, + include: { + product: { select: { title: true } }, + user: { select: { name: true, email: true } }, + }, + }) + if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' }) - const updated = await prisma.review.update({ - where: { id }, - data: { - status: action === "approve" ? "approved" : "rejected", - moderatedAt: new Date(), - }, - }); - request.server.eventBus.emit("review:created", { - rating: updated.rating, - text: updated.text || "", - productTitle: existing.product?.title || "", - userName: existing.user?.name || existing.user?.email || "", - reviewId: updated.id, - }); + const updated = await prisma.review.update({ + where: { id }, + data: { + status: action === 'approve' ? 'approved' : 'rejected', + moderatedAt: new Date(), + }, + }) + request.server.eventBus.emit('review:created', { + rating: updated.rating, + text: updated.text || '', + productTitle: existing.product?.title || '', + userName: existing.user?.name || existing.user?.email || '', + reviewId: updated.id, + }) - return { item: updated }; - }, - ); + return { item: updated } + }) } diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js index 4c5a653..38f50c0 100644 --- a/server/src/routes/api/admin-users.js +++ b/server/src/routes/api/admin-users.js @@ -1,166 +1,149 @@ -import { prisma } from '../../lib/prisma.js' import { normalizeEmail } from '../../lib/auth.js' +import { prisma } from '../../lib/prisma.js' export async function registerAdminUserRoutes(fastify) { - fastify.get( - '/api/admin/users', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const qRaw = request.query?.q - const q = typeof qRaw === 'string' ? qRaw.trim() : '' + fastify.get('/api/admin/users', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const qRaw = request.query?.q + const q = typeof qRaw === 'string' ? qRaw.trim() : '' - const pageRaw = request.query?.page - const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) - const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 - const pageSizeRaw = request.query?.pageSize - const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) - const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 - if (pageSize > 100) { - reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) - return - } + if (pageSize > 100) { + reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) + return + } - const where = q - ? { - OR: [{ email: { contains: q } }, { name: { contains: q } }], - } - : undefined + const where = q + ? { + OR: [{ email: { contains: q } }, { name: { contains: q } }], + } + : undefined - const total = await prisma.user.count({ where }) + const total = await prisma.user.count({ where }) - const users = await prisma.user.findMany({ - where, - select: { - id: true, - email: true, - name: true, - createdAt: true, - updatedAt: true, - }, - orderBy: { updatedAt: 'desc' }, - skip: (page - 1) * pageSize, - take: pageSize, - }) - const items = users.map((u) => ({ - id: u.id, - email: u.email, - name: u.name, - createdAt: u.createdAt, - updatedAt: u.updatedAt, - })) + const users = await prisma.user.findMany({ + where, + select: { + id: true, + email: true, + name: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { updatedAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + const items = users.map((u) => ({ + id: u.id, + email: u.email, + name: u.name, + createdAt: u.createdAt, + updatedAt: u.updatedAt, + })) - return { items, total, page, pageSize } - }, - ) + return { items, total, page, pageSize } + }) - fastify.post( - '/api/admin/users', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const body = request.body ?? {} + fastify.post('/api/admin/users', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const body = request.body ?? {} + const email = normalizeEmail(body.email) + if (!email || !email.includes('@')) { + reply.code(400).send({ error: 'Некорректная почта' }) + return + } + + const nameRaw = body.name + const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + if (name !== null && name.length > 40) { + reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + return + } + + const exists = await prisma.user.findUnique({ where: { email } }) + if (exists) { + reply.code(409).send({ error: 'Почта уже занята' }) + return + } + + const user = await prisma.user.create({ + data: { + email, + name: name && name.length ? name : null, + }, + }) + + reply.code(201).send({ + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }) + }) + + fastify.patch('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const body = request.body ?? {} + + const existing = await prisma.user.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Пользователь не найден' }) + return + } + + const data = {} + + if (body.email !== undefined) { const email = normalizeEmail(body.email) if (!email || !email.includes('@')) { reply.code(400).send({ error: 'Некорректная почта' }) return } + if (email !== existing.email) { + const clash = await prisma.user.findUnique({ where: { email } }) + if (clash) { + reply.code(409).send({ error: 'Почта уже занята' }) + return + } + data.email = email + } + } + if (body.name !== undefined) { const nameRaw = body.name const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() if (name !== null && name.length > 40) { reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) return } + data.name = name && name.length ? name : null + } - const exists = await prisma.user.findUnique({ where: { email } }) - if (exists) { - reply.code(409).send({ error: 'Почта уже занята' }) - return - } + const user = await prisma.user.update({ where: { id }, data }) + return { + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + } + }) - const user = await prisma.user.create({ - data: { - email, - name: name && name.length ? name : null, - }, - }) - - reply.code(201).send({ - id: user.id, - email: user.email, - name: user.name, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }) - }, - ) - - fastify.patch( - '/api/admin/users/:id', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params - const body = request.body ?? {} - - const existing = await prisma.user.findUnique({ where: { id } }) - if (!existing) { - reply.code(404).send({ error: 'Пользователь не найден' }) - return - } - - const data = {} - - if (body.email !== undefined) { - const email = normalizeEmail(body.email) - if (!email || !email.includes('@')) { - reply.code(400).send({ error: 'Некорректная почта' }) - return - } - if (email !== existing.email) { - const clash = await prisma.user.findUnique({ where: { email } }) - if (clash) { - reply.code(409).send({ error: 'Почта уже занята' }) - return - } - data.email = email - } - } - - if (body.name !== undefined) { - const nameRaw = body.name - const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() - if (name !== null && name.length > 40) { - reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) - return - } - data.name = name && name.length ? name : null - } - - const user = await prisma.user.update({ where: { id }, data }) - return { - id: user.id, - email: user.email, - name: user.name, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - } - }, - ) - - fastify.delete( - '/api/admin/users/:id', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params - try { - await prisma.user.delete({ where: { id } }) - reply.code(204).send() - } catch { - reply.code(404).send({ error: 'Пользователь не найден' }) - } - }, - ) + fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + try { + await prisma.user.delete({ where: { id } }) + reply.code(204).send() + } catch { + reply.code(404).send({ error: 'Пользователь не найден' }) + } + }) } - diff --git a/server/src/routes/api/admin/notifications.js b/server/src/routes/api/admin/notifications.js index b8f3bca..3cb0804 100644 --- a/server/src/routes/api/admin/notifications.js +++ b/server/src/routes/api/admin/notifications.js @@ -1,95 +1,78 @@ -import { prisma } from "../../../lib/prisma.js"; +import { prisma } from '../../../lib/prisma.js' export async function registerAdminNotificationRoutes(fastify) { - fastify.get( - "/api/admin/notifications/settings", - { preHandler: [fastify.verifyAdmin] }, - async () => { - let settings = await prisma.adminNotificationSettings.findFirst(); - if (!settings) { - settings = await prisma.adminNotificationSettings.create({ - data: { - emailEnabled: true, - telegramEnabled: false, - newOrder: true, - newOrderMessage: true, - newReview: true, - authCodeDuplicate: false, - }, - }); - } - return { settings }; - }, - ); + fastify.get('/api/admin/notifications/settings', { preHandler: [fastify.verifyAdmin] }, async () => { + let settings = await prisma.adminNotificationSettings.findFirst() + if (!settings) { + settings = await prisma.adminNotificationSettings.create({ + data: { + emailEnabled: true, + telegramEnabled: false, + newOrder: true, + newOrderMessage: true, + newReview: true, + authCodeDuplicate: false, + }, + }) + } + return { settings } + }) - fastify.put( - "/api/admin/notifications/settings", - { preHandler: [fastify.verifyAdmin] }, - async (request) => { - const body = request.body || {}; - let settings = await prisma.adminNotificationSettings.findFirst(); + fastify.put('/api/admin/notifications/settings', { preHandler: [fastify.verifyAdmin] }, async (request) => { + const body = request.body || {} + let settings = await prisma.adminNotificationSettings.findFirst() - const data = {}; - if ("emailEnabled" in body) - data.emailEnabled = Boolean(body.emailEnabled); - if ("telegramEnabled" in body) - data.telegramEnabled = Boolean(body.telegramEnabled); - if ("telegramChatId" in body) - data.telegramChatId = body.telegramChatId || null; - if ("newOrder" in body) data.newOrder = Boolean(body.newOrder); - if ("newOrderMessage" in body) - data.newOrderMessage = Boolean(body.newOrderMessage); - if ("newReview" in body) data.newReview = Boolean(body.newReview); - if ("authCodeDuplicate" in body) - data.authCodeDuplicate = Boolean(body.authCodeDuplicate); + const data = {} + if ('emailEnabled' in body) data.emailEnabled = Boolean(body.emailEnabled) + if ('telegramEnabled' in body) data.telegramEnabled = Boolean(body.telegramEnabled) + if ('telegramChatId' in body) data.telegramChatId = body.telegramChatId || null + if ('newOrder' in body) data.newOrder = Boolean(body.newOrder) + if ('newOrderMessage' in body) data.newOrderMessage = Boolean(body.newOrderMessage) + if ('newReview' in body) data.newReview = Boolean(body.newReview) + if ('authCodeDuplicate' in body) data.authCodeDuplicate = Boolean(body.authCodeDuplicate) - if (!settings) { - settings = await prisma.adminNotificationSettings.create({ data }); - } else { - settings = await prisma.adminNotificationSettings.update({ - where: { id: settings.id }, - data, - }); - } + if (!settings) { + settings = await prisma.adminNotificationSettings.create({ data }) + } else { + settings = await prisma.adminNotificationSettings.update({ + where: { id: settings.id }, + data, + }) + } - return { settings }; - }, - ); + return { settings } + }) - fastify.post("/api/admin/notifications/telegram/webhook", async (request) => { - const update = request.body || {}; - const message = update.message; - if (!message || !message.text || message.text !== "/start") - return { ok: true }; + fastify.post('/api/admin/notifications/telegram/webhook', async (request) => { + const update = request.body || {} + const message = update.message + if (!message || !message.text || message.text !== '/start') return { ok: true } - const chatId = String(message.chat.id); - const settings = await prisma.adminNotificationSettings.findFirst(); + const chatId = String(message.chat.id) + const settings = await prisma.adminNotificationSettings.findFirst() if (settings) { await prisma.adminNotificationSettings.update({ where: { id: settings.id }, data: { telegramChatId: chatId }, - }); + }) } else { await prisma.adminNotificationSettings.create({ data: { telegramChatId: chatId }, - }); + }) } if (process.env.TELEGRAM_BOT_TOKEN) { - await fetch( - `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chat_id: chatId, - text: "Вы подписаны на уведомления Любимый Креатив.", - }), - }, - ); + await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: 'Вы подписаны на уведомления Любимый Креатив.', + }), + }) } - return { ok: true }; - }); + return { ok: true } + }) } diff --git a/server/src/routes/api/catalog-slider.js b/server/src/routes/api/catalog-slider.js index 90cc59c..4e494c6 100644 --- a/server/src/routes/api/catalog-slider.js +++ b/server/src/routes/api/catalog-slider.js @@ -17,83 +17,75 @@ export async function registerCatalogSliderRoutes(fastify) { } }) - fastify.get( - '/api/admin/catalog-slider', - { preHandler: [fastify.verifyAdmin] }, - async () => { - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - galleryImageId: s.galleryImageId, - url: s.galleryImage.url, - caption: s.caption, - })), - } - }, - ) + fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async () => { + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + galleryImageId: s.galleryImageId, + url: s.galleryImage.url, + caption: s.caption, + })), + } + }) - fastify.put( - '/api/admin/catalog-slider', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const body = request.body ?? {} - const rawSlides = body.slides - if (!Array.isArray(rawSlides)) { - return reply.code(400).send({ error: 'Ожидается slides: массив' }) - } - if (rawSlides.length > MAX_SLIDES) { - return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` }) - } + fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const body = request.body ?? {} + const rawSlides = body.slides + if (!Array.isArray(rawSlides)) { + return reply.code(400).send({ error: 'Ожидается slides: массив' }) + } + if (rawSlides.length > MAX_SLIDES) { + return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` }) + } - const seenGalleryIds = new Set() - const normalized = [] - for (let i = 0; i < rawSlides.length; i++) { - const row = rawSlides[i] - const galleryImageId = String(row?.galleryImageId ?? '').trim() - if (!galleryImageId) { - return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` }) - } - if (seenGalleryIds.has(galleryImageId)) { - return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' }) - } - seenGalleryIds.add(galleryImageId) - const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } }) - if (!img) { - return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` }) - } - const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500) - normalized.push({ galleryImageId, caption, sortOrder: i }) + const seenGalleryIds = new Set() + const normalized = [] + for (let i = 0; i < rawSlides.length; i++) { + const row = rawSlides[i] + const galleryImageId = String(row?.galleryImageId ?? '').trim() + if (!galleryImageId) { + return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` }) } - - await prisma.$transaction(async (tx) => { - await tx.catalogSliderSlide.deleteMany({}) - for (const n of normalized) { - await tx.catalogSliderSlide.create({ - data: { - sortOrder: n.sortOrder, - caption: n.caption, - galleryImageId: n.galleryImageId, - }, - }) - } - }) - - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - galleryImageId: s.galleryImageId, - url: s.galleryImage.url, - caption: s.caption, - })), + if (seenGalleryIds.has(galleryImageId)) { + return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' }) } - }, - ) + seenGalleryIds.add(galleryImageId) + const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } }) + if (!img) { + return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` }) + } + const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500) + normalized.push({ galleryImageId, caption, sortOrder: i }) + } + + await prisma.$transaction(async (tx) => { + await tx.catalogSliderSlide.deleteMany({}) + for (const n of normalized) { + await tx.catalogSliderSlide.create({ + data: { + sortOrder: n.sortOrder, + caption: n.caption, + galleryImageId: n.galleryImageId, + }, + }) + } + }) + + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + galleryImageId: s.galleryImageId, + url: s.galleryImage.url, + caption: s.caption, + })), + } + }) } diff --git a/server/src/routes/api/info-page.js b/server/src/routes/api/info-page.js index f14bff4..58863f1 100644 --- a/server/src/routes/api/info-page.js +++ b/server/src/routes/api/info-page.js @@ -29,90 +29,74 @@ export async function registerInfoPageRoutes(fastify) { return { items } }) - fastify.get( - '/api/admin/info-page/blocks', - { preHandler: [fastify.verifyAdmin] }, - async () => { - const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] }) - return { items } - }, - ) + fastify.get('/api/admin/info-page/blocks', { preHandler: [fastify.verifyAdmin] }, async () => { + const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] }) + return { items } + }) - fastify.post( - '/api/admin/info-page/blocks', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const validated = validateBlockPayload(request.body, reply) - if (!validated) return + fastify.post('/api/admin/info-page/blocks', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const validated = validateBlockPayload(request.body, reply) + if (!validated) return - try { - const item = await prisma.infoPageBlock.create({ data: validated }) - return reply.code(201).send({ item }) - } catch { - return reply.code(409).send({ error: 'Блок с таким key уже существует' }) - } - }, - ) + try { + const item = await prisma.infoPageBlock.create({ data: validated }) + return reply.code(201).send({ item }) + } catch { + return reply.code(409).send({ error: 'Блок с таким key уже существует' }) + } + }) - fastify.patch( - '/api/admin/info-page/blocks/:id', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params - const existing = await prisma.infoPageBlock.findUnique({ where: { id } }) - if (!existing) return reply.code(404).send({ error: 'Блок не найден' }) + fastify.patch('/api/admin/info-page/blocks/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const existing = await prisma.infoPageBlock.findUnique({ where: { id } }) + if (!existing) return reply.code(404).send({ error: 'Блок не найден' }) - const body = request.body ?? {} - const data = {} - if (body.key !== undefined) { - const key = String(body.key || '').trim() - if (!key) return reply.code(400).send({ error: 'key обязателен' }) - if (!/^[a-z0-9_-]{2,60}$/i.test(key)) { - return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' }) - } - data.key = key - } - if (body.title !== undefined) { - const title = String(body.title || '').trim() - if (!title) return reply.code(400).send({ error: 'title обязателен' }) - if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' }) - data.title = title - } - if (body.body !== undefined) { - const content = String(body.body || '').trim() - if (!content) return reply.code(400).send({ error: 'body обязателен' }) - if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' }) - data.body = content - } - if (body.sort !== undefined) { - const sort = Number(body.sort) - if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' }) - data.sort = Math.trunc(sort) - } - if (body.published !== undefined) { - data.published = Boolean(body.published) + const body = request.body ?? {} + const data = {} + if (body.key !== undefined) { + const key = String(body.key || '').trim() + if (!key) return reply.code(400).send({ error: 'key обязателен' }) + if (!/^[a-z0-9_-]{2,60}$/i.test(key)) { + return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' }) } + data.key = key + } + if (body.title !== undefined) { + const title = String(body.title || '').trim() + if (!title) return reply.code(400).send({ error: 'title обязателен' }) + if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' }) + data.title = title + } + if (body.body !== undefined) { + const content = String(body.body || '').trim() + if (!content) return reply.code(400).send({ error: 'body обязателен' }) + if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' }) + data.body = content + } + if (body.sort !== undefined) { + const sort = Number(body.sort) + if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' }) + data.sort = Math.trunc(sort) + } + if (body.published !== undefined) { + data.published = Boolean(body.published) + } - try { - const item = await prisma.infoPageBlock.update({ where: { id }, data }) - return { item } - } catch { - return reply.code(409).send({ error: 'Блок с таким key уже существует' }) - } - }, - ) + try { + const item = await prisma.infoPageBlock.update({ where: { id }, data }) + return { item } + } catch { + return reply.code(409).send({ error: 'Блок с таким key уже существует' }) + } + }) - fastify.delete( - '/api/admin/info-page/blocks/:id', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - const { id } = request.params - try { - await prisma.infoPageBlock.delete({ where: { id } }) - return reply.code(204).send() - } catch { - return reply.code(404).send({ error: 'Блок не найден' }) - } - }, - ) + fastify.delete('/api/admin/info-page/blocks/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + try { + await prisma.infoPageBlock.delete({ where: { id } }) + return reply.code(204).send() + } catch { + return reply.code(404).send({ error: 'Блок не найден' }) + } + }) } diff --git a/server/src/routes/api/public-catalog.js b/server/src/routes/api/public-catalog.js index f03af1c..7977e85 100644 --- a/server/src/routes/api/public-catalog.js +++ b/server/src/routes/api/public-catalog.js @@ -79,7 +79,6 @@ export async function registerPublicCatalogRoutes(fastify) { }) fastify.get('/api/products', { schema: PUBLIC_PRODUCTS_QUERY_SCHEMA }, async (request, reply) => { - const { mapProductForApi } = request.server const { categorySlug } = request.query const qRaw = request.query?.q const q = typeof qRaw === 'string' ? qRaw.trim() : '' @@ -161,4 +160,3 @@ export async function registerPublicCatalogRoutes(fastify) { return request.server.mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY) }) } - diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js index e9d78f8..1019ebe 100644 --- a/server/src/routes/api/public-reviews.js +++ b/server/src/routes/api/public-reviews.js @@ -1,39 +1,35 @@ -import { publicReviewAuthorDisplay } from '../../lib/review-display.js' import { prisma } from '../../lib/prisma.js' +import { publicReviewAuthorDisplay } from '../../lib/review-display.js' +import { persistMultipartImages } from '../../lib/upload-images.js' import { formatFileTooLargeMessage, getOtherUploadMaxFileBytes, isMultipartFileTooLargeError, } from '../../lib/upload-limits.js' -import { persistMultipartImages } from '../../lib/upload-images.js' export async function registerPublicReviewRoutes(fastify) { - fastify.post( - '/api/reviews/upload-image', - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - try { - const urls = await persistMultipartImages(request, { - maxFiles: 1, - maxFileBytes: getOtherUploadMaxFileBytes(), - subdir: 'reviews', - }) - if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' }) - return { url: urls[0] } - } catch (error) { - let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение' - let statusCode = - error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) - ? Number(error.statusCode) - : 400 - if (isMultipartFileTooLargeError(error)) { - message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes()) - statusCode = 413 - } - return reply.code(statusCode).send({ error: message }) + fastify.post('/api/reviews/upload-image', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + const urls = await persistMultipartImages(request, { + maxFiles: 1, + maxFileBytes: getOtherUploadMaxFileBytes(), + subdir: 'reviews', + }) + if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' }) + return { url: urls[0] } + } catch (error) { + let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение' + let statusCode = + error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) + ? Number(error.statusCode) + : 400 + if (isMultipartFileTooLargeError(error)) { + message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes()) + statusCode = 413 } - }, - ) + return reply.code(statusCode).send({ error: message }) + } + }) fastify.get('/api/reviews/latest', async (request, reply) => { const limitRaw = request.query?.limit @@ -102,46 +98,42 @@ export async function registerPublicReviewRoutes(fastify) { return { items, total, page, pageSize } }) - fastify.post( - '/api/products/:id/reviews', - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub - const { id: productId } = request.params + fastify.post('/api/products/:id/reviews', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id: productId } = request.params - const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) - if (!product) return reply.code(404).send({ error: 'Товар не найден' }) + const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) - const rating = Number(request.body?.rating) - if (!Number.isFinite(rating) || rating < 1 || rating > 5) { - return reply.code(400).send({ error: 'rating должен быть от 1 до 5' }) - } - const textRaw = request.body?.text - const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim() - if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' }) - const imageUrlRaw = request.body?.imageUrl - const imageUrl = imageUrlRaw === null || imageUrlRaw === undefined ? null : String(imageUrlRaw).trim() - if (imageUrl !== null && imageUrl.length > 300) return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' }) - if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) { - return reply.code(400).send({ error: 'Некорректная ссылка на изображение' }) - } + const rating = Number(request.body?.rating) + if (!Number.isFinite(rating) || rating < 1 || rating > 5) { + return reply.code(400).send({ error: 'rating должен быть от 1 до 5' }) + } + const textRaw = request.body?.text + const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim() + if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' }) + const imageUrlRaw = request.body?.imageUrl + const imageUrl = imageUrlRaw === null || imageUrlRaw === undefined ? null : String(imageUrlRaw).trim() + if (imageUrl !== null && imageUrl.length > 300) + return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' }) + if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) { + return reply.code(400).send({ error: 'Некорректная ссылка на изображение' }) + } - try { - const created = await prisma.review.create({ - data: { - productId, - userId, - rating: Math.floor(rating), - text: text && text.length ? text : null, - imageUrl: imageUrl && imageUrl.length ? imageUrl : null, - status: 'pending', - }, - }) - return reply.code(201).send({ item: created }) - } catch { - return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' }) - } - }, - ) + try { + const created = await prisma.review.create({ + data: { + productId, + userId, + rating: Math.floor(rating), + text: text && text.length ? text : null, + imageUrl: imageUrl && imageUrl.length ? imageUrl : null, + status: 'pending', + }, + }) + return reply.code(201).send({ item: created }) + } catch { + return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' }) + } + }) } - diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 6118a9b..74b18d5 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,177 +1,139 @@ -import { - issueEmailCode, - normalizeEmail, - verifyEmailCode, -} from "../lib/auth.js"; -import { prisma } from "../lib/prisma.js"; -import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js"; +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js' +import { prisma } from '../lib/prisma.js' function mapUserForClient(user) { - const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL); - const userEmail = normalizeEmail(user.email); + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + const userEmail = normalizeEmail(user.email) return { id: user.id, email: user.email, name: user.name, phone: user.phone, isAdmin: Boolean(adminEmail) && userEmail === adminEmail, - }; + } } export async function registerAuthRoutes(fastify) { - fastify.post("/api/auth/request-code", async (request, reply) => { - const email = normalizeEmail(request.body?.email); - if (!email || !email.includes("@")) - return reply.code(400).send({ error: "Некорректная почта" }); + fastify.post('/api/auth/request-code', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - const code = await issueEmailCode({ email, purpose: "login" }); + const code = await issueEmailCode({ email, purpose: 'login' }) - const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase(); - const isAdmin = email === adminEmail; + const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() + const isAdmin = email === adminEmail request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { email, code, isAdmin, - }); + }) - return { ok: true }; - }); + return { ok: true } + }) - fastify.post("/api/auth/verify-code", async (request, reply) => { - const email = normalizeEmail(request.body?.email); - const code = String(request.body?.code || "").trim(); - if (!email || !email.includes("@")) - return reply.code(400).send({ error: "Некорректная почта" }); - if (!code || code.length !== 6) - return reply.code(400).send({ error: "Код должен быть из 6 цифр" }); + fastify.post('/api/auth/verify-code', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const code = String(request.body?.code || '').trim() + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) - const ok = await verifyEmailCode({ email, purpose: "login", code }); - if (!ok) - return reply.code(401).send({ error: "Неверный или истёкший код" }); + const ok = await verifyEmailCode({ email, purpose: 'login', code }) + if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) const user = await prisma.user.upsert({ where: { email }, update: {}, create: { email }, - }); + }) // Ensure notification preference exists await prisma.notificationPreference.upsert({ where: { userId: user.id }, create: { userId: user.id, globalEnabled: true }, update: {}, - }); + }) - const token = fastify.jwt.sign({ sub: user.id, email: user.email }); - return { token, user: mapUserForClient(user) }; - }); + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return { token, user: mapUserForClient(user) } + }) - fastify.get( - "/api/me", - { preHandler: [fastify.authenticate] }, - async (request) => { - const userId = request.user.sub; - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) return { user: null }; - return { user: mapUserForClient(user) }; - }, - ); + fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return { user: null } + return { user: mapUserForClient(user) } + }) - fastify.post( - "/api/me/change-email/request-code", - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub; - const newEmail = normalizeEmail(request.body?.newEmail); - if (!newEmail || !newEmail.includes("@")) - return reply.code(400).send({ error: "Некорректная почта" }); + fastify.post('/api/me/change-email/request-code', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const newEmail = normalizeEmail(request.body?.newEmail) + if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - const exists = await prisma.user.findUnique({ - where: { email: newEmail }, - }); - if (exists) - return reply.code(409).send({ error: "Эта почта уже занята" }); + const exists = await prisma.user.findUnique({ + where: { email: newEmail }, + }) + if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) - await issueEmailCode({ - email: newEmail, - purpose: "change_email", - userId, - }); - return { ok: true }; - }, - ); + await issueEmailCode({ + email: newEmail, + purpose: 'change_email', + userId, + }) + return { ok: true } + }) - fastify.post( - "/api/me/change-email/verify", - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub; - const newEmail = normalizeEmail(request.body?.newEmail); - const code = String(request.body?.code || "").trim(); - if (!newEmail || !newEmail.includes("@")) - return reply.code(400).send({ error: "Некорректная почта" }); - if (!code || code.length !== 6) - return reply.code(400).send({ error: "Код должен быть из 6 цифр" }); + fastify.post('/api/me/change-email/verify', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const newEmail = normalizeEmail(request.body?.newEmail) + const code = String(request.body?.code || '').trim() + if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) - const exists = await prisma.user.findUnique({ - where: { email: newEmail }, - }); - if (exists) - return reply.code(409).send({ error: "Эта почта уже занята" }); + const exists = await prisma.user.findUnique({ + where: { email: newEmail }, + }) + if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) - const ok = await verifyEmailCode({ - email: newEmail, - purpose: "change_email", - code, - userId, - }); - if (!ok) - return reply.code(401).send({ error: "Неверный или истёкший код" }); + const ok = await verifyEmailCode({ + email: newEmail, + purpose: 'change_email', + code, + userId, + }) + if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) - const user = await prisma.user.update({ - where: { id: userId }, - data: { email: newEmail }, - }); - return { user: mapUserForClient(user) }; - }, - ); + const user = await prisma.user.update({ + where: { id: userId }, + data: { email: newEmail }, + }) + return { user: mapUserForClient(user) } + }) - fastify.patch( - "/api/me/profile", - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub; - const nameRaw = request.body?.name; - const name = - nameRaw === null || nameRaw === undefined - ? null - : String(nameRaw).trim(); - const phoneRaw = request.body?.phone; - const phone = - phoneRaw === null || phoneRaw === undefined - ? null - : String(phoneRaw).trim(); + fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const nameRaw = request.body?.name + const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + const phoneRaw = request.body?.phone + const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim() - if (name !== null && name.length > 40) - return reply.code(400).send({ error: "Имя/ник максимум 40 символов" }); - if (phone !== null) { - const compact = phone.replace(/[\s()-]/g, ""); - if (compact.length > 20) - return reply.code(400).send({ error: "Телефон слишком длинный" }); - if (compact.length && !/^\+?\d{7,20}$/.test(compact)) { - return reply.code(400).send({ error: "Некорректный телефон" }); - } + if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + if (phone !== null) { + const compact = phone.replace(/[\s()-]/g, '') + if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' }) + if (compact.length && !/^\+?\d{7,20}$/.test(compact)) { + return reply.code(400).send({ error: 'Некорректный телефон' }) } + } - const updated = await prisma.user.update({ - where: { id: userId }, - data: { - name: name && name.length ? name : null, - phone: phone && phone.length ? phone : null, - }, - }); - return { user: mapUserForClient(updated) }; - }, - ); + const updated = await prisma.user.update({ + where: { id: userId }, + data: { + name: name && name.length ? name : null, + phone: phone && phone.length ? phone : null, + }, + }) + return { user: mapUserForClient(updated) } + }) } diff --git a/server/src/routes/uploads-resized.js b/server/src/routes/uploads-resized.js index 962028a..04a7d56 100644 --- a/server/src/routes/uploads-resized.js +++ b/server/src/routes/uploads-resized.js @@ -1,6 +1,5 @@ // server/src/routes/uploads-resized.js import fs from 'node:fs' -import path from 'node:path' import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js' const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable' @@ -18,7 +17,8 @@ export function registerUploadsResized(fastify) { // Parse: [subdir/]filename.format const parts = rawPath.split('/') - let filename, subdir = '' + let filename, + subdir = '' if (parts.length > 1) { subdir = parts.slice(0, -1).join('/') + '/' diff --git a/server/src/routes/user-addresses.js b/server/src/routes/user-addresses.js index 9a731d1..2440907 100644 --- a/server/src/routes/user-addresses.js +++ b/server/src/routes/user-addresses.js @@ -25,7 +25,8 @@ function validateAddressPayload(body, reply) { const commentRaw = body?.comment const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() - if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) + if (comment !== null && comment.length > 200) + return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) const lat = Number(body?.lat) const lng = Number(body?.lng) @@ -44,150 +45,133 @@ function validateAddressPayload(body, reply) { } export async function registerUserAddressRoutes(fastify) { - fastify.get( - '/api/me/addresses', - { preHandler: [fastify.authenticate] }, - async (request) => { - const userId = request.user.sub - const items = await prisma.shippingAddress.findMany({ - where: { userId }, - orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }], - }) - return { items } - }, - ) + fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const items = await prisma.shippingAddress.findMany({ + where: { userId }, + orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }], + }) + return { items } + }) - fastify.post( - '/api/me/addresses', - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub - const validated = validateAddressPayload(request.body, reply) - if (!validated) return + fastify.post('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const validated = validateAddressPayload(request.body, reply) + if (!validated) return - const isDefault = Boolean(request.body?.isDefault) - const created = await prisma.$transaction(async (tx) => { - if (isDefault) { - await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) - } - return tx.shippingAddress.create({ - data: { - userId, - ...validated, - isDefault, - }, - }) - }) - return reply.code(201).send({ item: created }) - }, - ) - - fastify.patch( - '/api/me/addresses/:id', - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) - if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) - - const body = request.body ?? {} - const data = {} - - if (body.label !== undefined) { - const labelRaw = body.label - const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() - if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) - data.label = label && label.length ? label : null - } - - if (body.recipientName !== undefined) { - const v = String(body.recipientName || '').trim() - if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' }) - if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' }) - data.recipientName = v - } - - if (body.recipientPhone !== undefined) { - const v = normalizePhoneLite(body.recipientPhone) - if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' }) - if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' }) - data.recipientPhone = v - } - - if (body.addressLine !== undefined) { - const v = String(body.addressLine || '').trim() - if (!v) return reply.code(400).send({ error: 'Укажите адрес' }) - if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' }) - data.addressLine = v - } - - if (body.comment !== undefined) { - const commentRaw = body.comment - const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() - if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) - data.comment = comment && comment.length ? comment : null - } - - if (body.lat !== undefined) { - const lat = Number(body.lat) - if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' }) - data.lat = lat - } - - if (body.lng !== undefined) { - const lng = Number(body.lng) - if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' }) - data.lng = lng - } - - const setDefault = body.isDefault === true - const updated = await prisma.$transaction(async (tx) => { - if (setDefault) { - await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) - } - return tx.shippingAddress.update({ - where: { id }, - data: { - ...data, - ...(setDefault ? { isDefault: true } : {}), - }, - }) - }) - - return { item: updated } - }, - ) - - fastify.delete( - '/api/me/addresses/:id', - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) - if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) - - await prisma.shippingAddress.delete({ where: { id } }) - return reply.code(204).send() - }, - ) - - fastify.post( - '/api/me/addresses/:id/default', - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) - if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) - - const updated = await prisma.$transaction(async (tx) => { + const isDefault = Boolean(request.body?.isDefault) + const created = await prisma.$transaction(async (tx) => { + if (isDefault) { await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) - return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } }) + } + return tx.shippingAddress.create({ + data: { + userId, + ...validated, + isDefault, + }, }) + }) + return reply.code(201).send({ item: created }) + }) - return { item: updated } - }, - ) + fastify.patch('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + + const body = request.body ?? {} + const data = {} + + if (body.label !== undefined) { + const labelRaw = body.label + const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() + if (label !== null && label.length > 40) + return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) + data.label = label && label.length ? label : null + } + + if (body.recipientName !== undefined) { + const v = String(body.recipientName || '').trim() + if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' }) + if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' }) + data.recipientName = v + } + + if (body.recipientPhone !== undefined) { + const v = normalizePhoneLite(body.recipientPhone) + if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' }) + if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' }) + data.recipientPhone = v + } + + if (body.addressLine !== undefined) { + const v = String(body.addressLine || '').trim() + if (!v) return reply.code(400).send({ error: 'Укажите адрес' }) + if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' }) + data.addressLine = v + } + + if (body.comment !== undefined) { + const commentRaw = body.comment + const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() + if (comment !== null && comment.length > 200) + return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) + data.comment = comment && comment.length ? comment : null + } + + if (body.lat !== undefined) { + const lat = Number(body.lat) + if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' }) + data.lat = lat + } + + if (body.lng !== undefined) { + const lng = Number(body.lng) + if (!Number.isFinite(lng) || lng < -180 || lng > 180) + return reply.code(400).send({ error: 'Некорректная долгота' }) + data.lng = lng + } + + const setDefault = body.isDefault === true + const updated = await prisma.$transaction(async (tx) => { + if (setDefault) { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + } + return tx.shippingAddress.update({ + where: { id }, + data: { + ...data, + ...(setDefault ? { isDefault: true } : {}), + }, + }) + }) + + return { item: updated } + }) + + fastify.delete('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + + await prisma.shippingAddress.delete({ where: { id } }) + return reply.code(204).send() + }) + + fastify.post('/api/me/addresses/:id/default', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + + const updated = await prisma.$transaction(async (tx) => { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } }) + }) + + return { item: updated } + }) } diff --git a/server/src/routes/user-cart.js b/server/src/routes/user-cart.js index c7980a3..b432d1c 100644 --- a/server/src/routes/user-cart.js +++ b/server/src/routes/user-cart.js @@ -1,92 +1,76 @@ import { prisma } from '../lib/prisma.js' export async function registerUserCartRoutes(fastify) { - fastify.get( - '/api/me/cart', - { preHandler: [fastify.authenticate] }, - async (request) => { - const userId = request.user.sub - const items = await prisma.cartItem.findMany({ - where: { userId }, - include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } }, - orderBy: { createdAt: 'asc' }, - }) - return { - items: items.map((x) => ({ - id: x.id, - qty: x.qty, - product: x.product, - })), - } - }, - ) + fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const items = await prisma.cartItem.findMany({ + where: { userId }, + include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } }, + orderBy: { createdAt: 'asc' }, + }) + return { + items: items.map((x) => ({ + id: x.id, + qty: x.qty, + product: x.product, + })), + } + }) - fastify.post( - '/api/me/cart/items', - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub - const productId = String(request.body?.productId || '').trim() - const qtyRaw = request.body?.qty - const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw) + fastify.post('/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const productId = String(request.body?.productId || '').trim() + const qtyRaw = request.body?.qty + const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw) - if (!productId) return reply.code(400).send({ error: 'productId обязателен' }) - if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' }) + if (!productId) return reply.code(400).send({ error: 'productId обязателен' }) + if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' }) - const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) - if (!product) return reply.code(404).send({ error: 'Товар не найден' }) + const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) - const available = product.inStock ? product.quantity : 1 - const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } }) - const nextQty = (existing?.qty ?? 0) + Math.floor(qty) - if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) + const available = product.inStock ? product.quantity : 1 + const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } }) + const nextQty = (existing?.qty ?? 0) + Math.floor(qty) + if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) - const item = await prisma.cartItem.upsert({ - where: { userId_productId: { userId, productId } }, - update: { qty: nextQty }, - create: { userId, productId, qty: nextQty }, - }) - return reply.code(201).send({ item }) - }, - ) + const item = await prisma.cartItem.upsert({ + where: { userId_productId: { userId, productId } }, + update: { qty: nextQty }, + create: { userId, productId, qty: nextQty }, + }) + return reply.code(201).send({ item }) + }) - fastify.patch( - '/api/me/cart/items/:id', - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const qtyRaw = request.body?.qty - const qty = Number(qtyRaw) - if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' }) + fastify.patch('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const qtyRaw = request.body?.qty + const qty = Number(qtyRaw) + if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' }) - const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } }) - if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } }) + if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) - if (qty === 0) { - await prisma.cartItem.delete({ where: { id } }) - return reply.code(204).send() - } - - const available = existing.product.inStock ? existing.product.quantity : 1 - const nextQty = Math.floor(qty) - if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) - - const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } }) - return { item: updated } - }, - ) - - fastify.delete( - '/api/me/cart/items/:id', - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) - if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + if (qty === 0) { await prisma.cartItem.delete({ where: { id } }) return reply.code(204).send() - }, - ) + } + + const available = existing.product.inStock ? existing.product.quantity : 1 + const nextQty = Math.floor(qty) + if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) + + const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } }) + return { item: updated } + }) + + fastify.delete('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + await prisma.cartItem.delete({ where: { id } }) + return reply.code(204).send() + }) } diff --git a/server/src/routes/user-messages.js b/server/src/routes/user-messages.js index e9db4bc..6812f33 100644 --- a/server/src/routes/user-messages.js +++ b/server/src/routes/user-messages.js @@ -1,155 +1,127 @@ -import { prisma } from "../lib/prisma.js"; -import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js"; +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { prisma } from '../lib/prisma.js' export async function registerUserMessageRoutes(fastify) { - fastify.get( - "/api/me/orders/:id/messages", - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub; - const { id } = request.params; - const order = await prisma.order.findFirst({ where: { id, userId } }); - if (!order) return reply.code(404).send({ error: "Заказ не найден" }); - const items = await prisma.orderMessage.findMany({ - where: { orderId: id }, - orderBy: { createdAt: "asc" }, - }); - return { items }; - }, - ); + fastify.get('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ where: { id, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + const items = await prisma.orderMessage.findMany({ + where: { orderId: id }, + orderBy: { createdAt: 'asc' }, + }) + return { items } + }) - fastify.post( - "/api/me/orders/:id/messages", - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub; - const { id } = request.params; - const order = await prisma.order.findFirst({ where: { id, userId } }); - if (!order) return reply.code(404).send({ error: "Заказ не найден" }); - const text = String(request.body?.text || "").trim(); - if (!text) return reply.code(400).send({ error: "Сообщение пустое" }); - if (text.length > 2000) - return reply.code(400).send({ error: "Сообщение слишком длинное" }); - const msg = await prisma.orderMessage.create({ - data: { orderId: id, authorType: "user", text }, - }); + fastify.post('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ where: { id, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + const text = String(request.body?.text || '').trim() + if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) + if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) + const msg = await prisma.orderMessage.create({ + data: { orderId: id, authorType: 'user', text }, + }) - request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, { - orderId: id, - authorType: "user", - messageId: msg.id, - preview: text, - }); + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, { + orderId: id, + authorType: 'user', + messageId: msg.id, + preview: text, + }) - return reply.code(201).send({ item: msg }); - }, - ); + return reply.code(201).send({ item: msg }) + }) - fastify.get( - "/api/me/messages/unread-count", - { preHandler: [fastify.authenticate] }, - async (request) => { - const userId = request.user.sub; - const orders = await prisma.order.findMany({ - where: { userId }, - select: { id: true }, - }); - if (orders.length === 0) return { count: 0 }; + fastify.get('/api/me/messages/unread-count', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const orders = await prisma.order.findMany({ + where: { userId }, + select: { id: true }, + }) + if (orders.length === 0) return { count: 0 } - const readStates = await prisma.userOrderMessageReadState.findMany({ - where: { userId }, - }); - const lastReadByOrder = new Map( - readStates.map((r) => [r.orderId, r.lastReadAt]), - ); + const readStates = await prisma.userOrderMessageReadState.findMany({ + where: { userId }, + }) + const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) - let count = 0; - for (const o of orders) { - const lastRead = lastReadByOrder.get(o.id) ?? new Date(0); - const n = await prisma.orderMessage.count({ - where: { - orderId: o.id, - authorType: "admin", - createdAt: { gt: lastRead }, - }, - }); - count += n; - } - return { count }; - }, - ); - - fastify.get( - "/api/me/conversations", - { preHandler: [fastify.authenticate] }, - async (request) => { - const userId = request.user.sub; - const orders = await prisma.order.findMany({ - where: { userId, messages: { some: {} } }, - select: { - id: true, - status: true, - deliveryType: true, - messages: { - orderBy: { createdAt: "desc" }, - take: 1, - select: { text: true, createdAt: true }, - }, - }, - orderBy: { updatedAt: "desc" }, - }); - - const readStates = await prisma.userOrderMessageReadState.findMany({ - where: { userId }, - }); - const lastReadByOrder = new Map( - readStates.map((r) => [r.orderId, r.lastReadAt]), - ); - - const items = []; - for (const o of orders) { - const lastMsg = o.messages[0]; - if (!lastMsg) continue; - const lastRead = lastReadByOrder.get(o.id) ?? new Date(0); - const unreadCount = await prisma.orderMessage.count({ - where: { - orderId: o.id, - authorType: "admin", - createdAt: { gt: lastRead }, - }, - }); - items.push({ + let count = 0 + for (const o of orders) { + const lastRead = lastReadByOrder.get(o.id) ?? new Date(0) + const n = await prisma.orderMessage.count({ + where: { orderId: o.id, - status: o.status, - deliveryType: o.deliveryType, - lastMessageAt: lastMsg.createdAt, - preview: - lastMsg.text.length > 280 - ? `${lastMsg.text.slice(0, 277)}…` - : lastMsg.text, - unreadCount, - }); - } - return { items }; - }, - ); + authorType: 'admin', + createdAt: { gt: lastRead }, + }, + }) + count += n + } + return { count } + }) - fastify.post( - "/api/me/orders/:id/messages/read", - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub; - const { id } = request.params; - const order = await prisma.order.findFirst({ where: { id, userId } }); - if (!order) return reply.code(404).send({ error: "Заказ не найден" }); + fastify.get('/api/me/conversations', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const orders = await prisma.order.findMany({ + where: { userId, messages: { some: {} } }, + select: { + id: true, + status: true, + deliveryType: true, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { text: true, createdAt: true }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }) - const now = new Date(); - await prisma.userOrderMessageReadState.upsert({ - where: { userId_orderId: { userId, orderId: id } }, - create: { userId, orderId: id, lastReadAt: now }, - update: { lastReadAt: now }, - }); - return { ok: true }; - }, - ); + const readStates = await prisma.userOrderMessageReadState.findMany({ + where: { userId }, + }) + const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) + + const items = [] + for (const o of orders) { + const lastMsg = o.messages[0] + if (!lastMsg) continue + const lastRead = lastReadByOrder.get(o.id) ?? new Date(0) + const unreadCount = await prisma.orderMessage.count({ + where: { + orderId: o.id, + authorType: 'admin', + createdAt: { gt: lastRead }, + }, + }) + items.push({ + orderId: o.id, + status: o.status, + deliveryType: o.deliveryType, + lastMessageAt: lastMsg.createdAt, + preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text, + unreadCount, + }) + } + return { items } + }) + + fastify.post('/api/me/orders/:id/messages/read', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ where: { id, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + + const now = new Date() + await prisma.userOrderMessageReadState.upsert({ + where: { userId_orderId: { userId, orderId: id } }, + create: { userId, orderId: id, lastReadAt: now }, + update: { lastReadAt: now }, + }) + return { ok: true } + }) } diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index ea4ce3c..0e20eb1 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -1,312 +1,271 @@ -import { isDeliveryCarrier } from "../lib/delivery-carrier.js"; -import { prisma } from "../lib/prisma.js"; -import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js"; +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { isDeliveryCarrier } from '../lib/delivery-carrier.js' +import { prisma } from '../lib/prisma.js' export async function registerUserOrderRoutes(fastify) { // ---- Создание заказа (checkout) ---- - fastify.post( - "/api/me/orders", - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub; - const deliveryTypeRaw = request.body?.deliveryType; - const deliveryType = - deliveryTypeRaw === undefined || - deliveryTypeRaw === null || - deliveryTypeRaw === "" - ? "delivery" - : String(deliveryTypeRaw).trim(); + fastify.post('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const deliveryTypeRaw = request.body?.deliveryType + const deliveryType = + deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === '' + ? 'delivery' + : String(deliveryTypeRaw).trim() - const addressId = String(request.body?.addressId || "").trim(); - const commentRaw = request.body?.comment; - const comment = - commentRaw === null || commentRaw === undefined - ? null - : String(commentRaw).trim(); + const addressId = String(request.body?.addressId || '').trim() + const commentRaw = request.body?.comment + const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() - const paymentMethodRaw = request.body?.paymentMethod; - const paymentMethod = - paymentMethodRaw === undefined || - paymentMethodRaw === null || - paymentMethodRaw === "" - ? "online" - : String(paymentMethodRaw).trim(); - if (paymentMethod !== "online" && paymentMethod !== "on_pickup") { - return reply - .code(400) - .send({ error: "paymentMethod должен быть online | on_pickup" }); + const paymentMethodRaw = request.body?.paymentMethod + const paymentMethod = + paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === '' + ? 'online' + : String(paymentMethodRaw).trim() + if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') { + return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' }) + } + + if (deliveryType !== 'delivery' && deliveryType !== 'pickup') { + return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' }) + } + + const carrierRaw = request.body?.deliveryCarrier + let deliveryCarrier = null + if (deliveryType === 'delivery') { + const carrierStr = + carrierRaw === undefined || carrierRaw === null || carrierRaw === '' ? '' : String(carrierRaw).trim() + if (!isDeliveryCarrier(carrierStr)) { + return reply.code(400).send({ + error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST', + }) } + deliveryCarrier = carrierStr + } - if (deliveryType !== "delivery" && deliveryType !== "pickup") { - return reply - .code(400) - .send({ error: "deliveryType должен быть delivery | pickup" }); + if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') { + return reply.code(400).send({ + error: 'Оплата при получении доступна только для самовывоза', + }) + } + + let address = null + if (deliveryType === 'delivery') { + if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' }) + address = await prisma.shippingAddress.findFirst({ + where: { id: addressId, userId }, + }) + if (!address) return reply.code(404).send({ error: 'Адрес не найден' }) + } + + const cartItems = await prisma.cartItem.findMany({ + where: { userId }, + include: { product: true }, + }) + if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' }) + + for (const ci of cartItems) { + const available = ci.product.inStock ? ci.product.quantity : 1 + if (ci.qty > available) { + return reply.code(409).send({ + error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`, + }) } + } - const carrierRaw = request.body?.deliveryCarrier; - let deliveryCarrier = null; - if (deliveryType === "delivery") { - const carrierStr = - carrierRaw === undefined || carrierRaw === null || carrierRaw === "" - ? "" - : String(carrierRaw).trim(); - if (!isDeliveryCarrier(carrierStr)) { - return reply.code(400).send({ - error: - "deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST", - }); - } - deliveryCarrier = carrierStr; - } + const itemsPayload = cartItems.map((ci) => ({ + productId: ci.productId, + qty: ci.qty, + titleSnapshot: ci.product.title, + priceCentsSnapshot: ci.product.priceCents, + })) - if (paymentMethod === "on_pickup" && deliveryType !== "pickup") { - return reply - .code(400) - .send({ - error: "Оплата при получении доступна только для самовывоза", - }); - } + const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0) + const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0 + const totalCents = itemsSubtotalCents + deliveryFeeCents - let address = null; - if (deliveryType === "delivery") { - if (!addressId) - return reply.code(400).send({ error: "Выберите адрес доставки" }); - address = await prisma.shippingAddress.findFirst({ - where: { id: addressId, userId }, - }); - if (!address) return reply.code(404).send({ error: "Адрес не найден" }); - } + const addressSnapshotJson = + deliveryType === 'pickup' + ? JSON.stringify({ deliveryType: 'pickup' }) + : JSON.stringify({ + deliveryType: 'delivery', + id: address.id, + label: address.label, + recipientName: address.recipientName, + recipientPhone: address.recipientPhone, + addressLine: address.addressLine, + comment: address.comment, + lat: address.lat, + lng: address.lng, + }) - const cartItems = await prisma.cartItem.findMany({ - where: { userId }, - include: { product: true }, - }); - if (cartItems.length === 0) - return reply.code(400).send({ error: "Корзина пуста" }); + let initialStatus = 'PENDING_PAYMENT' + let deliveryFeeLocked = true + if (paymentMethod === 'on_pickup') { + initialStatus = 'IN_PROGRESS' + } else if (deliveryType === 'delivery') { + initialStatus = 'PENDING_PAYMENT' + deliveryFeeLocked = false + } - for (const ci of cartItems) { - const available = ci.product.inStock ? ci.product.quantity : 1; - if (ci.qty > available) { - return reply - .code(409) - .send({ - error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`, - }); - } - } + let created + try { + created = await prisma.$transaction(async (tx) => { + for (const ci of cartItems) { + if (!ci.product.inStock) continue - const itemsPayload = cartItems.map((ci) => ({ - productId: ci.productId, - qty: ci.qty, - titleSnapshot: ci.product.title, - priceCentsSnapshot: ci.product.priceCents, - })); - - const itemsSubtotalCents = itemsPayload.reduce( - (sum, i) => sum + i.priceCentsSnapshot * i.qty, - 0, - ); - const deliveryFeeCents = deliveryType === "delivery" ? 50000 : 0; - const totalCents = itemsSubtotalCents + deliveryFeeCents; - - const addressSnapshotJson = - deliveryType === "pickup" - ? JSON.stringify({ deliveryType: "pickup" }) - : JSON.stringify({ - deliveryType: "delivery", - id: address.id, - label: address.label, - recipientName: address.recipientName, - recipientPhone: address.recipientPhone, - addressLine: address.addressLine, - comment: address.comment, - lat: address.lat, - lng: address.lng, - }); - - let initialStatus = "PENDING_PAYMENT"; - let deliveryFeeLocked = true; - if (paymentMethod === "on_pickup") { - initialStatus = "IN_PROGRESS"; - } else if (deliveryType === "delivery") { - initialStatus = "PENDING_PAYMENT"; - deliveryFeeLocked = false; - } - - let created; - try { - created = await prisma.$transaction(async (tx) => { - for (const ci of cartItems) { - if (!ci.product.inStock) continue; - - const res = await tx.product.updateMany({ - where: { id: ci.productId, quantity: { gte: ci.qty } }, - data: { quantity: { decrement: ci.qty } }, - }); - if (res.count !== 1) { - throw new Error(`Недостаточно товара: "${ci.product.title}"`); - } + const res = await tx.product.updateMany({ + where: { id: ci.productId, quantity: { gte: ci.qty } }, + data: { quantity: { decrement: ci.qty } }, + }) + if (res.count !== 1) { + throw new Error(`Недостаточно товара: "${ci.product.title}"`) } + } - const order = await tx.order.create({ - data: { - userId, - status: initialStatus, - deliveryFeeLocked, - deliveryType, - deliveryCarrier, - paymentMethod, - itemsSubtotalCents, - deliveryFeeCents, - totalCents, - currency: "RUB", - addressSnapshotJson, - comment: comment && comment.length ? comment : null, - items: { - create: itemsPayload.map((i) => ({ - productId: i.productId, - qty: i.qty, - titleSnapshot: i.titleSnapshot, - priceCentsSnapshot: i.priceCentsSnapshot, - })), - }, + const order = await tx.order.create({ + data: { + userId, + status: initialStatus, + deliveryFeeLocked, + deliveryType, + deliveryCarrier, + paymentMethod, + itemsSubtotalCents, + deliveryFeeCents, + totalCents, + currency: 'RUB', + addressSnapshotJson, + comment: comment && comment.length ? comment : null, + items: { + create: itemsPayload.map((i) => ({ + productId: i.productId, + qty: i.qty, + titleSnapshot: i.titleSnapshot, + priceCentsSnapshot: i.priceCentsSnapshot, + })), }, - }); - await tx.cartItem.deleteMany({ where: { userId } }); - return order; - }); - } catch (e) { - return reply - .code(409) - .send({ - error: (e instanceof Error && e.message) || "Недостаточно товара", - }); - } + }, + }) + await tx.cartItem.deleteMany({ where: { userId } }) + return order + }) + } catch (e) { + return reply.code(409).send({ + error: (e instanceof Error && e.message) || 'Недостаточно товара', + }) + } - // Emit notification events - request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, { - orderId: created.id, - userId, - totalCents: created.totalCents, - itemsCount: cartItems.length, - deliveryType: created.deliveryType, - }); + // Emit notification events + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, { + orderId: created.id, + userId, + totalCents: created.totalCents, + itemsCount: cartItems.length, + deliveryType: created.deliveryType, + }) - // Also emit admin notification - request.server.eventBus.emit("order:created:admin", { - orderId: created.id, - userId, - userEmail: request.user.email || "", - totalCents: created.totalCents, - itemsCount: cartItems.length, - deliveryType: created.deliveryType, - }); + // Also emit admin notification + request.server.eventBus.emit('order:created:admin', { + orderId: created.id, + userId, + userEmail: request.user.email || '', + totalCents: created.totalCents, + itemsCount: cartItems.length, + deliveryType: created.deliveryType, + }) - return reply.code(201).send({ orderId: created.id }); - }, - ); + return reply.code(201).send({ orderId: created.id }) + }) + + fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const orders = await prisma.order.findMany({ + where: { userId }, + include: { items: true }, + orderBy: { createdAt: 'desc' }, + }) + return { + items: orders.map((o) => ({ + id: o.id, + status: o.status, + totalCents: o.totalCents, + currency: o.currency, + createdAt: o.createdAt, + updatedAt: o.updatedAt, + itemsCount: o.items.reduce((s, i) => s + i.qty, 0), + })), + } + }) + + fastify.get('/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ + where: { id, userId }, + include: { items: true, messages: { orderBy: { createdAt: 'asc' } } }, + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + return { item: order } + }) fastify.get( - "/api/me/orders", - { preHandler: [fastify.authenticate] }, - async (request) => { - const userId = request.user.sub; - const orders = await prisma.order.findMany({ - where: { userId }, - include: { items: true }, - orderBy: { createdAt: "desc" }, - }); - return { - items: orders.map((o) => ({ - id: o.id, - status: o.status, - totalCents: o.totalCents, - currency: o.currency, - createdAt: o.createdAt, - updatedAt: o.updatedAt, - itemsCount: o.items.reduce((s, i) => s + i.qty, 0), - })), - }; - }, - ); - - fastify.get( - "/api/me/orders/:id", + '/api/me/orders/:id/review-eligibility', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub; - const { id } = request.params; - const order = await prisma.order.findFirst({ - where: { id, userId }, - include: { items: true, messages: { orderBy: { createdAt: "asc" } } }, - }); - if (!order) return reply.code(404).send({ error: "Заказ не найден" }); - return { item: order }; - }, - ); - - fastify.get( - "/api/me/orders/:id/review-eligibility", - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub; - const { id } = request.params; + const userId = request.user.sub + const { id } = request.params const order = await prisma.order.findFirst({ where: { id, userId }, include: { items: true }, - }); - if (!order) return reply.code(404).send({ error: "Заказ не найден" }); - if (order.status !== "DONE") { - return { canReview: false, items: [] }; + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + if (order.status !== 'DONE') { + return { canReview: false, items: [] } } - const uniq = new Map(); + const uniq = new Map() for (const it of order.items) { if (!uniq.has(it.productId)) { uniq.set(it.productId, { productId: it.productId, title: it.titleSnapshot, - }); + }) } } - const productIds = [...uniq.keys()]; + const productIds = [...uniq.keys()] const existing = await prisma.review.findMany({ where: { userId, productId: { in: productIds } }, select: { productId: true }, - }); - const reviewed = new Set(existing.map((r) => r.productId)); + }) + const reviewed = new Set(existing.map((r) => r.productId)) return { canReview: true, items: [...uniq.values()].map((x) => ({ ...x, hasReview: reviewed.has(x.productId), })), - }; + } }, - ); + ) fastify.post( - "/api/me/orders/:id/confirm-received", + '/api/me/orders/:id/confirm-received', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub; - const { id } = request.params; - const order = await prisma.order.findFirst({ where: { id, userId } }); - if (!order) return reply.code(404).send({ error: "Заказ не найден" }); + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ where: { id, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - const okDelivery = - order.deliveryType === "delivery" && order.status === "SHIPPED"; - const okPickup = - order.deliveryType === "pickup" && order.status === "READY_FOR_PICKUP"; + const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED' + const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP' if (!okDelivery && !okPickup) { - return reply - .code(409) - .send({ error: "Сейчас нельзя подтвердить получение заказа" }); + return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' }) } - await prisma.order.update({ where: { id }, data: { status: "DONE" } }); - return { ok: true, status: "DONE" }; + await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) + return { ok: true, status: 'DONE' } }, - ); + ) } diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index a864cad..98ccd4d 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -1,142 +1,114 @@ -import { prisma } from "../lib/prisma.js"; -import { escapeHtml } from "../lib/escape-html.js"; -import { getOtherUploadMaxFileBytes } from "../lib/upload-limits.js"; -import { saveImageBufferToUploads } from "../lib/upload-images.js"; -import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js"; +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { escapeHtml } from '../lib/escape-html.js' +import { prisma } from '../lib/prisma.js' +import { saveImageBufferToUploads } from '../lib/upload-images.js' +import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js' export async function registerUserPaymentRoutes(fastify) { - fastify.post( - "/api/me/orders/:id/pay", - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub; - const { id } = request.params; - const order = await prisma.order.findFirst({ where: { id, userId } }); - if (!order) return reply.code(404).send({ error: "Заказ не найден" }); + fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ where: { id, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - const paymentMethod = order.paymentMethod ?? "online"; - if (paymentMethod === "on_pickup") { - return reply - .code(409) - .send({ - error: - "Для этого заказа оплата при получении — кнопка оплаты не нужна.", - }); - } + const paymentMethod = order.paymentMethod ?? 'online' + if (paymentMethod === 'on_pickup') { + return reply.code(409).send({ + error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.', + }) + } - if (order.status !== "PENDING_PAYMENT") { - return reply - .code(409) - .send({ error: "Сейчас нельзя выполнить оплату для этого заказа" }); - } + if (order.status !== 'PENDING_PAYMENT') { + return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) + } - if (!request.isMultipart()) { - return reply - .code(400) - .send({ - error: - "Отправьте multipart/form-data: поле detail и/или файл receipt", - }); - } + if (!request.isMultipart()) { + return reply.code(400).send({ + error: 'Отправьте multipart/form-data: поле detail и/или файл receipt', + }) + } - let detail = ""; - let receiptBuffer = null; - let receiptFilename = ""; - try { - const otherLimit = getOtherUploadMaxFileBytes(); - const parts = request.parts({ - limits: { - fileSize: otherLimit, - files: 2, - }, - }); - for await (const part of parts) { - if (part.file) { - if (part.fieldname === "receipt") { - if (receiptBuffer !== null) { - return reply - .code(400) - .send({ error: "Допускается один файл receipt" }); - } - receiptBuffer = await part.toBuffer(); - receiptFilename = part.filename ?? "receipt"; + let detail = '' + let receiptBuffer = null + let receiptFilename = '' + try { + const otherLimit = getOtherUploadMaxFileBytes() + const parts = request.parts({ + limits: { + fileSize: otherLimit, + files: 2, + }, + }) + for await (const part of parts) { + if (part.file) { + if (part.fieldname === 'receipt') { + if (receiptBuffer !== null) { + return reply.code(400).send({ error: 'Допускается один файл receipt' }) } - } else if (part.fieldname === "detail") { - detail = String(part.value ?? "").trim(); + receiptBuffer = await part.toBuffer() + receiptFilename = part.filename ?? 'receipt' } - } - } catch (err) { - const msg = - err instanceof Error ? err.message : "Не удалось разобрать форму"; - return reply.code(400).send({ error: msg }); - } - - const hasDetail = detail.length > 0; - const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0; - - if (!hasDetail && !hasReceipt) { - return reply - .code(400) - .send({ - error: "Укажите текст о платеже и/или прикрепите изображение чека", - }); - } - - const maxDetail = 2000; - if (detail.length > maxDetail) { - return reply - .code(400) - .send({ error: `Текст не длиннее ${maxDetail} символов` }); - } - - let attachmentUrl = null; - if (hasReceipt) { - try { - attachmentUrl = await saveImageBufferToUploads( - receiptFilename, - receiptBuffer, - ); - } catch (err) { - const message = - err instanceof Error ? err.message : "Не удалось сохранить файл"; - const statusCode = - err && - typeof err === "object" && - "statusCode" in err && - Number.isInteger(err.statusCode) - ? Number(err.statusCode) - : 400; - return reply.code(statusCode).send({ error: message }); + } else if (part.fieldname === 'detail') { + detail = String(part.value ?? '').trim() } } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму' + return reply.code(400).send({ error: msg }) + } - const bodyHtml = hasDetail - ? `

${escapeHtml(detail).replace(/\r\n|\n|\r/g, "
")}

` - : ""; - const messageText = `

Подтверждение оплаты (перевод ВТБ / Сбербанк)

${bodyHtml}`; + const hasDetail = detail.length > 0 + const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0 + if (!hasDetail && !hasReceipt) { + return reply.code(400).send({ + error: 'Укажите текст о платеже и/или прикрепите изображение чека', + }) + } + + const maxDetail = 2000 + if (detail.length > maxDetail) { + return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` }) + } + + let attachmentUrl = null + if (hasReceipt) { try { - await prisma.$transaction(async (tx) => { - await tx.orderMessage.create({ - data: { - orderId: id, - authorType: "user", - text: messageText, - attachmentUrl, - }, - }); - }); + attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer) } catch (err) { - return reply.code(500).send({ error: "Не удалось сохранить оплату" }); + const message = err instanceof Error ? err.message : 'Не удалось сохранить файл' + const statusCode = + err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode) + ? Number(err.statusCode) + : 400 + return reply.code(statusCode).send({ error: message }) } + } - request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { - orderId: id, - userId, - paymentStatus: "pending", - }); + const bodyHtml = hasDetail ? `

${escapeHtml(detail).replace(/\r\n|\n|\r/g, '
')}

` : '' + const messageText = `

Подтверждение оплаты (перевод ВТБ / Сбербанк)

${bodyHtml}` - return { ok: true, status: "PENDING_PAYMENT" }; - }, - ); + try { + await prisma.$transaction(async (tx) => { + await tx.orderMessage.create({ + data: { + orderId: id, + authorType: 'user', + text: messageText, + attachmentUrl, + }, + }) + }) + } catch { + return reply.code(500).send({ error: 'Не удалось сохранить оплату' }) + } + + request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId: id, + userId, + paymentStatus: 'pending', + }) + + return { ok: true, status: 'PENDING_PAYMENT' } + }) } diff --git a/server/src/routes/user/notifications.js b/server/src/routes/user/notifications.js index c12f393..0750cec 100644 --- a/server/src/routes/user/notifications.js +++ b/server/src/routes/user/notifications.js @@ -1,39 +1,31 @@ -import { prisma } from '../../lib/prisma.js' import { ensureUserNotificationPreference } from '../../lib/notifications/preferences.js' +import { prisma } from '../../lib/prisma.js' export async function registerUserNotificationRoutes(fastify) { - fastify.get( - '/api/me/notifications/settings', - { preHandler: [fastify.authenticate] }, - async (request) => { - const userId = request.user.sub - const prefs = await ensureUserNotificationPreference(userId) - return { settings: prefs } - }, - ) + fastify.get('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const prefs = await ensureUserNotificationPreference(userId) + return { settings: prefs } + }) - fastify.put( - '/api/me/notifications/settings', - { preHandler: [fastify.authenticate] }, - async (request) => { - const userId = request.user.sub - const body = request.body || {} + fastify.put('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const body = request.body || {} - const data = {} - if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled) - if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated) - if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged) - if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived) - if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged) - if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted) + const data = {} + if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled) + if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated) + if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged) + if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived) + if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged) + if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted) - const prefs = await prisma.notificationPreference.upsert({ - where: { userId }, - create: { userId, ...data }, - update: data, - }) + const prefs = await prisma.notificationPreference.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + }) - return { settings: prefs } - }, - ) + return { settings: prefs } + }) } diff --git a/server/vitest.config.ts b/server/vitest.config.ts deleted file mode 100644 index 47cdb03..0000000 --- a/server/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - globals: true, - }, -}) diff --git a/shared/constants/delivery-carrier.d.ts b/shared/constants/delivery-carrier.d.ts index cec41db..b9ece2f 100644 --- a/shared/constants/delivery-carrier.d.ts +++ b/shared/constants/delivery-carrier.d.ts @@ -1 +1,10 @@ export declare const DELIVERY_CARRIERS: readonly ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'] + +export declare const DELIVERY_CARRIER_LABELS: { + readonly RUSSIAN_POST: 'Почта России' + readonly OZON_PVZ: 'Озон доставка (пункт выдачи)' + readonly YANDEX_PVZ: 'Яндекс доставка (пункт выдачи)' + readonly FIVE_POST: '5Post (пункт выдачи)' +} + +export declare function deliveryCarrierLabelRu(code: string | null | undefined): string | null diff --git a/shared/constants/delivery-carrier.js b/shared/constants/delivery-carrier.js index 1233e6e..06bc1d5 100644 --- a/shared/constants/delivery-carrier.js +++ b/shared/constants/delivery-carrier.js @@ -1 +1,13 @@ export const DELIVERY_CARRIERS = Object.freeze(['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST']) + +export const DELIVERY_CARRIER_LABELS = Object.freeze({ + RUSSIAN_POST: 'Почта России', + OZON_PVZ: 'Озон доставка (пункт выдачи)', + YANDEX_PVZ: 'Яндекс доставка (пункт выдачи)', + FIVE_POST: '5Post (пункт выдачи)', +}) + +export function deliveryCarrierLabelRu(code) { + if (!code) return null + return DELIVERY_CARRIER_LABELS[code] ?? code +} diff --git a/shared/constants/order-status.d.ts b/shared/constants/order-status.d.ts index 74aaa24..ad0a04f 100644 --- a/shared/constants/order-status.d.ts +++ b/shared/constants/order-status.d.ts @@ -8,3 +8,10 @@ export declare const ORDER_STATUSES: readonly [ 'DONE', 'CANCELLED', ] + +export type OrderStatus = (typeof ORDER_STATUSES)[number] + +export declare const ADMIN_ORDER_TRANSITIONS: Record + +export declare function getNextAdminStatuses(from: string, deliveryType: string): string[] +export declare function canTransitionAdminOrderStatus(order: { status: string; deliveryType: string }, next: string): boolean diff --git a/shared/constants/order-status.js b/shared/constants/order-status.js index 186a3fe..7795eb7 100644 --- a/shared/constants/order-status.js +++ b/shared/constants/order-status.js @@ -8,3 +8,31 @@ export const ORDER_STATUSES = Object.freeze([ 'DONE', 'CANCELLED', ]) + +/** + * Допустимые переходы статусов, доступные админу. + * Значение — массив из next-статусов. + * Для IN_PROGRESS: объект с ключами по deliveryType. + */ +export const ADMIN_ORDER_TRANSITIONS = Object.freeze({ + DRAFT: ['PENDING_PAYMENT', 'CANCELLED'], + PENDING_PAYMENT: ['PAID', 'CANCELLED'], + PAID: ['IN_PROGRESS', 'CANCELLED'], + IN_PROGRESS: Object.freeze({ + delivery: ['SHIPPED', 'CANCELLED'], + pickup: ['READY_FOR_PICKUP', 'CANCELLED'], + }), +}) + +export function getNextAdminStatuses(from, deliveryType) { + const transition = ADMIN_ORDER_TRANSITIONS[from] + if (!transition) return [] + if (Array.isArray(transition)) return [...transition] + return transition[deliveryType] ? [...transition[deliveryType]] : [] +} + +export function canTransitionAdminOrderStatus(order, next) { + const from = order.status + if (from === next) return true + return getNextAdminStatuses(from, order.deliveryType).includes(next) +}