Merge branch 'refack'
This commit is contained in:
@@ -14,5 +14,4 @@ uploads/.cache/
|
||||
server/uploads/
|
||||
|
||||
# Plans and design docs
|
||||
.opencode/plans/
|
||||
.agents
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Spec: Image Processing Refactor
|
||||
|
||||
## Context
|
||||
|
||||
Current image handling uses on-demand resize via `/uploads-resized/` route. Admin uploads save originals as-is (jpg/png/webp), and resize happens on first request. User uploads (reviews, 2MB limit) also use on-demand resize.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **User images (reviews, ≤2MB):** Improve size error messages to be user-friendly
|
||||
2. **Admin images (products, ≤20MB):** Eager processing at upload time
|
||||
- Generate all resize widths (320, 640, 1024, 1600) in AVIF + WebP
|
||||
- Convert original to WebP (delete source file)
|
||||
- Full-screen viewer shows original in WebP (no width limit)
|
||||
- Thumbnails use resized versions from cache
|
||||
|
||||
## Architecture
|
||||
|
||||
### Server Changes
|
||||
|
||||
#### 1. `server/src/lib/upload-images.js`
|
||||
- Add `eager` parameter to `persistMultipartImages`
|
||||
- When `eager: true`, after saving each file:
|
||||
1. Call `generateAllSizes(uuid, subdir, fullPath)` — generates all sizes from original
|
||||
2. Call `convertOriginalToWebp(uuid, subdir)` — converts original to WebP, deletes source
|
||||
3. Update URL to use `.webp` extension (replace original extension)
|
||||
|
||||
#### 2. `server/src/lib/image-resize.js`
|
||||
- Add `generateAllSizes(uuid, subdir, originalPath)`:
|
||||
- For each width in [320, 640, 1024, 1600]:
|
||||
- Generate AVIF and WebP in `.cache/<subdir>/`
|
||||
- Uses original file path (before conversion to WebP)
|
||||
- Add `convertOriginalToWebp(uuid, subdir)`:
|
||||
- Find original file (jpg/png)
|
||||
- Convert to WebP (quality 80) at same location with `.webp` extension
|
||||
- Delete original jpg/png file
|
||||
- Return new `.webp` path
|
||||
|
||||
#### 3. `server/src/routes/api/admin-products.js`
|
||||
- Pass `eager: true` to `persistMultipartImages`
|
||||
|
||||
#### 4. `server/src/routes/api/public-reviews.js`
|
||||
- Improve error message for file too large (413)
|
||||
|
||||
### Client Changes
|
||||
|
||||
#### 1. `client/src/entities/product/api/product-api.ts`
|
||||
- Add pre-upload size check for review images
|
||||
- Clear error message: "Файл «<name>» слишком большой (максимум 2 МБ)"
|
||||
|
||||
#### 2. `client/src/shared/ui/OptimizedImage.tsx`
|
||||
- Update `buildSrcSet` to use cached AVIF/WebP directly
|
||||
- Full-screen viewer: use original `.webp` URL (no `?w=`)
|
||||
- Remove fallback to original format for upload URLs
|
||||
|
||||
#### 3. `client/src/features/product-review/ui/ReviewDialog.tsx`
|
||||
- Show user-friendly error message for oversized files
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Admin Upload (Eager)
|
||||
1. Client sends FormData to `POST /api/admin/uploads`
|
||||
2. Server saves original (e.g., `uuid.jpg`)
|
||||
3. Server generates all sizes in `.cache/` from original
|
||||
4. Server converts original to WebP (`uuid.webp`), deletes `uuid.jpg`
|
||||
5. Returns URLs with `.webp` extension (e.g., `/uploads/<uuid>.webp`)
|
||||
6. Client displays using OptimizedImage with srcset from cache
|
||||
|
||||
### User Upload (Reviews)
|
||||
1. Client validates file size ≤2MB before upload
|
||||
2. Server validates and saves original
|
||||
3. On-demand resize still works (existing flow)
|
||||
4. Clear error messages at both client and server
|
||||
|
||||
## Error Handling
|
||||
|
||||
### User Upload Size Error
|
||||
- **Client:** Pre-upload check with message "Файл «<name>» слишком большой (максимум 2 МБ)"
|
||||
- **Server:** 413 with "Файл слишком большой (максимум 2 МБ)"
|
||||
|
||||
### Admin Upload Processing Error
|
||||
- If sharp fails: return 500 with "Ошибка обработки изображения"
|
||||
- If file not found after save: return 500 with "Внутренняя ошибка сервера"
|
||||
|
||||
## Testing
|
||||
|
||||
### Server Tests
|
||||
- Test `generateAllSizes` creates all width+format combinations
|
||||
- Test `convertOriginalToWebp` converts and deletes original
|
||||
- Test `persistMultipartImages` with `eager: true`
|
||||
- Test error messages for oversized files
|
||||
|
||||
### Client Tests
|
||||
- Test pre-upload size validation for reviews
|
||||
- Test OptimizedImage srcset generation for WebP originals
|
||||
- Test error message display in ReviewDialog
|
||||
@@ -0,0 +1,543 @@
|
||||
# Image Processing Refactor Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refactor image processing to use eager generation for admin product images and improve error messages for user uploads.
|
||||
|
||||
**Architecture:** Add eager processing functions to `image-resize.js`, integrate into `upload-images.js` via `eager` flag, update client-side validation and error handling.
|
||||
|
||||
**Tech Stack:** Node.js, Fastify, sharp, React, TypeScript, MUI
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add eager processing functions to image-resize.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/lib/image-resize.js`
|
||||
- Test: `server/src/lib/__tests__/image-resize.test.js`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for new functions**
|
||||
|
||||
Add to `server/src/lib/__tests__/image-resize.test.js`:
|
||||
|
||||
```javascript
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { generateAllSizes, convertOriginalToWebp, findOriginalFile } from '../image-resize.js'
|
||||
|
||||
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-eager')
|
||||
const TEST_CACHE_DIR = path.join(TEST_UPLOADS_DIR, '.cache')
|
||||
|
||||
describe('eager image processing', () => {
|
||||
beforeEach(async () => {
|
||||
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('generateAllSizes creates all width+format combinations', async () => {
|
||||
// Create a test PNG image using sharp
|
||||
const sharp = (await import('sharp')).default
|
||||
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid.png')
|
||||
await sharp({ create: { width: 2000, height: 1500, channels: 3, background: { r: 255, g: 0, b: 0 } } })
|
||||
.png()
|
||||
.toFile(testImagePath)
|
||||
|
||||
await generateAllSizes('test-uuid', '', testImagePath)
|
||||
|
||||
// Check all cache files exist
|
||||
for (const width of [320, 640, 1024, 1600]) {
|
||||
for (const format of ['avif', 'webp']) {
|
||||
const cachePath = path.join(TEST_CACHE_DIR, `test-uuid_w${width}.${format}`)
|
||||
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('convertOriginalToWebp converts and deletes original', async () => {
|
||||
const sharp = (await import('sharp')).default
|
||||
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.png')
|
||||
await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 0, g: 255, b: 0 } } })
|
||||
.png()
|
||||
.toFile(testImagePath)
|
||||
|
||||
const result = await convertOriginalToWebp('test-uuid2', '')
|
||||
|
||||
expect(result).toBe('/uploads/test-uuid2.webp')
|
||||
const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false)
|
||||
expect(pngExists).toBe(false)
|
||||
const webpPath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.webp')
|
||||
const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false)
|
||||
expect(webpExists).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd server && npm test -- --run image-resize.test.js`
|
||||
Expected: FAIL — `generateAllSizes` and `convertOriginalToWebp` are not defined
|
||||
|
||||
- [ ] **Step 3: Implement generateAllSizes and convertOriginalToWebp**
|
||||
|
||||
Add to `server/src/lib/image-resize.js` before the final `export` line:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Generate all resize widths in AVIF + WebP for eager processing.
|
||||
* @param {string} uuid - UUID without extension
|
||||
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
|
||||
* @param {string} originalPath - Full path to the original file
|
||||
*/
|
||||
export async function generateAllSizes(uuid, subdir, originalPath) {
|
||||
const cacheSubdir = subdir ? subdir : ''
|
||||
const cacheDir = path.join(CACHE_DIR, cacheSubdir)
|
||||
await fs.promises.mkdir(cacheDir, { recursive: true })
|
||||
|
||||
const sharp = (await import('sharp')).default
|
||||
|
||||
for (const width of VALID_WIDTHS) {
|
||||
for (const format of SUPPORTED_FORMATS) {
|
||||
const cacheFileName = `${uuid}_w${width}.${format}`
|
||||
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
|
||||
|
||||
const pipeline = sharp(originalPath).resize(width, null, { withoutEnlargement: true })
|
||||
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
|
||||
await pipeline[format](options).toFile(cachePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert original file to WebP and delete the source file.
|
||||
* @param {string} uuid - UUID without extension
|
||||
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
|
||||
* @returns {string} New URL path like `/uploads/<uuid>.webp`
|
||||
*/
|
||||
export async function convertOriginalToWebp(uuid, subdir) {
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
|
||||
|
||||
// Find original file
|
||||
const originalPath = await findOriginalFile(uuid, subdir)
|
||||
if (!originalPath) {
|
||||
throw new Error(`Original file not found for UUID: ${uuid}`)
|
||||
}
|
||||
|
||||
const originalExt = path.extname(originalPath).toLowerCase()
|
||||
const webpPath = path.join(targetDir, `${uuid}.webp`)
|
||||
|
||||
// Convert to WebP
|
||||
const sharp = (await import('sharp')).default
|
||||
await sharp(originalPath).webp({ quality: 80 }).toFile(webpPath)
|
||||
|
||||
// Delete original if it's not already WebP
|
||||
if (originalExt !== '.webp') {
|
||||
await fs.promises.unlink(originalPath)
|
||||
}
|
||||
|
||||
return subdir ? `/uploads/${subdir}/${uuid}.webp` : `/uploads/${uuid}.webp`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd server && npm test -- --run image-resize.test.js`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/src/lib/image-resize.js server/src/lib/__tests__/image-resize.test.js
|
||||
git commit -m "feat: add eager image processing functions (generateAllSizes, convertOriginalToWebp)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Integrate eager processing into upload-images.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/lib/upload-images.js`
|
||||
- Test: `server/src/lib/__tests__/upload-images.test.js` (create if not exists)
|
||||
|
||||
- [ ] **Step 1: Write failing test for eager mode**
|
||||
|
||||
Create `server/src/lib/__tests__/upload-images.test.js`:
|
||||
|
||||
```javascript
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { persistMultipartImages, uploadError } from '../upload-images.js'
|
||||
|
||||
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-persist')
|
||||
|
||||
describe('persistMultipartImages with eager mode', () => {
|
||||
beforeEach(async () => {
|
||||
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns WebP URLs when eager=true', async () => {
|
||||
// This test verifies the function signature accepts eager parameter
|
||||
// Full integration test requires mocking multipart request
|
||||
// For now, test that the function doesn't throw with eager option
|
||||
const mockRequest = {
|
||||
isMultipart: () => true,
|
||||
parts: async function* () {
|
||||
// Mock part with a small PNG buffer
|
||||
const pngHeader = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
|
||||
...new Array(100).fill(0), // dummy data
|
||||
])
|
||||
yield {
|
||||
file: true,
|
||||
filename: 'test.png',
|
||||
toBuffer: async () => pngHeader,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Should not throw with eager option
|
||||
try {
|
||||
await persistMultipartImages(mockRequest, {
|
||||
maxFiles: 1,
|
||||
maxFileBytes: 20 * 1024 * 1024,
|
||||
subdir: '',
|
||||
eager: true,
|
||||
})
|
||||
} catch (err) {
|
||||
// If sharp is not available or PNG is invalid, that's expected in unit test
|
||||
// The key is that the function accepts the eager parameter
|
||||
expect(err.message).not.toContain('eager')
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd server && npm test -- --run upload-images.test.js`
|
||||
Expected: FAIL — `eager` parameter is not handled
|
||||
|
||||
- [ ] **Step 3: Modify persistMultipartImages to support eager mode**
|
||||
|
||||
Replace the `persistMultipartImages` function in `server/src/lib/upload-images.js`:
|
||||
|
||||
```javascript
|
||||
export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '', eager = false }) {
|
||||
if (!request.isMultipart()) {
|
||||
throw uploadError('Ожидается multipart/form-data')
|
||||
}
|
||||
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
|
||||
await fs.promises.mkdir(targetDir, { recursive: true })
|
||||
|
||||
const urls = []
|
||||
const parts = request.parts({
|
||||
limits: {
|
||||
fileSize: maxFileBytes,
|
||||
files: maxFiles,
|
||||
},
|
||||
})
|
||||
for await (const part of parts) {
|
||||
if (!part.file) continue
|
||||
if (urls.length >= maxFiles) {
|
||||
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
|
||||
}
|
||||
const ext = safeImageExt(part.filename)
|
||||
if (!ext) {
|
||||
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
|
||||
}
|
||||
|
||||
const uuid = crypto.randomUUID()
|
||||
const fileName = `${uuid}${ext}`
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
await fs.promises.writeFile(fullPath, await part.toBuffer())
|
||||
|
||||
let finalUrl = subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
|
||||
|
||||
if (eager) {
|
||||
const { generateAllSizes, convertOriginalToWebp } = await import('./image-resize.js')
|
||||
await generateAllSizes(uuid, subdir, fullPath)
|
||||
finalUrl = await convertOriginalToWebp(uuid, subdir)
|
||||
}
|
||||
|
||||
urls.push(finalUrl)
|
||||
}
|
||||
|
||||
if (urls.length === 0) {
|
||||
throw uploadError(
|
||||
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
|
||||
)
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd server && npm test -- --run upload-images.test.js`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/src/lib/upload-images.js server/src/lib/__tests__/upload-images.test.js
|
||||
git commit -m "feat: add eager mode to persistMultipartImages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Enable eager mode in admin upload route
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/routes/api/admin-products.js`
|
||||
|
||||
- [ ] **Step 1: Update admin upload route to use eager mode**
|
||||
|
||||
Modify the `POST /api/admin/uploads` route in `server/src/routes/api/admin-products.js`:
|
||||
|
||||
```javascript
|
||||
fastify.post(
|
||||
'/api/admin/uploads',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const urls = await persistMultipartImages(request, {
|
||||
maxFiles: 10,
|
||||
maxFileBytes: getProductImageMaxFileBytes(),
|
||||
eager: true,
|
||||
})
|
||||
await upsertGalleryImagesByUrls(urls)
|
||||
return { urls }
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
|
||||
let statusCode =
|
||||
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||
? Number(error.statusCode)
|
||||
: 400
|
||||
if (isMultipartFileTooLargeError(error)) {
|
||||
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
|
||||
statusCode = 413
|
||||
}
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add server/src/routes/api/admin-products.js
|
||||
git commit -m "feat: enable eager image processing for admin uploads"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Improve user upload error messages
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/entities/product/api/reviews-api.ts`
|
||||
- Modify: `client/src/shared/constants/upload-limits.ts`
|
||||
- Modify: `client/src/features/product-review/ui/ReviewDialog.tsx`
|
||||
|
||||
- [ ] **Step 1: Add client-side size validation for review images**
|
||||
|
||||
Add to `client/src/shared/constants/upload-limits.ts`:
|
||||
|
||||
```typescript
|
||||
export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * 1024 * 1024 // 2 MB
|
||||
|
||||
export function formatOtherUploadMaxSizeHint(): string {
|
||||
return `${Math.round(OTHER_UPLOAD_MAX_FILE_BYTES / (1024 * 1024))} МБ`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add pre-upload size check in reviews-api.ts**
|
||||
|
||||
Modify `uploadReviewImage` in `client/src/entities/product/api/reviews-api.ts`:
|
||||
|
||||
```typescript
|
||||
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
|
||||
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
|
||||
if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) {
|
||||
throw new Error(
|
||||
`Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`,
|
||||
)
|
||||
}
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('file', file, file.name)
|
||||
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update ReviewDialog to show user-friendly error message**
|
||||
|
||||
Modify the uploadError display in `client/src/features/product-review/ui/ReviewDialog.tsx`:
|
||||
|
||||
Replace:
|
||||
```tsx
|
||||
{uploadError ? (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
|
||||
</Alert>
|
||||
) : null}
|
||||
```
|
||||
|
||||
With:
|
||||
```tsx
|
||||
{uploadError ? (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{uploadError instanceof Error ? uploadError.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
|
||||
</Alert>
|
||||
) : null}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/shared/constants/upload-limits.ts client/src/entities/product/api/reviews-api.ts client/src/features/product-review/ui/ReviewDialog.tsx
|
||||
git commit -m "feat: improve error messages for user upload size validation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update OptimizedImage for WebP originals
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/shared/ui/OptimizedImage.tsx`
|
||||
- Test: `client/src/shared/ui/__tests__/OptimizedImage.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Update parseUploadUrl to handle .webp originals**
|
||||
|
||||
Modify `parseUploadUrl` in `client/src/shared/ui/OptimizedImage.tsx`:
|
||||
|
||||
```typescript
|
||||
function parseUploadUrl(src: string): { uuid: string; ext: string; subdir: string } | null {
|
||||
const match = src.match(/^\/uploads(?:\/(reviews))?\/([^.\\/]+)\.(png|jpe?g|webp)/i)
|
||||
if (!match) return null
|
||||
return { subdir: match[1] || '', uuid: match[2], ext: match[3].toLowerCase() }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update buildSrcSet to use cached AVIF/WebP directly**
|
||||
|
||||
Modify `buildSrcSet` and `buildFallbackSrc`:
|
||||
|
||||
```typescript
|
||||
function buildSrcSet(src: string, widths: number[]): string | null {
|
||||
const parsed = parseUploadUrl(src)
|
||||
if (!parsed) return null
|
||||
|
||||
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
|
||||
return widths.map((w) => `/uploads-resized/${pathPrefix}${parsed.uuid}.avif?w=${w} ${w}w`).join(', ')
|
||||
}
|
||||
|
||||
function buildFallbackSrc(src: string, width: number): string {
|
||||
const parsed = parseUploadUrl(src)
|
||||
if (!parsed) return src
|
||||
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
|
||||
return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add original WebP URL getter for full-screen mode**
|
||||
|
||||
Add to `client/src/shared/ui/OptimizedImage.tsx`:
|
||||
|
||||
```typescript
|
||||
/** Get the original WebP URL for full-screen display (no resize) */
|
||||
export function getOriginalWebpUrl(src: string): string {
|
||||
const parsed = parseUploadUrl(src)
|
||||
if (!parsed) return src
|
||||
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
|
||||
return `/uploads/${pathPrefix}${parsed.uuid}.webp`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/shared/ui/OptimizedImage.tsx
|
||||
git commit -m "feat: update OptimizedImage for WebP originals and add getOriginalWebpUrl"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update ProductPage full-screen viewer
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/product/ui/ProductPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Find full-screen image viewer code**
|
||||
|
||||
Search for the full-screen image viewer in ProductPage.tsx. Look for where the original image URL is used.
|
||||
|
||||
- [ ] **Step 2: Use getOriginalWebpUrl for full-screen display**
|
||||
|
||||
Import and use `getOriginalWebpUrl`:
|
||||
|
||||
```typescript
|
||||
import { getOriginalWebpUrl } from '@/shared/ui/OptimizedImage'
|
||||
```
|
||||
|
||||
Replace the full-screen `<img>` src with:
|
||||
```typescript
|
||||
getOriginalWebpUrl(imageUrl)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/product/ui/ProductPage.tsx
|
||||
git commit -m "feat: use WebP original for full-screen product image viewer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Run full test suite and lint
|
||||
|
||||
- [ ] **Step 1: Run server tests**
|
||||
|
||||
```bash
|
||||
cd server && npm test
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run client lint and format check**
|
||||
|
||||
```bash
|
||||
cd client && npm run lint && npm run format:check
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run client tests**
|
||||
|
||||
```bash
|
||||
cd client && npm test
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run client build**
|
||||
|
||||
```bash
|
||||
cd client && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit any fixes**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "fix: address lint and test issues"
|
||||
```
|
||||
@@ -0,0 +1,174 @@
|
||||
# Design: Доработка товара — удаление «под заказ», обязательные quantity и категория
|
||||
|
||||
**Дата:** 2026-05-15
|
||||
**Статус:** На согласовании
|
||||
|
||||
## Цель
|
||||
|
||||
Упростить модель товара: убрать концепцию «под заказ», сделать количество и категорию обязательными полями. Категория «Не указано» остаётся технической заглушкой для переноса товаров при удалении категории, но не видна в каталоге и не выбирается при редактировании.
|
||||
|
||||
## Архитектура изменений
|
||||
|
||||
### 1. База данных (Prisma)
|
||||
|
||||
**Миграция:**
|
||||
- Перед удалением полей: все товары с `inStock = false` получают `quantity = 0`
|
||||
- Удалить поля `inStock` и `leadTimeDays` из модели `Product`
|
||||
- Статус наличия определяется исключительно по `quantity`:
|
||||
- `quantity > 0` → «В наличии»
|
||||
- `quantity = 0` → «Нет в наличии»
|
||||
|
||||
**`server/prisma/schema.prisma`:**
|
||||
```prisma
|
||||
model Product {
|
||||
// ... остальные поля без изменений ...
|
||||
quantity Int @default(0)
|
||||
// УДАЛЕНО: inStock Boolean @default(true)
|
||||
// УДАЛЕНО: leadTimeDays Int?
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||
categoryId String
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Сервер — валидация и CRUD
|
||||
|
||||
**`server/src/routes/api/admin-products.js`:**
|
||||
|
||||
**CREATE (POST):**
|
||||
- `quantity` — required, `Int >= 0` (было nullable)
|
||||
- `categoryId` — required (было: при пустом → авто-назначение «Не указано»)
|
||||
- Удалить валидацию `leadTimeDays` при `!inStock`
|
||||
- Удалить принудительную установку `quantity = 1` для «под заказ»
|
||||
- Вернуть 400: `'Укажите категорию'` если `categoryId` отсутствует
|
||||
|
||||
**UPDATE (PATCH):**
|
||||
- `quantity` — required, `Int >= 0` (было nullable)
|
||||
- `categoryId` — required (было: при пустом → «Не указано»)
|
||||
- Удалить логику очистки `leadTimeDays` при `inStock = true`
|
||||
- Удалить принудительную установку `quantity = 1`
|
||||
- Вернуть 400 при отсутствии `categoryId`
|
||||
|
||||
**JSON Schema:**
|
||||
- `CREATE_PRODUCT_SCHEMA`: убрать `leadTimeDays`, сделать `quantity` required (убрать `nullable`)
|
||||
- `PATCH_PRODUCT_SCHEMA`: убрать `leadTimeDays`, `quantity` — если передан, то `>= 0`
|
||||
|
||||
**`server/src/routes/api/public-catalog.js`:**
|
||||
- Удалить ветку `availability === 'in_stock'` и `availability === 'made_to_order'`
|
||||
- Фильтрация «в наличии» больше не нужна — все товары в каталоге
|
||||
|
||||
### 3. Клиент — админка (две страницы)
|
||||
|
||||
**`client/src/pages/admin/ui/AdminPage.tsx`** и **`client/src/pages/admin-products/ui/AdminProductsPage.tsx`:**
|
||||
|
||||
**FormState:**
|
||||
- Удалить `inStock: boolean` и `leadTimeDays: string`
|
||||
- `quantity: string` — без nullable-семантики
|
||||
|
||||
**UI:**
|
||||
- Удалить Switch «В наличии / Под заказ»
|
||||
- Удалить TextField «Срок исполнения, дней»
|
||||
- TextField «Количество»:
|
||||
- Без helper «Оставьте пустым...»
|
||||
- Новый helper: «0 = нет в наличии»
|
||||
- Валидация: не может быть пустым, `parseInt >= 0`
|
||||
- Select «Категория»:
|
||||
- Удалить `<MenuItem value="">` с «Не указано»
|
||||
- Валидация: не даёт сохранить без выбранной категории
|
||||
- Показать ошибку при попытке сохранить без категории
|
||||
|
||||
**Submit-валидация:**
|
||||
- Удалить проверку `leadTimeDays` при `!inStock`
|
||||
- Добавить проверку: `categoryId` не пустой → blocking error
|
||||
- Добавить проверку: `quantity` не пустой → blocking error
|
||||
|
||||
### 4. Клиент — каталог
|
||||
|
||||
**`client/src/entities/product/ui/ProductCard.tsx`:**
|
||||
- Удалить логику `'Под заказ · {leadTimeDays} дн.'`
|
||||
- Новый статус:
|
||||
- `quantity > 0` → «В наличии» (зелёный)
|
||||
- `quantity === 0` → «Нет в наличии» (серый/red)
|
||||
|
||||
**`client/src/pages/product/ui/ProductPage.tsx`:**
|
||||
- Удалить chip `'Под заказ · {leadTimeDays} дн.'`
|
||||
- Удалить alert `'Этот товар изготавливается под заказ...'`
|
||||
- Статус определяется по `quantity`
|
||||
|
||||
**`client/src/pages/checkout/ui/CheckoutPage.tsx`:**
|
||||
- Удалить определение made-to-order товаров в корзине
|
||||
- Удалить info alert о доставке после изготовления
|
||||
|
||||
### 5. Клиент — фильтры
|
||||
|
||||
**`client/src/pages/home/lib/use-product-filters.ts`:**
|
||||
- Удалить `availability: 'all' | 'in_stock' | 'made_to_order'` из state
|
||||
- Удалить `availability` из параметров `fetchPublicProducts()`
|
||||
|
||||
**`client/src/pages/home/ui/ProductFilters.tsx`:**
|
||||
- Удалить `ToggleButtonGroup` с `'all'`, `'in_stock'`, `'made_to_order'`
|
||||
- Удалить отображение категории «Не указано» из списка чипов (фильтр `cat.slug !== 'ne-ukazano'`)
|
||||
|
||||
### 6. Категория «Не указано» — что остаётся
|
||||
|
||||
| Где | Что происходит |
|
||||
|---|---|
|
||||
| `server/src/lib/default-category.js` | **Остаётся** — функция `getOrCreateUnspecifiedCategory()` |
|
||||
| `server/src/index.js` | **Остаётся** — вызов при старте |
|
||||
| `server/src/routes/api/admin-categories.js` | **Остаётся** — нельзя удалить/переименовать; при удалении категории товары переезжают в «Не указано» |
|
||||
| Админка категорий | **Остаётся** — кнопка удаления заблокирована |
|
||||
| Фильтры каталога | **Скрыта** — не показывается в чипах |
|
||||
| Форма товара | **Скрыта** — не выбирается в Select |
|
||||
|
||||
## Статус товара — новая логика
|
||||
|
||||
```
|
||||
quantity > 0 → «В наличии» (зелёный chip/badge)
|
||||
quantity = 0 → «Нет в наличии» (серый chip/badge)
|
||||
```
|
||||
|
||||
Никаких других статусов. Поле `inStock` больше не существует.
|
||||
|
||||
## Файлы для изменения
|
||||
|
||||
### Сервер
|
||||
| Файл | Изменения |
|
||||
|---|---|
|
||||
| `server/prisma/schema.prisma` | Удалить `inStock`, `leadTimeDays` |
|
||||
| `server/src/routes/api/admin-products.js` | Валидация, schema, убрать логику под заказ |
|
||||
| `server/src/routes/api/public-catalog.js` | Убрать фильтр availability |
|
||||
|
||||
### Клиент
|
||||
| Файл | Изменения |
|
||||
|---|---|
|
||||
| `client/src/pages/admin/ui/AdminPage.tsx` | FormState, UI, валидация |
|
||||
| `client/src/pages/admin-products/ui/AdminProductsPage.tsx` | FormState, UI, валидация |
|
||||
| `client/src/entities/product/ui/ProductCard.tsx` | Статус по quantity |
|
||||
| `client/src/pages/product/ui/ProductPage.tsx` | Убрать под заказ UI |
|
||||
| `client/src/pages/checkout/ui/CheckoutPage.tsx` | Убрать made-to-order detection |
|
||||
| `client/src/pages/home/ui/ProductFilters.tsx` | Убрать availability toggle, скрыть «Не указано» |
|
||||
| `client/src/pages/home/lib/use-product-filters.ts` | Убрать `availability` |
|
||||
|
||||
## Миграция данных
|
||||
|
||||
```javascript
|
||||
// В Prisma migration:
|
||||
// 1. UPDATE Product SET quantity = 0 WHERE inStock = false
|
||||
// 2. ALTER TABLE Product DROP COLUMN inStock
|
||||
// 3. ALTER TABLE Product DROP COLUMN leadTimeDays
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
**Сервер:**
|
||||
- CREATE без categoryId → 400
|
||||
- CREATE без quantity → 400
|
||||
- CREATE с quantity = 0 → OK
|
||||
- PATCH без categoryId → 400
|
||||
- PATCH с quantity = 0 → OK
|
||||
|
||||
**Клиент:**
|
||||
- Форма не сохраняется без категории
|
||||
- Форма не сохраняется без количества
|
||||
- Фильтры не содержат «Под заказ» и «Не указано»
|
||||
- Карточка товара показывает «Нет в наличии» при quantity = 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,10 +25,14 @@
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `npm run dev` | `node --env-file=.dev_env --watch src/index.js` (requires Node 20.6+) |
|
||||
| `npm run dev` | `node --env-file=.env --watch src/index.js` (requires Node 20.6+) |
|
||||
| `npm run dev:classic` | `node --watch src/index.js` (loads `.env` via dotenv) |
|
||||
| `npm run lint` | ESLint (flat config) |
|
||||
| `npm run lint:fix` | ESLint with `--fix` |
|
||||
| `npm run format` | Prettier write all |
|
||||
| `npm run format:check` | Prettier check only |
|
||||
| `npm test` | vitest run |
|
||||
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses `.dev_env`) |
|
||||
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses `.env`) |
|
||||
|
||||
### Build order (when changing both packages)
|
||||
|
||||
@@ -65,7 +69,7 @@ cd client && npm run build # full typecheck + build
|
||||
|
||||
## Notable quirks
|
||||
|
||||
- `.env` is gitignored. Use `.dev_env` in the server repo for local dev (it is committed). Copy `.env.example` to `.env` for custom config.
|
||||
- `.env` is gitignored. Copy `.env.example` to `.env` for local dev.
|
||||
- Vite dev server (client) relies on backend running at `127.0.0.1:3333`. Start server first.
|
||||
- Rich text rendering uses `shared/ui/RichTextMessageContent` (TipTap). Pass `tone="review"`, `tone="chat"`, or `tone="default"`.
|
||||
- `db:reset:test` runs `prisma migrate reset --force`, which destroys all data.
|
||||
|
||||
@@ -62,12 +62,13 @@ npx prisma db seed # опционально: тестовые категор
|
||||
npm run dev:classic # загрузка из `.env`
|
||||
```
|
||||
|
||||
**Вариант B — файл [`server/.dev_env`](server/.dev_env)** (то, что уже лежит в репозитории для локального стенда; нужен **Node.js 20.6+** из‑за `node --env-file`):
|
||||
**Вариант B — `.env` файл** (нужен **Node.js 20.6+** из‑за `node --env-file`):
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cp .env.example .env # укажите ADMIN_EMAIL и другие настройки
|
||||
npm install
|
||||
npm run dev # переменные из `.dev_env`
|
||||
npm run dev # переменные из `.env`
|
||||
```
|
||||
|
||||
Очистка БД до «чистого» тестового состояния (SQLite + миграции + seed): в `server/` выполните `npm run db:reset:test`.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { apiBaseURL } from '@/shared/config'
|
||||
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
|
||||
export async function fetchAdminProducts(): Promise<Product[]> {
|
||||
const { data } = await apiClient.get<Product[]>('admin/products')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createProduct(body: {
|
||||
title: string
|
||||
slug?: string
|
||||
shortDescription?: string | null
|
||||
description?: string | null
|
||||
quantity: number
|
||||
materials?: string[]
|
||||
priceCents: number
|
||||
imageUrl?: string | null
|
||||
imageUrls?: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}): Promise<Product> {
|
||||
const { data } = await apiClient.post<Product>('admin/products', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateProduct(
|
||||
id: string,
|
||||
body: Partial<{
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string | null
|
||||
description: string | null
|
||||
quantity: number
|
||||
materials: string[]
|
||||
priceCents: number
|
||||
imageUrl: string | null
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}>,
|
||||
): Promise<Product> {
|
||||
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/products/${id}`)
|
||||
}
|
||||
|
||||
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
|
||||
const { data } = await apiClient.post<Category>('admin/categories', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminCategories(): Promise<Category[]> {
|
||||
const { data } = await apiClient.get<{ items: Category[] }>('admin/categories')
|
||||
return data.items
|
||||
}
|
||||
|
||||
export async function updateAdminCategory(
|
||||
id: string,
|
||||
body: Partial<{ name: string; slug: string; sort: number }>,
|
||||
): Promise<Category> {
|
||||
const { data } = await apiClient.patch<Category>(`admin/categories/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAdminCategory(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/categories/${id}`)
|
||||
}
|
||||
|
||||
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
|
||||
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
|
||||
const list = Array.from(files)
|
||||
for (const f of list) {
|
||||
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const fd = new FormData()
|
||||
for (const f of list) {
|
||||
fd.append('files', f, f.name)
|
||||
}
|
||||
const token = localStorage.getItem('craftshop_auth_token')
|
||||
const base = apiBaseURL.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/admin/uploads`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
})
|
||||
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
|
||||
if (!res.ok) {
|
||||
if (res.status === 413) {
|
||||
throw new Error(
|
||||
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
|
||||
)
|
||||
}
|
||||
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
|
||||
}
|
||||
if (!Array.isArray(payload.urls)) {
|
||||
throw new Error('Некорректный ответ сервера')
|
||||
}
|
||||
return payload.urls
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { apiBaseURL } from '@/shared/config'
|
||||
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
|
||||
export type PublicProductsResponse = {
|
||||
items: Product[]
|
||||
@@ -42,107 +40,3 @@ export async function fetchCategories(): Promise<Category[]> {
|
||||
const { data } = await apiClient.get<Category[]>('categories')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminProducts(): Promise<Product[]> {
|
||||
const { data } = await apiClient.get<Product[]>('admin/products')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createProduct(body: {
|
||||
title: string
|
||||
slug?: string
|
||||
shortDescription?: string | null
|
||||
description?: string | null
|
||||
quantity: number
|
||||
materials?: string[]
|
||||
priceCents: number
|
||||
imageUrl?: string | null
|
||||
imageUrls?: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}): Promise<Product> {
|
||||
const { data } = await apiClient.post<Product>('admin/products', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateProduct(
|
||||
id: string,
|
||||
body: Partial<{
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string | null
|
||||
description: string | null
|
||||
quantity: number
|
||||
materials: string[]
|
||||
priceCents: number
|
||||
imageUrl: string | null
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}>,
|
||||
): Promise<Product> {
|
||||
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/products/${id}`)
|
||||
}
|
||||
|
||||
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
|
||||
const { data } = await apiClient.post<Category>('admin/categories', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminCategories(): Promise<Category[]> {
|
||||
const { data } = await apiClient.get<{ items: Category[] }>('admin/categories')
|
||||
return data.items
|
||||
}
|
||||
|
||||
export async function updateAdminCategory(
|
||||
id: string,
|
||||
body: Partial<{ name: string; slug: string; sort: number }>,
|
||||
): Promise<Category> {
|
||||
const { data } = await apiClient.patch<Category>(`admin/categories/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAdminCategory(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/categories/${id}`)
|
||||
}
|
||||
|
||||
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
|
||||
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
|
||||
const list = Array.from(files)
|
||||
for (const f of list) {
|
||||
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const fd = new FormData()
|
||||
for (const f of list) {
|
||||
fd.append('files', f, f.name)
|
||||
}
|
||||
const token = localStorage.getItem('craftshop_auth_token')
|
||||
const base = apiBaseURL.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/admin/uploads`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
})
|
||||
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
|
||||
if (!res.ok) {
|
||||
if (res.status === 413) {
|
||||
throw new Error(
|
||||
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
|
||||
)
|
||||
}
|
||||
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
|
||||
}
|
||||
if (!Array.isArray(payload.urls)) {
|
||||
throw new Error('Некорректный ответ сервера')
|
||||
}
|
||||
return payload.urls
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AddressFormDialog } from './ui/AddressFormDialog'
|
||||
export type { AddressFormValues } from './ui/AddressFormDialog'
|
||||
@@ -0,0 +1,127 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import { Controller, type UseFormReturn } from 'react-hook-form'
|
||||
import { AddressMapPicker } from '@/features/address-map-picker'
|
||||
|
||||
export type AddressFormValues = {
|
||||
label: string
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment: string
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export function AddressFormDialog({
|
||||
open,
|
||||
onClose,
|
||||
editing,
|
||||
form,
|
||||
onSubmit,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
editing: boolean
|
||||
form: UseFormReturn<AddressFormValues>
|
||||
onSubmit: () => void
|
||||
isPending: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="label"
|
||||
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="recipientName"
|
||||
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="recipientPhone"
|
||||
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="addressLine"
|
||||
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="comment"
|
||||
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="lat"
|
||||
render={({ field: latField }) => (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="lng"
|
||||
render={({ field: lngField }) => (
|
||||
<AddressMapPicker
|
||||
value={
|
||||
latField.value !== null && lngField.value !== null
|
||||
? { lat: latField.value, lng: lngField.value }
|
||||
: null
|
||||
}
|
||||
onChange={(v) => {
|
||||
latField.onChange(v.lat)
|
||||
lngField.onChange(v.lng)
|
||||
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="isDefault"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Адрес по умолчанию"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onSubmit}
|
||||
disabled={
|
||||
isPending ||
|
||||
!form.watch('recipientName').trim() ||
|
||||
!form.watch('recipientPhone').trim() ||
|
||||
!form.watch('addressLine').trim()
|
||||
}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DeliveryFeeAdjustmentForm } from './ui/DeliveryFeeAdjustmentForm'
|
||||
export { OrderDetailContent } from './ui/OrderDetailContent'
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { patchAdminOrderDeliveryFee } from '@/entities/order/api/admin-order-api'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
|
||||
export function DeliveryFeeAdjustmentForm({
|
||||
orderId,
|
||||
deliveryFeeCents,
|
||||
}: {
|
||||
orderId: string
|
||||
deliveryFeeCents: number
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [rub, setRub] = useState(() => String(deliveryFeeCents / 100))
|
||||
const feeMut = useMutation({
|
||||
mutationFn: () => patchAdminOrderDeliveryFee(orderId, Math.round(Number.parseFloat(rub) * 100)),
|
||||
onSuccess: async () => {
|
||||
await invalidateQueryKeys(qc, [
|
||||
['admin', 'orders'],
|
||||
['admin', 'orders', 'detail'],
|
||||
['admin', 'orders', 'summary'],
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Доставка, ₽"
|
||||
type="number"
|
||||
value={rub}
|
||||
onChange={(e) => setRub(e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
sx={{ width: { xs: '100%', sm: 200 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={
|
||||
feeMut.isPending ||
|
||||
!rub.trim() ||
|
||||
!Number.isFinite(Number.parseFloat(rub)) ||
|
||||
Number.parseFloat(rub) < 0 ||
|
||||
!Number.isInteger(Number.parseFloat(rub))
|
||||
}
|
||||
onClick={() => feeMut.mutate()}
|
||||
>
|
||||
Утвердить доставку и открыть оплату
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Select from '@mui/material/Select'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
||||
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
||||
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
||||
import { getAdminNextOrderStatuses } from '@/shared/constants/order'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm'
|
||||
|
||||
export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDetailResponse['item']; orderId: string }) {
|
||||
const qc = useQueryClient()
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const statusMut = useMutation({
|
||||
mutationFn: (next: string) => setAdminOrderStatus(orderId, next),
|
||||
onSuccess: async () => {
|
||||
await invalidateQueryKeys(qc, [
|
||||
['admin', 'orders'],
|
||||
['admin', 'orders', 'detail'],
|
||||
['admin', 'orders', 'summary'],
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const msgMut = useMutation({
|
||||
mutationFn: () => postAdminOrderMessage(orderId, msg.trim()),
|
||||
onSuccess: async () => {
|
||||
setMsg('')
|
||||
await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
|
||||
},
|
||||
})
|
||||
|
||||
const deliverySnapshot = useMemo(
|
||||
() => (detail.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null),
|
||||
[detail],
|
||||
)
|
||||
|
||||
const nextStatuses = useMemo(
|
||||
() => getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery'),
|
||||
[detail],
|
||||
)
|
||||
|
||||
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
|
||||
{formatPriceRub(detail.totalCents)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
|
||||
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
|
||||
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
|
||||
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{detail.deliveryType === 'delivery' && (
|
||||
<Box
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
Адрес и получатель (на момент заказа)
|
||||
</Typography>
|
||||
{deliverySnapshot ? (
|
||||
<Stack spacing={0.75}>
|
||||
{deliverySnapshot.label?.trim() && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Метка: {deliverySnapshot.label}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Адрес:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.addressLine ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Получатель:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.recipientName ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Телефон:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.recipientPhone ?? '—'}
|
||||
</Typography>
|
||||
{deliverySnapshot.comment?.trim() && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Комментарий к адресу: {deliverySnapshot.comment}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Данные адреса в заказе отсутствуют или не распознаны.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||
<Alert severity="info">
|
||||
Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
||||
<Select
|
||||
labelId="next-status-label"
|
||||
label="Сменить статус"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const next = String(e.target.value)
|
||||
if (!next) return
|
||||
statusMut.mutate(next)
|
||||
}}
|
||||
disabled={statusMut.isPending || nextStatuses.length === 0}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Выберите…</em>
|
||||
</MenuItem>
|
||||
{nextStatuses.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{orderStatusLabelRu(s)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Сообщения
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 1 }}>
|
||||
{detail.messages.map((m) => (
|
||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
||||
</ChatMessageBubble>
|
||||
))}
|
||||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => msgMut.mutate()}
|
||||
disabled={msgMut.isPending || !canSendMessage}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { FormState } from './model/types'
|
||||
export { emptyForm } from './lib/use-product-form-helpers'
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { FormState } from '../model/types'
|
||||
|
||||
export const emptyForm = (): FormState => ({
|
||||
title: '',
|
||||
slug: '',
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
quantity: '0',
|
||||
materials: '',
|
||||
priceRub: '',
|
||||
imageUrls: [],
|
||||
published: true,
|
||||
categoryId: '',
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
export type FormState = {
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string
|
||||
description: string
|
||||
quantity: string
|
||||
materials: string
|
||||
priceRub: string
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchAdminGallery } from '@/entities/gallery'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
|
||||
export function GalleryImagePicker({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentUrls,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (urls: string[]) => void
|
||||
currentUrls: string[]
|
||||
}) {
|
||||
const [selectedUrls, setSelectedUrls] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: ['admin', 'gallery'],
|
||||
queryFn: fetchAdminGallery,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const toggleUrl = (url: string) => {
|
||||
setSelectedUrls((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(url)) {
|
||||
next.delete(url)
|
||||
} else {
|
||||
next.add(url)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
onSelect([...selectedUrls])
|
||||
setSelectedUrls(new Set())
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedUrls(new Set())
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Изображения из галереи</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{galleryQuery.isLoading && <Typography color="text.secondary">Загрузка списка…</Typography>}
|
||||
{galleryQuery.isError && <Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>}
|
||||
{galleryQuery.data?.items.length === 0 && !galleryQuery.isLoading && (
|
||||
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
|
||||
)}
|
||||
{galleryQuery.data &&
|
||||
galleryQuery.data.items.length > 0 &&
|
||||
galleryQuery.data.items.filter((i) => i.isResized).length === 0 &&
|
||||
!galleryQuery.isLoading && (
|
||||
<Typography color="text.secondary">
|
||||
В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 1.5,
|
||||
pt: 1,
|
||||
}}
|
||||
>
|
||||
{(galleryQuery.data?.items ?? [])
|
||||
.filter((item) => item.isResized)
|
||||
.map((item) => {
|
||||
const alreadyInCard = currentUrls.includes(item.url)
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
sx={{ m: 0, alignItems: 'flex-start' }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={alreadyInCard || selectedUrls.has(item.url)}
|
||||
disabled={alreadyInCard}
|
||||
onChange={() => toggleUrl(item.url)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
|
||||
<OptimizedImage
|
||||
src={item.url}
|
||||
alt=""
|
||||
widths={[320, 640]}
|
||||
sizes="120px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleApply}
|
||||
disabled={![...selectedUrls].some((u) => !currentUrls.includes(u))}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import FormHelperText from '@mui/material/FormHelperText'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Select from '@mui/material/Select'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { Controller, type UseFormReturn } from 'react-hook-form'
|
||||
import type { Category } from '@/entities/product/model/types'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import type { FormState } from '../model/types'
|
||||
|
||||
export function ProductFormFields({
|
||||
form,
|
||||
categories,
|
||||
onRemoveImage,
|
||||
onPickFromGallery,
|
||||
}: {
|
||||
form: UseFormReturn<FormState>
|
||||
categories: Category[]
|
||||
onRemoveImage: (url: string) => void
|
||||
onPickFromGallery: () => void
|
||||
}) {
|
||||
return (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug (URL)"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Можно оставить пустым при создании — сгенерируется из названия"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="shortDescription"
|
||||
render={({ field }) => (
|
||||
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="materials"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Материалы"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Список через запятую (например: хлопок, дерево, акрил)"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="quantity"
|
||||
rules={{
|
||||
validate: (v) => {
|
||||
const n = Number(v)
|
||||
if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10'
|
||||
return true
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label="Количество"
|
||||
fullWidth
|
||||
{...field}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/[^0-9]/g, '')
|
||||
field.onChange(v)
|
||||
}}
|
||||
helperText={fieldState.error?.message ?? '0 = нет в наличии'}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="priceRub"
|
||||
rules={{
|
||||
required: 'Укажите цену',
|
||||
validate: (v) => {
|
||||
const n = Number(v.replace(',', '.'))
|
||||
if (!Number.isFinite(n) || n <= 0) return 'Цена должна быть больше 0'
|
||||
if (n > 10_000) return 'Цена не может превышать 10 000 ₽'
|
||||
if (!Number.isInteger(Math.round(n * 100))) return 'Не более 2 знаков после запятой'
|
||||
return true
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label="Цена, ₽"
|
||||
fullWidth
|
||||
{...field}
|
||||
inputMode="decimal"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/[^0-9.,]/g, '')
|
||||
field.onChange(v)
|
||||
}}
|
||||
helperText={fieldState.error?.message}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
Фото (из галереи)
|
||||
</Typography>
|
||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
||||
Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл остаётся
|
||||
на сервере и в галерее.
|
||||
</FormHelperText>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: { sm: 'center' },
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={onPickFromGallery}>
|
||||
Из галереи
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{form.watch('imageUrls').length > 0 && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{form.watch('imageUrls').map((url) => (
|
||||
<Box
|
||||
key={url}
|
||||
sx={{
|
||||
width: 92,
|
||||
height: 92,
|
||||
borderRadius: 1,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={url}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={url}
|
||||
alt="Фото товара"
|
||||
widths={[320, 640]}
|
||||
sizes="80px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => onRemoveImage(url)}
|
||||
aria-label="Убрать из карточки"
|
||||
title="Убрать из карточки"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
minWidth: 0,
|
||||
px: 0.75,
|
||||
py: 0,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth error={!field.value}>
|
||||
<InputLabel id="cat-label">Категория</InputLabel>
|
||||
<Select labelId="cat-label" label="Категория" {...field}>
|
||||
{categories.map((c: Category) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Показывать в каталоге"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { ReviewSection } from './ui/ReviewSection'
|
||||
export { ReviewDialog } from './ui/ReviewDialog'
|
||||
export { ProductReviewsList } from './ui/ProductReviewsList'
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Rating from '@mui/material/Rating'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Star } from 'lucide-react'
|
||||
import { fetchPublicProductReviews } from '@/entities/review/api/reviews-api'
|
||||
import type { PublicProductReviewItem } from '@/entities/review/api/reviews-api'
|
||||
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||
|
||||
function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
|
||||
const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
||||
<Stack spacing={0.75}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(rv.createdAt).toLocaleString('ru-RU')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Rating
|
||||
value={rv.rating}
|
||||
readOnly
|
||||
size="small"
|
||||
icon={<Star fontSize="inherit" />}
|
||||
emptyIcon={<Star fontSize="inherit" />}
|
||||
/>
|
||||
{body ? (
|
||||
<Box sx={{ color: 'text.secondary' }}>
|
||||
<RichTextMessageContent value={body} tone="review" />
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Без текстового комментария.
|
||||
</Typography>
|
||||
)}
|
||||
{rv.imageUrl && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 1.5,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={rv.imageUrl}
|
||||
alt="Фото к отзыву"
|
||||
widths={[320, 640]}
|
||||
sizes="140px"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProductReviewsList({ productId }: { productId: string }) {
|
||||
const reviewsQuery = useQuery({
|
||||
queryKey: ['products', 'public', productId, 'reviews', { page: 1, pageSize: 30 }],
|
||||
queryFn: () => fetchPublicProductReviews(productId, { page: 1, pageSize: 30 }),
|
||||
enabled: Boolean(productId),
|
||||
})
|
||||
|
||||
if (reviewsQuery.isLoading) return <Typography color="text.secondary">Загрузка отзывов…</Typography>
|
||||
if (reviewsQuery.isError) return <Alert severity="warning">Не удалось загрузить отзывы.</Alert>
|
||||
if (reviewsQuery.data && reviewsQuery.data.total === 0) {
|
||||
return (
|
||||
<Box sx={{ py: 3 }}>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Отзывов пока нет
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
|
||||
Будьте первым, кто оставит отзыв на этот товар. Ваше мнение поможет улучшить качество наших изделий.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
if (!reviewsQuery.data || reviewsQuery.data.items.length === 0) return null
|
||||
|
||||
return (
|
||||
<Stack spacing={1.25}>
|
||||
{reviewsQuery.data.items.map((rv) => (
|
||||
<ReviewItem key={rv.id} rv={rv} />
|
||||
))}
|
||||
{reviewsQuery.data.total > reviewsQuery.data.items.length && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Всего {reviewsCountRu(reviewsQuery.data.total)} — ниже показаны последние {reviewsQuery.data.items.length}.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
deleteAdminCategory,
|
||||
fetchAdminCategories,
|
||||
updateAdminCategory,
|
||||
} from '@/entities/product/api/product-api'
|
||||
} from '@/entities/product/api/admin-product-api'
|
||||
import type { Category } from '@/entities/product/model/types'
|
||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
|
||||
@@ -14,76 +14,21 @@ import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
fetchAdminOrder,
|
||||
fetchAdminOrders,
|
||||
patchAdminOrderDeliveryFee,
|
||||
postAdminOrderMessage,
|
||||
setAdminOrderStatus,
|
||||
} from '@/entities/order/api/admin-order-api'
|
||||
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
||||
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchAdminOrder, fetchAdminOrders } from '@/entities/order/api/admin-order-api'
|
||||
import { OrderDetailContent } from '@/features/order-detail/ui/OrderDetailContent'
|
||||
import { ORDER_STATUSES } from '@/shared/constants/order'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
|
||||
function DeliveryFeeAdjustmentForm({ orderId, deliveryFeeCents }: { orderId: string; deliveryFeeCents: number }) {
|
||||
const qc = useQueryClient()
|
||||
const [rub, setRub] = useState(() => String(deliveryFeeCents / 100))
|
||||
const feeMut = useMutation({
|
||||
mutationFn: () => patchAdminOrderDeliveryFee(orderId, Math.round(Number.parseFloat(rub) * 100)),
|
||||
onSuccess: async () => {
|
||||
await invalidateQueryKeys(qc, [
|
||||
['admin', 'orders'],
|
||||
['admin', 'orders', 'detail'],
|
||||
['admin', 'orders', 'summary'],
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Доставка, ₽"
|
||||
type="number"
|
||||
value={rub}
|
||||
onChange={(e) => setRub(e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
sx={{ width: { xs: '100%', sm: 200 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={
|
||||
feeMut.isPending ||
|
||||
!rub.trim() ||
|
||||
!Number.isFinite(Number.parseFloat(rub)) ||
|
||||
Number.parseFloat(rub) < 0 ||
|
||||
!Number.isInteger(Number.parseFloat(rub))
|
||||
}
|
||||
onClick={() => feeMut.mutate()}
|
||||
>
|
||||
Утвердить доставку и открыть оплату
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminOrdersPage() {
|
||||
const qc = useQueryClient()
|
||||
const [q, setQ] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['admin', 'orders', { q, status, deliveryType }],
|
||||
@@ -101,25 +46,6 @@ export function AdminOrdersPage() {
|
||||
enabled: Boolean(selectedId),
|
||||
})
|
||||
|
||||
const statusMut = useMutation({
|
||||
mutationFn: (next: string) => setAdminOrderStatus(selectedId!, next),
|
||||
onSuccess: async () => {
|
||||
await invalidateQueryKeys(qc, [
|
||||
['admin', 'orders'],
|
||||
['admin', 'orders', 'detail'],
|
||||
['admin', 'orders', 'summary'],
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const msgMut = useMutation({
|
||||
mutationFn: () => postAdminOrderMessage(selectedId!, msg.trim()),
|
||||
onSuccess: async () => {
|
||||
setMsg('')
|
||||
await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
|
||||
},
|
||||
})
|
||||
|
||||
const open = (id: string) => {
|
||||
setSelectedId(id)
|
||||
setDialogOpen(true)
|
||||
@@ -136,17 +62,6 @@ export function AdminOrdersPage() {
|
||||
)
|
||||
|
||||
const detail = orderDetailQuery.data?.item
|
||||
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||
|
||||
const deliverySnapshot = useMemo(
|
||||
() => (detail?.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null),
|
||||
[detail],
|
||||
)
|
||||
|
||||
const nextStatuses = useMemo(() => {
|
||||
if (!detail) return []
|
||||
return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery')
|
||||
}, [detail])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -252,146 +167,7 @@ export function AdminOrdersPage() {
|
||||
loading={!detail && orderDetailQuery.isLoading}
|
||||
error={orderDetailQuery.isError ? 'Не удалось загрузить заказ.' : null}
|
||||
>
|
||||
{detail && (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
|
||||
{formatPriceRub(detail.totalCents)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
|
||||
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
|
||||
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
|
||||
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{detail.deliveryType === 'delivery' && (
|
||||
<Box
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
Адрес и получатель (на момент заказа)
|
||||
</Typography>
|
||||
{deliverySnapshot ? (
|
||||
<Stack spacing={0.75}>
|
||||
{deliverySnapshot.label?.trim() && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Метка: {deliverySnapshot.label}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Адрес:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.addressLine ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Получатель:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.recipientName ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Телефон:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.recipientPhone ?? '—'}
|
||||
</Typography>
|
||||
{deliverySnapshot.comment?.trim() && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Комментарий к адресу: {deliverySnapshot.comment}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Данные адреса в заказе отсутствуют или не распознаны.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||
<Alert severity="info">
|
||||
Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой
|
||||
суммы.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||
<DeliveryFeeAdjustmentForm
|
||||
key={detail.id}
|
||||
orderId={detail.id}
|
||||
deliveryFeeCents={detail.deliveryFeeCents}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
||||
<Select
|
||||
labelId="next-status-label"
|
||||
label="Сменить статус"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const next = String(e.target.value)
|
||||
if (!next) return
|
||||
statusMut.mutate(next)
|
||||
}}
|
||||
disabled={statusMut.isPending || nextStatuses.length === 0}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Выберите…</em>
|
||||
</MenuItem>
|
||||
{nextStatuses.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{orderStatusLabelRu(s)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Сообщения
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 1 }}>
|
||||
{detail.messages.map((m) => (
|
||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
|
||||
{new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
||||
</ChatMessageBubble>
|
||||
))}
|
||||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => msgMut.mutate()}
|
||||
disabled={msgMut.isPending || !canSendMessage}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
{detail && <OrderDetailContent detail={detail} orderId={detail.id} />}
|
||||
</AdminDialog>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -2,75 +2,40 @@ import { useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import FormHelperText from '@mui/material/FormHelperText'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Select from '@mui/material/Select'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import Table from '@mui/material/Table'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { fetchAdminGallery } from '@/entities/gallery'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
createProduct,
|
||||
deleteProduct,
|
||||
fetchAdminProducts,
|
||||
fetchCategories,
|
||||
updateProduct,
|
||||
} from '@/entities/product/api/product-api'
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
} from '@/entities/product/api/admin-product-api'
|
||||
import { fetchCategories } from '@/entities/product/api/product-api'
|
||||
import type { Product } from '@/entities/product/model/types'
|
||||
import { emptyForm, type FormState } from '@/features/product-form'
|
||||
import { GalleryImagePicker } from '@/features/product-form/ui/GalleryImagePicker'
|
||||
import { ProductFormFields } from '@/features/product-form/ui/ProductFormFields'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
||||
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
|
||||
type FormState = {
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string
|
||||
description: string
|
||||
quantity: string
|
||||
materials: string
|
||||
priceRub: string
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
title: '',
|
||||
slug: '',
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
quantity: '0',
|
||||
materials: '',
|
||||
priceRub: '',
|
||||
imageUrls: [],
|
||||
published: true,
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
export function AdminProductsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
|
||||
const [galleryPickOpen, setGalleryPickOpen] = useState(false)
|
||||
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const productForm = useForm<FormState>({
|
||||
defaultValues: emptyForm(),
|
||||
@@ -89,12 +54,6 @@ export function AdminProductsPage() {
|
||||
queryFn: fetchAdminProducts,
|
||||
})
|
||||
|
||||
const galleryForPickQuery = useQuery({
|
||||
queryKey: ['admin', 'gallery'],
|
||||
queryFn: fetchAdminGallery,
|
||||
enabled: galleryPickOpen,
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
productForm.reset(emptyForm())
|
||||
openCreateDialog()
|
||||
@@ -212,29 +171,15 @@ export function AdminProductsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const toggleGalleryPickUrl = (url: string) => {
|
||||
setGallerySelectedUrls((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(url)) {
|
||||
next.delete(url)
|
||||
} else {
|
||||
next.add(url)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const appendGalleryUrlsToForm = () => {
|
||||
const handleGallerySelect = (urls: string[]) => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
const merged = [...current]
|
||||
for (const url of gallerySelectedUrls) {
|
||||
for (const url of urls) {
|
||||
if (!merged.includes(url)) {
|
||||
merged.push(url)
|
||||
}
|
||||
}
|
||||
productForm.setValue('imageUrls', merged, { shouldDirty: true })
|
||||
setGalleryPickOpen(false)
|
||||
setGallerySelectedUrls(new Set())
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -289,204 +234,12 @@ export function AdminProductsPage() {
|
||||
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="title"
|
||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug (URL)"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Можно оставить пустым при создании — сгенерируется из названия"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="shortDescription"
|
||||
render={({ field }) => (
|
||||
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="description"
|
||||
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="materials"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Материалы"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Список через запятую (например: хлопок, дерево, акрил)"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="quantity"
|
||||
rules={{
|
||||
validate: (v) => {
|
||||
const n = Number(v)
|
||||
if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10'
|
||||
return true
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label="Количество"
|
||||
fullWidth
|
||||
{...field}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/[^0-9]/g, '')
|
||||
field.onChange(v)
|
||||
}}
|
||||
helperText={fieldState.error?.message ?? '0 = нет в наличии'}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="priceRub"
|
||||
rules={{
|
||||
required: 'Укажите цену',
|
||||
validate: (v) => {
|
||||
const n = Number(v.replace(',', '.'))
|
||||
if (!Number.isFinite(n) || n <= 0) return 'Цена должна быть больше 0'
|
||||
if (n > 10_000) return 'Цена не может превышать 10 000 ₽'
|
||||
if (!Number.isInteger(Math.round(n * 100))) return 'Не более 2 знаков после запятой'
|
||||
return true
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label="Цена, ₽"
|
||||
fullWidth
|
||||
{...field}
|
||||
inputMode="decimal"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/[^0-9.,]/g, '')
|
||||
field.onChange(v)
|
||||
}}
|
||||
helperText={fieldState.error?.message}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
Фото (из галереи)
|
||||
</Typography>
|
||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
||||
Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл
|
||||
остаётся на сервере и в галерее.
|
||||
</FormHelperText>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: { sm: 'center' },
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setGallerySelectedUrls(new Set())
|
||||
setGalleryPickOpen(true)
|
||||
}}
|
||||
>
|
||||
Из галереи
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{productForm.watch('imageUrls').length > 0 && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{productForm.watch('imageUrls').map((url) => (
|
||||
<Box
|
||||
key={url}
|
||||
sx={{
|
||||
width: 92,
|
||||
height: 92,
|
||||
borderRadius: 1,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={url}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={url}
|
||||
alt="Фото товара"
|
||||
widths={[320, 640]}
|
||||
sizes="80px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => removeImage(url)}
|
||||
aria-label="Убрать из карточки"
|
||||
title="Убрать из карточки"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
minWidth: 0,
|
||||
px: 0.75,
|
||||
py: 0,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth error={!field.value}>
|
||||
<InputLabel id="cat-label">Категория</InputLabel>
|
||||
<Select labelId="cat-label" label="Категория" {...field}>
|
||||
{(categoriesQuery.data ?? []).map((c: Category) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Показывать в каталоге"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<ProductFormFields
|
||||
form={productForm}
|
||||
categories={categoriesQuery.data ?? []}
|
||||
onRemoveImage={removeImage}
|
||||
onPickFromGallery={() => setGalleryPickOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeDialog}>Отмена</Button>
|
||||
@@ -508,89 +261,12 @@ export function AdminProductsPage() {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
<GalleryImagePicker
|
||||
open={galleryPickOpen}
|
||||
onClose={() => {
|
||||
setGalleryPickOpen(false)
|
||||
setGallerySelectedUrls(new Set())
|
||||
}}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
>
|
||||
<DialogTitle>Изображения из галереи</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{galleryForPickQuery.isLoading && <Typography color="text.secondary">Загрузка списка…</Typography>}
|
||||
{galleryForPickQuery.isError && (
|
||||
<Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>
|
||||
)}
|
||||
{galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && (
|
||||
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
|
||||
)}
|
||||
{galleryForPickQuery.data &&
|
||||
galleryForPickQuery.data.items.length > 0 &&
|
||||
galleryForPickQuery.data.items.filter((i) => i.isResized).length === 0 &&
|
||||
!galleryForPickQuery.isLoading && (
|
||||
<Typography color="text.secondary">
|
||||
В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 1.5,
|
||||
pt: 1,
|
||||
}}
|
||||
>
|
||||
{(galleryForPickQuery.data?.items ?? [])
|
||||
.filter((item) => item.isResized)
|
||||
.map((item) => {
|
||||
const alreadyInCard = productForm.watch('imageUrls').includes(item.url)
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
sx={{ m: 0, alignItems: 'flex-start' }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={alreadyInCard || gallerySelectedUrls.has(item.url)}
|
||||
disabled={alreadyInCard}
|
||||
onChange={() => toggleGalleryPickUrl(item.url)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
|
||||
<OptimizedImage
|
||||
src={item.url}
|
||||
alt=""
|
||||
widths={[320, 640]}
|
||||
sizes="120px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setGalleryPickOpen(false)
|
||||
setGallerySelectedUrls(new Set())
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={appendGalleryUrlsToForm}
|
||||
disabled={![...gallerySelectedUrls].some((u) => !productForm.watch('imageUrls').includes(u))}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
onClose={() => setGalleryPickOpen(false)}
|
||||
onSelect={handleGallerySelect}
|
||||
currentUrls={productForm.watch('imageUrls')}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,10 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
createMyAddress,
|
||||
deleteMyAddress,
|
||||
@@ -22,7 +15,18 @@ import {
|
||||
updateMyAddress,
|
||||
} from '@/entities/user/api/address-api'
|
||||
import type { ShippingAddress } from '@/entities/user/model/types'
|
||||
import { AddressMapPicker } from '@/features/address-map-picker'
|
||||
import { AddressFormDialog, type AddressFormValues } from '@/features/address-form'
|
||||
|
||||
const defaultAddressForm = (isDefault: boolean): AddressFormValues => ({
|
||||
label: '',
|
||||
recipientName: '',
|
||||
recipientPhone: '',
|
||||
addressLine: '',
|
||||
comment: '',
|
||||
lat: null,
|
||||
lng: null,
|
||||
isDefault,
|
||||
})
|
||||
|
||||
export function AddressesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -34,26 +38,8 @@ export function AddressesPage() {
|
||||
queryFn: fetchMyAddresses,
|
||||
})
|
||||
|
||||
const form = useForm<{
|
||||
label: string
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment: string
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
isDefault: boolean
|
||||
}>({
|
||||
defaultValues: {
|
||||
label: '',
|
||||
recipientName: '',
|
||||
recipientPhone: '',
|
||||
addressLine: '',
|
||||
comment: '',
|
||||
lat: null,
|
||||
lng: null,
|
||||
isDefault: false,
|
||||
},
|
||||
const form = useForm<AddressFormValues>({
|
||||
defaultValues: defaultAddressForm(false),
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
@@ -115,16 +101,7 @@ export function AddressesPage() {
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.reset({
|
||||
label: '',
|
||||
recipientName: '',
|
||||
recipientPhone: '',
|
||||
addressLine: '',
|
||||
comment: '',
|
||||
lat: null,
|
||||
lng: null,
|
||||
isDefault: items.length === 0,
|
||||
})
|
||||
form.reset(defaultAddressForm(items.length === 0))
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
@@ -143,6 +120,11 @@ export function AddressesPage() {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editing) updateMut.mutate()
|
||||
else createMut.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
@@ -226,93 +208,14 @@ export function AddressesPage() {
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="md">
|
||||
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="label"
|
||||
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="recipientName"
|
||||
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="recipientPhone"
|
||||
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="addressLine"
|
||||
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="comment"
|
||||
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="lat"
|
||||
render={({ field: latField }) => (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="lng"
|
||||
render={({ field: lngField }) => (
|
||||
<AddressMapPicker
|
||||
value={
|
||||
latField.value !== null && lngField.value !== null
|
||||
? { lat: latField.value, lng: lngField.value }
|
||||
: null
|
||||
}
|
||||
onChange={(v) => {
|
||||
latField.onChange(v.lat)
|
||||
lngField.onChange(v.lng)
|
||||
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="isDefault"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Адрес по умолчанию"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||||
disabled={
|
||||
createMut.isPending ||
|
||||
updateMut.isPending ||
|
||||
!form.watch('recipientName').trim() ||
|
||||
!form.watch('recipientPhone').trim() ||
|
||||
!form.watch('addressLine').trim()
|
||||
}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<AddressFormDialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
editing={Boolean(editing)}
|
||||
form={form}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={createMut.isPending || updateMut.isPending}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
submitOrderPayment,
|
||||
fetchOrderReviewEligibility,
|
||||
} from '@/entities/order/api/order-api'
|
||||
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
|
||||
import { postProductReview, uploadReviewImage } from '@/entities/review/api/reviews-api'
|
||||
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||
import { OrderChat } from '@/features/order-chat'
|
||||
import { OrderPaymentSection } from '@/features/order-payment'
|
||||
|
||||
@@ -5,7 +5,6 @@ import Chip from '@mui/material/Chip'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Rating from '@mui/material/Rating'
|
||||
import Skeleton from '@mui/material/Skeleton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
@@ -19,14 +18,13 @@ import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import { fetchPublicProduct } from '@/entities/product/api/product-api'
|
||||
import { fetchPublicProductReviews } from '@/entities/product/api/reviews-api'
|
||||
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||
import { ProductReviewsList } from '@/features/product-review'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { getOriginalWebpUrl } from '@/shared/lib/get-original-webp-url'
|
||||
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||
|
||||
export function ProductPage() {
|
||||
const user = useUnit($user)
|
||||
@@ -41,12 +39,6 @@ export function ProductPage() {
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
const reviewsQuery = useQuery({
|
||||
queryKey: ['products', 'public', id, 'reviews', { page: 1, pageSize: 30 }],
|
||||
queryFn: () => fetchPublicProductReviews(id!, { page: 1, pageSize: 30 }),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
const imageUrls = useMemo(() => {
|
||||
const p = productQuery.data
|
||||
if (!p) return []
|
||||
@@ -191,83 +183,7 @@ export function ProductPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{reviewsQuery.isLoading && <Typography color="text.secondary">Загрузка отзывов…</Typography>}
|
||||
{reviewsQuery.isError && <Alert severity="warning">Не удалось загрузить отзывы.</Alert>}
|
||||
{reviewsQuery.data && reviewsQuery.data.total === 0 && (
|
||||
<Box sx={{ py: 3 }}>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Отзывов пока нет
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
|
||||
Будьте первым, кто оставит отзыв на этот товар. Ваше мнение поможет улучшить качество наших изделий.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{reviewsQuery.data && reviewsQuery.data.items.length > 0 && (
|
||||
<Stack spacing={1.25}>
|
||||
{reviewsQuery.data.items.map((rv) => {
|
||||
const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
|
||||
return (
|
||||
<Paper key={rv.id} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
||||
<Stack spacing={0.75}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(rv.createdAt).toLocaleString('ru-RU')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Rating
|
||||
value={rv.rating}
|
||||
readOnly
|
||||
size="small"
|
||||
icon={<Star fontSize="inherit" />}
|
||||
emptyIcon={<Star fontSize="inherit" />}
|
||||
/>
|
||||
{body ? (
|
||||
<Box sx={{ color: 'text.secondary' }}>
|
||||
<RichTextMessageContent value={body} tone="review" />
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Без текстового комментария.
|
||||
</Typography>
|
||||
)}
|
||||
{rv.imageUrl && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 1.5,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={rv.imageUrl}
|
||||
alt="Фото к отзыву"
|
||||
widths={[320, 640]}
|
||||
sizes="140px"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
})}
|
||||
{reviewsQuery.data.total > reviewsQuery.data.items.length && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Всего {reviewsCountRu(reviewsQuery.data.total)} — ниже показаны последние{' '}
|
||||
{reviewsQuery.data.items.length}.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<ProductReviewsList productId={id} />
|
||||
</Box>
|
||||
|
||||
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { DELIVERY_CARRIERS as SHARED_DELIVERY_CARRIERS } from '@shared/constants/delivery-carrier'
|
||||
import {
|
||||
DELIVERY_CARRIERS as SHARED_DELIVERY_CARRIERS,
|
||||
DELIVERY_CARRIER_LABELS,
|
||||
deliveryCarrierLabelRu as sharedDeliveryCarrierLabelRu,
|
||||
} from '@shared/constants/delivery-carrier'
|
||||
|
||||
export const DELIVERY_CARRIER_CODES = SHARED_DELIVERY_CARRIERS as typeof SHARED_DELIVERY_CARRIERS
|
||||
|
||||
export type DeliveryCarrierCode = (typeof DELIVERY_CARRIER_CODES)[number]
|
||||
|
||||
export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = [
|
||||
{ code: 'RUSSIAN_POST', label: 'Почта России' },
|
||||
{ code: 'OZON_PVZ', label: 'Озон доставка (пункт выдачи)' },
|
||||
{ code: 'YANDEX_PVZ', label: 'Яндекс доставка (пункт выдачи)' },
|
||||
{ code: 'FIVE_POST', label: '5Post (пункт выдачи)' },
|
||||
]
|
||||
|
||||
const carrierLabelMap: Record<DeliveryCarrierCode, string> = Object.fromEntries(
|
||||
DELIVERY_CARRIER_OPTIONS.map((o) => [o.code, o.label]),
|
||||
) as Record<DeliveryCarrierCode, string>
|
||||
export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> =
|
||||
DELIVERY_CARRIER_CODES.map((code) => ({
|
||||
code,
|
||||
label: DELIVERY_CARRIER_LABELS[code],
|
||||
}))
|
||||
|
||||
export function deliveryCarrierLabelRu(code: string | null | undefined): string | null {
|
||||
if (!code) return null
|
||||
return carrierLabelMap[code as DeliveryCarrierCode] ?? code
|
||||
return sharedDeliveryCarrierLabelRu(code)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import { ORDER_STATUSES as SHARED_ORDER_STATUSES } from '@shared/constants/order-status'
|
||||
import {
|
||||
ORDER_STATUSES as SHARED_ORDER_STATUSES,
|
||||
getNextAdminStatuses as sharedGetNextAdminStatuses,
|
||||
} from '@shared/constants/order-status'
|
||||
|
||||
export const ORDER_STATUSES = SHARED_ORDER_STATUSES as typeof SHARED_ORDER_STATUSES
|
||||
|
||||
export type OrderStatus = (typeof ORDER_STATUSES)[number]
|
||||
|
||||
export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
return ['PENDING_PAYMENT', 'CANCELLED']
|
||||
case 'PENDING_PAYMENT':
|
||||
return ['PAID', 'CANCELLED']
|
||||
case 'PAID':
|
||||
return ['IN_PROGRESS', 'CANCELLED']
|
||||
case 'IN_PROGRESS':
|
||||
if (deliveryType === 'delivery') return ['SHIPPED', 'CANCELLED']
|
||||
return ['READY_FOR_PICKUP', 'CANCELLED']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
return sharedGetNextAdminStatuses(status, deliveryType) as OrderStatus[]
|
||||
}
|
||||
|
||||
export function canTransitionOrderStatus(from: string, to: string): boolean {
|
||||
|
||||
@@ -9,7 +9,7 @@ import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { fetchLatestApprovedReviews } from '@/entities/product/api/reviews-api'
|
||||
import { fetchLatestApprovedReviews } from '@/entities/review/api/reviews-api'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Refactoring Round 2 — Design
|
||||
|
||||
> 2026-05-19
|
||||
|
||||
## Phase 1: Toolchain & Configs
|
||||
|
||||
1. Enable `strict: true` in `client/tsconfig.app.json`
|
||||
2. Add ESLint + Prettier to server (copy rules from client, adapt for JS)
|
||||
3. Remove dead configs: `server/vitest.config.ts` (duplicate of `.js`)
|
||||
4. Fix AGENTS.md: `dev` uses `.env` not `.dev_env`; add `db:reset:test` to server
|
||||
5. Clean .gitignore: remove `.opencode/plans/` reference
|
||||
|
||||
## Phase 2: Deduplication & Separation
|
||||
|
||||
1. Move order status transition logic to `shared/constants/order.js` as shared data
|
||||
2. Split `entities/product/api/product-api.ts` into public + admin API files
|
||||
3. Consolidate review API into `entities/review/`
|
||||
4. Move delivery-carrier labels into `shared/constants/delivery-carrier.js`
|
||||
5. Add `db:reset:test` script to `server/package.json`
|
||||
|
||||
## Phase 3: Large File Decomposition
|
||||
|
||||
1. `AdminProductsPage.tsx` (596 lines) → `features/product-form/` + table page
|
||||
2. `AdminOrdersPage.tsx` (398 lines) → `features/order-detail/` + table page
|
||||
3. `AddressesPage.tsx` (318 lines) → `features/address-form/` + list page
|
||||
4. `ProductPage.tsx` (304 lines) → extend `widgets/reviews-block/`
|
||||
@@ -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",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write --ignore-unknown",
|
||||
"format:check": "prettier . --check --ignore-unknown",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"db:reset:test": "prisma migrate reset --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
@@ -24,6 +29,13 @@
|
||||
"sharp": "0.32.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prisma": "5.22.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ async function main() {
|
||||
where: { isResized: false },
|
||||
data: { isResized: true },
|
||||
})
|
||||
console.log(`Marked ${count} existing images as resized`)
|
||||
console.info(`Marked ${count} existing images as resized`)
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
+95
-105
@@ -1,110 +1,104 @@
|
||||
import "dotenv/config";
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import jwt from "@fastify/jwt";
|
||||
import multipart from "@fastify/multipart";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import path from "node:path";
|
||||
import { ensureAdminUser } from "./lib/bootstrap-admin.js";
|
||||
import { getOrCreateUnspecifiedCategory } from "./lib/default-category.js";
|
||||
import {
|
||||
getMaxUploadBodyBytes,
|
||||
getProductImageMaxFileBytes,
|
||||
} from "./lib/upload-limits.js";
|
||||
import { createEventBus } from "./lib/notifications/event-bus.js";
|
||||
import { createNotificationQueue } from "./lib/notifications/queue.js";
|
||||
import { prisma } from "./lib/prisma.js";
|
||||
import 'dotenv/config'
|
||||
import path from 'node:path'
|
||||
import cors from '@fastify/cors'
|
||||
import jwt from '@fastify/jwt'
|
||||
import multipart from '@fastify/multipart'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import Fastify from 'fastify'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
import { ensureAdminUser } from './lib/bootstrap-admin.js'
|
||||
import { getOrCreateUnspecifiedCategory } from './lib/default-category.js'
|
||||
import { createEventBus } from './lib/notifications/event-bus.js'
|
||||
import {
|
||||
resolveUserNotificationTargets,
|
||||
resolveAdminNotificationTargets,
|
||||
resolveAuthCodeTargets,
|
||||
} from "./lib/notifications/preferences.js";
|
||||
import {
|
||||
NOTIFICATION_EVENTS,
|
||||
NOTIFICATION_CHANNELS,
|
||||
} from "../../shared/constants/notification-events.js";
|
||||
import { registerAuth } from "./plugins/auth.js";
|
||||
import { registerApiRoutes } from "./routes/api.js";
|
||||
import { registerAuthRoutes } from "./routes/auth.js";
|
||||
import { registerUserAddressRoutes } from "./routes/user-addresses.js";
|
||||
import { registerUserCartRoutes } from "./routes/user-cart.js";
|
||||
import { registerUserMessageRoutes } from "./routes/user-messages.js";
|
||||
import { registerUserOrderRoutes } from "./routes/user-orders.js";
|
||||
import { registerUserPaymentRoutes } from "./routes/user-payments.js";
|
||||
import { registerUserNotificationRoutes } from "./routes/user/notifications.js";
|
||||
import { registerOAuthSocialRoutes } from "./routes/oauth-social.js";
|
||||
import { registerUploadsResized } from "./routes/uploads-resized.js";
|
||||
} from './lib/notifications/preferences.js'
|
||||
import { createNotificationQueue } from './lib/notifications/queue.js'
|
||||
import { prisma } from './lib/prisma.js'
|
||||
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
|
||||
import { registerAuth } from './plugins/auth.js'
|
||||
import { registerApiRoutes } from './routes/api.js'
|
||||
import { registerAuthRoutes } from './routes/auth.js'
|
||||
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
||||
import { registerUploadsResized } from './routes/uploads-resized.js'
|
||||
import { registerUserNotificationRoutes } from './routes/user/notifications.js'
|
||||
import { registerUserAddressRoutes } from './routes/user-addresses.js'
|
||||
import { registerUserCartRoutes } from './routes/user-cart.js'
|
||||
import { registerUserMessageRoutes } from './routes/user-messages.js'
|
||||
import { registerUserOrderRoutes } from './routes/user-orders.js'
|
||||
import { registerUserPaymentRoutes } from './routes/user-payments.js'
|
||||
|
||||
const port = Number(process.env.PORT) || 3333;
|
||||
const origin = (process.env.CORS_ORIGIN ?? "")
|
||||
.split(",")
|
||||
const port = Number(process.env.PORT) || 3333
|
||||
const origin = (process.env.CORS_ORIGIN ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
.filter(Boolean)
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: true,
|
||||
bodyLimit: getMaxUploadBodyBytes(),
|
||||
});
|
||||
})
|
||||
|
||||
await fastify.register(cors, {
|
||||
origin: origin.length ? origin : true,
|
||||
credentials: true,
|
||||
});
|
||||
})
|
||||
|
||||
await fastify.register(jwt, {
|
||||
secret: process.env.JWT_SECRET || "dev-jwt-secret-change-me",
|
||||
});
|
||||
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
|
||||
})
|
||||
|
||||
await fastify.register(multipart, {
|
||||
limits: {
|
||||
files: 10,
|
||||
fileSize: getProductImageMaxFileBytes(),
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
registerUploadsResized(fastify);
|
||||
registerUploadsResized(fastify)
|
||||
|
||||
const uploadsDir = path.join(process.cwd(), "uploads");
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||
await fastify.register(fastifyStatic, {
|
||||
root: uploadsDir,
|
||||
prefix: "/uploads/",
|
||||
prefix: '/uploads/',
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.includes("/.cache/")) {
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
if (filePath.includes('/.cache/')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
} else {
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
fastify.decorate("authenticate", async function authenticate(request, reply) {
|
||||
fastify.decorate('authenticate', async function authenticate(request, reply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: "Не авторизован" });
|
||||
return reply.code(401).send({ error: 'Не авторизован' })
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const eventBus = createEventBus();
|
||||
const notificationQueue = createNotificationQueue();
|
||||
fastify.decorate("eventBus", eventBus);
|
||||
fastify.decorate("notificationQueue", notificationQueue);
|
||||
const eventBus = createEventBus()
|
||||
const notificationQueue = createNotificationQueue()
|
||||
fastify.decorate('eventBus', eventBus)
|
||||
fastify.decorate('notificationQueue', notificationQueue)
|
||||
|
||||
registerAuth(fastify);
|
||||
await registerAuthRoutes(fastify);
|
||||
await registerUserAddressRoutes(fastify);
|
||||
await registerUserCartRoutes(fastify);
|
||||
await registerUserMessageRoutes(fastify);
|
||||
await registerUserOrderRoutes(fastify);
|
||||
await registerUserPaymentRoutes(fastify);
|
||||
await registerUserNotificationRoutes(fastify);
|
||||
await registerOAuthSocialRoutes(fastify);
|
||||
await registerApiRoutes(fastify);
|
||||
await ensureAdminUser();
|
||||
await getOrCreateUnspecifiedCategory();
|
||||
registerAuth(fastify)
|
||||
await registerAuthRoutes(fastify)
|
||||
await registerUserAddressRoutes(fastify)
|
||||
await registerUserCartRoutes(fastify)
|
||||
await registerUserMessageRoutes(fastify)
|
||||
await registerUserOrderRoutes(fastify)
|
||||
await registerUserPaymentRoutes(fastify)
|
||||
await registerUserNotificationRoutes(fastify)
|
||||
await registerOAuthSocialRoutes(fastify)
|
||||
await registerApiRoutes(fastify)
|
||||
await ensureAdminUser()
|
||||
await getOrCreateUnspecifiedCategory()
|
||||
|
||||
await notificationQueue.flushPendingOnStartup();
|
||||
notificationQueue.start();
|
||||
await notificationQueue.flushPendingOnStartup()
|
||||
notificationQueue.start()
|
||||
|
||||
const {
|
||||
ORDER_CREATED,
|
||||
@@ -114,11 +108,11 @@ const {
|
||||
PAYMENT_STATUS_CHANGED,
|
||||
AUTH_CODE_REQUESTED,
|
||||
DELIVERY_FEE_ADJUSTED,
|
||||
} = NOTIFICATION_EVENTS;
|
||||
} = NOTIFICATION_EVENTS
|
||||
|
||||
async function dispatchNotification(eventType, payload) {
|
||||
if (eventType === AUTH_CODE_REQUESTED) {
|
||||
const targets = await resolveAuthCodeTargets(eventType, payload);
|
||||
const targets = await resolveAuthCodeTargets(eventType, payload)
|
||||
for (const target of targets.filter((t) => t.channel === 'telegram')) {
|
||||
const log = await prisma.notificationLog.create({
|
||||
data: {
|
||||
@@ -127,66 +121,62 @@ async function dispatchNotification(eventType, payload) {
|
||||
status: 'pending',
|
||||
payload: JSON.stringify(payload),
|
||||
},
|
||||
});
|
||||
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id });
|
||||
})
|
||||
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const userTargets = await resolveUserNotificationTargets(eventType, payload);
|
||||
const userTargets = await resolveUserNotificationTargets(eventType, payload)
|
||||
for (const target of userTargets) {
|
||||
const log = await prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: payload.userId,
|
||||
eventType,
|
||||
channel: target.channel,
|
||||
status: "pending",
|
||||
status: 'pending',
|
||||
payload: JSON.stringify(payload),
|
||||
},
|
||||
});
|
||||
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id });
|
||||
})
|
||||
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
|
||||
}
|
||||
|
||||
const adminEventType =
|
||||
eventType === "order:created:admin" ? ORDER_CREATED : eventType;
|
||||
const adminTargets = await resolveAdminNotificationTargets(
|
||||
adminEventType,
|
||||
payload,
|
||||
);
|
||||
const adminEventType = eventType === 'order:created:admin' ? ORDER_CREATED : eventType
|
||||
const adminTargets = await resolveAdminNotificationTargets(adminEventType, payload)
|
||||
for (const target of adminTargets) {
|
||||
const log = await prisma.notificationLog.create({
|
||||
data: {
|
||||
eventType,
|
||||
channel: target.channel,
|
||||
status: "pending",
|
||||
status: 'pending',
|
||||
payload: JSON.stringify(payload),
|
||||
},
|
||||
});
|
||||
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id });
|
||||
})
|
||||
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.on(ORDER_CREATED, (payload) => dispatchNotification(ORDER_CREATED, payload));
|
||||
eventBus.on(ORDER_STATUS_CHANGED, (payload) => dispatchNotification(ORDER_STATUS_CHANGED, payload));
|
||||
eventBus.on(ORDER_MESSAGE_SENT, (payload) => dispatchNotification(ORDER_MESSAGE_SENT, payload));
|
||||
eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, (payload) => dispatchNotification(ORDER_MESSAGE_ADMIN_REPLY, payload));
|
||||
eventBus.on(PAYMENT_STATUS_CHANGED, (payload) => dispatchNotification(PAYMENT_STATUS_CHANGED, payload));
|
||||
eventBus.on(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, payload));
|
||||
eventBus.on("order:created:admin", (payload) => dispatchNotification("order:created:admin", payload));
|
||||
eventBus.on("review:created", (payload) => dispatchNotification("review:created", payload));
|
||||
eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload));
|
||||
eventBus.on(ORDER_CREATED, (payload) => dispatchNotification(ORDER_CREATED, payload))
|
||||
eventBus.on(ORDER_STATUS_CHANGED, (payload) => dispatchNotification(ORDER_STATUS_CHANGED, payload))
|
||||
eventBus.on(ORDER_MESSAGE_SENT, (payload) => dispatchNotification(ORDER_MESSAGE_SENT, payload))
|
||||
eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, (payload) => dispatchNotification(ORDER_MESSAGE_ADMIN_REPLY, payload))
|
||||
eventBus.on(PAYMENT_STATUS_CHANGED, (payload) => dispatchNotification(PAYMENT_STATUS_CHANGED, payload))
|
||||
eventBus.on(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, payload))
|
||||
eventBus.on('order:created:admin', (payload) => dispatchNotification('order:created:admin', payload))
|
||||
eventBus.on('review:created', (payload) => dispatchNotification('review:created', payload))
|
||||
eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload))
|
||||
|
||||
async function shutdown() {
|
||||
notificationQueue.stop();
|
||||
await fastify.close();
|
||||
process.exit(0);
|
||||
notificationQueue.stop()
|
||||
await fastify.close()
|
||||
process.exit(0)
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
|
||||
try {
|
||||
await fastify.listen({ port, host: "0.0.0.0" });
|
||||
await fastify.listen({ port, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
fastify.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ describe('escapeHtml', () => {
|
||||
})
|
||||
|
||||
it('escapes mixed content', () => {
|
||||
expect(escapeHtml('<script>alert("xss")</script>')).toBe(
|
||||
'<script>alert("xss")</script>',
|
||||
)
|
||||
expect(escapeHtml('<script>alert("xss")</script>')).toBe('<script>alert("xss")</script>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,7 +64,10 @@ describe('image-resize', () => {
|
||||
expect(result.path).toContain('.cache')
|
||||
expect(result.path).toContain('_w100.avif')
|
||||
|
||||
const exists = await fs.promises.access(result.path).then(() => true).catch(() => false)
|
||||
const exists = await fs.promises
|
||||
.access(result.path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(exists).toBe(true)
|
||||
|
||||
// Verify it's actually AVIF (sharp reports AVIF as 'heif' in metadata)
|
||||
@@ -114,7 +117,10 @@ describe('eager image processing', () => {
|
||||
for (const width of [320, 640, 1024, 1600]) {
|
||||
for (const format of ['avif', 'webp']) {
|
||||
const cachePath = path.join(cacheDir, `${uuid}_w${width}.${format}`)
|
||||
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
|
||||
const exists = await fs.promises
|
||||
.access(cachePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
@@ -145,10 +151,16 @@ describe('eager image processing', () => {
|
||||
const result = await convertOriginalToWebp(uuid, '')
|
||||
|
||||
expect(result).toBe(`/uploads/${uuid}.webp`)
|
||||
const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false)
|
||||
const pngExists = await fs.promises
|
||||
.access(testImagePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(pngExists).toBe(false)
|
||||
const webpPath = path.join(UPLOADS_DIR, `${uuid}.webp`)
|
||||
const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false)
|
||||
const webpExists = await fs.promises
|
||||
.access(webpPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(webpExists).toBe(true)
|
||||
|
||||
// Cleanup
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { persistMultipartImages } from '../upload-images.js'
|
||||
|
||||
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
|
||||
@@ -45,5 +45,4 @@ describe('persistMultipartImages with eager=false', () => {
|
||||
expect(urls).toHaveLength(1)
|
||||
expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { prisma } from './prisma.js'
|
||||
import { sendLoginCodeEmail } from './email.js'
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
export function normalizeEmail(email) {
|
||||
return String(email || '').trim().toLowerCase()
|
||||
return String(email || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function randomCode6() {
|
||||
@@ -31,7 +33,9 @@ export async function issueEmailCode({ email, purpose, userId = null }) {
|
||||
}
|
||||
|
||||
function parseEnvBool(raw) {
|
||||
const v = String(raw ?? '').trim().toLowerCase()
|
||||
const v = String(raw ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return v === 'true' || v === '1' || v === 'yes'
|
||||
}
|
||||
|
||||
@@ -68,5 +72,3 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) {
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -8,7 +8,7 @@ export async function ensureAdminUser() {
|
||||
throw new Error('ADMIN_EMAIL должен быть валидным email')
|
||||
}
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
await prisma.user.upsert({
|
||||
where: { email: adminEmail },
|
||||
update: {},
|
||||
create: { email: adminEmail },
|
||||
|
||||
@@ -18,7 +18,7 @@ function createTransporter() {
|
||||
|
||||
export async function sendLoginCodeEmail({ to, code }) {
|
||||
if (!hasSmtpEnv()) {
|
||||
console.log(`[DEV] login code for ${to}: ${code}`)
|
||||
console.info(`[DEV] login code for ${to}: ${code}`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function sendLoginCodeEmail({ to, code }) {
|
||||
|
||||
export async function sendNotificationEmail({ to, subject, html }) {
|
||||
if (!hasSmtpEnv()) {
|
||||
console.log(`[DEV] notification email to ${to}: ${subject}`)
|
||||
console.info(`[DEV] notification email to ${to}: ${subject}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('preferences', () => {
|
||||
})
|
||||
|
||||
it('returns admin targets when settings enabled', async () => {
|
||||
const admin = await prisma.user.create({ data: { email: 'admin@test.com' } })
|
||||
await prisma.user.create({ data: { email: 'admin@test.com' } })
|
||||
const origAdminEmail = process.env.ADMIN_EMAIL
|
||||
process.env.ADMIN_EMAIL = 'admin@test.com'
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const templateRenderers = {
|
||||
|
||||
async function postToTelegram(chatId, text) {
|
||||
if (!TELEGRAM_BOT_TOKEN) {
|
||||
console.log(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`)
|
||||
console.info(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "../prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
|
||||
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
|
||||
import { prisma } from '../prisma.js'
|
||||
|
||||
const {
|
||||
ORDER_CREATED,
|
||||
@@ -7,105 +7,99 @@ const {
|
||||
ORDER_MESSAGE_SENT,
|
||||
ORDER_MESSAGE_ADMIN_REPLY,
|
||||
PAYMENT_STATUS_CHANGED,
|
||||
AUTH_CODE_REQUESTED,
|
||||
DELIVERY_FEE_ADJUSTED,
|
||||
} = NOTIFICATION_EVENTS;
|
||||
} = NOTIFICATION_EVENTS
|
||||
|
||||
const userEventFieldMap = {
|
||||
[ORDER_CREATED]: "orderCreated",
|
||||
[ORDER_STATUS_CHANGED]: "orderStatusChanged",
|
||||
[ORDER_MESSAGE_ADMIN_REPLY]: "orderMessageReceived",
|
||||
[PAYMENT_STATUS_CHANGED]: "paymentStatusChanged",
|
||||
[DELIVERY_FEE_ADJUSTED]: "deliveryFeeAdjusted",
|
||||
};
|
||||
[ORDER_CREATED]: 'orderCreated',
|
||||
[ORDER_STATUS_CHANGED]: 'orderStatusChanged',
|
||||
[ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived',
|
||||
[PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged',
|
||||
[DELIVERY_FEE_ADJUSTED]: 'deliveryFeeAdjusted',
|
||||
}
|
||||
|
||||
const adminEventFieldMap = {
|
||||
[ORDER_MESSAGE_SENT]: "newOrderMessage",
|
||||
"review:created": "newReview",
|
||||
};
|
||||
[ORDER_MESSAGE_SENT]: 'newOrderMessage',
|
||||
'review:created': 'newReview',
|
||||
}
|
||||
|
||||
export async function resolveUserNotificationTargets(eventType, payload) {
|
||||
const targets = [];
|
||||
const targets = []
|
||||
|
||||
if (payload.userId) {
|
||||
const prefs = await prisma.notificationPreference.findUnique({
|
||||
where: { userId: payload.userId },
|
||||
});
|
||||
})
|
||||
|
||||
if (prefs && prefs.globalEnabled) {
|
||||
const field = userEventFieldMap[eventType];
|
||||
const field = userEventFieldMap[eventType]
|
||||
if (field && prefs[field]) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
select: { email: true },
|
||||
});
|
||||
})
|
||||
if (user) {
|
||||
targets.push({ channel: "email", recipient: user.email });
|
||||
targets.push({ channel: 'email', recipient: user.email })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
return targets
|
||||
}
|
||||
|
||||
export async function resolveAdminNotificationTargets(eventType, payload) {
|
||||
const targets = [];
|
||||
const settings = await prisma.adminNotificationSettings.findFirst();
|
||||
if (!settings) return targets;
|
||||
const targets = []
|
||||
const settings = await prisma.adminNotificationSettings.findFirst()
|
||||
if (!settings) return targets
|
||||
|
||||
const field = adminEventFieldMap[eventType];
|
||||
if (field === "newReview") {
|
||||
if (!settings.newReview) return targets;
|
||||
const field = adminEventFieldMap[eventType]
|
||||
if (field === 'newReview') {
|
||||
if (!settings.newReview) return targets
|
||||
} else if (field && !settings[field]) {
|
||||
return targets;
|
||||
return targets
|
||||
}
|
||||
|
||||
if (settings.emailEnabled) {
|
||||
const admin = await prisma.user.findFirst({
|
||||
where: { email: process.env.ADMIN_EMAIL },
|
||||
select: { email: true },
|
||||
});
|
||||
})
|
||||
if (admin) {
|
||||
targets.push({ channel: "email", recipient: admin.email });
|
||||
targets.push({ channel: 'email', recipient: admin.email })
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.telegramEnabled && settings.telegramChatId) {
|
||||
targets.push({ channel: "telegram", recipient: settings.telegramChatId });
|
||||
targets.push({ channel: 'telegram', recipient: settings.telegramChatId })
|
||||
}
|
||||
|
||||
return targets;
|
||||
return targets
|
||||
}
|
||||
|
||||
export async function resolveAuthCodeTargets(eventType, payload) {
|
||||
const targets = [];
|
||||
const targets = []
|
||||
|
||||
if (payload.email) {
|
||||
targets.push({ channel: "email", recipient: payload.email });
|
||||
targets.push({ channel: 'email', recipient: payload.email })
|
||||
}
|
||||
|
||||
if (payload.isAdmin) {
|
||||
const settings = await prisma.adminNotificationSettings.findFirst();
|
||||
if (
|
||||
settings &&
|
||||
settings.telegramEnabled &&
|
||||
settings.telegramChatId &&
|
||||
settings.authCodeDuplicate
|
||||
) {
|
||||
targets.push({ channel: "telegram", recipient: settings.telegramChatId });
|
||||
const settings = await prisma.adminNotificationSettings.findFirst()
|
||||
if (settings && settings.telegramEnabled && settings.telegramChatId && settings.authCodeDuplicate) {
|
||||
targets.push({ channel: 'telegram', recipient: settings.telegramChatId })
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
return targets
|
||||
}
|
||||
|
||||
export async function ensureUserNotificationPreference(userId) {
|
||||
const existing = await prisma.notificationPreference.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
if (existing) return existing;
|
||||
})
|
||||
if (existing) return existing
|
||||
return prisma.notificationPreference.create({
|
||||
data: { userId, globalEnabled: true },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
NOTIFICATION_STATUSES,
|
||||
MAX_RETRY_ATTEMPTS,
|
||||
RETRY_DELAYS_MS,
|
||||
} from '../../../../shared/constants/notification-events.js'
|
||||
import { prisma } from '../prisma.js'
|
||||
import { NOTIFICATION_STATUSES, MAX_RETRY_ATTEMPTS, RETRY_DELAYS_MS } from '../../../../shared/constants/notification-events.js'
|
||||
import { emailChannel } from './channels/email-channel.js'
|
||||
import { telegramChannel } from './channels/telegram-channel.js'
|
||||
|
||||
@@ -120,7 +124,7 @@ class NotificationQueue {
|
||||
})
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
console.log(`[notifications] Marked ${pending.length} pending notifications as failed on startup`)
|
||||
console.info(`[notifications] Marked ${pending.length} pending notifications as failed on startup`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,132 +11,113 @@ function baseLayout(title, body) {
|
||||
<p>Любимый Креатив — магазин handmade изделий</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
</html>`
|
||||
}
|
||||
|
||||
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount, deliveryType }) {
|
||||
const total = (totalCents / 100).toLocaleString("ru-RU");
|
||||
const nextAction = deliveryType === "delivery"
|
||||
? "Оплата будет доступна после уточнения стоимости доставки."
|
||||
: "Ожидает оплаты.";
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
const nextAction =
|
||||
deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.'
|
||||
const body = `
|
||||
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
|
||||
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
|
||||
<p>${nextAction}</p>
|
||||
`;
|
||||
return { subject: "Заказ создан", html: baseLayout("Заказ создан", body) };
|
||||
`
|
||||
return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) }
|
||||
}
|
||||
|
||||
export function renderOrderStatusChangedEmail({
|
||||
orderId,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
}) {
|
||||
export function renderOrderStatusChangedEmail({ orderId, oldStatus, newStatus }) {
|
||||
const statusLabels = {
|
||||
DRAFT: "Черновик",
|
||||
PENDING_PAYMENT: "Ожидает оплаты",
|
||||
PAID: "Оплачен",
|
||||
IN_PROGRESS: "В работе",
|
||||
READY_FOR_PICKUP: "Готов к выдаче",
|
||||
SHIPPED: "Отправлен",
|
||||
DONE: "Выполнен",
|
||||
CANCELLED: "Отменён",
|
||||
};
|
||||
const oldLabel = statusLabels[oldStatus] || oldStatus;
|
||||
const newLabel = statusLabels[newStatus] || newStatus;
|
||||
DRAFT: 'Черновик',
|
||||
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||
PAID: 'Оплачен',
|
||||
IN_PROGRESS: 'В работе',
|
||||
READY_FOR_PICKUP: 'Готов к выдаче',
|
||||
SHIPPED: 'Отправлен',
|
||||
DONE: 'Выполнен',
|
||||
CANCELLED: 'Отменён',
|
||||
}
|
||||
const oldLabel = statusLabels[oldStatus] || oldStatus
|
||||
const newLabel = statusLabels[newStatus] || newStatus
|
||||
const body = `
|
||||
<p>Статус заказа <b>#${orderId.slice(0, 8)}</b> изменён.</p>
|
||||
<p><b>${oldLabel}</b> → <b>${newLabel}</b></p>
|
||||
`;
|
||||
`
|
||||
return {
|
||||
subject: `Статус заказа изменён — ${newLabel}`,
|
||||
html: baseLayout("Статус заказа изменён", body),
|
||||
};
|
||||
html: baseLayout('Статус заказа изменён', body),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderOrderMessageEmail({ orderId, preview }) {
|
||||
const truncated =
|
||||
preview.length > 200 ? preview.slice(0, 197) + "..." : preview;
|
||||
const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview
|
||||
const body = `
|
||||
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
|
||||
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
|
||||
${truncated}
|
||||
</div>
|
||||
<p>Ответьте в личном кабинете.</p>
|
||||
`;
|
||||
`
|
||||
return {
|
||||
subject: "Новое сообщение к заказу",
|
||||
html: baseLayout("Новое сообщение", body),
|
||||
};
|
||||
subject: 'Новое сообщение к заказу',
|
||||
html: baseLayout('Новое сообщение', body),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) {
|
||||
const statusLabels = {
|
||||
pending: "Ожидает",
|
||||
confirmed: "Подтверждён",
|
||||
rejected: "Отклонён",
|
||||
};
|
||||
const label = statusLabels[paymentStatus] || paymentStatus;
|
||||
pending: 'Ожидает',
|
||||
confirmed: 'Подтверждён',
|
||||
rejected: 'Отклонён',
|
||||
}
|
||||
const label = statusLabels[paymentStatus] || paymentStatus
|
||||
const body = `
|
||||
<p>Статус оплаты заказа <b>#${orderId.slice(0, 8)}</b>: <b>${label}</b>.</p>
|
||||
`;
|
||||
`
|
||||
return {
|
||||
subject: `Оплата заказа — ${label}`,
|
||||
html: baseLayout("Оплата заказа", body),
|
||||
};
|
||||
html: baseLayout('Оплата заказа', body),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderAdminOrderCreatedEmail({
|
||||
orderId,
|
||||
userEmail,
|
||||
totalCents,
|
||||
itemsCount,
|
||||
deliveryType,
|
||||
}) {
|
||||
const total = (totalCents / 100).toLocaleString("ru-RU");
|
||||
const note = deliveryType === "delivery"
|
||||
? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>'
|
||||
: "";
|
||||
export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount, deliveryType }) {
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
const note = deliveryType === 'delivery' ? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>' : ''
|
||||
const body = `
|
||||
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
|
||||
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
|
||||
${note}
|
||||
`;
|
||||
return { subject: "Новый заказ", html: baseLayout("Новый заказ", body) };
|
||||
`
|
||||
return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) }
|
||||
}
|
||||
|
||||
export function renderAdminNewReviewEmail({
|
||||
rating,
|
||||
text,
|
||||
productTitle,
|
||||
userName,
|
||||
}) {
|
||||
const stars = "★".repeat(rating) + "☆".repeat(5 - rating);
|
||||
export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) {
|
||||
const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
|
||||
const body = `
|
||||
<p>Новый отзыв ${stars} на товар <b>${productTitle}</b> от <b>${userName}</b>.</p>
|
||||
${text ? `<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">${text}</div>` : ""}
|
||||
${text ? `<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">${text}</div>` : ''}
|
||||
<p>Проверьте отзыв в админ-панели.</p>
|
||||
`;
|
||||
return { subject: "Новый отзыв", html: baseLayout("Новый отзыв", body) };
|
||||
`
|
||||
return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) }
|
||||
}
|
||||
|
||||
export function renderAuthCodeEmail({ code }) {
|
||||
const body = `
|
||||
<p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p>
|
||||
<p>Если это были не вы — просто проигнорируйте письмо.</p>
|
||||
`;
|
||||
return { subject: "Код входа", html: baseLayout("Код входа", body) };
|
||||
`
|
||||
return { subject: 'Код входа', html: baseLayout('Код входа', body) }
|
||||
}
|
||||
|
||||
export function renderDeliveryFeeAdjustedEmail({ orderId, totalCents }) {
|
||||
const total = (totalCents / 100).toLocaleString("ru-RU");
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
const body = `
|
||||
<p>Стоимость доставки заказа <b>#${orderId.slice(0, 8)}</b> скорректирована.</p>
|
||||
<p>Новая сумма: <b>${total} ₽</b></p>
|
||||
<p>Ожидает оплаты. Проверьте статус заказа в личном кабинете.</p>
|
||||
`;
|
||||
`
|
||||
return {
|
||||
subject: "Стоимость доставки скорректирована",
|
||||
html: baseLayout("Стоимость доставки скорректирована", body),
|
||||
};
|
||||
subject: 'Стоимость доставки скорректирована',
|
||||
html: baseLayout('Стоимость доставки скорректирована', body),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
export function renderOrderCreatedTg({ orderId, totalCents, itemsCount, deliveryType }) {
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
const nextAction = deliveryType === 'delivery'
|
||||
? 'Оплата будет доступна после уточнения стоимости доставки.'
|
||||
: 'Ожидает оплаты.'
|
||||
const nextAction =
|
||||
deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.'
|
||||
return `📦 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total} ₽\n${nextAction}`
|
||||
}
|
||||
|
||||
export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
|
||||
const labels = {
|
||||
DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', PAID: 'Оплачен', IN_PROGRESS: 'В работе',
|
||||
READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён',
|
||||
DRAFT: 'Черновик',
|
||||
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||
PAID: 'Оплачен',
|
||||
IN_PROGRESS: 'В работе',
|
||||
READY_FOR_PICKUP: 'Готов к выдаче',
|
||||
SHIPPED: 'Отправлен',
|
||||
DONE: 'Выполнен',
|
||||
CANCELLED: 'Отменён',
|
||||
}
|
||||
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
|
||||
}
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
export { ORDER_STATUSES } from '../../../shared/constants/order-status.js'
|
||||
|
||||
/**
|
||||
* Переходы, которые делает админ через PATCH /api/admin/orders/:id/status
|
||||
* (подтверждение получения пользователем — отдельный эндпоинт).
|
||||
*/
|
||||
export function canTransitionAdminOrderStatus(order, next) {
|
||||
const from = order.status
|
||||
const dt = order.deliveryType
|
||||
if (from === next) return true
|
||||
|
||||
switch (from) {
|
||||
case 'DRAFT':
|
||||
return next === 'PENDING_PAYMENT' || next === 'CANCELLED'
|
||||
case 'PENDING_PAYMENT':
|
||||
return next === 'PAID' || next === 'CANCELLED'
|
||||
case 'PAID':
|
||||
return next === 'IN_PROGRESS' || next === 'CANCELLED'
|
||||
case 'IN_PROGRESS':
|
||||
if (next === 'CANCELLED') return true
|
||||
if (dt === 'delivery') return next === 'SHIPPED'
|
||||
if (dt === 'pickup') return next === 'READY_FOR_PICKUP'
|
||||
return false
|
||||
case 'SHIPPED':
|
||||
case 'READY_FOR_PICKUP':
|
||||
case 'DONE':
|
||||
case 'CANCELLED':
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated используйте canTransitionAdminOrderStatus */
|
||||
export function canTransitionOrderStatus(from, to) {
|
||||
return canTransitionAdminOrderStatus({ status: from, deliveryType: 'delivery' }, to)
|
||||
}
|
||||
export {
|
||||
ORDER_STATUSES,
|
||||
getNextAdminStatuses,
|
||||
canTransitionAdminOrderStatus,
|
||||
} from '../../../shared/constants/order-status.js'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export function registerAuth(fastify) {
|
||||
function normalizeEmail(email) {
|
||||
return String(email || '').trim().toLowerCase()
|
||||
return String(email || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
mapProductForApi,
|
||||
parseMaterialsInput,
|
||||
slugify,
|
||||
} from './api/_product-helpers.js'
|
||||
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
||||
import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js'
|
||||
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
||||
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
||||
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
|
||||
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
||||
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
||||
import { registerAdminProductRoutes } from './api/admin-products.js'
|
||||
import { registerAdminReviewRoutes } from './api/admin-reviews.js'
|
||||
import { registerAdminUserRoutes } from './api/admin-users.js'
|
||||
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
||||
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
|
||||
import { registerInfoPageRoutes } from './api/info-page.js'
|
||||
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
|
||||
import { registerPublicReviewRoutes } from './api/public-reviews.js'
|
||||
@@ -33,4 +29,3 @@ export async function registerApiRoutes(fastify) {
|
||||
await registerAdminUserRoutes(fastify)
|
||||
await registerAdminNotificationRoutes(fastify)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
|
||||
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
|
||||
|
||||
@@ -36,7 +36,10 @@ describe('Admin gallery resize integration', () => {
|
||||
expect(newUrl).toBe(`/uploads/${testUuid}.webp`)
|
||||
|
||||
// Verify original PNG is deleted
|
||||
const pngExists = await fs.promises.access(testOriginalPath).then(() => true).catch(() => false)
|
||||
const pngExists = await fs.promises
|
||||
.access(testOriginalPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(pngExists).toBe(false)
|
||||
|
||||
// Verify cached files exist
|
||||
@@ -44,13 +47,19 @@ describe('Admin gallery resize integration', () => {
|
||||
for (const width of [320, 640, 1024, 1600]) {
|
||||
for (const format of ['avif', 'webp']) {
|
||||
const cachePath = path.join(cacheDir, `${testUuid}_w${width}.${format}`)
|
||||
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
|
||||
const exists = await fs.promises
|
||||
.access(cachePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify webp original exists
|
||||
const webpExists = await fs.promises.access(path.join(UPLOADS_DIR, `${testUuid}.webp`)).then(() => true).catch(() => false)
|
||||
const webpExists = await fs.promises
|
||||
.access(path.join(UPLOADS_DIR, `${testUuid}.webp`))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(webpExists).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,4 +53,3 @@ export function mapProductForApi(p, reviewsSummary = null) {
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
|
||||
@@ -6,132 +6,116 @@ import {
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function registerAdminCategoryRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/admin/categories',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
const items = await prisma.category.findMany({
|
||||
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async () => {
|
||||
const items = await prisma.category.findMany({
|
||||
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/categories',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const body = request.body ?? {}
|
||||
const name = String(body.name ?? '').trim()
|
||||
if (!name) {
|
||||
reply.code(400).send({ error: 'Укажите название категории' })
|
||||
return
|
||||
}
|
||||
const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}`
|
||||
if (isUnspecifiedCategorySlug(slug)) {
|
||||
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
|
||||
return
|
||||
}
|
||||
const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined
|
||||
const exists = await prisma.category.findUnique({ where: { slug } })
|
||||
if (exists) {
|
||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||
return
|
||||
}
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
sort: Number.isFinite(sort) ? Math.round(sort) : 0,
|
||||
},
|
||||
})
|
||||
reply.code(201).send(category)
|
||||
},
|
||||
)
|
||||
fastify.post('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const body = request.body ?? {}
|
||||
const name = String(body.name ?? '').trim()
|
||||
if (!name) {
|
||||
reply.code(400).send({ error: 'Укажите название категории' })
|
||||
return
|
||||
}
|
||||
const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}`
|
||||
if (isUnspecifiedCategorySlug(slug)) {
|
||||
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
|
||||
return
|
||||
}
|
||||
const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined
|
||||
const exists = await prisma.category.findUnique({ where: { slug } })
|
||||
if (exists) {
|
||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||
return
|
||||
}
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
sort: Number.isFinite(sort) ? Math.round(sort) : 0,
|
||||
},
|
||||
})
|
||||
reply.code(201).send(category)
|
||||
})
|
||||
|
||||
fastify.patch(
|
||||
'/api/admin/categories/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const body = request.body ?? {}
|
||||
const existing = await prisma.category.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'Категория не найдена' })
|
||||
fastify.patch('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const body = request.body ?? {}
|
||||
const existing = await prisma.category.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'Категория не найдена' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = {}
|
||||
if (body.name !== undefined) data.name = String(body.name ?? '').trim()
|
||||
if (body.sort !== undefined) {
|
||||
const s = Number(body.sort)
|
||||
if (!Number.isFinite(s)) {
|
||||
reply.code(400).send({ error: 'Некорректный sort' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = {}
|
||||
if (body.name !== undefined) data.name = String(body.name ?? '').trim()
|
||||
if (body.sort !== undefined) {
|
||||
const s = Number(body.sort)
|
||||
if (!Number.isFinite(s)) {
|
||||
reply.code(400).send({ error: 'Некорректный sort' })
|
||||
data.sort = Math.round(s)
|
||||
}
|
||||
if (body.slug !== undefined) {
|
||||
const s = String(body.slug ?? '').trim()
|
||||
if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) {
|
||||
reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' })
|
||||
return
|
||||
}
|
||||
if (!s) {
|
||||
reply.code(400).send({ error: 'Slug не может быть пустым' })
|
||||
return
|
||||
}
|
||||
if (s !== existing.slug) {
|
||||
if (isUnspecifiedCategorySlug(s)) {
|
||||
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
|
||||
return
|
||||
}
|
||||
data.sort = Math.round(s)
|
||||
}
|
||||
if (body.slug !== undefined) {
|
||||
const s = String(body.slug ?? '').trim()
|
||||
if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) {
|
||||
reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' })
|
||||
const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } })
|
||||
if (clash) {
|
||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||
return
|
||||
}
|
||||
if (!s) {
|
||||
reply.code(400).send({ error: 'Slug не может быть пустым' })
|
||||
return
|
||||
}
|
||||
if (s !== existing.slug) {
|
||||
if (isUnspecifiedCategorySlug(s)) {
|
||||
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
|
||||
return
|
||||
}
|
||||
const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } })
|
||||
if (clash) {
|
||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||
return
|
||||
}
|
||||
}
|
||||
data.slug = s
|
||||
}
|
||||
data.slug = s
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return existing
|
||||
}
|
||||
if (data.name !== undefined && !data.name) {
|
||||
reply.code(400).send({ error: 'Укажите название' })
|
||||
return
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return existing
|
||||
}
|
||||
if (data.name !== undefined && !data.name) {
|
||||
reply.code(400).send({ error: 'Укажите название' })
|
||||
return
|
||||
}
|
||||
|
||||
const updated = await prisma.category.update({ where: { id }, data })
|
||||
return updated
|
||||
},
|
||||
)
|
||||
const updated = await prisma.category.update({ where: { id }, data })
|
||||
return updated
|
||||
})
|
||||
|
||||
fastify.delete(
|
||||
'/api/admin/categories/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const existing = await prisma.category.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'Категория не найдена' })
|
||||
return
|
||||
}
|
||||
if (isUnspecifiedCategorySlug(existing.slug)) {
|
||||
reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' })
|
||||
return
|
||||
}
|
||||
fastify.delete('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const existing = await prisma.category.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'Категория не найдена' })
|
||||
return
|
||||
}
|
||||
if (isUnspecifiedCategorySlug(existing.slug)) {
|
||||
reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' })
|
||||
return
|
||||
}
|
||||
|
||||
const fallback = await getOrCreateUnspecifiedCategory()
|
||||
await prisma.$transaction([
|
||||
prisma.product.updateMany({
|
||||
where: { categoryId: id },
|
||||
data: { categoryId: fallback.id },
|
||||
}),
|
||||
prisma.category.delete({ where: { id } }),
|
||||
])
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
const fallback = await getOrCreateUnspecifiedCategory()
|
||||
await prisma.$transaction([
|
||||
prisma.product.updateMany({
|
||||
where: { categoryId: id },
|
||||
data: { categoryId: fallback.id },
|
||||
}),
|
||||
prisma.category.delete({ where: { id } }),
|
||||
])
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,114 +9,98 @@ import {
|
||||
} from '../../lib/upload-limits.js'
|
||||
|
||||
export async function registerAdminGalleryRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/admin/gallery',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
const items = await prisma.galleryImage.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
fastify.get('/api/admin/gallery', { preHandler: [fastify.verifyAdmin] }, async () => {
|
||||
const items = await prisma.galleryImage.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.post('/api/admin/gallery/upload', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
try {
|
||||
const urls = await persistMultipartImages(request, {
|
||||
maxFiles: 10,
|
||||
maxFileBytes: getProductImageMaxFileBytes(),
|
||||
subdir: '',
|
||||
eager: false,
|
||||
})
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/gallery/upload',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const urls = await persistMultipartImages(request, {
|
||||
maxFiles: 10,
|
||||
maxFileBytes: getProductImageMaxFileBytes(),
|
||||
subdir: '',
|
||||
eager: false,
|
||||
for (const url of urls) {
|
||||
await prisma.galleryImage.create({
|
||||
data: { url, isResized: false },
|
||||
})
|
||||
for (const url of urls) {
|
||||
await prisma.galleryImage.create({
|
||||
data: { url, isResized: false },
|
||||
})
|
||||
}
|
||||
return { urls }
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
|
||||
let statusCode =
|
||||
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||
? Number(error.statusCode)
|
||||
: 400
|
||||
if (isMultipartFileTooLargeError(error)) {
|
||||
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
|
||||
statusCode = 413
|
||||
}
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/gallery/:id/resize',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const row = await prisma.galleryImage.findUnique({ where: { id } })
|
||||
if (!row) {
|
||||
return reply.code(404).send({ error: 'Изображение не найдено' })
|
||||
return { urls }
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
|
||||
let statusCode =
|
||||
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||
? Number(error.statusCode)
|
||||
: 400
|
||||
if (isMultipartFileTooLargeError(error)) {
|
||||
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
|
||||
statusCode = 413
|
||||
}
|
||||
if (row.isResized) {
|
||||
return reply.code(409).send({ error: 'Изображение уже обработано' })
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
fastify.post('/api/admin/gallery/:id/resize', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const row = await prisma.galleryImage.findUnique({ where: { id } })
|
||||
if (!row) {
|
||||
return reply.code(404).send({ error: 'Изображение не найдено' })
|
||||
}
|
||||
if (row.isResized) {
|
||||
return reply.code(409).send({ error: 'Изображение уже обработано' })
|
||||
}
|
||||
|
||||
const urlParts = row.url.replace(/^\//, '').split('/')
|
||||
const fileName = urlParts[urlParts.length - 1]
|
||||
const uuid = path.parse(fileName).name
|
||||
|
||||
try {
|
||||
const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')
|
||||
|
||||
const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
|
||||
await generateAllSizes(uuid, '', fullPath)
|
||||
const newUrl = await convertOriginalToWebp(uuid, '')
|
||||
|
||||
await prisma.galleryImage.update({
|
||||
where: { id },
|
||||
data: { url: newUrl, isResized: true },
|
||||
})
|
||||
|
||||
return { url: newUrl }
|
||||
} catch (error) {
|
||||
request.log.error(error, 'Resize failed')
|
||||
return reply.code(500).send({ error: 'Ошибка обработки изображения' })
|
||||
}
|
||||
})
|
||||
|
||||
fastify.delete('/api/admin/gallery/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const row = await prisma.galleryImage.findUnique({ where: { id } })
|
||||
if (!row) {
|
||||
return reply.code(404).send({ error: 'Не найдено' })
|
||||
}
|
||||
|
||||
const usedInImages = await prisma.productImage.count({ where: { url: row.url } })
|
||||
const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } })
|
||||
if (usedInImages > 0 || usedAsLegacy > 0) {
|
||||
return reply.code(409).send({ error: 'Изображение используется в карточке товара' })
|
||||
}
|
||||
|
||||
const relative = row.url.replace(/^\//, '')
|
||||
const filePath = path.join(process.cwd(), relative)
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const urlParts = row.url.replace(/^\//, '').split('/')
|
||||
const fileName = urlParts[urlParts.length - 1]
|
||||
const uuid = path.parse(fileName).name
|
||||
|
||||
try {
|
||||
const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')
|
||||
|
||||
const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
|
||||
await generateAllSizes(uuid, '', fullPath)
|
||||
const newUrl = await convertOriginalToWebp(uuid, '')
|
||||
|
||||
await prisma.galleryImage.update({
|
||||
where: { id },
|
||||
data: { url: newUrl, isResized: true },
|
||||
})
|
||||
|
||||
return { url: newUrl }
|
||||
} catch (error) {
|
||||
request.log.error(error, 'Resize failed')
|
||||
return reply.code(500).send({ error: 'Ошибка обработки изображения' })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fastify.delete(
|
||||
'/api/admin/gallery/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const row = await prisma.galleryImage.findUnique({ where: { id } })
|
||||
if (!row) {
|
||||
return reply.code(404).send({ error: 'Не найдено' })
|
||||
}
|
||||
|
||||
const usedInImages = await prisma.productImage.count({ where: { url: row.url } })
|
||||
const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } })
|
||||
if (usedInImages > 0 || usedAsLegacy > 0) {
|
||||
return reply.code(409).send({ error: 'Изображение используется в карточке товара' })
|
||||
}
|
||||
|
||||
const relative = row.url.replace(/^\//, '')
|
||||
const filePath = path.join(process.cwd(), relative)
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.galleryImage.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
await prisma.galleryImage.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,236 +1,172 @@
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
import { canTransitionAdminOrderStatus } from "../../lib/order-status.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
|
||||
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
|
||||
import { canTransitionAdminOrderStatus } from '../../lib/order-status.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function registerAdminOrderRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/api/admin/orders/summary",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
const attentionCount = await prisma.order.count({
|
||||
where: {
|
||||
status: "PENDING_PAYMENT",
|
||||
},
|
||||
});
|
||||
return { attentionCount };
|
||||
},
|
||||
);
|
||||
fastify.get('/api/admin/orders/summary', { preHandler: [fastify.verifyAdmin] }, async () => {
|
||||
const attentionCount = await prisma.order.count({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
},
|
||||
})
|
||||
return { attentionCount }
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
"/api/admin/orders",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const status =
|
||||
typeof request.query?.status === "string"
|
||||
? request.query.status.trim()
|
||||
: "";
|
||||
const q =
|
||||
typeof request.query?.q === "string" ? request.query.q.trim() : "";
|
||||
const deliveryTypeRaw = request.query?.deliveryType;
|
||||
const deliveryType =
|
||||
typeof deliveryTypeRaw === "string" ? deliveryTypeRaw.trim() : "";
|
||||
fastify.get('/api/admin/orders', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : ''
|
||||
const q = typeof request.query?.q === 'string' ? request.query.q.trim() : ''
|
||||
const deliveryTypeRaw = request.query?.deliveryType
|
||||
const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.trim() : ''
|
||||
|
||||
const pageRaw = request.query?.page;
|
||||
const pageParsed =
|
||||
typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw);
|
||||
const page =
|
||||
Number.isFinite(pageParsed) && pageParsed > 0
|
||||
? Math.floor(pageParsed)
|
||||
: 1;
|
||||
const pageRaw = request.query?.page
|
||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||
|
||||
const pageSizeRaw = request.query?.pageSize;
|
||||
const pageSizeParsed =
|
||||
typeof pageSizeRaw === "string"
|
||||
? Number(pageSizeRaw)
|
||||
: Number(pageSizeRaw);
|
||||
const pageSize =
|
||||
Number.isFinite(pageSizeParsed) && pageSizeParsed > 0
|
||||
? Math.floor(pageSizeParsed)
|
||||
: 20;
|
||||
if (pageSize > 100)
|
||||
return reply.code(400).send({ error: "pageSize должен быть ≤ 100" });
|
||||
const pageSizeRaw = request.query?.pageSize
|
||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||
|
||||
const where = {};
|
||||
if (status) where.status = status;
|
||||
if (deliveryType) {
|
||||
if (deliveryType !== "delivery" && deliveryType !== "pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "deliveryType должен быть delivery | pickup" });
|
||||
}
|
||||
where.deliveryType = deliveryType;
|
||||
}
|
||||
if (q) {
|
||||
where.OR = [
|
||||
{ id: { contains: q } },
|
||||
{ user: { email: { contains: q } } },
|
||||
];
|
||||
const where = {}
|
||||
if (status) where.status = status
|
||||
if (deliveryType) {
|
||||
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
||||
}
|
||||
where.deliveryType = deliveryType
|
||||
}
|
||||
if (q) {
|
||||
where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }]
|
||||
}
|
||||
|
||||
const total = await prisma.order.count({ where });
|
||||
const items = await prisma.order.findMany({
|
||||
where,
|
||||
include: { user: { select: { id: true, email: true } }, items: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
const total = await prisma.order.count({ where })
|
||||
const items = await prisma.order.findMany({
|
||||
where,
|
||||
include: { user: { select: { id: true, email: true } }, items: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map((o) => ({
|
||||
id: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
deliveryCarrier: o.deliveryCarrier,
|
||||
paymentMethod: o.paymentMethod,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
createdAt: o.createdAt,
|
||||
updatedAt: o.updatedAt,
|
||||
user: o.user,
|
||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
items: items.map((o) => ({
|
||||
id: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
deliveryCarrier: o.deliveryCarrier,
|
||||
paymentMethod: o.paymentMethod,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
createdAt: o.createdAt,
|
||||
updatedAt: o.updatedAt,
|
||||
user: o.user,
|
||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
"/api/admin/orders/:id",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, name: true, phone: true } },
|
||||
items: true,
|
||||
messages: { orderBy: { createdAt: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
return { item: order };
|
||||
},
|
||||
);
|
||||
fastify.get('/api/admin/orders/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, name: true, phone: true } },
|
||||
items: true,
|
||||
messages: { orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
})
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
return { item: order }
|
||||
})
|
||||
|
||||
fastify.patch(
|
||||
"/api/admin/orders/:id/status",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const next = String(request.body?.status || "").trim();
|
||||
if (!next) return reply.code(400).send({ error: "status обязателен" });
|
||||
fastify.patch('/api/admin/orders/:id/status', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const next = String(request.body?.status || '').trim()
|
||||
if (!next) return reply.code(400).send({ error: 'status обязателен' })
|
||||
|
||||
const existing = await prisma.order.findUnique({ where: { id } });
|
||||
if (!existing) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
if (!canTransitionAdminOrderStatus(existing, next)) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error: `Нельзя сменить статус ${existing.status} → ${next}`,
|
||||
});
|
||||
}
|
||||
const existing = await prisma.order.findUnique({ where: { id } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
if (!canTransitionAdminOrderStatus(existing, next)) {
|
||||
return reply.code(409).send({
|
||||
error: `Нельзя сменить статус ${existing.status} → ${next}`,
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await prisma.order.update({
|
||||
where: { id },
|
||||
data: { status: next },
|
||||
});
|
||||
const updated = await prisma.order.update({
|
||||
where: { id },
|
||||
data: { status: next },
|
||||
})
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, {
|
||||
orderId: updated.id,
|
||||
userId: existing.userId,
|
||||
oldStatus: existing.status,
|
||||
newStatus: next,
|
||||
});
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, {
|
||||
orderId: updated.id,
|
||||
userId: existing.userId,
|
||||
oldStatus: existing.status,
|
||||
newStatus: next,
|
||||
})
|
||||
|
||||
return { item: updated };
|
||||
},
|
||||
);
|
||||
return { item: updated }
|
||||
})
|
||||
|
||||
fastify.patch(
|
||||
"/api/admin/orders/:id/delivery-fee",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const feeRaw = request.body?.deliveryFeeCents;
|
||||
const parsed =
|
||||
typeof feeRaw === "string"
|
||||
? Number.parseInt(feeRaw, 10)
|
||||
: typeof feeRaw === "number"
|
||||
? feeRaw
|
||||
: NaN;
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error: "deliveryFeeCents должно быть целым числом ≥ 0 (копейки)",
|
||||
});
|
||||
}
|
||||
fastify.patch('/api/admin/orders/:id/delivery-fee', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const feeRaw = request.body?.deliveryFeeCents
|
||||
const parsed = typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
return reply.code(400).send({
|
||||
error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)',
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await prisma.order.findUnique({ where: { id } });
|
||||
if (!existing) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
if (
|
||||
existing.status !== "PENDING_PAYMENT" ||
|
||||
existing.deliveryFeeLocked !== false
|
||||
) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error:
|
||||
"Корректировка доставки доступна только пока стоимость не утверждена",
|
||||
});
|
||||
}
|
||||
const existing = await prisma.order.findUnique({ where: { id } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) {
|
||||
return reply.code(409).send({
|
||||
error: 'Корректировка доставки доступна только пока стоимость не утверждена',
|
||||
})
|
||||
}
|
||||
|
||||
const totalCents = existing.itemsSubtotalCents + parsed;
|
||||
const updated = await prisma.order.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deliveryFeeCents: parsed,
|
||||
totalCents,
|
||||
deliveryFeeLocked: true,
|
||||
},
|
||||
});
|
||||
const totalCents = existing.itemsSubtotalCents + parsed
|
||||
const updated = await prisma.order.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deliveryFeeCents: parsed,
|
||||
totalCents,
|
||||
deliveryFeeLocked: true,
|
||||
},
|
||||
})
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, {
|
||||
orderId: updated.id,
|
||||
userId: existing.userId,
|
||||
totalCents: updated.totalCents,
|
||||
});
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, {
|
||||
orderId: updated.id,
|
||||
userId: existing.userId,
|
||||
totalCents: updated.totalCents,
|
||||
})
|
||||
|
||||
return { item: updated };
|
||||
},
|
||||
);
|
||||
return { item: updated }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
"/api/admin/orders/:id/messages",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const text = String(request.body?.text || "").trim();
|
||||
if (!text) return reply.code(400).send({ error: "Сообщение пустое" });
|
||||
if (text.length > 2000)
|
||||
return reply.code(400).send({ error: "Сообщение слишком длинное" });
|
||||
fastify.post('/api/admin/orders/:id/messages', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const text = String(request.body?.text || '').trim()
|
||||
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
|
||||
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
|
||||
|
||||
const order = await prisma.order.findUnique({ where: { id } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
const order = await prisma.order.findUnique({ where: { id } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
|
||||
const msg = await prisma.orderMessage.create({
|
||||
data: { orderId: id, authorType: "admin", text },
|
||||
});
|
||||
const msg = await prisma.orderMessage.create({
|
||||
data: { orderId: id, authorType: 'admin', text },
|
||||
})
|
||||
|
||||
request.server.eventBus.emit(
|
||||
NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY,
|
||||
{
|
||||
orderId: id,
|
||||
userId: order.userId,
|
||||
messageId: msg.id,
|
||||
preview: text,
|
||||
},
|
||||
);
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, {
|
||||
orderId: id,
|
||||
userId: order.userId,
|
||||
messageId: msg.id,
|
||||
preview: text,
|
||||
})
|
||||
|
||||
return reply.code(201).send({ item: msg });
|
||||
},
|
||||
);
|
||||
return reply.code(201).send({ item: msg })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,17 +40,13 @@ const PATCH_PRODUCT_SCHEMA = {
|
||||
}
|
||||
|
||||
export async function registerAdminProductRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/admin/products',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request) => {
|
||||
const items = await prisma.product.findMany({
|
||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
return items.map((p) => request.server.mapProductForApi(p))
|
||||
},
|
||||
)
|
||||
fastify.get('/api/admin/products', { preHandler: [fastify.verifyAdmin] }, async (request) => {
|
||||
const items = await prisma.product.findMany({
|
||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
return items.map((p) => request.server.mapProductForApi(p))
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/products',
|
||||
@@ -102,7 +98,9 @@ export async function registerAdminProductRoutes(fastify) {
|
||||
return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
|
||||
}
|
||||
if (notResized.length > 0) {
|
||||
return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,7 +225,9 @@ export async function registerAdminProductRoutes(fastify) {
|
||||
return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
|
||||
}
|
||||
if (notResized.length > 0) {
|
||||
return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,17 +255,13 @@ export async function registerAdminProductRoutes(fastify) {
|
||||
},
|
||||
)
|
||||
|
||||
fastify.delete(
|
||||
'/api/admin/products/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
await prisma.product.delete({ where: { id } })
|
||||
reply.code(204).send()
|
||||
} catch {
|
||||
reply.code(404).send({ error: 'Товар не найден' })
|
||||
}
|
||||
},
|
||||
)
|
||||
fastify.delete('/api/admin/products/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
await prisma.product.delete({ where: { id } })
|
||||
reply.code(204).send()
|
||||
} catch {
|
||||
reply.code(404).send({ error: 'Товар не найден' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,90 +1,65 @@
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function registerAdminReviewRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/api/admin/reviews",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const status =
|
||||
typeof request.query?.status === "string"
|
||||
? request.query.status.trim()
|
||||
: "pending";
|
||||
fastify.get('/api/admin/reviews', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending'
|
||||
|
||||
const pageRaw = request.query?.page;
|
||||
const pageParsed =
|
||||
typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw);
|
||||
const page =
|
||||
Number.isFinite(pageParsed) && pageParsed > 0
|
||||
? Math.floor(pageParsed)
|
||||
: 1;
|
||||
const pageRaw = request.query?.page
|
||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||
|
||||
const pageSizeRaw = request.query?.pageSize;
|
||||
const pageSizeParsed =
|
||||
typeof pageSizeRaw === "string"
|
||||
? Number(pageSizeRaw)
|
||||
: Number(pageSizeRaw);
|
||||
const pageSize =
|
||||
Number.isFinite(pageSizeParsed) && pageSizeParsed > 0
|
||||
? Math.floor(pageSizeParsed)
|
||||
: 20;
|
||||
if (pageSize > 100)
|
||||
return reply.code(400).send({ error: "pageSize должен быть ≤ 100" });
|
||||
const pageSizeRaw = request.query?.pageSize
|
||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||
|
||||
const where = status ? { status } : {};
|
||||
const total = await prisma.review.count({ where });
|
||||
const items = await prisma.review.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, email: true, name: true } },
|
||||
product: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
const where = status ? { status } : {}
|
||||
const total = await prisma.review.count({ where })
|
||||
const items = await prisma.review.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, email: true, name: true } },
|
||||
product: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
},
|
||||
);
|
||||
return { items, total, page, pageSize }
|
||||
})
|
||||
|
||||
fastify.patch(
|
||||
"/api/admin/reviews/:id",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const action = String(request.body?.action || "").trim();
|
||||
if (action !== "approve" && action !== "reject") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "action должен быть approve или reject" });
|
||||
}
|
||||
fastify.patch('/api/admin/reviews/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const action = String(request.body?.action || '').trim()
|
||||
if (action !== 'approve' && action !== 'reject') {
|
||||
return reply.code(400).send({ error: 'action должен быть approve или reject' })
|
||||
}
|
||||
|
||||
const existing = await prisma.review.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
product: { select: { title: true } },
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) return reply.code(404).send({ error: "Отзыв не найден" });
|
||||
const existing = await prisma.review.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
product: { select: { title: true } },
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
})
|
||||
if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' })
|
||||
|
||||
const updated = await prisma.review.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: action === "approve" ? "approved" : "rejected",
|
||||
moderatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
request.server.eventBus.emit("review:created", {
|
||||
rating: updated.rating,
|
||||
text: updated.text || "",
|
||||
productTitle: existing.product?.title || "",
|
||||
userName: existing.user?.name || existing.user?.email || "",
|
||||
reviewId: updated.id,
|
||||
});
|
||||
const updated = await prisma.review.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: action === 'approve' ? 'approved' : 'rejected',
|
||||
moderatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
request.server.eventBus.emit('review:created', {
|
||||
rating: updated.rating,
|
||||
text: updated.text || '',
|
||||
productTitle: existing.product?.title || '',
|
||||
userName: existing.user?.name || existing.user?.email || '',
|
||||
reviewId: updated.id,
|
||||
})
|
||||
|
||||
return { item: updated };
|
||||
},
|
||||
);
|
||||
return { item: updated }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,166 +1,149 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { normalizeEmail } from '../../lib/auth.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function registerAdminUserRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/admin/users',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const qRaw = request.query?.q
|
||||
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
||||
fastify.get('/api/admin/users', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const qRaw = request.query?.q
|
||||
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
||||
|
||||
const pageRaw = request.query?.page
|
||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||
const pageRaw = request.query?.page
|
||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||
|
||||
const pageSizeRaw = request.query?.pageSize
|
||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||
const pageSizeRaw = request.query?.pageSize
|
||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||
|
||||
if (pageSize > 100) {
|
||||
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||
return
|
||||
}
|
||||
if (pageSize > 100) {
|
||||
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||
return
|
||||
}
|
||||
|
||||
const where = q
|
||||
? {
|
||||
OR: [{ email: { contains: q } }, { name: { contains: q } }],
|
||||
}
|
||||
: undefined
|
||||
const where = q
|
||||
? {
|
||||
OR: [{ email: { contains: q } }, { name: { contains: q } }],
|
||||
}
|
||||
: undefined
|
||||
|
||||
const total = await prisma.user.count({ where })
|
||||
const total = await prisma.user.count({ where })
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
const items = users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
}))
|
||||
const users = await prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
const items = users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
}))
|
||||
|
||||
return { items, total, page, pageSize }
|
||||
},
|
||||
)
|
||||
return { items, total, page, pageSize }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/users',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const body = request.body ?? {}
|
||||
fastify.post('/api/admin/users', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const body = request.body ?? {}
|
||||
|
||||
const email = normalizeEmail(body.email)
|
||||
if (!email || !email.includes('@')) {
|
||||
reply.code(400).send({ error: 'Некорректная почта' })
|
||||
return
|
||||
}
|
||||
|
||||
const nameRaw = body.name
|
||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||
if (name !== null && name.length > 40) {
|
||||
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||
return
|
||||
}
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { email } })
|
||||
if (exists) {
|
||||
reply.code(409).send({ error: 'Почта уже занята' })
|
||||
return
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: name && name.length ? name : null,
|
||||
},
|
||||
})
|
||||
|
||||
reply.code(201).send({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
})
|
||||
})
|
||||
|
||||
fastify.patch('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const body = request.body ?? {}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = {}
|
||||
|
||||
if (body.email !== undefined) {
|
||||
const email = normalizeEmail(body.email)
|
||||
if (!email || !email.includes('@')) {
|
||||
reply.code(400).send({ error: 'Некорректная почта' })
|
||||
return
|
||||
}
|
||||
if (email !== existing.email) {
|
||||
const clash = await prisma.user.findUnique({ where: { email } })
|
||||
if (clash) {
|
||||
reply.code(409).send({ error: 'Почта уже занята' })
|
||||
return
|
||||
}
|
||||
data.email = email
|
||||
}
|
||||
}
|
||||
|
||||
if (body.name !== undefined) {
|
||||
const nameRaw = body.name
|
||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||
if (name !== null && name.length > 40) {
|
||||
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||
return
|
||||
}
|
||||
data.name = name && name.length ? name : null
|
||||
}
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { email } })
|
||||
if (exists) {
|
||||
reply.code(409).send({ error: 'Почта уже занята' })
|
||||
return
|
||||
}
|
||||
const user = await prisma.user.update({ where: { id }, data })
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}
|
||||
})
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: name && name.length ? name : null,
|
||||
},
|
||||
})
|
||||
|
||||
reply.code(201).send({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
fastify.patch(
|
||||
'/api/admin/users/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const body = request.body ?? {}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = {}
|
||||
|
||||
if (body.email !== undefined) {
|
||||
const email = normalizeEmail(body.email)
|
||||
if (!email || !email.includes('@')) {
|
||||
reply.code(400).send({ error: 'Некорректная почта' })
|
||||
return
|
||||
}
|
||||
if (email !== existing.email) {
|
||||
const clash = await prisma.user.findUnique({ where: { email } })
|
||||
if (clash) {
|
||||
reply.code(409).send({ error: 'Почта уже занята' })
|
||||
return
|
||||
}
|
||||
data.email = email
|
||||
}
|
||||
}
|
||||
|
||||
if (body.name !== undefined) {
|
||||
const nameRaw = body.name
|
||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||
if (name !== null && name.length > 40) {
|
||||
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||
return
|
||||
}
|
||||
data.name = name && name.length ? name : null
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({ where: { id }, data })
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fastify.delete(
|
||||
'/api/admin/users/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
await prisma.user.delete({ where: { id } })
|
||||
reply.code(204).send()
|
||||
} catch {
|
||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||
}
|
||||
},
|
||||
)
|
||||
fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
await prisma.user.delete({ where: { id } })
|
||||
reply.code(204).send()
|
||||
} catch {
|
||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,95 +1,78 @@
|
||||
import { prisma } from "../../../lib/prisma.js";
|
||||
import { prisma } from '../../../lib/prisma.js'
|
||||
|
||||
export async function registerAdminNotificationRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/api/admin/notifications/settings",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
let settings = await prisma.adminNotificationSettings.findFirst();
|
||||
if (!settings) {
|
||||
settings = await prisma.adminNotificationSettings.create({
|
||||
data: {
|
||||
emailEnabled: true,
|
||||
telegramEnabled: false,
|
||||
newOrder: true,
|
||||
newOrderMessage: true,
|
||||
newReview: true,
|
||||
authCodeDuplicate: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { settings };
|
||||
},
|
||||
);
|
||||
fastify.get('/api/admin/notifications/settings', { preHandler: [fastify.verifyAdmin] }, async () => {
|
||||
let settings = await prisma.adminNotificationSettings.findFirst()
|
||||
if (!settings) {
|
||||
settings = await prisma.adminNotificationSettings.create({
|
||||
data: {
|
||||
emailEnabled: true,
|
||||
telegramEnabled: false,
|
||||
newOrder: true,
|
||||
newOrderMessage: true,
|
||||
newReview: true,
|
||||
authCodeDuplicate: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
return { settings }
|
||||
})
|
||||
|
||||
fastify.put(
|
||||
"/api/admin/notifications/settings",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request) => {
|
||||
const body = request.body || {};
|
||||
let settings = await prisma.adminNotificationSettings.findFirst();
|
||||
fastify.put('/api/admin/notifications/settings', { preHandler: [fastify.verifyAdmin] }, async (request) => {
|
||||
const body = request.body || {}
|
||||
let settings = await prisma.adminNotificationSettings.findFirst()
|
||||
|
||||
const data = {};
|
||||
if ("emailEnabled" in body)
|
||||
data.emailEnabled = Boolean(body.emailEnabled);
|
||||
if ("telegramEnabled" in body)
|
||||
data.telegramEnabled = Boolean(body.telegramEnabled);
|
||||
if ("telegramChatId" in body)
|
||||
data.telegramChatId = body.telegramChatId || null;
|
||||
if ("newOrder" in body) data.newOrder = Boolean(body.newOrder);
|
||||
if ("newOrderMessage" in body)
|
||||
data.newOrderMessage = Boolean(body.newOrderMessage);
|
||||
if ("newReview" in body) data.newReview = Boolean(body.newReview);
|
||||
if ("authCodeDuplicate" in body)
|
||||
data.authCodeDuplicate = Boolean(body.authCodeDuplicate);
|
||||
const data = {}
|
||||
if ('emailEnabled' in body) data.emailEnabled = Boolean(body.emailEnabled)
|
||||
if ('telegramEnabled' in body) data.telegramEnabled = Boolean(body.telegramEnabled)
|
||||
if ('telegramChatId' in body) data.telegramChatId = body.telegramChatId || null
|
||||
if ('newOrder' in body) data.newOrder = Boolean(body.newOrder)
|
||||
if ('newOrderMessage' in body) data.newOrderMessage = Boolean(body.newOrderMessage)
|
||||
if ('newReview' in body) data.newReview = Boolean(body.newReview)
|
||||
if ('authCodeDuplicate' in body) data.authCodeDuplicate = Boolean(body.authCodeDuplicate)
|
||||
|
||||
if (!settings) {
|
||||
settings = await prisma.adminNotificationSettings.create({ data });
|
||||
} else {
|
||||
settings = await prisma.adminNotificationSettings.update({
|
||||
where: { id: settings.id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
if (!settings) {
|
||||
settings = await prisma.adminNotificationSettings.create({ data })
|
||||
} else {
|
||||
settings = await prisma.adminNotificationSettings.update({
|
||||
where: { id: settings.id },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
return { settings };
|
||||
},
|
||||
);
|
||||
return { settings }
|
||||
})
|
||||
|
||||
fastify.post("/api/admin/notifications/telegram/webhook", async (request) => {
|
||||
const update = request.body || {};
|
||||
const message = update.message;
|
||||
if (!message || !message.text || message.text !== "/start")
|
||||
return { ok: true };
|
||||
fastify.post('/api/admin/notifications/telegram/webhook', async (request) => {
|
||||
const update = request.body || {}
|
||||
const message = update.message
|
||||
if (!message || !message.text || message.text !== '/start') return { ok: true }
|
||||
|
||||
const chatId = String(message.chat.id);
|
||||
const settings = await prisma.adminNotificationSettings.findFirst();
|
||||
const chatId = String(message.chat.id)
|
||||
const settings = await prisma.adminNotificationSettings.findFirst()
|
||||
|
||||
if (settings) {
|
||||
await prisma.adminNotificationSettings.update({
|
||||
where: { id: settings.id },
|
||||
data: { telegramChatId: chatId },
|
||||
});
|
||||
})
|
||||
} else {
|
||||
await prisma.adminNotificationSettings.create({
|
||||
data: { telegramChatId: chatId },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
if (process.env.TELEGRAM_BOT_TOKEN) {
|
||||
await fetch(
|
||||
`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: "Вы подписаны на уведомления Любимый Креатив.",
|
||||
}),
|
||||
},
|
||||
);
|
||||
await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: 'Вы подписаны на уведомления Любимый Креатив.',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,83 +17,75 @@ export async function registerCatalogSliderRoutes(fastify) {
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
'/api/admin/catalog-slider',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
const slides = await prisma.catalogSliderSlide.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { galleryImage: true },
|
||||
})
|
||||
return {
|
||||
slides: slides.map((s) => ({
|
||||
id: s.id,
|
||||
galleryImageId: s.galleryImageId,
|
||||
url: s.galleryImage.url,
|
||||
caption: s.caption,
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async () => {
|
||||
const slides = await prisma.catalogSliderSlide.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { galleryImage: true },
|
||||
})
|
||||
return {
|
||||
slides: slides.map((s) => ({
|
||||
id: s.id,
|
||||
galleryImageId: s.galleryImageId,
|
||||
url: s.galleryImage.url,
|
||||
caption: s.caption,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
fastify.put(
|
||||
'/api/admin/catalog-slider',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const body = request.body ?? {}
|
||||
const rawSlides = body.slides
|
||||
if (!Array.isArray(rawSlides)) {
|
||||
return reply.code(400).send({ error: 'Ожидается slides: массив' })
|
||||
}
|
||||
if (rawSlides.length > MAX_SLIDES) {
|
||||
return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` })
|
||||
}
|
||||
fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const body = request.body ?? {}
|
||||
const rawSlides = body.slides
|
||||
if (!Array.isArray(rawSlides)) {
|
||||
return reply.code(400).send({ error: 'Ожидается slides: массив' })
|
||||
}
|
||||
if (rawSlides.length > MAX_SLIDES) {
|
||||
return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` })
|
||||
}
|
||||
|
||||
const seenGalleryIds = new Set()
|
||||
const normalized = []
|
||||
for (let i = 0; i < rawSlides.length; i++) {
|
||||
const row = rawSlides[i]
|
||||
const galleryImageId = String(row?.galleryImageId ?? '').trim()
|
||||
if (!galleryImageId) {
|
||||
return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` })
|
||||
}
|
||||
if (seenGalleryIds.has(galleryImageId)) {
|
||||
return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' })
|
||||
}
|
||||
seenGalleryIds.add(galleryImageId)
|
||||
const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } })
|
||||
if (!img) {
|
||||
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
|
||||
}
|
||||
const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500)
|
||||
normalized.push({ galleryImageId, caption, sortOrder: i })
|
||||
const seenGalleryIds = new Set()
|
||||
const normalized = []
|
||||
for (let i = 0; i < rawSlides.length; i++) {
|
||||
const row = rawSlides[i]
|
||||
const galleryImageId = String(row?.galleryImageId ?? '').trim()
|
||||
if (!galleryImageId) {
|
||||
return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` })
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.catalogSliderSlide.deleteMany({})
|
||||
for (const n of normalized) {
|
||||
await tx.catalogSliderSlide.create({
|
||||
data: {
|
||||
sortOrder: n.sortOrder,
|
||||
caption: n.caption,
|
||||
galleryImageId: n.galleryImageId,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const slides = await prisma.catalogSliderSlide.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { galleryImage: true },
|
||||
})
|
||||
return {
|
||||
slides: slides.map((s) => ({
|
||||
id: s.id,
|
||||
galleryImageId: s.galleryImageId,
|
||||
url: s.galleryImage.url,
|
||||
caption: s.caption,
|
||||
})),
|
||||
if (seenGalleryIds.has(galleryImageId)) {
|
||||
return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' })
|
||||
}
|
||||
},
|
||||
)
|
||||
seenGalleryIds.add(galleryImageId)
|
||||
const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } })
|
||||
if (!img) {
|
||||
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
|
||||
}
|
||||
const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500)
|
||||
normalized.push({ galleryImageId, caption, sortOrder: i })
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.catalogSliderSlide.deleteMany({})
|
||||
for (const n of normalized) {
|
||||
await tx.catalogSliderSlide.create({
|
||||
data: {
|
||||
sortOrder: n.sortOrder,
|
||||
caption: n.caption,
|
||||
galleryImageId: n.galleryImageId,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const slides = await prisma.catalogSliderSlide.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { galleryImage: true },
|
||||
})
|
||||
return {
|
||||
slides: slides.map((s) => ({
|
||||
id: s.id,
|
||||
galleryImageId: s.galleryImageId,
|
||||
url: s.galleryImage.url,
|
||||
caption: s.caption,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,90 +29,74 @@ export async function registerInfoPageRoutes(fastify) {
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
'/api/admin/info-page/blocks',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] })
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
fastify.get('/api/admin/info-page/blocks', { preHandler: [fastify.verifyAdmin] }, async () => {
|
||||
const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] })
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/info-page/blocks',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const validated = validateBlockPayload(request.body, reply)
|
||||
if (!validated) return
|
||||
fastify.post('/api/admin/info-page/blocks', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const validated = validateBlockPayload(request.body, reply)
|
||||
if (!validated) return
|
||||
|
||||
try {
|
||||
const item = await prisma.infoPageBlock.create({ data: validated })
|
||||
return reply.code(201).send({ item })
|
||||
} catch {
|
||||
return reply.code(409).send({ error: 'Блок с таким key уже существует' })
|
||||
}
|
||||
},
|
||||
)
|
||||
try {
|
||||
const item = await prisma.infoPageBlock.create({ data: validated })
|
||||
return reply.code(201).send({ item })
|
||||
} catch {
|
||||
return reply.code(409).send({ error: 'Блок с таким key уже существует' })
|
||||
}
|
||||
})
|
||||
|
||||
fastify.patch(
|
||||
'/api/admin/info-page/blocks/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const existing = await prisma.infoPageBlock.findUnique({ where: { id } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Блок не найден' })
|
||||
fastify.patch('/api/admin/info-page/blocks/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const existing = await prisma.infoPageBlock.findUnique({ where: { id } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Блок не найден' })
|
||||
|
||||
const body = request.body ?? {}
|
||||
const data = {}
|
||||
if (body.key !== undefined) {
|
||||
const key = String(body.key || '').trim()
|
||||
if (!key) return reply.code(400).send({ error: 'key обязателен' })
|
||||
if (!/^[a-z0-9_-]{2,60}$/i.test(key)) {
|
||||
return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' })
|
||||
}
|
||||
data.key = key
|
||||
}
|
||||
if (body.title !== undefined) {
|
||||
const title = String(body.title || '').trim()
|
||||
if (!title) return reply.code(400).send({ error: 'title обязателен' })
|
||||
if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' })
|
||||
data.title = title
|
||||
}
|
||||
if (body.body !== undefined) {
|
||||
const content = String(body.body || '').trim()
|
||||
if (!content) return reply.code(400).send({ error: 'body обязателен' })
|
||||
if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' })
|
||||
data.body = content
|
||||
}
|
||||
if (body.sort !== undefined) {
|
||||
const sort = Number(body.sort)
|
||||
if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' })
|
||||
data.sort = Math.trunc(sort)
|
||||
}
|
||||
if (body.published !== undefined) {
|
||||
data.published = Boolean(body.published)
|
||||
const body = request.body ?? {}
|
||||
const data = {}
|
||||
if (body.key !== undefined) {
|
||||
const key = String(body.key || '').trim()
|
||||
if (!key) return reply.code(400).send({ error: 'key обязателен' })
|
||||
if (!/^[a-z0-9_-]{2,60}$/i.test(key)) {
|
||||
return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' })
|
||||
}
|
||||
data.key = key
|
||||
}
|
||||
if (body.title !== undefined) {
|
||||
const title = String(body.title || '').trim()
|
||||
if (!title) return reply.code(400).send({ error: 'title обязателен' })
|
||||
if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' })
|
||||
data.title = title
|
||||
}
|
||||
if (body.body !== undefined) {
|
||||
const content = String(body.body || '').trim()
|
||||
if (!content) return reply.code(400).send({ error: 'body обязателен' })
|
||||
if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' })
|
||||
data.body = content
|
||||
}
|
||||
if (body.sort !== undefined) {
|
||||
const sort = Number(body.sort)
|
||||
if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' })
|
||||
data.sort = Math.trunc(sort)
|
||||
}
|
||||
if (body.published !== undefined) {
|
||||
data.published = Boolean(body.published)
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await prisma.infoPageBlock.update({ where: { id }, data })
|
||||
return { item }
|
||||
} catch {
|
||||
return reply.code(409).send({ error: 'Блок с таким key уже существует' })
|
||||
}
|
||||
},
|
||||
)
|
||||
try {
|
||||
const item = await prisma.infoPageBlock.update({ where: { id }, data })
|
||||
return { item }
|
||||
} catch {
|
||||
return reply.code(409).send({ error: 'Блок с таким key уже существует' })
|
||||
}
|
||||
})
|
||||
|
||||
fastify.delete(
|
||||
'/api/admin/info-page/blocks/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
await prisma.infoPageBlock.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
} catch {
|
||||
return reply.code(404).send({ error: 'Блок не найден' })
|
||||
}
|
||||
},
|
||||
)
|
||||
fastify.delete('/api/admin/info-page/blocks/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
await prisma.infoPageBlock.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
} catch {
|
||||
return reply.code(404).send({ error: 'Блок не найден' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ export async function registerPublicCatalogRoutes(fastify) {
|
||||
})
|
||||
|
||||
fastify.get('/api/products', { schema: PUBLIC_PRODUCTS_QUERY_SCHEMA }, async (request, reply) => {
|
||||
const { mapProductForApi } = request.server
|
||||
const { categorySlug } = request.query
|
||||
const qRaw = request.query?.q
|
||||
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
||||
@@ -161,4 +160,3 @@ export async function registerPublicCatalogRoutes(fastify) {
|
||||
return request.server.mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
|
||||
import { persistMultipartImages } from '../../lib/upload-images.js'
|
||||
import {
|
||||
formatFileTooLargeMessage,
|
||||
getOtherUploadMaxFileBytes,
|
||||
isMultipartFileTooLargeError,
|
||||
} from '../../lib/upload-limits.js'
|
||||
import { persistMultipartImages } from '../../lib/upload-images.js'
|
||||
|
||||
export async function registerPublicReviewRoutes(fastify) {
|
||||
fastify.post(
|
||||
'/api/reviews/upload-image',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const urls = await persistMultipartImages(request, {
|
||||
maxFiles: 1,
|
||||
maxFileBytes: getOtherUploadMaxFileBytes(),
|
||||
subdir: 'reviews',
|
||||
})
|
||||
if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
|
||||
return { url: urls[0] }
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение'
|
||||
let statusCode =
|
||||
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||
? Number(error.statusCode)
|
||||
: 400
|
||||
if (isMultipartFileTooLargeError(error)) {
|
||||
message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes())
|
||||
statusCode = 413
|
||||
}
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
fastify.post('/api/reviews/upload-image', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
try {
|
||||
const urls = await persistMultipartImages(request, {
|
||||
maxFiles: 1,
|
||||
maxFileBytes: getOtherUploadMaxFileBytes(),
|
||||
subdir: 'reviews',
|
||||
})
|
||||
if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
|
||||
return { url: urls[0] }
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение'
|
||||
let statusCode =
|
||||
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||
? Number(error.statusCode)
|
||||
: 400
|
||||
if (isMultipartFileTooLargeError(error)) {
|
||||
message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes())
|
||||
statusCode = 413
|
||||
}
|
||||
},
|
||||
)
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/reviews/latest', async (request, reply) => {
|
||||
const limitRaw = request.query?.limit
|
||||
@@ -102,46 +98,42 @@ export async function registerPublicReviewRoutes(fastify) {
|
||||
return { items, total, page, pageSize }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
'/api/products/:id/reviews',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id: productId } = request.params
|
||||
fastify.post('/api/products/:id/reviews', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id: productId } = request.params
|
||||
|
||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||
|
||||
const rating = Number(request.body?.rating)
|
||||
if (!Number.isFinite(rating) || rating < 1 || rating > 5) {
|
||||
return reply.code(400).send({ error: 'rating должен быть от 1 до 5' })
|
||||
}
|
||||
const textRaw = request.body?.text
|
||||
const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim()
|
||||
if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' })
|
||||
const imageUrlRaw = request.body?.imageUrl
|
||||
const imageUrl = imageUrlRaw === null || imageUrlRaw === undefined ? null : String(imageUrlRaw).trim()
|
||||
if (imageUrl !== null && imageUrl.length > 300) return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' })
|
||||
if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) {
|
||||
return reply.code(400).send({ error: 'Некорректная ссылка на изображение' })
|
||||
}
|
||||
const rating = Number(request.body?.rating)
|
||||
if (!Number.isFinite(rating) || rating < 1 || rating > 5) {
|
||||
return reply.code(400).send({ error: 'rating должен быть от 1 до 5' })
|
||||
}
|
||||
const textRaw = request.body?.text
|
||||
const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim()
|
||||
if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' })
|
||||
const imageUrlRaw = request.body?.imageUrl
|
||||
const imageUrl = imageUrlRaw === null || imageUrlRaw === undefined ? null : String(imageUrlRaw).trim()
|
||||
if (imageUrl !== null && imageUrl.length > 300)
|
||||
return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' })
|
||||
if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) {
|
||||
return reply.code(400).send({ error: 'Некорректная ссылка на изображение' })
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await prisma.review.create({
|
||||
data: {
|
||||
productId,
|
||||
userId,
|
||||
rating: Math.floor(rating),
|
||||
text: text && text.length ? text : null,
|
||||
imageUrl: imageUrl && imageUrl.length ? imageUrl : null,
|
||||
status: 'pending',
|
||||
},
|
||||
})
|
||||
return reply.code(201).send({ item: created })
|
||||
} catch {
|
||||
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
|
||||
}
|
||||
},
|
||||
)
|
||||
try {
|
||||
const created = await prisma.review.create({
|
||||
data: {
|
||||
productId,
|
||||
userId,
|
||||
rating: Math.floor(rating),
|
||||
text: text && text.length ? text : null,
|
||||
imageUrl: imageUrl && imageUrl.length ? imageUrl : null,
|
||||
status: 'pending',
|
||||
},
|
||||
})
|
||||
return reply.code(201).send({ item: created })
|
||||
} catch {
|
||||
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+93
-131
@@ -1,177 +1,139 @@
|
||||
import {
|
||||
issueEmailCode,
|
||||
normalizeEmail,
|
||||
verifyEmailCode,
|
||||
} from "../lib/auth.js";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
function mapUserForClient(user) {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL);
|
||||
const userEmail = normalizeEmail(user.email);
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
const userEmail = normalizeEmail(user.email)
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(fastify) {
|
||||
fastify.post("/api/auth/request-code", async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email);
|
||||
if (!email || !email.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
fastify.post('/api/auth/request-code', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
|
||||
const code = await issueEmailCode({ email, purpose: "login" });
|
||||
const code = await issueEmailCode({ email, purpose: 'login' })
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
||||
const isAdmin = email === adminEmail;
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
|
||||
const isAdmin = email === adminEmail
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, {
|
||||
email,
|
||||
code,
|
||||
isAdmin,
|
||||
});
|
||||
})
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
fastify.post("/api/auth/verify-code", async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email);
|
||||
const code = String(request.body?.code || "").trim();
|
||||
if (!email || !email.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
if (!code || code.length !== 6)
|
||||
return reply.code(400).send({ error: "Код должен быть из 6 цифр" });
|
||||
fastify.post('/api/auth/verify-code', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
const code = String(request.body?.code || '').trim()
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
||||
|
||||
const ok = await verifyEmailCode({ email, purpose: "login", code });
|
||||
if (!ok)
|
||||
return reply.code(401).send({ error: "Неверный или истёкший код" });
|
||||
const ok = await verifyEmailCode({ email, purpose: 'login', code })
|
||||
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {},
|
||||
create: { email },
|
||||
});
|
||||
})
|
||||
|
||||
// Ensure notification preference exists
|
||||
await prisma.notificationPreference.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, globalEnabled: true },
|
||||
update: {},
|
||||
});
|
||||
})
|
||||
|
||||
const token = fastify.jwt.sign({ sub: user.id, email: user.email });
|
||||
return { token, user: mapUserForClient(user) };
|
||||
});
|
||||
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
||||
return { token, user: mapUserForClient(user) }
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
"/api/me",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub;
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) return { user: null };
|
||||
return { user: mapUserForClient(user) };
|
||||
},
|
||||
);
|
||||
fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) return { user: null }
|
||||
return { user: mapUserForClient(user) }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
"/api/me/change-email/request-code",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const newEmail = normalizeEmail(request.body?.newEmail);
|
||||
if (!newEmail || !newEmail.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
fastify.post('/api/me/change-email/request-code', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const newEmail = normalizeEmail(request.body?.newEmail)
|
||||
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
});
|
||||
if (exists)
|
||||
return reply.code(409).send({ error: "Эта почта уже занята" });
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
})
|
||||
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
|
||||
|
||||
await issueEmailCode({
|
||||
email: newEmail,
|
||||
purpose: "change_email",
|
||||
userId,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
await issueEmailCode({
|
||||
email: newEmail,
|
||||
purpose: 'change_email',
|
||||
userId,
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
"/api/me/change-email/verify",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const newEmail = normalizeEmail(request.body?.newEmail);
|
||||
const code = String(request.body?.code || "").trim();
|
||||
if (!newEmail || !newEmail.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
if (!code || code.length !== 6)
|
||||
return reply.code(400).send({ error: "Код должен быть из 6 цифр" });
|
||||
fastify.post('/api/me/change-email/verify', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const newEmail = normalizeEmail(request.body?.newEmail)
|
||||
const code = String(request.body?.code || '').trim()
|
||||
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
||||
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
});
|
||||
if (exists)
|
||||
return reply.code(409).send({ error: "Эта почта уже занята" });
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
})
|
||||
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
|
||||
|
||||
const ok = await verifyEmailCode({
|
||||
email: newEmail,
|
||||
purpose: "change_email",
|
||||
code,
|
||||
userId,
|
||||
});
|
||||
if (!ok)
|
||||
return reply.code(401).send({ error: "Неверный или истёкший код" });
|
||||
const ok = await verifyEmailCode({
|
||||
email: newEmail,
|
||||
purpose: 'change_email',
|
||||
code,
|
||||
userId,
|
||||
})
|
||||
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { email: newEmail },
|
||||
});
|
||||
return { user: mapUserForClient(user) };
|
||||
},
|
||||
);
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { email: newEmail },
|
||||
})
|
||||
return { user: mapUserForClient(user) }
|
||||
})
|
||||
|
||||
fastify.patch(
|
||||
"/api/me/profile",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const nameRaw = request.body?.name;
|
||||
const name =
|
||||
nameRaw === null || nameRaw === undefined
|
||||
? null
|
||||
: String(nameRaw).trim();
|
||||
const phoneRaw = request.body?.phone;
|
||||
const phone =
|
||||
phoneRaw === null || phoneRaw === undefined
|
||||
? null
|
||||
: String(phoneRaw).trim();
|
||||
fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const nameRaw = request.body?.name
|
||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||
const phoneRaw = request.body?.phone
|
||||
const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim()
|
||||
|
||||
if (name !== null && name.length > 40)
|
||||
return reply.code(400).send({ error: "Имя/ник максимум 40 символов" });
|
||||
if (phone !== null) {
|
||||
const compact = phone.replace(/[\s()-]/g, "");
|
||||
if (compact.length > 20)
|
||||
return reply.code(400).send({ error: "Телефон слишком длинный" });
|
||||
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) {
|
||||
return reply.code(400).send({ error: "Некорректный телефон" });
|
||||
}
|
||||
if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||
if (phone !== null) {
|
||||
const compact = phone.replace(/[\s()-]/g, '')
|
||||
if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' })
|
||||
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) {
|
||||
return reply.code(400).send({ error: 'Некорректный телефон' })
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
name: name && name.length ? name : null,
|
||||
phone: phone && phone.length ? phone : null,
|
||||
},
|
||||
});
|
||||
return { user: mapUserForClient(updated) };
|
||||
},
|
||||
);
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
name: name && name.length ? name : null,
|
||||
phone: phone && phone.length ? phone : null,
|
||||
},
|
||||
})
|
||||
return { user: mapUserForClient(updated) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// server/src/routes/uploads-resized.js
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js'
|
||||
|
||||
const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable'
|
||||
@@ -18,7 +17,8 @@ export function registerUploadsResized(fastify) {
|
||||
|
||||
// Parse: [subdir/]filename.format
|
||||
const parts = rawPath.split('/')
|
||||
let filename, subdir = ''
|
||||
let filename,
|
||||
subdir = ''
|
||||
|
||||
if (parts.length > 1) {
|
||||
subdir = parts.slice(0, -1).join('/') + '/'
|
||||
|
||||
+126
-142
@@ -25,7 +25,8 @@ function validateAddressPayload(body, reply) {
|
||||
|
||||
const commentRaw = body?.comment
|
||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||
if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||
if (comment !== null && comment.length > 200)
|
||||
return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||
|
||||
const lat = Number(body?.lat)
|
||||
const lng = Number(body?.lng)
|
||||
@@ -44,150 +45,133 @@ function validateAddressPayload(body, reply) {
|
||||
}
|
||||
|
||||
export async function registerUserAddressRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/me/addresses',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const items = await prisma.shippingAddress.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const items = await prisma.shippingAddress.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
'/api/me/addresses',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const validated = validateAddressPayload(request.body, reply)
|
||||
if (!validated) return
|
||||
fastify.post('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const validated = validateAddressPayload(request.body, reply)
|
||||
if (!validated) return
|
||||
|
||||
const isDefault = Boolean(request.body?.isDefault)
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
if (isDefault) {
|
||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||
}
|
||||
return tx.shippingAddress.create({
|
||||
data: {
|
||||
userId,
|
||||
...validated,
|
||||
isDefault,
|
||||
},
|
||||
})
|
||||
})
|
||||
return reply.code(201).send({ item: created })
|
||||
},
|
||||
)
|
||||
|
||||
fastify.patch(
|
||||
'/api/me/addresses/:id',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
const body = request.body ?? {}
|
||||
const data = {}
|
||||
|
||||
if (body.label !== undefined) {
|
||||
const labelRaw = body.label
|
||||
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
||||
if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
||||
data.label = label && label.length ? label : null
|
||||
}
|
||||
|
||||
if (body.recipientName !== undefined) {
|
||||
const v = String(body.recipientName || '').trim()
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
||||
if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
||||
data.recipientName = v
|
||||
}
|
||||
|
||||
if (body.recipientPhone !== undefined) {
|
||||
const v = normalizePhoneLite(body.recipientPhone)
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
||||
if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
||||
data.recipientPhone = v
|
||||
}
|
||||
|
||||
if (body.addressLine !== undefined) {
|
||||
const v = String(body.addressLine || '').trim()
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите адрес' })
|
||||
if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
||||
data.addressLine = v
|
||||
}
|
||||
|
||||
if (body.comment !== undefined) {
|
||||
const commentRaw = body.comment
|
||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||
if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||
data.comment = comment && comment.length ? comment : null
|
||||
}
|
||||
|
||||
if (body.lat !== undefined) {
|
||||
const lat = Number(body.lat)
|
||||
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
||||
data.lat = lat
|
||||
}
|
||||
|
||||
if (body.lng !== undefined) {
|
||||
const lng = Number(body.lng)
|
||||
if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' })
|
||||
data.lng = lng
|
||||
}
|
||||
|
||||
const setDefault = body.isDefault === true
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (setDefault) {
|
||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||
}
|
||||
return tx.shippingAddress.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(setDefault ? { isDefault: true } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return { item: updated }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.delete(
|
||||
'/api/me/addresses/:id',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
await prisma.shippingAddress.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/me/addresses/:id/default',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const isDefault = Boolean(request.body?.isDefault)
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
if (isDefault) {
|
||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||
return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
|
||||
}
|
||||
return tx.shippingAddress.create({
|
||||
data: {
|
||||
userId,
|
||||
...validated,
|
||||
isDefault,
|
||||
},
|
||||
})
|
||||
})
|
||||
return reply.code(201).send({ item: created })
|
||||
})
|
||||
|
||||
return { item: updated }
|
||||
},
|
||||
)
|
||||
fastify.patch('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
const body = request.body ?? {}
|
||||
const data = {}
|
||||
|
||||
if (body.label !== undefined) {
|
||||
const labelRaw = body.label
|
||||
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
||||
if (label !== null && label.length > 40)
|
||||
return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
||||
data.label = label && label.length ? label : null
|
||||
}
|
||||
|
||||
if (body.recipientName !== undefined) {
|
||||
const v = String(body.recipientName || '').trim()
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
||||
if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
||||
data.recipientName = v
|
||||
}
|
||||
|
||||
if (body.recipientPhone !== undefined) {
|
||||
const v = normalizePhoneLite(body.recipientPhone)
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
||||
if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
||||
data.recipientPhone = v
|
||||
}
|
||||
|
||||
if (body.addressLine !== undefined) {
|
||||
const v = String(body.addressLine || '').trim()
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите адрес' })
|
||||
if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
||||
data.addressLine = v
|
||||
}
|
||||
|
||||
if (body.comment !== undefined) {
|
||||
const commentRaw = body.comment
|
||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||
if (comment !== null && comment.length > 200)
|
||||
return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||
data.comment = comment && comment.length ? comment : null
|
||||
}
|
||||
|
||||
if (body.lat !== undefined) {
|
||||
const lat = Number(body.lat)
|
||||
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
||||
data.lat = lat
|
||||
}
|
||||
|
||||
if (body.lng !== undefined) {
|
||||
const lng = Number(body.lng)
|
||||
if (!Number.isFinite(lng) || lng < -180 || lng > 180)
|
||||
return reply.code(400).send({ error: 'Некорректная долгота' })
|
||||
data.lng = lng
|
||||
}
|
||||
|
||||
const setDefault = body.isDefault === true
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (setDefault) {
|
||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||
}
|
||||
return tx.shippingAddress.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(setDefault ? { isDefault: true } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return { item: updated }
|
||||
})
|
||||
|
||||
fastify.delete('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
await prisma.shippingAddress.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
})
|
||||
|
||||
fastify.post('/api/me/addresses/:id/default', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||
return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
|
||||
})
|
||||
|
||||
return { item: updated }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,92 +1,76 @@
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
export async function registerUserCartRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/me/cart',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const items = await prisma.cartItem.findMany({
|
||||
where: { userId },
|
||||
include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
return {
|
||||
items: items.map((x) => ({
|
||||
id: x.id,
|
||||
qty: x.qty,
|
||||
product: x.product,
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const items = await prisma.cartItem.findMany({
|
||||
where: { userId },
|
||||
include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
return {
|
||||
items: items.map((x) => ({
|
||||
id: x.id,
|
||||
qty: x.qty,
|
||||
product: x.product,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
'/api/me/cart/items',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const productId = String(request.body?.productId || '').trim()
|
||||
const qtyRaw = request.body?.qty
|
||||
const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw)
|
||||
fastify.post('/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const productId = String(request.body?.productId || '').trim()
|
||||
const qtyRaw = request.body?.qty
|
||||
const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw)
|
||||
|
||||
if (!productId) return reply.code(400).send({ error: 'productId обязателен' })
|
||||
if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' })
|
||||
if (!productId) return reply.code(400).send({ error: 'productId обязателен' })
|
||||
if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' })
|
||||
|
||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||
|
||||
const available = product.inStock ? product.quantity : 1
|
||||
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
||||
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||
const available = product.inStock ? product.quantity : 1
|
||||
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
||||
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||
|
||||
const item = await prisma.cartItem.upsert({
|
||||
where: { userId_productId: { userId, productId } },
|
||||
update: { qty: nextQty },
|
||||
create: { userId, productId, qty: nextQty },
|
||||
})
|
||||
return reply.code(201).send({ item })
|
||||
},
|
||||
)
|
||||
const item = await prisma.cartItem.upsert({
|
||||
where: { userId_productId: { userId, productId } },
|
||||
update: { qty: nextQty },
|
||||
create: { userId, productId, qty: nextQty },
|
||||
})
|
||||
return reply.code(201).send({ item })
|
||||
})
|
||||
|
||||
fastify.patch(
|
||||
'/api/me/cart/items/:id',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const qtyRaw = request.body?.qty
|
||||
const qty = Number(qtyRaw)
|
||||
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' })
|
||||
fastify.patch('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const qtyRaw = request.body?.qty
|
||||
const qty = Number(qtyRaw)
|
||||
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' })
|
||||
|
||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||
|
||||
if (qty === 0) {
|
||||
await prisma.cartItem.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
}
|
||||
|
||||
const available = existing.product.inStock ? existing.product.quantity : 1
|
||||
const nextQty = Math.floor(qty)
|
||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||
|
||||
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
|
||||
return { item: updated }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.delete(
|
||||
'/api/me/cart/items/:id',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||
if (qty === 0) {
|
||||
await prisma.cartItem.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const available = existing.product.inStock ? existing.product.quantity : 1
|
||||
const nextQty = Math.floor(qty)
|
||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||
|
||||
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
|
||||
return { item: updated }
|
||||
})
|
||||
|
||||
fastify.delete('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||
await prisma.cartItem.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
+115
-143
@@ -1,155 +1,127 @@
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
export async function registerUserMessageRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/api/me/orders/:id/messages",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
const items = await prisma.orderMessage.findMany({
|
||||
where: { orderId: id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return { items };
|
||||
},
|
||||
);
|
||||
fastify.get('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
const items = await prisma.orderMessage.findMany({
|
||||
where: { orderId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
"/api/me/orders/:id/messages",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
const text = String(request.body?.text || "").trim();
|
||||
if (!text) return reply.code(400).send({ error: "Сообщение пустое" });
|
||||
if (text.length > 2000)
|
||||
return reply.code(400).send({ error: "Сообщение слишком длинное" });
|
||||
const msg = await prisma.orderMessage.create({
|
||||
data: { orderId: id, authorType: "user", text },
|
||||
});
|
||||
fastify.post('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
const text = String(request.body?.text || '').trim()
|
||||
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
|
||||
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
|
||||
const msg = await prisma.orderMessage.create({
|
||||
data: { orderId: id, authorType: 'user', text },
|
||||
})
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
|
||||
orderId: id,
|
||||
authorType: "user",
|
||||
messageId: msg.id,
|
||||
preview: text,
|
||||
});
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
|
||||
orderId: id,
|
||||
authorType: 'user',
|
||||
messageId: msg.id,
|
||||
preview: text,
|
||||
})
|
||||
|
||||
return reply.code(201).send({ item: msg });
|
||||
},
|
||||
);
|
||||
return reply.code(201).send({ item: msg })
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
"/api/me/messages/unread-count",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub;
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (orders.length === 0) return { count: 0 };
|
||||
fastify.get('/api/me/messages/unread-count', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (orders.length === 0) return { count: 0 }
|
||||
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
const lastReadByOrder = new Map(
|
||||
readStates.map((r) => [r.orderId, r.lastReadAt]),
|
||||
);
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
})
|
||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||
|
||||
let count = 0;
|
||||
for (const o of orders) {
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0);
|
||||
const n = await prisma.orderMessage.count({
|
||||
where: {
|
||||
orderId: o.id,
|
||||
authorType: "admin",
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
});
|
||||
count += n;
|
||||
}
|
||||
return { count };
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/api/me/conversations",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub;
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId, messages: { some: {} } },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
deliveryType: true,
|
||||
messages: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { text: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
const lastReadByOrder = new Map(
|
||||
readStates.map((r) => [r.orderId, r.lastReadAt]),
|
||||
);
|
||||
|
||||
const items = [];
|
||||
for (const o of orders) {
|
||||
const lastMsg = o.messages[0];
|
||||
if (!lastMsg) continue;
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0);
|
||||
const unreadCount = await prisma.orderMessage.count({
|
||||
where: {
|
||||
orderId: o.id,
|
||||
authorType: "admin",
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
let count = 0
|
||||
for (const o of orders) {
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||
const n = await prisma.orderMessage.count({
|
||||
where: {
|
||||
orderId: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
lastMessageAt: lastMsg.createdAt,
|
||||
preview:
|
||||
lastMsg.text.length > 280
|
||||
? `${lastMsg.text.slice(0, 277)}…`
|
||||
: lastMsg.text,
|
||||
unreadCount,
|
||||
});
|
||||
}
|
||||
return { items };
|
||||
},
|
||||
);
|
||||
authorType: 'admin',
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
})
|
||||
count += n
|
||||
}
|
||||
return { count }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
"/api/me/orders/:id/messages/read",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
fastify.get('/api/me/conversations', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId, messages: { some: {} } },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
deliveryType: true,
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: { text: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
const now = new Date();
|
||||
await prisma.userOrderMessageReadState.upsert({
|
||||
where: { userId_orderId: { userId, orderId: id } },
|
||||
create: { userId, orderId: id, lastReadAt: now },
|
||||
update: { lastReadAt: now },
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
})
|
||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||
|
||||
const items = []
|
||||
for (const o of orders) {
|
||||
const lastMsg = o.messages[0]
|
||||
if (!lastMsg) continue
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||
const unreadCount = await prisma.orderMessage.count({
|
||||
where: {
|
||||
orderId: o.id,
|
||||
authorType: 'admin',
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
})
|
||||
items.push({
|
||||
orderId: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
lastMessageAt: lastMsg.createdAt,
|
||||
preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text,
|
||||
unreadCount,
|
||||
})
|
||||
}
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.post('/api/me/orders/:id/messages/read', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
|
||||
const now = new Date()
|
||||
await prisma.userOrderMessageReadState.upsert({
|
||||
where: { userId_orderId: { userId, orderId: id } },
|
||||
create: { userId, orderId: id, lastReadAt: now },
|
||||
update: { lastReadAt: now },
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
|
||||
+214
-255
@@ -1,312 +1,271 @@
|
||||
import { isDeliveryCarrier } from "../lib/delivery-carrier.js";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
export async function registerUserOrderRoutes(fastify) {
|
||||
// ---- Создание заказа (checkout) ----
|
||||
|
||||
fastify.post(
|
||||
"/api/me/orders",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const deliveryTypeRaw = request.body?.deliveryType;
|
||||
const deliveryType =
|
||||
deliveryTypeRaw === undefined ||
|
||||
deliveryTypeRaw === null ||
|
||||
deliveryTypeRaw === ""
|
||||
? "delivery"
|
||||
: String(deliveryTypeRaw).trim();
|
||||
fastify.post('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const deliveryTypeRaw = request.body?.deliveryType
|
||||
const deliveryType =
|
||||
deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === ''
|
||||
? 'delivery'
|
||||
: String(deliveryTypeRaw).trim()
|
||||
|
||||
const addressId = String(request.body?.addressId || "").trim();
|
||||
const commentRaw = request.body?.comment;
|
||||
const comment =
|
||||
commentRaw === null || commentRaw === undefined
|
||||
? null
|
||||
: String(commentRaw).trim();
|
||||
const addressId = String(request.body?.addressId || '').trim()
|
||||
const commentRaw = request.body?.comment
|
||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||
|
||||
const paymentMethodRaw = request.body?.paymentMethod;
|
||||
const paymentMethod =
|
||||
paymentMethodRaw === undefined ||
|
||||
paymentMethodRaw === null ||
|
||||
paymentMethodRaw === ""
|
||||
? "online"
|
||||
: String(paymentMethodRaw).trim();
|
||||
if (paymentMethod !== "online" && paymentMethod !== "on_pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "paymentMethod должен быть online | on_pickup" });
|
||||
const paymentMethodRaw = request.body?.paymentMethod
|
||||
const paymentMethod =
|
||||
paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === ''
|
||||
? 'online'
|
||||
: String(paymentMethodRaw).trim()
|
||||
if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') {
|
||||
return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' })
|
||||
}
|
||||
|
||||
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
||||
}
|
||||
|
||||
const carrierRaw = request.body?.deliveryCarrier
|
||||
let deliveryCarrier = null
|
||||
if (deliveryType === 'delivery') {
|
||||
const carrierStr =
|
||||
carrierRaw === undefined || carrierRaw === null || carrierRaw === '' ? '' : String(carrierRaw).trim()
|
||||
if (!isDeliveryCarrier(carrierStr)) {
|
||||
return reply.code(400).send({
|
||||
error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST',
|
||||
})
|
||||
}
|
||||
deliveryCarrier = carrierStr
|
||||
}
|
||||
|
||||
if (deliveryType !== "delivery" && deliveryType !== "pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "deliveryType должен быть delivery | pickup" });
|
||||
if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({
|
||||
error: 'Оплата при получении доступна только для самовывоза',
|
||||
})
|
||||
}
|
||||
|
||||
let address = null
|
||||
if (deliveryType === 'delivery') {
|
||||
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
|
||||
address = await prisma.shippingAddress.findFirst({
|
||||
where: { id: addressId, userId },
|
||||
})
|
||||
if (!address) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
}
|
||||
|
||||
const cartItems = await prisma.cartItem.findMany({
|
||||
where: { userId },
|
||||
include: { product: true },
|
||||
})
|
||||
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
|
||||
|
||||
for (const ci of cartItems) {
|
||||
const available = ci.product.inStock ? ci.product.quantity : 1
|
||||
if (ci.qty > available) {
|
||||
return reply.code(409).send({
|
||||
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const carrierRaw = request.body?.deliveryCarrier;
|
||||
let deliveryCarrier = null;
|
||||
if (deliveryType === "delivery") {
|
||||
const carrierStr =
|
||||
carrierRaw === undefined || carrierRaw === null || carrierRaw === ""
|
||||
? ""
|
||||
: String(carrierRaw).trim();
|
||||
if (!isDeliveryCarrier(carrierStr)) {
|
||||
return reply.code(400).send({
|
||||
error:
|
||||
"deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST",
|
||||
});
|
||||
}
|
||||
deliveryCarrier = carrierStr;
|
||||
}
|
||||
const itemsPayload = cartItems.map((ci) => ({
|
||||
productId: ci.productId,
|
||||
qty: ci.qty,
|
||||
titleSnapshot: ci.product.title,
|
||||
priceCentsSnapshot: ci.product.priceCents,
|
||||
}))
|
||||
|
||||
if (paymentMethod === "on_pickup" && deliveryType !== "pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error: "Оплата при получении доступна только для самовывоза",
|
||||
});
|
||||
}
|
||||
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
|
||||
const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0
|
||||
const totalCents = itemsSubtotalCents + deliveryFeeCents
|
||||
|
||||
let address = null;
|
||||
if (deliveryType === "delivery") {
|
||||
if (!addressId)
|
||||
return reply.code(400).send({ error: "Выберите адрес доставки" });
|
||||
address = await prisma.shippingAddress.findFirst({
|
||||
where: { id: addressId, userId },
|
||||
});
|
||||
if (!address) return reply.code(404).send({ error: "Адрес не найден" });
|
||||
}
|
||||
const addressSnapshotJson =
|
||||
deliveryType === 'pickup'
|
||||
? JSON.stringify({ deliveryType: 'pickup' })
|
||||
: JSON.stringify({
|
||||
deliveryType: 'delivery',
|
||||
id: address.id,
|
||||
label: address.label,
|
||||
recipientName: address.recipientName,
|
||||
recipientPhone: address.recipientPhone,
|
||||
addressLine: address.addressLine,
|
||||
comment: address.comment,
|
||||
lat: address.lat,
|
||||
lng: address.lng,
|
||||
})
|
||||
|
||||
const cartItems = await prisma.cartItem.findMany({
|
||||
where: { userId },
|
||||
include: { product: true },
|
||||
});
|
||||
if (cartItems.length === 0)
|
||||
return reply.code(400).send({ error: "Корзина пуста" });
|
||||
let initialStatus = 'PENDING_PAYMENT'
|
||||
let deliveryFeeLocked = true
|
||||
if (paymentMethod === 'on_pickup') {
|
||||
initialStatus = 'IN_PROGRESS'
|
||||
} else if (deliveryType === 'delivery') {
|
||||
initialStatus = 'PENDING_PAYMENT'
|
||||
deliveryFeeLocked = false
|
||||
}
|
||||
|
||||
for (const ci of cartItems) {
|
||||
const available = ci.product.inStock ? ci.product.quantity : 1;
|
||||
if (ci.qty > available) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
let created
|
||||
try {
|
||||
created = await prisma.$transaction(async (tx) => {
|
||||
for (const ci of cartItems) {
|
||||
if (!ci.product.inStock) continue
|
||||
|
||||
const itemsPayload = cartItems.map((ci) => ({
|
||||
productId: ci.productId,
|
||||
qty: ci.qty,
|
||||
titleSnapshot: ci.product.title,
|
||||
priceCentsSnapshot: ci.product.priceCents,
|
||||
}));
|
||||
|
||||
const itemsSubtotalCents = itemsPayload.reduce(
|
||||
(sum, i) => sum + i.priceCentsSnapshot * i.qty,
|
||||
0,
|
||||
);
|
||||
const deliveryFeeCents = deliveryType === "delivery" ? 50000 : 0;
|
||||
const totalCents = itemsSubtotalCents + deliveryFeeCents;
|
||||
|
||||
const addressSnapshotJson =
|
||||
deliveryType === "pickup"
|
||||
? JSON.stringify({ deliveryType: "pickup" })
|
||||
: JSON.stringify({
|
||||
deliveryType: "delivery",
|
||||
id: address.id,
|
||||
label: address.label,
|
||||
recipientName: address.recipientName,
|
||||
recipientPhone: address.recipientPhone,
|
||||
addressLine: address.addressLine,
|
||||
comment: address.comment,
|
||||
lat: address.lat,
|
||||
lng: address.lng,
|
||||
});
|
||||
|
||||
let initialStatus = "PENDING_PAYMENT";
|
||||
let deliveryFeeLocked = true;
|
||||
if (paymentMethod === "on_pickup") {
|
||||
initialStatus = "IN_PROGRESS";
|
||||
} else if (deliveryType === "delivery") {
|
||||
initialStatus = "PENDING_PAYMENT";
|
||||
deliveryFeeLocked = false;
|
||||
}
|
||||
|
||||
let created;
|
||||
try {
|
||||
created = await prisma.$transaction(async (tx) => {
|
||||
for (const ci of cartItems) {
|
||||
if (!ci.product.inStock) continue;
|
||||
|
||||
const res = await tx.product.updateMany({
|
||||
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
||||
data: { quantity: { decrement: ci.qty } },
|
||||
});
|
||||
if (res.count !== 1) {
|
||||
throw new Error(`Недостаточно товара: "${ci.product.title}"`);
|
||||
}
|
||||
const res = await tx.product.updateMany({
|
||||
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
||||
data: { quantity: { decrement: ci.qty } },
|
||||
})
|
||||
if (res.count !== 1) {
|
||||
throw new Error(`Недостаточно товара: "${ci.product.title}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
userId,
|
||||
status: initialStatus,
|
||||
deliveryFeeLocked,
|
||||
deliveryType,
|
||||
deliveryCarrier,
|
||||
paymentMethod,
|
||||
itemsSubtotalCents,
|
||||
deliveryFeeCents,
|
||||
totalCents,
|
||||
currency: "RUB",
|
||||
addressSnapshotJson,
|
||||
comment: comment && comment.length ? comment : null,
|
||||
items: {
|
||||
create: itemsPayload.map((i) => ({
|
||||
productId: i.productId,
|
||||
qty: i.qty,
|
||||
titleSnapshot: i.titleSnapshot,
|
||||
priceCentsSnapshot: i.priceCentsSnapshot,
|
||||
})),
|
||||
},
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
userId,
|
||||
status: initialStatus,
|
||||
deliveryFeeLocked,
|
||||
deliveryType,
|
||||
deliveryCarrier,
|
||||
paymentMethod,
|
||||
itemsSubtotalCents,
|
||||
deliveryFeeCents,
|
||||
totalCents,
|
||||
currency: 'RUB',
|
||||
addressSnapshotJson,
|
||||
comment: comment && comment.length ? comment : null,
|
||||
items: {
|
||||
create: itemsPayload.map((i) => ({
|
||||
productId: i.productId,
|
||||
qty: i.qty,
|
||||
titleSnapshot: i.titleSnapshot,
|
||||
priceCentsSnapshot: i.priceCentsSnapshot,
|
||||
})),
|
||||
},
|
||||
});
|
||||
await tx.cartItem.deleteMany({ where: { userId } });
|
||||
return order;
|
||||
});
|
||||
} catch (e) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error: (e instanceof Error && e.message) || "Недостаточно товара",
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
await tx.cartItem.deleteMany({ where: { userId } })
|
||||
return order
|
||||
})
|
||||
} catch (e) {
|
||||
return reply.code(409).send({
|
||||
error: (e instanceof Error && e.message) || 'Недостаточно товара',
|
||||
})
|
||||
}
|
||||
|
||||
// Emit notification events
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, {
|
||||
orderId: created.id,
|
||||
userId,
|
||||
totalCents: created.totalCents,
|
||||
itemsCount: cartItems.length,
|
||||
deliveryType: created.deliveryType,
|
||||
});
|
||||
// Emit notification events
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, {
|
||||
orderId: created.id,
|
||||
userId,
|
||||
totalCents: created.totalCents,
|
||||
itemsCount: cartItems.length,
|
||||
deliveryType: created.deliveryType,
|
||||
})
|
||||
|
||||
// Also emit admin notification
|
||||
request.server.eventBus.emit("order:created:admin", {
|
||||
orderId: created.id,
|
||||
userId,
|
||||
userEmail: request.user.email || "",
|
||||
totalCents: created.totalCents,
|
||||
itemsCount: cartItems.length,
|
||||
deliveryType: created.deliveryType,
|
||||
});
|
||||
// Also emit admin notification
|
||||
request.server.eventBus.emit('order:created:admin', {
|
||||
orderId: created.id,
|
||||
userId,
|
||||
userEmail: request.user.email || '',
|
||||
totalCents: created.totalCents,
|
||||
itemsCount: cartItems.length,
|
||||
deliveryType: created.deliveryType,
|
||||
})
|
||||
|
||||
return reply.code(201).send({ orderId: created.id });
|
||||
},
|
||||
);
|
||||
return reply.code(201).send({ orderId: created.id })
|
||||
})
|
||||
|
||||
fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
include: { items: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return {
|
||||
items: orders.map((o) => ({
|
||||
id: o.id,
|
||||
status: o.status,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
createdAt: o.createdAt,
|
||||
updatedAt: o.updatedAt,
|
||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { id, userId },
|
||||
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||
})
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
return { item: order }
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
"/api/me/orders",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub;
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
include: { items: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return {
|
||||
items: orders.map((o) => ({
|
||||
id: o.id,
|
||||
status: o.status,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
createdAt: o.createdAt,
|
||||
updatedAt: o.updatedAt,
|
||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/api/me/orders/:id",
|
||||
'/api/me/orders/:id/review-eligibility',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { id, userId },
|
||||
include: { items: true, messages: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
return { item: order };
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/api/me/orders/:id/review-eligibility",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { id, userId },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
if (order.status !== "DONE") {
|
||||
return { canReview: false, items: [] };
|
||||
})
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
if (order.status !== 'DONE') {
|
||||
return { canReview: false, items: [] }
|
||||
}
|
||||
|
||||
const uniq = new Map();
|
||||
const uniq = new Map()
|
||||
for (const it of order.items) {
|
||||
if (!uniq.has(it.productId)) {
|
||||
uniq.set(it.productId, {
|
||||
productId: it.productId,
|
||||
title: it.titleSnapshot,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
const productIds = [...uniq.keys()];
|
||||
const productIds = [...uniq.keys()]
|
||||
const existing = await prisma.review.findMany({
|
||||
where: { userId, productId: { in: productIds } },
|
||||
select: { productId: true },
|
||||
});
|
||||
const reviewed = new Set(existing.map((r) => r.productId));
|
||||
})
|
||||
const reviewed = new Set(existing.map((r) => r.productId))
|
||||
return {
|
||||
canReview: true,
|
||||
items: [...uniq.values()].map((x) => ({
|
||||
...x,
|
||||
hasReview: reviewed.has(x.productId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
"/api/me/orders/:id/confirm-received",
|
||||
'/api/me/orders/:id/confirm-received',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
|
||||
const okDelivery =
|
||||
order.deliveryType === "delivery" && order.status === "SHIPPED";
|
||||
const okPickup =
|
||||
order.deliveryType === "pickup" && order.status === "READY_FOR_PICKUP";
|
||||
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
|
||||
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
|
||||
if (!okDelivery && !okPickup) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: "Сейчас нельзя подтвердить получение заказа" });
|
||||
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
|
||||
}
|
||||
|
||||
await prisma.order.update({ where: { id }, data: { status: "DONE" } });
|
||||
return { ok: true, status: "DONE" };
|
||||
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
|
||||
return { ok: true, status: 'DONE' }
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,142 +1,114 @@
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { escapeHtml } from "../lib/escape-html.js";
|
||||
import { getOtherUploadMaxFileBytes } from "../lib/upload-limits.js";
|
||||
import { saveImageBufferToUploads } from "../lib/upload-images.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { escapeHtml } from '../lib/escape-html.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { saveImageBufferToUploads } from '../lib/upload-images.js'
|
||||
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
|
||||
|
||||
export async function registerUserPaymentRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/api/me/orders/:id/pay",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
|
||||
const paymentMethod = order.paymentMethod ?? "online";
|
||||
if (paymentMethod === "on_pickup") {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error:
|
||||
"Для этого заказа оплата при получении — кнопка оплаты не нужна.",
|
||||
});
|
||||
}
|
||||
const paymentMethod = order.paymentMethod ?? 'online'
|
||||
if (paymentMethod === 'on_pickup') {
|
||||
return reply.code(409).send({
|
||||
error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.',
|
||||
})
|
||||
}
|
||||
|
||||
if (order.status !== "PENDING_PAYMENT") {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: "Сейчас нельзя выполнить оплату для этого заказа" });
|
||||
}
|
||||
if (order.status !== 'PENDING_PAYMENT') {
|
||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||
}
|
||||
|
||||
if (!request.isMultipart()) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error:
|
||||
"Отправьте multipart/form-data: поле detail и/или файл receipt",
|
||||
});
|
||||
}
|
||||
if (!request.isMultipart()) {
|
||||
return reply.code(400).send({
|
||||
error: 'Отправьте multipart/form-data: поле detail и/или файл receipt',
|
||||
})
|
||||
}
|
||||
|
||||
let detail = "";
|
||||
let receiptBuffer = null;
|
||||
let receiptFilename = "";
|
||||
try {
|
||||
const otherLimit = getOtherUploadMaxFileBytes();
|
||||
const parts = request.parts({
|
||||
limits: {
|
||||
fileSize: otherLimit,
|
||||
files: 2,
|
||||
},
|
||||
});
|
||||
for await (const part of parts) {
|
||||
if (part.file) {
|
||||
if (part.fieldname === "receipt") {
|
||||
if (receiptBuffer !== null) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "Допускается один файл receipt" });
|
||||
}
|
||||
receiptBuffer = await part.toBuffer();
|
||||
receiptFilename = part.filename ?? "receipt";
|
||||
let detail = ''
|
||||
let receiptBuffer = null
|
||||
let receiptFilename = ''
|
||||
try {
|
||||
const otherLimit = getOtherUploadMaxFileBytes()
|
||||
const parts = request.parts({
|
||||
limits: {
|
||||
fileSize: otherLimit,
|
||||
files: 2,
|
||||
},
|
||||
})
|
||||
for await (const part of parts) {
|
||||
if (part.file) {
|
||||
if (part.fieldname === 'receipt') {
|
||||
if (receiptBuffer !== null) {
|
||||
return reply.code(400).send({ error: 'Допускается один файл receipt' })
|
||||
}
|
||||
} else if (part.fieldname === "detail") {
|
||||
detail = String(part.value ?? "").trim();
|
||||
receiptBuffer = await part.toBuffer()
|
||||
receiptFilename = part.filename ?? 'receipt'
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : "Не удалось разобрать форму";
|
||||
return reply.code(400).send({ error: msg });
|
||||
}
|
||||
|
||||
const hasDetail = detail.length > 0;
|
||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0;
|
||||
|
||||
if (!hasDetail && !hasReceipt) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error: "Укажите текст о платеже и/или прикрепите изображение чека",
|
||||
});
|
||||
}
|
||||
|
||||
const maxDetail = 2000;
|
||||
if (detail.length > maxDetail) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: `Текст не длиннее ${maxDetail} символов` });
|
||||
}
|
||||
|
||||
let attachmentUrl = null;
|
||||
if (hasReceipt) {
|
||||
try {
|
||||
attachmentUrl = await saveImageBufferToUploads(
|
||||
receiptFilename,
|
||||
receiptBuffer,
|
||||
);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Не удалось сохранить файл";
|
||||
const statusCode =
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"statusCode" in err &&
|
||||
Number.isInteger(err.statusCode)
|
||||
? Number(err.statusCode)
|
||||
: 400;
|
||||
return reply.code(statusCode).send({ error: message });
|
||||
} else if (part.fieldname === 'detail') {
|
||||
detail = String(part.value ?? '').trim()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
|
||||
return reply.code(400).send({ error: msg })
|
||||
}
|
||||
|
||||
const bodyHtml = hasDetail
|
||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, "<br/>")}</p>`
|
||||
: "";
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`;
|
||||
const hasDetail = detail.length > 0
|
||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
||||
|
||||
if (!hasDetail && !hasReceipt) {
|
||||
return reply.code(400).send({
|
||||
error: 'Укажите текст о платеже и/или прикрепите изображение чека',
|
||||
})
|
||||
}
|
||||
|
||||
const maxDetail = 2000
|
||||
if (detail.length > maxDetail) {
|
||||
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
|
||||
}
|
||||
|
||||
let attachmentUrl = null
|
||||
if (hasReceipt) {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.orderMessage.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
authorType: "user",
|
||||
text: messageText,
|
||||
attachmentUrl,
|
||||
},
|
||||
});
|
||||
});
|
||||
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
||||
} catch (err) {
|
||||
return reply.code(500).send({ error: "Не удалось сохранить оплату" });
|
||||
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
||||
const statusCode =
|
||||
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
|
||||
? Number(err.statusCode)
|
||||
: 400
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
}
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||
orderId: id,
|
||||
userId,
|
||||
paymentStatus: "pending",
|
||||
});
|
||||
const bodyHtml = hasDetail ? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>` : ''
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
|
||||
|
||||
return { ok: true, status: "PENDING_PAYMENT" };
|
||||
},
|
||||
);
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.orderMessage.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
authorType: 'user',
|
||||
text: messageText,
|
||||
attachmentUrl,
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
||||
}
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||
orderId: id,
|
||||
userId,
|
||||
paymentStatus: 'pending',
|
||||
})
|
||||
|
||||
return { ok: true, status: 'PENDING_PAYMENT' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { ensureUserNotificationPreference } from '../../lib/notifications/preferences.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function registerUserNotificationRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/me/notifications/settings',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const prefs = await ensureUserNotificationPreference(userId)
|
||||
return { settings: prefs }
|
||||
},
|
||||
)
|
||||
fastify.get('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const prefs = await ensureUserNotificationPreference(userId)
|
||||
return { settings: prefs }
|
||||
})
|
||||
|
||||
fastify.put(
|
||||
'/api/me/notifications/settings',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const body = request.body || {}
|
||||
fastify.put('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const body = request.body || {}
|
||||
|
||||
const data = {}
|
||||
if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled)
|
||||
if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated)
|
||||
if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged)
|
||||
if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived)
|
||||
if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged)
|
||||
if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted)
|
||||
const data = {}
|
||||
if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled)
|
||||
if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated)
|
||||
if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged)
|
||||
if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived)
|
||||
if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged)
|
||||
if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted)
|
||||
|
||||
const prefs = await prisma.notificationPreference.upsert({
|
||||
where: { userId },
|
||||
create: { userId, ...data },
|
||||
update: data,
|
||||
})
|
||||
const prefs = await prisma.notificationPreference.upsert({
|
||||
where: { userId },
|
||||
create: { userId, ...data },
|
||||
update: data,
|
||||
})
|
||||
|
||||
return { settings: prefs }
|
||||
},
|
||||
)
|
||||
return { settings: prefs }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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_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_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',
|
||||
'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',
|
||||
'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