test commit
This commit is contained in:
+100
-90
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,89 +8,113 @@ function baseLayout(title, body) {
|
||||
</div>
|
||||
${body}
|
||||
<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>
|
||||
</body>
|
||||
</html>`
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount }) {
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
const total = (totalCents / 100).toLocaleString("ru-RU");
|
||||
const body = `
|
||||
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
|
||||
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></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 = {
|
||||
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 = `
|
||||
<p>Статус заказа <b>#${orderId.slice(0, 8)}</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 }) {
|
||||
const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview
|
||||
const truncated =
|
||||
preview.length > 200 ? preview.slice(0, 197) + "..." : preview;
|
||||
const body = `
|
||||
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
|
||||
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
|
||||
${truncated}
|
||||
</div>
|
||||
<p>Ответьте в личном кабинете.</p>
|
||||
`
|
||||
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 = `
|
||||
<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 }) {
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
export function renderAdminOrderCreatedEmail({
|
||||
orderId,
|
||||
userEmail,
|
||||
totalCents,
|
||||
itemsCount,
|
||||
}) {
|
||||
const total = (totalCents / 100).toLocaleString("ru-RU");
|
||||
const body = `
|
||||
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</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 }) {
|
||||
const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
|
||||
export function renderAdminNewReviewEmail({
|
||||
rating,
|
||||
text,
|
||||
productTitle,
|
||||
userName,
|
||||
}) {
|
||||
const stars = "★".repeat(rating) + "☆".repeat(5 - rating);
|
||||
const body = `
|
||||
<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>
|
||||
`
|
||||
return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) }
|
||||
`;
|
||||
return { subject: "Новый отзыв", html: baseLayout("Новый отзыв", body) };
|
||||
}
|
||||
|
||||
export function renderAuthCodeEmail({ code }) {
|
||||
const body = `
|
||||
<p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p>
|
||||
<p>Если это были не вы — просто проигнорируйте письмо.</p>
|
||||
`
|
||||
return { subject: 'Код входа', html: baseLayout('Код входа', body) }
|
||||
`;
|
||||
return { subject: "Код входа", html: baseLayout("Код входа", body) };
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (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' },
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
+107
-69
@@ -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) };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
return { count }
|
||||
where: {
|
||||
orderId: o.id,
|
||||
authorType: "admin",
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
)
|
||||
});
|
||||
count += n;
|
||||
}
|
||||
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 };
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+152
-115
@@ -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",
|
||||
});
|
||||
}
|
||||
deliveryCarrier = carrierStr;
|
||||
}
|
||||
|
||||
if (paymentMethod === "on_pickup" && deliveryType !== "pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error:
|
||||
'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST',
|
||||
})
|
||||
}
|
||||
deliveryCarrier = carrierStr
|
||||
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" };
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>`
|
||||
: ''
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
|
||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, "<br/>")}</p>`
|
||||
: "";
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`;
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.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" };
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user