test commit

This commit is contained in:
Kirill
2026-05-19 11:25:23 +05:00
parent f8867f6457
commit 5adbe9baa7
81 changed files with 6549 additions and 3108 deletions
-1
View File
@@ -14,5 +14,4 @@ uploads/.cache/
server/uploads/ server/uploads/
# Plans and design docs # Plans and design docs
.opencode/plans/
.agents .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 | | 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 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 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) ### Build order (when changing both packages)
@@ -65,7 +69,7 @@ cd client && npm run build # full typecheck + build
## Notable quirks ## 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. - 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"`. - 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. - `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` 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 ```bash
cd server cd server
cp .env.example .env # укажите ADMIN_EMAIL и другие настройки
npm install npm install
npm run dev # переменные из `.dev_env` npm run dev # переменные из `.env`
``` ```
Очистка БД до «чистого» тестового состояния (SQLite + миграции + seed): в `server/` выполните `npm run db:reset:test`. Очистка БД до «чистого» тестового состояния (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 type { Category, Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client' 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 = { export type PublicProductsResponse = {
items: Product[] items: Product[]
@@ -42,107 +40,3 @@ export async function fetchCategories(): Promise<Category[]> {
const { data } = await apiClient.get<Category[]>('categories') const { data } = await apiClient.get<Category[]>('categories')
return data 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 { ReviewSection } from './ui/ReviewSection'
export { ReviewDialog } from './ui/ReviewDialog' 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, deleteAdminCategory,
fetchAdminCategories, fetchAdminCategories,
updateAdminCategory, updateAdminCategory,
} from '@/entities/product/api/product-api' } from '@/entities/product/api/admin-product-api'
import type { Category } from '@/entities/product/model/types' import type { Category } from '@/entities/product/model/types'
import { getErrorMessage } from '@/shared/lib/get-error-message' import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' 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 TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { import { fetchAdminOrder, fetchAdminOrders } from '@/entities/order/api/admin-order-api'
fetchAdminOrder, import { OrderDetailContent } from '@/features/order-detail/ui/OrderDetailContent'
fetchAdminOrders, import { ORDER_STATUSES } from '@/shared/constants/order'
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 { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status' 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 { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog' 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() { export function AdminOrdersPage() {
const qc = useQueryClient()
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('') const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [msg, setMsg] = useState('')
const ordersQuery = useQuery({ const ordersQuery = useQuery({
queryKey: ['admin', 'orders', { q, status, deliveryType }], queryKey: ['admin', 'orders', { q, status, deliveryType }],
@@ -101,25 +46,6 @@ export function AdminOrdersPage() {
enabled: Boolean(selectedId), 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) => { const open = (id: string) => {
setSelectedId(id) setSelectedId(id)
setDialogOpen(true) setDialogOpen(true)
@@ -136,17 +62,6 @@ export function AdminOrdersPage() {
) )
const detail = orderDetailQuery.data?.item 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 ( return (
<Box> <Box>
@@ -252,146 +167,7 @@ export function AdminOrdersPage() {
loading={!detail && orderDetailQuery.isLoading} loading={!detail && orderDetailQuery.isLoading}
error={orderDetailQuery.isError ? 'Не удалось загрузить заказ.' : null} error={orderDetailQuery.isError ? 'Не удалось загрузить заказ.' : null}
> >
{detail && ( {detail && <OrderDetailContent detail={detail} orderId={detail.id} />}
<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>
)}
</AdminDialog> </AdminDialog>
</Box> </Box>
) )
@@ -2,75 +2,40 @@ import { useState } from 'react'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent' import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle' 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 Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import Table from '@mui/material/Table' import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody' import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell' import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead' import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow' import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { fetchAdminGallery } from '@/entities/gallery'
import { import {
createProduct, createProduct,
deleteProduct, deleteProduct,
fetchAdminProducts, fetchAdminProducts,
fetchCategories,
updateProduct, updateProduct,
} from '@/entities/product/api/product-api' } from '@/entities/product/api/admin-product-api'
import type { Category, Product } from '@/entities/product/model/types' 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 { formatPriceRub } from '@/shared/lib/format-price'
import { getErrorMessage } from '@/shared/lib/get-error-message' import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state' import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
import { EntityRowActions } from '@/shared/ui/EntityRowActions' 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() { export function AdminProductsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>() const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
const [galleryPickOpen, setGalleryPickOpen] = useState(false) const [galleryPickOpen, setGalleryPickOpen] = useState(false)
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
const productForm = useForm<FormState>({ const productForm = useForm<FormState>({
defaultValues: emptyForm(), defaultValues: emptyForm(),
@@ -89,12 +54,6 @@ export function AdminProductsPage() {
queryFn: fetchAdminProducts, queryFn: fetchAdminProducts,
}) })
const galleryForPickQuery = useQuery({
queryKey: ['admin', 'gallery'],
queryFn: fetchAdminGallery,
enabled: galleryPickOpen,
})
const openCreate = () => { const openCreate = () => {
productForm.reset(emptyForm()) productForm.reset(emptyForm())
openCreateDialog() openCreateDialog()
@@ -212,29 +171,15 @@ export function AdminProductsPage() {
) )
} }
const toggleGalleryPickUrl = (url: string) => { const handleGallerySelect = (urls: string[]) => {
setGallerySelectedUrls((prev) => {
const next = new Set(prev)
if (next.has(url)) {
next.delete(url)
} else {
next.add(url)
}
return next
})
}
const appendGalleryUrlsToForm = () => {
const current = productForm.getValues('imageUrls') const current = productForm.getValues('imageUrls')
const merged = [...current] const merged = [...current]
for (const url of gallerySelectedUrls) { for (const url of urls) {
if (!merged.includes(url)) { if (!merged.includes(url)) {
merged.push(url) merged.push(url)
} }
} }
productForm.setValue('imageUrls', merged, { shouldDirty: true }) productForm.setValue('imageUrls', merged, { shouldDirty: true })
setGalleryPickOpen(false)
setGallerySelectedUrls(new Set())
} }
return ( return (
@@ -289,204 +234,12 @@ export function AdminProductsPage() {
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm"> <Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle> <DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}> <ProductFormFields
<Controller form={productForm}
control={productForm.control} categories={categoriesQuery.data ?? []}
name="title" onRemoveImage={removeImage}
render={({ field }) => <TextField label="Название" fullWidth required {...field} />} onPickFromGallery={() => setGalleryPickOpen(true)}
/> />
<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>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={closeDialog}>Отмена</Button> <Button onClick={closeDialog}>Отмена</Button>
@@ -508,89 +261,12 @@ export function AdminProductsPage() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Dialog <GalleryImagePicker
open={galleryPickOpen} open={galleryPickOpen}
onClose={() => { onClose={() => setGalleryPickOpen(false)}
setGalleryPickOpen(false) onSelect={handleGallerySelect}
setGallerySelectedUrls(new Set()) currentUrls={productForm.watch('imageUrls')}
}}
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>
</Box> </Box>
) )
} }
+28 -125
View File
@@ -3,17 +3,10 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip' 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 Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { import {
createMyAddress, createMyAddress,
deleteMyAddress, deleteMyAddress,
@@ -22,7 +15,18 @@ import {
updateMyAddress, updateMyAddress,
} from '@/entities/user/api/address-api' } from '@/entities/user/api/address-api'
import type { ShippingAddress } from '@/entities/user/model/types' 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() { export function AddressesPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -34,26 +38,8 @@ export function AddressesPage() {
queryFn: fetchMyAddresses, queryFn: fetchMyAddresses,
}) })
const form = useForm<{ const form = useForm<AddressFormValues>({
label: string defaultValues: defaultAddressForm(false),
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,
},
mode: 'onChange', mode: 'onChange',
}) })
@@ -115,16 +101,7 @@ export function AddressesPage() {
const openCreate = () => { const openCreate = () => {
setEditing(null) setEditing(null)
form.reset({ form.reset(defaultAddressForm(items.length === 0))
label: '',
recipientName: '',
recipientPhone: '',
addressLine: '',
comment: '',
lat: null,
lng: null,
isDefault: items.length === 0,
})
setOpen(true) setOpen(true)
} }
@@ -143,6 +120,11 @@ export function AddressesPage() {
setOpen(true) setOpen(true)
} }
const handleSubmit = () => {
if (editing) updateMut.mutate()
else createMut.mutate()
}
return ( return (
<Box> <Box>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
@@ -226,93 +208,14 @@ export function AddressesPage() {
)} )}
</Stack> </Stack>
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="md"> <AddressFormDialog
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle> open={open}
<DialogContent> onClose={() => setOpen(false)}
<Stack spacing={2} sx={{ mt: 1 }}> editing={Boolean(editing)}
<Controller form={form}
control={form.control} onSubmit={handleSubmit}
name="label" isPending={createMut.isPending || updateMut.isPending}
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>
</Box> </Box>
) )
} }
@@ -15,7 +15,7 @@ import {
submitOrderPayment, submitOrderPayment,
fetchOrderReviewEligibility, fetchOrderReviewEligibility,
} from '@/entities/order/api/order-api' } 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 { markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { OrderChat } from '@/features/order-chat' import { OrderChat } from '@/features/order-chat'
import { OrderPaymentSection } from '@/features/order-payment' 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 Dialog from '@mui/material/Dialog'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton' import IconButton from '@mui/material/IconButton'
import Paper from '@mui/material/Paper'
import Rating from '@mui/material/Rating' import Rating from '@mui/material/Rating'
import Skeleton from '@mui/material/Skeleton' import Skeleton from '@mui/material/Skeleton'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
@@ -19,14 +18,13 @@ import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css' import 'swiper/css'
import 'swiper/css/navigation' import 'swiper/css/navigation'
import { fetchPublicProduct } from '@/entities/product/api/product-api' 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 { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { ProductReviewsList } from '@/features/product-review'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { getOriginalWebpUrl } from '@/shared/lib/get-original-webp-url' import { getOriginalWebpUrl } from '@/shared/lib/get-original-webp-url'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru' import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
import { $user } from '@/shared/model/auth' import { $user } from '@/shared/model/auth'
import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
export function ProductPage() { export function ProductPage() {
const user = useUnit($user) const user = useUnit($user)
@@ -41,12 +39,6 @@ export function ProductPage() {
enabled: Boolean(id), 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 imageUrls = useMemo(() => {
const p = productQuery.data const p = productQuery.data
if (!p) return [] if (!p) return []
@@ -191,83 +183,7 @@ export function ProductPage() {
</Stack> </Stack>
)} )}
{reviewsQuery.isLoading && <Typography color="text.secondary">Загрузка отзывов</Typography>} <ProductReviewsList productId={id} />
{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>
)}
</Box> </Box>
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}> <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 const DELIVERY_CARRIER_CODES = SHARED_DELIVERY_CARRIERS as typeof SHARED_DELIVERY_CARRIERS
export type DeliveryCarrierCode = (typeof DELIVERY_CARRIER_CODES)[number] export type DeliveryCarrierCode = (typeof DELIVERY_CARRIER_CODES)[number]
export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = [ export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> =
{ code: 'RUSSIAN_POST', label: 'Почта России' }, DELIVERY_CARRIER_CODES.map((code) => ({
{ code: 'OZON_PVZ', label: 'Озон доставка (пункт выдачи)' }, code,
{ code: 'YANDEX_PVZ', label: 'Яндекс доставка (пункт выдачи)' }, label: DELIVERY_CARRIER_LABELS[code],
{ 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 function deliveryCarrierLabelRu(code: string | null | undefined): string | null { export function deliveryCarrierLabelRu(code: string | null | undefined): string | null {
if (!code) return null return sharedDeliveryCarrierLabelRu(code)
return carrierLabelMap[code as DeliveryCarrierCode] ?? 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 const ORDER_STATUSES = SHARED_ORDER_STATUSES as typeof SHARED_ORDER_STATUSES
export type OrderStatus = (typeof ORDER_STATUSES)[number] export type OrderStatus = (typeof ORDER_STATUSES)[number]
export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] { export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
switch (status) { return sharedGetNextAdminStatuses(status, deliveryType) as OrderStatus[]
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 []
}
} }
export function canTransitionOrderStatus(from: string, to: string): boolean { 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 Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom' 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 { OptimizedImage } from '@/shared/ui/OptimizedImage'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
+1
View File
@@ -11,6 +11,7 @@
"module": "esnext", "module": "esnext",
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
"strict": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "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", "start": "node src/index.js",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:studio": "prisma studio", "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": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"db:reset:test": "prisma migrate reset --force"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
@@ -24,6 +29,13 @@
"sharp": "0.32.6" "sharp": "0.32.6"
}, },
"devDependencies": { "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", "prisma": "5.22.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
+1 -1
View File
@@ -5,7 +5,7 @@ async function main() {
where: { isResized: false }, where: { isResized: false },
data: { isResized: true }, data: { isResized: true },
}) })
console.log(`Marked ${count} existing images as resized`) console.info(`Marked ${count} existing images as resized`)
} }
main() main()
+95 -105
View File
@@ -1,110 +1,104 @@
import "dotenv/config"; import 'dotenv/config'
import Fastify from "fastify"; import path from 'node:path'
import cors from "@fastify/cors"; import cors from '@fastify/cors'
import jwt from "@fastify/jwt"; import jwt from '@fastify/jwt'
import multipart from "@fastify/multipart"; import multipart from '@fastify/multipart'
import fastifyStatic from "@fastify/static"; import fastifyStatic from '@fastify/static'
import path from "node:path"; import Fastify from 'fastify'
import { ensureAdminUser } from "./lib/bootstrap-admin.js"; import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
import { getOrCreateUnspecifiedCategory } from "./lib/default-category.js"; import { ensureAdminUser } from './lib/bootstrap-admin.js'
import { import { getOrCreateUnspecifiedCategory } from './lib/default-category.js'
getMaxUploadBodyBytes, import { createEventBus } from './lib/notifications/event-bus.js'
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 { import {
resolveUserNotificationTargets, resolveUserNotificationTargets,
resolveAdminNotificationTargets, resolveAdminNotificationTargets,
resolveAuthCodeTargets, resolveAuthCodeTargets,
} from "./lib/notifications/preferences.js"; } from './lib/notifications/preferences.js'
import { import { createNotificationQueue } from './lib/notifications/queue.js'
NOTIFICATION_EVENTS, import { prisma } from './lib/prisma.js'
NOTIFICATION_CHANNELS, import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
} from "../../shared/constants/notification-events.js"; import { registerAuth } from './plugins/auth.js'
import { registerAuth } from "./plugins/auth.js"; import { registerApiRoutes } from './routes/api.js'
import { registerApiRoutes } from "./routes/api.js"; import { registerAuthRoutes } from './routes/auth.js'
import { registerAuthRoutes } from "./routes/auth.js"; import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
import { registerUserAddressRoutes } from "./routes/user-addresses.js"; import { registerUploadsResized } from './routes/uploads-resized.js'
import { registerUserCartRoutes } from "./routes/user-cart.js"; import { registerUserNotificationRoutes } from './routes/user/notifications.js'
import { registerUserMessageRoutes } from "./routes/user-messages.js"; import { registerUserAddressRoutes } from './routes/user-addresses.js'
import { registerUserOrderRoutes } from "./routes/user-orders.js"; import { registerUserCartRoutes } from './routes/user-cart.js'
import { registerUserPaymentRoutes } from "./routes/user-payments.js"; import { registerUserMessageRoutes } from './routes/user-messages.js'
import { registerUserNotificationRoutes } from "./routes/user/notifications.js"; import { registerUserOrderRoutes } from './routes/user-orders.js'
import { registerOAuthSocialRoutes } from "./routes/oauth-social.js"; import { registerUserPaymentRoutes } from './routes/user-payments.js'
import { registerUploadsResized } from "./routes/uploads-resized.js";
const port = Number(process.env.PORT) || 3333; const port = Number(process.env.PORT) || 3333
const origin = (process.env.CORS_ORIGIN ?? "") const origin = (process.env.CORS_ORIGIN ?? '')
.split(",") .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean)
const fastify = Fastify({ const fastify = Fastify({
logger: true, logger: true,
bodyLimit: getMaxUploadBodyBytes(), bodyLimit: getMaxUploadBodyBytes(),
}); })
await fastify.register(cors, { await fastify.register(cors, {
origin: origin.length ? origin : true, origin: origin.length ? origin : true,
credentials: true, credentials: true,
}); })
await fastify.register(jwt, { 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, { await fastify.register(multipart, {
limits: { limits: {
files: 10, files: 10,
fileSize: getProductImageMaxFileBytes(), fileSize: getProductImageMaxFileBytes(),
}, },
}); })
registerUploadsResized(fastify); registerUploadsResized(fastify)
const uploadsDir = path.join(process.cwd(), "uploads"); const uploadsDir = path.join(process.cwd(), 'uploads')
await fastify.register(fastifyStatic, { await fastify.register(fastifyStatic, {
root: uploadsDir, root: uploadsDir,
prefix: "/uploads/", prefix: '/uploads/',
setHeaders(res, filePath) { setHeaders(res, filePath) {
if (filePath.includes("/.cache/")) { if (filePath.includes('/.cache/')) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else { } 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 { try {
await request.jwtVerify(); await request.jwtVerify()
} catch { } catch {
return reply.code(401).send({ error: "Не авторизован" }); return reply.code(401).send({ error: 'Не авторизован' })
} }
}); })
const eventBus = createEventBus(); const eventBus = createEventBus()
const notificationQueue = createNotificationQueue(); const notificationQueue = createNotificationQueue()
fastify.decorate("eventBus", eventBus); fastify.decorate('eventBus', eventBus)
fastify.decorate("notificationQueue", notificationQueue); fastify.decorate('notificationQueue', notificationQueue)
registerAuth(fastify); registerAuth(fastify)
await registerAuthRoutes(fastify); await registerAuthRoutes(fastify)
await registerUserAddressRoutes(fastify); await registerUserAddressRoutes(fastify)
await registerUserCartRoutes(fastify); await registerUserCartRoutes(fastify)
await registerUserMessageRoutes(fastify); await registerUserMessageRoutes(fastify)
await registerUserOrderRoutes(fastify); await registerUserOrderRoutes(fastify)
await registerUserPaymentRoutes(fastify); await registerUserPaymentRoutes(fastify)
await registerUserNotificationRoutes(fastify); await registerUserNotificationRoutes(fastify)
await registerOAuthSocialRoutes(fastify); await registerOAuthSocialRoutes(fastify)
await registerApiRoutes(fastify); await registerApiRoutes(fastify)
await ensureAdminUser(); await ensureAdminUser()
await getOrCreateUnspecifiedCategory(); await getOrCreateUnspecifiedCategory()
await notificationQueue.flushPendingOnStartup(); await notificationQueue.flushPendingOnStartup()
notificationQueue.start(); notificationQueue.start()
const { const {
ORDER_CREATED, ORDER_CREATED,
@@ -114,11 +108,11 @@ const {
PAYMENT_STATUS_CHANGED, PAYMENT_STATUS_CHANGED,
AUTH_CODE_REQUESTED, AUTH_CODE_REQUESTED,
DELIVERY_FEE_ADJUSTED, DELIVERY_FEE_ADJUSTED,
} = NOTIFICATION_EVENTS; } = NOTIFICATION_EVENTS
async function dispatchNotification(eventType, payload) { async function dispatchNotification(eventType, payload) {
if (eventType === AUTH_CODE_REQUESTED) { 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')) { for (const target of targets.filter((t) => t.channel === 'telegram')) {
const log = await prisma.notificationLog.create({ const log = await prisma.notificationLog.create({
data: { data: {
@@ -127,66 +121,62 @@ async function dispatchNotification(eventType, payload) {
status: 'pending', status: 'pending',
payload: JSON.stringify(payload), 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) { for (const target of userTargets) {
const log = await prisma.notificationLog.create({ const log = await prisma.notificationLog.create({
data: { data: {
userId: payload.userId, userId: payload.userId,
eventType, eventType,
channel: target.channel, channel: target.channel,
status: "pending", status: 'pending',
payload: JSON.stringify(payload), payload: JSON.stringify(payload),
}, },
}); })
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }); notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
} }
const adminEventType = const adminEventType = eventType === 'order:created:admin' ? ORDER_CREATED : eventType
eventType === "order:created:admin" ? ORDER_CREATED : eventType; const adminTargets = await resolveAdminNotificationTargets(adminEventType, payload)
const adminTargets = await resolveAdminNotificationTargets(
adminEventType,
payload,
);
for (const target of adminTargets) { for (const target of adminTargets) {
const log = await prisma.notificationLog.create({ const log = await prisma.notificationLog.create({
data: { data: {
eventType, eventType,
channel: target.channel, channel: target.channel,
status: "pending", status: 'pending',
payload: JSON.stringify(payload), 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_CREATED, (payload) => dispatchNotification(ORDER_CREATED, payload))
eventBus.on(ORDER_STATUS_CHANGED, (payload) => dispatchNotification(ORDER_STATUS_CHANGED, 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_SENT, (payload) => dispatchNotification(ORDER_MESSAGE_SENT, payload))
eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, (payload) => dispatchNotification(ORDER_MESSAGE_ADMIN_REPLY, 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(PAYMENT_STATUS_CHANGED, (payload) => dispatchNotification(PAYMENT_STATUS_CHANGED, payload))
eventBus.on(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, 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('order:created:admin', (payload) => dispatchNotification('order:created:admin', payload))
eventBus.on("review:created", (payload) => dispatchNotification("review:created", payload)); eventBus.on('review:created', (payload) => dispatchNotification('review:created', payload))
eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload)); eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload))
async function shutdown() { async function shutdown() {
notificationQueue.stop(); notificationQueue.stop()
await fastify.close(); await fastify.close()
process.exit(0); process.exit(0)
} }
process.on("SIGINT", shutdown); process.on('SIGINT', shutdown)
process.on("SIGTERM", shutdown); process.on('SIGTERM', shutdown)
try { try {
await fastify.listen({ port, host: "0.0.0.0" }); await fastify.listen({ port, host: '0.0.0.0' })
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err)
process.exit(1); process.exit(1)
} }
+1 -3
View File
@@ -16,8 +16,6 @@ describe('escapeHtml', () => {
}) })
it('escapes mixed content', () => { it('escapes mixed content', () => {
expect(escapeHtml('<script>alert("xss")</script>')).toBe( expect(escapeHtml('<script>alert("xss")</script>')).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
'&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('.cache')
expect(result.path).toContain('_w100.avif') 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) expect(exists).toBe(true)
// Verify it's actually AVIF (sharp reports AVIF as 'heif' in metadata) // 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 width of [320, 640, 1024, 1600]) {
for (const format of ['avif', 'webp']) { for (const format of ['avif', 'webp']) {
const cachePath = path.join(cacheDir, `${uuid}_w${width}.${format}`) 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) expect(exists).toBe(true)
} }
} }
@@ -145,10 +151,16 @@ describe('eager image processing', () => {
const result = await convertOriginalToWebp(uuid, '') const result = await convertOriginalToWebp(uuid, '')
expect(result).toBe(`/uploads/${uuid}.webp`) 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) expect(pngExists).toBe(false)
const webpPath = path.join(UPLOADS_DIR, `${uuid}.webp`) 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) expect(webpExists).toBe(true)
// Cleanup // Cleanup
@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { describe, it, expect, afterEach } from 'vitest'
import { persistMultipartImages } from '../upload-images.js' import { persistMultipartImages } from '../upload-images.js'
const UPLOADS_DIR = path.join(process.cwd(), 'uploads') const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
@@ -45,5 +45,4 @@ describe('persistMultipartImages with eager=false', () => {
expect(urls).toHaveLength(1) expect(urls).toHaveLength(1)
expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/) expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/)
}) })
}) })
+7 -5
View File
@@ -1,9 +1,11 @@
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { prisma } from './prisma.js'
import { sendLoginCodeEmail } from './email.js' import { sendLoginCodeEmail } from './email.js'
import { prisma } from './prisma.js'
export function normalizeEmail(email) { export function normalizeEmail(email) {
return String(email || '').trim().toLowerCase() return String(email || '')
.trim()
.toLowerCase()
} }
export function randomCode6() { export function randomCode6() {
@@ -31,7 +33,9 @@ export async function issueEmailCode({ email, purpose, userId = null }) {
} }
function parseEnvBool(raw) { function parseEnvBool(raw) {
const v = String(raw ?? '').trim().toLowerCase() const v = String(raw ?? '')
.trim()
.toLowerCase()
return v === 'true' || v === '1' || v === 'yes' return v === 'true' || v === '1' || v === 'yes'
} }
@@ -68,5 +72,3 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) {
}) })
return true return true
} }
+1 -1
View File
@@ -8,7 +8,7 @@ export async function ensureAdminUser() {
throw new Error('ADMIN_EMAIL должен быть валидным email') throw new Error('ADMIN_EMAIL должен быть валидным email')
} }
const admin = await prisma.user.upsert({ await prisma.user.upsert({
where: { email: adminEmail }, where: { email: adminEmail },
update: {}, update: {},
create: { email: adminEmail }, create: { email: adminEmail },
+2 -2
View File
@@ -18,7 +18,7 @@ function createTransporter() {
export async function sendLoginCodeEmail({ to, code }) { export async function sendLoginCodeEmail({ to, code }) {
if (!hasSmtpEnv()) { if (!hasSmtpEnv()) {
console.log(`[DEV] login code for ${to}: ${code}`) console.info(`[DEV] login code for ${to}: ${code}`)
return return
} }
@@ -35,7 +35,7 @@ export async function sendLoginCodeEmail({ to, code }) {
export async function sendNotificationEmail({ to, subject, html }) { export async function sendNotificationEmail({ to, subject, html }) {
if (!hasSmtpEnv()) { if (!hasSmtpEnv()) {
console.log(`[DEV] notification email to ${to}: ${subject}`) console.info(`[DEV] notification email to ${to}: ${subject}`)
return { success: true } return { success: true }
} }
-1
View File
@@ -1,4 +1,3 @@
import crypto from 'node:crypto'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
@@ -65,7 +65,7 @@ describe('preferences', () => {
}) })
it('returns admin targets when settings enabled', async () => { 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 const origAdminEmail = process.env.ADMIN_EMAIL
process.env.ADMIN_EMAIL = 'admin@test.com' process.env.ADMIN_EMAIL = 'admin@test.com'
@@ -25,7 +25,7 @@ const templateRenderers = {
async function postToTelegram(chatId, text) { async function postToTelegram(chatId, text) {
if (!TELEGRAM_BOT_TOKEN) { 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 } 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 { const {
ORDER_CREATED, ORDER_CREATED,
@@ -7,105 +7,99 @@ const {
ORDER_MESSAGE_SENT, ORDER_MESSAGE_SENT,
ORDER_MESSAGE_ADMIN_REPLY, ORDER_MESSAGE_ADMIN_REPLY,
PAYMENT_STATUS_CHANGED, PAYMENT_STATUS_CHANGED,
AUTH_CODE_REQUESTED,
DELIVERY_FEE_ADJUSTED, DELIVERY_FEE_ADJUSTED,
} = NOTIFICATION_EVENTS; } = NOTIFICATION_EVENTS
const userEventFieldMap = { const userEventFieldMap = {
[ORDER_CREATED]: "orderCreated", [ORDER_CREATED]: 'orderCreated',
[ORDER_STATUS_CHANGED]: "orderStatusChanged", [ORDER_STATUS_CHANGED]: 'orderStatusChanged',
[ORDER_MESSAGE_ADMIN_REPLY]: "orderMessageReceived", [ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived',
[PAYMENT_STATUS_CHANGED]: "paymentStatusChanged", [PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged',
[DELIVERY_FEE_ADJUSTED]: "deliveryFeeAdjusted", [DELIVERY_FEE_ADJUSTED]: 'deliveryFeeAdjusted',
}; }
const adminEventFieldMap = { const adminEventFieldMap = {
[ORDER_MESSAGE_SENT]: "newOrderMessage", [ORDER_MESSAGE_SENT]: 'newOrderMessage',
"review:created": "newReview", 'review:created': 'newReview',
}; }
export async function resolveUserNotificationTargets(eventType, payload) { export async function resolveUserNotificationTargets(eventType, payload) {
const targets = []; const targets = []
if (payload.userId) { if (payload.userId) {
const prefs = await prisma.notificationPreference.findUnique({ const prefs = await prisma.notificationPreference.findUnique({
where: { userId: payload.userId }, where: { userId: payload.userId },
}); })
if (prefs && prefs.globalEnabled) { if (prefs && prefs.globalEnabled) {
const field = userEventFieldMap[eventType]; const field = userEventFieldMap[eventType]
if (field && prefs[field]) { if (field && prefs[field]) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: payload.userId }, where: { id: payload.userId },
select: { email: true }, select: { email: true },
}); })
if (user) { 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) { export async function resolveAdminNotificationTargets(eventType, payload) {
const targets = []; const targets = []
const settings = await prisma.adminNotificationSettings.findFirst(); const settings = await prisma.adminNotificationSettings.findFirst()
if (!settings) return targets; if (!settings) return targets
const field = adminEventFieldMap[eventType]; const field = adminEventFieldMap[eventType]
if (field === "newReview") { if (field === 'newReview') {
if (!settings.newReview) return targets; if (!settings.newReview) return targets
} else if (field && !settings[field]) { } else if (field && !settings[field]) {
return targets; return targets
} }
if (settings.emailEnabled) { if (settings.emailEnabled) {
const admin = await prisma.user.findFirst({ const admin = await prisma.user.findFirst({
where: { email: process.env.ADMIN_EMAIL }, where: { email: process.env.ADMIN_EMAIL },
select: { email: true }, select: { email: true },
}); })
if (admin) { if (admin) {
targets.push({ channel: "email", recipient: admin.email }); targets.push({ channel: 'email', recipient: admin.email })
} }
} }
if (settings.telegramEnabled && settings.telegramChatId) { 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) { export async function resolveAuthCodeTargets(eventType, payload) {
const targets = []; const targets = []
if (payload.email) { if (payload.email) {
targets.push({ channel: "email", recipient: payload.email }); targets.push({ channel: 'email', recipient: payload.email })
} }
if (payload.isAdmin) { if (payload.isAdmin) {
const settings = await prisma.adminNotificationSettings.findFirst(); const settings = await prisma.adminNotificationSettings.findFirst()
if ( if (settings && settings.telegramEnabled && settings.telegramChatId && settings.authCodeDuplicate) {
settings && targets.push({ channel: 'telegram', recipient: settings.telegramChatId })
settings.telegramEnabled &&
settings.telegramChatId &&
settings.authCodeDuplicate
) {
targets.push({ channel: "telegram", recipient: settings.telegramChatId });
} }
} }
return targets; return targets
} }
export async function ensureUserNotificationPreference(userId) { export async function ensureUserNotificationPreference(userId) {
const existing = await prisma.notificationPreference.findUnique({ const existing = await prisma.notificationPreference.findUnique({
where: { userId }, where: { userId },
}); })
if (existing) return existing; if (existing) return existing
return prisma.notificationPreference.create({ return prisma.notificationPreference.create({
data: { userId, globalEnabled: true }, 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 { 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 { emailChannel } from './channels/email-channel.js'
import { telegramChannel } from './channels/telegram-channel.js' import { telegramChannel } from './channels/telegram-channel.js'
@@ -120,7 +124,7 @@ class NotificationQueue {
}) })
} }
if (pending.length > 0) { 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> <p>Любимый Креатив — магазин handmade изделий</p>
</div> </div>
</body> </body>
</html>`; </html>`
} }
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount, deliveryType }) { export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString("ru-RU"); const total = (totalCents / 100).toLocaleString('ru-RU')
const nextAction = deliveryType === "delivery" const nextAction =
? "Оплата будет доступна после уточнения стоимости доставки." deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.'
: "Ожидает оплаты.";
const body = ` const body = `
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p> <p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p> <p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
<p>${nextAction}</p> <p>${nextAction}</p>
`; `
return { subject: "Заказ создан", html: baseLayout("Заказ создан", body) }; return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) }
} }
export function renderOrderStatusChangedEmail({ export function renderOrderStatusChangedEmail({ orderId, oldStatus, newStatus }) {
orderId,
oldStatus,
newStatus,
}) {
const statusLabels = { const statusLabels = {
DRAFT: "Черновик", DRAFT: 'Черновик',
PENDING_PAYMENT: "Ожидает оплаты", PENDING_PAYMENT: 'Ожидает оплаты',
PAID: "Оплачен", PAID: 'Оплачен',
IN_PROGRESS: "В работе", IN_PROGRESS: 'В работе',
READY_FOR_PICKUP: "Готов к выдаче", READY_FOR_PICKUP: 'Готов к выдаче',
SHIPPED: "Отправлен", SHIPPED: 'Отправлен',
DONE: "Выполнен", DONE: 'Выполнен',
CANCELLED: "Отменён", CANCELLED: 'Отменён',
}; }
const oldLabel = statusLabels[oldStatus] || oldStatus; const oldLabel = statusLabels[oldStatus] || oldStatus
const newLabel = statusLabels[newStatus] || newStatus; const newLabel = statusLabels[newStatus] || newStatus
const body = ` const body = `
<p>Статус заказа <b>#${orderId.slice(0, 8)}</b> изменён.</p> <p>Статус заказа <b>#${orderId.slice(0, 8)}</b> изменён.</p>
<p><b>${oldLabel}</b> → <b>${newLabel}</b></p> <p><b>${oldLabel}</b> → <b>${newLabel}</b></p>
`; `
return { return {
subject: `Статус заказа изменён — ${newLabel}`, subject: `Статус заказа изменён — ${newLabel}`,
html: baseLayout("Статус заказа изменён", body), html: baseLayout('Статус заказа изменён', body),
}; }
} }
export function renderOrderMessageEmail({ orderId, preview }) { export function renderOrderMessageEmail({ orderId, preview }) {
const truncated = const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview
preview.length > 200 ? preview.slice(0, 197) + "..." : preview;
const body = ` const body = `
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p> <p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;"> <div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
${truncated} ${truncated}
</div> </div>
<p>Ответьте в личном кабинете.</p> <p>Ответьте в личном кабинете.</p>
`; `
return { return {
subject: "Новое сообщение к заказу", subject: 'Новое сообщение к заказу',
html: baseLayout("Новое сообщение", body), html: baseLayout('Новое сообщение', body),
}; }
} }
export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) { export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) {
const statusLabels = { const statusLabels = {
pending: "Ожидает", pending: 'Ожидает',
confirmed: "Подтверждён", confirmed: 'Подтверждён',
rejected: "Отклонён", rejected: 'Отклонён',
}; }
const label = statusLabels[paymentStatus] || paymentStatus; const label = statusLabels[paymentStatus] || paymentStatus
const body = ` const body = `
<p>Статус оплаты заказа <b>#${orderId.slice(0, 8)}</b>: <b>${label}</b>.</p> <p>Статус оплаты заказа <b>#${orderId.slice(0, 8)}</b>: <b>${label}</b>.</p>
`; `
return { return {
subject: `Оплата заказа — ${label}`, subject: `Оплата заказа — ${label}`,
html: baseLayout("Оплата заказа", body), html: baseLayout('Оплата заказа', body),
}; }
} }
export function renderAdminOrderCreatedEmail({ export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount, deliveryType }) {
orderId, const total = (totalCents / 100).toLocaleString('ru-RU')
userEmail, const note = deliveryType === 'delivery' ? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>' : ''
totalCents,
itemsCount,
deliveryType,
}) {
const total = (totalCents / 100).toLocaleString("ru-RU");
const note = deliveryType === "delivery"
? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>'
: "";
const body = ` const body = `
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p> <p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p> <p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
${note} ${note}
`; `
return { subject: "Новый заказ", html: baseLayout("Новый заказ", body) }; return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) }
} }
export function renderAdminNewReviewEmail({ export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) {
rating, const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
text,
productTitle,
userName,
}) {
const stars = "★".repeat(rating) + "☆".repeat(5 - rating);
const body = ` const body = `
<p>Новый отзыв ${stars} на товар <b>${productTitle}</b> от <b>${userName}</b>.</p> <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> <p>Проверьте отзыв в админ-панели.</p>
`; `
return { subject: "Новый отзыв", html: baseLayout("Новый отзыв", body) }; return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) }
} }
export function renderAuthCodeEmail({ code }) { export function renderAuthCodeEmail({ code }) {
const body = ` const body = `
<p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p> <p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p>
<p>Если это были не вы — просто проигнорируйте письмо.</p> <p>Если это были не вы — просто проигнорируйте письмо.</p>
`; `
return { subject: "Код входа", html: baseLayout("Код входа", body) }; return { subject: 'Код входа', html: baseLayout('Код входа', body) }
} }
export function renderDeliveryFeeAdjustedEmail({ orderId, totalCents }) { export function renderDeliveryFeeAdjustedEmail({ orderId, totalCents }) {
const total = (totalCents / 100).toLocaleString("ru-RU"); const total = (totalCents / 100).toLocaleString('ru-RU')
const body = ` const body = `
<p>Стоимость доставки заказа <b>#${orderId.slice(0, 8)}</b> скорректирована.</p> <p>Стоимость доставки заказа <b>#${orderId.slice(0, 8)}</b> скорректирована.</p>
<p>Новая сумма: <b>${total} ₽</b></p> <p>Новая сумма: <b>${total} ₽</b></p>
<p>Ожидает оплаты. Проверьте статус заказа в личном кабинете.</p> <p>Ожидает оплаты. Проверьте статус заказа в личном кабинете.</p>
`; `
return { return {
subject: "Стоимость доставки скорректирована", subject: 'Стоимость доставки скорректирована',
html: baseLayout("Стоимость доставки скорректирована", body), html: baseLayout('Стоимость доставки скорректирована', body),
}; }
} }
@@ -1,15 +1,20 @@
export function renderOrderCreatedTg({ orderId, totalCents, itemsCount, deliveryType }) { export function renderOrderCreatedTg({ orderId, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString('ru-RU') 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}` return `📦 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total}\n${nextAction}`
} }
export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) { export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
const labels = { const labels = {
DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', PAID: 'Оплачен', IN_PROGRESS: 'В работе', DRAFT: 'Черновик',
READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён', 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>` 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' export {
ORDER_STATUSES,
/** getNextAdminStatuses,
* Переходы, которые делает админ через PATCH /api/admin/orders/:id/status canTransitionAdminOrderStatus,
* (подтверждение получения пользователем — отдельный эндпоинт). } from '../../../shared/constants/order-status.js'
*/
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)
}
+3 -1
View File
@@ -1,6 +1,8 @@
export function registerAuth(fastify) { export function registerAuth(fastify) {
function normalizeEmail(email) { function normalizeEmail(email) {
return String(email || '').trim().toLowerCase() return String(email || '')
.trim()
.toLowerCase()
} }
fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) { fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) {
+4 -9
View File
@@ -1,16 +1,12 @@
import { import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js'
mapProductForApi, import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
parseMaterialsInput,
slugify,
} from './api/_product-helpers.js'
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
import { registerAdminCategoryRoutes } from './api/admin-categories.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 { registerAdminOrderRoutes } from './api/admin-orders.js'
import { registerAdminProductRoutes } from './api/admin-products.js' import { registerAdminProductRoutes } from './api/admin-products.js'
import { registerAdminReviewRoutes } from './api/admin-reviews.js' import { registerAdminReviewRoutes } from './api/admin-reviews.js'
import { registerAdminUserRoutes } from './api/admin-users.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 { registerInfoPageRoutes } from './api/info-page.js'
import { registerPublicCatalogRoutes } from './api/public-catalog.js' import { registerPublicCatalogRoutes } from './api/public-catalog.js'
import { registerPublicReviewRoutes } from './api/public-reviews.js' import { registerPublicReviewRoutes } from './api/public-reviews.js'
@@ -33,4 +29,3 @@ export async function registerApiRoutes(fastify) {
await registerAdminUserRoutes(fastify) await registerAdminUserRoutes(fastify)
await registerAdminNotificationRoutes(fastify) await registerAdminNotificationRoutes(fastify)
} }
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
const UPLOADS_DIR = path.join(process.cwd(), 'uploads') const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
@@ -36,7 +36,10 @@ describe('Admin gallery resize integration', () => {
expect(newUrl).toBe(`/uploads/${testUuid}.webp`) expect(newUrl).toBe(`/uploads/${testUuid}.webp`)
// Verify original PNG is deleted // 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) expect(pngExists).toBe(false)
// Verify cached files exist // Verify cached files exist
@@ -44,13 +47,19 @@ describe('Admin gallery resize integration', () => {
for (const width of [320, 640, 1024, 1600]) { for (const width of [320, 640, 1024, 1600]) {
for (const format of ['avif', 'webp']) { for (const format of ['avif', 'webp']) {
const cachePath = path.join(cacheDir, `${testUuid}_w${width}.${format}`) 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) expect(exists).toBe(true)
} }
} }
// Verify webp original exists // 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) expect(webpExists).toBe(true)
}) })
}) })
@@ -53,4 +53,3 @@ export function mapProductForApi(p, reviewsSummary = null) {
} }
return base return base
} }
+8 -24
View File
@@ -6,21 +6,14 @@ import {
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
export async function registerAdminCategoryRoutes(fastify) { export async function registerAdminCategoryRoutes(fastify) {
fastify.get( fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async () => {
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.category.findMany({ const items = await prisma.category.findMany({
orderBy: [{ sort: 'asc' }, { name: 'asc' }], orderBy: [{ sort: 'asc' }, { name: 'asc' }],
}) })
return { items } return { items }
}, })
)
fastify.post( fastify.post('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {} const body = request.body ?? {}
const name = String(body.name ?? '').trim() const name = String(body.name ?? '').trim()
if (!name) { if (!name) {
@@ -46,13 +39,9 @@ export async function registerAdminCategoryRoutes(fastify) {
}, },
}) })
reply.code(201).send(category) reply.code(201).send(category)
}, })
)
fastify.patch( fastify.patch('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/categories/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params const { id } = request.params
const body = request.body ?? {} const body = request.body ?? {}
const existing = await prisma.category.findUnique({ where: { id } }) const existing = await prisma.category.findUnique({ where: { id } })
@@ -105,13 +94,9 @@ export async function registerAdminCategoryRoutes(fastify) {
const updated = await prisma.category.update({ where: { id }, data }) const updated = await prisma.category.update({ where: { id }, data })
return updated return updated
}, })
)
fastify.delete( fastify.delete('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/categories/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params const { id } = request.params
const existing = await prisma.category.findUnique({ where: { id } }) const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) { if (!existing) {
@@ -132,6 +117,5 @@ export async function registerAdminCategoryRoutes(fastify) {
prisma.category.delete({ where: { id } }), prisma.category.delete({ where: { id } }),
]) ])
return reply.code(204).send() return reply.code(204).send()
}, })
)
} }
+8 -24
View File
@@ -9,21 +9,14 @@ import {
} from '../../lib/upload-limits.js' } from '../../lib/upload-limits.js'
export async function registerAdminGalleryRoutes(fastify) { export async function registerAdminGalleryRoutes(fastify) {
fastify.get( fastify.get('/api/admin/gallery', { preHandler: [fastify.verifyAdmin] }, async () => {
'/api/admin/gallery',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.galleryImage.findMany({ const items = await prisma.galleryImage.findMany({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })
return { items } return { items }
}, })
)
fastify.post( fastify.post('/api/admin/gallery/upload', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/gallery/upload',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
try { try {
const urls = await persistMultipartImages(request, { const urls = await persistMultipartImages(request, {
maxFiles: 10, maxFiles: 10,
@@ -49,13 +42,9 @@ export async function registerAdminGalleryRoutes(fastify) {
} }
return reply.code(statusCode).send({ error: message }) return reply.code(statusCode).send({ error: message })
} }
}, })
)
fastify.post( fastify.post('/api/admin/gallery/:id/resize', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/gallery/:id/resize',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params const { id } = request.params
const row = await prisma.galleryImage.findUnique({ where: { id } }) const row = await prisma.galleryImage.findUnique({ where: { id } })
if (!row) { if (!row) {
@@ -86,13 +75,9 @@ export async function registerAdminGalleryRoutes(fastify) {
request.log.error(error, 'Resize failed') request.log.error(error, 'Resize failed')
return reply.code(500).send({ error: 'Ошибка обработки изображения' }) return reply.code(500).send({ error: 'Ошибка обработки изображения' })
} }
}, })
)
fastify.delete( fastify.delete('/api/admin/gallery/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/gallery/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params const { id } = request.params
const row = await prisma.galleryImage.findUnique({ where: { id } }) const row = await prisma.galleryImage.findUnique({ where: { id } })
if (!row) { if (!row) {
@@ -117,6 +102,5 @@ export async function registerAdminGalleryRoutes(fastify) {
await prisma.galleryImage.delete({ where: { id } }) await prisma.galleryImage.delete({ where: { id } })
return reply.code(204).send() return reply.code(204).send()
}, })
)
} }
+81 -145
View File
@@ -1,80 +1,52 @@
import { prisma } from "../../lib/prisma.js"; import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
import { canTransitionAdminOrderStatus } from "../../lib/order-status.js"; import { canTransitionAdminOrderStatus } from '../../lib/order-status.js'
import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js"; import { prisma } from '../../lib/prisma.js'
export async function registerAdminOrderRoutes(fastify) { export async function registerAdminOrderRoutes(fastify) {
fastify.get( fastify.get('/api/admin/orders/summary', { preHandler: [fastify.verifyAdmin] }, async () => {
"/api/admin/orders/summary",
{ preHandler: [fastify.verifyAdmin] },
async () => {
const attentionCount = await prisma.order.count({ const attentionCount = await prisma.order.count({
where: { where: {
status: "PENDING_PAYMENT", status: 'PENDING_PAYMENT',
}, },
}); })
return { attentionCount }; return { attentionCount }
}, })
);
fastify.get( fastify.get('/api/admin/orders', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
"/api/admin/orders", const status = typeof request.query?.status === 'string' ? request.query.status.trim() : ''
{ preHandler: [fastify.verifyAdmin] }, const q = typeof request.query?.q === 'string' ? request.query.q.trim() : ''
async (request, reply) => { const deliveryTypeRaw = request.query?.deliveryType
const status = const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.trim() : ''
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 pageRaw = request.query?.page
const pageParsed = const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw); const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
const page =
Number.isFinite(pageParsed) && pageParsed > 0
? Math.floor(pageParsed)
: 1;
const pageSizeRaw = request.query?.pageSize; const pageSizeRaw = request.query?.pageSize
const pageSizeParsed = const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
typeof pageSizeRaw === "string" const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
? Number(pageSizeRaw) if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
: 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 = {}; const where = {}
if (status) where.status = status; if (status) where.status = status
if (deliveryType) { if (deliveryType) {
if (deliveryType !== "delivery" && deliveryType !== "pickup") { if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
return reply return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
.code(400)
.send({ error: "deliveryType должен быть delivery | pickup" });
} }
where.deliveryType = deliveryType; where.deliveryType = deliveryType
} }
if (q) { if (q) {
where.OR = [ where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }]
{ id: { contains: q } },
{ user: { email: { contains: q } } },
];
} }
const total = await prisma.order.count({ where }); const total = await prisma.order.count({ where })
const items = await prisma.order.findMany({ const items = await prisma.order.findMany({
where, where,
include: { user: { select: { id: true, email: true } }, items: true }, include: { user: { select: { id: true, email: true } }, items: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}); })
return { return {
items: items.map((o) => ({ items: items.map((o) => ({
@@ -93,97 +65,70 @@ export async function registerAdminOrderRoutes(fastify) {
total, total,
page, page,
pageSize, pageSize,
}; }
}, })
);
fastify.get( fastify.get('/api/admin/orders/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
"/api/admin/orders/:id", const { id } = request.params
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id }, where: { id },
include: { include: {
user: { select: { id: true, email: true, name: true, phone: true } }, user: { select: { id: true, email: true, name: true, phone: true } },
items: true, items: true,
messages: { orderBy: { createdAt: "asc" } }, messages: { orderBy: { createdAt: 'asc' } },
}, },
}); })
if (!order) return reply.code(404).send({ error: "Заказ не найден" }); if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
return { item: order }; return { item: order }
}, })
);
fastify.patch( fastify.patch('/api/admin/orders/:id/status', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
"/api/admin/orders/:id/status", const { id } = request.params
{ preHandler: [fastify.verifyAdmin] }, const next = String(request.body?.status || '').trim()
async (request, reply) => { if (!next) return reply.code(400).send({ error: 'status обязателен' })
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 } }); const existing = await prisma.order.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: "Заказ не найден" }); if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
if (!canTransitionAdminOrderStatus(existing, next)) { if (!canTransitionAdminOrderStatus(existing, next)) {
return reply return reply.code(409).send({
.code(409)
.send({
error: `Нельзя сменить статус ${existing.status}${next}`, error: `Нельзя сменить статус ${existing.status}${next}`,
}); })
} }
const updated = await prisma.order.update({ const updated = await prisma.order.update({
where: { id }, where: { id },
data: { status: next }, data: { status: next },
}); })
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, { request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, {
orderId: updated.id, orderId: updated.id,
userId: existing.userId, userId: existing.userId,
oldStatus: existing.status, oldStatus: existing.status,
newStatus: next, newStatus: next,
}); })
return { item: updated }; return { item: updated }
}, })
);
fastify.patch( fastify.patch('/api/admin/orders/:id/delivery-fee', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
"/api/admin/orders/:id/delivery-fee", const { id } = request.params
{ preHandler: [fastify.verifyAdmin] }, const feeRaw = request.body?.deliveryFeeCents
async (request, reply) => { const parsed = typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN
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) { if (!Number.isInteger(parsed) || parsed < 0) {
return reply return reply.code(400).send({
.code(400) error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)',
.send({ })
error: "deliveryFeeCents должно быть целым числом ≥ 0 (копейки)",
});
} }
const existing = await prisma.order.findUnique({ where: { id } }); const existing = await prisma.order.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: "Заказ не найден" }); if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
if ( if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) {
existing.status !== "PENDING_PAYMENT" || return reply.code(409).send({
existing.deliveryFeeLocked !== false error: 'Корректировка доставки доступна только пока стоимость не утверждена',
) { })
return reply
.code(409)
.send({
error:
"Корректировка доставки доступна только пока стоимость не утверждена",
});
} }
const totalCents = existing.itemsSubtotalCents + parsed; const totalCents = existing.itemsSubtotalCents + parsed
const updated = await prisma.order.update({ const updated = await prisma.order.update({
where: { id }, where: { id },
data: { data: {
@@ -191,46 +136,37 @@ export async function registerAdminOrderRoutes(fastify) {
totalCents, totalCents,
deliveryFeeLocked: true, deliveryFeeLocked: true,
}, },
}); })
request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, { request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, {
orderId: updated.id, orderId: updated.id,
userId: existing.userId, userId: existing.userId,
totalCents: updated.totalCents, totalCents: updated.totalCents,
}); })
return { item: updated }; return { item: updated }
}, })
);
fastify.post( fastify.post('/api/admin/orders/:id/messages', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
"/api/admin/orders/:id/messages", const { id } = request.params
{ preHandler: [fastify.verifyAdmin] }, const text = String(request.body?.text || '').trim()
async (request, reply) => { if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
const { id } = request.params; if (text.length > 2000) return reply.code(400).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 order = await prisma.order.findUnique({ where: { id } }); const order = await prisma.order.findUnique({ where: { id } })
if (!order) return reply.code(404).send({ error: "Заказ не найден" }); if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const msg = await prisma.orderMessage.create({ const msg = await prisma.orderMessage.create({
data: { orderId: id, authorType: "admin", text }, data: { orderId: id, authorType: 'admin', text },
}); })
request.server.eventBus.emit( request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, {
NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY,
{
orderId: id, orderId: id,
userId: order.userId, userId: order.userId,
messageId: msg.id, messageId: msg.id,
preview: text, preview: text,
}, })
);
return reply.code(201).send({ item: msg }); return reply.code(201).send({ item: msg })
}, })
);
} }
+10 -14
View File
@@ -40,17 +40,13 @@ const PATCH_PRODUCT_SCHEMA = {
} }
export async function registerAdminProductRoutes(fastify) { export async function registerAdminProductRoutes(fastify) {
fastify.get( fastify.get('/api/admin/products', { preHandler: [fastify.verifyAdmin] }, async (request) => {
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
async (request) => {
const items = await prisma.product.findMany({ const items = await prisma.product.findMany({
include: { category: true, images: { orderBy: { sort: 'asc' } } }, include: { category: true, images: { orderBy: { sort: 'asc' } } },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
}) })
return items.map((p) => request.server.mapProductForApi(p)) return items.map((p) => request.server.mapProductForApi(p))
}, })
)
fastify.post( fastify.post(
'/api/admin/products', '/api/admin/products',
@@ -102,7 +98,9 @@ export async function registerAdminProductRoutes(fastify) {
return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' }) return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
} }
if (notResized.length > 0) { 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: 'Некоторые изображения не найдены в галерее' }) return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
} }
if (notResized.length > 0) { if (notResized.length > 0) {
return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' }) return reply
.code(400)
.send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
} }
} }
} }
@@ -255,10 +255,7 @@ export async function registerAdminProductRoutes(fastify) {
}, },
) )
fastify.delete( fastify.delete('/api/admin/products/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/products/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params const { id } = request.params
try { try {
await prisma.product.delete({ where: { id } }) await prisma.product.delete({ where: { id } })
@@ -266,6 +263,5 @@ export async function registerAdminProductRoutes(fastify) {
} catch { } catch {
reply.code(404).send({ error: 'Товар не найден' }) reply.code(404).send({ error: 'Товар не найден' })
} }
}, })
)
} }
+32 -57
View File
@@ -1,63 +1,39 @@
import { prisma } from "../../lib/prisma.js"; import { prisma } from '../../lib/prisma.js'
import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
export async function registerAdminReviewRoutes(fastify) { export async function registerAdminReviewRoutes(fastify) {
fastify.get( fastify.get('/api/admin/reviews', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
"/api/admin/reviews", const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending'
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const status =
typeof request.query?.status === "string"
? request.query.status.trim()
: "pending";
const pageRaw = request.query?.page; const pageRaw = request.query?.page
const pageParsed = const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw); const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
const page =
Number.isFinite(pageParsed) && pageParsed > 0
? Math.floor(pageParsed)
: 1;
const pageSizeRaw = request.query?.pageSize; const pageSizeRaw = request.query?.pageSize
const pageSizeParsed = const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
typeof pageSizeRaw === "string" const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
? Number(pageSizeRaw) if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
: 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 where = status ? { status } : {}
const total = await prisma.review.count({ where }); const total = await prisma.review.count({ where })
const items = await prisma.review.findMany({ const items = await prisma.review.findMany({
where, where,
include: { include: {
user: { select: { id: true, email: true, name: true } }, user: { select: { id: true, email: true, name: true } },
product: { select: { id: true, title: true } }, product: { select: { id: true, title: true } },
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}); })
return { items, total, page, pageSize }; return { items, total, page, pageSize }
}, })
);
fastify.patch( fastify.patch('/api/admin/reviews/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
"/api/admin/reviews/:id", const { id } = request.params
{ preHandler: [fastify.verifyAdmin] }, const action = String(request.body?.action || '').trim()
async (request, reply) => { if (action !== 'approve' && action !== 'reject') {
const { id } = request.params; return reply.code(400).send({ error: 'action должен быть approve или reject' })
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({ const existing = await prisma.review.findUnique({
@@ -66,25 +42,24 @@ export async function registerAdminReviewRoutes(fastify) {
product: { select: { title: true } }, product: { select: { title: true } },
user: { select: { name: true, email: true } }, user: { select: { name: true, email: true } },
}, },
}); })
if (!existing) return reply.code(404).send({ error: "Отзыв не найден" }); if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' })
const updated = await prisma.review.update({ const updated = await prisma.review.update({
where: { id }, where: { id },
data: { data: {
status: action === "approve" ? "approved" : "rejected", status: action === 'approve' ? 'approved' : 'rejected',
moderatedAt: new Date(), moderatedAt: new Date(),
}, },
}); })
request.server.eventBus.emit("review:created", { request.server.eventBus.emit('review:created', {
rating: updated.rating, rating: updated.rating,
text: updated.text || "", text: updated.text || '',
productTitle: existing.product?.title || "", productTitle: existing.product?.title || '',
userName: existing.user?.name || existing.user?.email || "", userName: existing.user?.name || existing.user?.email || '',
reviewId: updated.id, reviewId: updated.id,
}); })
return { item: updated }; return { item: updated }
}, })
);
} }
+9 -26
View File
@@ -1,11 +1,8 @@
import { prisma } from '../../lib/prisma.js'
import { normalizeEmail } from '../../lib/auth.js' import { normalizeEmail } from '../../lib/auth.js'
import { prisma } from '../../lib/prisma.js'
export async function registerAdminUserRoutes(fastify) { export async function registerAdminUserRoutes(fastify) {
fastify.get( fastify.get('/api/admin/users', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/users',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const qRaw = request.query?.q const qRaw = request.query?.q
const q = typeof qRaw === 'string' ? qRaw.trim() : '' const q = typeof qRaw === 'string' ? qRaw.trim() : ''
@@ -52,13 +49,9 @@ export async function registerAdminUserRoutes(fastify) {
})) }))
return { items, total, page, pageSize } return { items, total, page, pageSize }
}, })
)
fastify.post( fastify.post('/api/admin/users', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/users',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {} const body = request.body ?? {}
const email = normalizeEmail(body.email) const email = normalizeEmail(body.email)
@@ -94,13 +87,9 @@ export async function registerAdminUserRoutes(fastify) {
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
}) })
}, })
)
fastify.patch( fastify.patch('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/users/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params const { id } = request.params
const body = request.body ?? {} const body = request.body ?? {}
@@ -146,13 +135,9 @@ export async function registerAdminUserRoutes(fastify) {
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
} }
}, })
)
fastify.delete( fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/users/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params const { id } = request.params
try { try {
await prisma.user.delete({ where: { id } }) await prisma.user.delete({ where: { id } })
@@ -160,7 +145,5 @@ export async function registerAdminUserRoutes(fastify) {
} catch { } catch {
reply.code(404).send({ error: 'Пользователь не найден' }) reply.code(404).send({ error: 'Пользователь не найден' })
} }
}, })
)
} }
+36 -53
View File
@@ -1,11 +1,8 @@
import { prisma } from "../../../lib/prisma.js"; import { prisma } from '../../../lib/prisma.js'
export async function registerAdminNotificationRoutes(fastify) { export async function registerAdminNotificationRoutes(fastify) {
fastify.get( fastify.get('/api/admin/notifications/settings', { preHandler: [fastify.verifyAdmin] }, async () => {
"/api/admin/notifications/settings", let settings = await prisma.adminNotificationSettings.findFirst()
{ preHandler: [fastify.verifyAdmin] },
async () => {
let settings = await prisma.adminNotificationSettings.findFirst();
if (!settings) { if (!settings) {
settings = await prisma.adminNotificationSettings.create({ settings = await prisma.adminNotificationSettings.create({
data: { data: {
@@ -16,80 +13,66 @@ export async function registerAdminNotificationRoutes(fastify) {
newReview: true, newReview: true,
authCodeDuplicate: false, authCodeDuplicate: false,
}, },
}); })
} }
return { settings }; return { settings }
}, })
);
fastify.put( fastify.put('/api/admin/notifications/settings', { preHandler: [fastify.verifyAdmin] }, async (request) => {
"/api/admin/notifications/settings", const body = request.body || {}
{ preHandler: [fastify.verifyAdmin] }, let settings = await prisma.adminNotificationSettings.findFirst()
async (request) => {
const body = request.body || {};
let settings = await prisma.adminNotificationSettings.findFirst();
const data = {}; const data = {}
if ("emailEnabled" in body) if ('emailEnabled' in body) data.emailEnabled = Boolean(body.emailEnabled)
data.emailEnabled = Boolean(body.emailEnabled); if ('telegramEnabled' in body) data.telegramEnabled = Boolean(body.telegramEnabled)
if ("telegramEnabled" in body) if ('telegramChatId' in body) data.telegramChatId = body.telegramChatId || null
data.telegramEnabled = Boolean(body.telegramEnabled); if ('newOrder' in body) data.newOrder = Boolean(body.newOrder)
if ("telegramChatId" in body) if ('newOrderMessage' in body) data.newOrderMessage = Boolean(body.newOrderMessage)
data.telegramChatId = body.telegramChatId || null; if ('newReview' in body) data.newReview = Boolean(body.newReview)
if ("newOrder" in body) data.newOrder = Boolean(body.newOrder); if ('authCodeDuplicate' in body) data.authCodeDuplicate = Boolean(body.authCodeDuplicate)
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) { if (!settings) {
settings = await prisma.adminNotificationSettings.create({ data }); settings = await prisma.adminNotificationSettings.create({ data })
} else { } else {
settings = await prisma.adminNotificationSettings.update({ settings = await prisma.adminNotificationSettings.update({
where: { id: settings.id }, where: { id: settings.id },
data, data,
}); })
} }
return { settings }; return { settings }
}, })
);
fastify.post("/api/admin/notifications/telegram/webhook", async (request) => { fastify.post('/api/admin/notifications/telegram/webhook', async (request) => {
const update = request.body || {}; const update = request.body || {}
const message = update.message; const message = update.message
if (!message || !message.text || message.text !== "/start") if (!message || !message.text || message.text !== '/start') return { ok: true }
return { ok: true };
const chatId = String(message.chat.id); const chatId = String(message.chat.id)
const settings = await prisma.adminNotificationSettings.findFirst(); const settings = await prisma.adminNotificationSettings.findFirst()
if (settings) { if (settings) {
await prisma.adminNotificationSettings.update({ await prisma.adminNotificationSettings.update({
where: { id: settings.id }, where: { id: settings.id },
data: { telegramChatId: chatId }, data: { telegramChatId: chatId },
}); })
} else { } else {
await prisma.adminNotificationSettings.create({ await prisma.adminNotificationSettings.create({
data: { telegramChatId: chatId }, data: { telegramChatId: chatId },
}); })
} }
if (process.env.TELEGRAM_BOT_TOKEN) { if (process.env.TELEGRAM_BOT_TOKEN) {
await fetch( await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, method: 'POST',
{ headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
chat_id: chatId, chat_id: chatId,
text: "Вы подписаны на уведомления Любимый Креатив.", text: 'Вы подписаны на уведомления Любимый Креатив.',
}), }),
}, })
);
} }
return { ok: true }; return { ok: true }
}); })
} }
+4 -12
View File
@@ -17,10 +17,7 @@ export async function registerCatalogSliderRoutes(fastify) {
} }
}) })
fastify.get( fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async () => {
'/api/admin/catalog-slider',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const slides = await prisma.catalogSliderSlide.findMany({ const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
include: { galleryImage: true }, include: { galleryImage: true },
@@ -33,13 +30,9 @@ export async function registerCatalogSliderRoutes(fastify) {
caption: s.caption, caption: s.caption,
})), })),
} }
}, })
)
fastify.put( fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/catalog-slider',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {} const body = request.body ?? {}
const rawSlides = body.slides const rawSlides = body.slides
if (!Array.isArray(rawSlides)) { if (!Array.isArray(rawSlides)) {
@@ -94,6 +87,5 @@ export async function registerCatalogSliderRoutes(fastify) {
caption: s.caption, caption: s.caption,
})), })),
} }
}, })
)
} }
+8 -24
View File
@@ -29,19 +29,12 @@ export async function registerInfoPageRoutes(fastify) {
return { items } return { items }
}) })
fastify.get( fastify.get('/api/admin/info-page/blocks', { preHandler: [fastify.verifyAdmin] }, async () => {
'/api/admin/info-page/blocks',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] }) const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] })
return { items } return { items }
}, })
)
fastify.post( fastify.post('/api/admin/info-page/blocks', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/info-page/blocks',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const validated = validateBlockPayload(request.body, reply) const validated = validateBlockPayload(request.body, reply)
if (!validated) return if (!validated) return
@@ -51,13 +44,9 @@ export async function registerInfoPageRoutes(fastify) {
} catch { } catch {
return reply.code(409).send({ error: 'Блок с таким key уже существует' }) return reply.code(409).send({ error: 'Блок с таким key уже существует' })
} }
}, })
)
fastify.patch( fastify.patch('/api/admin/info-page/blocks/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/info-page/blocks/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params const { id } = request.params
const existing = await prisma.infoPageBlock.findUnique({ where: { id } }) const existing = await prisma.infoPageBlock.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Блок не найден' }) if (!existing) return reply.code(404).send({ error: 'Блок не найден' })
@@ -99,13 +88,9 @@ export async function registerInfoPageRoutes(fastify) {
} catch { } catch {
return reply.code(409).send({ error: 'Блок с таким key уже существует' }) return reply.code(409).send({ error: 'Блок с таким key уже существует' })
} }
}, })
)
fastify.delete( fastify.delete('/api/admin/info-page/blocks/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
'/api/admin/info-page/blocks/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params const { id } = request.params
try { try {
await prisma.infoPageBlock.delete({ where: { id } }) await prisma.infoPageBlock.delete({ where: { id } })
@@ -113,6 +98,5 @@ export async function registerInfoPageRoutes(fastify) {
} catch { } catch {
return reply.code(404).send({ error: 'Блок не найден' }) 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) => { fastify.get('/api/products', { schema: PUBLIC_PRODUCTS_QUERY_SCHEMA }, async (request, reply) => {
const { mapProductForApi } = request.server
const { categorySlug } = request.query const { categorySlug } = request.query
const qRaw = request.query?.q const qRaw = request.query?.q
const q = typeof qRaw === 'string' ? qRaw.trim() : '' 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) return request.server.mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
}) })
} }
+8 -16
View File
@@ -1,17 +1,14 @@
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
import { import {
formatFileTooLargeMessage, formatFileTooLargeMessage,
getOtherUploadMaxFileBytes, getOtherUploadMaxFileBytes,
isMultipartFileTooLargeError, isMultipartFileTooLargeError,
} from '../../lib/upload-limits.js' } from '../../lib/upload-limits.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
export async function registerPublicReviewRoutes(fastify) { export async function registerPublicReviewRoutes(fastify) {
fastify.post( fastify.post('/api/reviews/upload-image', { preHandler: [fastify.authenticate] }, async (request, reply) => {
'/api/reviews/upload-image',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
try { try {
const urls = await persistMultipartImages(request, { const urls = await persistMultipartImages(request, {
maxFiles: 1, maxFiles: 1,
@@ -32,8 +29,7 @@ export async function registerPublicReviewRoutes(fastify) {
} }
return reply.code(statusCode).send({ error: message }) return reply.code(statusCode).send({ error: message })
} }
}, })
)
fastify.get('/api/reviews/latest', async (request, reply) => { fastify.get('/api/reviews/latest', async (request, reply) => {
const limitRaw = request.query?.limit const limitRaw = request.query?.limit
@@ -102,10 +98,7 @@ export async function registerPublicReviewRoutes(fastify) {
return { items, total, page, pageSize } return { items, total, page, pageSize }
}) })
fastify.post( fastify.post('/api/products/:id/reviews', { preHandler: [fastify.authenticate] }, async (request, reply) => {
'/api/products/:id/reviews',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub
const { id: productId } = request.params const { id: productId } = request.params
@@ -121,7 +114,8 @@ export async function registerPublicReviewRoutes(fastify) {
if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' }) if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' })
const imageUrlRaw = request.body?.imageUrl const imageUrlRaw = request.body?.imageUrl
const imageUrl = imageUrlRaw === null || imageUrlRaw === undefined ? null : String(imageUrlRaw).trim() 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 > 300)
return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' })
if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) { if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) {
return reply.code(400).send({ error: 'Некорректная ссылка на изображение' }) return reply.code(400).send({ error: 'Некорректная ссылка на изображение' })
} }
@@ -141,7 +135,5 @@ export async function registerPublicReviewRoutes(fastify) {
} catch { } catch {
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' }) return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
} }
}, })
)
} }
+70 -108
View File
@@ -1,166 +1,129 @@
import { import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
issueEmailCode, import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
normalizeEmail, import { prisma } from '../lib/prisma.js'
verifyEmailCode,
} from "../lib/auth.js";
import { prisma } from "../lib/prisma.js";
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
function mapUserForClient(user) { function mapUserForClient(user) {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL); const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
const userEmail = normalizeEmail(user.email); const userEmail = normalizeEmail(user.email)
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: user.name,
phone: user.phone, phone: user.phone,
isAdmin: Boolean(adminEmail) && userEmail === adminEmail, isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
}; }
} }
export async function registerAuthRoutes(fastify) { export async function registerAuthRoutes(fastify) {
fastify.post("/api/auth/request-code", async (request, reply) => { fastify.post('/api/auth/request-code', async (request, reply) => {
const email = normalizeEmail(request.body?.email); const email = normalizeEmail(request.body?.email)
if (!email || !email.includes("@")) if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
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 adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
const isAdmin = email === adminEmail; const isAdmin = email === adminEmail
request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, {
email, email,
code, code,
isAdmin, isAdmin,
}); })
return { ok: true }; return { ok: true }
}); })
fastify.post("/api/auth/verify-code", async (request, reply) => { fastify.post('/api/auth/verify-code', async (request, reply) => {
const email = normalizeEmail(request.body?.email); const email = normalizeEmail(request.body?.email)
const code = String(request.body?.code || "").trim(); const code = String(request.body?.code || '').trim()
if (!email || !email.includes("@")) if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
return reply.code(400).send({ error: "Некорректная почта" }); if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
if (!code || code.length !== 6)
return reply.code(400).send({ error: "Код должен быть из 6 цифр" });
const ok = await verifyEmailCode({ email, purpose: "login", code }); const ok = await verifyEmailCode({ email, purpose: 'login', code })
if (!ok) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
return reply.code(401).send({ error: "Неверный или истёкший код" });
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email }, where: { email },
update: {}, update: {},
create: { email }, create: { email },
}); })
// Ensure notification preference exists // Ensure notification preference exists
await prisma.notificationPreference.upsert({ await prisma.notificationPreference.upsert({
where: { userId: user.id }, where: { userId: user.id },
create: { userId: user.id, globalEnabled: true }, create: { userId: user.id, globalEnabled: true },
update: {}, update: {},
}); })
const token = fastify.jwt.sign({ sub: user.id, email: user.email }); const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return { token, user: mapUserForClient(user) }; return { token, user: mapUserForClient(user) }
}); })
fastify.get( fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => {
"/api/me", const userId = request.user.sub
{ preHandler: [fastify.authenticate] }, const user = await prisma.user.findUnique({ where: { id: userId } })
async (request) => { if (!user) return { user: null }
const userId = request.user.sub; return { user: mapUserForClient(user) }
const user = await prisma.user.findUnique({ where: { id: userId } }); })
if (!user) return { user: null };
return { user: mapUserForClient(user) };
},
);
fastify.post( fastify.post('/api/me/change-email/request-code', { preHandler: [fastify.authenticate] }, async (request, reply) => {
"/api/me/change-email/request-code", const userId = request.user.sub
{ preHandler: [fastify.authenticate] }, const newEmail = normalizeEmail(request.body?.newEmail)
async (request, reply) => { if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
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({ const exists = await prisma.user.findUnique({
where: { email: newEmail }, where: { email: newEmail },
}); })
if (exists) if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
return reply.code(409).send({ error: "Эта почта уже занята" });
await issueEmailCode({ await issueEmailCode({
email: newEmail, email: newEmail,
purpose: "change_email", purpose: 'change_email',
userId, userId,
}); })
return { ok: true }; return { ok: true }
}, })
);
fastify.post( fastify.post('/api/me/change-email/verify', { preHandler: [fastify.authenticate] }, async (request, reply) => {
"/api/me/change-email/verify", const userId = request.user.sub
{ preHandler: [fastify.authenticate] }, const newEmail = normalizeEmail(request.body?.newEmail)
async (request, reply) => { const code = String(request.body?.code || '').trim()
const userId = request.user.sub; if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
const newEmail = normalizeEmail(request.body?.newEmail); if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
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({ const exists = await prisma.user.findUnique({
where: { email: newEmail }, where: { email: newEmail },
}); })
if (exists) if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
return reply.code(409).send({ error: "Эта почта уже занята" });
const ok = await verifyEmailCode({ const ok = await verifyEmailCode({
email: newEmail, email: newEmail,
purpose: "change_email", purpose: 'change_email',
code, code,
userId, userId,
}); })
if (!ok) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
return reply.code(401).send({ error: "Неверный или истёкший код" });
const user = await prisma.user.update({ const user = await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { email: newEmail }, data: { email: newEmail },
}); })
return { user: mapUserForClient(user) }; return { user: mapUserForClient(user) }
}, })
);
fastify.patch( fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => {
"/api/me/profile", const userId = request.user.sub
{ preHandler: [fastify.authenticate] }, const nameRaw = request.body?.name
async (request, reply) => { const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
const userId = request.user.sub; const phoneRaw = request.body?.phone
const nameRaw = request.body?.name; const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim()
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) if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
return reply.code(400).send({ error: "Имя/ник максимум 40 символов" });
if (phone !== null) { if (phone !== null) {
const compact = phone.replace(/[\s()-]/g, ""); const compact = phone.replace(/[\s()-]/g, '')
if (compact.length > 20) if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' })
return reply.code(400).send({ error: "Телефон слишком длинный" });
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) { if (compact.length && !/^\+?\d{7,20}$/.test(compact)) {
return reply.code(400).send({ error: "Некорректный телефон" }); return reply.code(400).send({ error: 'Некорректный телефон' })
} }
} }
@@ -170,8 +133,7 @@ export async function registerAuthRoutes(fastify) {
name: name && name.length ? name : null, name: name && name.length ? name : null,
phone: phone && phone.length ? phone : null, phone: phone && phone.length ? phone : null,
}, },
}); })
return { user: mapUserForClient(updated) }; return { user: mapUserForClient(updated) }
}, })
);
} }
+2 -2
View File
@@ -1,6 +1,5 @@
// server/src/routes/uploads-resized.js // server/src/routes/uploads-resized.js
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path'
import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js' import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js'
const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable' const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable'
@@ -18,7 +17,8 @@ export function registerUploadsResized(fastify) {
// Parse: [subdir/]filename.format // Parse: [subdir/]filename.format
const parts = rawPath.split('/') const parts = rawPath.split('/')
let filename, subdir = '' let filename,
subdir = ''
if (parts.length > 1) { if (parts.length > 1) {
subdir = parts.slice(0, -1).join('/') + '/' subdir = parts.slice(0, -1).join('/') + '/'
+18 -34
View File
@@ -25,7 +25,8 @@ function validateAddressPayload(body, reply) {
const commentRaw = body?.comment const commentRaw = body?.comment
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() 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 lat = Number(body?.lat)
const lng = Number(body?.lng) const lng = Number(body?.lng)
@@ -44,23 +45,16 @@ function validateAddressPayload(body, reply) {
} }
export async function registerUserAddressRoutes(fastify) { export async function registerUserAddressRoutes(fastify) {
fastify.get( fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request) => {
'/api/me/addresses',
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub const userId = request.user.sub
const items = await prisma.shippingAddress.findMany({ const items = await prisma.shippingAddress.findMany({
where: { userId }, where: { userId },
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }], orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
}) })
return { items } return { items }
}, })
)
fastify.post( fastify.post('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
'/api/me/addresses',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub
const validated = validateAddressPayload(request.body, reply) const validated = validateAddressPayload(request.body, reply)
if (!validated) return if (!validated) return
@@ -79,13 +73,9 @@ export async function registerUserAddressRoutes(fastify) {
}) })
}) })
return reply.code(201).send({ item: created }) return reply.code(201).send({ item: created })
}, })
)
fastify.patch( fastify.patch('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
'/api/me/addresses/:id',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub
const { id } = request.params const { id } = request.params
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
@@ -97,7 +87,8 @@ export async function registerUserAddressRoutes(fastify) {
if (body.label !== undefined) { if (body.label !== undefined) {
const labelRaw = body.label const labelRaw = body.label
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) if (label !== null && label.length > 40)
return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
data.label = label && label.length ? label : null data.label = label && label.length ? label : null
} }
@@ -125,7 +116,8 @@ export async function registerUserAddressRoutes(fastify) {
if (body.comment !== undefined) { if (body.comment !== undefined) {
const commentRaw = body.comment const commentRaw = body.comment
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() 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 символов' })
data.comment = comment && comment.length ? comment : null data.comment = comment && comment.length ? comment : null
} }
@@ -137,7 +129,8 @@ export async function registerUserAddressRoutes(fastify) {
if (body.lng !== undefined) { if (body.lng !== undefined) {
const lng = Number(body.lng) const lng = Number(body.lng)
if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' }) if (!Number.isFinite(lng) || lng < -180 || lng > 180)
return reply.code(400).send({ error: 'Некорректная долгота' })
data.lng = lng data.lng = lng
} }
@@ -156,13 +149,9 @@ export async function registerUserAddressRoutes(fastify) {
}) })
return { item: updated } return { item: updated }
}, })
)
fastify.delete( fastify.delete('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
'/api/me/addresses/:id',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub
const { id } = request.params const { id } = request.params
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
@@ -170,13 +159,9 @@ export async function registerUserAddressRoutes(fastify) {
await prisma.shippingAddress.delete({ where: { id } }) await prisma.shippingAddress.delete({ where: { id } })
return reply.code(204).send() return reply.code(204).send()
}, })
)
fastify.post( fastify.post('/api/me/addresses/:id/default', { preHandler: [fastify.authenticate] }, async (request, reply) => {
'/api/me/addresses/:id/default',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub
const { id } = request.params const { id } = request.params
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
@@ -188,6 +173,5 @@ export async function registerUserAddressRoutes(fastify) {
}) })
return { item: updated } return { item: updated }
}, })
)
} }
+8 -24
View File
@@ -1,10 +1,7 @@
import { prisma } from '../lib/prisma.js' import { prisma } from '../lib/prisma.js'
export async function registerUserCartRoutes(fastify) { export async function registerUserCartRoutes(fastify) {
fastify.get( fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request) => {
'/api/me/cart',
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub const userId = request.user.sub
const items = await prisma.cartItem.findMany({ const items = await prisma.cartItem.findMany({
where: { userId }, where: { userId },
@@ -18,13 +15,9 @@ export async function registerUserCartRoutes(fastify) {
product: x.product, product: x.product,
})), })),
} }
}, })
)
fastify.post( fastify.post('/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => {
'/api/me/cart/items',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub
const productId = String(request.body?.productId || '').trim() const productId = String(request.body?.productId || '').trim()
const qtyRaw = request.body?.qty const qtyRaw = request.body?.qty
@@ -47,13 +40,9 @@ export async function registerUserCartRoutes(fastify) {
create: { userId, productId, qty: nextQty }, create: { userId, productId, qty: nextQty },
}) })
return reply.code(201).send({ item }) return reply.code(201).send({ item })
}, })
)
fastify.patch( fastify.patch('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
'/api/me/cart/items/:id',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub
const { id } = request.params const { id } = request.params
const qtyRaw = request.body?.qty const qtyRaw = request.body?.qty
@@ -74,19 +63,14 @@ export async function registerUserCartRoutes(fastify) {
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } }) const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
return { item: updated } return { item: updated }
}, })
)
fastify.delete( fastify.delete('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
'/api/me/cart/items/:id',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub
const { id } = request.params const { id } = request.params
const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
await prisma.cartItem.delete({ where: { id } }) await prisma.cartItem.delete({ where: { id } })
return reply.code(204).send() return reply.code(204).send()
}, })
)
} }
+64 -92
View File
@@ -1,89 +1,71 @@
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) { export async function registerUserMessageRoutes(fastify) {
fastify.get( fastify.get('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => {
"/api/me/orders/:id/messages", const userId = request.user.sub
{ preHandler: [fastify.authenticate] }, const { id } = request.params
async (request, reply) => { const order = await prisma.order.findFirst({ where: { id, userId } })
const userId = request.user.sub; if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
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({ const items = await prisma.orderMessage.findMany({
where: { orderId: id }, where: { orderId: id },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: 'asc' },
}); })
return { items }; return { items }
}, })
);
fastify.post( fastify.post('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => {
"/api/me/orders/:id/messages", const userId = request.user.sub
{ preHandler: [fastify.authenticate] }, const { id } = request.params
async (request, reply) => { const order = await prisma.order.findFirst({ where: { id, userId } })
const userId = request.user.sub; if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const { id } = request.params; const text = String(request.body?.text || '').trim()
const order = await prisma.order.findFirst({ where: { id, userId } }); if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
if (!order) return reply.code(404).send({ error: "Заказ не найден" }); if (text.length > 2000) return reply.code(400).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({ const msg = await prisma.orderMessage.create({
data: { orderId: id, authorType: "user", text }, data: { orderId: id, authorType: 'user', text },
}); })
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, { request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
orderId: id, orderId: id,
authorType: "user", authorType: 'user',
messageId: msg.id, messageId: msg.id,
preview: text, preview: text,
}); })
return reply.code(201).send({ item: msg }); return reply.code(201).send({ item: msg })
}, })
);
fastify.get( fastify.get('/api/me/messages/unread-count', { preHandler: [fastify.authenticate] }, async (request) => {
"/api/me/messages/unread-count", const userId = request.user.sub
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub;
const orders = await prisma.order.findMany({ const orders = await prisma.order.findMany({
where: { userId }, where: { userId },
select: { id: true }, select: { id: true },
}); })
if (orders.length === 0) return { count: 0 }; if (orders.length === 0) return { count: 0 }
const readStates = await prisma.userOrderMessageReadState.findMany({ const readStates = await prisma.userOrderMessageReadState.findMany({
where: { userId }, where: { userId },
}); })
const lastReadByOrder = new Map( const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
readStates.map((r) => [r.orderId, r.lastReadAt]),
);
let count = 0; let count = 0
for (const o of orders) { for (const o of orders) {
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0); const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
const n = await prisma.orderMessage.count({ const n = await prisma.orderMessage.count({
where: { where: {
orderId: o.id, orderId: o.id,
authorType: "admin", authorType: 'admin',
createdAt: { gt: lastRead }, createdAt: { gt: lastRead },
}, },
}); })
count += n; count += n
} }
return { count }; return { count }
}, })
);
fastify.get( fastify.get('/api/me/conversations', { preHandler: [fastify.authenticate] }, async (request) => {
"/api/me/conversations", const userId = request.user.sub
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub;
const orders = await prisma.order.findMany({ const orders = await prisma.order.findMany({
where: { userId, messages: { some: {} } }, where: { userId, messages: { some: {} } },
select: { select: {
@@ -91,65 +73,55 @@ export async function registerUserMessageRoutes(fastify) {
status: true, status: true,
deliveryType: true, deliveryType: true,
messages: { messages: {
orderBy: { createdAt: "desc" }, orderBy: { createdAt: 'desc' },
take: 1, take: 1,
select: { text: true, createdAt: true }, select: { text: true, createdAt: true },
}, },
}, },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: 'desc' },
}); })
const readStates = await prisma.userOrderMessageReadState.findMany({ const readStates = await prisma.userOrderMessageReadState.findMany({
where: { userId }, where: { userId },
}); })
const lastReadByOrder = new Map( const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
readStates.map((r) => [r.orderId, r.lastReadAt]),
);
const items = []; const items = []
for (const o of orders) { for (const o of orders) {
const lastMsg = o.messages[0]; const lastMsg = o.messages[0]
if (!lastMsg) continue; if (!lastMsg) continue
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0); const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
const unreadCount = await prisma.orderMessage.count({ const unreadCount = await prisma.orderMessage.count({
where: { where: {
orderId: o.id, orderId: o.id,
authorType: "admin", authorType: 'admin',
createdAt: { gt: lastRead }, createdAt: { gt: lastRead },
}, },
}); })
items.push({ items.push({
orderId: o.id, orderId: o.id,
status: o.status, status: o.status,
deliveryType: o.deliveryType, deliveryType: o.deliveryType,
lastMessageAt: lastMsg.createdAt, lastMessageAt: lastMsg.createdAt,
preview: preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}` : lastMsg.text,
lastMsg.text.length > 280
? `${lastMsg.text.slice(0, 277)}`
: lastMsg.text,
unreadCount, unreadCount,
}); })
} }
return { items }; return { items }
}, })
);
fastify.post( fastify.post('/api/me/orders/:id/messages/read', { preHandler: [fastify.authenticate] }, async (request, reply) => {
"/api/me/orders/:id/messages/read", const userId = request.user.sub
{ preHandler: [fastify.authenticate] }, const { id } = request.params
async (request, reply) => { const order = await prisma.order.findFirst({ where: { id, userId } })
const userId = request.user.sub; if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
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(); const now = new Date()
await prisma.userOrderMessageReadState.upsert({ await prisma.userOrderMessageReadState.upsert({
where: { userId_orderId: { userId, orderId: id } }, where: { userId_orderId: { userId, orderId: id } },
create: { userId, orderId: id, lastReadAt: now }, create: { userId, orderId: id, lastReadAt: now },
update: { lastReadAt: now }, update: { lastReadAt: now },
}); })
return { ok: true }; return { ok: true }
}, })
);
} }
+113 -154
View File
@@ -1,98 +1,75 @@
import { isDeliveryCarrier } from "../lib/delivery-carrier.js"; import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import { prisma } from "../lib/prisma.js"; import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js"; import { prisma } from '../lib/prisma.js'
export async function registerUserOrderRoutes(fastify) { export async function registerUserOrderRoutes(fastify) {
// ---- Создание заказа (checkout) ---- // ---- Создание заказа (checkout) ----
fastify.post( fastify.post('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => {
"/api/me/orders", const userId = request.user.sub
{ preHandler: [fastify.authenticate] }, const deliveryTypeRaw = request.body?.deliveryType
async (request, reply) => {
const userId = request.user.sub;
const deliveryTypeRaw = request.body?.deliveryType;
const deliveryType = const deliveryType =
deliveryTypeRaw === undefined || deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === ''
deliveryTypeRaw === null || ? 'delivery'
deliveryTypeRaw === "" : String(deliveryTypeRaw).trim()
? "delivery"
: String(deliveryTypeRaw).trim();
const addressId = String(request.body?.addressId || "").trim(); const addressId = String(request.body?.addressId || '').trim()
const commentRaw = request.body?.comment; const commentRaw = request.body?.comment
const comment = const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
commentRaw === null || commentRaw === undefined
? null
: String(commentRaw).trim();
const paymentMethodRaw = request.body?.paymentMethod; const paymentMethodRaw = request.body?.paymentMethod
const paymentMethod = const paymentMethod =
paymentMethodRaw === undefined || paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === ''
paymentMethodRaw === null || ? 'online'
paymentMethodRaw === "" : String(paymentMethodRaw).trim()
? "online" if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') {
: String(paymentMethodRaw).trim(); return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' })
if (paymentMethod !== "online" && paymentMethod !== "on_pickup") {
return reply
.code(400)
.send({ error: "paymentMethod должен быть online | on_pickup" });
} }
if (deliveryType !== "delivery" && deliveryType !== "pickup") { if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
return reply return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
.code(400)
.send({ error: "deliveryType должен быть delivery | pickup" });
} }
const carrierRaw = request.body?.deliveryCarrier; const carrierRaw = request.body?.deliveryCarrier
let deliveryCarrier = null; let deliveryCarrier = null
if (deliveryType === "delivery") { if (deliveryType === 'delivery') {
const carrierStr = const carrierStr =
carrierRaw === undefined || carrierRaw === null || carrierRaw === "" carrierRaw === undefined || carrierRaw === null || carrierRaw === '' ? '' : String(carrierRaw).trim()
? ""
: String(carrierRaw).trim();
if (!isDeliveryCarrier(carrierStr)) { if (!isDeliveryCarrier(carrierStr)) {
return reply.code(400).send({ return reply.code(400).send({
error: error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST',
"deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST", })
});
} }
deliveryCarrier = carrierStr; deliveryCarrier = carrierStr
} }
if (paymentMethod === "on_pickup" && deliveryType !== "pickup") { if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
return reply return reply.code(400).send({
.code(400) error: 'Оплата при получении доступна только для самовывоза',
.send({ })
error: "Оплата при получении доступна только для самовывоза",
});
} }
let address = null; let address = null
if (deliveryType === "delivery") { if (deliveryType === 'delivery') {
if (!addressId) if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
return reply.code(400).send({ error: "Выберите адрес доставки" });
address = await prisma.shippingAddress.findFirst({ address = await prisma.shippingAddress.findFirst({
where: { id: addressId, userId }, where: { id: addressId, userId },
}); })
if (!address) return reply.code(404).send({ error: "Адрес не найден" }); if (!address) return reply.code(404).send({ error: 'Адрес не найден' })
} }
const cartItems = await prisma.cartItem.findMany({ const cartItems = await prisma.cartItem.findMany({
where: { userId }, where: { userId },
include: { product: true }, include: { product: true },
}); })
if (cartItems.length === 0) if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
return reply.code(400).send({ error: "Корзина пуста" });
for (const ci of cartItems) { for (const ci of cartItems) {
const available = ci.product.inStock ? ci.product.quantity : 1; const available = ci.product.inStock ? ci.product.quantity : 1
if (ci.qty > available) { if (ci.qty > available) {
return reply return reply.code(409).send({
.code(409)
.send({
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`, error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
}); })
} }
} }
@@ -101,20 +78,17 @@ export async function registerUserOrderRoutes(fastify) {
qty: ci.qty, qty: ci.qty,
titleSnapshot: ci.product.title, titleSnapshot: ci.product.title,
priceCentsSnapshot: ci.product.priceCents, priceCentsSnapshot: ci.product.priceCents,
})); }))
const itemsSubtotalCents = itemsPayload.reduce( const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
(sum, i) => sum + i.priceCentsSnapshot * i.qty, const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0
0, const totalCents = itemsSubtotalCents + deliveryFeeCents
);
const deliveryFeeCents = deliveryType === "delivery" ? 50000 : 0;
const totalCents = itemsSubtotalCents + deliveryFeeCents;
const addressSnapshotJson = const addressSnapshotJson =
deliveryType === "pickup" deliveryType === 'pickup'
? JSON.stringify({ deliveryType: "pickup" }) ? JSON.stringify({ deliveryType: 'pickup' })
: JSON.stringify({ : JSON.stringify({
deliveryType: "delivery", deliveryType: 'delivery',
id: address.id, id: address.id,
label: address.label, label: address.label,
recipientName: address.recipientName, recipientName: address.recipientName,
@@ -123,29 +97,29 @@ export async function registerUserOrderRoutes(fastify) {
comment: address.comment, comment: address.comment,
lat: address.lat, lat: address.lat,
lng: address.lng, lng: address.lng,
}); })
let initialStatus = "PENDING_PAYMENT"; let initialStatus = 'PENDING_PAYMENT'
let deliveryFeeLocked = true; let deliveryFeeLocked = true
if (paymentMethod === "on_pickup") { if (paymentMethod === 'on_pickup') {
initialStatus = "IN_PROGRESS"; initialStatus = 'IN_PROGRESS'
} else if (deliveryType === "delivery") { } else if (deliveryType === 'delivery') {
initialStatus = "PENDING_PAYMENT"; initialStatus = 'PENDING_PAYMENT'
deliveryFeeLocked = false; deliveryFeeLocked = false
} }
let created; let created
try { try {
created = await prisma.$transaction(async (tx) => { created = await prisma.$transaction(async (tx) => {
for (const ci of cartItems) { for (const ci of cartItems) {
if (!ci.product.inStock) continue; if (!ci.product.inStock) continue
const res = await tx.product.updateMany({ const res = await tx.product.updateMany({
where: { id: ci.productId, quantity: { gte: ci.qty } }, where: { id: ci.productId, quantity: { gte: ci.qty } },
data: { quantity: { decrement: ci.qty } }, data: { quantity: { decrement: ci.qty } },
}); })
if (res.count !== 1) { if (res.count !== 1) {
throw new Error(`Недостаточно товара: "${ci.product.title}"`); throw new Error(`Недостаточно товара: "${ci.product.title}"`)
} }
} }
@@ -160,7 +134,7 @@ export async function registerUserOrderRoutes(fastify) {
itemsSubtotalCents, itemsSubtotalCents,
deliveryFeeCents, deliveryFeeCents,
totalCents, totalCents,
currency: "RUB", currency: 'RUB',
addressSnapshotJson, addressSnapshotJson,
comment: comment && comment.length ? comment : null, comment: comment && comment.length ? comment : null,
items: { items: {
@@ -172,16 +146,14 @@ export async function registerUserOrderRoutes(fastify) {
})), })),
}, },
}, },
}); })
await tx.cartItem.deleteMany({ where: { userId } }); await tx.cartItem.deleteMany({ where: { userId } })
return order; return order
}); })
} catch (e) { } catch (e) {
return reply return reply.code(409).send({
.code(409) error: (e instanceof Error && e.message) || 'Недостаточно товара',
.send({ })
error: (e instanceof Error && e.message) || "Недостаточно товара",
});
} }
// Emit notification events // Emit notification events
@@ -191,32 +163,28 @@ export async function registerUserOrderRoutes(fastify) {
totalCents: created.totalCents, totalCents: created.totalCents,
itemsCount: cartItems.length, itemsCount: cartItems.length,
deliveryType: created.deliveryType, deliveryType: created.deliveryType,
}); })
// Also emit admin notification // Also emit admin notification
request.server.eventBus.emit("order:created:admin", { request.server.eventBus.emit('order:created:admin', {
orderId: created.id, orderId: created.id,
userId, userId,
userEmail: request.user.email || "", userEmail: request.user.email || '',
totalCents: created.totalCents, totalCents: created.totalCents,
itemsCount: cartItems.length, itemsCount: cartItems.length,
deliveryType: created.deliveryType, deliveryType: created.deliveryType,
}); })
return reply.code(201).send({ orderId: created.id }); return reply.code(201).send({ orderId: created.id })
}, })
);
fastify.get( fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => {
"/api/me/orders", const userId = request.user.sub
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub;
const orders = await prisma.order.findMany({ const orders = await prisma.order.findMany({
where: { userId }, where: { userId },
include: { items: true }, include: { items: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: 'desc' },
}); })
return { return {
items: orders.map((o) => ({ items: orders.map((o) => ({
id: o.id, id: o.id,
@@ -227,86 +195,77 @@ export async function registerUserOrderRoutes(fastify) {
updatedAt: o.updatedAt, updatedAt: o.updatedAt,
itemsCount: o.items.reduce((s, i) => s + i.qty, 0), itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
})), })),
}; }
}, })
);
fastify.get( fastify.get('/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
"/api/me/orders/:id", const userId = request.user.sub
{ preHandler: [fastify.authenticate] }, const { id } = request.params
async (request, reply) => {
const userId = request.user.sub;
const { id } = request.params;
const order = await prisma.order.findFirst({ const order = await prisma.order.findFirst({
where: { id, userId }, where: { id, userId },
include: { items: true, messages: { orderBy: { createdAt: "asc" } } }, include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
}); })
if (!order) return reply.code(404).send({ error: "Заказ не найден" }); if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
return { item: order }; return { item: order }
}, })
);
fastify.get( fastify.get(
"/api/me/orders/:id/review-eligibility", '/api/me/orders/:id/review-eligibility',
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub; const userId = request.user.sub
const { id } = request.params; const { id } = request.params
const order = await prisma.order.findFirst({ const order = await prisma.order.findFirst({
where: { id, userId }, where: { id, userId },
include: { items: true }, include: { items: true },
}); })
if (!order) return reply.code(404).send({ error: "Заказ не найден" }); if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
if (order.status !== "DONE") { if (order.status !== 'DONE') {
return { canReview: false, items: [] }; return { canReview: false, items: [] }
} }
const uniq = new Map(); const uniq = new Map()
for (const it of order.items) { for (const it of order.items) {
if (!uniq.has(it.productId)) { if (!uniq.has(it.productId)) {
uniq.set(it.productId, { uniq.set(it.productId, {
productId: it.productId, productId: it.productId,
title: it.titleSnapshot, title: it.titleSnapshot,
}); })
} }
} }
const productIds = [...uniq.keys()]; const productIds = [...uniq.keys()]
const existing = await prisma.review.findMany({ const existing = await prisma.review.findMany({
where: { userId, productId: { in: productIds } }, where: { userId, productId: { in: productIds } },
select: { productId: true }, select: { productId: true },
}); })
const reviewed = new Set(existing.map((r) => r.productId)); const reviewed = new Set(existing.map((r) => r.productId))
return { return {
canReview: true, canReview: true,
items: [...uniq.values()].map((x) => ({ items: [...uniq.values()].map((x) => ({
...x, ...x,
hasReview: reviewed.has(x.productId), hasReview: reviewed.has(x.productId),
})), })),
}; }
}, },
); )
fastify.post( fastify.post(
"/api/me/orders/:id/confirm-received", '/api/me/orders/:id/confirm-received',
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub; const userId = request.user.sub
const { id } = request.params; const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } }); const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: "Заказ не найден" }); if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const okDelivery = const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
order.deliveryType === "delivery" && order.status === "SHIPPED"; const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
const okPickup =
order.deliveryType === "pickup" && order.status === "READY_FOR_PICKUP";
if (!okDelivery && !okPickup) { if (!okDelivery && !okPickup) {
return reply return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
.code(409)
.send({ error: "Сейчас нельзя подтвердить получение заказа" });
} }
await prisma.order.update({ where: { id }, data: { status: "DONE" } }); await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
return { ok: true, status: "DONE" }; return { ok: true, status: 'DONE' }
}, },
); )
} }
+57 -85
View File
@@ -1,142 +1,114 @@
import { prisma } from "../lib/prisma.js"; import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import { escapeHtml } from "../lib/escape-html.js"; import { escapeHtml } from '../lib/escape-html.js'
import { getOtherUploadMaxFileBytes } from "../lib/upload-limits.js"; import { prisma } from '../lib/prisma.js'
import { saveImageBufferToUploads } from "../lib/upload-images.js"; import { saveImageBufferToUploads } from '../lib/upload-images.js'
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js"; import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
export async function registerUserPaymentRoutes(fastify) { export async function registerUserPaymentRoutes(fastify) {
fastify.post( fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => {
"/api/me/orders/:id/pay", const userId = request.user.sub
{ preHandler: [fastify.authenticate] }, const { id } = request.params
async (request, reply) => { const order = await prisma.order.findFirst({ where: { id, userId } })
const userId = request.user.sub; if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
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"; const paymentMethod = order.paymentMethod ?? 'online'
if (paymentMethod === "on_pickup") { if (paymentMethod === 'on_pickup') {
return reply return reply.code(409).send({
.code(409) error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.',
.send({ })
error:
"Для этого заказа оплата при получении — кнопка оплаты не нужна.",
});
} }
if (order.status !== "PENDING_PAYMENT") { if (order.status !== 'PENDING_PAYMENT') {
return reply return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
.code(409)
.send({ error: "Сейчас нельзя выполнить оплату для этого заказа" });
} }
if (!request.isMultipart()) { if (!request.isMultipart()) {
return reply return reply.code(400).send({
.code(400) error: 'Отправьте multipart/form-data: поле detail и/или файл receipt',
.send({ })
error:
"Отправьте multipart/form-data: поле detail и/или файл receipt",
});
} }
let detail = ""; let detail = ''
let receiptBuffer = null; let receiptBuffer = null
let receiptFilename = ""; let receiptFilename = ''
try { try {
const otherLimit = getOtherUploadMaxFileBytes(); const otherLimit = getOtherUploadMaxFileBytes()
const parts = request.parts({ const parts = request.parts({
limits: { limits: {
fileSize: otherLimit, fileSize: otherLimit,
files: 2, files: 2,
}, },
}); })
for await (const part of parts) { for await (const part of parts) {
if (part.file) { if (part.file) {
if (part.fieldname === "receipt") { if (part.fieldname === 'receipt') {
if (receiptBuffer !== null) { if (receiptBuffer !== null) {
return reply return reply.code(400).send({ error: 'Допускается один файл receipt' })
.code(400)
.send({ error: "Допускается один файл receipt" });
} }
receiptBuffer = await part.toBuffer(); receiptBuffer = await part.toBuffer()
receiptFilename = part.filename ?? "receipt"; receiptFilename = part.filename ?? 'receipt'
} }
} else if (part.fieldname === "detail") { } else if (part.fieldname === 'detail') {
detail = String(part.value ?? "").trim(); detail = String(part.value ?? '').trim()
} }
} }
} catch (err) { } catch (err) {
const msg = const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
err instanceof Error ? err.message : "Не удалось разобрать форму"; return reply.code(400).send({ error: msg })
return reply.code(400).send({ error: msg });
} }
const hasDetail = detail.length > 0; const hasDetail = detail.length > 0
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0; const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
if (!hasDetail && !hasReceipt) { if (!hasDetail && !hasReceipt) {
return reply return reply.code(400).send({
.code(400) error: 'Укажите текст о платеже и/или прикрепите изображение чека',
.send({ })
error: "Укажите текст о платеже и/или прикрепите изображение чека",
});
} }
const maxDetail = 2000; const maxDetail = 2000
if (detail.length > maxDetail) { if (detail.length > maxDetail) {
return reply return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
.code(400)
.send({ error: `Текст не длиннее ${maxDetail} символов` });
} }
let attachmentUrl = null; let attachmentUrl = null
if (hasReceipt) { if (hasReceipt) {
try { try {
attachmentUrl = await saveImageBufferToUploads( attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
receiptFilename,
receiptBuffer,
);
} catch (err) { } catch (err) {
const message = const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
err instanceof Error ? err.message : "Не удалось сохранить файл";
const statusCode = const statusCode =
err && err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
typeof err === "object" &&
"statusCode" in err &&
Number.isInteger(err.statusCode)
? Number(err.statusCode) ? Number(err.statusCode)
: 400; : 400
return reply.code(statusCode).send({ error: message }); return reply.code(statusCode).send({ error: message })
} }
} }
const bodyHtml = hasDetail const bodyHtml = hasDetail ? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>` : ''
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, "<br/>")}</p>` const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
: "";
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`;
try { try {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.orderMessage.create({ await tx.orderMessage.create({
data: { data: {
orderId: id, orderId: id,
authorType: "user", authorType: 'user',
text: messageText, text: messageText,
attachmentUrl, attachmentUrl,
}, },
}); })
}); })
} catch (err) { } catch {
return reply.code(500).send({ error: "Не удалось сохранить оплату" }); return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
} }
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
orderId: id, orderId: id,
userId, userId,
paymentStatus: "pending", paymentStatus: 'pending',
}); })
return { ok: true, status: "PENDING_PAYMENT" }; return { ok: true, status: 'PENDING_PAYMENT' }
}, })
);
} }
+5 -13
View File
@@ -1,21 +1,14 @@
import { prisma } from '../../lib/prisma.js'
import { ensureUserNotificationPreference } from '../../lib/notifications/preferences.js' import { ensureUserNotificationPreference } from '../../lib/notifications/preferences.js'
import { prisma } from '../../lib/prisma.js'
export async function registerUserNotificationRoutes(fastify) { export async function registerUserNotificationRoutes(fastify) {
fastify.get( fastify.get('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => {
'/api/me/notifications/settings',
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub const userId = request.user.sub
const prefs = await ensureUserNotificationPreference(userId) const prefs = await ensureUserNotificationPreference(userId)
return { settings: prefs } return { settings: prefs }
}, })
)
fastify.put( fastify.put('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => {
'/api/me/notifications/settings',
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub const userId = request.user.sub
const body = request.body || {} const body = request.body || {}
@@ -34,6 +27,5 @@ export async function registerUserNotificationRoutes(fastify) {
}) })
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_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_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', 'DONE',
'CANCELLED', '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', 'DONE',
'CANCELLED', '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)
}