+}
+
+export function NavigationDrawer({
+ open,
+ onClose,
+ user,
+ isAdmin,
+ navItems,
+ themeControls,
+ onNavigate,
+ onLogout,
+ ThemeControlsMobile,
+}: Props) {
+ const go = (to: string) => {
+ onClose()
+ onNavigate(to)
+ }
+
+ return (
+
+
+
+
+ {STORE_NAME}
+
+
+
+ {navItems.map((i) => (
+
+ ))}
+ {!isAdmin && (
+
+ )}
+ {user && !isAdmin && (
+
+ )}
+ {!isAdmin && (
+
+ )}
+ {!user && isAdmin && (
+
+ )}
+ {user && (
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json
index 3e0bd24..b2b639a 100644
--- a/client/tsconfig.app.json
+++ b/client/tsconfig.app.json
@@ -2,7 +2,8 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
- "@/*": ["src/*"]
+ "@/*": ["src/*"],
+ "@shared/*": ["../shared/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 10c196a..106c3fb 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
const rootDir = fileURLToPath(new URL('.', import.meta.url))
+const projectRoot = path.resolve(rootDir, '..')
// https://vite.dev/config/
export default defineConfig({
@@ -11,9 +12,13 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(rootDir, 'src'),
+ '@shared': path.resolve(projectRoot, 'shared'),
},
},
server: {
+ fs: {
+ allow: [projectRoot],
+ },
port: 5173,
proxy: {
'/api': {
diff --git a/docs/deploy-changes.md b/docs/deploy-changes.md
index 404fd32..8dd5e1b 100644
--- a/docs/deploy-changes.md
+++ b/docs/deploy-changes.md
@@ -16,6 +16,7 @@
4. Если не помогло: вручную `cd client && npm run build`, затем **`./scripts/deploy-ssh.sh --frontend-only --skip-build`** (выложится уже готовый `client/dist`).
- **Бэкенд**: при изменениях в `server/prisma` — миграции должны быть в репозитории; на сервере выполнится `prisma migrate deploy` (см. скрипт деплоя).
+- **Общие константы**: каталог `shared/constants/` синхронизируется скриптом деплоя вместе с `server/` (автоматически в `deploy_backend`).
## 2. Переменные окружения на сервере
@@ -91,6 +92,7 @@ npm run build
## 6. Что не потерять при деплое
+- Каталоги **`shared/`** и **`server/`** должны быть рядом на одном уровне (например, `/opt/craftshop/shared/constants/order-status.js` и `/opt/craftshop/server/src/lib/order-status.js`). Скрипт деплоя синхронизирует оба.
- Файл **SQLite** и каталог **`server/uploads/`** должны лежать на **персистентном диске** (не внутри временного слоя контейнера без тома).
- Nginx (или аналог): **`/api`** → прокси на Fastify, **`/uploads`** → те же файлы, что пишет сервер, либо прокси на `@fastify/static` (см. [test-deploy-proxmox.md](test-deploy-proxmox.md)).
diff --git a/opencode.jsonc b/opencode.jsonc
new file mode 100644
index 0000000..e0ed0db
--- /dev/null
+++ b/opencode.jsonc
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "context7": {
+ "type": "remote",
+ "url": "https://mcp.context7.com/mcp",
+ },
+ },
+}
diff --git a/scripts/deploy-ssh.sh b/scripts/deploy-ssh.sh
index b6d072f..8ea53c3 100644
--- a/scripts/deploy-ssh.sh
+++ b/scripts/deploy-ssh.sh
@@ -115,9 +115,10 @@ build_rsync_rsh() {
deploy_backend() {
remote_exec mkdir -p "$DEPLOY_PATH/server"
+ remote_exec mkdir -p "$DEPLOY_PATH/shared"
if should_use_tar_transport; then
- echo ">>> Бэкенд: tar|ssh → $REMOTE:$DEPLOY_PATH/server/"
+ echo ">>> Бэкенд (server): tar|ssh → $REMOTE:$DEPLOY_PATH/server/"
if [[ -n "$DRY_RUN" ]]; then
echo "(dry-run) без передачи tar"
else
@@ -132,9 +133,17 @@ deploy_backend() {
--exclude=.dev_env \
.
) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/server && tar xzf - -C ${DEPLOY_PATH}/server"
+
+ echo ">>> Бэкенд (shared): tar|ssh → $REMOTE:$DEPLOY_PATH/shared/"
+ (
+ cd "$ROOT/shared" || exit 1
+ tar -czf - \
+ --exclude=.git \
+ .
+ ) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/shared && tar xzf - -C ${DEPLOY_PATH}/shared"
fi
else
- echo ">>> Бэкенд: rsync → $REMOTE:$DEPLOY_PATH/server/"
+ echo ">>> Бэкенд (server): rsync → $REMOTE:$DEPLOY_PATH/server/"
local rsh
rsh="$(build_rsync_rsh)"
@@ -147,6 +156,12 @@ deploy_backend() {
--exclude .env \
--exclude .dev_env \
"${ROOT}/server/" "${REMOTE}:${DEPLOY_PATH}/server/"
+
+ echo ">>> Бэкенд (shared): rsync → $REMOTE:$DEPLOY_PATH/shared/"
+ rsync "${RSYNC_OPTS[@]}" \
+ -e "$rsh" \
+ --exclude .git \
+ "${ROOT}/shared/" "${REMOTE}:${DEPLOY_PATH}/shared/"
fi
if [[ -n "$DRY_RUN" ]]; then
@@ -164,6 +179,7 @@ deploy_backend() {
if [[ "${DEPLOY_USER}" == "root" && "${DEPLOY_SKIP_CHOWN}" != "1" ]]; then
echo ">>> Права на серверный каталог: chown ${DEPLOY_SERVER_OWNER} (деплой от root)"
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/server"
+ remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/shared"
fi
if [[ -n "${DEPLOY_RESTART_CMD}" ]]; then
echo ">>> Рестарт: $DEPLOY_RESTART_CMD"
diff --git a/server/src/index.js b/server/src/index.js
index 26769c4..5ea1985 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -11,6 +11,11 @@ import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload
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 { registerOAuthSocialRoutes } from './routes/oauth-social.js'
const port = Number(process.env.PORT) || 3333
@@ -57,6 +62,11 @@ fastify.decorate('authenticate', async function authenticate(request, reply) {
registerAuth(fastify)
await registerAuthRoutes(fastify)
+await registerUserAddressRoutes(fastify)
+await registerUserCartRoutes(fastify)
+await registerUserMessageRoutes(fastify)
+await registerUserOrderRoutes(fastify)
+await registerUserPaymentRoutes(fastify)
await registerOAuthSocialRoutes(fastify)
await registerApiRoutes(fastify)
await ensureAdminUser()
diff --git a/server/src/lib/delivery-carrier.js b/server/src/lib/delivery-carrier.js
index 34ca6c2..4656f69 100644
--- a/server/src/lib/delivery-carrier.js
+++ b/server/src/lib/delivery-carrier.js
@@ -1,4 +1,4 @@
-export const DELIVERY_CARRIERS = ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST']
+export { DELIVERY_CARRIERS } from '../../../shared/constants/delivery-carrier.js'
/**
* @param {unknown} value
diff --git a/server/src/lib/order-status.js b/server/src/lib/order-status.js
index 4971b57..aeb8d71 100644
--- a/server/src/lib/order-status.js
+++ b/server/src/lib/order-status.js
@@ -1,15 +1,4 @@
-export const ORDER_STATUSES = [
- 'DRAFT',
- 'DELIVERY_FEE_ADJUSTMENT',
- 'PENDING_PAYMENT',
- 'PAYMENT_VERIFICATION',
- 'PAID',
- 'IN_PROGRESS',
- 'SHIPPED',
- 'READY_FOR_PICKUP',
- 'DONE',
- 'CANCELLED',
-]
+export { ORDER_STATUSES } from '../../../shared/constants/order-status.js'
/**
* Переходы, которые делает админ через PATCH /api/admin/orders/:id/status
diff --git a/server/src/lib/upload-limits.js b/server/src/lib/upload-limits.js
index e22384e..94e6acb 100644
--- a/server/src/lib/upload-limits.js
+++ b/server/src/lib/upload-limits.js
@@ -1,10 +1,8 @@
+import { ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT as SHARED_DEFAULT } from '../../../shared/constants/upload-limits.js'
+
const MB = 1024 * 1024
-/**
- * Один файл изображения в админке: товары, галерея (`POST /api/admin/uploads`).
- * Должно совпадать с лимитом плагина multipart в `server/src/index.js`.
- */
-export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = 20 * MB
+export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = SHARED_DEFAULT
/** @deprecated используйте ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT; оставлено для совместимости импортов */
export const PRODUCT_IMAGE_MAX_FILE_BYTES = ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
diff --git a/server/src/routes/api.js b/server/src/routes/api.js
index 35d0bbb..b0c30c7 100644
--- a/server/src/routes/api.js
+++ b/server/src/routes/api.js
@@ -15,18 +15,18 @@ import { registerPublicCatalogRoutes } from './api/public-catalog.js'
import { registerPublicReviewRoutes } from './api/public-reviews.js'
export async function registerApiRoutes(fastify) {
- await registerPublicCatalogRoutes(fastify, { mapProductForApi })
+ fastify.decorate('slugify', slugify)
+ fastify.decorate('parseMaterialsInput', parseMaterialsInput)
+ fastify.decorate('mapProductForApi', mapProductForApi)
+
+ await registerPublicCatalogRoutes(fastify)
await registerPublicReviewRoutes(fastify)
await registerInfoPageRoutes(fastify)
await registerCatalogSliderRoutes(fastify)
- await registerAdminProductRoutes(fastify, {
- slugify,
- parseMaterialsInput,
- mapProductForApi,
- })
+ await registerAdminProductRoutes(fastify)
await registerAdminGalleryRoutes(fastify)
- await registerAdminCategoryRoutes(fastify, { slugify })
+ await registerAdminCategoryRoutes(fastify)
await registerAdminOrderRoutes(fastify)
await registerAdminReviewRoutes(fastify)
await registerAdminUserRoutes(fastify)
diff --git a/server/src/routes/api/admin-categories.js b/server/src/routes/api/admin-categories.js
index 022cfae..32b2270 100644
--- a/server/src/routes/api/admin-categories.js
+++ b/server/src/routes/api/admin-categories.js
@@ -5,7 +5,7 @@ import {
} from '../../lib/default-category.js'
import { prisma } from '../../lib/prisma.js'
-export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
+export async function registerAdminCategoryRoutes(fastify) {
fastify.get(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
@@ -27,7 +27,7 @@ export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
reply.code(400).send({ error: 'Укажите название категории' })
return
}
- const slug = String(body.slug ?? '').trim() || slugify(name) || `cat-${Date.now()}`
+ 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
diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js
index 87a8d82..e4caafe 100644
--- a/server/src/routes/api/admin-products.js
+++ b/server/src/routes/api/admin-products.js
@@ -8,19 +8,59 @@ import {
} from '../../lib/upload-limits.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
-export async function registerAdminProductRoutes(
- fastify,
- { slugify, parseMaterialsInput, mapProductForApi } = {},
-) {
+const CREATE_PRODUCT_SCHEMA = {
+ body: {
+ type: 'object',
+ required: ['title', 'priceCents'],
+ properties: {
+ title: { type: 'string', minLength: 1 },
+ slug: { type: 'string' },
+ categoryId: { type: 'string' },
+ priceCents: { type: 'number', minimum: 0 },
+ quantity: { type: 'number', minimum: 0 },
+ inStock: { type: 'boolean' },
+ leadTimeDays: { type: 'number', minimum: 1 },
+ shortDescription: { type: 'string' },
+ description: { type: 'string' },
+ materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] },
+ imageUrl: { type: 'string' },
+ imageUrls: { type: 'array', items: { type: 'string' } },
+ published: { type: 'boolean' },
+ },
+ },
+}
+
+const PATCH_PRODUCT_SCHEMA = {
+ body: {
+ type: 'object',
+ properties: {
+ title: { type: 'string', minLength: 1 },
+ slug: { type: 'string' },
+ categoryId: { type: 'string' },
+ priceCents: { type: 'number', minimum: 0 },
+ quantity: { type: 'number', minimum: 0 },
+ inStock: { type: 'boolean' },
+ leadTimeDays: { type: 'number', minimum: 1 },
+ shortDescription: { type: 'string' },
+ description: { type: 'string' },
+ materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] },
+ imageUrl: { type: 'string' },
+ imageUrls: { type: 'array', items: { type: 'string' } },
+ published: { type: 'boolean' },
+ },
+ },
+}
+
+export async function registerAdminProductRoutes(fastify) {
fastify.get(
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
- async () => {
+ async (request) => {
const items = await prisma.product.findMany({
include: { category: true, images: { orderBy: { sort: 'asc' } } },
orderBy: { updatedAt: 'desc' },
})
- return items.map(mapProductForApi)
+ return items.map((p) => request.server.mapProductForApi(p))
},
)
@@ -52,7 +92,7 @@ export async function registerAdminProductRoutes(
fastify.post(
'/api/admin/products',
- { preHandler: [fastify.verifyAdmin] },
+ { preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA },
async (request, reply) => {
const body = request.body ?? {}
const title = String(body.title ?? '').trim()
@@ -60,7 +100,7 @@ export async function registerAdminProductRoutes(
reply.code(400).send({ error: 'Укажите название' })
return
}
- const slug = String(body.slug ?? '').trim() || slugify(title) || `item-${Date.now()}`
+ const slug = String(body.slug ?? '').trim() || request.server.slugify(title) || `item-${Date.now()}`
let categoryId = String(body.categoryId ?? '').trim()
if (!categoryId) {
categoryId = (await getOrCreateUnspecifiedCategory()).id
@@ -115,7 +155,7 @@ export async function registerAdminProductRoutes(
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
description: body.description ? String(body.description) : null,
quantity,
- materials: JSON.stringify(parseMaterialsInput(body.materials)),
+ materials: JSON.stringify(request.server.parseMaterialsInput(body.materials)),
priceCents: Math.round(priceCents),
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
published: Boolean(body.published),
@@ -134,13 +174,13 @@ export async function registerAdminProductRoutes(
},
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
- reply.code(201).send(mapProductForApi(product))
+ reply.code(201).send(request.server.mapProductForApi(product))
},
)
fastify.patch(
'/api/admin/products/:id',
- { preHandler: [fastify.verifyAdmin] },
+ { preHandler: [fastify.verifyAdmin], schema: PATCH_PRODUCT_SCHEMA },
async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
@@ -182,7 +222,7 @@ export async function registerAdminProductRoutes(
data.quantity = Math.floor(n)
}
if (body.materials !== undefined) {
- data.materials = JSON.stringify(parseMaterialsInput(body.materials))
+ data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials))
}
if (body.priceCents !== undefined) {
const p = Number(body.priceCents)
@@ -254,7 +294,7 @@ export async function registerAdminProductRoutes(
data: { ...data, images: imagesUpdate },
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
- return mapProductForApi(product)
+ return request.server.mapProductForApi(product)
},
)
diff --git a/server/src/routes/api/public-catalog.js b/server/src/routes/api/public-catalog.js
index 57cdc98..8012dd1 100644
--- a/server/src/routes/api/public-catalog.js
+++ b/server/src/routes/api/public-catalog.js
@@ -1,5 +1,21 @@
import { prisma } from '../../lib/prisma.js'
+const PUBLIC_PRODUCTS_QUERY_SCHEMA = {
+ querystring: {
+ type: 'object',
+ properties: {
+ categorySlug: { type: 'string' },
+ q: { type: 'string' },
+ availability: { type: 'string', enum: ['all', 'in_stock', 'made_to_order'] },
+ sort: { type: 'string', enum: ['', 'price_asc', 'price_desc'] },
+ page: { type: 'integer', minimum: 1 },
+ pageSize: { type: 'integer', minimum: 1, maximum: 100 },
+ priceMin: { type: 'number', minimum: 0 },
+ priceMax: { type: 'number', minimum: 0 },
+ },
+ },
+}
+
const EMPTY_REVIEWS_SUMMARY = Object.freeze({
approvedReviewCount: 0,
avgRating: null,
@@ -58,12 +74,13 @@ export async function approvedReviewSummariesForProducts(productIds) {
return map
}
-export async function registerPublicCatalogRoutes(fastify, { mapProductForApi } = {}) {
+export async function registerPublicCatalogRoutes(fastify) {
fastify.get('/api/categories', async () => {
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
})
- fastify.get('/api/products', async (request, reply) => {
+ 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() : ''
@@ -134,7 +151,7 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
const summaries = await approvedReviewSummariesForProducts(items.map((it) => it.id))
return {
- items: items.map((p) => mapProductForApi(p, summaries.get(p.id) ?? EMPTY_REVIEWS_SUMMARY)),
+ items: items.map((p) => request.server.mapProductForApi(p, summaries.get(p.id) ?? EMPTY_REVIEWS_SUMMARY)),
total,
page,
pageSize,
@@ -152,7 +169,7 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
return
}
const summaries = await approvedReviewSummariesForProducts([product.id])
- return mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
+ return request.server.mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
})
}
diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js
index a2006b6..2d3e218 100644
--- a/server/src/routes/auth.js
+++ b/server/src/routes/auth.js
@@ -1,9 +1,5 @@
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
-import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
-import { escapeHtml } from '../lib/escape-html.js'
import { prisma } from '../lib/prisma.js'
-import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
-import { saveImageBufferToUploads } from '../lib/upload-images.js'
function mapUserForClient(user) {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
@@ -22,7 +18,6 @@ export async function registerAuthRoutes(fastify) {
const email = normalizeEmail(request.body?.email)
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
- // purpose: login (включает и регистрацию — пользователь создастся при verify)
await issueEmailCode({ email, purpose: 'login' })
return { ok: true }
})
@@ -123,770 +118,4 @@ export async function registerAuthRoutes(fastify) {
return { user: mapUserForClient(updated) }
},
)
-
- // ---- Адреса доставки ----
-
- function normalizePhoneLite(input) {
- const s = String(input || '').trim()
- if (!s) return ''
- return s.replace(/[\s()-]/g, '')
- }
-
- function validateAddressPayload(body, reply) {
- 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 символов' })
-
- const recipientName = String(body?.recipientName || '').trim()
- if (!recipientName) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
- if (recipientName.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
-
- const recipientPhone = normalizePhoneLite(body?.recipientPhone)
- if (!recipientPhone) return reply.code(400).send({ error: 'Укажите телефон получателя' })
- if (!/^\+?\d{7,20}$/.test(recipientPhone)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
-
- const addressLine = String(body?.addressLine || '').trim()
- if (!addressLine) return reply.code(400).send({ error: 'Укажите адрес' })
- if (addressLine.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
-
- 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 символов' })
-
- const lat = Number(body?.lat)
- const lng = Number(body?.lng)
- if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
- if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' })
-
- return {
- label,
- recipientName,
- recipientPhone,
- addressLine,
- comment,
- lat,
- lng,
- }
- }
-
- 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
-
- 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) => {
- await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
- return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
- })
-
- return { item: updated }
- },
- )
-
- // ---- Корзина ----
-
- 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)
-
- 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 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 })
- },
- )
-
- 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: 'Позиция корзины не найдена' })
-
- 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()
- },
- )
-
- // ---- Заказы (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()
-
- 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' })
- }
-
- 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 (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 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'
- if (paymentMethod === 'on_pickup') initialStatus = 'IN_PROGRESS'
- else if (deliveryType === 'delivery') initialStatus = 'DELIVERY_FEE_ADJUSTMENT'
-
- let created
- try {
- created = await prisma.$transaction(async (tx) => {
- for (const ci of cartItems) {
- if (!ci.product.inStock) continue
-
- const res = await tx.product.updateMany({
- where: { id: ci.productId, quantity: { gte: ci.qty } },
- data: { quantity: { decrement: ci.qty } },
- })
- if (res.count !== 1) {
- throw new Error(`Недостаточно товара: "${ci.product.title}"`)
- }
-
- }
-
- const order = await tx.order.create({
- data: {
- userId,
- status: initialStatus,
- 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) || 'Недостаточно товара' })
- }
-
- 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/: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 } })
- 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 }
-
- 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({
- 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 }
- },
- )
-
- 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: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
- }
-
- if (order.status === 'DELIVERY_FEE_ADJUSTMENT') {
- return reply
- .code(409)
- .send({
- error:
- 'Оплата станет доступна после корректировки стоимости доставки администратором.',
- })
- }
-
- let nextStatus = order.status
- if (order.status === 'DRAFT') {
- await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })
- nextStatus = 'PENDING_PAYMENT'
- return { ok: true, status: nextStatus }
- }
-
- if (order.status === 'PAYMENT_VERIFICATION') {
- return { ok: true, status: nextStatus }
- }
-
- if (order.status === 'PENDING_PAYMENT') {
- 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'
- }
- } 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 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 })
- }
- }
-
- const bodyHtml = hasDetail
- ? `${escapeHtml(detail).replace(/\r\n|\n|\r/g, '
')}
`
- : ''
- const messageText = `Подтверждение оплаты (перевод ВТБ / Сбербанк)
${bodyHtml}`
-
- try {
- await prisma.$transaction(async (tx) => {
- await tx.order.update({ where: { id }, data: { status: 'PAYMENT_VERIFICATION' } })
- await tx.orderMessage.create({
- data: {
- orderId: id,
- authorType: 'user',
- text: messageText,
- attachmentUrl,
- },
- })
- })
- } catch (err) {
- return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
- }
-
- return { ok: true, status: 'PAYMENT_VERIFICATION' }
- }
-
- return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
- },
- )
-
- fastify.get(
- '/api/me/orders/:id/review-eligibility',
- { preHandler: [fastify.authenticate] },
- async (request, reply) => {
- const userId = request.user.sub
- const { id } = request.params
- const order = await prisma.order.findFirst({ where: { id, userId }, include: { items: true } })
- if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
- if (order.status !== 'DONE') {
- return { canReview: false, items: [] }
- }
-
- 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 existing = await prisma.review.findMany({
- where: { userId, productId: { in: productIds } },
- select: { productId: true },
- })
- 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',
- { 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 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: 'Сейчас нельзя подтвердить получение заказа' })
- }
-
- await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
- return { ok: true, status: 'DONE' }
- },
- )
}
-
diff --git a/server/src/routes/user-addresses.js b/server/src/routes/user-addresses.js
new file mode 100644
index 0000000..9a731d1
--- /dev/null
+++ b/server/src/routes/user-addresses.js
@@ -0,0 +1,193 @@
+import { prisma } from '../lib/prisma.js'
+
+function normalizePhoneLite(input) {
+ const s = String(input || '').trim()
+ if (!s) return ''
+ return s.replace(/[\s()-]/g, '')
+}
+
+function validateAddressPayload(body, reply) {
+ 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 символов' })
+
+ const recipientName = String(body?.recipientName || '').trim()
+ if (!recipientName) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
+ if (recipientName.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
+
+ const recipientPhone = normalizePhoneLite(body?.recipientPhone)
+ if (!recipientPhone) return reply.code(400).send({ error: 'Укажите телефон получателя' })
+ if (!/^\+?\d{7,20}$/.test(recipientPhone)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
+
+ const addressLine = String(body?.addressLine || '').trim()
+ if (!addressLine) return reply.code(400).send({ error: 'Укажите адрес' })
+ if (addressLine.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
+
+ 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 символов' })
+
+ const lat = Number(body?.lat)
+ const lng = Number(body?.lng)
+ if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
+ if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' })
+
+ return {
+ label,
+ recipientName,
+ recipientPhone,
+ addressLine,
+ comment,
+ lat,
+ lng,
+ }
+}
+
+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.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) => {
+ await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
+ return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
+ })
+
+ return { item: updated }
+ },
+ )
+}
diff --git a/server/src/routes/user-cart.js b/server/src/routes/user-cart.js
new file mode 100644
index 0000000..c7980a3
--- /dev/null
+++ b/server/src/routes/user-cart.js
@@ -0,0 +1,92 @@
+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.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' })
+
+ 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 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' })
+
+ 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: 'Позиция корзины не найдена' })
+ await prisma.cartItem.delete({ where: { id } })
+ return reply.code(204).send()
+ },
+ )
+}
diff --git a/server/src/routes/user-messages.js b/server/src/routes/user-messages.js
new file mode 100644
index 0000000..76eb835
--- /dev/null
+++ b/server/src/routes/user-messages.js
@@ -0,0 +1,114 @@
+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.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 } })
+ 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 }
+
+ 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({
+ orderId: o.id,
+ status: o.status,
+ deliveryType: o.deliveryType,
+ lastMessageAt: lastMsg.createdAt,
+ preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text,
+ unreadCount,
+ })
+ }
+ return { items }
+ },
+ )
+
+ fastify.post(
+ '/api/me/orders/:id/messages/read',
+ { preHandler: [fastify.authenticate] },
+ async (request, reply) => {
+ const userId = request.user.sub
+ const { id } = request.params
+ const order = await prisma.order.findFirst({ where: { id, userId } })
+ if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
+
+ const now = new Date()
+ await prisma.userOrderMessageReadState.upsert({
+ where: { userId_orderId: { userId, orderId: id } },
+ create: { userId, orderId: id, lastReadAt: now },
+ update: { lastReadAt: now },
+ })
+ return { ok: true }
+ },
+ )
+}
diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js
new file mode 100644
index 0000000..addeade
--- /dev/null
+++ b/server/src/routes/user-orders.js
@@ -0,0 +1,249 @@
+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()
+
+ 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' })
+ }
+
+ 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 (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 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'
+ if (paymentMethod === 'on_pickup') initialStatus = 'IN_PROGRESS'
+ else if (deliveryType === 'delivery') initialStatus = 'DELIVERY_FEE_ADJUSTMENT'
+
+ let created
+ try {
+ created = await prisma.$transaction(async (tx) => {
+ for (const ci of cartItems) {
+ if (!ci.product.inStock) continue
+
+ const res = await tx.product.updateMany({
+ where: { id: ci.productId, quantity: { gte: ci.qty } },
+ data: { quantity: { decrement: ci.qty } },
+ })
+ if (res.count !== 1) {
+ throw new Error(`Недостаточно товара: "${ci.product.title}"`)
+ }
+
+ }
+
+ const order = await tx.order.create({
+ data: {
+ userId,
+ status: initialStatus,
+ 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) || 'Недостаточно товара' })
+ }
+
+ 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/: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 } })
+ if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
+ if (order.status !== 'DONE') {
+ return { canReview: false, items: [] }
+ }
+
+ 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 existing = await prisma.review.findMany({
+ where: { userId, productId: { in: productIds } },
+ select: { productId: true },
+ })
+ 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',
+ { 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 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: 'Сейчас нельзя подтвердить получение заказа' })
+ }
+
+ await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
+ return { ok: true, status: 'DONE' }
+ },
+ )
+}
diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js
new file mode 100644
index 0000000..1d3a3d0
--- /dev/null
+++ b/server/src/routes/user-payments.js
@@ -0,0 +1,132 @@
+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'
+
+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: 'Заказ не найден' })
+
+ const paymentMethod = order.paymentMethod ?? 'online'
+ if (paymentMethod === 'on_pickup') {
+ return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
+ }
+
+ if (order.status === 'DELIVERY_FEE_ADJUSTMENT') {
+ return reply
+ .code(409)
+ .send({
+ error:
+ 'Оплата станет доступна после корректировки стоимости доставки администратором.',
+ })
+ }
+
+ let nextStatus = order.status
+ if (order.status === 'DRAFT') {
+ await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })
+ nextStatus = 'PENDING_PAYMENT'
+ return { ok: true, status: nextStatus }
+ }
+
+ if (order.status === 'PAYMENT_VERIFICATION') {
+ return { ok: true, status: nextStatus }
+ }
+
+ if (order.status === 'PENDING_PAYMENT') {
+ 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'
+ }
+ } 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 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 })
+ }
+ }
+
+ const bodyHtml = hasDetail
+ ? `${escapeHtml(detail).replace(/\r\n|\n|\r/g, '
')}
`
+ : ''
+ const messageText = `Подтверждение оплаты (перевод ВТБ / Сбербанк)
${bodyHtml}`
+
+ try {
+ await prisma.$transaction(async (tx) => {
+ await tx.order.update({ where: { id }, data: { status: 'PAYMENT_VERIFICATION' } })
+ await tx.orderMessage.create({
+ data: {
+ orderId: id,
+ authorType: 'user',
+ text: messageText,
+ attachmentUrl,
+ },
+ })
+ })
+ } catch (err) {
+ return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
+ }
+
+ return { ok: true, status: 'PAYMENT_VERIFICATION' }
+ }
+
+ return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
+ },
+ )
+}
diff --git a/shared/constants/delivery-carrier.d.ts b/shared/constants/delivery-carrier.d.ts
new file mode 100644
index 0000000..cec41db
--- /dev/null
+++ b/shared/constants/delivery-carrier.d.ts
@@ -0,0 +1 @@
+export declare const DELIVERY_CARRIERS: readonly ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST']
diff --git a/shared/constants/delivery-carrier.js b/shared/constants/delivery-carrier.js
new file mode 100644
index 0000000..1233e6e
--- /dev/null
+++ b/shared/constants/delivery-carrier.js
@@ -0,0 +1 @@
+export const DELIVERY_CARRIERS = Object.freeze(['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'])
diff --git a/shared/constants/order-status.d.ts b/shared/constants/order-status.d.ts
new file mode 100644
index 0000000..c3178f2
--- /dev/null
+++ b/shared/constants/order-status.d.ts
@@ -0,0 +1,12 @@
+export declare const ORDER_STATUSES: readonly [
+ 'DRAFT',
+ 'DELIVERY_FEE_ADJUSTMENT',
+ 'PENDING_PAYMENT',
+ 'PAYMENT_VERIFICATION',
+ 'PAID',
+ 'IN_PROGRESS',
+ 'SHIPPED',
+ 'READY_FOR_PICKUP',
+ 'DONE',
+ 'CANCELLED',
+]
diff --git a/shared/constants/order-status.js b/shared/constants/order-status.js
new file mode 100644
index 0000000..d5e25c0
--- /dev/null
+++ b/shared/constants/order-status.js
@@ -0,0 +1,12 @@
+export const ORDER_STATUSES = Object.freeze([
+ 'DRAFT',
+ 'DELIVERY_FEE_ADJUSTMENT',
+ 'PENDING_PAYMENT',
+ 'PAYMENT_VERIFICATION',
+ 'PAID',
+ 'IN_PROGRESS',
+ 'SHIPPED',
+ 'READY_FOR_PICKUP',
+ 'DONE',
+ 'CANCELLED',
+])
diff --git a/shared/constants/payment-method.d.ts b/shared/constants/payment-method.d.ts
new file mode 100644
index 0000000..70a9fee
--- /dev/null
+++ b/shared/constants/payment-method.d.ts
@@ -0,0 +1 @@
+export declare const PAYMENT_METHODS: readonly ['online', 'on_pickup']
diff --git a/shared/constants/payment-method.js b/shared/constants/payment-method.js
new file mode 100644
index 0000000..9616e19
--- /dev/null
+++ b/shared/constants/payment-method.js
@@ -0,0 +1 @@
+export const PAYMENT_METHODS = Object.freeze(['online', 'on_pickup'])
diff --git a/shared/constants/upload-limits.d.ts b/shared/constants/upload-limits.d.ts
new file mode 100644
index 0000000..8f9caf1
--- /dev/null
+++ b/shared/constants/upload-limits.d.ts
@@ -0,0 +1 @@
+export declare const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT: 20971520
diff --git a/shared/constants/upload-limits.js b/shared/constants/upload-limits.js
new file mode 100644
index 0000000..5c020a3
--- /dev/null
+++ b/shared/constants/upload-limits.js
@@ -0,0 +1,3 @@
+const MB = 1024 * 1024
+
+export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = 20 * MB