test commit
This commit is contained in:
+100
-90
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) };
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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" };
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" };
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user