test commit

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