From 2f67c3750229249f10ca47180922ba02d0729e5e Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 13:54:05 +0500 Subject: [PATCH] test commit --- server/src/index.js | 190 +++++++------ server/src/lib/notifications/preferences.js | 86 +++--- .../templates/email-templates.js | 102 ++++--- server/src/routes/api/admin-orders.js | 207 ++++++++------ server/src/routes/api/admin-reviews.js | 88 +++--- server/src/routes/api/admin/notifications.js | 104 +++---- server/src/routes/auth.js | 176 +++++++----- server/src/routes/user-messages.js | 156 ++++++---- server/src/routes/user-orders.js | 267 ++++++++++-------- server/src/routes/user-payments.js | 126 +++++---- 10 files changed, 875 insertions(+), 627 deletions(-) diff --git a/server/src/index.js b/server/src/index.js index 4e8e07e..7cdac4e 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1,104 +1,110 @@ -import 'dotenv/config' -import Fastify from 'fastify' -import cors from '@fastify/cors' -import jwt from '@fastify/jwt' -import multipart from '@fastify/multipart' -import fastifyStatic from '@fastify/static' -import path from 'node:path' -import { ensureAdminUser } from './lib/bootstrap-admin.js' -import { getOrCreateUnspecifiedCategory } from './lib/default-category.js' -import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' -import { createEventBus } from './lib/notifications/event-bus.js' -import { createNotificationQueue } from './lib/notifications/queue.js' -import { prisma } from './lib/prisma.js' +import "dotenv/config"; +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import jwt from "@fastify/jwt"; +import multipart from "@fastify/multipart"; +import fastifyStatic from "@fastify/static"; +import path from "node:path"; +import { ensureAdminUser } from "./lib/bootstrap-admin.js"; +import { getOrCreateUnspecifiedCategory } from "./lib/default-category.js"; +import { + getMaxUploadBodyBytes, + getProductImageMaxFileBytes, +} from "./lib/upload-limits.js"; +import { createEventBus } from "./lib/notifications/event-bus.js"; +import { createNotificationQueue } from "./lib/notifications/queue.js"; +import { prisma } from "./lib/prisma.js"; import { resolveUserNotificationTargets, resolveAdminNotificationTargets, resolveAuthCodeTargets, -} from './lib/notifications/preferences.js' -import { NOTIFICATION_EVENTS, NOTIFICATION_CHANNELS } from './shared/constants/notification-events.js' -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 { registerUserNotificationRoutes } from './routes/user/notifications.js' -import { registerOAuthSocialRoutes } from './routes/oauth-social.js' -import { registerUploadsResized } from './routes/uploads-resized.js' +} from "./lib/notifications/preferences.js"; +import { + NOTIFICATION_EVENTS, + NOTIFICATION_CHANNELS, +} from "../../shared/constants/notification-events.js"; +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 { registerUserNotificationRoutes } from "./routes/user/notifications.js"; +import { registerOAuthSocialRoutes } from "./routes/oauth-social.js"; +import { registerUploadsResized } from "./routes/uploads-resized.js"; -const port = Number(process.env.PORT) || 3333 -const origin = (process.env.CORS_ORIGIN ?? '') - .split(',') +const port = Number(process.env.PORT) || 3333; +const origin = (process.env.CORS_ORIGIN ?? "") + .split(",") .map((s) => s.trim()) - .filter(Boolean) + .filter(Boolean); const fastify = Fastify({ logger: true, bodyLimit: getMaxUploadBodyBytes(), -}) +}); await fastify.register(cors, { origin: origin.length ? origin : true, credentials: true, -}) +}); await fastify.register(jwt, { - secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me', -}) + secret: process.env.JWT_SECRET || "dev-jwt-secret-change-me", +}); await fastify.register(multipart, { limits: { files: 10, fileSize: getProductImageMaxFileBytes(), }, -}) +}); -registerUploadsResized(fastify) +registerUploadsResized(fastify); -const uploadsDir = path.join(process.cwd(), 'uploads') +const uploadsDir = path.join(process.cwd(), "uploads"); await fastify.register(fastifyStatic, { root: uploadsDir, - prefix: '/uploads/', + prefix: "/uploads/", setHeaders(res, filePath) { - if (filePath.includes('/.cache/')) { - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + if (filePath.includes("/.cache/")) { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } else { - res.setHeader('Cache-Control', 'public, max-age=86400') + res.setHeader("Cache-Control", "public, max-age=86400"); } }, -}) +}); -fastify.decorate('authenticate', async function authenticate(request, reply) { +fastify.decorate("authenticate", async function authenticate(request, reply) { try { - await request.jwtVerify() + await request.jwtVerify(); } catch { - return reply.code(401).send({ error: 'Не авторизован' }) + return reply.code(401).send({ error: "Не авторизован" }); } -}) +}); -const eventBus = createEventBus() -const notificationQueue = createNotificationQueue() -fastify.decorate('eventBus', eventBus) -fastify.decorate('notificationQueue', notificationQueue) +const eventBus = createEventBus(); +const notificationQueue = createNotificationQueue(); +fastify.decorate("eventBus", eventBus); +fastify.decorate("notificationQueue", notificationQueue); -registerAuth(fastify) -await registerAuthRoutes(fastify) -await registerUserAddressRoutes(fastify) -await registerUserCartRoutes(fastify) -await registerUserMessageRoutes(fastify) -await registerUserOrderRoutes(fastify) -await registerUserPaymentRoutes(fastify) -await registerUserNotificationRoutes(fastify) -await registerOAuthSocialRoutes(fastify) -await registerApiRoutes(fastify) -await ensureAdminUser() -await getOrCreateUnspecifiedCategory() +registerAuth(fastify); +await registerAuthRoutes(fastify); +await registerUserAddressRoutes(fastify); +await registerUserCartRoutes(fastify); +await registerUserMessageRoutes(fastify); +await registerUserOrderRoutes(fastify); +await registerUserPaymentRoutes(fastify); +await registerUserNotificationRoutes(fastify); +await registerOAuthSocialRoutes(fastify); +await registerApiRoutes(fastify); +await ensureAdminUser(); +await getOrCreateUnspecifiedCategory(); -await notificationQueue.flushPendingOnStartup() -notificationQueue.start() +await notificationQueue.flushPendingOnStartup(); +notificationQueue.start(); const { ORDER_CREATED, @@ -107,58 +113,62 @@ const { ORDER_MESSAGE_ADMIN_REPLY, PAYMENT_STATUS_CHANGED, AUTH_CODE_REQUESTED, -} = NOTIFICATION_EVENTS +} = NOTIFICATION_EVENTS; async function dispatchNotification(eventType, payload) { - const userTargets = await resolveUserNotificationTargets(eventType, payload) + const userTargets = await resolveUserNotificationTargets(eventType, payload); for (const target of userTargets) { const log = await prisma.notificationLog.create({ data: { userId: payload.userId, eventType, channel: target.channel, - status: 'pending', + status: "pending", payload: JSON.stringify(payload), }, - }) - notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }) + }); + notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }); } - const adminEventType = eventType === 'order:created:admin' ? ORDER_CREATED : eventType - const adminTargets = await resolveAdminNotificationTargets(adminEventType, payload) + const adminEventType = + eventType === "order:created:admin" ? ORDER_CREATED : eventType; + const adminTargets = await resolveAdminNotificationTargets( + adminEventType, + payload, + ); for (const target of adminTargets) { const log = await prisma.notificationLog.create({ data: { eventType, channel: target.channel, - status: 'pending', + status: "pending", payload: JSON.stringify(payload), }, - }) - notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }) + }); + notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }); } } -eventBus.on(ORDER_CREATED, dispatchNotification) -eventBus.on(ORDER_STATUS_CHANGED, dispatchNotification) -eventBus.on(ORDER_MESSAGE_SENT, dispatchNotification) -eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, dispatchNotification) -eventBus.on(PAYMENT_STATUS_CHANGED, dispatchNotification) -eventBus.on(AUTH_CODE_REQUESTED, dispatchNotification) -eventBus.on('order:created:admin', dispatchNotification) -eventBus.on('review:created', dispatchNotification) +eventBus.on(ORDER_CREATED, (payload) => dispatchNotification(ORDER_CREATED, payload)); +eventBus.on(ORDER_STATUS_CHANGED, (payload) => dispatchNotification(ORDER_STATUS_CHANGED, payload)); +eventBus.on(ORDER_MESSAGE_SENT, (payload) => dispatchNotification(ORDER_MESSAGE_SENT, payload)); +eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, (payload) => dispatchNotification(ORDER_MESSAGE_ADMIN_REPLY, payload)); +eventBus.on(PAYMENT_STATUS_CHANGED, (payload) => dispatchNotification(PAYMENT_STATUS_CHANGED, payload)); +eventBus.on(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, payload)); +eventBus.on("order:created:admin", (payload) => dispatchNotification("order:created:admin", payload)); +eventBus.on("review:created", (payload) => dispatchNotification("review:created", payload)); async function shutdown() { - notificationQueue.stop() - await fastify.close() - process.exit(0) + notificationQueue.stop(); + await fastify.close(); + process.exit(0); } -process.on('SIGINT', shutdown) -process.on('SIGTERM', shutdown) +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); try { - await fastify.listen({ port, host: '0.0.0.0' }) + await fastify.listen({ port, host: "0.0.0.0" }); } catch (err) { - fastify.log.error(err) - process.exit(1) + fastify.log.error(err); + process.exit(1); } diff --git a/server/src/lib/notifications/preferences.js b/server/src/lib/notifications/preferences.js index 0ce256c..e67159f 100644 --- a/server/src/lib/notifications/preferences.js +++ b/server/src/lib/notifications/preferences.js @@ -1,5 +1,5 @@ -import { prisma } from '../prisma.js' -import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' +import { prisma } from "../prisma.js"; +import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js"; const { ORDER_CREATED, @@ -8,93 +8,103 @@ const { ORDER_MESSAGE_ADMIN_REPLY, PAYMENT_STATUS_CHANGED, AUTH_CODE_REQUESTED, -} = NOTIFICATION_EVENTS +} = NOTIFICATION_EVENTS; const userEventFieldMap = { - [ORDER_CREATED]: 'orderCreated', - [ORDER_STATUS_CHANGED]: 'orderStatusChanged', - [ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived', - [PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged', -} + [ORDER_CREATED]: "orderCreated", + [ORDER_STATUS_CHANGED]: "orderStatusChanged", + [ORDER_MESSAGE_ADMIN_REPLY]: "orderMessageReceived", + [PAYMENT_STATUS_CHANGED]: "paymentStatusChanged", +}; const adminEventFieldMap = { - [ORDER_CREATED]: 'newOrder', - [ORDER_MESSAGE_SENT]: 'newOrderMessage', - 'review:created': 'newReview', -} + [ORDER_CREATED]: "newOrder", + [ORDER_MESSAGE_SENT]: "newOrderMessage", + "review:created": "newReview", +}; export async function resolveUserNotificationTargets(eventType, payload) { - const targets = [] + const targets = []; if (payload.userId) { const prefs = await prisma.notificationPreference.findUnique({ where: { userId: payload.userId }, - }) + }); if (prefs && prefs.globalEnabled) { - const field = userEventFieldMap[eventType] + const field = userEventFieldMap[eventType]; if (field && prefs[field]) { - const user = await prisma.user.findUnique({ where: { id: payload.userId }, select: { email: true } }) + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { email: true }, + }); if (user) { - targets.push({ channel: 'email', recipient: user.email }) + targets.push({ channel: "email", recipient: user.email }); } } } } - return targets + return targets; } export async function resolveAdminNotificationTargets(eventType, payload) { - const targets = [] - const settings = await prisma.adminNotificationSettings.findFirst() - if (!settings) return targets + const targets = []; + const settings = await prisma.adminNotificationSettings.findFirst(); + if (!settings) return targets; - const field = adminEventFieldMap[eventType] - if (field === 'newReview') { - if (!settings.newReview) return targets + const field = adminEventFieldMap[eventType]; + if (field === "newReview") { + if (!settings.newReview) return targets; } else if (field && !settings[field]) { - return targets + return targets; } if (settings.emailEnabled) { const admin = await prisma.user.findFirst({ where: { email: process.env.ADMIN_EMAIL }, select: { email: true }, - }) + }); if (admin) { - targets.push({ channel: 'email', recipient: admin.email }) + targets.push({ channel: "email", recipient: admin.email }); } } if (settings.telegramEnabled && settings.telegramChatId) { - targets.push({ channel: 'telegram', recipient: settings.telegramChatId }) + targets.push({ channel: "telegram", recipient: settings.telegramChatId }); } - return targets + return targets; } export async function resolveAuthCodeTargets(eventType, payload) { - const targets = [] + const targets = []; if (payload.email) { - targets.push({ channel: 'email', recipient: payload.email }) + targets.push({ channel: "email", recipient: payload.email }); } if (payload.isAdmin) { - const settings = await prisma.adminNotificationSettings.findFirst() - if (settings && settings.telegramEnabled && settings.telegramChatId && settings.authCodeDuplicate) { - targets.push({ channel: 'telegram', recipient: settings.telegramChatId }) + const settings = await prisma.adminNotificationSettings.findFirst(); + if ( + settings && + settings.telegramEnabled && + settings.telegramChatId && + settings.authCodeDuplicate + ) { + targets.push({ channel: "telegram", recipient: settings.telegramChatId }); } } - return targets + return targets; } export async function ensureUserNotificationPreference(userId) { - const existing = await prisma.notificationPreference.findUnique({ where: { userId } }) - if (existing) return existing + const existing = await prisma.notificationPreference.findUnique({ + where: { userId }, + }); + if (existing) return existing; return prisma.notificationPreference.create({ data: { userId, globalEnabled: true }, - }) + }); } diff --git a/server/src/lib/notifications/templates/email-templates.js b/server/src/lib/notifications/templates/email-templates.js index 65c5790..7bcfa08 100644 --- a/server/src/lib/notifications/templates/email-templates.js +++ b/server/src/lib/notifications/templates/email-templates.js @@ -8,89 +8,113 @@ function baseLayout(title, body) { ${body}
-

Craftshop — магазин handmade изделий

+

Любимый Креатив — магазин handmade изделий

-` +`; } export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount }) { - const total = (totalCents / 100).toLocaleString('ru-RU') + const total = (totalCents / 100).toLocaleString("ru-RU"); const body = `

Ваш заказ #${orderId.slice(0, 8)} успешно создан.

Товаров: ${itemsCount} | Сумма: ${total} ₽

Мы сообщим вам об изменениях статуса.

- ` - return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) } + `; + return { subject: "Заказ создан", html: baseLayout("Заказ создан", body) }; } -export function renderOrderStatusChangedEmail({ orderId, oldStatus, newStatus }) { +export function renderOrderStatusChangedEmail({ + orderId, + oldStatus, + newStatus, +}) { const statusLabels = { - DRAFT: 'Черновик', - PENDING_PAYMENT: 'Ожидает оплаты', - IN_PROGRESS: 'В работе', - READY_FOR_PICKUP: 'Готов к выдаче', - SHIPPED: 'Отправлен', - DONE: 'Выполнен', - CANCELLED: 'Отменён', - } - const oldLabel = statusLabels[oldStatus] || oldStatus - const newLabel = statusLabels[newStatus] || newStatus + DRAFT: "Черновик", + PENDING_PAYMENT: "Ожидает оплаты", + IN_PROGRESS: "В работе", + READY_FOR_PICKUP: "Готов к выдаче", + SHIPPED: "Отправлен", + DONE: "Выполнен", + CANCELLED: "Отменён", + }; + const oldLabel = statusLabels[oldStatus] || oldStatus; + const newLabel = statusLabels[newStatus] || newStatus; const body = `

Статус заказа #${orderId.slice(0, 8)} изменён.

${oldLabel}${newLabel}

- ` - return { subject: `Статус заказа изменён — ${newLabel}`, html: baseLayout('Статус заказа изменён', body) } + `; + return { + subject: `Статус заказа изменён — ${newLabel}`, + html: baseLayout("Статус заказа изменён", body), + }; } export function renderOrderMessageEmail({ orderId, preview }) { - const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview + const truncated = + preview.length > 200 ? preview.slice(0, 197) + "..." : preview; const body = `

Новое сообщение к заказу #${orderId.slice(0, 8)}:

${truncated}

Ответьте в личном кабинете.

- ` - return { subject: 'Новое сообщение к заказу', html: baseLayout('Новое сообщение', body) } + `; + return { + subject: "Новое сообщение к заказу", + html: baseLayout("Новое сообщение", body), + }; } export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) { const statusLabels = { - pending: 'Ожидает', - confirmed: 'Подтверждён', - rejected: 'Отклонён', - } - const label = statusLabels[paymentStatus] || paymentStatus + pending: "Ожидает", + confirmed: "Подтверждён", + rejected: "Отклонён", + }; + const label = statusLabels[paymentStatus] || paymentStatus; const body = `

Статус оплаты заказа #${orderId.slice(0, 8)}: ${label}.

- ` - return { subject: `Оплата заказа — ${label}`, html: baseLayout('Оплата заказа', body) } + `; + return { + subject: `Оплата заказа — ${label}`, + html: baseLayout("Оплата заказа", body), + }; } -export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount }) { - const total = (totalCents / 100).toLocaleString('ru-RU') +export function renderAdminOrderCreatedEmail({ + orderId, + userEmail, + totalCents, + itemsCount, +}) { + const total = (totalCents / 100).toLocaleString("ru-RU"); const body = `

Новый заказ #${orderId.slice(0, 8)} от ${userEmail}.

Товаров: ${itemsCount} | Сумма: ${total} ₽

- ` - return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) } + `; + return { subject: "Новый заказ", html: baseLayout("Новый заказ", body) }; } -export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) { - const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating) +export function renderAdminNewReviewEmail({ + rating, + text, + productTitle, + userName, +}) { + const stars = "★".repeat(rating) + "☆".repeat(5 - rating); const body = `

Новый отзыв ${stars} на товар ${productTitle} от ${userName}.

- ${text ? `
${text}
` : ''} + ${text ? `
${text}
` : ""}

Проверьте отзыв в админ-панели.

- ` - return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) } + `; + return { subject: "Новый отзыв", html: baseLayout("Новый отзыв", body) }; } export function renderAuthCodeEmail({ code }) { const body = `

Ваш код входа: ${code}

Если это были не вы — просто проигнорируйте письмо.

- ` - return { subject: 'Код входа', html: baseLayout('Код входа', body) } + `; + return { subject: "Код входа", html: baseLayout("Код входа", body) }; } diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index e033be3..7ea6461 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -1,59 +1,80 @@ -import { prisma } from '../../lib/prisma.js' -import { canTransitionAdminOrderStatus } from '../../lib/order-status.js' -import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +import { prisma } from "../../lib/prisma.js"; +import { canTransitionAdminOrderStatus } from "../../lib/order-status.js"; +import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js"; export async function registerAdminOrderRoutes(fastify) { fastify.get( - '/api/admin/orders/summary', + "/api/admin/orders/summary", { preHandler: [fastify.verifyAdmin] }, async () => { const attentionCount = await prisma.order.count({ where: { - status: 'PENDING_PAYMENT', + status: "PENDING_PAYMENT", }, - }) - return { attentionCount } + }); + return { attentionCount }; }, - ) + ); fastify.get( - '/api/admin/orders', + "/api/admin/orders", { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const status = typeof request.query?.status === 'string' ? request.query.status.trim() : '' - const q = typeof request.query?.q === 'string' ? request.query.q.trim() : '' - const deliveryTypeRaw = request.query?.deliveryType - const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.trim() : '' + const status = + typeof request.query?.status === "string" + ? request.query.status.trim() + : ""; + const q = + typeof request.query?.q === "string" ? request.query.q.trim() : ""; + const deliveryTypeRaw = request.query?.deliveryType; + const deliveryType = + typeof deliveryTypeRaw === "string" ? deliveryTypeRaw.trim() : ""; - const pageRaw = request.query?.page - const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) - const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + const pageRaw = request.query?.page; + const pageParsed = + typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw); + const page = + Number.isFinite(pageParsed) && pageParsed > 0 + ? Math.floor(pageParsed) + : 1; - const pageSizeRaw = request.query?.pageSize - const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) - const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 - if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) + const pageSizeRaw = request.query?.pageSize; + const pageSizeParsed = + typeof pageSizeRaw === "string" + ? Number(pageSizeRaw) + : Number(pageSizeRaw); + const pageSize = + Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 + ? Math.floor(pageSizeParsed) + : 20; + if (pageSize > 100) + return reply.code(400).send({ error: "pageSize должен быть ≤ 100" }); - const where = {} - if (status) where.status = status + const where = {}; + if (status) where.status = status; if (deliveryType) { - if (deliveryType !== 'delivery' && deliveryType !== 'pickup') { - return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' }) + if (deliveryType !== "delivery" && deliveryType !== "pickup") { + return reply + .code(400) + .send({ error: "deliveryType должен быть delivery | pickup" }); } - where.deliveryType = deliveryType + where.deliveryType = deliveryType; } if (q) { - where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }] + where.OR = [ + { id: { contains: q } }, + { user: { email: { contains: q } } }, + ]; } - const total = await prisma.order.count({ where }) + const total = await prisma.order.count({ where }); const items = await prisma.order.findMany({ where, include: { user: { select: { id: true, email: true } }, items: true }, - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, skip: (page - 1) * pageSize, take: pageSize, - }) + }); return { items: items.map((o) => ({ @@ -72,74 +93,97 @@ export async function registerAdminOrderRoutes(fastify) { total, page, pageSize, - } + }; }, - ) + ); fastify.get( - '/api/admin/orders/:id', + "/api/admin/orders/:id", { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params + const { id } = request.params; const order = await prisma.order.findUnique({ where: { id }, include: { user: { select: { id: true, email: true, name: true, phone: true } }, items: true, - messages: { orderBy: { createdAt: 'asc' } }, + messages: { orderBy: { createdAt: "asc" } }, }, - }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - return { item: order } + }); + if (!order) return reply.code(404).send({ error: "Заказ не найден" }); + return { item: order }; }, - ) + ); fastify.patch( - '/api/admin/orders/:id/status', + "/api/admin/orders/:id/status", { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params - const next = String(request.body?.status || '').trim() - if (!next) return reply.code(400).send({ error: 'status обязателен' }) + const { id } = request.params; + const next = String(request.body?.status || "").trim(); + if (!next) return reply.code(400).send({ error: "status обязателен" }); - const existing = await prisma.order.findUnique({ where: { id } }) - if (!existing) return reply.code(404).send({ error: 'Заказ не найден' }) + const existing = await prisma.order.findUnique({ where: { id } }); + if (!existing) return reply.code(404).send({ error: "Заказ не найден" }); if (!canTransitionAdminOrderStatus(existing, next)) { - return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status} → ${next}` }) + return reply + .code(409) + .send({ + error: `Нельзя сменить статус ${existing.status} → ${next}`, + }); } - const updated = await prisma.order.update({ where: { id }, data: { status: next } }) + const updated = await prisma.order.update({ + where: { id }, + data: { status: next }, + }); request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, { orderId: updated.id, userId: existing.userId, oldStatus: existing.status, newStatus: next, - }) + }); - return { item: updated } + return { item: updated }; }, - ) + ); fastify.patch( - '/api/admin/orders/:id/delivery-fee', + "/api/admin/orders/:id/delivery-fee", { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params - const feeRaw = request.body?.deliveryFeeCents + const { id } = request.params; + const feeRaw = request.body?.deliveryFeeCents; const parsed = - typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN + typeof feeRaw === "string" + ? Number.parseInt(feeRaw, 10) + : typeof feeRaw === "number" + ? feeRaw + : NaN; if (!Number.isInteger(parsed) || parsed < 0) { - return reply.code(400).send({ error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)' }) + return reply + .code(400) + .send({ + error: "deliveryFeeCents должно быть целым числом ≥ 0 (копейки)", + }); } - const existing = await prisma.order.findUnique({ where: { id } }) - if (!existing) return reply.code(404).send({ error: 'Заказ не найден' }) - if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) { - return reply.code(409).send({ error: 'Корректировка доставки доступна только пока стоимость не утверждена' }) + const existing = await prisma.order.findUnique({ where: { id } }); + if (!existing) return reply.code(404).send({ error: "Заказ не найден" }); + if ( + existing.status !== "PENDING_PAYMENT" || + existing.deliveryFeeLocked !== false + ) { + return reply + .code(409) + .send({ + error: + "Корректировка доставки доступна только пока стоимость не утверждена", + }); } - const totalCents = existing.itemsSubtotalCents + parsed + const totalCents = existing.itemsSubtotalCents + parsed; const updated = await prisma.order.update({ where: { id }, data: { @@ -147,34 +191,39 @@ export async function registerAdminOrderRoutes(fastify) { totalCents, deliveryFeeLocked: true, }, - }) - return { item: updated } + }); + return { item: updated }; }, - ) + ); fastify.post( - '/api/admin/orders/:id/messages', + "/api/admin/orders/:id/messages", { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params - 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 { id } = request.params; + 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 order = await prisma.order.findUnique({ where: { id } }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + const order = await prisma.order.findUnique({ where: { id } }); + if (!order) return reply.code(404).send({ error: "Заказ не найден" }); - const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'admin', text } }) + const msg = await prisma.orderMessage.create({ + data: { orderId: id, authorType: "admin", text }, + }); - request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, { - orderId: id, - userId: order.userId, - messageId: msg.id, - preview: text, - }) + request.server.eventBus.emit( + NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, + { + orderId: id, + userId: order.userId, + messageId: msg.id, + preview: text, + }, + ); - return reply.code(201).send({ item: msg }) + return reply.code(201).send({ item: msg }); }, - ) + ); } - diff --git a/server/src/routes/api/admin-reviews.js b/server/src/routes/api/admin-reviews.js index 9a64ade..a236240 100644 --- a/server/src/routes/api/admin-reviews.js +++ b/server/src/routes/api/admin-reviews.js @@ -1,72 +1,90 @@ -import { prisma } from '../../lib/prisma.js' -import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +import { prisma } from "../../lib/prisma.js"; +import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js"; export async function registerAdminReviewRoutes(fastify) { fastify.get( - '/api/admin/reviews', + "/api/admin/reviews", { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending' + const status = + typeof request.query?.status === "string" + ? request.query.status.trim() + : "pending"; - const pageRaw = request.query?.page - const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) - const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + const pageRaw = request.query?.page; + const pageParsed = + typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw); + const page = + Number.isFinite(pageParsed) && pageParsed > 0 + ? Math.floor(pageParsed) + : 1; - const pageSizeRaw = request.query?.pageSize - const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) - const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 - if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) + const pageSizeRaw = request.query?.pageSize; + const pageSizeParsed = + typeof pageSizeRaw === "string" + ? Number(pageSizeRaw) + : Number(pageSizeRaw); + const pageSize = + Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 + ? Math.floor(pageSizeParsed) + : 20; + if (pageSize > 100) + return reply.code(400).send({ error: "pageSize должен быть ≤ 100" }); - const where = status ? { status } : {} - const total = await prisma.review.count({ where }) + const where = status ? { status } : {}; + const total = await prisma.review.count({ where }); const items = await prisma.review.findMany({ where, include: { user: { select: { id: true, email: true, name: true } }, product: { select: { id: true, title: true } }, }, - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, skip: (page - 1) * pageSize, take: pageSize, - }) + }); - return { items, total, page, pageSize } + return { items, total, page, pageSize }; }, - ) + ); fastify.patch( - '/api/admin/reviews/:id', + "/api/admin/reviews/:id", { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params - const action = String(request.body?.action || '').trim() - if (action !== 'approve' && action !== 'reject') { - return reply.code(400).send({ error: 'action должен быть approve или reject' }) + const { id } = request.params; + const action = String(request.body?.action || "").trim(); + if (action !== "approve" && action !== "reject") { + return reply + .code(400) + .send({ error: "action должен быть approve или reject" }); } const existing = await prisma.review.findUnique({ where: { id }, - include: { product: { select: { title: true } }, user: { select: { name: true, email: true } } }, - }) - if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' }) + include: { + product: { select: { title: true } }, + user: { select: { name: true, email: true } }, + }, + }); + if (!existing) return reply.code(404).send({ error: "Отзыв не найден" }); const updated = await prisma.review.update({ where: { id }, data: { - status: action === 'approve' ? 'approved' : 'rejected', + status: action === "approve" ? "approved" : "rejected", moderatedAt: new Date(), }, - }) - request.server.eventBus.emit('review:created', { + }); + request.server.eventBus.emit("review:created", { rating: updated.rating, - text: updated.text || '', - productTitle: existing.product?.title || '', - userName: existing.user?.name || existing.user?.email || '', + text: updated.text || "", + productTitle: existing.product?.title || "", + userName: existing.user?.name || existing.user?.email || "", reviewId: updated.id, - }) + }); - return { item: updated } + return { item: updated }; }, - ) + ); } - diff --git a/server/src/routes/api/admin/notifications.js b/server/src/routes/api/admin/notifications.js index 44b5bcd..b8f3bca 100644 --- a/server/src/routes/api/admin/notifications.js +++ b/server/src/routes/api/admin/notifications.js @@ -1,11 +1,11 @@ -import { prisma } from '../../lib/prisma.js' +import { prisma } from "../../../lib/prisma.js"; export async function registerAdminNotificationRoutes(fastify) { fastify.get( - '/api/admin/notifications/settings', + "/api/admin/notifications/settings", { preHandler: [fastify.verifyAdmin] }, async () => { - let settings = await prisma.adminNotificationSettings.findFirst() + let settings = await prisma.adminNotificationSettings.findFirst(); if (!settings) { settings = await prisma.adminNotificationSettings.create({ data: { @@ -16,74 +16,80 @@ export async function registerAdminNotificationRoutes(fastify) { newReview: true, authCodeDuplicate: false, }, - }) + }); } - return { settings } + return { settings }; }, - ) + ); fastify.put( - '/api/admin/notifications/settings', + "/api/admin/notifications/settings", { preHandler: [fastify.verifyAdmin] }, async (request) => { - const body = request.body || {} - let settings = await prisma.adminNotificationSettings.findFirst() + const body = request.body || {}; + let settings = await prisma.adminNotificationSettings.findFirst(); - const data = {} - if ('emailEnabled' in body) data.emailEnabled = Boolean(body.emailEnabled) - if ('telegramEnabled' in body) data.telegramEnabled = Boolean(body.telegramEnabled) - if ('telegramChatId' in body) data.telegramChatId = body.telegramChatId || null - if ('newOrder' in body) data.newOrder = Boolean(body.newOrder) - if ('newOrderMessage' in body) data.newOrderMessage = Boolean(body.newOrderMessage) - if ('newReview' in body) data.newReview = Boolean(body.newReview) - if ('authCodeDuplicate' in body) data.authCodeDuplicate = Boolean(body.authCodeDuplicate) + const data = {}; + if ("emailEnabled" in body) + data.emailEnabled = Boolean(body.emailEnabled); + if ("telegramEnabled" in body) + data.telegramEnabled = Boolean(body.telegramEnabled); + if ("telegramChatId" in body) + data.telegramChatId = body.telegramChatId || null; + if ("newOrder" in body) data.newOrder = Boolean(body.newOrder); + if ("newOrderMessage" in body) + data.newOrderMessage = Boolean(body.newOrderMessage); + if ("newReview" in body) data.newReview = Boolean(body.newReview); + if ("authCodeDuplicate" in body) + data.authCodeDuplicate = Boolean(body.authCodeDuplicate); if (!settings) { - settings = await prisma.adminNotificationSettings.create({ data }) + settings = await prisma.adminNotificationSettings.create({ data }); } else { settings = await prisma.adminNotificationSettings.update({ where: { id: settings.id }, data, - }) + }); } - return { settings } + return { settings }; }, - ) + ); - fastify.post( - '/api/admin/notifications/telegram/webhook', - async (request) => { - const update = request.body || {} - const message = update.message - if (!message || !message.text || message.text !== '/start') return { ok: true } + fastify.post("/api/admin/notifications/telegram/webhook", async (request) => { + const update = request.body || {}; + const message = update.message; + if (!message || !message.text || message.text !== "/start") + return { ok: true }; - const chatId = String(message.chat.id) - const settings = await prisma.adminNotificationSettings.findFirst() + const chatId = String(message.chat.id); + const settings = await prisma.adminNotificationSettings.findFirst(); - if (settings) { - await prisma.adminNotificationSettings.update({ - where: { id: settings.id }, - data: { telegramChatId: chatId }, - }) - } else { - await prisma.adminNotificationSettings.create({ - data: { telegramChatId: chatId }, - }) - } + if (settings) { + await prisma.adminNotificationSettings.update({ + where: { id: settings.id }, + data: { telegramChatId: chatId }, + }); + } else { + await prisma.adminNotificationSettings.create({ + data: { telegramChatId: chatId }, + }); + } - if (process.env.TELEGRAM_BOT_TOKEN) { - await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + if (process.env.TELEGRAM_BOT_TOKEN) { + await fetch( + `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: chatId, - text: 'Вы подписаны на уведомления Craftshop.', + text: "Вы подписаны на уведомления Любимый Креатив.", }), - }) - } + }, + ); + } - return { ok: true } - }, - ) + return { ok: true }; + }); } diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 41b63ab..6118a9b 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,139 +1,177 @@ -import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js' -import { prisma } from '../lib/prisma.js' -import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +import { + issueEmailCode, + normalizeEmail, + verifyEmailCode, +} from "../lib/auth.js"; +import { prisma } from "../lib/prisma.js"; +import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js"; function mapUserForClient(user) { - const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) - const userEmail = normalizeEmail(user.email) + 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: 'Некорректная почта' }) + 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: "Некорректная почта" }); - const code = await issueEmailCode({ email, purpose: 'login' }) + const code = await issueEmailCode({ email, purpose: "login" }); - const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() - const isAdmin = email === adminEmail + const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase(); + const isAdmin = email === adminEmail; request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { email, code, isAdmin, - }) + }); - return { ok: true } - }) + 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 цифр' }) + 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 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 }, - }) + }); // Ensure notification preference exists await prisma.notificationPreference.upsert({ where: { userId: user.id }, create: { userId: user.id, globalEnabled: true }, update: {}, - }) + }); - const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return { token, user: mapUserForClient(user) } - }) + const token = fastify.jwt.sign({ sub: user.id, email: user.email }); + return { token, user: mapUserForClient(user) }; + }); fastify.get( - '/api/me', + "/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) } + 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', + "/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 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: 'Эта почта уже занята' }) + 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 } + await issueEmailCode({ + email: newEmail, + purpose: "change_email", + userId, + }); + return { ok: true }; }, - ) + ); fastify.post( - '/api/me/change-email/verify', + "/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 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 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 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) } + }); + return { user: mapUserForClient(user) }; }, - ) + ); fastify.patch( - '/api/me/profile', + "/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() + 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 (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: 'Телефон слишком длинный' }) + 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: 'Некорректный телефон' }) + 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) } + data: { + name: name && name.length ? name : null, + phone: phone && phone.length ? phone : null, + }, + }); + return { user: mapUserForClient(updated) }; }, - ) + ); } diff --git a/server/src/routes/user-messages.js b/server/src/routes/user-messages.js index bae01ac..e9db4bc 100644 --- a/server/src/routes/user-messages.js +++ b/server/src/routes/user-messages.js @@ -1,123 +1,155 @@ -import { prisma } from '../lib/prisma.js' -import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +import { prisma } from "../lib/prisma.js"; +import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js"; export async function registerUserMessageRoutes(fastify) { fastify.get( - '/api/me/orders/:id/messages', + "/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 } + 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', + "/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 } }) + 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 }, + }); request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, { orderId: id, - authorType: 'user', + 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', + "/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 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])) + const readStates = await prisma.userOrderMessageReadState.findMany({ + where: { userId }, + }); + const lastReadByOrder = new Map( + readStates.map((r) => [r.orderId, r.lastReadAt]), + ); - let count = 0 + let count = 0; for (const o of orders) { - const lastRead = lastReadByOrder.get(o.id) ?? new Date(0) + 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 + where: { + orderId: o.id, + authorType: "admin", + createdAt: { gt: lastRead }, + }, + }); + count += n; } - return { count } + return { count }; }, - ) + ); fastify.get( - '/api/me/conversations', + "/api/me/conversations", { preHandler: [fastify.authenticate] }, async (request) => { - const userId = request.user.sub + 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 } }, + messages: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { text: true, createdAt: true }, + }, }, - orderBy: { updatedAt: 'desc' }, - }) + orderBy: { updatedAt: "desc" }, + }); - const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } }) - const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) + const readStates = await prisma.userOrderMessageReadState.findMany({ + where: { userId }, + }); + const lastReadByOrder = new Map( + readStates.map((r) => [r.orderId, r.lastReadAt]), + ); - const items = [] + 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 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 } }, - }) + 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, + preview: + lastMsg.text.length > 280 + ? `${lastMsg.text.slice(0, 277)}…` + : lastMsg.text, unreadCount, - }) + }); } - return { items } + return { items }; }, - ) + ); fastify.post( - '/api/me/orders/:id/messages/read', + "/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 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() + 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 } + }); + return { ok: true }; }, - ) + ); } diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 2822590..e1792e1 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -1,77 +1,98 @@ -import { isDeliveryCarrier } from '../lib/delivery-carrier.js' -import { prisma } from '../lib/prisma.js' -import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +import { isDeliveryCarrier } from "../lib/delivery-carrier.js"; +import { prisma } from "../lib/prisma.js"; +import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js"; export async function registerUserOrderRoutes(fastify) { // ---- Создание заказа (checkout) ---- fastify.post( - '/api/me/orders', + "/api/me/orders", { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const deliveryTypeRaw = request.body?.deliveryType + const userId = request.user.sub; + const deliveryTypeRaw = request.body?.deliveryType; const deliveryType = - deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === '' - ? 'delivery' - : String(deliveryTypeRaw).trim() + 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 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 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' }) + 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' }) + 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 carrierRaw = request.body?.deliveryCarrier; + let deliveryCarrier = null; + if (deliveryType === "delivery") { const carrierStr = - carrierRaw === undefined || carrierRaw === null || carrierRaw === '' - ? '' - : String(carrierRaw).trim() + 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', - }) + return reply.code(400).send({ + error: + "deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST", + }); } - deliveryCarrier = carrierStr + deliveryCarrier = carrierStr; } - if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') { - return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза' }) + 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: 'Адрес не найден' }) + 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: 'Корзина пуста' }) + }); + if (cartItems.length === 0) + return reply.code(400).send({ error: "Корзина пуста" }); for (const ci of cartItems) { - const available = ci.product.inStock ? ci.product.quantity : 1 + const available = ci.product.inStock ? ci.product.quantity : 1; if (ci.qty > available) { - return reply.code(409).send({ error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.` }) + return reply + .code(409) + .send({ + error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`, + }); } } @@ -80,17 +101,20 @@ export async function registerUserOrderRoutes(fastify) { 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 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' }) + deliveryType === "pickup" + ? JSON.stringify({ deliveryType: "pickup" }) : JSON.stringify({ - deliveryType: 'delivery', + deliveryType: "delivery", id: address.id, label: address.label, recipientName: address.recipientName, @@ -99,31 +123,30 @@ export async function registerUserOrderRoutes(fastify) { comment: address.comment, lat: address.lat, lng: address.lng, - }) + }); - let initialStatus = 'PENDING_PAYMENT' - let deliveryFeeLocked = true - if (paymentMethod === 'on_pickup') { - initialStatus = 'IN_PROGRESS' - } else if (deliveryType === 'delivery') { - initialStatus = 'PENDING_PAYMENT' - deliveryFeeLocked = false + let initialStatus = "PENDING_PAYMENT"; + let deliveryFeeLocked = true; + if (paymentMethod === "on_pickup") { + initialStatus = "IN_PROGRESS"; + } else if (deliveryType === "delivery") { + initialStatus = "PENDING_PAYMENT"; + deliveryFeeLocked = false; } - let created + let created; try { created = await prisma.$transaction(async (tx) => { for (const ci of cartItems) { - if (!ci.product.inStock) continue + 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}"`) + throw new Error(`Недостаточно товара: "${ci.product.title}"`); } - } const order = await tx.order.create({ @@ -137,7 +160,7 @@ export async function registerUserOrderRoutes(fastify) { itemsSubtotalCents, deliveryFeeCents, totalCents, - currency: 'RUB', + currency: "RUB", addressSnapshotJson, comment: comment && comment.length ? comment : null, items: { @@ -149,12 +172,16 @@ export async function registerUserOrderRoutes(fastify) { })), }, }, - }) - await tx.cartItem.deleteMany({ where: { userId } }) - return order - }) + }); + await tx.cartItem.deleteMany({ where: { userId } }); + return order; + }); } catch (e) { - return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара' }) + return reply + .code(409) + .send({ + error: (e instanceof Error && e.message) || "Недостаточно товара", + }); } // Emit notification events @@ -163,31 +190,31 @@ export async function registerUserOrderRoutes(fastify) { userId, totalCents: created.totalCents, itemsCount: cartItems.length, - }) + }); // Also emit admin notification - request.server.eventBus.emit('order:created:admin', { + request.server.eventBus.emit("order:created:admin", { orderId: created.id, userId, - userEmail: request.user.email || '', + userEmail: request.user.email || "", totalCents: created.totalCents, itemsCount: cartItems.length, - }) + }); - return reply.code(201).send({ orderId: created.id }) + return reply.code(201).send({ orderId: created.id }); }, - ) + ); fastify.get( - '/api/me/orders', + "/api/me/orders", { preHandler: [fastify.authenticate] }, async (request) => { - const userId = request.user.sub + const userId = request.user.sub; const orders = await prisma.order.findMany({ where: { userId }, include: { items: true }, - orderBy: { createdAt: 'desc' }, - }) + orderBy: { createdAt: "desc" }, + }); return { items: orders.map((o) => ({ id: o.id, @@ -198,76 +225,86 @@ export async function registerUserOrderRoutes(fastify) { updatedAt: o.updatedAt, itemsCount: o.items.reduce((s, i) => s + i.qty, 0), })), - } + }; }, - ) + ); fastify.get( - '/api/me/orders/:id', + "/api/me/orders/:id", { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params + 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 } + 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', + "/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 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() + 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 }) + uniq.set(it.productId, { + productId: it.productId, + title: it.titleSnapshot, + }); } } - const productIds = [...uniq.keys()] + 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)) + }); + 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', + "/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 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' + 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: 'Сейчас нельзя подтвердить получение заказа' }) + return reply + .code(409) + .send({ error: "Сейчас нельзя подтвердить получение заказа" }); } - await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) - return { ok: true, status: 'DONE' } + await prisma.order.update({ where: { id }, data: { status: "DONE" } }); + return { ok: true, status: "DONE" }; }, - ) + ); } diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index c9c3633..a864cad 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -1,118 +1,142 @@ -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' -import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +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"; +import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js"; export async function registerUserPaymentRoutes(fastify) { fastify.post( - '/api/me/orders/:id/pay', + "/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 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: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' }) + const paymentMethod = order.paymentMethod ?? "online"; + if (paymentMethod === "on_pickup") { + return reply + .code(409) + .send({ + error: + "Для этого заказа оплата при получении — кнопка оплаты не нужна.", + }); } - if (order.status !== 'PENDING_PAYMENT') { - return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) + if (order.status !== "PENDING_PAYMENT") { + return reply + .code(409) + .send({ error: "Сейчас нельзя выполнить оплату для этого заказа" }); } if (!request.isMultipart()) { return reply .code(400) - .send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' }) + .send({ + error: + "Отправьте multipart/form-data: поле detail и/или файл receipt", + }); } - let detail = '' - let receiptBuffer = null - let receiptFilename = '' + let detail = ""; + let receiptBuffer = null; + let receiptFilename = ""; try { - const otherLimit = getOtherUploadMaxFileBytes() + 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 (part.fieldname === "receipt") { if (receiptBuffer !== null) { - return reply.code(400).send({ error: 'Допускается один файл receipt' }) + return reply + .code(400) + .send({ error: "Допускается один файл receipt" }); } - receiptBuffer = await part.toBuffer() - receiptFilename = part.filename ?? 'receipt' + receiptBuffer = await part.toBuffer(); + receiptFilename = part.filename ?? "receipt"; } - } else if (part.fieldname === 'detail') { - detail = String(part.value ?? '').trim() + } 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 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 + const hasDetail = detail.length > 0; + const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0; if (!hasDetail && !hasReceipt) { return reply .code(400) - .send({ error: 'Укажите текст о платеже и/или прикрепите изображение чека' }) + .send({ + error: "Укажите текст о платеже и/или прикрепите изображение чека", + }); } - const maxDetail = 2000 + const maxDetail = 2000; if (detail.length > maxDetail) { - return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` }) + return reply + .code(400) + .send({ error: `Текст не длиннее ${maxDetail} символов` }); } - let attachmentUrl = null + let attachmentUrl = null; if (hasReceipt) { try { - attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer) + attachmentUrl = await saveImageBufferToUploads( + receiptFilename, + receiptBuffer, + ); } catch (err) { - const message = err instanceof Error ? err.message : 'Не удалось сохранить файл' + const message = + err instanceof Error ? err.message : "Не удалось сохранить файл"; const statusCode = - err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode) + err && + typeof err === "object" && + "statusCode" in err && + Number.isInteger(err.statusCode) ? Number(err.statusCode) - : 400 - return reply.code(statusCode).send({ error: message }) + : 400; + return reply.code(statusCode).send({ error: message }); } } const bodyHtml = hasDetail - ? `

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

` - : '' - const messageText = `

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

${bodyHtml}` + ? `

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

` + : ""; + const messageText = `

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

${bodyHtml}`; try { await prisma.$transaction(async (tx) => { await tx.orderMessage.create({ data: { orderId: id, - authorType: 'user', + authorType: "user", text: messageText, attachmentUrl, }, - }) - }) + }); + }); } catch (err) { - return reply.code(500).send({ error: 'Не удалось сохранить оплату' }) + return reply.code(500).send({ error: "Не удалось сохранить оплату" }); } request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { orderId: id, userId, - paymentStatus: 'pending', - }) + paymentStatus: "pending", + }); - return { ok: true, status: 'PENDING_PAYMENT' } + return { ok: true, status: "PENDING_PAYMENT" }; }, - ) + ); }