From 45627206c037a3288a5721804dc8c2267520b9dd Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 27 May 2026 21:47:16 +0500 Subject: [PATCH] refactor: extract findUserOrder helper --- .../src/lib/__tests__/find-user-order.test.js | 27 ++ server/src/lib/find-user-order.js | 12 + server/src/routes/user-messages.js | 97 +++--- server/src/routes/user-orders.js | 21 +- server/src/routes/user-payments.js | 285 +++++++++--------- 5 files changed, 246 insertions(+), 196 deletions(-) create mode 100644 server/src/lib/__tests__/find-user-order.test.js create mode 100644 server/src/lib/find-user-order.js diff --git a/server/src/lib/__tests__/find-user-order.test.js b/server/src/lib/__tests__/find-user-order.test.js new file mode 100644 index 0000000..a101edb --- /dev/null +++ b/server/src/lib/__tests__/find-user-order.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect, vi } from 'vitest' +import { findUserOrder } from '../find-user-order.js' + +describe('findUserOrder', () => { + it('returns order when found', async () => { + const mockOrder = { id: '1', userId: 'user1' } + const prisma = { order: { findFirst: vi.fn().mockResolvedValue(mockOrder) } } + const result = await findUserOrder(prisma, '1', 'user1') + expect(result).toEqual(mockOrder) + expect(prisma.order.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: '1', userId: 'user1' } }), + ) + }) + + it('throws 404 when order not found', async () => { + const prisma = { order: { findFirst: vi.fn().mockResolvedValue(null) } } + await expect(findUserOrder(prisma, '999', 'user1')).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('passes include option', async () => { + const mockOrder = { id: '1', userId: 'user1', items: [] } + const prisma = { order: { findFirst: vi.fn().mockResolvedValue(mockOrder) } } + const result = await findUserOrder(prisma, '1', 'user1', { items: true }) + expect(result).toEqual(mockOrder) + expect(prisma.order.findFirst).toHaveBeenCalledWith(expect.objectContaining({ include: { items: true } })) + }) +}) diff --git a/server/src/lib/find-user-order.js b/server/src/lib/find-user-order.js new file mode 100644 index 0000000..6c14d09 --- /dev/null +++ b/server/src/lib/find-user-order.js @@ -0,0 +1,12 @@ +export async function findUserOrder(prisma, orderId, userId, include = {}) { + const order = await prisma.order.findFirst({ + where: { id: orderId, userId }, + include, + }) + + if (!order) { + throw Object.assign(new Error('Order not found'), { statusCode: 404 }) + } + + return order +} diff --git a/server/src/routes/user-messages.js b/server/src/routes/user-messages.js index cd446ad..863f821 100644 --- a/server/src/routes/user-messages.js +++ b/server/src/routes/user-messages.js @@ -1,40 +1,48 @@ import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { asyncHandler } from '../lib/async-handler.js' +import { findUserOrder } from '../lib/find-user-order.js' import { prisma } from '../lib/prisma.js' export async function registerUserMessageRoutes(fastify) { - fastify.get('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const order = await prisma.order.findFirst({ where: { id, userId } }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - const items = await prisma.orderMessage.findMany({ - where: { orderId: id }, - orderBy: { createdAt: 'asc' }, - }) - return { items } - }) + fastify.get( + '/api/me/orders/:id/messages', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + await findUserOrder(prisma, id, userId) + const items = await prisma.orderMessage.findMany({ + where: { orderId: id }, + orderBy: { createdAt: 'asc' }, + }) + return { items } + }), + ) - fastify.post('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const order = await prisma.order.findFirst({ where: { id, userId } }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - const text = String(request.body?.text || '').trim() - if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) - if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) - const msg = await prisma.orderMessage.create({ - data: { orderId: id, authorType: 'user', text }, - }) + fastify.post( + '/api/me/orders/:id/messages', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + await findUserOrder(prisma, id, userId) + const text = String(request.body?.text || '').trim() + if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) + if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) + const msg = await prisma.orderMessage.create({ + data: { orderId: id, authorType: 'user', text }, + }) - request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, { - orderId: id, - authorType: 'user', - messageId: msg.id, - preview: text, - }) + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, { + orderId: id, + authorType: 'user', + messageId: msg.id, + preview: text, + }) - return reply.code(201).send({ item: msg }) - }) + return reply.code(201).send({ item: msg }) + }), + ) fastify.get('/api/me/messages/unread-count', { preHandler: [fastify.authenticate] }, async (request) => { const userId = request.user.sub @@ -116,18 +124,21 @@ export async function registerUserMessageRoutes(fastify) { 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: 'Заказ не найден' }) + fastify.post( + '/api/me/orders/:id/messages/read', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + await findUserOrder(prisma, id, userId) - const now = new Date() - await prisma.userOrderMessageReadState.upsert({ - where: { userId_orderId: { userId, orderId: id } }, - create: { userId, orderId: id, lastReadAt: now }, - update: { lastReadAt: now }, - }) - return { ok: true } - }) + const now = new Date() + await prisma.userOrderMessageReadState.upsert({ + where: { userId_orderId: { userId, orderId: id } }, + create: { userId, orderId: id, lastReadAt: now }, + update: { lastReadAt: now }, + }) + return { ok: true } + }), + ) } diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 0a68e40..4d5cc45 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -1,6 +1,7 @@ import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' import { asyncHandler } from '../lib/async-handler.js' import { isDeliveryCarrier } from '../lib/delivery-carrier.js' +import { findUserOrder } from '../lib/find-user-order.js' import { prisma } from '../lib/prisma.js' export async function registerUserOrderRoutes(fastify) { @@ -207,11 +208,10 @@ export async function registerUserOrderRoutes(fastify) { asyncHandler(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' } } }, + const order = await findUserOrder(prisma, id, userId, { + items: true, + messages: { orderBy: { createdAt: 'asc' } }, }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) return { item: order } }), ) @@ -219,14 +219,10 @@ export async function registerUserOrderRoutes(fastify) { fastify.get( '/api/me/orders/:id/review-eligibility', { preHandler: [fastify.authenticate] }, - async (request, reply) => { + asyncHandler(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: 'Заказ не найден' }) + const order = await findUserOrder(prisma, id, userId, { items: true }) if (order.status !== 'DONE') { return { canReview: false, items: [] } } @@ -253,7 +249,7 @@ export async function registerUserOrderRoutes(fastify) { hasReview: reviewed.has(x.productId), })), } - }, + }), ) fastify.post( @@ -262,8 +258,7 @@ export async function registerUserOrderRoutes(fastify) { asyncHandler(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 order = await findUserOrder(prisma, id, userId) const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED' const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP' diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index 3f3a59f..22cc260 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -1,149 +1,154 @@ import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { asyncHandler } from '../lib/async-handler.js' +import { findUserOrder } from '../lib/find-user-order.js' import { prisma } from '../lib/prisma.js' import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.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 userEmail = request.user.email + fastify.post( + '/api/me/orders/:id/pay', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const userEmail = request.user.email - if (!userEmail) { - return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' }) - } - - 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.paymentMethod === 'on_pickup') { - return reply.code(409).send({ - error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна', - }) - } - - if (order.status !== 'PENDING_PAYMENT') { - return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) - } - - if (!order.deliveryFeeLocked) { - return reply.code(409).send({ - error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже', - }) - } - - const existingPayment = await prisma.payment.findFirst({ - where: { - orderId: id, - status: { in: ['pending', 'waiting_for_capture'] }, - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], - }, - orderBy: { createdAt: 'desc' }, - }) - - if (existingPayment && existingPayment.confirmationUrl) { - return { confirmationUrl: existingPayment.confirmationUrl } - } - - const idempotencyKey = `${id}-${Date.now()}` - const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '') - const returnUrl = `${clientUrl}/me/orders/${id}?paid=1` - const clientIp = request.ip - - const amount = { - value: (order.totalCents / 100).toFixed(2), - currency: order.currency, - } - - const receipt = buildReceipt({ - orderItems: order.items, - deliveryFeeCents: order.deliveryFeeCents, - userEmail: userEmail, - }) - - let result - try { - result = await createPayment({ - amount, - description: `Оплата заказа №${order.id.slice(-6)}`, - receipt, - confirmation: { type: 'redirect', return_url: returnUrl }, - metadata: { orderId: order.id }, - idempotencyKey, - clientIp, - }) - } catch (err) { - request.log.error({ err, orderId: id }, 'YooKassa createPayment failed') - return reply.code(502).send({ - error: 'Не удалось создать платёж. Платёжный сервис временно недоступен.', - }) - } - - await prisma.payment.create({ - data: { - orderId: order.id, - yookassaPaymentId: result.paymentId, - status: result.status, - amountCents: order.totalCents, - currency: order.currency, - confirmationUrl: result.confirmationUrl, - expiresAt: result.expiresAt ? new Date(result.expiresAt) : null, - }, - }) - - return { confirmationUrl: result.confirmationUrl } - }) - - fastify.get('/api/me/orders/:orderId/payment', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { orderId } = request.params - - const order = await prisma.order.findFirst({ where: { id: orderId, userId } }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - - const payment = await prisma.payment.findFirst({ - where: { orderId }, - orderBy: { createdAt: 'desc' }, - }) - if (!payment) { - return { status: null, paid: false } - } - - if (payment.status === 'succeeded' || payment.status === 'canceled') { - return { status: payment.status, paid: payment.status === 'succeeded' } - } - - try { - const ykPayment = await getPayment(payment.yookassaPaymentId) - - if (ykPayment.status !== payment.status) { - await prisma.payment.update({ - where: { id: payment.id }, - data: { status: ykPayment.status }, - }) - - if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') { - const updated = await prisma.order.updateMany({ - where: { id: orderId, status: 'PENDING_PAYMENT' }, - data: { status: 'PAID' }, - }) - if (updated.count > 0) { - request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { - orderId, - userId: order.userId, - paymentStatus: 'paid', - }) - } - } + if (!userEmail) { + return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' }) } - return { status: ykPayment.status, paid: ykPayment.paid } - } catch (err) { - request.log.error({ err }, '[user-payments] Operation failed') - return { status: payment.status, paid: payment.status === 'succeeded' } - } - }) + const { id } = request.params + + const order = await findUserOrder(prisma, id, userId, { items: true }) + + if (order.paymentMethod === 'on_pickup') { + return reply.code(409).send({ + error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна', + }) + } + + if (order.status !== 'PENDING_PAYMENT') { + return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) + } + + if (!order.deliveryFeeLocked) { + return reply.code(409).send({ + error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже', + }) + } + + const existingPayment = await prisma.payment.findFirst({ + where: { + orderId: id, + status: { in: ['pending', 'waiting_for_capture'] }, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + orderBy: { createdAt: 'desc' }, + }) + + if (existingPayment && existingPayment.confirmationUrl) { + return { confirmationUrl: existingPayment.confirmationUrl } + } + + const idempotencyKey = `${id}-${Date.now()}` + const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '') + const returnUrl = `${clientUrl}/me/orders/${id}?paid=1` + const clientIp = request.ip + + const amount = { + value: (order.totalCents / 100).toFixed(2), + currency: order.currency, + } + + const receipt = buildReceipt({ + orderItems: order.items, + deliveryFeeCents: order.deliveryFeeCents, + userEmail: userEmail, + }) + + let result + try { + result = await createPayment({ + amount, + description: `Оплата заказа №${order.id.slice(-6)}`, + receipt, + confirmation: { type: 'redirect', return_url: returnUrl }, + metadata: { orderId: order.id }, + idempotencyKey, + clientIp, + }) + } catch (err) { + request.log.error({ err, orderId: id }, 'YooKassa createPayment failed') + return reply.code(502).send({ + error: 'Не удалось создать платёж. Платёжный сервис временно недоступен.', + }) + } + + await prisma.payment.create({ + data: { + orderId: order.id, + yookassaPaymentId: result.paymentId, + status: result.status, + amountCents: order.totalCents, + currency: order.currency, + confirmationUrl: result.confirmationUrl, + expiresAt: result.expiresAt ? new Date(result.expiresAt) : null, + }, + }) + + return { confirmationUrl: result.confirmationUrl } + }), + ) + + fastify.get( + '/api/me/orders/:orderId/payment', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { orderId } = request.params + + const order = await findUserOrder(prisma, orderId, userId) + + const payment = await prisma.payment.findFirst({ + where: { orderId }, + orderBy: { createdAt: 'desc' }, + }) + if (!payment) { + return { status: null, paid: false } + } + + if (payment.status === 'succeeded' || payment.status === 'canceled') { + return { status: payment.status, paid: payment.status === 'succeeded' } + } + + try { + const ykPayment = await getPayment(payment.yookassaPaymentId) + + if (ykPayment.status !== payment.status) { + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: ykPayment.status }, + }) + + if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') { + const updated = await prisma.order.updateMany({ + where: { id: orderId, status: 'PENDING_PAYMENT' }, + data: { status: 'PAID' }, + }) + if (updated.count > 0) { + request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId, + userId: order.userId, + paymentStatus: 'paid', + }) + } + } + } + + return { status: ykPayment.status, paid: ykPayment.paid } + } catch (err) { + request.log.error({ err }, '[user-payments] Operation failed') + return { status: payment.status, paid: payment.status === 'succeeded' } + } + }), + ) }