Merge branch 'refactor'
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>`
|
||||
: ''
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${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' }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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' }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>`
|
||||
: ''
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${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: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user