test commit

This commit is contained in:
Kirill
2026-05-19 11:25:23 +05:00
parent f8867f6457
commit 5adbe9baa7
81 changed files with 6549 additions and 3108 deletions
+1 -3
View File
@@ -16,8 +16,6 @@ describe('escapeHtml', () => {
})
it('escapes mixed content', () => {
expect(escapeHtml('<script>alert("xss")</script>')).toBe(
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;',
)
expect(escapeHtml('<script>alert("xss")</script>')).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
})
})
+16 -4
View File
@@ -64,7 +64,10 @@ describe('image-resize', () => {
expect(result.path).toContain('.cache')
expect(result.path).toContain('_w100.avif')
const exists = await fs.promises.access(result.path).then(() => true).catch(() => false)
const exists = await fs.promises
.access(result.path)
.then(() => true)
.catch(() => false)
expect(exists).toBe(true)
// Verify it's actually AVIF (sharp reports AVIF as 'heif' in metadata)
@@ -114,7 +117,10 @@ describe('eager image processing', () => {
for (const width of [320, 640, 1024, 1600]) {
for (const format of ['avif', 'webp']) {
const cachePath = path.join(cacheDir, `${uuid}_w${width}.${format}`)
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
const exists = await fs.promises
.access(cachePath)
.then(() => true)
.catch(() => false)
expect(exists).toBe(true)
}
}
@@ -145,10 +151,16 @@ describe('eager image processing', () => {
const result = await convertOriginalToWebp(uuid, '')
expect(result).toBe(`/uploads/${uuid}.webp`)
const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false)
const pngExists = await fs.promises
.access(testImagePath)
.then(() => true)
.catch(() => false)
expect(pngExists).toBe(false)
const webpPath = path.join(UPLOADS_DIR, `${uuid}.webp`)
const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false)
const webpExists = await fs.promises
.access(webpPath)
.then(() => true)
.catch(() => false)
expect(webpExists).toBe(true)
// Cleanup
@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { describe, it, expect, afterEach } from 'vitest'
import { persistMultipartImages } from '../upload-images.js'
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
@@ -45,5 +45,4 @@ describe('persistMultipartImages with eager=false', () => {
expect(urls).toHaveLength(1)
expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/)
})
})
+7 -5
View File
@@ -1,9 +1,11 @@
import crypto from 'node:crypto'
import { prisma } from './prisma.js'
import { sendLoginCodeEmail } from './email.js'
import { prisma } from './prisma.js'
export function normalizeEmail(email) {
return String(email || '').trim().toLowerCase()
return String(email || '')
.trim()
.toLowerCase()
}
export function randomCode6() {
@@ -31,7 +33,9 @@ export async function issueEmailCode({ email, purpose, userId = null }) {
}
function parseEnvBool(raw) {
const v = String(raw ?? '').trim().toLowerCase()
const v = String(raw ?? '')
.trim()
.toLowerCase()
return v === 'true' || v === '1' || v === 'yes'
}
@@ -68,5 +72,3 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) {
})
return true
}
+1 -1
View File
@@ -8,7 +8,7 @@ export async function ensureAdminUser() {
throw new Error('ADMIN_EMAIL должен быть валидным email')
}
const admin = await prisma.user.upsert({
await prisma.user.upsert({
where: { email: adminEmail },
update: {},
create: { email: adminEmail },
+2 -2
View File
@@ -18,7 +18,7 @@ function createTransporter() {
export async function sendLoginCodeEmail({ to, code }) {
if (!hasSmtpEnv()) {
console.log(`[DEV] login code for ${to}: ${code}`)
console.info(`[DEV] login code for ${to}: ${code}`)
return
}
@@ -35,7 +35,7 @@ export async function sendLoginCodeEmail({ to, code }) {
export async function sendNotificationEmail({ to, subject, html }) {
if (!hasSmtpEnv()) {
console.log(`[DEV] notification email to ${to}: ${subject}`)
console.info(`[DEV] notification email to ${to}: ${subject}`)
return { success: true }
}
-1
View File
@@ -1,4 +1,3 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
@@ -65,7 +65,7 @@ describe('preferences', () => {
})
it('returns admin targets when settings enabled', async () => {
const admin = await prisma.user.create({ data: { email: 'admin@test.com' } })
await prisma.user.create({ data: { email: 'admin@test.com' } })
const origAdminEmail = process.env.ADMIN_EMAIL
process.env.ADMIN_EMAIL = 'admin@test.com'
@@ -25,7 +25,7 @@ const templateRenderers = {
async function postToTelegram(chatId, text) {
if (!TELEGRAM_BOT_TOKEN) {
console.log(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`)
console.info(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`)
return { success: true }
}
+38 -44
View File
@@ -1,5 +1,5 @@
import { prisma } from "../prisma.js";
import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
import { prisma } from '../prisma.js'
const {
ORDER_CREATED,
@@ -7,105 +7,99 @@ const {
ORDER_MESSAGE_SENT,
ORDER_MESSAGE_ADMIN_REPLY,
PAYMENT_STATUS_CHANGED,
AUTH_CODE_REQUESTED,
DELIVERY_FEE_ADJUSTED,
} = NOTIFICATION_EVENTS;
} = NOTIFICATION_EVENTS
const userEventFieldMap = {
[ORDER_CREATED]: "orderCreated",
[ORDER_STATUS_CHANGED]: "orderStatusChanged",
[ORDER_MESSAGE_ADMIN_REPLY]: "orderMessageReceived",
[PAYMENT_STATUS_CHANGED]: "paymentStatusChanged",
[DELIVERY_FEE_ADJUSTED]: "deliveryFeeAdjusted",
};
[ORDER_CREATED]: 'orderCreated',
[ORDER_STATUS_CHANGED]: 'orderStatusChanged',
[ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived',
[PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged',
[DELIVERY_FEE_ADJUSTED]: 'deliveryFeeAdjusted',
}
const adminEventFieldMap = {
[ORDER_MESSAGE_SENT]: "newOrderMessage",
"review:created": "newReview",
};
[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 },
});
})
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;
})
if (existing) return existing
return prisma.notificationPreference.create({
data: { userId, globalEnabled: true },
});
})
}
+6 -2
View File
@@ -1,5 +1,9 @@
import {
NOTIFICATION_STATUSES,
MAX_RETRY_ATTEMPTS,
RETRY_DELAYS_MS,
} from '../../../../shared/constants/notification-events.js'
import { prisma } from '../prisma.js'
import { NOTIFICATION_STATUSES, MAX_RETRY_ATTEMPTS, RETRY_DELAYS_MS } from '../../../../shared/constants/notification-events.js'
import { emailChannel } from './channels/email-channel.js'
import { telegramChannel } from './channels/telegram-channel.js'
@@ -120,7 +124,7 @@ class NotificationQueue {
})
}
if (pending.length > 0) {
console.log(`[notifications] Marked ${pending.length} pending notifications as failed on startup`)
console.info(`[notifications] Marked ${pending.length} pending notifications as failed on startup`)
}
}
}
@@ -11,132 +11,113 @@ function baseLayout(title, body) {
<p>Любимый Креатив — магазин handmade изделий</p>
</div>
</body>
</html>`;
</html>`
}
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString("ru-RU");
const nextAction = deliveryType === "delivery"
? "Оплата будет доступна после уточнения стоимости доставки."
: "Ожидает оплаты.";
const total = (totalCents / 100).toLocaleString('ru-RU')
const nextAction =
deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.'
const body = `
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
<p>${nextAction}</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: "Ожидает оплаты",
PAID: "Оплачен",
IN_PROGRESS: "В работе",
READY_FOR_PICKUP: "Готов к выдаче",
SHIPPED: "Отправлен",
DONE: "Выполнен",
CANCELLED: "Отменён",
};
const oldLabel = statusLabels[oldStatus] || oldStatus;
const newLabel = statusLabels[newStatus] || newStatus;
DRAFT: 'Черновик',
PENDING_PAYMENT: 'Ожидает оплаты',
PAID: 'Оплачен',
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),
};
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),
};
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),
};
html: baseLayout('Оплата заказа', body),
}
}
export function renderAdminOrderCreatedEmail({
orderId,
userEmail,
totalCents,
itemsCount,
deliveryType,
}) {
const total = (totalCents / 100).toLocaleString("ru-RU");
const note = deliveryType === "delivery"
? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>'
: "";
export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
const note = deliveryType === 'delivery' ? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>' : ''
const body = `
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
${note}
`;
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) }
}
export function renderDeliveryFeeAdjustedEmail({ orderId, totalCents }) {
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>Новая сумма: <b>${total} ₽</b></p>
<p>Ожидает оплаты. Проверьте статус заказа в личном кабинете.</p>
`;
`
return {
subject: "Стоимость доставки скорректирована",
html: baseLayout("Стоимость доставки скорректирована", body),
};
subject: 'Стоимость доставки скорректирована',
html: baseLayout('Стоимость доставки скорректирована', body),
}
}
@@ -1,15 +1,20 @@
export function renderOrderCreatedTg({ orderId, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
const nextAction = deliveryType === 'delivery'
? 'Оплата будет доступна после уточнения стоимости доставки.'
: 'Ожидает оплаты.'
const nextAction =
deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.'
return `📦 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total}\n${nextAction}`
}
export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
const labels = {
DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', PAID: 'Оплачен', IN_PROGRESS: 'В работе',
READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён',
DRAFT: 'Черновик',
PENDING_PAYMENT: 'Ожидает оплаты',
PAID: 'Оплачен',
IN_PROGRESS: 'В работе',
READY_FOR_PICKUP: 'Готов к выдаче',
SHIPPED: 'Отправлен',
DONE: 'Выполнен',
CANCELLED: 'Отменён',
}
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
}
+5 -37
View File
@@ -1,37 +1,5 @@
export { ORDER_STATUSES } from '../../../shared/constants/order-status.js'
/**
* Переходы, которые делает админ через PATCH /api/admin/orders/:id/status
* (подтверждение получения пользователем — отдельный эндпоинт).
*/
export function canTransitionAdminOrderStatus(order, next) {
const from = order.status
const dt = order.deliveryType
if (from === next) return true
switch (from) {
case 'DRAFT':
return next === 'PENDING_PAYMENT' || next === 'CANCELLED'
case 'PENDING_PAYMENT':
return next === 'PAID' || next === 'CANCELLED'
case 'PAID':
return next === 'IN_PROGRESS' || next === 'CANCELLED'
case 'IN_PROGRESS':
if (next === 'CANCELLED') return true
if (dt === 'delivery') return next === 'SHIPPED'
if (dt === 'pickup') return next === 'READY_FOR_PICKUP'
return false
case 'SHIPPED':
case 'READY_FOR_PICKUP':
case 'DONE':
case 'CANCELLED':
return false
default:
return false
}
}
/** @deprecated используйте canTransitionAdminOrderStatus */
export function canTransitionOrderStatus(from, to) {
return canTransitionAdminOrderStatus({ status: from, deliveryType: 'delivery' }, to)
}
export {
ORDER_STATUSES,
getNextAdminStatuses,
canTransitionAdminOrderStatus,
} from '../../../shared/constants/order-status.js'