test commit

This commit is contained in:
Kirill
2026-05-18 13:54:05 +05:00
parent 7421384161
commit 2f67c37502
10 changed files with 875 additions and 627 deletions
+100 -90
View File
@@ -1,104 +1,110 @@
import 'dotenv/config' import "dotenv/config";
import Fastify from 'fastify' import Fastify from "fastify";
import cors from '@fastify/cors' import cors from "@fastify/cors";
import jwt from '@fastify/jwt' import jwt from "@fastify/jwt";
import multipart from '@fastify/multipart' import multipart from "@fastify/multipart";
import fastifyStatic from '@fastify/static' import fastifyStatic from "@fastify/static";
import path from 'node:path' import path from "node:path";
import { ensureAdminUser } from './lib/bootstrap-admin.js' import { ensureAdminUser } from "./lib/bootstrap-admin.js";
import { getOrCreateUnspecifiedCategory } from './lib/default-category.js' import { getOrCreateUnspecifiedCategory } from "./lib/default-category.js";
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' import {
import { createEventBus } from './lib/notifications/event-bus.js' getMaxUploadBodyBytes,
import { createNotificationQueue } from './lib/notifications/queue.js' getProductImageMaxFileBytes,
import { prisma } from './lib/prisma.js' } 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 { import {
resolveUserNotificationTargets, resolveUserNotificationTargets,
resolveAdminNotificationTargets, resolveAdminNotificationTargets,
resolveAuthCodeTargets, resolveAuthCodeTargets,
} from './lib/notifications/preferences.js' } from "./lib/notifications/preferences.js";
import { NOTIFICATION_EVENTS, NOTIFICATION_CHANNELS } from './shared/constants/notification-events.js' import {
import { registerAuth } from './plugins/auth.js' NOTIFICATION_EVENTS,
import { registerApiRoutes } from './routes/api.js' NOTIFICATION_CHANNELS,
import { registerAuthRoutes } from './routes/auth.js' } from "../../shared/constants/notification-events.js";
import { registerUserAddressRoutes } from './routes/user-addresses.js' import { registerAuth } from "./plugins/auth.js";
import { registerUserCartRoutes } from './routes/user-cart.js' import { registerApiRoutes } from "./routes/api.js";
import { registerUserMessageRoutes } from './routes/user-messages.js' import { registerAuthRoutes } from "./routes/auth.js";
import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserAddressRoutes } from "./routes/user-addresses.js";
import { registerUserPaymentRoutes } from './routes/user-payments.js' import { registerUserCartRoutes } from "./routes/user-cart.js";
import { registerUserNotificationRoutes } from './routes/user/notifications.js' import { registerUserMessageRoutes } from "./routes/user-messages.js";
import { registerOAuthSocialRoutes } from './routes/oauth-social.js' import { registerUserOrderRoutes } from "./routes/user-orders.js";
import { registerUploadsResized } from './routes/uploads-resized.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 port = Number(process.env.PORT) || 3333;
const origin = (process.env.CORS_ORIGIN ?? '') const origin = (process.env.CORS_ORIGIN ?? "")
.split(',') .split(",")
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean);
const fastify = Fastify({ const fastify = Fastify({
logger: true, logger: true,
bodyLimit: getMaxUploadBodyBytes(), bodyLimit: getMaxUploadBodyBytes(),
}) });
await fastify.register(cors, { await fastify.register(cors, {
origin: origin.length ? origin : true, origin: origin.length ? origin : true,
credentials: true, credentials: true,
}) });
await fastify.register(jwt, { 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, { await fastify.register(multipart, {
limits: { limits: {
files: 10, files: 10,
fileSize: getProductImageMaxFileBytes(), fileSize: getProductImageMaxFileBytes(),
}, },
}) });
registerUploadsResized(fastify) registerUploadsResized(fastify);
const uploadsDir = path.join(process.cwd(), 'uploads') const uploadsDir = path.join(process.cwd(), "uploads");
await fastify.register(fastifyStatic, { await fastify.register(fastifyStatic, {
root: uploadsDir, root: uploadsDir,
prefix: '/uploads/', prefix: "/uploads/",
setHeaders(res, filePath) { setHeaders(res, filePath) {
if (filePath.includes('/.cache/')) { if (filePath.includes("/.cache/")) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
} else { } 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 { try {
await request.jwtVerify() await request.jwtVerify();
} catch { } catch {
return reply.code(401).send({ error: 'Не авторизован' }) return reply.code(401).send({ error: "Не авторизован" });
} }
}) });
const eventBus = createEventBus() const eventBus = createEventBus();
const notificationQueue = createNotificationQueue() const notificationQueue = createNotificationQueue();
fastify.decorate('eventBus', eventBus) fastify.decorate("eventBus", eventBus);
fastify.decorate('notificationQueue', notificationQueue) fastify.decorate("notificationQueue", notificationQueue);
registerAuth(fastify) registerAuth(fastify);
await registerAuthRoutes(fastify) await registerAuthRoutes(fastify);
await registerUserAddressRoutes(fastify) await registerUserAddressRoutes(fastify);
await registerUserCartRoutes(fastify) await registerUserCartRoutes(fastify);
await registerUserMessageRoutes(fastify) await registerUserMessageRoutes(fastify);
await registerUserOrderRoutes(fastify) await registerUserOrderRoutes(fastify);
await registerUserPaymentRoutes(fastify) await registerUserPaymentRoutes(fastify);
await registerUserNotificationRoutes(fastify) await registerUserNotificationRoutes(fastify);
await registerOAuthSocialRoutes(fastify) await registerOAuthSocialRoutes(fastify);
await registerApiRoutes(fastify) await registerApiRoutes(fastify);
await ensureAdminUser() await ensureAdminUser();
await getOrCreateUnspecifiedCategory() await getOrCreateUnspecifiedCategory();
await notificationQueue.flushPendingOnStartup() await notificationQueue.flushPendingOnStartup();
notificationQueue.start() notificationQueue.start();
const { const {
ORDER_CREATED, ORDER_CREATED,
@@ -107,58 +113,62 @@ const {
ORDER_MESSAGE_ADMIN_REPLY, ORDER_MESSAGE_ADMIN_REPLY,
PAYMENT_STATUS_CHANGED, PAYMENT_STATUS_CHANGED,
AUTH_CODE_REQUESTED, AUTH_CODE_REQUESTED,
} = NOTIFICATION_EVENTS } = NOTIFICATION_EVENTS;
async function dispatchNotification(eventType, payload) { async function dispatchNotification(eventType, payload) {
const userTargets = await resolveUserNotificationTargets(eventType, payload) const userTargets = await resolveUserNotificationTargets(eventType, payload);
for (const target of userTargets) { for (const target of userTargets) {
const log = await prisma.notificationLog.create({ const log = await prisma.notificationLog.create({
data: { data: {
userId: payload.userId, userId: payload.userId,
eventType, eventType,
channel: target.channel, channel: target.channel,
status: 'pending', status: "pending",
payload: JSON.stringify(payload), 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 adminEventType =
const adminTargets = await resolveAdminNotificationTargets(adminEventType, payload) eventType === "order:created:admin" ? ORDER_CREATED : eventType;
const adminTargets = await resolveAdminNotificationTargets(
adminEventType,
payload,
);
for (const target of adminTargets) { for (const target of adminTargets) {
const log = await prisma.notificationLog.create({ const log = await prisma.notificationLog.create({
data: { data: {
eventType, eventType,
channel: target.channel, channel: target.channel,
status: 'pending', status: "pending",
payload: JSON.stringify(payload), 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_CREATED, (payload) => dispatchNotification(ORDER_CREATED, payload));
eventBus.on(ORDER_STATUS_CHANGED, dispatchNotification) eventBus.on(ORDER_STATUS_CHANGED, (payload) => dispatchNotification(ORDER_STATUS_CHANGED, payload));
eventBus.on(ORDER_MESSAGE_SENT, dispatchNotification) eventBus.on(ORDER_MESSAGE_SENT, (payload) => dispatchNotification(ORDER_MESSAGE_SENT, payload));
eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, dispatchNotification) eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, (payload) => dispatchNotification(ORDER_MESSAGE_ADMIN_REPLY, payload));
eventBus.on(PAYMENT_STATUS_CHANGED, dispatchNotification) eventBus.on(PAYMENT_STATUS_CHANGED, (payload) => dispatchNotification(PAYMENT_STATUS_CHANGED, payload));
eventBus.on(AUTH_CODE_REQUESTED, dispatchNotification) eventBus.on(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, payload));
eventBus.on('order:created:admin', dispatchNotification) eventBus.on("order:created:admin", (payload) => dispatchNotification("order:created:admin", payload));
eventBus.on('review:created', dispatchNotification) eventBus.on("review:created", (payload) => dispatchNotification("review:created", payload));
async function shutdown() { async function shutdown() {
notificationQueue.stop() notificationQueue.stop();
await fastify.close() await fastify.close();
process.exit(0) process.exit(0);
} }
process.on('SIGINT', shutdown) process.on("SIGINT", shutdown);
process.on('SIGTERM', shutdown) process.on("SIGTERM", shutdown);
try { try {
await fastify.listen({ port, host: '0.0.0.0' }) await fastify.listen({ port, host: "0.0.0.0" });
} catch (err) { } catch (err) {
fastify.log.error(err) fastify.log.error(err);
process.exit(1) process.exit(1);
} }
+48 -38
View File
@@ -1,5 +1,5 @@
import { prisma } from '../prisma.js' import { prisma } from "../prisma.js";
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
const { const {
ORDER_CREATED, ORDER_CREATED,
@@ -8,93 +8,103 @@ const {
ORDER_MESSAGE_ADMIN_REPLY, ORDER_MESSAGE_ADMIN_REPLY,
PAYMENT_STATUS_CHANGED, PAYMENT_STATUS_CHANGED,
AUTH_CODE_REQUESTED, AUTH_CODE_REQUESTED,
} = NOTIFICATION_EVENTS } = NOTIFICATION_EVENTS;
const userEventFieldMap = { const userEventFieldMap = {
[ORDER_CREATED]: 'orderCreated', [ORDER_CREATED]: "orderCreated",
[ORDER_STATUS_CHANGED]: 'orderStatusChanged', [ORDER_STATUS_CHANGED]: "orderStatusChanged",
[ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived', [ORDER_MESSAGE_ADMIN_REPLY]: "orderMessageReceived",
[PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged', [PAYMENT_STATUS_CHANGED]: "paymentStatusChanged",
} };
const adminEventFieldMap = { const adminEventFieldMap = {
[ORDER_CREATED]: 'newOrder', [ORDER_CREATED]: "newOrder",
[ORDER_MESSAGE_SENT]: 'newOrderMessage', [ORDER_MESSAGE_SENT]: "newOrderMessage",
'review:created': 'newReview', "review:created": "newReview",
} };
export async function resolveUserNotificationTargets(eventType, payload) { export async function resolveUserNotificationTargets(eventType, payload) {
const targets = [] const targets = [];
if (payload.userId) { if (payload.userId) {
const prefs = await prisma.notificationPreference.findUnique({ const prefs = await prisma.notificationPreference.findUnique({
where: { userId: payload.userId }, where: { userId: payload.userId },
}) });
if (prefs && prefs.globalEnabled) { if (prefs && prefs.globalEnabled) {
const field = userEventFieldMap[eventType] const field = userEventFieldMap[eventType];
if (field && prefs[field]) { 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) { 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) { export async function resolveAdminNotificationTargets(eventType, payload) {
const targets = [] const targets = [];
const settings = await prisma.adminNotificationSettings.findFirst() const settings = await prisma.adminNotificationSettings.findFirst();
if (!settings) return targets if (!settings) return targets;
const field = adminEventFieldMap[eventType] const field = adminEventFieldMap[eventType];
if (field === 'newReview') { if (field === "newReview") {
if (!settings.newReview) return targets if (!settings.newReview) return targets;
} else if (field && !settings[field]) { } else if (field && !settings[field]) {
return targets return targets;
} }
if (settings.emailEnabled) { if (settings.emailEnabled) {
const admin = await prisma.user.findFirst({ const admin = await prisma.user.findFirst({
where: { email: process.env.ADMIN_EMAIL }, where: { email: process.env.ADMIN_EMAIL },
select: { email: true }, select: { email: true },
}) });
if (admin) { if (admin) {
targets.push({ channel: 'email', recipient: admin.email }) targets.push({ channel: "email", recipient: admin.email });
} }
} }
if (settings.telegramEnabled && settings.telegramChatId) { 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) { export async function resolveAuthCodeTargets(eventType, payload) {
const targets = [] const targets = [];
if (payload.email) { if (payload.email) {
targets.push({ channel: 'email', recipient: payload.email }) targets.push({ channel: "email", recipient: payload.email });
} }
if (payload.isAdmin) { if (payload.isAdmin) {
const settings = await prisma.adminNotificationSettings.findFirst() const settings = await prisma.adminNotificationSettings.findFirst();
if (settings && settings.telegramEnabled && settings.telegramChatId && settings.authCodeDuplicate) { if (
targets.push({ channel: 'telegram', recipient: settings.telegramChatId }) settings &&
settings.telegramEnabled &&
settings.telegramChatId &&
settings.authCodeDuplicate
) {
targets.push({ channel: "telegram", recipient: settings.telegramChatId });
} }
} }
return targets return targets;
} }
export async function ensureUserNotificationPreference(userId) { export async function ensureUserNotificationPreference(userId) {
const existing = await prisma.notificationPreference.findUnique({ where: { userId } }) const existing = await prisma.notificationPreference.findUnique({
if (existing) return existing where: { userId },
});
if (existing) return existing;
return prisma.notificationPreference.create({ return prisma.notificationPreference.create({
data: { userId, globalEnabled: true }, data: { userId, globalEnabled: true },
}) });
} }
@@ -8,89 +8,113 @@ function baseLayout(title, body) {
</div> </div>
${body} ${body}
<div style="margin-top:24px;padding-top:16px;border-top:1px solid #e0e0e0;color:#666;font-size:14px;"> <div style="margin-top:24px;padding-top:16px;border-top:1px solid #e0e0e0;color:#666;font-size:14px;">
<p>Craftshop — магазин handmade изделий</p> <p>Любимый Креатив — магазин handmade изделий</p>
</div> </div>
</body> </body>
</html>` </html>`;
} }
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount }) { export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount }) {
const total = (totalCents / 100).toLocaleString('ru-RU') const total = (totalCents / 100).toLocaleString("ru-RU");
const body = ` const body = `
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p> <p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p> <p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
<p>Мы сообщим вам об изменениях статуса.</p> <p>Мы сообщим вам об изменениях статуса.</p>
` `;
return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) } return { subject: "Заказ создан", html: baseLayout("Заказ создан", body) };
} }
export function renderOrderStatusChangedEmail({ orderId, oldStatus, newStatus }) { export function renderOrderStatusChangedEmail({
orderId,
oldStatus,
newStatus,
}) {
const statusLabels = { const statusLabels = {
DRAFT: 'Черновик', DRAFT: "Черновик",
PENDING_PAYMENT: 'Ожидает оплаты', PENDING_PAYMENT: "Ожидает оплаты",
IN_PROGRESS: 'В работе', IN_PROGRESS: "В работе",
READY_FOR_PICKUP: 'Готов к выдаче', READY_FOR_PICKUP: "Готов к выдаче",
SHIPPED: 'Отправлен', SHIPPED: "Отправлен",
DONE: 'Выполнен', DONE: "Выполнен",
CANCELLED: 'Отменён', CANCELLED: "Отменён",
} };
const oldLabel = statusLabels[oldStatus] || oldStatus const oldLabel = statusLabels[oldStatus] || oldStatus;
const newLabel = statusLabels[newStatus] || newStatus const newLabel = statusLabels[newStatus] || newStatus;
const body = ` const body = `
<p>Статус заказа <b>#${orderId.slice(0, 8)}</b> изменён.</p> <p>Статус заказа <b>#${orderId.slice(0, 8)}</b> изменён.</p>
<p><b>${oldLabel}</b> → <b>${newLabel}</b></p> <p><b>${oldLabel}</b> → <b>${newLabel}</b></p>
` `;
return { subject: `Статус заказа изменён — ${newLabel}`, html: baseLayout('Статус заказа изменён', body) } return {
subject: `Статус заказа изменён — ${newLabel}`,
html: baseLayout("Статус заказа изменён", body),
};
} }
export function renderOrderMessageEmail({ orderId, preview }) { 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 = ` const body = `
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p> <p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;"> <div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
${truncated} ${truncated}
</div> </div>
<p>Ответьте в личном кабинете.</p> <p>Ответьте в личном кабинете.</p>
` `;
return { subject: 'Новое сообщение к заказу', html: baseLayout('Новое сообщение', body) } return {
subject: "Новое сообщение к заказу",
html: baseLayout("Новое сообщение", body),
};
} }
export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) { export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) {
const statusLabels = { const statusLabels = {
pending: 'Ожидает', pending: "Ожидает",
confirmed: 'Подтверждён', confirmed: "Подтверждён",
rejected: 'Отклонён', rejected: "Отклонён",
} };
const label = statusLabels[paymentStatus] || paymentStatus const label = statusLabels[paymentStatus] || paymentStatus;
const body = ` const body = `
<p>Статус оплаты заказа <b>#${orderId.slice(0, 8)}</b>: <b>${label}</b>.</p> <p>Статус оплаты заказа <b>#${orderId.slice(0, 8)}</b>: <b>${label}</b>.</p>
` `;
return { subject: `Оплата заказа — ${label}`, html: baseLayout('Оплата заказа', body) } return {
subject: `Оплата заказа — ${label}`,
html: baseLayout("Оплата заказа", body),
};
} }
export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount }) { export function renderAdminOrderCreatedEmail({
const total = (totalCents / 100).toLocaleString('ru-RU') orderId,
userEmail,
totalCents,
itemsCount,
}) {
const total = (totalCents / 100).toLocaleString("ru-RU");
const body = ` const body = `
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p> <p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p> <p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
` `;
return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) } return { subject: "Новый заказ", html: baseLayout("Новый заказ", body) };
} }
export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) { export function renderAdminNewReviewEmail({
const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating) rating,
text,
productTitle,
userName,
}) {
const stars = "★".repeat(rating) + "☆".repeat(5 - rating);
const body = ` const body = `
<p>Новый отзыв ${stars} на товар <b>${productTitle}</b> от <b>${userName}</b>.</p> <p>Новый отзыв ${stars} на товар <b>${productTitle}</b> от <b>${userName}</b>.</p>
${text ? `<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">${text}</div>` : ''} ${text ? `<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">${text}</div>` : ""}
<p>Проверьте отзыв в админ-панели.</p> <p>Проверьте отзыв в админ-панели.</p>
` `;
return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) } return { subject: "Новый отзыв", html: baseLayout("Новый отзыв", body) };
} }
export function renderAuthCodeEmail({ code }) { export function renderAuthCodeEmail({ code }) {
const body = ` const body = `
<p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p> <p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p>
<p>Если это были не вы — просто проигнорируйте письмо.</p> <p>Если это были не вы — просто проигнорируйте письмо.</p>
` `;
return { subject: 'Код входа', html: baseLayout('Код входа', body) } return { subject: "Код входа", html: baseLayout("Код входа", body) };
} }
+128 -79
View File
@@ -1,59 +1,80 @@
import { prisma } from '../../lib/prisma.js' import { prisma } from "../../lib/prisma.js";
import { canTransitionAdminOrderStatus } from '../../lib/order-status.js' import { canTransitionAdminOrderStatus } from "../../lib/order-status.js";
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
export async function registerAdminOrderRoutes(fastify) { export async function registerAdminOrderRoutes(fastify) {
fastify.get( fastify.get(
'/api/admin/orders/summary', "/api/admin/orders/summary",
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async () => { async () => {
const attentionCount = await prisma.order.count({ const attentionCount = await prisma.order.count({
where: { where: {
status: 'PENDING_PAYMENT', status: "PENDING_PAYMENT",
}, },
}) });
return { attentionCount } return { attentionCount };
}, },
) );
fastify.get( fastify.get(
'/api/admin/orders', "/api/admin/orders",
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request, reply) => { async (request, reply) => {
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : '' const status =
const q = typeof request.query?.q === 'string' ? request.query.q.trim() : '' typeof request.query?.status === "string"
const deliveryTypeRaw = request.query?.deliveryType ? request.query.status.trim()
const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.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 pageRaw = request.query?.page;
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) const pageParsed =
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw);
const page =
Number.isFinite(pageParsed) && pageParsed > 0
? Math.floor(pageParsed)
: 1;
const pageSizeRaw = request.query?.pageSize const pageSizeRaw = request.query?.pageSize;
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) const pageSizeParsed =
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 typeof pageSizeRaw === "string"
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) ? 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 = {} const where = {};
if (status) where.status = status if (status) where.status = status;
if (deliveryType) { if (deliveryType) {
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') { if (deliveryType !== "delivery" && deliveryType !== "pickup") {
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' }) return reply
.code(400)
.send({ error: "deliveryType должен быть delivery | pickup" });
} }
where.deliveryType = deliveryType where.deliveryType = deliveryType;
} }
if (q) { 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({ const items = await prisma.order.findMany({
where, where,
include: { user: { select: { id: true, email: true } }, items: true }, include: { user: { select: { id: true, email: true } }, items: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}) });
return { return {
items: items.map((o) => ({ items: items.map((o) => ({
@@ -72,74 +93,97 @@ export async function registerAdminOrderRoutes(fastify) {
total, total,
page, page,
pageSize, pageSize,
} };
}, },
) );
fastify.get( fastify.get(
'/api/admin/orders/:id', "/api/admin/orders/:id",
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request, reply) => { async (request, reply) => {
const { id } = request.params const { id } = request.params;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id }, where: { id },
include: { include: {
user: { select: { id: true, email: true, name: true, phone: true } }, user: { select: { id: true, email: true, name: true, phone: true } },
items: true, items: true,
messages: { orderBy: { createdAt: 'asc' } }, messages: { orderBy: { createdAt: "asc" } },
}, },
}) });
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) if (!order) return reply.code(404).send({ error: "Заказ не найден" });
return { item: order } return { item: order };
}, },
) );
fastify.patch( fastify.patch(
'/api/admin/orders/:id/status', "/api/admin/orders/:id/status",
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request, reply) => { async (request, reply) => {
const { id } = request.params const { id } = request.params;
const next = String(request.body?.status || '').trim() const next = String(request.body?.status || "").trim();
if (!next) return reply.code(400).send({ error: 'status обязателен' }) if (!next) return reply.code(400).send({ error: "status обязателен" });
const existing = await prisma.order.findUnique({ where: { id } }) const existing = await prisma.order.findUnique({ where: { id } });
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' }) if (!existing) return reply.code(404).send({ error: "Заказ не найден" });
if (!canTransitionAdminOrderStatus(existing, next)) { 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, { request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, {
orderId: updated.id, orderId: updated.id,
userId: existing.userId, userId: existing.userId,
oldStatus: existing.status, oldStatus: existing.status,
newStatus: next, newStatus: next,
}) });
return { item: updated } return { item: updated };
}, },
) );
fastify.patch( fastify.patch(
'/api/admin/orders/:id/delivery-fee', "/api/admin/orders/:id/delivery-fee",
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request, reply) => { async (request, reply) => {
const { id } = request.params const { id } = request.params;
const feeRaw = request.body?.deliveryFeeCents const feeRaw = request.body?.deliveryFeeCents;
const parsed = 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) { 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 } }) const existing = await prisma.order.findUnique({ where: { id } });
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' }) if (!existing) return reply.code(404).send({ error: "Заказ не найден" });
if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) { if (
return reply.code(409).send({ error: 'Корректировка доставки доступна только пока стоимость не утверждена' }) 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({ const updated = await prisma.order.update({
where: { id }, where: { id },
data: { data: {
@@ -147,34 +191,39 @@ export async function registerAdminOrderRoutes(fastify) {
totalCents, totalCents,
deliveryFeeLocked: true, deliveryFeeLocked: true,
}, },
}) });
return { item: updated } return { item: updated };
}, },
) );
fastify.post( fastify.post(
'/api/admin/orders/:id/messages', "/api/admin/orders/:id/messages",
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request, reply) => { async (request, reply) => {
const { id } = request.params const { id } = request.params;
const text = String(request.body?.text || '').trim() const text = String(request.body?.text || "").trim();
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) if (!text) return reply.code(400).send({ error: "Сообщение пустое" });
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) if (text.length > 2000)
return reply.code(400).send({ error: "Сообщение слишком длинное" });
const order = await prisma.order.findUnique({ where: { id } }) const order = await prisma.order.findUnique({ where: { id } });
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) 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, { request.server.eventBus.emit(
orderId: id, NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY,
userId: order.userId, {
messageId: msg.id, orderId: id,
preview: text, userId: order.userId,
}) messageId: msg.id,
preview: text,
},
);
return reply.code(201).send({ item: msg }) return reply.code(201).send({ item: msg });
}, },
) );
} }
+53 -35
View File
@@ -1,72 +1,90 @@
import { prisma } from '../../lib/prisma.js' import { prisma } from "../../lib/prisma.js";
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
export async function registerAdminReviewRoutes(fastify) { export async function registerAdminReviewRoutes(fastify) {
fastify.get( fastify.get(
'/api/admin/reviews', "/api/admin/reviews",
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request, reply) => { 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 pageRaw = request.query?.page;
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) const pageParsed =
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw);
const page =
Number.isFinite(pageParsed) && pageParsed > 0
? Math.floor(pageParsed)
: 1;
const pageSizeRaw = request.query?.pageSize const pageSizeRaw = request.query?.pageSize;
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) const pageSizeParsed =
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 typeof pageSizeRaw === "string"
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) ? 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 where = status ? { status } : {};
const total = await prisma.review.count({ where }) const total = await prisma.review.count({ where });
const items = await prisma.review.findMany({ const items = await prisma.review.findMany({
where, where,
include: { include: {
user: { select: { id: true, email: true, name: true } }, user: { select: { id: true, email: true, name: true } },
product: { select: { id: true, title: true } }, product: { select: { id: true, title: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}) });
return { items, total, page, pageSize } return { items, total, page, pageSize };
}, },
) );
fastify.patch( fastify.patch(
'/api/admin/reviews/:id', "/api/admin/reviews/:id",
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request, reply) => { async (request, reply) => {
const { id } = request.params const { id } = request.params;
const action = String(request.body?.action || '').trim() const action = String(request.body?.action || "").trim();
if (action !== 'approve' && action !== 'reject') { if (action !== "approve" && action !== "reject") {
return reply.code(400).send({ error: 'action должен быть approve или reject' }) return reply
.code(400)
.send({ error: "action должен быть approve или reject" });
} }
const existing = await prisma.review.findUnique({ const existing = await prisma.review.findUnique({
where: { id }, where: { id },
include: { product: { select: { title: true } }, user: { select: { name: true, email: true } } }, include: {
}) product: { select: { title: true } },
if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' }) user: { select: { name: true, email: true } },
},
});
if (!existing) return reply.code(404).send({ error: "Отзыв не найден" });
const updated = await prisma.review.update({ const updated = await prisma.review.update({
where: { id }, where: { id },
data: { data: {
status: action === 'approve' ? 'approved' : 'rejected', status: action === "approve" ? "approved" : "rejected",
moderatedAt: new Date(), moderatedAt: new Date(),
}, },
}) });
request.server.eventBus.emit('review:created', { request.server.eventBus.emit("review:created", {
rating: updated.rating, rating: updated.rating,
text: updated.text || '', text: updated.text || "",
productTitle: existing.product?.title || '', productTitle: existing.product?.title || "",
userName: existing.user?.name || existing.user?.email || '', userName: existing.user?.name || existing.user?.email || "",
reviewId: updated.id, reviewId: updated.id,
}) });
return { item: updated } return { item: updated };
}, },
) );
} }
+55 -49
View File
@@ -1,11 +1,11 @@
import { prisma } from '../../lib/prisma.js' import { prisma } from "../../../lib/prisma.js";
export async function registerAdminNotificationRoutes(fastify) { export async function registerAdminNotificationRoutes(fastify) {
fastify.get( fastify.get(
'/api/admin/notifications/settings', "/api/admin/notifications/settings",
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async () => { async () => {
let settings = await prisma.adminNotificationSettings.findFirst() let settings = await prisma.adminNotificationSettings.findFirst();
if (!settings) { if (!settings) {
settings = await prisma.adminNotificationSettings.create({ settings = await prisma.adminNotificationSettings.create({
data: { data: {
@@ -16,74 +16,80 @@ export async function registerAdminNotificationRoutes(fastify) {
newReview: true, newReview: true,
authCodeDuplicate: false, authCodeDuplicate: false,
}, },
}) });
} }
return { settings } return { settings };
}, },
) );
fastify.put( fastify.put(
'/api/admin/notifications/settings', "/api/admin/notifications/settings",
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request) => { async (request) => {
const body = request.body || {} const body = request.body || {};
let settings = await prisma.adminNotificationSettings.findFirst() let settings = await prisma.adminNotificationSettings.findFirst();
const data = {} const data = {};
if ('emailEnabled' in body) data.emailEnabled = Boolean(body.emailEnabled) if ("emailEnabled" in body)
if ('telegramEnabled' in body) data.telegramEnabled = Boolean(body.telegramEnabled) data.emailEnabled = Boolean(body.emailEnabled);
if ('telegramChatId' in body) data.telegramChatId = body.telegramChatId || null if ("telegramEnabled" in body)
if ('newOrder' in body) data.newOrder = Boolean(body.newOrder) data.telegramEnabled = Boolean(body.telegramEnabled);
if ('newOrderMessage' in body) data.newOrderMessage = Boolean(body.newOrderMessage) if ("telegramChatId" in body)
if ('newReview' in body) data.newReview = Boolean(body.newReview) data.telegramChatId = body.telegramChatId || null;
if ('authCodeDuplicate' in body) data.authCodeDuplicate = Boolean(body.authCodeDuplicate) 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) { if (!settings) {
settings = await prisma.adminNotificationSettings.create({ data }) settings = await prisma.adminNotificationSettings.create({ data });
} else { } else {
settings = await prisma.adminNotificationSettings.update({ settings = await prisma.adminNotificationSettings.update({
where: { id: settings.id }, where: { id: settings.id },
data, data,
}) });
} }
return { settings } return { settings };
}, },
) );
fastify.post( fastify.post("/api/admin/notifications/telegram/webhook", async (request) => {
'/api/admin/notifications/telegram/webhook', const update = request.body || {};
async (request) => { const message = update.message;
const update = request.body || {} if (!message || !message.text || message.text !== "/start")
const message = update.message return { ok: true };
if (!message || !message.text || message.text !== '/start') return { ok: true }
const chatId = String(message.chat.id) const chatId = String(message.chat.id);
const settings = await prisma.adminNotificationSettings.findFirst() const settings = await prisma.adminNotificationSettings.findFirst();
if (settings) { if (settings) {
await prisma.adminNotificationSettings.update({ await prisma.adminNotificationSettings.update({
where: { id: settings.id }, where: { id: settings.id },
data: { telegramChatId: chatId }, data: { telegramChatId: chatId },
}) });
} else { } else {
await prisma.adminNotificationSettings.create({ await prisma.adminNotificationSettings.create({
data: { telegramChatId: chatId }, data: { telegramChatId: chatId },
}) });
} }
if (process.env.TELEGRAM_BOT_TOKEN) { if (process.env.TELEGRAM_BOT_TOKEN) {
await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, { await fetch(
method: 'POST', `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
headers: { 'Content-Type': 'application/json' }, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
chat_id: chatId, chat_id: chatId,
text: 'Вы подписаны на уведомления Craftshop.', text: "Вы подписаны на уведомления Любимый Креатив.",
}), }),
}) },
} );
}
return { ok: true } return { ok: true };
}, });
)
} }
+107 -69
View File
@@ -1,139 +1,177 @@
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js' import {
import { prisma } from '../lib/prisma.js' issueEmailCode,
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' normalizeEmail,
verifyEmailCode,
} from "../lib/auth.js";
import { prisma } from "../lib/prisma.js";
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
function mapUserForClient(user) { function mapUserForClient(user) {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL);
const userEmail = normalizeEmail(user.email) const userEmail = normalizeEmail(user.email);
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: user.name,
phone: user.phone, phone: user.phone,
isAdmin: Boolean(adminEmail) && userEmail === adminEmail, isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
} };
} }
export async function registerAuthRoutes(fastify) { export async function registerAuthRoutes(fastify) {
fastify.post('/api/auth/request-code', async (request, reply) => { fastify.post("/api/auth/request-code", async (request, reply) => {
const email = normalizeEmail(request.body?.email) const email = normalizeEmail(request.body?.email);
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) 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 adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase();
const isAdmin = email === adminEmail const isAdmin = email === adminEmail;
request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, {
email, email,
code, code,
isAdmin, isAdmin,
}) });
return { ok: true } return { ok: true };
}) });
fastify.post('/api/auth/verify-code', async (request, reply) => { fastify.post("/api/auth/verify-code", async (request, reply) => {
const email = normalizeEmail(request.body?.email) const email = normalizeEmail(request.body?.email);
const code = String(request.body?.code || '').trim() const code = String(request.body?.code || "").trim();
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!email || !email.includes("@"))
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) 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 }) const ok = await verifyEmailCode({ email, purpose: "login", code });
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) if (!ok)
return reply.code(401).send({ error: "Неверный или истёкший код" });
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email }, where: { email },
update: {}, update: {},
create: { email }, create: { email },
}) });
// Ensure notification preference exists // Ensure notification preference exists
await prisma.notificationPreference.upsert({ await prisma.notificationPreference.upsert({
where: { userId: user.id }, where: { userId: user.id },
create: { userId: user.id, globalEnabled: true }, create: { userId: user.id, globalEnabled: true },
update: {}, update: {},
}) });
const token = fastify.jwt.sign({ sub: user.id, email: user.email }) const token = fastify.jwt.sign({ sub: user.id, email: user.email });
return { token, user: mapUserForClient(user) } return { token, user: mapUserForClient(user) };
}) });
fastify.get( fastify.get(
'/api/me', "/api/me",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request) => { async (request) => {
const userId = request.user.sub const userId = request.user.sub;
const user = await prisma.user.findUnique({ where: { id: userId } }) const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return { user: null } if (!user) return { user: null };
return { user: mapUserForClient(user) } return { user: mapUserForClient(user) };
}, },
) );
fastify.post( fastify.post(
'/api/me/change-email/request-code', "/api/me/change-email/request-code",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const newEmail = normalizeEmail(request.body?.newEmail) const newEmail = normalizeEmail(request.body?.newEmail);
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!newEmail || !newEmail.includes("@"))
return reply.code(400).send({ error: "Некорректная почта" });
const exists = await prisma.user.findUnique({ where: { email: newEmail } }) const exists = await prisma.user.findUnique({
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) where: { email: newEmail },
});
if (exists)
return reply.code(409).send({ error: "Эта почта уже занята" });
await issueEmailCode({ email: newEmail, purpose: 'change_email', userId }) await issueEmailCode({
return { ok: true } email: newEmail,
purpose: "change_email",
userId,
});
return { ok: true };
}, },
) );
fastify.post( fastify.post(
'/api/me/change-email/verify', "/api/me/change-email/verify",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const newEmail = normalizeEmail(request.body?.newEmail) const newEmail = normalizeEmail(request.body?.newEmail);
const code = String(request.body?.code || '').trim() const code = String(request.body?.code || "").trim();
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!newEmail || !newEmail.includes("@"))
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) 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 } }) const exists = await prisma.user.findUnique({
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) where: { email: newEmail },
});
if (exists)
return reply.code(409).send({ error: "Эта почта уже занята" });
const ok = await verifyEmailCode({ email: newEmail, purpose: 'change_email', code, userId }) const ok = await verifyEmailCode({
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) email: newEmail,
purpose: "change_email",
code,
userId,
});
if (!ok)
return reply.code(401).send({ error: "Неверный или истёкший код" });
const user = await prisma.user.update({ const user = await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { email: newEmail }, data: { email: newEmail },
}) });
return { user: mapUserForClient(user) } return { user: mapUserForClient(user) };
}, },
) );
fastify.patch( fastify.patch(
'/api/me/profile', "/api/me/profile",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const nameRaw = request.body?.name const nameRaw = request.body?.name;
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() const name =
const phoneRaw = request.body?.phone nameRaw === null || nameRaw === undefined
const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim() ? 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) { if (phone !== null) {
const compact = phone.replace(/[\s()-]/g, '') const compact = phone.replace(/[\s()-]/g, "");
if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' }) if (compact.length > 20)
return reply.code(400).send({ error: "Телефон слишком длинный" });
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) { 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({ const updated = await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { name: name && name.length ? name : null, phone: phone && phone.length ? phone : null }, data: {
}) name: name && name.length ? name : null,
return { user: mapUserForClient(updated) } phone: phone && phone.length ? phone : null,
},
});
return { user: mapUserForClient(updated) };
}, },
) );
} }
+94 -62
View File
@@ -1,123 +1,155 @@
import { prisma } from '../lib/prisma.js' import { prisma } from "../lib/prisma.js";
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
export async function registerUserMessageRoutes(fastify) { export async function registerUserMessageRoutes(fastify) {
fastify.get( fastify.get(
'/api/me/orders/:id/messages', "/api/me/orders/:id/messages",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const { id } = request.params const { id } = request.params;
const order = await prisma.order.findFirst({ where: { id, userId } }) const order = await prisma.order.findFirst({ where: { id, userId } });
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) if (!order) return reply.code(404).send({ error: "Заказ не найден" });
const items = await prisma.orderMessage.findMany({ where: { orderId: id }, orderBy: { createdAt: 'asc' } }) const items = await prisma.orderMessage.findMany({
return { items } where: { orderId: id },
orderBy: { createdAt: "asc" },
});
return { items };
}, },
) );
fastify.post( fastify.post(
'/api/me/orders/:id/messages', "/api/me/orders/:id/messages",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const { id } = request.params const { id } = request.params;
const order = await prisma.order.findFirst({ where: { id, userId } }) const order = await prisma.order.findFirst({ where: { id, userId } });
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) if (!order) return reply.code(404).send({ error: "Заказ не найден" });
const text = String(request.body?.text || '').trim() const text = String(request.body?.text || "").trim();
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) if (!text) return reply.code(400).send({ error: "Сообщение пустое" });
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) if (text.length > 2000)
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'user', text } }) 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, { request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
orderId: id, orderId: id,
authorType: 'user', authorType: "user",
messageId: msg.id, messageId: msg.id,
preview: text, preview: text,
}) });
return reply.code(201).send({ item: msg }) return reply.code(201).send({ item: msg });
}, },
) );
fastify.get( fastify.get(
'/api/me/messages/unread-count', "/api/me/messages/unread-count",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request) => { async (request) => {
const userId = request.user.sub const userId = request.user.sub;
const orders = await prisma.order.findMany({ where: { userId }, select: { id: true } }) const orders = await prisma.order.findMany({
if (orders.length === 0) return { count: 0 } where: { userId },
select: { id: true },
});
if (orders.length === 0) return { count: 0 };
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } }) const readStates = await prisma.userOrderMessageReadState.findMany({
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) where: { userId },
});
const lastReadByOrder = new Map(
readStates.map((r) => [r.orderId, r.lastReadAt]),
);
let count = 0 let count = 0;
for (const o of orders) { 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({ const n = await prisma.orderMessage.count({
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } }, where: {
}) orderId: o.id,
count += n authorType: "admin",
createdAt: { gt: lastRead },
},
});
count += n;
} }
return { count } return { count };
}, },
) );
fastify.get( fastify.get(
'/api/me/conversations', "/api/me/conversations",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request) => { async (request) => {
const userId = request.user.sub const userId = request.user.sub;
const orders = await prisma.order.findMany({ const orders = await prisma.order.findMany({
where: { userId, messages: { some: {} } }, where: { userId, messages: { some: {} } },
select: { select: {
id: true, id: true,
status: true, status: true,
deliveryType: 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 readStates = await prisma.userOrderMessageReadState.findMany({
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) where: { userId },
});
const lastReadByOrder = new Map(
readStates.map((r) => [r.orderId, r.lastReadAt]),
);
const items = [] const items = [];
for (const o of orders) { for (const o of orders) {
const lastMsg = o.messages[0] const lastMsg = o.messages[0];
if (!lastMsg) continue if (!lastMsg) continue;
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0) const lastRead = lastReadByOrder.get(o.id) ?? new Date(0);
const unreadCount = await prisma.orderMessage.count({ 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({ items.push({
orderId: o.id, orderId: o.id,
status: o.status, status: o.status,
deliveryType: o.deliveryType, deliveryType: o.deliveryType,
lastMessageAt: lastMsg.createdAt, 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, unreadCount,
}) });
} }
return { items } return { items };
}, },
) );
fastify.post( fastify.post(
'/api/me/orders/:id/messages/read', "/api/me/orders/:id/messages/read",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const { id } = request.params const { id } = request.params;
const order = await prisma.order.findFirst({ where: { id, userId } }) const order = await prisma.order.findFirst({ where: { id, userId } });
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) if (!order) return reply.code(404).send({ error: "Заказ не найден" });
const now = new Date() const now = new Date();
await prisma.userOrderMessageReadState.upsert({ await prisma.userOrderMessageReadState.upsert({
where: { userId_orderId: { userId, orderId: id } }, where: { userId_orderId: { userId, orderId: id } },
create: { userId, orderId: id, lastReadAt: now }, create: { userId, orderId: id, lastReadAt: now },
update: { lastReadAt: now }, update: { lastReadAt: now },
}) });
return { ok: true } return { ok: true };
}, },
) );
} }
+152 -115
View File
@@ -1,77 +1,98 @@
import { isDeliveryCarrier } from '../lib/delivery-carrier.js' import { isDeliveryCarrier } from "../lib/delivery-carrier.js";
import { prisma } from '../lib/prisma.js' import { prisma } from "../lib/prisma.js";
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
export async function registerUserOrderRoutes(fastify) { export async function registerUserOrderRoutes(fastify) {
// ---- Создание заказа (checkout) ---- // ---- Создание заказа (checkout) ----
fastify.post( fastify.post(
'/api/me/orders', "/api/me/orders",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const deliveryTypeRaw = request.body?.deliveryType const deliveryTypeRaw = request.body?.deliveryType;
const deliveryType = const deliveryType =
deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === '' deliveryTypeRaw === undefined ||
? 'delivery' deliveryTypeRaw === null ||
: String(deliveryTypeRaw).trim() deliveryTypeRaw === ""
? "delivery"
: String(deliveryTypeRaw).trim();
const addressId = String(request.body?.addressId || '').trim() const addressId = String(request.body?.addressId || "").trim();
const commentRaw = request.body?.comment const commentRaw = request.body?.comment;
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() const comment =
commentRaw === null || commentRaw === undefined
? null
: String(commentRaw).trim();
const paymentMethodRaw = request.body?.paymentMethod const paymentMethodRaw = request.body?.paymentMethod;
const paymentMethod = const paymentMethod =
paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === '' paymentMethodRaw === undefined ||
? 'online' paymentMethodRaw === null ||
: String(paymentMethodRaw).trim() paymentMethodRaw === ""
if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') { ? "online"
return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' }) : String(paymentMethodRaw).trim();
if (paymentMethod !== "online" && paymentMethod !== "on_pickup") {
return reply
.code(400)
.send({ error: "paymentMethod должен быть online | on_pickup" });
} }
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') { if (deliveryType !== "delivery" && deliveryType !== "pickup") {
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' }) return reply
.code(400)
.send({ error: "deliveryType должен быть delivery | pickup" });
} }
const carrierRaw = request.body?.deliveryCarrier const carrierRaw = request.body?.deliveryCarrier;
let deliveryCarrier = null let deliveryCarrier = null;
if (deliveryType === 'delivery') { if (deliveryType === "delivery") {
const carrierStr = const carrierStr =
carrierRaw === undefined || carrierRaw === null || carrierRaw === '' carrierRaw === undefined || carrierRaw === null || carrierRaw === ""
? '' ? ""
: String(carrierRaw).trim() : String(carrierRaw).trim();
if (!isDeliveryCarrier(carrierStr)) { if (!isDeliveryCarrier(carrierStr)) {
return reply return reply.code(400).send({
.code(400) error:
.send({ "deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST",
error: });
'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST',
})
} }
deliveryCarrier = carrierStr deliveryCarrier = carrierStr;
} }
if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') { if (paymentMethod === "on_pickup" && deliveryType !== "pickup") {
return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза' }) return reply
.code(400)
.send({
error: "Оплата при получении доступна только для самовывоза",
});
} }
let address = null let address = null;
if (deliveryType === 'delivery') { if (deliveryType === "delivery") {
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' }) if (!addressId)
address = await prisma.shippingAddress.findFirst({ where: { id: addressId, userId } }) return reply.code(400).send({ error: "Выберите адрес доставки" });
if (!address) return reply.code(404).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({ const cartItems = await prisma.cartItem.findMany({
where: { userId }, where: { userId },
include: { product: true }, 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) { 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) { 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, qty: ci.qty,
titleSnapshot: ci.product.title, titleSnapshot: ci.product.title,
priceCentsSnapshot: ci.product.priceCents, priceCentsSnapshot: ci.product.priceCents,
})) }));
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0) const itemsSubtotalCents = itemsPayload.reduce(
const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0 (sum, i) => sum + i.priceCentsSnapshot * i.qty,
const totalCents = itemsSubtotalCents + deliveryFeeCents 0,
);
const deliveryFeeCents = deliveryType === "delivery" ? 50000 : 0;
const totalCents = itemsSubtotalCents + deliveryFeeCents;
const addressSnapshotJson = const addressSnapshotJson =
deliveryType === 'pickup' deliveryType === "pickup"
? JSON.stringify({ deliveryType: 'pickup' }) ? JSON.stringify({ deliveryType: "pickup" })
: JSON.stringify({ : JSON.stringify({
deliveryType: 'delivery', deliveryType: "delivery",
id: address.id, id: address.id,
label: address.label, label: address.label,
recipientName: address.recipientName, recipientName: address.recipientName,
@@ -99,31 +123,30 @@ export async function registerUserOrderRoutes(fastify) {
comment: address.comment, comment: address.comment,
lat: address.lat, lat: address.lat,
lng: address.lng, lng: address.lng,
}) });
let initialStatus = 'PENDING_PAYMENT' let initialStatus = "PENDING_PAYMENT";
let deliveryFeeLocked = true let deliveryFeeLocked = true;
if (paymentMethod === 'on_pickup') { if (paymentMethod === "on_pickup") {
initialStatus = 'IN_PROGRESS' initialStatus = "IN_PROGRESS";
} else if (deliveryType === 'delivery') { } else if (deliveryType === "delivery") {
initialStatus = 'PENDING_PAYMENT' initialStatus = "PENDING_PAYMENT";
deliveryFeeLocked = false deliveryFeeLocked = false;
} }
let created let created;
try { try {
created = await prisma.$transaction(async (tx) => { created = await prisma.$transaction(async (tx) => {
for (const ci of cartItems) { for (const ci of cartItems) {
if (!ci.product.inStock) continue if (!ci.product.inStock) continue;
const res = await tx.product.updateMany({ const res = await tx.product.updateMany({
where: { id: ci.productId, quantity: { gte: ci.qty } }, where: { id: ci.productId, quantity: { gte: ci.qty } },
data: { quantity: { decrement: ci.qty } }, data: { quantity: { decrement: ci.qty } },
}) });
if (res.count !== 1) { if (res.count !== 1) {
throw new Error(`Недостаточно товара: "${ci.product.title}"`) throw new Error(`Недостаточно товара: "${ci.product.title}"`);
} }
} }
const order = await tx.order.create({ const order = await tx.order.create({
@@ -137,7 +160,7 @@ export async function registerUserOrderRoutes(fastify) {
itemsSubtotalCents, itemsSubtotalCents,
deliveryFeeCents, deliveryFeeCents,
totalCents, totalCents,
currency: 'RUB', currency: "RUB",
addressSnapshotJson, addressSnapshotJson,
comment: comment && comment.length ? comment : null, comment: comment && comment.length ? comment : null,
items: { items: {
@@ -149,12 +172,16 @@ export async function registerUserOrderRoutes(fastify) {
})), })),
}, },
}, },
}) });
await tx.cartItem.deleteMany({ where: { userId } }) await tx.cartItem.deleteMany({ where: { userId } });
return order return order;
}) });
} catch (e) { } 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 // Emit notification events
@@ -163,31 +190,31 @@ export async function registerUserOrderRoutes(fastify) {
userId, userId,
totalCents: created.totalCents, totalCents: created.totalCents,
itemsCount: cartItems.length, itemsCount: cartItems.length,
}) });
// Also emit admin notification // Also emit admin notification
request.server.eventBus.emit('order:created:admin', { request.server.eventBus.emit("order:created:admin", {
orderId: created.id, orderId: created.id,
userId, userId,
userEmail: request.user.email || '', userEmail: request.user.email || "",
totalCents: created.totalCents, totalCents: created.totalCents,
itemsCount: cartItems.length, itemsCount: cartItems.length,
}) });
return reply.code(201).send({ orderId: created.id }) return reply.code(201).send({ orderId: created.id });
}, },
) );
fastify.get( fastify.get(
'/api/me/orders', "/api/me/orders",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request) => { async (request) => {
const userId = request.user.sub const userId = request.user.sub;
const orders = await prisma.order.findMany({ const orders = await prisma.order.findMany({
where: { userId }, where: { userId },
include: { items: true }, include: { items: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
}) });
return { return {
items: orders.map((o) => ({ items: orders.map((o) => ({
id: o.id, id: o.id,
@@ -198,76 +225,86 @@ export async function registerUserOrderRoutes(fastify) {
updatedAt: o.updatedAt, updatedAt: o.updatedAt,
itemsCount: o.items.reduce((s, i) => s + i.qty, 0), itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
})), })),
} };
}, },
) );
fastify.get( fastify.get(
'/api/me/orders/:id', "/api/me/orders/:id",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const { id } = request.params const { id } = request.params;
const order = await prisma.order.findFirst({ const order = await prisma.order.findFirst({
where: { id, userId }, where: { id, userId },
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } }, include: { items: true, messages: { orderBy: { createdAt: "asc" } } },
}) });
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) if (!order) return reply.code(404).send({ error: "Заказ не найден" });
return { item: order } return { item: order };
}, },
) );
fastify.get( fastify.get(
'/api/me/orders/:id/review-eligibility', "/api/me/orders/:id/review-eligibility",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const { id } = request.params const { id } = request.params;
const order = await prisma.order.findFirst({ where: { id, userId }, include: { items: true } }) const order = await prisma.order.findFirst({
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) where: { id, userId },
if (order.status !== 'DONE') { include: { items: true },
return { canReview: false, items: [] } });
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) { for (const it of order.items) {
if (!uniq.has(it.productId)) { 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({ const existing = await prisma.review.findMany({
where: { userId, productId: { in: productIds } }, where: { userId, productId: { in: productIds } },
select: { productId: true }, select: { productId: true },
}) });
const reviewed = new Set(existing.map((r) => r.productId)) const reviewed = new Set(existing.map((r) => r.productId));
return { return {
canReview: true, canReview: true,
items: [...uniq.values()].map((x) => ({ items: [...uniq.values()].map((x) => ({
...x, ...x,
hasReview: reviewed.has(x.productId), hasReview: reviewed.has(x.productId),
})), })),
} };
}, },
) );
fastify.post( fastify.post(
'/api/me/orders/:id/confirm-received', "/api/me/orders/:id/confirm-received",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const { id } = request.params const { id } = request.params;
const order = await prisma.order.findFirst({ where: { id, userId } }) const order = await prisma.order.findFirst({ where: { id, userId } });
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) if (!order) return reply.code(404).send({ error: "Заказ не найден" });
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED' const okDelivery =
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP' order.deliveryType === "delivery" && order.status === "SHIPPED";
const okPickup =
order.deliveryType === "pickup" && order.status === "READY_FOR_PICKUP";
if (!okDelivery && !okPickup) { if (!okDelivery && !okPickup) {
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' }) return reply
.code(409)
.send({ error: "Сейчас нельзя подтвердить получение заказа" });
} }
await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) await prisma.order.update({ where: { id }, data: { status: "DONE" } });
return { ok: true, status: 'DONE' } return { ok: true, status: "DONE" };
}, },
) );
} }
+75 -51
View File
@@ -1,118 +1,142 @@
import { prisma } from '../lib/prisma.js' import { prisma } from "../lib/prisma.js";
import { escapeHtml } from '../lib/escape-html.js' import { escapeHtml } from "../lib/escape-html.js";
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js' import { getOtherUploadMaxFileBytes } from "../lib/upload-limits.js";
import { saveImageBufferToUploads } from '../lib/upload-images.js' import { saveImageBufferToUploads } from "../lib/upload-images.js";
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
export async function registerUserPaymentRoutes(fastify) { export async function registerUserPaymentRoutes(fastify) {
fastify.post( fastify.post(
'/api/me/orders/:id/pay', "/api/me/orders/:id/pay",
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub;
const { id } = request.params const { id } = request.params;
const order = await prisma.order.findFirst({ where: { id, userId } }) const order = await prisma.order.findFirst({ where: { id, userId } });
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) if (!order) return reply.code(404).send({ error: "Заказ не найден" });
const paymentMethod = order.paymentMethod ?? 'online' const paymentMethod = order.paymentMethod ?? "online";
if (paymentMethod === 'on_pickup') { if (paymentMethod === "on_pickup") {
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' }) return reply
.code(409)
.send({
error:
"Для этого заказа оплата при получении — кнопка оплаты не нужна.",
});
} }
if (order.status !== 'PENDING_PAYMENT') { if (order.status !== "PENDING_PAYMENT") {
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) return reply
.code(409)
.send({ error: "Сейчас нельзя выполнить оплату для этого заказа" });
} }
if (!request.isMultipart()) { if (!request.isMultipart()) {
return reply return reply
.code(400) .code(400)
.send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' }) .send({
error:
"Отправьте multipart/form-data: поле detail и/или файл receipt",
});
} }
let detail = '' let detail = "";
let receiptBuffer = null let receiptBuffer = null;
let receiptFilename = '' let receiptFilename = "";
try { try {
const otherLimit = getOtherUploadMaxFileBytes() const otherLimit = getOtherUploadMaxFileBytes();
const parts = request.parts({ const parts = request.parts({
limits: { limits: {
fileSize: otherLimit, fileSize: otherLimit,
files: 2, files: 2,
}, },
}) });
for await (const part of parts) { for await (const part of parts) {
if (part.file) { if (part.file) {
if (part.fieldname === 'receipt') { if (part.fieldname === "receipt") {
if (receiptBuffer !== null) { if (receiptBuffer !== null) {
return reply.code(400).send({ error: 'Допускается один файл receipt' }) return reply
.code(400)
.send({ error: "Допускается один файл receipt" });
} }
receiptBuffer = await part.toBuffer() receiptBuffer = await part.toBuffer();
receiptFilename = part.filename ?? 'receipt' receiptFilename = part.filename ?? "receipt";
} }
} else if (part.fieldname === 'detail') { } else if (part.fieldname === "detail") {
detail = String(part.value ?? '').trim() detail = String(part.value ?? "").trim();
} }
} }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму' const msg =
return reply.code(400).send({ error: msg }) err instanceof Error ? err.message : "Не удалось разобрать форму";
return reply.code(400).send({ error: msg });
} }
const hasDetail = detail.length > 0 const hasDetail = detail.length > 0;
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0 const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0;
if (!hasDetail && !hasReceipt) { if (!hasDetail && !hasReceipt) {
return reply return reply
.code(400) .code(400)
.send({ error: 'Укажите текст о платеже и/или прикрепите изображение чека' }) .send({
error: "Укажите текст о платеже и/или прикрепите изображение чека",
});
} }
const maxDetail = 2000 const maxDetail = 2000;
if (detail.length > maxDetail) { 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) { if (hasReceipt) {
try { try {
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer) attachmentUrl = await saveImageBufferToUploads(
receiptFilename,
receiptBuffer,
);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл' const message =
err instanceof Error ? err.message : "Не удалось сохранить файл";
const statusCode = 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) ? Number(err.statusCode)
: 400 : 400;
return reply.code(statusCode).send({ error: message }) return reply.code(statusCode).send({ error: message });
} }
} }
const bodyHtml = hasDetail const bodyHtml = hasDetail
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>` ? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, "<br/>")}</p>`
: '' : "";
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}` const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`;
try { try {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.orderMessage.create({ await tx.orderMessage.create({
data: { data: {
orderId: id, orderId: id,
authorType: 'user', authorType: "user",
text: messageText, text: messageText,
attachmentUrl, attachmentUrl,
}, },
}) });
}) });
} catch (err) { } catch (err) {
return reply.code(500).send({ error: 'Не удалось сохранить оплату' }) return reply.code(500).send({ error: "Не удалось сохранить оплату" });
} }
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
orderId: id, orderId: id,
userId, userId,
paymentStatus: 'pending', paymentStatus: "pending",
}) });
return { ok: true, status: 'PENDING_PAYMENT' } return { ok: true, status: "PENDING_PAYMENT" };
}, },
) );
} }