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 ? `

${escapeHtml(detail).replace(/\r\n|\n|\r/g, '
')}

` : '' const messageText = `

Подтверждение оплаты (перевод ВТБ / Сбербанк)

${bodyHtml}` try { await prisma.$transaction(async (tx) => { await tx.order.update({ where: { id }, data: { status: 'PAYMENT_VERIFICATION' } }) await tx.orderMessage.create({ data: { orderId: id, authorType: 'user', text: messageText, attachmentUrl, }, }) }) } catch (err) { return reply.code(500).send({ error: 'Не удалось сохранить оплату' }) } return { ok: true, status: 'PAYMENT_VERIFICATION' } } return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) }, ) fastify.get( '/api/me/orders/:id/review-eligibility', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const order = await prisma.order.findFirst({ where: { id, userId }, include: { items: true } }) if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) if (order.status !== 'DONE') { return { canReview: false, items: [] } } const uniq = new Map() for (const it of order.items) { if (!uniq.has(it.productId)) { uniq.set(it.productId, { productId: it.productId, title: it.titleSnapshot }) } } const productIds = [...uniq.keys()] const existing = await prisma.review.findMany({ where: { userId, productId: { in: productIds } }, select: { productId: true }, }) const reviewed = new Set(existing.map((r) => r.productId)) return { canReview: true, items: [...uniq.values()].map((x) => ({ ...x, hasReview: reviewed.has(x.productId), })), } }, ) fastify.post( '/api/me/orders/:id/confirm-received', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const order = await prisma.order.findFirst({ where: { id, userId } }) if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED' const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP' if (!okDelivery && !okPickup) { return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' }) } await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) return { ok: true, status: 'DONE' } }, ) }