Merge branch 'refack'

This commit is contained in:
Kirill
2026-05-19 11:26:17 +05:00
81 changed files with 6549 additions and 3108 deletions
-1
View File
@@ -14,5 +14,4 @@ uploads/.cache/
server/uploads/
# Plans and design docs
.opencode/plans/
.agents
@@ -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/<subdir>/`
- 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: "Файл «<name>» слишком большой (максимум 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/<uuid>.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 "Файл «<name>» слишком большой (максимум 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
@@ -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/<uuid>.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 ? (
<Alert severity="error" sx={{ mt: 2 }}>
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
</Alert>
) : null}
```
With:
```tsx
{uploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
{uploadError instanceof Error ? uploadError.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
</Alert>
) : 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 `<img>` 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"
```
@@ -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 «Категория»:
- Удалить `<MenuItem value="">` с «Не указано»
- Валидация: не даёт сохранить без выбранной категории
- Показать ошибку при попытке сохранить без категории
**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
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -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.
+3 -2
View File
@@ -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`.
@@ -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<Product[]> {
const { data } = await apiClient.get<Product[]>('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<Product> {
const { data } = await apiClient.post<Product>('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<Product> {
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
return data
}
export async function deleteProduct(id: string): Promise<void> {
await apiClient.delete(`admin/products/${id}`)
}
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
const { data } = await apiClient.post<Category>('admin/categories', body)
return data
}
export async function fetchAdminCategories(): Promise<Category[]> {
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<Category> {
const { data } = await apiClient.patch<Category>(`admin/categories/${id}`, body)
return data
}
export async function deleteAdminCategory(id: string): Promise<void> {
await apiClient.delete(`admin/categories/${id}`)
}
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
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
}
@@ -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<Category[]> {
const { data } = await apiClient.get<Category[]>('categories')
return data
}
export async function fetchAdminProducts(): Promise<Product[]> {
const { data } = await apiClient.get<Product[]>('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<Product> {
const { data } = await apiClient.post<Product>('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<Product> {
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
return data
}
export async function deleteProduct(id: string): Promise<void> {
await apiClient.delete(`admin/products/${id}`)
}
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
const { data } = await apiClient.post<Category>('admin/categories', body)
return data
}
export async function fetchAdminCategories(): Promise<Category[]> {
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<Category> {
const { data } = await apiClient.patch<Category>(`admin/categories/${id}`, body)
return data
}
export async function deleteAdminCategory(id: string): Promise<void> {
await apiClient.delete(`admin/categories/${id}`)
}
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
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
}
@@ -0,0 +1,2 @@
export { AddressFormDialog } from './ui/AddressFormDialog'
export type { AddressFormValues } from './ui/AddressFormDialog'
@@ -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<AddressFormValues>
onSubmit: () => void
isPending: boolean
}) {
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={form.control}
name="label"
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Controller
control={form.control}
name="recipientName"
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="recipientPhone"
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
/>
</Stack>
<Controller
control={form.control}
name="addressLine"
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="comment"
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
/>
<Controller
control={form.control}
name="lat"
render={({ field: latField }) => (
<Controller
control={form.control}
name="lng"
render={({ field: lngField }) => (
<AddressMapPicker
value={
latField.value !== null && lngField.value !== null
? { lat: latField.value, lng: lngField.value }
: null
}
onChange={(v) => {
latField.onChange(v.lat)
lngField.onChange(v.lng)
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
}}
/>
)}
/>
)}
/>
<Controller
control={form.control}
name="isDefault"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
label="Адрес по умолчанию"
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Отмена</Button>
<Button
variant="contained"
onClick={onSubmit}
disabled={
isPending ||
!form.watch('recipientName').trim() ||
!form.watch('recipientPhone').trim() ||
!form.watch('addressLine').trim()
}
>
Сохранить
</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,2 @@
export { DeliveryFeeAdjustmentForm } from './ui/DeliveryFeeAdjustmentForm'
export { OrderDetailContent } from './ui/OrderDetailContent'
@@ -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 (
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
size="small"
label="Доставка, ₽"
type="number"
value={rub}
onChange={(e) => setRub(e.target.value)}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
sx={{ width: { xs: '100%', sm: 200 } }}
/>
<Button
variant="contained"
disabled={
feeMut.isPending ||
!rub.trim() ||
!Number.isFinite(Number.parseFloat(rub)) ||
Number.parseFloat(rub) < 0 ||
!Number.isInteger(Number.parseFloat(rub))
}
onClick={() => feeMut.mutate()}
>
Утвердить доставку и открыть оплату
</Button>
</Stack>
)
}
@@ -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 (
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography sx={{ fontWeight: 700 }}>
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
{formatPriceRub(detail.totalCents)}
</Typography>
<Typography variant="body2" color="text.secondary">
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
)}
</Typography>
{detail.deliveryType === 'delivery' && (
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'action.hover',
}}
>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
Адрес и получатель (на момент заказа)
</Typography>
{deliverySnapshot ? (
<Stack spacing={0.75}>
{deliverySnapshot.label?.trim() && (
<Typography variant="body2" color="text.secondary">
Метка: {deliverySnapshot.label}
</Typography>
)}
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Адрес:
</Box>{' '}
{deliverySnapshot.addressLine ?? '—'}
</Typography>
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Получатель:
</Box>{' '}
{deliverySnapshot.recipientName ?? '—'}
</Typography>
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Телефон:
</Box>{' '}
{deliverySnapshot.recipientPhone ?? '—'}
</Typography>
{deliverySnapshot.comment?.trim() && (
<Typography variant="body2" color="text.secondary">
Комментарий к адресу: {deliverySnapshot.comment}
</Typography>
)}
</Stack>
) : (
<Typography color="text.secondary" variant="body2">
Данные адреса в заказе отсутствуют или не распознаны.
</Typography>
)}
</Box>
)}
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
<Alert severity="info">
Укажите итоговую стоимость доставки (). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
</Alert>
)}
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
)}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel id="next-status-label">Сменить статус</InputLabel>
<Select
labelId="next-status-label"
label="Сменить статус"
value=""
onChange={(e) => {
const next = String(e.target.value)
if (!next) return
statusMut.mutate(next)
}}
disabled={statusMut.isPending || nextStatuses.length === 0}
>
<MenuItem value="">
<em>Выберите</em>
</MenuItem>
{nextStatuses.map((s) => (
<MenuItem key={s} value={s}>
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Сообщения
</Typography>
<Stack spacing={1} sx={{ mb: 1 }}>
{detail.messages.map((m) => (
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
</ChatMessageBubble>
))}
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
</Box>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !canSendMessage}
sx={{ minWidth: 160 }}
>
Отправить
</Button>
</Stack>
</Box>
</Stack>
)
}
@@ -0,0 +1,2 @@
export type { FormState } from './model/types'
export { emptyForm } from './lib/use-product-form-helpers'
@@ -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: '',
})
@@ -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
}
@@ -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<Set<string>>(() => 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 (
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
<DialogTitle>Изображения из галереи</DialogTitle>
<DialogContent dividers>
{galleryQuery.isLoading && <Typography color="text.secondary">Загрузка списка</Typography>}
{galleryQuery.isError && <Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>}
{galleryQuery.data?.items.length === 0 && !galleryQuery.isLoading && (
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
)}
{galleryQuery.data &&
galleryQuery.data.items.length > 0 &&
galleryQuery.data.items.filter((i) => i.isResized).length === 0 &&
!galleryQuery.isLoading && (
<Typography color="text.secondary">
В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
</Typography>
)}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 1.5,
pt: 1,
}}
>
{(galleryQuery.data?.items ?? [])
.filter((item) => item.isResized)
.map((item) => {
const alreadyInCard = currentUrls.includes(item.url)
return (
<FormControlLabel
key={item.id}
sx={{ m: 0, alignItems: 'flex-start' }}
control={
<Checkbox
checked={alreadyInCard || selectedUrls.has(item.url)}
disabled={alreadyInCard}
onChange={() => toggleUrl(item.url)}
/>
}
label={
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
<OptimizedImage
src={item.url}
alt=""
widths={[320, 640]}
sizes="120px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
}
/>
)
})}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Отмена</Button>
<Button
variant="contained"
onClick={handleApply}
disabled={![...selectedUrls].some((u) => !currentUrls.includes(u))}
>
Добавить
</Button>
</DialogActions>
</Dialog>
)
}
@@ -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<FormState>
categories: Category[]
onRemoveImage: (url: string) => void
onPickFromGallery: () => void
}) {
return (
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={form.control}
name="title"
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="slug"
render={({ field }) => (
<TextField
label="Slug (URL)"
fullWidth
{...field}
helperText="Можно оставить пустым при создании — сгенерируется из названия"
/>
)}
/>
<Controller
control={form.control}
name="shortDescription"
render={({ field }) => (
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
)}
/>
<Controller
control={form.control}
name="description"
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
/>
<Controller
control={form.control}
name="materials"
render={({ field }) => (
<TextField
label="Материалы"
fullWidth
{...field}
helperText="Список через запятую (например: хлопок, дерево, акрил)"
/>
)}
/>
<Controller
control={form.control}
name="quantity"
rules={{
validate: (v) => {
const n = Number(v)
if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10'
return true
},
}}
render={({ field, fieldState }) => (
<TextField
label="Количество"
fullWidth
{...field}
inputMode="numeric"
onChange={(e) => {
const v = e.target.value.replace(/[^0-9]/g, '')
field.onChange(v)
}}
helperText={fieldState.error?.message ?? '0 = нет в наличии'}
error={!!fieldState.error}
/>
)}
/>
<Controller
control={form.control}
name="priceRub"
rules={{
required: 'Укажите цену',
validate: (v) => {
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 }) => (
<TextField
label="Цена, ₽"
fullWidth
{...field}
inputMode="decimal"
onChange={(e) => {
const v = e.target.value.replace(/[^0-9.,]/g, '')
field.onChange(v)
}}
helperText={fieldState.error?.message}
error={!!fieldState.error}
/>
)}
/>
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Фото (из галереи)
</Typography>
<FormHelperText sx={{ mt: 0, mb: 1 }}>
Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл остаётся
на сервере и в галерее.
</FormHelperText>
<Box
sx={{
display: 'flex',
gap: 2,
alignItems: { sm: 'center' },
flexDirection: { xs: 'column', sm: 'row' },
flexWrap: 'wrap',
}}
>
<Button variant="outlined" onClick={onPickFromGallery}>
Из галереи
</Button>
</Box>
{form.watch('imageUrls').length > 0 && (
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{form.watch('imageUrls').map((url) => (
<Box
key={url}
sx={{
width: 92,
height: 92,
borderRadius: 1,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
position: 'relative',
}}
title={url}
>
<OptimizedImage
src={url}
alt="Фото товара"
widths={[320, 640]}
sizes="80px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<Button
size="small"
color="error"
variant="contained"
onClick={() => onRemoveImage(url)}
aria-label="Убрать из карточки"
title="Убрать из карточки"
sx={{
position: 'absolute',
top: 4,
right: 4,
minWidth: 0,
px: 0.75,
py: 0,
lineHeight: 1.2,
}}
>
×
</Button>
</Box>
))}
</Box>
)}
</Box>
<Controller
control={form.control}
name="categoryId"
render={({ field }) => (
<FormControl fullWidth error={!field.value}>
<InputLabel id="cat-label">Категория</InputLabel>
<Select labelId="cat-label" label="Категория" {...field}>
{categories.map((c: Category) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</Select>
{!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
</FormControl>
)}
/>
<Controller
control={form.control}
name="published"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
label="Показывать в каталоге"
/>
)}
/>
</Stack>
)
}
@@ -1,2 +1,3 @@
export { ReviewSection } from './ui/ReviewSection'
export { ReviewDialog } from './ui/ReviewDialog'
export { ProductReviewsList } from './ui/ProductReviewsList'
@@ -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 (
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={0.75}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}>
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(rv.createdAt).toLocaleString('ru-RU')}
</Typography>
</Stack>
<Rating
value={rv.rating}
readOnly
size="small"
icon={<Star fontSize="inherit" />}
emptyIcon={<Star fontSize="inherit" />}
/>
{body ? (
<Box sx={{ color: 'text.secondary' }}>
<RichTextMessageContent value={body} tone="review" />
</Box>
) : (
<Typography variant="caption" color="text.secondary">
Без текстового комментария.
</Typography>
)}
{rv.imageUrl && (
<Box
sx={{
width: 140,
height: 140,
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
}}
>
<OptimizedImage
src={rv.imageUrl}
alt="Фото к отзыву"
widths={[320, 640]}
sizes="140px"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
)}
</Stack>
</Paper>
)
}
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 <Typography color="text.secondary">Загрузка отзывов</Typography>
if (reviewsQuery.isError) return <Alert severity="warning">Не удалось загрузить отзывы.</Alert>
if (reviewsQuery.data && reviewsQuery.data.total === 0) {
return (
<Box sx={{ py: 3 }}>
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
Отзывов пока нет
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
Будьте первым, кто оставит отзыв на этот товар. Ваше мнение поможет улучшить качество наших изделий.
</Typography>
</Box>
)
}
if (!reviewsQuery.data || reviewsQuery.data.items.length === 0) return null
return (
<Stack spacing={1.25}>
{reviewsQuery.data.items.map((rv) => (
<ReviewItem key={rv.id} rv={rv} />
))}
{reviewsQuery.data.total > reviewsQuery.data.items.length && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Всего {reviewsCountRu(reviewsQuery.data.total)} ниже показаны последние {reviewsQuery.data.items.length}.
</Typography>
)}
</Stack>
)
}
@@ -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'
@@ -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 (
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
size="small"
label="Доставка, ₽"
type="number"
value={rub}
onChange={(e) => setRub(e.target.value)}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
sx={{ width: { xs: '100%', sm: 200 } }}
/>
<Button
variant="contained"
disabled={
feeMut.isPending ||
!rub.trim() ||
!Number.isFinite(Number.parseFloat(rub)) ||
Number.parseFloat(rub) < 0 ||
!Number.isInteger(Number.parseFloat(rub))
}
onClick={() => feeMut.mutate()}
>
Утвердить доставку и открыть оплату
</Button>
</Stack>
)
}
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<string | null>(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 (
<Box>
@@ -252,146 +167,7 @@ export function AdminOrdersPage() {
loading={!detail && orderDetailQuery.isLoading}
error={orderDetailQuery.isError ? 'Не удалось загрузить заказ.' : null}
>
{detail && (
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography sx={{ fontWeight: 700 }}>
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
{formatPriceRub(detail.totalCents)}
</Typography>
<Typography variant="body2" color="text.secondary">
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
)}
</Typography>
{detail.deliveryType === 'delivery' && (
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'action.hover',
}}
>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
Адрес и получатель (на момент заказа)
</Typography>
{deliverySnapshot ? (
<Stack spacing={0.75}>
{deliverySnapshot.label?.trim() && (
<Typography variant="body2" color="text.secondary">
Метка: {deliverySnapshot.label}
</Typography>
)}
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Адрес:
</Box>{' '}
{deliverySnapshot.addressLine ?? '—'}
</Typography>
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Получатель:
</Box>{' '}
{deliverySnapshot.recipientName ?? '—'}
</Typography>
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Телефон:
</Box>{' '}
{deliverySnapshot.recipientPhone ?? '—'}
</Typography>
{deliverySnapshot.comment?.trim() && (
<Typography variant="body2" color="text.secondary">
Комментарий к адресу: {deliverySnapshot.comment}
</Typography>
)}
</Stack>
) : (
<Typography color="text.secondary" variant="body2">
Данные адреса в заказе отсутствуют или не распознаны.
</Typography>
)}
</Box>
)}
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
<Alert severity="info">
Укажите итоговую стоимость доставки (). После сохранения клиент сможет оплатить заказ с учётом этой
суммы.
</Alert>
)}
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
<DeliveryFeeAdjustmentForm
key={detail.id}
orderId={detail.id}
deliveryFeeCents={detail.deliveryFeeCents}
/>
)}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel id="next-status-label">Сменить статус</InputLabel>
<Select
labelId="next-status-label"
label="Сменить статус"
value=""
onChange={(e) => {
const next = String(e.target.value)
if (!next) return
statusMut.mutate(next)
}}
disabled={statusMut.isPending || nextStatuses.length === 0}
>
<MenuItem value="">
<em>Выберите</em>
</MenuItem>
{nextStatuses.map((s) => (
<MenuItem key={s} value={s}>
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Сообщения
</Typography>
<Stack spacing={1} sx={{ mb: 1 }}>
{detail.messages.map((m) => (
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
{new Date(m.createdAt).toLocaleString()}
</Typography>
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
</ChatMessageBubble>
))}
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
</Box>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !canSendMessage}
sx={{ minWidth: 160 }}
>
Отправить
</Button>
</Stack>
</Box>
</Stack>
)}
{detail && <OrderDetailContent detail={detail} orderId={detail.id} />}
</AdminDialog>
</Box>
)
@@ -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<Product>()
const [galleryPickOpen, setGalleryPickOpen] = useState(false)
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
const productForm = useForm<FormState>({
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() {
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={productForm.control}
name="title"
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
/>
<Controller
control={productForm.control}
name="slug"
render={({ field }) => (
<TextField
label="Slug (URL)"
fullWidth
{...field}
helperText="Можно оставить пустым при создании — сгенерируется из названия"
/>
)}
/>
<Controller
control={productForm.control}
name="shortDescription"
render={({ field }) => (
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
)}
/>
<Controller
control={productForm.control}
name="description"
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
/>
<Controller
control={productForm.control}
name="materials"
render={({ field }) => (
<TextField
label="Материалы"
fullWidth
{...field}
helperText="Список через запятую (например: хлопок, дерево, акрил)"
/>
)}
/>
<Controller
control={productForm.control}
name="quantity"
rules={{
validate: (v) => {
const n = Number(v)
if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10'
return true
},
}}
render={({ field, fieldState }) => (
<TextField
label="Количество"
fullWidth
{...field}
inputMode="numeric"
onChange={(e) => {
const v = e.target.value.replace(/[^0-9]/g, '')
field.onChange(v)
}}
helperText={fieldState.error?.message ?? '0 = нет в наличии'}
error={!!fieldState.error}
/>
)}
/>
<Controller
control={productForm.control}
name="priceRub"
rules={{
required: 'Укажите цену',
validate: (v) => {
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 }) => (
<TextField
label="Цена, ₽"
fullWidth
{...field}
inputMode="decimal"
onChange={(e) => {
const v = e.target.value.replace(/[^0-9.,]/g, '')
field.onChange(v)
}}
helperText={fieldState.error?.message}
error={!!fieldState.error}
/>
)}
/>
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Фото (из галереи)
</Typography>
<FormHelperText sx={{ mt: 0, mb: 1 }}>
Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл
остаётся на сервере и в галерее.
</FormHelperText>
<Box
sx={{
display: 'flex',
gap: 2,
alignItems: { sm: 'center' },
flexDirection: { xs: 'column', sm: 'row' },
flexWrap: 'wrap',
}}
>
<Button
variant="outlined"
onClick={() => {
setGallerySelectedUrls(new Set())
setGalleryPickOpen(true)
}}
>
Из галереи
</Button>
</Box>
{productForm.watch('imageUrls').length > 0 && (
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{productForm.watch('imageUrls').map((url) => (
<Box
key={url}
sx={{
width: 92,
height: 92,
borderRadius: 1,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
position: 'relative',
}}
title={url}
>
<OptimizedImage
src={url}
alt="Фото товара"
widths={[320, 640]}
sizes="80px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<Button
size="small"
color="error"
variant="contained"
onClick={() => removeImage(url)}
aria-label="Убрать из карточки"
title="Убрать из карточки"
sx={{
position: 'absolute',
top: 4,
right: 4,
minWidth: 0,
px: 0.75,
py: 0,
lineHeight: 1.2,
}}
>
×
</Button>
</Box>
))}
</Box>
)}
</Box>
<Controller
control={productForm.control}
name="categoryId"
render={({ field }) => (
<FormControl fullWidth error={!field.value}>
<InputLabel id="cat-label">Категория</InputLabel>
<Select labelId="cat-label" label="Категория" {...field}>
{(categoriesQuery.data ?? []).map((c: Category) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</Select>
{!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
</FormControl>
)}
/>
<Controller
control={productForm.control}
name="published"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
label="Показывать в каталоге"
/>
)}
/>
</Stack>
<ProductFormFields
form={productForm}
categories={categoriesQuery.data ?? []}
onRemoveImage={removeImage}
onPickFromGallery={() => setGalleryPickOpen(true)}
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Отмена</Button>
@@ -508,89 +261,12 @@ export function AdminProductsPage() {
</DialogActions>
</Dialog>
<Dialog
<GalleryImagePicker
open={galleryPickOpen}
onClose={() => {
setGalleryPickOpen(false)
setGallerySelectedUrls(new Set())
}}
fullWidth
maxWidth="sm"
>
<DialogTitle>Изображения из галереи</DialogTitle>
<DialogContent dividers>
{galleryForPickQuery.isLoading && <Typography color="text.secondary">Загрузка списка</Typography>}
{galleryForPickQuery.isError && (
<Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>
)}
{galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && (
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
)}
{galleryForPickQuery.data &&
galleryForPickQuery.data.items.length > 0 &&
galleryForPickQuery.data.items.filter((i) => i.isResized).length === 0 &&
!galleryForPickQuery.isLoading && (
<Typography color="text.secondary">
В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
</Typography>
)}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 1.5,
pt: 1,
}}
>
{(galleryForPickQuery.data?.items ?? [])
.filter((item) => item.isResized)
.map((item) => {
const alreadyInCard = productForm.watch('imageUrls').includes(item.url)
return (
<FormControlLabel
key={item.id}
sx={{ m: 0, alignItems: 'flex-start' }}
control={
<Checkbox
checked={alreadyInCard || gallerySelectedUrls.has(item.url)}
disabled={alreadyInCard}
onChange={() => toggleGalleryPickUrl(item.url)}
/>
}
label={
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
<OptimizedImage
src={item.url}
alt=""
widths={[320, 640]}
sizes="120px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
}
/>
)
})}
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setGalleryPickOpen(false)
setGallerySelectedUrls(new Set())
}}
>
Отмена
</Button>
<Button
variant="contained"
onClick={appendGalleryUrlsToForm}
disabled={![...gallerySelectedUrls].some((u) => !productForm.watch('imageUrls').includes(u))}
>
Добавить
</Button>
</DialogActions>
</Dialog>
onClose={() => setGalleryPickOpen(false)}
onSelect={handleGallerySelect}
currentUrls={productForm.watch('imageUrls')}
/>
</Box>
)
}
+29 -126
View File
@@ -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<AddressFormValues>({
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 (
<Box>
<Typography variant="h4" gutterBottom>
@@ -226,93 +208,14 @@ export function AddressesPage() {
)}
</Stack>
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="md">
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={form.control}
name="label"
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Controller
control={form.control}
name="recipientName"
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="recipientPhone"
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
/>
</Stack>
<Controller
control={form.control}
name="addressLine"
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="comment"
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
/>
<Controller
control={form.control}
name="lat"
render={({ field: latField }) => (
<Controller
control={form.control}
name="lng"
render={({ field: lngField }) => (
<AddressMapPicker
value={
latField.value !== null && lngField.value !== null
? { lat: latField.value, lng: lngField.value }
: null
}
onChange={(v) => {
latField.onChange(v.lat)
lngField.onChange(v.lng)
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
}}
/>
)}
/>
)}
/>
<Controller
control={form.control}
name="isDefault"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
label="Адрес по умолчанию"
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>
<Button
variant="contained"
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
disabled={
createMut.isPending ||
updateMut.isPending ||
!form.watch('recipientName').trim() ||
!form.watch('recipientPhone').trim() ||
!form.watch('addressLine').trim()
}
>
Сохранить
</Button>
</DialogActions>
</Dialog>
<AddressFormDialog
open={open}
onClose={() => setOpen(false)}
editing={Boolean(editing)}
form={form}
onSubmit={handleSubmit}
isPending={createMut.isPending || updateMut.isPending}
/>
</Box>
)
}
@@ -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'
+2 -86
View File
@@ -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() {
</Stack>
)}
{reviewsQuery.isLoading && <Typography color="text.secondary">Загрузка отзывов</Typography>}
{reviewsQuery.isError && <Alert severity="warning">Не удалось загрузить отзывы.</Alert>}
{reviewsQuery.data && reviewsQuery.data.total === 0 && (
<Box sx={{ py: 3 }}>
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
Отзывов пока нет
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
Будьте первым, кто оставит отзыв на этот товар. Ваше мнение поможет улучшить качество наших изделий.
</Typography>
</Box>
)}
{reviewsQuery.data && reviewsQuery.data.items.length > 0 && (
<Stack spacing={1.25}>
{reviewsQuery.data.items.map((rv) => {
const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
return (
<Paper key={rv.id} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={0.75}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}>
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(rv.createdAt).toLocaleString('ru-RU')}
</Typography>
</Stack>
<Rating
value={rv.rating}
readOnly
size="small"
icon={<Star fontSize="inherit" />}
emptyIcon={<Star fontSize="inherit" />}
/>
{body ? (
<Box sx={{ color: 'text.secondary' }}>
<RichTextMessageContent value={body} tone="review" />
</Box>
) : (
<Typography variant="caption" color="text.secondary">
Без текстового комментария.
</Typography>
)}
{rv.imageUrl && (
<Box
sx={{
width: 140,
height: 140,
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
}}
>
<OptimizedImage
src={rv.imageUrl}
alt="Фото к отзыву"
widths={[320, 640]}
sizes="140px"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
)}
</Stack>
</Paper>
)
})}
{reviewsQuery.data.total > reviewsQuery.data.items.length && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Всего {reviewsCountRu(reviewsQuery.data.total)} ниже показаны последние{' '}
{reviewsQuery.data.items.length}.
</Typography>
)}
</Stack>
)}
<ProductReviewsList productId={id} />
</Box>
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
+11 -13
View File
@@ -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<DeliveryCarrierCode, string> = Object.fromEntries(
DELIVERY_CARRIER_OPTIONS.map((o) => [o.code, o.label]),
) as Record<DeliveryCarrierCode, string>
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)
}
+5 -14
View File
@@ -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 {
@@ -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'
+1
View File
@@ -11,6 +11,7 @@
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
"strict": true,
/* Bundler mode */
"moduleResolution": "bundler",
@@ -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/`
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"semi": false,
"printWidth": 120,
"trailingComma": "all",
"endOfLine": "lf",
"arrowParens": "always"
}
+64
View File
@@ -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',
},
},
]
+1456
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -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"
}
+1 -1
View File
@@ -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()
+95 -105
View File
@@ -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)
}
+1 -3
View File
@@ -16,8 +16,6 @@ describe('escapeHtml', () => {
})
it('escapes mixed content', () => {
expect(escapeHtml('<script>alert("xss")</script>')).toBe(
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;',
)
expect(escapeHtml('<script>alert("xss")</script>')).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
})
})
+16 -4
View File
@@ -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
@@ -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$/)
})
})
+7 -5
View File
@@ -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
}
+1 -1
View File
@@ -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 },
+2 -2
View File
@@ -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 }
}
-1
View File
@@ -1,4 +1,3 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
@@ -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'
@@ -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 }
}
+38 -44
View File
@@ -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 },
});
})
}
+6 -2
View File
@@ -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`)
}
}
}
@@ -11,132 +11,113 @@ function baseLayout(title, body) {
<p>Любимый Креатив — магазин handmade изделий</p>
</div>
</body>
</html>`;
</html>`
}
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 = `
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
<p>${nextAction}</p>
`;
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 = `
<p>Статус заказа <b>#${orderId.slice(0, 8)}</b> изменён.</p>
<p><b>${oldLabel}</b> → <b>${newLabel}</b></p>
`;
`
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 = `
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
${truncated}
</div>
<p>Ответьте в личном кабинете.</p>
`;
`
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 = `
<p>Статус оплаты заказа <b>#${orderId.slice(0, 8)}</b>: <b>${label}</b>.</p>
`;
`
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"
? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>'
: "";
export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
const note = deliveryType === 'delivery' ? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>' : ''
const body = `
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
${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 = `
<p>Новый отзыв ${stars} на товар <b>${productTitle}</b> от <b>${userName}</b>.</p>
${text ? `<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">${text}</div>` : ""}
${text ? `<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">${text}</div>` : ''}
<p>Проверьте отзыв в админ-панели.</p>
`;
return { subject: "Новый отзыв", html: baseLayout("Новый отзыв", body) };
`
return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) }
}
export function renderAuthCodeEmail({ code }) {
const body = `
<p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p>
<p>Если это были не вы — просто проигнорируйте письмо.</p>
`;
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 = `
<p>Стоимость доставки заказа <b>#${orderId.slice(0, 8)}</b> скорректирована.</p>
<p>Новая сумма: <b>${total} ₽</b></p>
<p>Ожидает оплаты. Проверьте статус заказа в личном кабинете.</p>
`;
`
return {
subject: "Стоимость доставки скорректирована",
html: baseLayout("Стоимость доставки скорректирована", body),
};
subject: 'Стоимость доставки скорректирована',
html: baseLayout('Стоимость доставки скорректирована', body),
}
}
@@ -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 `📦 <b>Новый заказ</b> #${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} → <b>${labels[newStatus] || newStatus}</b>`
}
+5 -37
View File
@@ -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'
+3 -1
View File
@@ -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) {
+4 -9
View File
@@ -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)
}
@@ -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)
})
})
@@ -53,4 +53,3 @@ export function mapProductForApi(p, reviewsSummary = null) {
}
return base
}
+99 -115
View File
@@ -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()
})
}
+88 -104
View File
@@ -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()
})
}
+146 -210
View File
@@ -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 })
})
}
+22 -26
View File
@@ -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: 'Товар не найден' })
}
})
}
+54 -79
View File
@@ -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 }
})
}
+122 -139
View File
@@ -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: 'Пользователь не найден' })
}
})
}
+56 -73
View File
@@ -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 }
})
}
+67 -75
View File
@@ -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,
})),
}
})
}
+63 -79
View File
@@ -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: 'Блок не найден' })
}
})
}
-2
View File
@@ -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)
})
}
+58 -66
View File
@@ -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: 'Вы уже оставляли отзыв на этот товар' })
}
})
}
+93 -131
View File
@@ -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) }
})
}
+2 -2
View File
@@ -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('/') + '/'
+126 -142
View File
@@ -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 }
})
}
+62 -78
View File
@@ -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()
})
}
+115 -143
View File
@@ -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 }
})
}
+214 -255
View File
@@ -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' }
},
);
)
}
+96 -124
View File
@@ -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
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, "<br/>")}</p>`
: "";
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${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 ? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>` : ''
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${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' }
})
}
+23 -31
View File
@@ -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 }
})
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
},
})
+9
View File
@@ -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
+12
View File
@@ -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
}
+7
View File
@@ -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<string, readonly string[] | { readonly delivery: readonly string[]; readonly pickup: readonly string[] }>
export declare function getNextAdminStatuses(from: string, deliveryType: string): string[]
export declare function canTransitionAdminOrderStatus(order: { status: string; deliveryType: string }, next: string): boolean
+28
View File
@@ -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)
}