test commit
This commit is contained in:
@@ -1,59 +1,80 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { canTransitionAdminOrderStatus } from '../../lib/order-status.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
import { canTransitionAdminOrderStatus } from "../../lib/order-status.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
|
||||
|
||||
export async function registerAdminOrderRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/admin/orders/summary',
|
||||
"/api/admin/orders/summary",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
const attentionCount = await prisma.order.count({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
status: "PENDING_PAYMENT",
|
||||
},
|
||||
})
|
||||
return { attentionCount }
|
||||
});
|
||||
return { attentionCount };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/api/admin/orders',
|
||||
"/api/admin/orders",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : ''
|
||||
const q = typeof request.query?.q === 'string' ? request.query.q.trim() : ''
|
||||
const deliveryTypeRaw = request.query?.deliveryType
|
||||
const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.trim() : ''
|
||||
const status =
|
||||
typeof request.query?.status === "string"
|
||||
? request.query.status.trim()
|
||||
: "";
|
||||
const q =
|
||||
typeof request.query?.q === "string" ? request.query.q.trim() : "";
|
||||
const deliveryTypeRaw = request.query?.deliveryType;
|
||||
const deliveryType =
|
||||
typeof deliveryTypeRaw === "string" ? deliveryTypeRaw.trim() : "";
|
||||
|
||||
const pageRaw = request.query?.page
|
||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||
const pageRaw = request.query?.page;
|
||||
const pageParsed =
|
||||
typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw);
|
||||
const page =
|
||||
Number.isFinite(pageParsed) && pageParsed > 0
|
||||
? Math.floor(pageParsed)
|
||||
: 1;
|
||||
|
||||
const pageSizeRaw = request.query?.pageSize
|
||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||
const pageSizeRaw = request.query?.pageSize;
|
||||
const pageSizeParsed =
|
||||
typeof pageSizeRaw === "string"
|
||||
? Number(pageSizeRaw)
|
||||
: Number(pageSizeRaw);
|
||||
const pageSize =
|
||||
Number.isFinite(pageSizeParsed) && pageSizeParsed > 0
|
||||
? Math.floor(pageSizeParsed)
|
||||
: 20;
|
||||
if (pageSize > 100)
|
||||
return reply.code(400).send({ error: "pageSize должен быть ≤ 100" });
|
||||
|
||||
const where = {}
|
||||
if (status) where.status = status
|
||||
const where = {};
|
||||
if (status) where.status = status;
|
||||
if (deliveryType) {
|
||||
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
||||
if (deliveryType !== "delivery" && deliveryType !== "pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "deliveryType должен быть delivery | pickup" });
|
||||
}
|
||||
where.deliveryType = deliveryType
|
||||
where.deliveryType = deliveryType;
|
||||
}
|
||||
if (q) {
|
||||
where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }]
|
||||
where.OR = [
|
||||
{ id: { contains: q } },
|
||||
{ user: { email: { contains: q } } },
|
||||
];
|
||||
}
|
||||
|
||||
const total = await prisma.order.count({ where })
|
||||
const total = await prisma.order.count({ where });
|
||||
const items = await prisma.order.findMany({
|
||||
where,
|
||||
include: { user: { select: { id: true, email: true } }, items: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((o) => ({
|
||||
@@ -72,74 +93,97 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
};
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/api/admin/orders/:id',
|
||||
"/api/admin/orders/:id",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, name: true, phone: true } },
|
||||
items: true,
|
||||
messages: { orderBy: { createdAt: 'asc' } },
|
||||
messages: { orderBy: { createdAt: "asc" } },
|
||||
},
|
||||
})
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
return { item: order }
|
||||
});
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
return { item: order };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
'/api/admin/orders/:id/status',
|
||||
"/api/admin/orders/:id/status",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const next = String(request.body?.status || '').trim()
|
||||
if (!next) return reply.code(400).send({ error: 'status обязателен' })
|
||||
const { id } = request.params;
|
||||
const next = String(request.body?.status || "").trim();
|
||||
if (!next) return reply.code(400).send({ error: "status обязателен" });
|
||||
|
||||
const existing = await prisma.order.findUnique({ where: { id } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
const existing = await prisma.order.findUnique({ where: { id } });
|
||||
if (!existing) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
if (!canTransitionAdminOrderStatus(existing, next)) {
|
||||
return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status} → ${next}` })
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error: `Нельзя сменить статус ${existing.status} → ${next}`,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.order.update({ where: { id }, data: { status: next } })
|
||||
const updated = await prisma.order.update({
|
||||
where: { id },
|
||||
data: { status: next },
|
||||
});
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, {
|
||||
orderId: updated.id,
|
||||
userId: existing.userId,
|
||||
oldStatus: existing.status,
|
||||
newStatus: next,
|
||||
})
|
||||
});
|
||||
|
||||
return { item: updated }
|
||||
return { item: updated };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
'/api/admin/orders/:id/delivery-fee',
|
||||
"/api/admin/orders/:id/delivery-fee",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const feeRaw = request.body?.deliveryFeeCents
|
||||
const { id } = request.params;
|
||||
const feeRaw = request.body?.deliveryFeeCents;
|
||||
const parsed =
|
||||
typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN
|
||||
typeof feeRaw === "string"
|
||||
? Number.parseInt(feeRaw, 10)
|
||||
: typeof feeRaw === "number"
|
||||
? feeRaw
|
||||
: NaN;
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
return reply.code(400).send({ error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)' })
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error: "deliveryFeeCents должно быть целым числом ≥ 0 (копейки)",
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await prisma.order.findUnique({ where: { id } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) {
|
||||
return reply.code(409).send({ error: 'Корректировка доставки доступна только пока стоимость не утверждена' })
|
||||
const existing = await prisma.order.findUnique({ where: { id } });
|
||||
if (!existing) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
if (
|
||||
existing.status !== "PENDING_PAYMENT" ||
|
||||
existing.deliveryFeeLocked !== false
|
||||
) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error:
|
||||
"Корректировка доставки доступна только пока стоимость не утверждена",
|
||||
});
|
||||
}
|
||||
|
||||
const totalCents = existing.itemsSubtotalCents + parsed
|
||||
const totalCents = existing.itemsSubtotalCents + parsed;
|
||||
const updated = await prisma.order.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -147,34 +191,39 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
totalCents,
|
||||
deliveryFeeLocked: true,
|
||||
},
|
||||
})
|
||||
return { item: updated }
|
||||
});
|
||||
return { item: updated };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/orders/:id/messages',
|
||||
"/api/admin/orders/:id/messages",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const text = String(request.body?.text || '').trim()
|
||||
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
|
||||
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
|
||||
const { id } = request.params;
|
||||
const text = String(request.body?.text || "").trim();
|
||||
if (!text) return reply.code(400).send({ error: "Сообщение пустое" });
|
||||
if (text.length > 2000)
|
||||
return reply.code(400).send({ error: "Сообщение слишком длинное" });
|
||||
|
||||
const order = await prisma.order.findUnique({ where: { id } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
const order = await prisma.order.findUnique({ where: { id } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
|
||||
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'admin', text } })
|
||||
const msg = await prisma.orderMessage.create({
|
||||
data: { orderId: id, authorType: "admin", text },
|
||||
});
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, {
|
||||
orderId: id,
|
||||
userId: order.userId,
|
||||
messageId: msg.id,
|
||||
preview: text,
|
||||
})
|
||||
request.server.eventBus.emit(
|
||||
NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY,
|
||||
{
|
||||
orderId: id,
|
||||
userId: order.userId,
|
||||
messageId: msg.id,
|
||||
preview: text,
|
||||
},
|
||||
);
|
||||
|
||||
return reply.code(201).send({ item: msg })
|
||||
return reply.code(201).send({ item: msg });
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,72 +1,90 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
|
||||
|
||||
export async function registerAdminReviewRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/admin/reviews',
|
||||
"/api/admin/reviews",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending'
|
||||
const status =
|
||||
typeof request.query?.status === "string"
|
||||
? request.query.status.trim()
|
||||
: "pending";
|
||||
|
||||
const pageRaw = request.query?.page
|
||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||
const pageRaw = request.query?.page;
|
||||
const pageParsed =
|
||||
typeof pageRaw === "string" ? Number(pageRaw) : Number(pageRaw);
|
||||
const page =
|
||||
Number.isFinite(pageParsed) && pageParsed > 0
|
||||
? Math.floor(pageParsed)
|
||||
: 1;
|
||||
|
||||
const pageSizeRaw = request.query?.pageSize
|
||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||
const pageSizeRaw = request.query?.pageSize;
|
||||
const pageSizeParsed =
|
||||
typeof pageSizeRaw === "string"
|
||||
? Number(pageSizeRaw)
|
||||
: Number(pageSizeRaw);
|
||||
const pageSize =
|
||||
Number.isFinite(pageSizeParsed) && pageSizeParsed > 0
|
||||
? Math.floor(pageSizeParsed)
|
||||
: 20;
|
||||
if (pageSize > 100)
|
||||
return reply.code(400).send({ error: "pageSize должен быть ≤ 100" });
|
||||
|
||||
const where = status ? { status } : {}
|
||||
const total = await prisma.review.count({ where })
|
||||
const where = status ? { status } : {};
|
||||
const total = await prisma.review.count({ where });
|
||||
const items = await prisma.review.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, email: true, name: true } },
|
||||
product: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
});
|
||||
|
||||
return { items, total, page, pageSize }
|
||||
return { items, total, page, pageSize };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
'/api/admin/reviews/:id',
|
||||
"/api/admin/reviews/:id",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const action = String(request.body?.action || '').trim()
|
||||
if (action !== 'approve' && action !== 'reject') {
|
||||
return reply.code(400).send({ error: 'action должен быть approve или reject' })
|
||||
const { id } = request.params;
|
||||
const action = String(request.body?.action || "").trim();
|
||||
if (action !== "approve" && action !== "reject") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "action должен быть approve или reject" });
|
||||
}
|
||||
|
||||
const existing = await prisma.review.findUnique({
|
||||
where: { id },
|
||||
include: { product: { select: { title: true } }, user: { select: { name: true, email: true } } },
|
||||
})
|
||||
if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' })
|
||||
include: {
|
||||
product: { select: { title: true } },
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) return reply.code(404).send({ error: "Отзыв не найден" });
|
||||
|
||||
const updated = await prisma.review.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: action === 'approve' ? 'approved' : 'rejected',
|
||||
status: action === "approve" ? "approved" : "rejected",
|
||||
moderatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
request.server.eventBus.emit('review:created', {
|
||||
});
|
||||
request.server.eventBus.emit("review:created", {
|
||||
rating: updated.rating,
|
||||
text: updated.text || '',
|
||||
productTitle: existing.product?.title || '',
|
||||
userName: existing.user?.name || existing.user?.email || '',
|
||||
text: updated.text || "",
|
||||
productTitle: existing.product?.title || "",
|
||||
userName: existing.user?.name || existing.user?.email || "",
|
||||
reviewId: updated.id,
|
||||
})
|
||||
});
|
||||
|
||||
return { item: updated }
|
||||
return { item: updated };
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { prisma } from "../../../lib/prisma.js";
|
||||
|
||||
export async function registerAdminNotificationRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/admin/notifications/settings',
|
||||
"/api/admin/notifications/settings",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
let settings = await prisma.adminNotificationSettings.findFirst()
|
||||
let settings = await prisma.adminNotificationSettings.findFirst();
|
||||
if (!settings) {
|
||||
settings = await prisma.adminNotificationSettings.create({
|
||||
data: {
|
||||
@@ -16,74 +16,80 @@ export async function registerAdminNotificationRoutes(fastify) {
|
||||
newReview: true,
|
||||
authCodeDuplicate: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
return { settings }
|
||||
return { settings };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
'/api/admin/notifications/settings',
|
||||
"/api/admin/notifications/settings",
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request) => {
|
||||
const body = request.body || {}
|
||||
let settings = await prisma.adminNotificationSettings.findFirst()
|
||||
const body = request.body || {};
|
||||
let settings = await prisma.adminNotificationSettings.findFirst();
|
||||
|
||||
const data = {}
|
||||
if ('emailEnabled' in body) data.emailEnabled = Boolean(body.emailEnabled)
|
||||
if ('telegramEnabled' in body) data.telegramEnabled = Boolean(body.telegramEnabled)
|
||||
if ('telegramChatId' in body) data.telegramChatId = body.telegramChatId || null
|
||||
if ('newOrder' in body) data.newOrder = Boolean(body.newOrder)
|
||||
if ('newOrderMessage' in body) data.newOrderMessage = Boolean(body.newOrderMessage)
|
||||
if ('newReview' in body) data.newReview = Boolean(body.newReview)
|
||||
if ('authCodeDuplicate' in body) data.authCodeDuplicate = Boolean(body.authCodeDuplicate)
|
||||
const data = {};
|
||||
if ("emailEnabled" in body)
|
||||
data.emailEnabled = Boolean(body.emailEnabled);
|
||||
if ("telegramEnabled" in body)
|
||||
data.telegramEnabled = Boolean(body.telegramEnabled);
|
||||
if ("telegramChatId" in body)
|
||||
data.telegramChatId = body.telegramChatId || null;
|
||||
if ("newOrder" in body) data.newOrder = Boolean(body.newOrder);
|
||||
if ("newOrderMessage" in body)
|
||||
data.newOrderMessage = Boolean(body.newOrderMessage);
|
||||
if ("newReview" in body) data.newReview = Boolean(body.newReview);
|
||||
if ("authCodeDuplicate" in body)
|
||||
data.authCodeDuplicate = Boolean(body.authCodeDuplicate);
|
||||
|
||||
if (!settings) {
|
||||
settings = await prisma.adminNotificationSettings.create({ data })
|
||||
settings = await prisma.adminNotificationSettings.create({ data });
|
||||
} else {
|
||||
settings = await prisma.adminNotificationSettings.update({
|
||||
where: { id: settings.id },
|
||||
data,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return { settings }
|
||||
return { settings };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/notifications/telegram/webhook',
|
||||
async (request) => {
|
||||
const update = request.body || {}
|
||||
const message = update.message
|
||||
if (!message || !message.text || message.text !== '/start') return { ok: true }
|
||||
fastify.post("/api/admin/notifications/telegram/webhook", async (request) => {
|
||||
const update = request.body || {};
|
||||
const message = update.message;
|
||||
if (!message || !message.text || message.text !== "/start")
|
||||
return { ok: true };
|
||||
|
||||
const chatId = String(message.chat.id)
|
||||
const settings = await prisma.adminNotificationSettings.findFirst()
|
||||
const chatId = String(message.chat.id);
|
||||
const settings = await prisma.adminNotificationSettings.findFirst();
|
||||
|
||||
if (settings) {
|
||||
await prisma.adminNotificationSettings.update({
|
||||
where: { id: settings.id },
|
||||
data: { telegramChatId: chatId },
|
||||
})
|
||||
} else {
|
||||
await prisma.adminNotificationSettings.create({
|
||||
data: { telegramChatId: chatId },
|
||||
})
|
||||
}
|
||||
if (settings) {
|
||||
await prisma.adminNotificationSettings.update({
|
||||
where: { id: settings.id },
|
||||
data: { telegramChatId: chatId },
|
||||
});
|
||||
} else {
|
||||
await prisma.adminNotificationSettings.create({
|
||||
data: { telegramChatId: chatId },
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.TELEGRAM_BOT_TOKEN) {
|
||||
await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
if (process.env.TELEGRAM_BOT_TOKEN) {
|
||||
await fetch(
|
||||
`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: 'Вы подписаны на уведомления Craftshop.',
|
||||
text: "Вы подписаны на уведомления Любимый Креатив.",
|
||||
}),
|
||||
})
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
+107
-69
@@ -1,139 +1,177 @@
|
||||
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
import {
|
||||
issueEmailCode,
|
||||
normalizeEmail,
|
||||
verifyEmailCode,
|
||||
} from "../lib/auth.js";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
|
||||
function mapUserForClient(user) {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
const userEmail = normalizeEmail(user.email)
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL);
|
||||
const userEmail = normalizeEmail(user.email);
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(fastify) {
|
||||
fastify.post('/api/auth/request-code', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
fastify.post("/api/auth/request-code", async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email);
|
||||
if (!email || !email.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
|
||||
const code = await issueEmailCode({ email, purpose: 'login' })
|
||||
const code = await issueEmailCode({ email, purpose: "login" });
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
|
||||
const isAdmin = email === adminEmail
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
||||
const isAdmin = email === adminEmail;
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, {
|
||||
email,
|
||||
code,
|
||||
isAdmin,
|
||||
})
|
||||
});
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
fastify.post('/api/auth/verify-code', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
const code = String(request.body?.code || '').trim()
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
||||
fastify.post("/api/auth/verify-code", async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email);
|
||||
const code = String(request.body?.code || "").trim();
|
||||
if (!email || !email.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
if (!code || code.length !== 6)
|
||||
return reply.code(400).send({ error: "Код должен быть из 6 цифр" });
|
||||
|
||||
const ok = await verifyEmailCode({ email, purpose: 'login', code })
|
||||
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||
const ok = await verifyEmailCode({ email, purpose: "login", code });
|
||||
if (!ok)
|
||||
return reply.code(401).send({ error: "Неверный или истёкший код" });
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {},
|
||||
create: { email },
|
||||
})
|
||||
});
|
||||
|
||||
// Ensure notification preference exists
|
||||
await prisma.notificationPreference.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, globalEnabled: true },
|
||||
update: {},
|
||||
})
|
||||
});
|
||||
|
||||
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
||||
return { token, user: mapUserForClient(user) }
|
||||
})
|
||||
const token = fastify.jwt.sign({ sub: user.id, email: user.email });
|
||||
return { token, user: mapUserForClient(user) };
|
||||
});
|
||||
|
||||
fastify.get(
|
||||
'/api/me',
|
||||
"/api/me",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) return { user: null }
|
||||
return { user: mapUserForClient(user) }
|
||||
const userId = request.user.sub;
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) return { user: null };
|
||||
return { user: mapUserForClient(user) };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/api/me/change-email/request-code',
|
||||
"/api/me/change-email/request-code",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const newEmail = normalizeEmail(request.body?.newEmail)
|
||||
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
const userId = request.user.sub;
|
||||
const newEmail = normalizeEmail(request.body?.newEmail);
|
||||
if (!newEmail || !newEmail.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { email: newEmail } })
|
||||
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
});
|
||||
if (exists)
|
||||
return reply.code(409).send({ error: "Эта почта уже занята" });
|
||||
|
||||
await issueEmailCode({ email: newEmail, purpose: 'change_email', userId })
|
||||
return { ok: true }
|
||||
await issueEmailCode({
|
||||
email: newEmail,
|
||||
purpose: "change_email",
|
||||
userId,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/api/me/change-email/verify',
|
||||
"/api/me/change-email/verify",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const newEmail = normalizeEmail(request.body?.newEmail)
|
||||
const code = String(request.body?.code || '').trim()
|
||||
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
||||
const userId = request.user.sub;
|
||||
const newEmail = normalizeEmail(request.body?.newEmail);
|
||||
const code = String(request.body?.code || "").trim();
|
||||
if (!newEmail || !newEmail.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
if (!code || code.length !== 6)
|
||||
return reply.code(400).send({ error: "Код должен быть из 6 цифр" });
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { email: newEmail } })
|
||||
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
});
|
||||
if (exists)
|
||||
return reply.code(409).send({ error: "Эта почта уже занята" });
|
||||
|
||||
const ok = await verifyEmailCode({ email: newEmail, purpose: 'change_email', code, userId })
|
||||
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||
const ok = await verifyEmailCode({
|
||||
email: newEmail,
|
||||
purpose: "change_email",
|
||||
code,
|
||||
userId,
|
||||
});
|
||||
if (!ok)
|
||||
return reply.code(401).send({ error: "Неверный или истёкший код" });
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { email: newEmail },
|
||||
})
|
||||
return { user: mapUserForClient(user) }
|
||||
});
|
||||
return { user: mapUserForClient(user) };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
'/api/me/profile',
|
||||
"/api/me/profile",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const nameRaw = request.body?.name
|
||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||
const phoneRaw = request.body?.phone
|
||||
const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim()
|
||||
const userId = request.user.sub;
|
||||
const nameRaw = request.body?.name;
|
||||
const name =
|
||||
nameRaw === null || nameRaw === undefined
|
||||
? null
|
||||
: String(nameRaw).trim();
|
||||
const phoneRaw = request.body?.phone;
|
||||
const phone =
|
||||
phoneRaw === null || phoneRaw === undefined
|
||||
? null
|
||||
: String(phoneRaw).trim();
|
||||
|
||||
if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||
if (name !== null && name.length > 40)
|
||||
return reply.code(400).send({ error: "Имя/ник максимум 40 символов" });
|
||||
if (phone !== null) {
|
||||
const compact = phone.replace(/[\s()-]/g, '')
|
||||
if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' })
|
||||
const compact = phone.replace(/[\s()-]/g, "");
|
||||
if (compact.length > 20)
|
||||
return reply.code(400).send({ error: "Телефон слишком длинный" });
|
||||
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) {
|
||||
return reply.code(400).send({ error: 'Некорректный телефон' })
|
||||
return reply.code(400).send({ error: "Некорректный телефон" });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { name: name && name.length ? name : null, phone: phone && phone.length ? phone : null },
|
||||
})
|
||||
return { user: mapUserForClient(updated) }
|
||||
data: {
|
||||
name: name && name.length ? name : null,
|
||||
phone: phone && phone.length ? phone : null,
|
||||
},
|
||||
});
|
||||
return { user: mapUserForClient(updated) };
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,123 +1,155 @@
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
|
||||
export async function registerUserMessageRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/me/orders/:id/messages',
|
||||
"/api/me/orders/:id/messages",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
const items = await prisma.orderMessage.findMany({ where: { orderId: id }, orderBy: { createdAt: 'asc' } })
|
||||
return { items }
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
const items = await prisma.orderMessage.findMany({
|
||||
where: { orderId: id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return { items };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/api/me/orders/:id/messages',
|
||||
"/api/me/orders/:id/messages",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
const text = String(request.body?.text || '').trim()
|
||||
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
|
||||
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
|
||||
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'user', text } })
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
const text = String(request.body?.text || "").trim();
|
||||
if (!text) return reply.code(400).send({ error: "Сообщение пустое" });
|
||||
if (text.length > 2000)
|
||||
return reply.code(400).send({ error: "Сообщение слишком длинное" });
|
||||
const msg = await prisma.orderMessage.create({
|
||||
data: { orderId: id, authorType: "user", text },
|
||||
});
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
|
||||
orderId: id,
|
||||
authorType: 'user',
|
||||
authorType: "user",
|
||||
messageId: msg.id,
|
||||
preview: text,
|
||||
})
|
||||
});
|
||||
|
||||
return reply.code(201).send({ item: msg })
|
||||
return reply.code(201).send({ item: msg });
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/api/me/messages/unread-count',
|
||||
"/api/me/messages/unread-count",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const orders = await prisma.order.findMany({ where: { userId }, select: { id: true } })
|
||||
if (orders.length === 0) return { count: 0 }
|
||||
const userId = request.user.sub;
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (orders.length === 0) return { count: 0 };
|
||||
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } })
|
||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
const lastReadByOrder = new Map(
|
||||
readStates.map((r) => [r.orderId, r.lastReadAt]),
|
||||
);
|
||||
|
||||
let count = 0
|
||||
let count = 0;
|
||||
for (const o of orders) {
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0);
|
||||
const n = await prisma.orderMessage.count({
|
||||
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } },
|
||||
})
|
||||
count += n
|
||||
where: {
|
||||
orderId: o.id,
|
||||
authorType: "admin",
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
});
|
||||
count += n;
|
||||
}
|
||||
return { count }
|
||||
return { count };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/api/me/conversations',
|
||||
"/api/me/conversations",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const userId = request.user.sub;
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId, messages: { some: {} } },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
deliveryType: true,
|
||||
messages: { orderBy: { createdAt: 'desc' }, take: 1, select: { text: true, createdAt: true } },
|
||||
messages: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { text: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } })
|
||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
const lastReadByOrder = new Map(
|
||||
readStates.map((r) => [r.orderId, r.lastReadAt]),
|
||||
);
|
||||
|
||||
const items = []
|
||||
const items = [];
|
||||
for (const o of orders) {
|
||||
const lastMsg = o.messages[0]
|
||||
if (!lastMsg) continue
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||
const lastMsg = o.messages[0];
|
||||
if (!lastMsg) continue;
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0);
|
||||
const unreadCount = await prisma.orderMessage.count({
|
||||
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } },
|
||||
})
|
||||
where: {
|
||||
orderId: o.id,
|
||||
authorType: "admin",
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
orderId: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
lastMessageAt: lastMsg.createdAt,
|
||||
preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text,
|
||||
preview:
|
||||
lastMsg.text.length > 280
|
||||
? `${lastMsg.text.slice(0, 277)}…`
|
||||
: lastMsg.text,
|
||||
unreadCount,
|
||||
})
|
||||
});
|
||||
}
|
||||
return { items }
|
||||
return { items };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/api/me/orders/:id/messages/read',
|
||||
"/api/me/orders/:id/messages/read",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
|
||||
const now = new Date()
|
||||
const now = new Date();
|
||||
await prisma.userOrderMessageReadState.upsert({
|
||||
where: { userId_orderId: { userId, orderId: id } },
|
||||
create: { userId, orderId: id, lastReadAt: now },
|
||||
update: { lastReadAt: now },
|
||||
})
|
||||
return { ok: true }
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+152
-115
@@ -1,77 +1,98 @@
|
||||
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
import { isDeliveryCarrier } from "../lib/delivery-carrier.js";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
|
||||
export async function registerUserOrderRoutes(fastify) {
|
||||
// ---- Создание заказа (checkout) ----
|
||||
|
||||
fastify.post(
|
||||
'/api/me/orders',
|
||||
"/api/me/orders",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const deliveryTypeRaw = request.body?.deliveryType
|
||||
const userId = request.user.sub;
|
||||
const deliveryTypeRaw = request.body?.deliveryType;
|
||||
const deliveryType =
|
||||
deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === ''
|
||||
? 'delivery'
|
||||
: String(deliveryTypeRaw).trim()
|
||||
deliveryTypeRaw === undefined ||
|
||||
deliveryTypeRaw === null ||
|
||||
deliveryTypeRaw === ""
|
||||
? "delivery"
|
||||
: String(deliveryTypeRaw).trim();
|
||||
|
||||
const addressId = String(request.body?.addressId || '').trim()
|
||||
const commentRaw = request.body?.comment
|
||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||
const addressId = String(request.body?.addressId || "").trim();
|
||||
const commentRaw = request.body?.comment;
|
||||
const comment =
|
||||
commentRaw === null || commentRaw === undefined
|
||||
? null
|
||||
: String(commentRaw).trim();
|
||||
|
||||
const paymentMethodRaw = request.body?.paymentMethod
|
||||
const paymentMethodRaw = request.body?.paymentMethod;
|
||||
const paymentMethod =
|
||||
paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === ''
|
||||
? 'online'
|
||||
: String(paymentMethodRaw).trim()
|
||||
if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') {
|
||||
return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' })
|
||||
paymentMethodRaw === undefined ||
|
||||
paymentMethodRaw === null ||
|
||||
paymentMethodRaw === ""
|
||||
? "online"
|
||||
: String(paymentMethodRaw).trim();
|
||||
if (paymentMethod !== "online" && paymentMethod !== "on_pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "paymentMethod должен быть online | on_pickup" });
|
||||
}
|
||||
|
||||
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
||||
if (deliveryType !== "delivery" && deliveryType !== "pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "deliveryType должен быть delivery | pickup" });
|
||||
}
|
||||
|
||||
const carrierRaw = request.body?.deliveryCarrier
|
||||
let deliveryCarrier = null
|
||||
if (deliveryType === 'delivery') {
|
||||
const carrierRaw = request.body?.deliveryCarrier;
|
||||
let deliveryCarrier = null;
|
||||
if (deliveryType === "delivery") {
|
||||
const carrierStr =
|
||||
carrierRaw === undefined || carrierRaw === null || carrierRaw === ''
|
||||
? ''
|
||||
: String(carrierRaw).trim()
|
||||
carrierRaw === undefined || carrierRaw === null || carrierRaw === ""
|
||||
? ""
|
||||
: String(carrierRaw).trim();
|
||||
if (!isDeliveryCarrier(carrierStr)) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error:
|
||||
'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST',
|
||||
})
|
||||
return reply.code(400).send({
|
||||
error:
|
||||
"deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST",
|
||||
});
|
||||
}
|
||||
deliveryCarrier = carrierStr
|
||||
deliveryCarrier = carrierStr;
|
||||
}
|
||||
|
||||
if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза' })
|
||||
if (paymentMethod === "on_pickup" && deliveryType !== "pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error: "Оплата при получении доступна только для самовывоза",
|
||||
});
|
||||
}
|
||||
|
||||
let address = null
|
||||
if (deliveryType === 'delivery') {
|
||||
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
|
||||
address = await prisma.shippingAddress.findFirst({ where: { id: addressId, userId } })
|
||||
if (!address) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
let address = null;
|
||||
if (deliveryType === "delivery") {
|
||||
if (!addressId)
|
||||
return reply.code(400).send({ error: "Выберите адрес доставки" });
|
||||
address = await prisma.shippingAddress.findFirst({
|
||||
where: { id: addressId, userId },
|
||||
});
|
||||
if (!address) return reply.code(404).send({ error: "Адрес не найден" });
|
||||
}
|
||||
|
||||
const cartItems = await prisma.cartItem.findMany({
|
||||
where: { userId },
|
||||
include: { product: true },
|
||||
})
|
||||
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
|
||||
});
|
||||
if (cartItems.length === 0)
|
||||
return reply.code(400).send({ error: "Корзина пуста" });
|
||||
|
||||
for (const ci of cartItems) {
|
||||
const available = ci.product.inStock ? ci.product.quantity : 1
|
||||
const available = ci.product.inStock ? ci.product.quantity : 1;
|
||||
if (ci.qty > available) {
|
||||
return reply.code(409).send({ error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.` })
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,17 +101,20 @@ export async function registerUserOrderRoutes(fastify) {
|
||||
qty: ci.qty,
|
||||
titleSnapshot: ci.product.title,
|
||||
priceCentsSnapshot: ci.product.priceCents,
|
||||
}))
|
||||
}));
|
||||
|
||||
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
|
||||
const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0
|
||||
const totalCents = itemsSubtotalCents + deliveryFeeCents
|
||||
const itemsSubtotalCents = itemsPayload.reduce(
|
||||
(sum, i) => sum + i.priceCentsSnapshot * i.qty,
|
||||
0,
|
||||
);
|
||||
const deliveryFeeCents = deliveryType === "delivery" ? 50000 : 0;
|
||||
const totalCents = itemsSubtotalCents + deliveryFeeCents;
|
||||
|
||||
const addressSnapshotJson =
|
||||
deliveryType === 'pickup'
|
||||
? JSON.stringify({ deliveryType: 'pickup' })
|
||||
deliveryType === "pickup"
|
||||
? JSON.stringify({ deliveryType: "pickup" })
|
||||
: JSON.stringify({
|
||||
deliveryType: 'delivery',
|
||||
deliveryType: "delivery",
|
||||
id: address.id,
|
||||
label: address.label,
|
||||
recipientName: address.recipientName,
|
||||
@@ -99,31 +123,30 @@ export async function registerUserOrderRoutes(fastify) {
|
||||
comment: address.comment,
|
||||
lat: address.lat,
|
||||
lng: address.lng,
|
||||
})
|
||||
});
|
||||
|
||||
let initialStatus = 'PENDING_PAYMENT'
|
||||
let deliveryFeeLocked = true
|
||||
if (paymentMethod === 'on_pickup') {
|
||||
initialStatus = 'IN_PROGRESS'
|
||||
} else if (deliveryType === 'delivery') {
|
||||
initialStatus = 'PENDING_PAYMENT'
|
||||
deliveryFeeLocked = false
|
||||
let initialStatus = "PENDING_PAYMENT";
|
||||
let deliveryFeeLocked = true;
|
||||
if (paymentMethod === "on_pickup") {
|
||||
initialStatus = "IN_PROGRESS";
|
||||
} else if (deliveryType === "delivery") {
|
||||
initialStatus = "PENDING_PAYMENT";
|
||||
deliveryFeeLocked = false;
|
||||
}
|
||||
|
||||
let created
|
||||
let created;
|
||||
try {
|
||||
created = await prisma.$transaction(async (tx) => {
|
||||
for (const ci of cartItems) {
|
||||
if (!ci.product.inStock) continue
|
||||
if (!ci.product.inStock) continue;
|
||||
|
||||
const res = await tx.product.updateMany({
|
||||
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
||||
data: { quantity: { decrement: ci.qty } },
|
||||
})
|
||||
});
|
||||
if (res.count !== 1) {
|
||||
throw new Error(`Недостаточно товара: "${ci.product.title}"`)
|
||||
throw new Error(`Недостаточно товара: "${ci.product.title}"`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const order = await tx.order.create({
|
||||
@@ -137,7 +160,7 @@ export async function registerUserOrderRoutes(fastify) {
|
||||
itemsSubtotalCents,
|
||||
deliveryFeeCents,
|
||||
totalCents,
|
||||
currency: 'RUB',
|
||||
currency: "RUB",
|
||||
addressSnapshotJson,
|
||||
comment: comment && comment.length ? comment : null,
|
||||
items: {
|
||||
@@ -149,12 +172,16 @@ export async function registerUserOrderRoutes(fastify) {
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
await tx.cartItem.deleteMany({ where: { userId } })
|
||||
return order
|
||||
})
|
||||
});
|
||||
await tx.cartItem.deleteMany({ where: { userId } });
|
||||
return order;
|
||||
});
|
||||
} catch (e) {
|
||||
return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара' })
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error: (e instanceof Error && e.message) || "Недостаточно товара",
|
||||
});
|
||||
}
|
||||
|
||||
// Emit notification events
|
||||
@@ -163,31 +190,31 @@ export async function registerUserOrderRoutes(fastify) {
|
||||
userId,
|
||||
totalCents: created.totalCents,
|
||||
itemsCount: cartItems.length,
|
||||
})
|
||||
});
|
||||
|
||||
// Also emit admin notification
|
||||
request.server.eventBus.emit('order:created:admin', {
|
||||
request.server.eventBus.emit("order:created:admin", {
|
||||
orderId: created.id,
|
||||
userId,
|
||||
userEmail: request.user.email || '',
|
||||
userEmail: request.user.email || "",
|
||||
totalCents: created.totalCents,
|
||||
itemsCount: cartItems.length,
|
||||
})
|
||||
});
|
||||
|
||||
return reply.code(201).send({ orderId: created.id })
|
||||
return reply.code(201).send({ orderId: created.id });
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/api/me/orders',
|
||||
"/api/me/orders",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const userId = request.user.sub;
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
include: { items: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return {
|
||||
items: orders.map((o) => ({
|
||||
id: o.id,
|
||||
@@ -198,76 +225,86 @@ export async function registerUserOrderRoutes(fastify) {
|
||||
updatedAt: o.updatedAt,
|
||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||
})),
|
||||
}
|
||||
};
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/api/me/orders/:id',
|
||||
"/api/me/orders/:id",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { id, userId },
|
||||
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||
})
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
return { item: order }
|
||||
include: { items: true, messages: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
return { item: order };
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/api/me/orders/:id/review-eligibility',
|
||||
"/api/me/orders/:id/review-eligibility",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId }, include: { items: true } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
if (order.status !== 'DONE') {
|
||||
return { canReview: false, items: [] }
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { id, userId },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
if (order.status !== "DONE") {
|
||||
return { canReview: false, items: [] };
|
||||
}
|
||||
|
||||
const uniq = new Map()
|
||||
const uniq = new Map();
|
||||
for (const it of order.items) {
|
||||
if (!uniq.has(it.productId)) {
|
||||
uniq.set(it.productId, { productId: it.productId, title: it.titleSnapshot })
|
||||
uniq.set(it.productId, {
|
||||
productId: it.productId,
|
||||
title: it.titleSnapshot,
|
||||
});
|
||||
}
|
||||
}
|
||||
const productIds = [...uniq.keys()]
|
||||
const productIds = [...uniq.keys()];
|
||||
const existing = await prisma.review.findMany({
|
||||
where: { userId, productId: { in: productIds } },
|
||||
select: { productId: true },
|
||||
})
|
||||
const reviewed = new Set(existing.map((r) => r.productId))
|
||||
});
|
||||
const reviewed = new Set(existing.map((r) => r.productId));
|
||||
return {
|
||||
canReview: true,
|
||||
items: [...uniq.values()].map((x) => ({
|
||||
...x,
|
||||
hasReview: reviewed.has(x.productId),
|
||||
})),
|
||||
}
|
||||
};
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/api/me/orders/:id/confirm-received',
|
||||
"/api/me/orders/:id/confirm-received",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
|
||||
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
|
||||
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
|
||||
const okDelivery =
|
||||
order.deliveryType === "delivery" && order.status === "SHIPPED";
|
||||
const okPickup =
|
||||
order.deliveryType === "pickup" && order.status === "READY_FOR_PICKUP";
|
||||
if (!okDelivery && !okPickup) {
|
||||
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: "Сейчас нельзя подтвердить получение заказа" });
|
||||
}
|
||||
|
||||
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
|
||||
return { ok: true, status: 'DONE' }
|
||||
await prisma.order.update({ where: { id }, data: { status: "DONE" } });
|
||||
return { ok: true, status: "DONE" };
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,118 +1,142 @@
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { escapeHtml } from '../lib/escape-html.js'
|
||||
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
|
||||
import { saveImageBufferToUploads } from '../lib/upload-images.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { escapeHtml } from "../lib/escape-html.js";
|
||||
import { getOtherUploadMaxFileBytes } from "../lib/upload-limits.js";
|
||||
import { saveImageBufferToUploads } from "../lib/upload-images.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
|
||||
export async function registerUserPaymentRoutes(fastify) {
|
||||
fastify.post(
|
||||
'/api/me/orders/:id/pay',
|
||||
"/api/me/orders/:id/pay",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
|
||||
const paymentMethod = order.paymentMethod ?? 'online'
|
||||
if (paymentMethod === 'on_pickup') {
|
||||
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
|
||||
const paymentMethod = order.paymentMethod ?? "online";
|
||||
if (paymentMethod === "on_pickup") {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error:
|
||||
"Для этого заказа оплата при получении — кнопка оплаты не нужна.",
|
||||
});
|
||||
}
|
||||
|
||||
if (order.status !== 'PENDING_PAYMENT') {
|
||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||
if (order.status !== "PENDING_PAYMENT") {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: "Сейчас нельзя выполнить оплату для этого заказа" });
|
||||
}
|
||||
|
||||
if (!request.isMultipart()) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' })
|
||||
.send({
|
||||
error:
|
||||
"Отправьте multipart/form-data: поле detail и/или файл receipt",
|
||||
});
|
||||
}
|
||||
|
||||
let detail = ''
|
||||
let receiptBuffer = null
|
||||
let receiptFilename = ''
|
||||
let detail = "";
|
||||
let receiptBuffer = null;
|
||||
let receiptFilename = "";
|
||||
try {
|
||||
const otherLimit = getOtherUploadMaxFileBytes()
|
||||
const otherLimit = getOtherUploadMaxFileBytes();
|
||||
const parts = request.parts({
|
||||
limits: {
|
||||
fileSize: otherLimit,
|
||||
files: 2,
|
||||
},
|
||||
})
|
||||
});
|
||||
for await (const part of parts) {
|
||||
if (part.file) {
|
||||
if (part.fieldname === 'receipt') {
|
||||
if (part.fieldname === "receipt") {
|
||||
if (receiptBuffer !== null) {
|
||||
return reply.code(400).send({ error: 'Допускается один файл receipt' })
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "Допускается один файл receipt" });
|
||||
}
|
||||
receiptBuffer = await part.toBuffer()
|
||||
receiptFilename = part.filename ?? 'receipt'
|
||||
receiptBuffer = await part.toBuffer();
|
||||
receiptFilename = part.filename ?? "receipt";
|
||||
}
|
||||
} else if (part.fieldname === 'detail') {
|
||||
detail = String(part.value ?? '').trim()
|
||||
} else if (part.fieldname === "detail") {
|
||||
detail = String(part.value ?? "").trim();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
|
||||
return reply.code(400).send({ error: msg })
|
||||
const msg =
|
||||
err instanceof Error ? err.message : "Не удалось разобрать форму";
|
||||
return reply.code(400).send({ error: msg });
|
||||
}
|
||||
|
||||
const hasDetail = detail.length > 0
|
||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
||||
const hasDetail = detail.length > 0;
|
||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0;
|
||||
|
||||
if (!hasDetail && !hasReceipt) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: 'Укажите текст о платеже и/или прикрепите изображение чека' })
|
||||
.send({
|
||||
error: "Укажите текст о платеже и/или прикрепите изображение чека",
|
||||
});
|
||||
}
|
||||
|
||||
const maxDetail = 2000
|
||||
const maxDetail = 2000;
|
||||
if (detail.length > maxDetail) {
|
||||
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: `Текст не длиннее ${maxDetail} символов` });
|
||||
}
|
||||
|
||||
let attachmentUrl = null
|
||||
let attachmentUrl = null;
|
||||
if (hasReceipt) {
|
||||
try {
|
||||
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
||||
attachmentUrl = await saveImageBufferToUploads(
|
||||
receiptFilename,
|
||||
receiptBuffer,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Не удалось сохранить файл";
|
||||
const statusCode =
|
||||
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"statusCode" in err &&
|
||||
Number.isInteger(err.statusCode)
|
||||
? Number(err.statusCode)
|
||||
: 400
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
: 400;
|
||||
return reply.code(statusCode).send({ error: message });
|
||||
}
|
||||
}
|
||||
|
||||
const bodyHtml = hasDetail
|
||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>`
|
||||
: ''
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
|
||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, "<br/>")}</p>`
|
||||
: "";
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`;
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.orderMessage.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
authorType: 'user',
|
||||
authorType: "user",
|
||||
text: messageText,
|
||||
attachmentUrl,
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
||||
return reply.code(500).send({ error: "Не удалось сохранить оплату" });
|
||||
}
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||
orderId: id,
|
||||
userId,
|
||||
paymentStatus: 'pending',
|
||||
})
|
||||
paymentStatus: "pending",
|
||||
});
|
||||
|
||||
return { ok: true, status: 'PENDING_PAYMENT' }
|
||||
return { ok: true, status: "PENDING_PAYMENT" };
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user