test commit
This commit is contained in:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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/`
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
Generated
+1456
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('<script>alert("xss")</script>')
|
||||||
'<script>alert("xss")</script>',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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 },
|
||||||
|
|||||||
@@ -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,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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,132 +6,116 @@ 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',
|
const items = await prisma.category.findMany({
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
|
||||||
async () => {
|
})
|
||||||
const items = await prisma.category.findMany({
|
return { items }
|
||||||
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
|
})
|
||||||
})
|
|
||||||
return { items }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
fastify.post('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
'/api/admin/categories',
|
const body = request.body ?? {}
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
const name = String(body.name ?? '').trim()
|
||||||
async (request, reply) => {
|
if (!name) {
|
||||||
const body = request.body ?? {}
|
reply.code(400).send({ error: 'Укажите название категории' })
|
||||||
const name = String(body.name ?? '').trim()
|
return
|
||||||
if (!name) {
|
}
|
||||||
reply.code(400).send({ error: 'Укажите название категории' })
|
const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}`
|
||||||
return
|
if (isUnspecifiedCategorySlug(slug)) {
|
||||||
}
|
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
|
||||||
const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}`
|
return
|
||||||
if (isUnspecifiedCategorySlug(slug)) {
|
}
|
||||||
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
|
const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined
|
||||||
return
|
const exists = await prisma.category.findUnique({ where: { slug } })
|
||||||
}
|
if (exists) {
|
||||||
const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined
|
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||||
const exists = await prisma.category.findUnique({ where: { slug } })
|
return
|
||||||
if (exists) {
|
}
|
||||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
const category = await prisma.category.create({
|
||||||
return
|
data: {
|
||||||
}
|
name,
|
||||||
const category = await prisma.category.create({
|
slug,
|
||||||
data: {
|
sort: Number.isFinite(sort) ? Math.round(sort) : 0,
|
||||||
name,
|
},
|
||||||
slug,
|
})
|
||||||
sort: Number.isFinite(sort) ? Math.round(sort) : 0,
|
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',
|
const { id } = request.params
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
const body = request.body ?? {}
|
||||||
async (request, reply) => {
|
const existing = await prisma.category.findUnique({ where: { id } })
|
||||||
const { id } = request.params
|
if (!existing) {
|
||||||
const body = request.body ?? {}
|
reply.code(404).send({ error: 'Категория не найдена' })
|
||||||
const existing = await prisma.category.findUnique({ where: { id } })
|
return
|
||||||
if (!existing) {
|
}
|
||||||
reply.code(404).send({ error: 'Категория не найдена' })
|
|
||||||
|
const data = {}
|
||||||
|
if (body.name !== undefined) data.name = String(body.name ?? '').trim()
|
||||||
|
if (body.sort !== undefined) {
|
||||||
|
const s = Number(body.sort)
|
||||||
|
if (!Number.isFinite(s)) {
|
||||||
|
reply.code(400).send({ error: 'Некорректный sort' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
data.sort = Math.round(s)
|
||||||
const data = {}
|
}
|
||||||
if (body.name !== undefined) data.name = String(body.name ?? '').trim()
|
if (body.slug !== undefined) {
|
||||||
if (body.sort !== undefined) {
|
const s = String(body.slug ?? '').trim()
|
||||||
const s = Number(body.sort)
|
if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) {
|
||||||
if (!Number.isFinite(s)) {
|
reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' })
|
||||||
reply.code(400).send({ error: 'Некорректный sort' })
|
return
|
||||||
|
}
|
||||||
|
if (!s) {
|
||||||
|
reply.code(400).send({ error: 'Slug не может быть пустым' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (s !== existing.slug) {
|
||||||
|
if (isUnspecifiedCategorySlug(s)) {
|
||||||
|
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.sort = Math.round(s)
|
const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } })
|
||||||
}
|
if (clash) {
|
||||||
if (body.slug !== undefined) {
|
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||||
const s = String(body.slug ?? '').trim()
|
|
||||||
if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) {
|
|
||||||
reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!s) {
|
|
||||||
reply.code(400).send({ error: 'Slug не может быть пустым' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (s !== existing.slug) {
|
|
||||||
if (isUnspecifiedCategorySlug(s)) {
|
|
||||||
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } })
|
|
||||||
if (clash) {
|
|
||||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data.slug = s
|
|
||||||
}
|
}
|
||||||
|
data.slug = s
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) {
|
if (Object.keys(data).length === 0) {
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
if (data.name !== undefined && !data.name) {
|
if (data.name !== undefined && !data.name) {
|
||||||
reply.code(400).send({ error: 'Укажите название' })
|
reply.code(400).send({ error: 'Укажите название' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
const { id } = request.params
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
const existing = await prisma.category.findUnique({ where: { id } })
|
||||||
async (request, reply) => {
|
if (!existing) {
|
||||||
const { id } = request.params
|
reply.code(404).send({ error: 'Категория не найдена' })
|
||||||
const existing = await prisma.category.findUnique({ where: { id } })
|
return
|
||||||
if (!existing) {
|
}
|
||||||
reply.code(404).send({ error: 'Категория не найдена' })
|
if (isUnspecifiedCategorySlug(existing.slug)) {
|
||||||
return
|
reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' })
|
||||||
}
|
return
|
||||||
if (isUnspecifiedCategorySlug(existing.slug)) {
|
}
|
||||||
reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallback = await getOrCreateUnspecifiedCategory()
|
const fallback = await getOrCreateUnspecifiedCategory()
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.product.updateMany({
|
prisma.product.updateMany({
|
||||||
where: { categoryId: id },
|
where: { categoryId: id },
|
||||||
data: { categoryId: fallback.id },
|
data: { categoryId: fallback.id },
|
||||||
}),
|
}),
|
||||||
prisma.category.delete({ where: { id } }),
|
prisma.category.delete({ where: { id } }),
|
||||||
])
|
])
|
||||||
return reply.code(204).send()
|
return reply.code(204).send()
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,114 +9,98 @@ 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',
|
const items = await prisma.galleryImage.findMany({
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
orderBy: { createdAt: 'desc' },
|
||||||
async () => {
|
})
|
||||||
const items = await prisma.galleryImage.findMany({
|
return { items }
|
||||||
orderBy: { createdAt: 'desc' },
|
})
|
||||||
|
|
||||||
|
fastify.post('/api/admin/gallery/upload', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const urls = await persistMultipartImages(request, {
|
||||||
|
maxFiles: 10,
|
||||||
|
maxFileBytes: getProductImageMaxFileBytes(),
|
||||||
|
subdir: '',
|
||||||
|
eager: false,
|
||||||
})
|
})
|
||||||
return { items }
|
for (const url of urls) {
|
||||||
},
|
await prisma.galleryImage.create({
|
||||||
)
|
data: { url, isResized: false },
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/admin/gallery/upload',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const urls = await persistMultipartImages(request, {
|
|
||||||
maxFiles: 10,
|
|
||||||
maxFileBytes: getProductImageMaxFileBytes(),
|
|
||||||
subdir: '',
|
|
||||||
eager: false,
|
|
||||||
})
|
})
|
||||||
for (const url of urls) {
|
|
||||||
await prisma.galleryImage.create({
|
|
||||||
data: { url, isResized: false },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
},
|
return { urls }
|
||||||
)
|
} catch (error) {
|
||||||
|
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
|
||||||
fastify.post(
|
let statusCode =
|
||||||
'/api/admin/gallery/:id/resize',
|
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
? Number(error.statusCode)
|
||||||
async (request, reply) => {
|
: 400
|
||||||
const { id } = request.params
|
if (isMultipartFileTooLargeError(error)) {
|
||||||
const row = await prisma.galleryImage.findUnique({ where: { id } })
|
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
|
||||||
if (!row) {
|
statusCode = 413
|
||||||
return reply.code(404).send({ error: 'Изображение не найдено' })
|
|
||||||
}
|
}
|
||||||
if (row.isResized) {
|
return reply.code(statusCode).send({ error: message })
|
||||||
return reply.code(409).send({ error: 'Изображение уже обработано' })
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.post('/api/admin/gallery/:id/resize', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const row = await prisma.galleryImage.findUnique({ where: { id } })
|
||||||
|
if (!row) {
|
||||||
|
return reply.code(404).send({ error: 'Изображение не найдено' })
|
||||||
|
}
|
||||||
|
if (row.isResized) {
|
||||||
|
return reply.code(409).send({ error: 'Изображение уже обработано' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlParts = row.url.replace(/^\//, '').split('/')
|
||||||
|
const fileName = urlParts[urlParts.length - 1]
|
||||||
|
const uuid = path.parse(fileName).name
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')
|
||||||
|
|
||||||
|
const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
|
||||||
|
await generateAllSizes(uuid, '', fullPath)
|
||||||
|
const newUrl = await convertOriginalToWebp(uuid, '')
|
||||||
|
|
||||||
|
await prisma.galleryImage.update({
|
||||||
|
where: { id },
|
||||||
|
data: { url: newUrl, isResized: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { url: newUrl }
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error(error, 'Resize failed')
|
||||||
|
return reply.code(500).send({ error: 'Ошибка обработки изображения' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.delete('/api/admin/gallery/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const row = await prisma.galleryImage.findUnique({ where: { id } })
|
||||||
|
if (!row) {
|
||||||
|
return reply.code(404).send({ error: 'Не найдено' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedInImages = await prisma.productImage.count({ where: { url: row.url } })
|
||||||
|
const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } })
|
||||||
|
if (usedInImages > 0 || usedAsLegacy > 0) {
|
||||||
|
return reply.code(409).send({ error: 'Изображение используется в карточке товара' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const relative = row.url.replace(/^\//, '')
|
||||||
|
const filePath = path.join(process.cwd(), relative)
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath)
|
||||||
|
} catch (err) {
|
||||||
|
if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') {
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const urlParts = row.url.replace(/^\//, '').split('/')
|
await prisma.galleryImage.delete({ where: { id } })
|
||||||
const fileName = urlParts[urlParts.length - 1]
|
return reply.code(204).send()
|
||||||
const uuid = path.parse(fileName).name
|
})
|
||||||
|
|
||||||
try {
|
|
||||||
const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')
|
|
||||||
|
|
||||||
const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
|
|
||||||
await generateAllSizes(uuid, '', fullPath)
|
|
||||||
const newUrl = await convertOriginalToWebp(uuid, '')
|
|
||||||
|
|
||||||
await prisma.galleryImage.update({
|
|
||||||
where: { id },
|
|
||||||
data: { url: newUrl, isResized: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { url: newUrl }
|
|
||||||
} catch (error) {
|
|
||||||
request.log.error(error, 'Resize failed')
|
|
||||||
return reply.code(500).send({ error: 'Ошибка обработки изображения' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.delete(
|
|
||||||
'/api/admin/gallery/:id',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
const row = await prisma.galleryImage.findUnique({ where: { id } })
|
|
||||||
if (!row) {
|
|
||||||
return reply.code(404).send({ error: 'Не найдено' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const usedInImages = await prisma.productImage.count({ where: { url: row.url } })
|
|
||||||
const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } })
|
|
||||||
if (usedInImages > 0 || usedAsLegacy > 0) {
|
|
||||||
return reply.code(409).send({ error: 'Изображение используется в карточке товара' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const relative = row.url.replace(/^\//, '')
|
|
||||||
const filePath = path.join(process.cwd(), relative)
|
|
||||||
try {
|
|
||||||
await fs.unlink(filePath)
|
|
||||||
} catch (err) {
|
|
||||||
if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.galleryImage.delete({ where: { id } })
|
|
||||||
return reply.code(204).send()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,236 +1,172 @@
|
|||||||
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",
|
const attentionCount = await prisma.order.count({
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
where: {
|
||||||
async () => {
|
status: 'PENDING_PAYMENT',
|
||||||
const attentionCount = await prisma.order.count({
|
},
|
||||||
where: {
|
})
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (q) {
|
|
||||||
where.OR = [
|
|
||||||
{ id: { contains: q } },
|
|
||||||
{ user: { email: { contains: q } } },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
where.deliveryType = deliveryType
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
where.OR = [{ 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) => ({
|
||||||
id: o.id,
|
id: o.id,
|
||||||
status: o.status,
|
status: o.status,
|
||||||
deliveryType: o.deliveryType,
|
deliveryType: o.deliveryType,
|
||||||
deliveryCarrier: o.deliveryCarrier,
|
deliveryCarrier: o.deliveryCarrier,
|
||||||
paymentMethod: o.paymentMethod,
|
paymentMethod: o.paymentMethod,
|
||||||
totalCents: o.totalCents,
|
totalCents: o.totalCents,
|
||||||
currency: o.currency,
|
currency: o.currency,
|
||||||
createdAt: o.createdAt,
|
createdAt: o.createdAt,
|
||||||
updatedAt: o.updatedAt,
|
updatedAt: o.updatedAt,
|
||||||
user: o.user,
|
user: o.user,
|
||||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||||
})),
|
})),
|
||||||
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] },
|
const order = await prisma.order.findUnique({
|
||||||
async (request, reply) => {
|
where: { id },
|
||||||
const { id } = request.params;
|
include: {
|
||||||
const order = await prisma.order.findUnique({
|
user: { select: { id: true, email: true, name: true, phone: true } },
|
||||||
where: { id },
|
items: true,
|
||||||
include: {
|
messages: { orderBy: { createdAt: 'asc' } },
|
||||||
user: { select: { id: true, email: true, name: true, phone: true } },
|
},
|
||||||
items: true,
|
})
|
||||||
messages: { orderBy: { createdAt: "asc" } },
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
},
|
return { item: order }
|
||||||
});
|
})
|
||||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
|
||||||
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)
|
error: `Нельзя сменить статус ${existing.status} → ${next}`,
|
||||||
.send({
|
})
|
||||||
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;
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||||
const feeRaw = request.body?.deliveryFeeCents;
|
return reply.code(400).send({
|
||||||
const parsed =
|
error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)',
|
||||||
typeof feeRaw === "string"
|
})
|
||||||
? Number.parseInt(feeRaw, 10)
|
}
|
||||||
: typeof feeRaw === "number"
|
|
||||||
? feeRaw
|
|
||||||
: NaN;
|
|
||||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
||||||
return reply
|
|
||||||
.code(400)
|
|
||||||
.send({
|
|
||||||
error: "deliveryFeeCents должно быть целым числом ≥ 0 (копейки)",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.order.findUnique({ where: { id } });
|
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: {
|
||||||
deliveryFeeCents: parsed,
|
deliveryFeeCents: parsed,
|
||||||
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,
|
||||||
{
|
userId: order.userId,
|
||||||
orderId: id,
|
messageId: msg.id,
|
||||||
userId: order.userId,
|
preview: text,
|
||||||
messageId: msg.id,
|
})
|
||||||
preview: text,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return reply.code(201).send({ item: msg });
|
return reply.code(201).send({ item: msg })
|
||||||
},
|
})
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
const items = await prisma.product.findMany({
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
async (request) => {
|
orderBy: { updatedAt: 'desc' },
|
||||||
const items = await prisma.product.findMany({
|
})
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
return items.map((p) => request.server.mapProductForApi(p))
|
||||||
orderBy: { updatedAt: 'desc' },
|
})
|
||||||
})
|
|
||||||
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,17 +255,13 @@ export async function registerAdminProductRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
fastify.delete(
|
fastify.delete('/api/admin/products/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
'/api/admin/products/:id',
|
const { id } = request.params
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
try {
|
||||||
async (request, reply) => {
|
await prisma.product.delete({ where: { id } })
|
||||||
const { id } = request.params
|
reply.code(204).send()
|
||||||
try {
|
} catch {
|
||||||
await prisma.product.delete({ where: { id } })
|
reply.code(404).send({ error: 'Товар не найден' })
|
||||||
reply.code(204).send()
|
}
|
||||||
} catch {
|
})
|
||||||
reply.code(404).send({ error: 'Товар не найден' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,65 @@
|
|||||||
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({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
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 }
|
||||||
},
|
})
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,166 +1,149 @@
|
|||||||
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',
|
const qRaw = request.query?.q
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
||||||
async (request, reply) => {
|
|
||||||
const qRaw = request.query?.q
|
|
||||||
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
|
||||||
|
|
||||||
const pageRaw = request.query?.page
|
const pageRaw = request.query?.page
|
||||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
const pageParsed = 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 = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||||
|
|
||||||
if (pageSize > 100) {
|
if (pageSize > 100) {
|
||||||
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = q
|
const where = q
|
||||||
? {
|
? {
|
||||||
OR: [{ email: { contains: q } }, { name: { contains: q } }],
|
OR: [{ email: { contains: q } }, { name: { contains: q } }],
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const total = await prisma.user.count({ where })
|
const total = await prisma.user.count({ where })
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where,
|
where,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
})
|
})
|
||||||
const items = users.map((u) => ({
|
const items = users.map((u) => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
createdAt: u.createdAt,
|
createdAt: u.createdAt,
|
||||||
updatedAt: u.updatedAt,
|
updatedAt: u.updatedAt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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',
|
const body = request.body ?? {}
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const body = request.body ?? {}
|
|
||||||
|
|
||||||
|
const email = normalizeEmail(body.email)
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
reply.code(400).send({ error: 'Некорректная почта' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameRaw = body.name
|
||||||
|
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||||
|
if (name !== null && name.length > 40) {
|
||||||
|
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await prisma.user.findUnique({ where: { email } })
|
||||||
|
if (exists) {
|
||||||
|
reply.code(409).send({ error: 'Почта уже занята' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name: name && name.length ? name : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
reply.code(201).send({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.patch('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const body = request.body ?? {}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { id } })
|
||||||
|
if (!existing) {
|
||||||
|
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {}
|
||||||
|
|
||||||
|
if (body.email !== undefined) {
|
||||||
const email = normalizeEmail(body.email)
|
const email = normalizeEmail(body.email)
|
||||||
if (!email || !email.includes('@')) {
|
if (!email || !email.includes('@')) {
|
||||||
reply.code(400).send({ error: 'Некорректная почта' })
|
reply.code(400).send({ error: 'Некорректная почта' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (email !== existing.email) {
|
||||||
|
const clash = await prisma.user.findUnique({ where: { email } })
|
||||||
|
if (clash) {
|
||||||
|
reply.code(409).send({ error: 'Почта уже занята' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.email = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.name !== undefined) {
|
||||||
const nameRaw = body.name
|
const nameRaw = body.name
|
||||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||||
if (name !== null && name.length > 40) {
|
if (name !== null && name.length > 40) {
|
||||||
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
data.name = name && name.length ? name : null
|
||||||
|
}
|
||||||
|
|
||||||
const exists = await prisma.user.findUnique({ where: { email } })
|
const user = await prisma.user.update({ where: { id }, data })
|
||||||
if (exists) {
|
return {
|
||||||
reply.code(409).send({ error: 'Почта уже занята' })
|
id: user.id,
|
||||||
return
|
email: user.email,
|
||||||
}
|
name: user.name,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
data: {
|
const { id } = request.params
|
||||||
email,
|
try {
|
||||||
name: name && name.length ? name : null,
|
await prisma.user.delete({ where: { id } })
|
||||||
},
|
reply.code(204).send()
|
||||||
})
|
} catch {
|
||||||
|
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||||
reply.code(201).send({
|
}
|
||||||
id: user.id,
|
})
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
updatedAt: user.updatedAt,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.patch(
|
|
||||||
'/api/admin/users/:id',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
const body = request.body ?? {}
|
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { id } })
|
|
||||||
if (!existing) {
|
|
||||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {}
|
|
||||||
|
|
||||||
if (body.email !== undefined) {
|
|
||||||
const email = normalizeEmail(body.email)
|
|
||||||
if (!email || !email.includes('@')) {
|
|
||||||
reply.code(400).send({ error: 'Некорректная почта' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (email !== existing.email) {
|
|
||||||
const clash = await prisma.user.findUnique({ where: { email } })
|
|
||||||
if (clash) {
|
|
||||||
reply.code(409).send({ error: 'Почта уже занята' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.email = email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
const nameRaw = body.name
|
|
||||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
|
||||||
if (name !== null && name.length > 40) {
|
|
||||||
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.name = name && name.length ? name : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.update({ where: { id }, data })
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
updatedAt: user.updatedAt,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.delete(
|
|
||||||
'/api/admin/users/:id',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
try {
|
|
||||||
await prisma.user.delete({ where: { id } })
|
|
||||||
reply.code(204).send()
|
|
||||||
} catch {
|
|
||||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +1,78 @@
|
|||||||
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] },
|
if (!settings) {
|
||||||
async () => {
|
settings = await prisma.adminNotificationSettings.create({
|
||||||
let settings = await prisma.adminNotificationSettings.findFirst();
|
data: {
|
||||||
if (!settings) {
|
emailEnabled: true,
|
||||||
settings = await prisma.adminNotificationSettings.create({
|
telegramEnabled: false,
|
||||||
data: {
|
newOrder: true,
|
||||||
emailEnabled: true,
|
newOrderMessage: true,
|
||||||
telegramEnabled: false,
|
newReview: true,
|
||||||
newOrder: true,
|
authCodeDuplicate: false,
|
||||||
newOrderMessage: true,
|
},
|
||||||
newReview: true,
|
})
|
||||||
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",
|
body: JSON.stringify({
|
||||||
headers: { "Content-Type": "application/json" },
|
chat_id: chatId,
|
||||||
body: JSON.stringify({
|
text: 'Вы подписаны на уведомления Любимый Креатив.',
|
||||||
chat_id: chatId,
|
}),
|
||||||
text: "Вы подписаны на уведомления Любимый Креатив.",
|
})
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true }
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,83 +17,75 @@ export async function registerCatalogSliderRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
fastify.get(
|
fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async () => {
|
||||||
'/api/admin/catalog-slider',
|
const slides = await prisma.catalogSliderSlide.findMany({
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
orderBy: { sortOrder: 'asc' },
|
||||||
async () => {
|
include: { galleryImage: true },
|
||||||
const slides = await prisma.catalogSliderSlide.findMany({
|
})
|
||||||
orderBy: { sortOrder: 'asc' },
|
return {
|
||||||
include: { galleryImage: true },
|
slides: slides.map((s) => ({
|
||||||
})
|
id: s.id,
|
||||||
return {
|
galleryImageId: s.galleryImageId,
|
||||||
slides: slides.map((s) => ({
|
url: s.galleryImage.url,
|
||||||
id: s.id,
|
caption: s.caption,
|
||||||
galleryImageId: s.galleryImageId,
|
})),
|
||||||
url: s.galleryImage.url,
|
}
|
||||||
caption: s.caption,
|
})
|
||||||
})),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.put(
|
fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
'/api/admin/catalog-slider',
|
const body = request.body ?? {}
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
const rawSlides = body.slides
|
||||||
async (request, reply) => {
|
if (!Array.isArray(rawSlides)) {
|
||||||
const body = request.body ?? {}
|
return reply.code(400).send({ error: 'Ожидается slides: массив' })
|
||||||
const rawSlides = body.slides
|
}
|
||||||
if (!Array.isArray(rawSlides)) {
|
if (rawSlides.length > MAX_SLIDES) {
|
||||||
return reply.code(400).send({ error: 'Ожидается slides: массив' })
|
return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` })
|
||||||
}
|
}
|
||||||
if (rawSlides.length > MAX_SLIDES) {
|
|
||||||
return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` })
|
|
||||||
}
|
|
||||||
|
|
||||||
const seenGalleryIds = new Set()
|
const seenGalleryIds = new Set()
|
||||||
const normalized = []
|
const normalized = []
|
||||||
for (let i = 0; i < rawSlides.length; i++) {
|
for (let i = 0; i < rawSlides.length; i++) {
|
||||||
const row = rawSlides[i]
|
const row = rawSlides[i]
|
||||||
const galleryImageId = String(row?.galleryImageId ?? '').trim()
|
const galleryImageId = String(row?.galleryImageId ?? '').trim()
|
||||||
if (!galleryImageId) {
|
if (!galleryImageId) {
|
||||||
return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` })
|
return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` })
|
||||||
}
|
|
||||||
if (seenGalleryIds.has(galleryImageId)) {
|
|
||||||
return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' })
|
|
||||||
}
|
|
||||||
seenGalleryIds.add(galleryImageId)
|
|
||||||
const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } })
|
|
||||||
if (!img) {
|
|
||||||
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
|
|
||||||
}
|
|
||||||
const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500)
|
|
||||||
normalized.push({ galleryImageId, caption, sortOrder: i })
|
|
||||||
}
|
}
|
||||||
|
if (seenGalleryIds.has(galleryImageId)) {
|
||||||
await prisma.$transaction(async (tx) => {
|
return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' })
|
||||||
await tx.catalogSliderSlide.deleteMany({})
|
|
||||||
for (const n of normalized) {
|
|
||||||
await tx.catalogSliderSlide.create({
|
|
||||||
data: {
|
|
||||||
sortOrder: n.sortOrder,
|
|
||||||
caption: n.caption,
|
|
||||||
galleryImageId: n.galleryImageId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const slides = await prisma.catalogSliderSlide.findMany({
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
include: { galleryImage: true },
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
slides: slides.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
galleryImageId: s.galleryImageId,
|
|
||||||
url: s.galleryImage.url,
|
|
||||||
caption: s.caption,
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
},
|
seenGalleryIds.add(galleryImageId)
|
||||||
)
|
const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } })
|
||||||
|
if (!img) {
|
||||||
|
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
|
||||||
|
}
|
||||||
|
const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500)
|
||||||
|
normalized.push({ galleryImageId, caption, sortOrder: i })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.catalogSliderSlide.deleteMany({})
|
||||||
|
for (const n of normalized) {
|
||||||
|
await tx.catalogSliderSlide.create({
|
||||||
|
data: {
|
||||||
|
sortOrder: n.sortOrder,
|
||||||
|
caption: n.caption,
|
||||||
|
galleryImageId: n.galleryImageId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const slides = await prisma.catalogSliderSlide.findMany({
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { galleryImage: true },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
slides: slides.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
galleryImageId: s.galleryImageId,
|
||||||
|
url: s.galleryImage.url,
|
||||||
|
caption: s.caption,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,90 +29,74 @@ 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',
|
const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] })
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
return { items }
|
||||||
async () => {
|
})
|
||||||
const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] })
|
|
||||||
return { items }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
fastify.post('/api/admin/info-page/blocks', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
'/api/admin/info-page/blocks',
|
const validated = validateBlockPayload(request.body, reply)
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
if (!validated) return
|
||||||
async (request, reply) => {
|
|
||||||
const validated = validateBlockPayload(request.body, reply)
|
|
||||||
if (!validated) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await prisma.infoPageBlock.create({ data: validated })
|
const item = await prisma.infoPageBlock.create({ data: validated })
|
||||||
return reply.code(201).send({ item })
|
return reply.code(201).send({ item })
|
||||||
} 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',
|
const { id } = request.params
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
const existing = await prisma.infoPageBlock.findUnique({ where: { id } })
|
||||||
async (request, reply) => {
|
if (!existing) return reply.code(404).send({ error: 'Блок не найден' })
|
||||||
const { id } = request.params
|
|
||||||
const existing = await prisma.infoPageBlock.findUnique({ where: { id } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Блок не найден' })
|
|
||||||
|
|
||||||
const body = request.body ?? {}
|
const body = request.body ?? {}
|
||||||
const data = {}
|
const data = {}
|
||||||
if (body.key !== undefined) {
|
if (body.key !== undefined) {
|
||||||
const key = String(body.key || '').trim()
|
const key = String(body.key || '').trim()
|
||||||
if (!key) return reply.code(400).send({ error: 'key обязателен' })
|
if (!key) return reply.code(400).send({ error: 'key обязателен' })
|
||||||
if (!/^[a-z0-9_-]{2,60}$/i.test(key)) {
|
if (!/^[a-z0-9_-]{2,60}$/i.test(key)) {
|
||||||
return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' })
|
return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' })
|
||||||
}
|
|
||||||
data.key = key
|
|
||||||
}
|
|
||||||
if (body.title !== undefined) {
|
|
||||||
const title = String(body.title || '').trim()
|
|
||||||
if (!title) return reply.code(400).send({ error: 'title обязателен' })
|
|
||||||
if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' })
|
|
||||||
data.title = title
|
|
||||||
}
|
|
||||||
if (body.body !== undefined) {
|
|
||||||
const content = String(body.body || '').trim()
|
|
||||||
if (!content) return reply.code(400).send({ error: 'body обязателен' })
|
|
||||||
if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' })
|
|
||||||
data.body = content
|
|
||||||
}
|
|
||||||
if (body.sort !== undefined) {
|
|
||||||
const sort = Number(body.sort)
|
|
||||||
if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' })
|
|
||||||
data.sort = Math.trunc(sort)
|
|
||||||
}
|
|
||||||
if (body.published !== undefined) {
|
|
||||||
data.published = Boolean(body.published)
|
|
||||||
}
|
}
|
||||||
|
data.key = key
|
||||||
|
}
|
||||||
|
if (body.title !== undefined) {
|
||||||
|
const title = String(body.title || '').trim()
|
||||||
|
if (!title) return reply.code(400).send({ error: 'title обязателен' })
|
||||||
|
if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' })
|
||||||
|
data.title = title
|
||||||
|
}
|
||||||
|
if (body.body !== undefined) {
|
||||||
|
const content = String(body.body || '').trim()
|
||||||
|
if (!content) return reply.code(400).send({ error: 'body обязателен' })
|
||||||
|
if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' })
|
||||||
|
data.body = content
|
||||||
|
}
|
||||||
|
if (body.sort !== undefined) {
|
||||||
|
const sort = Number(body.sort)
|
||||||
|
if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' })
|
||||||
|
data.sort = Math.trunc(sort)
|
||||||
|
}
|
||||||
|
if (body.published !== undefined) {
|
||||||
|
data.published = Boolean(body.published)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await prisma.infoPageBlock.update({ where: { id }, data })
|
const item = await prisma.infoPageBlock.update({ where: { id }, data })
|
||||||
return { item }
|
return { item }
|
||||||
} 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',
|
const { id } = request.params
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
try {
|
||||||
async (request, reply) => {
|
await prisma.infoPageBlock.delete({ where: { id } })
|
||||||
const { id } = request.params
|
return reply.code(204).send()
|
||||||
try {
|
} catch {
|
||||||
await prisma.infoPageBlock.delete({ where: { id } })
|
return reply.code(404).send({ error: 'Блок не найден' })
|
||||||
return reply.code(204).send()
|
}
|
||||||
} catch {
|
})
|
||||||
return reply.code(404).send({ error: 'Блок не найден' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,35 @@
|
|||||||
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',
|
try {
|
||||||
{ preHandler: [fastify.authenticate] },
|
const urls = await persistMultipartImages(request, {
|
||||||
async (request, reply) => {
|
maxFiles: 1,
|
||||||
try {
|
maxFileBytes: getOtherUploadMaxFileBytes(),
|
||||||
const urls = await persistMultipartImages(request, {
|
subdir: 'reviews',
|
||||||
maxFiles: 1,
|
})
|
||||||
maxFileBytes: getOtherUploadMaxFileBytes(),
|
if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
|
||||||
subdir: 'reviews',
|
return { url: urls[0] }
|
||||||
})
|
} catch (error) {
|
||||||
if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
|
let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение'
|
||||||
return { url: urls[0] }
|
let statusCode =
|
||||||
} catch (error) {
|
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||||
let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение'
|
? Number(error.statusCode)
|
||||||
let statusCode =
|
: 400
|
||||||
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
if (isMultipartFileTooLargeError(error)) {
|
||||||
? Number(error.statusCode)
|
message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes())
|
||||||
: 400
|
statusCode = 413
|
||||||
if (isMultipartFileTooLargeError(error)) {
|
|
||||||
message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes())
|
|
||||||
statusCode = 413
|
|
||||||
}
|
|
||||||
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,46 +98,42 @@ 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',
|
const userId = request.user.sub
|
||||||
{ preHandler: [fastify.authenticate] },
|
const { id: productId } = request.params
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id: productId } = request.params
|
|
||||||
|
|
||||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||||
|
|
||||||
const rating = Number(request.body?.rating)
|
const rating = Number(request.body?.rating)
|
||||||
if (!Number.isFinite(rating) || rating < 1 || rating > 5) {
|
if (!Number.isFinite(rating) || rating < 1 || rating > 5) {
|
||||||
return reply.code(400).send({ error: 'rating должен быть от 1 до 5' })
|
return reply.code(400).send({ error: 'rating должен быть от 1 до 5' })
|
||||||
}
|
}
|
||||||
const textRaw = request.body?.text
|
const textRaw = request.body?.text
|
||||||
const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim()
|
const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim()
|
||||||
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)
|
||||||
if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) {
|
return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' })
|
||||||
return reply.code(400).send({ error: 'Некорректная ссылка на изображение' })
|
if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) {
|
||||||
}
|
return reply.code(400).send({ error: 'Некорректная ссылка на изображение' })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await prisma.review.create({
|
const created = await prisma.review.create({
|
||||||
data: {
|
data: {
|
||||||
productId,
|
productId,
|
||||||
userId,
|
userId,
|
||||||
rating: Math.floor(rating),
|
rating: Math.floor(rating),
|
||||||
text: text && text.length ? text : null,
|
text: text && text.length ? text : null,
|
||||||
imageUrl: imageUrl && imageUrl.length ? imageUrl : null,
|
imageUrl: imageUrl && imageUrl.length ? imageUrl : null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return reply.code(201).send({ item: created })
|
return reply.code(201).send({ item: created })
|
||||||
} catch {
|
} catch {
|
||||||
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
|
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+93
-131
@@ -1,177 +1,139 @@
|
|||||||
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) return reply.code(400).send({ error: 'Телефон слишком длинный' })
|
||||||
if (compact.length > 20)
|
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) {
|
||||||
return reply.code(400).send({ error: "Телефон слишком длинный" });
|
return reply.code(400).send({ error: 'Некорректный телефон' })
|
||||||
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) {
|
|
||||||
return reply.code(400).send({ error: "Некорректный телефон" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await prisma.user.update({
|
const updated = await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
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) }
|
||||||
},
|
})
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('/') + '/'
|
||||||
|
|||||||
+126
-142
@@ -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,150 +45,133 @@ 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',
|
const userId = request.user.sub
|
||||||
{ preHandler: [fastify.authenticate] },
|
const items = await prisma.shippingAddress.findMany({
|
||||||
async (request) => {
|
where: { userId },
|
||||||
const userId = request.user.sub
|
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
||||||
const items = await prisma.shippingAddress.findMany({
|
})
|
||||||
where: { userId },
|
return { items }
|
||||||
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
})
|
||||||
})
|
|
||||||
return { items }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
fastify.post('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
'/api/me/addresses',
|
const userId = request.user.sub
|
||||||
{ preHandler: [fastify.authenticate] },
|
const validated = validateAddressPayload(request.body, reply)
|
||||||
async (request, reply) => {
|
if (!validated) return
|
||||||
const userId = request.user.sub
|
|
||||||
const validated = validateAddressPayload(request.body, reply)
|
|
||||||
if (!validated) return
|
|
||||||
|
|
||||||
const isDefault = Boolean(request.body?.isDefault)
|
const isDefault = Boolean(request.body?.isDefault)
|
||||||
const created = await prisma.$transaction(async (tx) => {
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
|
||||||
}
|
|
||||||
return tx.shippingAddress.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
...validated,
|
|
||||||
isDefault,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return reply.code(201).send({ item: created })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.patch(
|
|
||||||
'/api/me/addresses/:id',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
|
||||||
|
|
||||||
const body = request.body ?? {}
|
|
||||||
const data = {}
|
|
||||||
|
|
||||||
if (body.label !== undefined) {
|
|
||||||
const labelRaw = body.label
|
|
||||||
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
|
||||||
if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
|
||||||
data.label = label && label.length ? label : null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.recipientName !== undefined) {
|
|
||||||
const v = String(body.recipientName || '').trim()
|
|
||||||
if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
|
||||||
if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
|
||||||
data.recipientName = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.recipientPhone !== undefined) {
|
|
||||||
const v = normalizePhoneLite(body.recipientPhone)
|
|
||||||
if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
|
||||||
if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
|
||||||
data.recipientPhone = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.addressLine !== undefined) {
|
|
||||||
const v = String(body.addressLine || '').trim()
|
|
||||||
if (!v) return reply.code(400).send({ error: 'Укажите адрес' })
|
|
||||||
if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
|
||||||
data.addressLine = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.comment !== undefined) {
|
|
||||||
const commentRaw = body.comment
|
|
||||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
|
||||||
if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
|
||||||
data.comment = comment && comment.length ? comment : null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.lat !== undefined) {
|
|
||||||
const lat = Number(body.lat)
|
|
||||||
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
|
||||||
data.lat = lat
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.lng !== undefined) {
|
|
||||||
const lng = Number(body.lng)
|
|
||||||
if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' })
|
|
||||||
data.lng = lng
|
|
||||||
}
|
|
||||||
|
|
||||||
const setDefault = body.isDefault === true
|
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
|
||||||
if (setDefault) {
|
|
||||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
|
||||||
}
|
|
||||||
return tx.shippingAddress.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
...(setDefault ? { isDefault: true } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return { item: updated }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.delete(
|
|
||||||
'/api/me/addresses/:id',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
|
||||||
|
|
||||||
await prisma.shippingAddress.delete({ where: { id } })
|
|
||||||
return reply.code(204).send()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/me/addresses/:id/default',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||||
return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
|
}
|
||||||
|
return tx.shippingAddress.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
...validated,
|
||||||
|
isDefault,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
return reply.code(201).send({ item: created })
|
||||||
|
})
|
||||||
|
|
||||||
return { item: updated }
|
fastify.patch('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
},
|
const userId = request.user.sub
|
||||||
)
|
const { id } = request.params
|
||||||
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
|
||||||
|
const body = request.body ?? {}
|
||||||
|
const data = {}
|
||||||
|
|
||||||
|
if (body.label !== undefined) {
|
||||||
|
const labelRaw = body.label
|
||||||
|
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
||||||
|
if (label !== null && label.length > 40)
|
||||||
|
return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
||||||
|
data.label = label && label.length ? label : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.recipientName !== undefined) {
|
||||||
|
const v = String(body.recipientName || '').trim()
|
||||||
|
if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
||||||
|
if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
||||||
|
data.recipientName = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.recipientPhone !== undefined) {
|
||||||
|
const v = normalizePhoneLite(body.recipientPhone)
|
||||||
|
if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
||||||
|
if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
||||||
|
data.recipientPhone = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.addressLine !== undefined) {
|
||||||
|
const v = String(body.addressLine || '').trim()
|
||||||
|
if (!v) return reply.code(400).send({ error: 'Укажите адрес' })
|
||||||
|
if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
||||||
|
data.addressLine = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.comment !== undefined) {
|
||||||
|
const commentRaw = body.comment
|
||||||
|
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||||
|
if (comment !== null && comment.length > 200)
|
||||||
|
return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||||
|
data.comment = comment && comment.length ? comment : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.lat !== undefined) {
|
||||||
|
const lat = Number(body.lat)
|
||||||
|
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
||||||
|
data.lat = lat
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.lng !== undefined) {
|
||||||
|
const lng = Number(body.lng)
|
||||||
|
if (!Number.isFinite(lng) || lng < -180 || lng > 180)
|
||||||
|
return reply.code(400).send({ error: 'Некорректная долгота' })
|
||||||
|
data.lng = lng
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDefault = body.isDefault === true
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
if (setDefault) {
|
||||||
|
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||||
|
}
|
||||||
|
return tx.shippingAddress.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
...(setDefault ? { isDefault: true } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { item: updated }
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.delete('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
|
||||||
|
await prisma.shippingAddress.delete({ where: { id } })
|
||||||
|
return reply.code(204).send()
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.post('/api/me/addresses/:id/default', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||||
|
return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
return { item: updated }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,92 +1,76 @@
|
|||||||
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',
|
const userId = request.user.sub
|
||||||
{ preHandler: [fastify.authenticate] },
|
const items = await prisma.cartItem.findMany({
|
||||||
async (request) => {
|
where: { userId },
|
||||||
const userId = request.user.sub
|
include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } },
|
||||||
const items = await prisma.cartItem.findMany({
|
orderBy: { createdAt: 'asc' },
|
||||||
where: { userId },
|
})
|
||||||
include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } },
|
return {
|
||||||
orderBy: { createdAt: 'asc' },
|
items: items.map((x) => ({
|
||||||
})
|
id: x.id,
|
||||||
return {
|
qty: x.qty,
|
||||||
items: items.map((x) => ({
|
product: x.product,
|
||||||
id: x.id,
|
})),
|
||||||
qty: x.qty,
|
}
|
||||||
product: x.product,
|
})
|
||||||
})),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
fastify.post('/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
'/api/me/cart/items',
|
const userId = request.user.sub
|
||||||
{ preHandler: [fastify.authenticate] },
|
const productId = String(request.body?.productId || '').trim()
|
||||||
async (request, reply) => {
|
const qtyRaw = request.body?.qty
|
||||||
const userId = request.user.sub
|
const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw)
|
||||||
const productId = String(request.body?.productId || '').trim()
|
|
||||||
const qtyRaw = request.body?.qty
|
|
||||||
const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw)
|
|
||||||
|
|
||||||
if (!productId) return reply.code(400).send({ error: 'productId обязателен' })
|
if (!productId) return reply.code(400).send({ error: 'productId обязателен' })
|
||||||
if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' })
|
if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' })
|
||||||
|
|
||||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||||
|
|
||||||
const available = product.inStock ? product.quantity : 1
|
const available = product.inStock ? product.quantity : 1
|
||||||
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
||||||
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
||||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||||
|
|
||||||
const item = await prisma.cartItem.upsert({
|
const item = await prisma.cartItem.upsert({
|
||||||
where: { userId_productId: { userId, productId } },
|
where: { userId_productId: { userId, productId } },
|
||||||
update: { qty: nextQty },
|
update: { qty: nextQty },
|
||||||
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',
|
const userId = request.user.sub
|
||||||
{ preHandler: [fastify.authenticate] },
|
const { id } = request.params
|
||||||
async (request, reply) => {
|
const qtyRaw = request.body?.qty
|
||||||
const userId = request.user.sub
|
const qty = Number(qtyRaw)
|
||||||
const { id } = request.params
|
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' })
|
||||||
const qtyRaw = request.body?.qty
|
|
||||||
const qty = Number(qtyRaw)
|
|
||||||
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' })
|
|
||||||
|
|
||||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } })
|
const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } })
|
||||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||||
|
|
||||||
if (qty === 0) {
|
if (qty === 0) {
|
||||||
await prisma.cartItem.delete({ where: { id } })
|
|
||||||
return reply.code(204).send()
|
|
||||||
}
|
|
||||||
|
|
||||||
const available = existing.product.inStock ? existing.product.quantity : 1
|
|
||||||
const nextQty = Math.floor(qty)
|
|
||||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
|
||||||
|
|
||||||
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
|
|
||||||
return { item: updated }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.delete(
|
|
||||||
'/api/me/cart/items/:id',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
|
||||||
await prisma.cartItem.delete({ where: { id } })
|
await prisma.cartItem.delete({ where: { id } })
|
||||||
return reply.code(204).send()
|
return reply.code(204).send()
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
const available = existing.product.inStock ? existing.product.quantity : 1
|
||||||
|
const nextQty = Math.floor(qty)
|
||||||
|
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||||
|
|
||||||
|
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
|
||||||
|
return { item: updated }
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.delete('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||||
|
await prisma.cartItem.delete({ where: { id } })
|
||||||
|
return reply.code(204).send()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-143
@@ -1,155 +1,127 @@
|
|||||||
import { prisma } from "../lib/prisma.js";
|
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
|
||||||
export async function registerUserMessageRoutes(fastify) {
|
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 items = await prisma.orderMessage.findMany({
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
where: { orderId: id },
|
||||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
orderBy: { createdAt: 'asc' },
|
||||||
const items = await prisma.orderMessage.findMany({
|
})
|
||||||
where: { orderId: id },
|
return { items }
|
||||||
orderBy: { createdAt: "asc" },
|
})
|
||||||
});
|
|
||||||
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();
|
const msg = await prisma.orderMessage.create({
|
||||||
if (!text) return reply.code(400).send({ error: "Сообщение пустое" });
|
data: { orderId: id, authorType: 'user', text },
|
||||||
if (text.length > 2000)
|
})
|
||||||
return reply.code(400).send({ error: "Сообщение слишком длинное" });
|
|
||||||
const msg = await prisma.orderMessage.create({
|
|
||||||
data: { orderId: id, authorType: "user", text },
|
|
||||||
});
|
|
||||||
|
|
||||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
|
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] },
|
const orders = await prisma.order.findMany({
|
||||||
async (request) => {
|
where: { userId },
|
||||||
const userId = request.user.sub;
|
select: { id: true },
|
||||||
const orders = await prisma.order.findMany({
|
})
|
||||||
where: { userId },
|
if (orders.length === 0) return { count: 0 }
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
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,
|
|
||||||
authorType: "admin",
|
|
||||||
createdAt: { gt: lastRead },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
count += n;
|
|
||||||
}
|
|
||||||
return { count };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
"/api/me/conversations",
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request) => {
|
|
||||||
const userId = request.user.sub;
|
|
||||||
const orders = await prisma.order.findMany({
|
|
||||||
where: { userId, messages: { some: {} } },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
status: true,
|
|
||||||
deliveryType: true,
|
|
||||||
messages: {
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 1,
|
|
||||||
select: { text: true, createdAt: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
|
||||||
where: { userId },
|
|
||||||
});
|
|
||||||
const lastReadByOrder = new Map(
|
|
||||||
readStates.map((r) => [r.orderId, r.lastReadAt]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = [];
|
|
||||||
for (const o of orders) {
|
|
||||||
const lastMsg = o.messages[0];
|
|
||||||
if (!lastMsg) continue;
|
|
||||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0);
|
|
||||||
const unreadCount = await prisma.orderMessage.count({
|
|
||||||
where: {
|
|
||||||
orderId: o.id,
|
|
||||||
authorType: "admin",
|
|
||||||
createdAt: { gt: lastRead },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
orderId: o.id,
|
orderId: o.id,
|
||||||
status: o.status,
|
authorType: 'admin',
|
||||||
deliveryType: o.deliveryType,
|
createdAt: { gt: lastRead },
|
||||||
lastMessageAt: lastMsg.createdAt,
|
},
|
||||||
preview:
|
})
|
||||||
lastMsg.text.length > 280
|
count += n
|
||||||
? `${lastMsg.text.slice(0, 277)}…`
|
}
|
||||||
: lastMsg.text,
|
return { count }
|
||||||
unreadCount,
|
})
|
||||||
});
|
|
||||||
}
|
|
||||||
return { items };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
fastify.post(
|
fastify.get('/api/me/conversations', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||||
"/api/me/orders/:id/messages/read",
|
const userId = request.user.sub
|
||||||
{ preHandler: [fastify.authenticate] },
|
const orders = await prisma.order.findMany({
|
||||||
async (request, reply) => {
|
where: { userId, messages: { some: {} } },
|
||||||
const userId = request.user.sub;
|
select: {
|
||||||
const { id } = request.params;
|
id: true,
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
status: true,
|
||||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
deliveryType: true,
|
||||||
|
messages: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
select: { text: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
const now = new Date();
|
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||||
await prisma.userOrderMessageReadState.upsert({
|
where: { userId },
|
||||||
where: { userId_orderId: { userId, orderId: id } },
|
})
|
||||||
create: { userId, orderId: id, lastReadAt: now },
|
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||||
update: { lastReadAt: now },
|
|
||||||
});
|
const items = []
|
||||||
return { ok: true };
|
for (const o of orders) {
|
||||||
},
|
const lastMsg = o.messages[0]
|
||||||
);
|
if (!lastMsg) continue
|
||||||
|
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||||
|
const unreadCount = await prisma.orderMessage.count({
|
||||||
|
where: {
|
||||||
|
orderId: o.id,
|
||||||
|
authorType: 'admin',
|
||||||
|
createdAt: { gt: lastRead },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
items.push({
|
||||||
|
orderId: o.id,
|
||||||
|
status: o.status,
|
||||||
|
deliveryType: o.deliveryType,
|
||||||
|
lastMessageAt: lastMsg.createdAt,
|
||||||
|
preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text,
|
||||||
|
unreadCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { items }
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.post('/api/me/orders/:id/messages/read', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
await prisma.userOrderMessageReadState.upsert({
|
||||||
|
where: { userId_orderId: { userId, orderId: id } },
|
||||||
|
create: { userId, orderId: id, lastReadAt: now },
|
||||||
|
update: { lastReadAt: now },
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+214
-255
@@ -1,312 +1,271 @@
|
|||||||
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 deliveryType =
|
||||||
const userId = request.user.sub;
|
deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === ''
|
||||||
const deliveryTypeRaw = request.body?.deliveryType;
|
? 'delivery'
|
||||||
const deliveryType =
|
: String(deliveryTypeRaw).trim()
|
||||||
deliveryTypeRaw === undefined ||
|
|
||||||
deliveryTypeRaw === null ||
|
|
||||||
deliveryTypeRaw === ""
|
|
||||||
? "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)
|
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
||||||
.send({ error: "paymentMethod должен быть online | on_pickup" });
|
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const carrierRaw = request.body?.deliveryCarrier
|
||||||
|
let deliveryCarrier = null
|
||||||
|
if (deliveryType === 'delivery') {
|
||||||
|
const carrierStr =
|
||||||
|
carrierRaw === undefined || carrierRaw === null || carrierRaw === '' ? '' : String(carrierRaw).trim()
|
||||||
|
if (!isDeliveryCarrier(carrierStr)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
deliveryCarrier = carrierStr
|
||||||
|
}
|
||||||
|
|
||||||
if (deliveryType !== "delivery" && deliveryType !== "pickup") {
|
if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
|
||||||
return reply
|
return reply.code(400).send({
|
||||||
.code(400)
|
error: 'Оплата при получении доступна только для самовывоза',
|
||||||
.send({ error: "deliveryType должен быть delivery | pickup" });
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let address = null
|
||||||
|
if (deliveryType === 'delivery') {
|
||||||
|
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
|
||||||
|
address = await prisma.shippingAddress.findFirst({
|
||||||
|
where: { id: addressId, userId },
|
||||||
|
})
|
||||||
|
if (!address) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartItems = await prisma.cartItem.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { product: true },
|
||||||
|
})
|
||||||
|
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
|
||||||
|
|
||||||
|
for (const ci of cartItems) {
|
||||||
|
const available = ci.product.inStock ? ci.product.quantity : 1
|
||||||
|
if (ci.qty > available) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const carrierRaw = request.body?.deliveryCarrier;
|
const itemsPayload = cartItems.map((ci) => ({
|
||||||
let deliveryCarrier = null;
|
productId: ci.productId,
|
||||||
if (deliveryType === "delivery") {
|
qty: ci.qty,
|
||||||
const carrierStr =
|
titleSnapshot: ci.product.title,
|
||||||
carrierRaw === undefined || carrierRaw === null || carrierRaw === ""
|
priceCentsSnapshot: ci.product.priceCents,
|
||||||
? ""
|
}))
|
||||||
: String(carrierRaw).trim();
|
|
||||||
if (!isDeliveryCarrier(carrierStr)) {
|
|
||||||
return reply.code(400).send({
|
|
||||||
error:
|
|
||||||
"deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
deliveryCarrier = carrierStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paymentMethod === "on_pickup" && deliveryType !== "pickup") {
|
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
|
||||||
return reply
|
const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0
|
||||||
.code(400)
|
const totalCents = itemsSubtotalCents + deliveryFeeCents
|
||||||
.send({
|
|
||||||
error: "Оплата при получении доступна только для самовывоза",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let address = null;
|
const addressSnapshotJson =
|
||||||
if (deliveryType === "delivery") {
|
deliveryType === 'pickup'
|
||||||
if (!addressId)
|
? JSON.stringify({ deliveryType: 'pickup' })
|
||||||
return reply.code(400).send({ error: "Выберите адрес доставки" });
|
: JSON.stringify({
|
||||||
address = await prisma.shippingAddress.findFirst({
|
deliveryType: 'delivery',
|
||||||
where: { id: addressId, userId },
|
id: address.id,
|
||||||
});
|
label: address.label,
|
||||||
if (!address) return reply.code(404).send({ error: "Адрес не найден" });
|
recipientName: address.recipientName,
|
||||||
}
|
recipientPhone: address.recipientPhone,
|
||||||
|
addressLine: address.addressLine,
|
||||||
|
comment: address.comment,
|
||||||
|
lat: address.lat,
|
||||||
|
lng: address.lng,
|
||||||
|
})
|
||||||
|
|
||||||
const cartItems = await prisma.cartItem.findMany({
|
let initialStatus = 'PENDING_PAYMENT'
|
||||||
where: { userId },
|
let deliveryFeeLocked = true
|
||||||
include: { product: true },
|
if (paymentMethod === 'on_pickup') {
|
||||||
});
|
initialStatus = 'IN_PROGRESS'
|
||||||
if (cartItems.length === 0)
|
} else if (deliveryType === 'delivery') {
|
||||||
return reply.code(400).send({ error: "Корзина пуста" });
|
initialStatus = 'PENDING_PAYMENT'
|
||||||
|
deliveryFeeLocked = false
|
||||||
|
}
|
||||||
|
|
||||||
for (const ci of cartItems) {
|
let created
|
||||||
const available = ci.product.inStock ? ci.product.quantity : 1;
|
try {
|
||||||
if (ci.qty > available) {
|
created = await prisma.$transaction(async (tx) => {
|
||||||
return reply
|
for (const ci of cartItems) {
|
||||||
.code(409)
|
if (!ci.product.inStock) continue
|
||||||
.send({
|
|
||||||
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsPayload = cartItems.map((ci) => ({
|
const res = await tx.product.updateMany({
|
||||||
productId: ci.productId,
|
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
||||||
qty: ci.qty,
|
data: { quantity: { decrement: ci.qty } },
|
||||||
titleSnapshot: ci.product.title,
|
})
|
||||||
priceCentsSnapshot: ci.product.priceCents,
|
if (res.count !== 1) {
|
||||||
}));
|
throw new Error(`Недостаточно товара: "${ci.product.title}"`)
|
||||||
|
|
||||||
const itemsSubtotalCents = itemsPayload.reduce(
|
|
||||||
(sum, i) => sum + i.priceCentsSnapshot * i.qty,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const deliveryFeeCents = deliveryType === "delivery" ? 50000 : 0;
|
|
||||||
const totalCents = itemsSubtotalCents + deliveryFeeCents;
|
|
||||||
|
|
||||||
const addressSnapshotJson =
|
|
||||||
deliveryType === "pickup"
|
|
||||||
? JSON.stringify({ deliveryType: "pickup" })
|
|
||||||
: JSON.stringify({
|
|
||||||
deliveryType: "delivery",
|
|
||||||
id: address.id,
|
|
||||||
label: address.label,
|
|
||||||
recipientName: address.recipientName,
|
|
||||||
recipientPhone: address.recipientPhone,
|
|
||||||
addressLine: address.addressLine,
|
|
||||||
comment: address.comment,
|
|
||||||
lat: address.lat,
|
|
||||||
lng: address.lng,
|
|
||||||
});
|
|
||||||
|
|
||||||
let initialStatus = "PENDING_PAYMENT";
|
|
||||||
let deliveryFeeLocked = true;
|
|
||||||
if (paymentMethod === "on_pickup") {
|
|
||||||
initialStatus = "IN_PROGRESS";
|
|
||||||
} else if (deliveryType === "delivery") {
|
|
||||||
initialStatus = "PENDING_PAYMENT";
|
|
||||||
deliveryFeeLocked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let created;
|
|
||||||
try {
|
|
||||||
created = await prisma.$transaction(async (tx) => {
|
|
||||||
for (const ci of cartItems) {
|
|
||||||
if (!ci.product.inStock) continue;
|
|
||||||
|
|
||||||
const res = await tx.product.updateMany({
|
|
||||||
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
|
||||||
data: { quantity: { decrement: ci.qty } },
|
|
||||||
});
|
|
||||||
if (res.count !== 1) {
|
|
||||||
throw new Error(`Недостаточно товара: "${ci.product.title}"`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const order = await tx.order.create({
|
const order = await tx.order.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
deliveryFeeLocked,
|
deliveryFeeLocked,
|
||||||
deliveryType,
|
deliveryType,
|
||||||
deliveryCarrier,
|
deliveryCarrier,
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
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: {
|
||||||
create: itemsPayload.map((i) => ({
|
create: itemsPayload.map((i) => ({
|
||||||
productId: i.productId,
|
productId: i.productId,
|
||||||
qty: i.qty,
|
qty: i.qty,
|
||||||
titleSnapshot: i.titleSnapshot,
|
titleSnapshot: i.titleSnapshot,
|
||||||
priceCentsSnapshot: i.priceCentsSnapshot,
|
priceCentsSnapshot: i.priceCentsSnapshot,
|
||||||
})),
|
})),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
await tx.cartItem.deleteMany({ where: { userId } });
|
})
|
||||||
return order;
|
await tx.cartItem.deleteMany({ where: { userId } })
|
||||||
});
|
return order
|
||||||
} catch (e) {
|
})
|
||||||
return reply
|
} catch (e) {
|
||||||
.code(409)
|
return reply.code(409).send({
|
||||||
.send({
|
error: (e instanceof Error && e.message) || 'Недостаточно товара',
|
||||||
error: (e instanceof Error && e.message) || "Недостаточно товара",
|
})
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Emit notification events
|
// Emit notification events
|
||||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, {
|
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, {
|
||||||
orderId: created.id,
|
orderId: created.id,
|
||||||
userId,
|
userId,
|
||||||
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('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { items: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
items: orders.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
status: o.status,
|
||||||
|
totalCents: o.totalCents,
|
||||||
|
currency: o.currency,
|
||||||
|
createdAt: o.createdAt,
|
||||||
|
updatedAt: o.updatedAt,
|
||||||
|
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.get('/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: { id, userId },
|
||||||
|
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||||
|
})
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
return { item: order }
|
||||||
|
})
|
||||||
|
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/api/me/orders",
|
'/api/me/orders/:id/review-eligibility',
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request) => {
|
|
||||||
const userId = request.user.sub;
|
|
||||||
const orders = await prisma.order.findMany({
|
|
||||||
where: { userId },
|
|
||||||
include: { items: true },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
items: orders.map((o) => ({
|
|
||||||
id: o.id,
|
|
||||||
status: o.status,
|
|
||||||
totalCents: o.totalCents,
|
|
||||||
currency: o.currency,
|
|
||||||
createdAt: o.createdAt,
|
|
||||||
updatedAt: o.updatedAt,
|
|
||||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
"/api/me/orders/:id",
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
{ 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 },
|
|
||||||
include: { items: true, messages: { orderBy: { createdAt: "asc" } } },
|
|
||||||
});
|
|
||||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
|
||||||
return { item: order };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
"/api/me/orders/:id/review-eligibility",
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub;
|
|
||||||
const { id } = request.params;
|
|
||||||
const 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' }
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
receiptFilename = part.filename ?? "receipt";
|
|
||||||
}
|
}
|
||||||
} else if (part.fieldname === "detail") {
|
receiptBuffer = await part.toBuffer()
|
||||||
detail = String(part.value ?? "").trim();
|
receiptFilename = part.filename ?? 'receipt'
|
||||||
}
|
}
|
||||||
}
|
} else if (part.fieldname === 'detail') {
|
||||||
} catch (err) {
|
detail = String(part.value ?? '').trim()
|
||||||
const msg =
|
|
||||||
err instanceof Error ? err.message : "Не удалось разобрать форму";
|
|
||||||
return reply.code(400).send({ error: msg });
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasDetail = detail.length > 0;
|
|
||||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0;
|
|
||||||
|
|
||||||
if (!hasDetail && !hasReceipt) {
|
|
||||||
return reply
|
|
||||||
.code(400)
|
|
||||||
.send({
|
|
||||||
error: "Укажите текст о платеже и/или прикрепите изображение чека",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxDetail = 2000;
|
|
||||||
if (detail.length > maxDetail) {
|
|
||||||
return reply
|
|
||||||
.code(400)
|
|
||||||
.send({ error: `Текст не длиннее ${maxDetail} символов` });
|
|
||||||
}
|
|
||||||
|
|
||||||
let attachmentUrl = null;
|
|
||||||
if (hasReceipt) {
|
|
||||||
try {
|
|
||||||
attachmentUrl = await saveImageBufferToUploads(
|
|
||||||
receiptFilename,
|
|
||||||
receiptBuffer,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error ? err.message : "Не удалось сохранить файл";
|
|
||||||
const statusCode =
|
|
||||||
err &&
|
|
||||||
typeof err === "object" &&
|
|
||||||
"statusCode" in err &&
|
|
||||||
Number.isInteger(err.statusCode)
|
|
||||||
? Number(err.statusCode)
|
|
||||||
: 400;
|
|
||||||
return reply.code(statusCode).send({ error: message });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
|
||||||
|
return reply.code(400).send({ error: msg })
|
||||||
|
}
|
||||||
|
|
||||||
const bodyHtml = hasDetail
|
const hasDetail = detail.length > 0
|
||||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, "<br/>")}</p>`
|
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
||||||
: "";
|
|
||||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`;
|
|
||||||
|
|
||||||
|
if (!hasDetail && !hasReceipt) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Укажите текст о платеже и/или прикрепите изображение чека',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDetail = 2000
|
||||||
|
if (detail.length > maxDetail) {
|
||||||
|
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachmentUrl = null
|
||||||
|
if (hasReceipt) {
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(async (tx) => {
|
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
||||||
await tx.orderMessage.create({
|
|
||||||
data: {
|
|
||||||
orderId: id,
|
|
||||||
authorType: "user",
|
|
||||||
text: messageText,
|
|
||||||
attachmentUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(500).send({ error: "Не удалось сохранить оплату" });
|
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
||||||
|
const statusCode =
|
||||||
|
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
|
||||||
|
? Number(err.statusCode)
|
||||||
|
: 400
|
||||||
|
return reply.code(statusCode).send({ error: message })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
const bodyHtml = hasDetail ? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>` : ''
|
||||||
orderId: id,
|
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
|
||||||
userId,
|
|
||||||
paymentStatus: "pending",
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ok: true, status: "PENDING_PAYMENT" };
|
try {
|
||||||
},
|
await prisma.$transaction(async (tx) => {
|
||||||
);
|
await tx.orderMessage.create({
|
||||||
|
data: {
|
||||||
|
orderId: id,
|
||||||
|
authorType: 'user',
|
||||||
|
text: messageText,
|
||||||
|
attachmentUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
||||||
|
}
|
||||||
|
|
||||||
|
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||||
|
orderId: id,
|
||||||
|
userId,
|
||||||
|
paymentStatus: 'pending',
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true, status: 'PENDING_PAYMENT' }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,31 @@
|
|||||||
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',
|
const userId = request.user.sub
|
||||||
{ preHandler: [fastify.authenticate] },
|
const prefs = await ensureUserNotificationPreference(userId)
|
||||||
async (request) => {
|
return { settings: prefs }
|
||||||
const userId = request.user.sub
|
})
|
||||||
const prefs = await ensureUserNotificationPreference(userId)
|
|
||||||
return { settings: prefs }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.put(
|
fastify.put('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||||
'/api/me/notifications/settings',
|
const userId = request.user.sub
|
||||||
{ preHandler: [fastify.authenticate] },
|
const body = request.body || {}
|
||||||
async (request) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const body = request.body || {}
|
|
||||||
|
|
||||||
const data = {}
|
const data = {}
|
||||||
if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled)
|
if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled)
|
||||||
if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated)
|
if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated)
|
||||||
if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged)
|
if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged)
|
||||||
if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived)
|
if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived)
|
||||||
if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged)
|
if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged)
|
||||||
if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted)
|
if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted)
|
||||||
|
|
||||||
const prefs = await prisma.notificationPreference.upsert({
|
const prefs = await prisma.notificationPreference.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
create: { userId, ...data },
|
create: { userId, ...data },
|
||||||
update: data,
|
update: data,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { settings: prefs }
|
return { settings: prefs }
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
+9
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+7
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user