893 lines
34 KiB
JavaScript
893 lines
34 KiB
JavaScript
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)
|
|
const userEmail = normalizeEmail(user.email)
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
phone: user.phone,
|
|
isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
|
|
}
|
|
}
|
|
|
|
export async function registerAuthRoutes(fastify) {
|
|
fastify.post('/api/auth/request-code', async (request, reply) => {
|
|
const email = normalizeEmail(request.body?.email)
|
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
|
|
|
// purpose: login (включает и регистрацию — пользователь создастся при verify)
|
|
await issueEmailCode({ email, purpose: 'login' })
|
|
return { ok: true }
|
|
})
|
|
|
|
fastify.post('/api/auth/verify-code', async (request, reply) => {
|
|
const email = normalizeEmail(request.body?.email)
|
|
const code = String(request.body?.code || '').trim()
|
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
|
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
|
|
|
const ok = await verifyEmailCode({ email, purpose: 'login', code })
|
|
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
|
|
|
const user = await prisma.user.upsert({
|
|
where: { email },
|
|
update: {},
|
|
create: { email },
|
|
})
|
|
|
|
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
|
return { token, user: mapUserForClient(user) }
|
|
})
|
|
|
|
fastify.get(
|
|
'/api/me',
|
|
{ preHandler: [fastify.authenticate] },
|
|
async (request) => {
|
|
const userId = request.user.sub
|
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
|
if (!user) return { user: null }
|
|
return { user: mapUserForClient(user) }
|
|
},
|
|
)
|
|
|
|
fastify.post(
|
|
'/api/me/change-email/request-code',
|
|
{ preHandler: [fastify.authenticate] },
|
|
async (request, reply) => {
|
|
const userId = request.user.sub
|
|
const newEmail = normalizeEmail(request.body?.newEmail)
|
|
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
|
|
|
const exists = await prisma.user.findUnique({ where: { email: newEmail } })
|
|
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
|
|
|
|
await issueEmailCode({ email: newEmail, purpose: 'change_email', userId })
|
|
return { ok: true }
|
|
},
|
|
)
|
|
|
|
fastify.post(
|
|
'/api/me/change-email/verify',
|
|
{ preHandler: [fastify.authenticate] },
|
|
async (request, reply) => {
|
|
const userId = request.user.sub
|
|
const newEmail = normalizeEmail(request.body?.newEmail)
|
|
const code = String(request.body?.code || '').trim()
|
|
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
|
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
|
|
|
const exists = await prisma.user.findUnique({ where: { email: newEmail } })
|
|
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
|
|
|
|
const ok = await verifyEmailCode({ email: newEmail, purpose: 'change_email', code, userId })
|
|
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
|
|
|
const user = await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { email: newEmail },
|
|
})
|
|
return { user: mapUserForClient(user) }
|
|
},
|
|
)
|
|
|
|
fastify.patch(
|
|
'/api/me/profile',
|
|
{ preHandler: [fastify.authenticate] },
|
|
async (request, reply) => {
|
|
const userId = request.user.sub
|
|
const nameRaw = request.body?.name
|
|
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
|
const phoneRaw = request.body?.phone
|
|
const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim()
|
|
|
|
if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
|
if (phone !== null) {
|
|
const compact = phone.replace(/[\s()-]/g, '')
|
|
if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' })
|
|
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) {
|
|
return reply.code(400).send({ error: 'Некорректный телефон' })
|
|
}
|
|
}
|
|
|
|
const updated = await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { name: name && name.length ? name : null, phone: phone && phone.length ? phone : null },
|
|
})
|
|
return { user: mapUserForClient(updated) }
|
|
},
|
|
)
|
|
|
|
// ---- Адреса доставки ----
|
|
|
|
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' }
|
|
},
|
|
)
|
|
}
|
|
|