From c8281a39e5663cf7032dec4fddf0038e61cc1391 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 17 May 2026 17:39:44 +0500 Subject: [PATCH 01/28] feat(db): add isResized to GalleryImage --- .../20260517123931_add_is_resized/migration.sql | 15 +++++++++++++++ server/prisma/schema.prisma | 1 + server/prisma/seed-is-resized.js | 13 +++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 server/prisma/migrations/20260517123931_add_is_resized/migration.sql create mode 100644 server/prisma/seed-is-resized.js diff --git a/server/prisma/migrations/20260517123931_add_is_resized/migration.sql b/server/prisma/migrations/20260517123931_add_is_resized/migration.sql new file mode 100644 index 0000000..9f545e7 --- /dev/null +++ b/server/prisma/migrations/20260517123931_add_is_resized/migration.sql @@ -0,0 +1,15 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_GalleryImage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "url" TEXT NOT NULL, + "isResized" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +INSERT INTO "new_GalleryImage" ("createdAt", "id", "url") SELECT "createdAt", "id", "url" FROM "GalleryImage"; +DROP TABLE "GalleryImage"; +ALTER TABLE "new_GalleryImage" RENAME TO "GalleryImage"; +CREATE UNIQUE INDEX "GalleryImage_url_key" ON "GalleryImage"("url"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 572c360..af2366e 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -57,6 +57,7 @@ model ProductImage { model GalleryImage { id String @id @default(cuid()) url String @unique + isResized Boolean @default(false) createdAt DateTime @default(now()) catalogSliderSlides CatalogSliderSlide[] diff --git a/server/prisma/seed-is-resized.js b/server/prisma/seed-is-resized.js new file mode 100644 index 0000000..2137f4a --- /dev/null +++ b/server/prisma/seed-is-resized.js @@ -0,0 +1,13 @@ +import { prisma } from '../src/lib/prisma.js' + +async function main() { + const { count } = await prisma.galleryImage.updateMany({ + where: { isResized: false }, + data: { isResized: true }, + }) + console.log(`Marked ${count} existing images as resized`) +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()) From 248f8766aaa94c139ffbbb8554ebd0801fb362cf Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 17 May 2026 17:43:47 +0500 Subject: [PATCH 02/28] feat(server): add POST /api/admin/gallery/upload endpoint --- server/src/routes/api/admin-gallery.js | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/server/src/routes/api/admin-gallery.js b/server/src/routes/api/admin-gallery.js index d60a410..0d13deb 100644 --- a/server/src/routes/api/admin-gallery.js +++ b/server/src/routes/api/admin-gallery.js @@ -1,6 +1,12 @@ import fs from 'node:fs/promises' import path from 'node:path' import { prisma } from '../../lib/prisma.js' +import { persistMultipartImages } from '../../lib/upload-images.js' +import { + formatFileTooLargeMessage, + getProductImageMaxFileBytes, + isMultipartFileTooLargeError, +} from '../../lib/upload-limits.js' export async function registerAdminGalleryRoutes(fastify) { fastify.get( @@ -14,6 +20,38 @@ export async function registerAdminGalleryRoutes(fastify) { }, ) + fastify.post( + '/api/admin/gallery/upload', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + try { + const urls = await persistMultipartImages(request, { + maxFiles: 10, + maxFileBytes: getProductImageMaxFileBytes(), + subdir: '', + eager: false, + }) + for (const url of urls) { + await prisma.galleryImage.create({ + data: { url, isResized: false }, + }) + } + return { urls } + } catch (error) { + let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' + let statusCode = + error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) + ? Number(error.statusCode) + : 400 + if (isMultipartFileTooLargeError(error)) { + message = formatFileTooLargeMessage(getProductImageMaxFileBytes()) + statusCode = 413 + } + return reply.code(statusCode).send({ error: message }) + } + }, + ) + fastify.delete( '/api/admin/gallery/:id', { preHandler: [fastify.verifyAdmin] }, From 9226bcc571a7db2b84d4d84a63e3aee51ecf9cb7 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 17 May 2026 17:47:02 +0500 Subject: [PATCH 03/28] feat(server): add POST /api/admin/gallery/:id/resize endpoint --- server/src/routes/api/admin-gallery.js | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/server/src/routes/api/admin-gallery.js b/server/src/routes/api/admin-gallery.js index 0d13deb..8f02b23 100644 --- a/server/src/routes/api/admin-gallery.js +++ b/server/src/routes/api/admin-gallery.js @@ -52,6 +52,43 @@ export async function registerAdminGalleryRoutes(fastify) { }, ) + fastify.post( + '/api/admin/gallery/:id/resize', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const { id } = request.params + const row = await prisma.galleryImage.findUnique({ where: { id } }) + if (!row) { + return reply.code(404).send({ error: 'Изображение не найдено' }) + } + if (row.isResized) { + return reply.code(409).send({ error: 'Изображение уже обработано' }) + } + + const urlParts = row.url.replace(/^\//, '').split('/') + const fileName = urlParts[urlParts.length - 1] + const uuid = path.parse(fileName).name + + try { + const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js') + + const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName) + await generateAllSizes(uuid, '', fullPath) + const newUrl = await convertOriginalToWebp(uuid, '') + + await prisma.galleryImage.update({ + where: { id }, + data: { url: newUrl, isResized: true }, + }) + + return { url: newUrl } + } catch (error) { + request.log.error(error, 'Resize failed') + return reply.code(500).send({ error: 'Ошибка обработки изображения' }) + } + }, + ) + fastify.delete( '/api/admin/gallery/:id', { preHandler: [fastify.verifyAdmin] }, From 5637bb7db9c20d5757372b50c01968fe04f951c5 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 17 May 2026 17:51:47 +0500 Subject: [PATCH 04/28] feat(server): remove old /admin/uploads, validate isResized on product endpoints --- server/src/index.js | 2 +- server/src/routes/api/admin-products.js | 72 +++++++++++++------------ 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/server/src/index.js b/server/src/index.js index 3fc5684..c5bf3be 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -42,7 +42,7 @@ await fastify.register(jwt, { await fastify.register(multipart, { limits: { files: 10, - /** Совпадает с лимитом одного файла для `POST /api/admin/uploads` (товары, галерея). */ + /** Совпадает с лимитом одного файла для `POST /api/admin/gallery/upload` (галерея). */ fileSize: getProductImageMaxFileBytes(), }, }) diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js index 8492402..2294660 100644 --- a/server/src/routes/api/admin-products.js +++ b/server/src/routes/api/admin-products.js @@ -1,11 +1,4 @@ -import { upsertGalleryImagesByUrls } from '../../lib/gallery.js' import { prisma } from '../../lib/prisma.js' -import { - formatFileTooLargeMessage, - getProductImageMaxFileBytes, - isMultipartFileTooLargeError, -} from '../../lib/upload-limits.js' -import { persistMultipartImages } from '../../lib/upload-images.js' const CREATE_PRODUCT_SCHEMA = { body: { @@ -59,33 +52,6 @@ export async function registerAdminProductRoutes(fastify) { }, ) - fastify.post( - '/api/admin/uploads', - { preHandler: [fastify.verifyAdmin] }, - async (request, reply) => { - try { - const urls = await persistMultipartImages(request, { - maxFiles: 10, - maxFileBytes: getProductImageMaxFileBytes(), - eager: true, - }) - await upsertGalleryImagesByUrls(urls) - return { urls } - } catch (error) { - let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' - let statusCode = - error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) - ? Number(error.statusCode) - : 400 - if (isMultipartFileTooLargeError(error)) { - message = formatFileTooLargeMessage(getProductImageMaxFileBytes()) - statusCode = 413 - } - return reply.code(statusCode).send({ error: message }) - } - }, - ) - fastify.post( '/api/admin/products', { preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA }, @@ -122,6 +88,25 @@ export async function registerAdminProductRoutes(fastify) { return } + if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) { + const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean) + if (urls.length > 0) { + const galleryImages = await prisma.galleryImage.findMany({ + where: { url: { in: urls } }, + select: { url: true, isResized: true }, + }) + const galleryMap = new Map(galleryImages.map((g) => [g.url, g])) + const notFound = urls.filter((u) => !galleryMap.has(u)) + const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized) + if (notFound.length > 0) { + return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' }) + } + if (notResized.length > 0) { + return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' }) + } + } + } + const n = Number(body.quantity) if (!Number.isInteger(n) || n < 0 || n > 10) { reply.code(400).send({ error: 'Количество — целое число от 0 до 10' }) @@ -228,6 +213,25 @@ export async function registerAdminProductRoutes(fastify) { data.categoryId = cid } + if (body.imageUrls !== undefined && Array.isArray(body.imageUrls)) { + const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean) + if (urls.length > 0) { + const galleryImages = await prisma.galleryImage.findMany({ + where: { url: { in: urls } }, + select: { url: true, isResized: true }, + }) + const galleryMap = new Map(galleryImages.map((g) => [g.url, g])) + const notFound = urls.filter((u) => !galleryMap.has(u)) + const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized) + if (notFound.length > 0) { + return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' }) + } + if (notResized.length > 0) { + return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' }) + } + } + } + const imagesUpdate = body.imageUrls !== undefined ? { From 02172f799552a94c9708ff02205d083021b1d1db Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 17 May 2026 17:56:21 +0500 Subject: [PATCH 05/28] test(server): add gallery resize test, adapt upload tests --- .../src/lib/__tests__/upload-images.test.js | 78 +------------------ .../api/__tests__/admin-gallery.test.js | 56 +++++++++++++ 2 files changed, 58 insertions(+), 76 deletions(-) create mode 100644 server/src/routes/api/__tests__/admin-gallery.test.js diff --git a/server/src/lib/__tests__/upload-images.test.js b/server/src/lib/__tests__/upload-images.test.js index ee02537..bfea05c 100644 --- a/server/src/lib/__tests__/upload-images.test.js +++ b/server/src/lib/__tests__/upload-images.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' import fs from 'node:fs' import path from 'node:path' import { persistMultipartImages } from '../upload-images.js' @@ -6,7 +6,7 @@ import { persistMultipartImages } from '../upload-images.js' const UPLOADS_DIR = path.join(process.cwd(), 'uploads') const TEST_PREFIX = 'upload-test-' -describe('persistMultipartImages with eager mode', () => { +describe('persistMultipartImages with eager=false', () => { afterEach(async () => { const files = await fs.promises.readdir(UPLOADS_DIR).catch(() => []) for (const file of files) { @@ -16,50 +16,6 @@ describe('persistMultipartImages with eager mode', () => { } }) - it('returns WebP URLs when eager=true', async () => { - const sharp = (await import('sharp')).default - const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original.png`) - - const filesBefore = await fs.promises.readdir(UPLOADS_DIR) - - await sharp({ create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } } }) - .png() - .toFile(testImagePath) - - const mockRequest = { - isMultipart: () => true, - parts: async function* () { - const buffer = await fs.promises.readFile(testImagePath) - yield { - file: true, - filename: 'test.png', - toBuffer: async () => buffer, - } - }, - } - - const urls = await persistMultipartImages(mockRequest, { - maxFiles: 1, - maxFileBytes: 20 * 1024 * 1024, - subdir: '', - eager: true, - }) - - expect(urls).toHaveLength(1) - expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.webp$/) - - // Verify the intermediate PNG file written by persistMultipartImages was deleted - const filesAfter = await fs.promises.readdir(UPLOADS_DIR) - const newPngFiles = filesAfter.filter( - (f) => - !filesBefore.includes(f) && - f.endsWith('.png') && - f !== path.basename(testImagePath) && - !f.startsWith('test-eager-uuid-'), - ) - expect(newPngFiles).toHaveLength(0) - }) - it('returns original format URLs when eager=false', async () => { const sharp = (await import('sharp')).default const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original2.png`) @@ -90,34 +46,4 @@ describe('persistMultipartImages with eager mode', () => { expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/) }) - it('cleans up original file on eager processing error', async () => { - const invalidBuffer = Buffer.from('not an image') - - const filesBefore = await fs.promises.readdir(UPLOADS_DIR) - - const mockRequest = { - isMultipart: () => true, - parts: async function* () { - yield { - file: true, - filename: 'test.png', - toBuffer: async () => invalidBuffer, - } - }, - } - - await expect( - persistMultipartImages(mockRequest, { - maxFiles: 1, - maxFileBytes: 20 * 1024 * 1024, - subdir: '', - eager: true, - }), - ).rejects.toThrow() - - // The intermediate file written by persistMultipartImages should be cleaned up - const filesAfter = await fs.promises.readdir(UPLOADS_DIR) - const newFiles = filesAfter.filter((f) => !filesBefore.includes(f) && f !== '.cache') - expect(newFiles).toHaveLength(0) - }) }) diff --git a/server/src/routes/api/__tests__/admin-gallery.test.js b/server/src/routes/api/__tests__/admin-gallery.test.js new file mode 100644 index 0000000..27c0acd --- /dev/null +++ b/server/src/routes/api/__tests__/admin-gallery.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' + +const UPLOADS_DIR = path.join(process.cwd(), 'uploads') + +import { generateAllSizes, convertOriginalToWebp } from '../../../lib/image-resize.js' + +describe('Admin gallery resize integration', () => { + const testUuid = 'gallery-test-resize-uuid' + const testOriginalPath = path.join(UPLOADS_DIR, `${testUuid}.png`) + + beforeAll(async () => { + const sharp = (await import('sharp')).default + await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } }) + .png() + .toFile(testOriginalPath) + }) + + afterAll(async () => { + await fs.promises.unlink(testOriginalPath).catch(() => {}) + const webpPath = path.join(UPLOADS_DIR, `${testUuid}.webp`) + await fs.promises.unlink(webpPath).catch(() => {}) + const cacheDir = path.join(UPLOADS_DIR, '.cache') + for (const width of [320, 640, 1024, 1600]) { + for (const format of ['avif', 'webp']) { + await fs.promises.unlink(path.join(cacheDir, `${testUuid}_w${width}.${format}`)).catch(() => {}) + } + } + }) + + it('generateAllSizes + convertOriginalToWebp works on raw upload', async () => { + await generateAllSizes(testUuid, '', testOriginalPath) + const newUrl = await convertOriginalToWebp(testUuid, '') + + expect(newUrl).toBe(`/uploads/${testUuid}.webp`) + + // Verify original PNG is deleted + const pngExists = await fs.promises.access(testOriginalPath).then(() => true).catch(() => false) + expect(pngExists).toBe(false) + + // Verify cached files exist + const cacheDir = path.join(UPLOADS_DIR, '.cache') + for (const width of [320, 640, 1024, 1600]) { + for (const format of ['avif', 'webp']) { + const cachePath = path.join(cacheDir, `${testUuid}_w${width}.${format}`) + const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false) + expect(exists).toBe(true) + } + } + + // Verify webp original exists + const webpExists = await fs.promises.access(path.join(UPLOADS_DIR, `${testUuid}.webp`)).then(() => true).catch(() => false) + expect(webpExists).toBe(true) + }) +}) From cf6b5da4fc000d657c9621f18a7cb306d6283022 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 17 May 2026 18:03:32 +0500 Subject: [PATCH 06/28] feat(client): add isResized type, uploadGalleryImages, resizeGalleryImage API --- .../src/entities/gallery/api/gallery-api.ts | 41 +++++++++++++++++++ client/src/entities/gallery/index.ts | 2 +- client/src/entities/gallery/model/types.ts | 1 + shared/constants/upload-limits.d.ts | 4 ++ shared/constants/upload-limits.js | 6 +++ 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/client/src/entities/gallery/api/gallery-api.ts b/client/src/entities/gallery/api/gallery-api.ts index 8838fe8..4d4b15d 100644 --- a/client/src/entities/gallery/api/gallery-api.ts +++ b/client/src/entities/gallery/api/gallery-api.ts @@ -1,5 +1,7 @@ import type { GalleryImageItem } from '@/entities/gallery/model/types' import { apiClient } from '@/shared/api/client' +import { apiBaseURL } from '@/shared/config' +import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> { const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery') @@ -9,3 +11,42 @@ export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] } export async function deleteGalleryImage(id: string): Promise { await apiClient.delete(`admin/gallery/${id}`) } + +export async function uploadGalleryImages(files: File[]): Promise { + for (const f of files) { + if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) { + throw new Error( + `Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`, + ) + } + } + const fd = new FormData() + for (const f of files) { + fd.append('files', f, f.name) + } + const token = localStorage.getItem('craftshop_auth_token') + const base = apiBaseURL.replace(/\/$/, '') + const res = await fetch(`${base}/admin/gallery/upload`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: fd, + }) + const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string } + if (!res.ok) { + if (res.status === 413) { + throw new Error( + 'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.', + ) + } + throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`) + } + if (!Array.isArray(payload.urls)) { + throw new Error('Некорректный ответ сервера') + } + return payload.urls +} + +export async function resizeGalleryImage(id: string): Promise<{ url: string }> { + const { data } = await apiClient.post<{ url: string }>(`admin/gallery/${id}/resize`) + return data +} diff --git a/client/src/entities/gallery/index.ts b/client/src/entities/gallery/index.ts index 7fa845f..9f9114c 100644 --- a/client/src/entities/gallery/index.ts +++ b/client/src/entities/gallery/index.ts @@ -1,3 +1,3 @@ -export { fetchAdminGallery, deleteGalleryImage } from './api/gallery-api' +export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api' export type { GalleryImageItem } from './model/types' export { GalleryGrid } from './ui/GalleryGrid' diff --git a/client/src/entities/gallery/model/types.ts b/client/src/entities/gallery/model/types.ts index 7fe0189..922ce43 100644 --- a/client/src/entities/gallery/model/types.ts +++ b/client/src/entities/gallery/model/types.ts @@ -1,5 +1,6 @@ export type GalleryImageItem = { id: string url: string + isResized: boolean createdAt: string } diff --git a/shared/constants/upload-limits.d.ts b/shared/constants/upload-limits.d.ts index 8f9caf1..88f7e54 100644 --- a/shared/constants/upload-limits.d.ts +++ b/shared/constants/upload-limits.d.ts @@ -1 +1,5 @@ export declare const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT: 20971520 + +export declare const ADMIN_UPLOAD_IMAGE_MAX_BYTES: 20971520 + +export declare function formatAdminImageMaxSizeHint(): string diff --git a/shared/constants/upload-limits.js b/shared/constants/upload-limits.js index 5c020a3..ffea1d9 100644 --- a/shared/constants/upload-limits.js +++ b/shared/constants/upload-limits.js @@ -1,3 +1,9 @@ const MB = 1024 * 1024 export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = 20 * MB + +export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * MB + +export function formatAdminImageMaxSizeHint() { + return '20 МБ' +} From 5411f8ae24dadff4aedac704bac87b2773a37e7f Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 17 May 2026 18:09:12 +0500 Subject: [PATCH 07/28] feat(client): add resize button and status badge to GalleryGrid --- .../src/entities/gallery/ui/GalleryGrid.tsx | 74 ++++++++++++++----- .../admin-gallery/ui/AdminGalleryPage.tsx | 17 ++++- 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/client/src/entities/gallery/ui/GalleryGrid.tsx b/client/src/entities/gallery/ui/GalleryGrid.tsx index ab65f09..ad9bb83 100644 --- a/client/src/entities/gallery/ui/GalleryGrid.tsx +++ b/client/src/entities/gallery/ui/GalleryGrid.tsx @@ -1,5 +1,8 @@ +import AutoFixHighOutlinedIcon from '@mui/icons-material/AutoFixHighOutlined' +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined' import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined' import Box from '@mui/material/Box' +import Chip from '@mui/material/Chip' import IconButton from '@mui/material/IconButton' import Tooltip from '@mui/material/Tooltip' import { OptimizedImage } from '@/shared/ui/OptimizedImage' @@ -8,10 +11,12 @@ import type { GalleryImageItem } from '../model/types' type Props = { items: GalleryImageItem[] deleting?: boolean + resizing?: string | null onDelete: (id: string) => void + onResize: (id: string) => void } -export function GalleryGrid({ items, deleting, onDelete }: Props) { +export function GalleryGrid({ items, deleting, resizing, onDelete, onResize }: Props) { return ( - - onDelete(item.id)} - > - - - + + {item.isResized ? ( + } + sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }} + /> + ) : ( + + )} + + + {!item.isResized && ( + + onResize(item.id)} + > + + + + )} + + onDelete(item.id)} + > + + + + ))} diff --git a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx index 271ff37..9163b27 100644 --- a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx +++ b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx @@ -6,7 +6,7 @@ import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { fetchAdminCatalogSlider } from '@/entities/catalog-slider' -import { deleteGalleryImage, fetchAdminGallery, GalleryGrid } from '@/entities/gallery' +import { deleteGalleryImage, fetchAdminGallery, GalleryGrid, resizeGalleryImage } from '@/entities/gallery' import { uploadAdminProductImages } from '@/entities/product/api/product-api' import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' @@ -50,6 +50,13 @@ export function AdminGalleryPage() { }, }) + const resizeMut = useMutation({ + mutationFn: (id: string) => resizeGalleryImage(id), + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']]) + }, + }) + const items = galleryQuery.data?.items ?? [] return ( @@ -122,7 +129,13 @@ export function AdminGalleryPage() { )} - deleteMut.mutate(id)} /> + deleteMut.mutate(id)} + onResize={(id) => resizeMut.mutate(id)} + /> {!galleryQuery.isLoading && items.length === 0 && ( Пока нет загруженных изображений. From 35dee985f727efac3039849d79eaeff22b15775d Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 17 May 2026 18:15:07 +0500 Subject: [PATCH 08/28] feat(client): complete AdminGalleryPage with new upload and resize UI --- .../admin-gallery/ui/AdminGalleryPage.tsx | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx index 9163b27..5943cef 100644 --- a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx +++ b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react' +import { useRef, useState } from 'react' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Divider from '@mui/material/Divider' @@ -6,8 +6,13 @@ import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { fetchAdminCatalogSlider } from '@/entities/catalog-slider' -import { deleteGalleryImage, fetchAdminGallery, GalleryGrid, resizeGalleryImage } from '@/entities/gallery' -import { uploadAdminProductImages } from '@/entities/product/api/product-api' +import { + deleteGalleryImage, + fetchAdminGallery, + GalleryGrid, + resizeGalleryImage, + uploadGalleryImages, +} from '@/entities/gallery' import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { GallerySliderSection } from './GallerySliderSection' @@ -22,6 +27,7 @@ function getApiErrorMessage(error: unknown): string | null { export function AdminGalleryPage() { const queryClient = useQueryClient() const fileInputRef = useRef(null) + const [resizingId, setResizingId] = useState(null) const sliderQuery = useQuery({ queryKey: ['admin', 'catalog-slider'], @@ -34,7 +40,7 @@ export function AdminGalleryPage() { }) const uploadMut = useMutation({ - mutationFn: (files: File[]) => uploadAdminProductImages(files), + mutationFn: (files: File[]) => uploadGalleryImages(files), onSuccess: () => { void invalidateQueryKeys(queryClient, [['admin', 'gallery']]) if (fileInputRef.current) { @@ -51,7 +57,14 @@ export function AdminGalleryPage() { }) const resizeMut = useMutation({ - mutationFn: (id: string) => resizeGalleryImage(id), + mutationFn: async (id: string) => { + setResizingId(id) + try { + return await resizeGalleryImage(id) + } finally { + setResizingId(null) + } + }, onSuccess: () => { void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']]) }, @@ -65,8 +78,8 @@ export function AdminGalleryPage() { Галерея - Изображения без привязки к товару можно загружать здесь; их же можно добавить в карточку товара через «Из - галереи». Удаление из списка стирает файл с диска, если оно не используется в товаре. + Изображения загружаются без обработки. После загрузки нажмите «Resize» для подготовки к публикации. Обработанные + изображения доступны для добавления в карточку товара и слайдер. {sliderQuery.isError && ( @@ -121,6 +134,9 @@ export function AdminGalleryPage() { {deleteMut.isError && ( {getApiErrorMessage(deleteMut.error) ?? 'Ошибка удаления'} )} + {resizeMut.isError && ( + {getApiErrorMessage(resizeMut.error) ?? 'Ошибка обработки'} + )} {galleryQuery.isError && ( @@ -132,7 +148,7 @@ export function AdminGalleryPage() { deleteMut.mutate(id)} onResize={(id) => resizeMut.mutate(id)} /> From f0365d0b984e34fb5e489ecd8481124b60273308 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 17 May 2026 18:17:27 +0500 Subject: [PATCH 09/28] feat(client): remove direct upload from product form, filter gallery to resized --- .../admin-products/ui/AdminProductsPage.tsx | 104 +++++++----------- 1 file changed, 41 insertions(+), 63 deletions(-) diff --git a/client/src/pages/admin-products/ui/AdminProductsPage.tsx b/client/src/pages/admin-products/ui/AdminProductsPage.tsx index ac71b31..1f73960 100644 --- a/client/src/pages/admin-products/ui/AdminProductsPage.tsx +++ b/client/src/pages/admin-products/ui/AdminProductsPage.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -31,10 +31,8 @@ import { fetchAdminProducts, fetchCategories, updateProduct, - uploadAdminProductImages, } from '@/entities/product/api/product-api' import type { Category, Product } from '@/entities/product/model/types' -import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' import { formatPriceRub } from '@/shared/lib/format-price' import { getErrorMessage } from '@/shared/lib/get-error-message' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' @@ -203,20 +201,7 @@ export function AdminProductsPage() { else createMut.mutate() } - const productImagesInputRef = useRef(null) - - const uploadImagesMut = useMutation({ - mutationFn: (picked: File[]) => uploadAdminProductImages(picked), - onSuccess: (urls) => { - const current = productForm.getValues('imageUrls') - productForm.setValue('imageUrls', [...current, ...urls], { shouldDirty: true }) - if (productImagesInputRef.current) { - productImagesInputRef.current.value = '' - } - }, - }) - - const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error ?? uploadImagesMut.error + const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error const removeImage = (url: string) => { const current = productForm.getValues('imageUrls') @@ -401,11 +386,11 @@ export function AdminProductsPage() { /> - Фото (загрузка) + Фото (из галереи) - PNG, JPEG или WebP, до {formatAdminImageMaxSizeHint()} на файл. Крестик на превью убирает фото только из - карточки; файл остаётся на сервере и в галерее. + Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл + остаётся на сервере и в галерее. - - {uploadImagesMut.isPending && Загрузка…} - {uploadImagesMut.isError && Не удалось загрузить фото} {productForm.watch('imageUrls').length > 0 && ( @@ -558,6 +526,14 @@ export function AdminProductsPage() { {galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && ( В галерее пока нет файлов. Загрузите их в разделе «Галерея». )} + {galleryForPickQuery.data && + galleryForPickQuery.data.items.length > 0 && + galleryForPickQuery.data.items.filter((i) => i.isResized).length === 0 && + !galleryForPickQuery.isLoading && ( + + В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея». + + )} - {(galleryForPickQuery.data?.items ?? []).map((item) => { - const alreadyInCard = productForm.watch('imageUrls').includes(item.url) - return ( - toggleGalleryPickUrl(item.url)} - /> - } - label={ - - item.isResized) + .map((item) => { + const alreadyInCard = productForm.watch('imageUrls').includes(item.url) + return ( + toggleGalleryPickUrl(item.url)} /> - - } - /> - ) - })} + } + label={ + + + + } + /> + ) + })} From d18546c45aba9c5f0d40ae3f3942b6aa545c745d Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 17 May 2026 18:20:57 +0500 Subject: [PATCH 10/28] feat(client): slider picker shows only resized images chore(server): remove unused gallery.js --- .../pages/admin-gallery/ui/GallerySliderSection.tsx | 2 +- server/src/lib/gallery.js | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 server/src/lib/gallery.js diff --git a/client/src/pages/admin-gallery/ui/GallerySliderSection.tsx b/client/src/pages/admin-gallery/ui/GallerySliderSection.tsx index c702b2a..c28f9fb 100644 --- a/client/src/pages/admin-gallery/ui/GallerySliderSection.tsx +++ b/client/src/pages/admin-gallery/ui/GallerySliderSection.tsx @@ -31,7 +31,7 @@ export function GallerySliderSection({ initialSlides, galleryItems }: Props) { const [pickOpen, setPickOpen] = useState(false) const usedIds = new Set(sliderDraft.map((s) => s.galleryImageId)) - const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id)) + const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id) && i.isResized) const saveSliderMut = useMutation({ mutationFn: () => putAdminCatalogSlider({ slides: sliderDraft }), diff --git a/server/src/lib/gallery.js b/server/src/lib/gallery.js deleted file mode 100644 index 732eda4..0000000 --- a/server/src/lib/gallery.js +++ /dev/null @@ -1,12 +0,0 @@ -import { prisma } from './prisma.js' - -/** Регистрация загруженных путей в медиатеке (идемпотентно). */ -export async function upsertGalleryImagesByUrls(urls) { - for (const url of urls) { - await prisma.galleryImage.upsert({ - where: { url }, - create: { url }, - update: {}, - }) - } -} From 4816d098da4bad01d0fde87bbe2153e74bda8409 Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:17:02 +0500 Subject: [PATCH 11/28] feat: add notification system database models --- .../migration.sql | 54 +++++++++++++++++++ .../migration.sql | 2 + server/prisma/schema.prisma | 50 +++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 server/prisma/migrations/20260518061700_add_notification_system/migration.sql create mode 100644 server/prisma/migrations/20260518062042_fix_notification_schema/migration.sql diff --git a/server/prisma/migrations/20260518061700_add_notification_system/migration.sql b/server/prisma/migrations/20260518061700_add_notification_system/migration.sql new file mode 100644 index 0000000..c780066 --- /dev/null +++ b/server/prisma/migrations/20260518061700_add_notification_system/migration.sql @@ -0,0 +1,54 @@ +-- CreateTable +CREATE TABLE "NotificationPreference" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "globalEnabled" BOOLEAN NOT NULL DEFAULT true, + "orderCreated" BOOLEAN NOT NULL DEFAULT true, + "orderStatusChanged" BOOLEAN NOT NULL DEFAULT true, + "orderMessageReceived" BOOLEAN NOT NULL DEFAULT true, + "paymentStatusChanged" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AdminNotificationSettings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "emailEnabled" BOOLEAN NOT NULL DEFAULT true, + "telegramEnabled" BOOLEAN NOT NULL DEFAULT false, + "telegramChatId" TEXT, + "newOrder" BOOLEAN NOT NULL DEFAULT true, + "newOrderMessage" BOOLEAN NOT NULL DEFAULT true, + "newReview" BOOLEAN NOT NULL DEFAULT true, + "authCodeDuplicate" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "NotificationLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT, + "eventType" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "status" TEXT NOT NULL, + "error" TEXT, + "payload" TEXT NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId"); + +-- CreateIndex +CREATE INDEX "NotificationPreference_userId_idx" ON "NotificationPreference"("userId"); + +-- CreateIndex +CREATE INDEX "NotificationLog_status_createdAt_idx" ON "NotificationLog"("status", "createdAt"); + +-- CreateIndex +CREATE INDEX "NotificationLog_userId_createdAt_idx" ON "NotificationLog"("userId", "createdAt"); diff --git a/server/prisma/migrations/20260518062042_fix_notification_schema/migration.sql b/server/prisma/migrations/20260518062042_fix_notification_schema/migration.sql new file mode 100644 index 0000000..23e6a7a --- /dev/null +++ b/server/prisma/migrations/20260518062042_fix_notification_schema/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "NotificationPreference_userId_idx"; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index af2366e..8cf163b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -90,6 +90,8 @@ model User { reviews Review[] orderMessageReadStates UserOrderMessageReadState[] oauthAccounts OAuthAccount[] + notificationPreference NotificationPreference? + notificationLogs NotificationLog[] } /// Прочитанность чата по заказу (для сообщений от админа после lastReadAt) @@ -269,3 +271,51 @@ model InfoPageBlock { @@index([published, sort]) } + +/// Настройки оповещений пользователя +model NotificationPreference { + id String @id @default(cuid()) + userId String @unique + globalEnabled Boolean @default(true) + orderCreated Boolean @default(true) + orderStatusChanged Boolean @default(true) + orderMessageReceived Boolean @default(true) + paymentStatusChanged Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +/// Настройки оповещений админа +model AdminNotificationSettings { + id String @id @default(cuid()) + emailEnabled Boolean @default(true) + telegramEnabled Boolean @default(false) + telegramChatId String? + newOrder Boolean @default(true) + newOrderMessage Boolean @default(true) + newReview Boolean @default(true) + authCodeDuplicate Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +/// Лог отправки оповещений +model NotificationLog { + id String @id @default(cuid()) + userId String? + eventType String + channel String + status String + error String? + payload String + attempts Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([status, createdAt]) + @@index([userId, createdAt]) +} From dcbcb42acdf6aa5d1cedc8253c5dd23059e01465 Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:22:17 +0500 Subject: [PATCH 12/28] feat: add notification event type constants --- shared/constants/notification-events.d.ts | 34 +++++++++++++++++++++++ shared/constants/notification-events.js | 25 +++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 shared/constants/notification-events.d.ts create mode 100644 shared/constants/notification-events.js diff --git a/shared/constants/notification-events.d.ts b/shared/constants/notification-events.d.ts new file mode 100644 index 0000000..1eba015 --- /dev/null +++ b/shared/constants/notification-events.d.ts @@ -0,0 +1,34 @@ +export type NotificationEventType = + | 'order:created' + | 'order:statusChanged' + | 'orderMessage:sent' + | 'orderMessage:adminReply' + | 'payment:statusChanged' + | 'auth:codeRequested' + +export type NotificationChannel = 'email' | 'telegram' + +export type NotificationStatus = 'pending' | 'sent' | 'failed' + +export const NOTIFICATION_EVENTS: { + ORDER_CREATED: NotificationEventType + ORDER_STATUS_CHANGED: NotificationEventType + ORDER_MESSAGE_SENT: NotificationEventType + ORDER_MESSAGE_ADMIN_REPLY: NotificationEventType + PAYMENT_STATUS_CHANGED: NotificationEventType + AUTH_CODE_REQUESTED: NotificationEventType +} + +export const NOTIFICATION_CHANNELS: { + EMAIL: NotificationChannel + TELEGRAM: NotificationChannel +} + +export const NOTIFICATION_STATUSES: { + PENDING: NotificationStatus + SENT: NotificationStatus + FAILED: NotificationStatus +} + +export const MAX_RETRY_ATTEMPTS: number +export const RETRY_DELAYS_MS: number[] diff --git a/shared/constants/notification-events.js b/shared/constants/notification-events.js new file mode 100644 index 0000000..82d5578 --- /dev/null +++ b/shared/constants/notification-events.js @@ -0,0 +1,25 @@ +/** @typedef {'order:created' | 'order:statusChanged' | 'orderMessage:sent' | 'orderMessage:adminReply' | 'payment:statusChanged' | 'auth:codeRequested'} NotificationEventType */ + +export const NOTIFICATION_EVENTS = { + ORDER_CREATED: 'order:created', + ORDER_STATUS_CHANGED: 'order:statusChanged', + ORDER_MESSAGE_SENT: 'orderMessage:sent', + ORDER_MESSAGE_ADMIN_REPLY: 'orderMessage:adminReply', + PAYMENT_STATUS_CHANGED: 'payment:statusChanged', + AUTH_CODE_REQUESTED: 'auth:codeRequested', +} + +export const NOTIFICATION_CHANNELS = { + EMAIL: 'email', + TELEGRAM: 'telegram', +} + +export const NOTIFICATION_STATUSES = { + PENDING: 'pending', + SENT: 'sent', + FAILED: 'failed', +} + +export const MAX_RETRY_ATTEMPTS = 3 + +export const RETRY_DELAYS_MS = [5_000, 30_000, 120_000] From 09ada62dafb8b04cb9a71e59610f624f68a7031b Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:24:01 +0500 Subject: [PATCH 13/28] feat: add notification event bus --- server/src/lib/notifications/event-bus.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 server/src/lib/notifications/event-bus.js diff --git a/server/src/lib/notifications/event-bus.js b/server/src/lib/notifications/event-bus.js new file mode 100644 index 0000000..b3dbbf3 --- /dev/null +++ b/server/src/lib/notifications/event-bus.js @@ -0,0 +1,7 @@ +import { EventEmitter } from 'node:events' + +export function createEventBus() { + const bus = new EventEmitter() + bus.setMaxListeners(50) + return bus +} From 86f8569840eb6e26854f130012a4f50cf268332c Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:25:46 +0500 Subject: [PATCH 14/28] feat: add email templates for notifications --- .../templates/email-templates.js | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 server/src/lib/notifications/templates/email-templates.js diff --git a/server/src/lib/notifications/templates/email-templates.js b/server/src/lib/notifications/templates/email-templates.js new file mode 100644 index 0000000..65c5790 --- /dev/null +++ b/server/src/lib/notifications/templates/email-templates.js @@ -0,0 +1,96 @@ +function baseLayout(title, body) { + return ` + +${title} + +
+

${title}

+
+ ${body} +
+

Craftshop — магазин handmade изделий

+
+ +` +} + +export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + const body = ` +

Ваш заказ #${orderId.slice(0, 8)} успешно создан.

+

Товаров: ${itemsCount} | Сумма: ${total} ₽

+

Мы сообщим вам об изменениях статуса.

+ ` + return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) } +} + +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 + const body = ` +

Статус заказа #${orderId.slice(0, 8)} изменён.

+

${oldLabel}${newLabel}

+ ` + return { subject: `Статус заказа изменён — ${newLabel}`, html: baseLayout('Статус заказа изменён', body) } +} + +export function renderOrderMessageEmail({ orderId, preview }) { + const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview + const body = ` +

Новое сообщение к заказу #${orderId.slice(0, 8)}:

+
+ ${truncated} +
+

Ответьте в личном кабинете.

+ ` + return { subject: 'Новое сообщение к заказу', html: baseLayout('Новое сообщение', body) } +} + +export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) { + const statusLabels = { + pending: 'Ожидает', + confirmed: 'Подтверждён', + rejected: 'Отклонён', + } + const label = statusLabels[paymentStatus] || paymentStatus + const body = ` +

Статус оплаты заказа #${orderId.slice(0, 8)}: ${label}.

+ ` + return { subject: `Оплата заказа — ${label}`, html: baseLayout('Оплата заказа', body) } +} + +export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + const body = ` +

Новый заказ #${orderId.slice(0, 8)} от ${userEmail}.

+

Товаров: ${itemsCount} | Сумма: ${total} ₽

+ ` + return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) } +} + +export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) { + const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating) + const body = ` +

Новый отзыв ${stars} на товар ${productTitle} от ${userName}.

+ ${text ? `
${text}
` : ''} +

Проверьте отзыв в админ-панели.

+ ` + return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) } +} + +export function renderAuthCodeEmail({ code }) { + const body = ` +

Ваш код входа: ${code}

+

Если это были не вы — просто проигнорируйте письмо.

+ ` + return { subject: 'Код входа', html: baseLayout('Код входа', body) } +} From 79c85b0a88a1a2e36f767a301d10e6fe13a55118 Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:26:52 +0500 Subject: [PATCH 15/28] feat: add Telegram message templates --- .../templates/telegram-templates.js | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 server/src/lib/notifications/templates/telegram-templates.js diff --git a/server/src/lib/notifications/templates/telegram-templates.js b/server/src/lib/notifications/templates/telegram-templates.js new file mode 100644 index 0000000..e4e5120 --- /dev/null +++ b/server/src/lib/notifications/templates/telegram-templates.js @@ -0,0 +1,36 @@ +export function renderOrderCreatedTg({ orderId, totalCents, itemsCount }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + return `📦 Новый заказ #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total} ₽` +} + +export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) { + const labels = { + DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', IN_PROGRESS: 'В работе', + READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён', + } + return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → ${labels[newStatus] || newStatus}` +} + +export function renderOrderMessageTg({ orderId, preview }) { + const truncated = preview.length > 300 ? preview.slice(0, 297) + '...' : preview + return `💬 Сообщение к заказу #${orderId.slice(0, 8)}\n\n${truncated}` +} + +export function renderPaymentStatusChangedTg({ orderId, paymentStatus }) { + const labels = { pending: 'Ожидает', confirmed: 'Подтверждён', rejected: 'Отклонён' } + return `💳 Оплата заказа #${orderId.slice(0, 8)}: ${labels[paymentStatus] || paymentStatus}` +} + +export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + return `🛒 Новый заказ #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total} ₽` +} + +export function renderAdminNewReviewTg({ rating, text, productTitle, userName }) { + const stars = '⭐'.repeat(rating) + return `📝 Новый отзыв ${stars}\nТовар: ${productTitle}\nАвтор: ${userName}${text ? '\n\n' + text : ''}` +} + +export function renderAuthCodeTg({ code }) { + return `🔐 Код входа: ${code}` +} From 8f3d1ae5efe34029d1fa602d55ebbc4ba682c2d1 Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:28:46 +0500 Subject: [PATCH 16/28] feat: add email notification channel --- server/src/lib/email.js | 39 +++++++++++++++---- .../notifications/channels/email-channel.js | 37 ++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 server/src/lib/notifications/channels/email-channel.js diff --git a/server/src/lib/email.js b/server/src/lib/email.js index 97b4c9d..242091e 100644 --- a/server/src/lib/email.js +++ b/server/src/lib/email.js @@ -4,14 +4,8 @@ function hasSmtpEnv() { return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS) } -export async function sendLoginCodeEmail({ to, code }) { - if (!hasSmtpEnv()) { - // dev fallback - console.log(`[DEV] login code for ${to}: ${code}`) - return - } - - const transporter = nodemailer.createTransport({ +function createTransporter() { + return nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT), secure: process.env.SMTP_SECURE === 'true', @@ -20,7 +14,15 @@ export async function sendLoginCodeEmail({ to, code }) { pass: process.env.SMTP_PASS, }, }) +} +export async function sendLoginCodeEmail({ to, code }) { + if (!hasSmtpEnv()) { + console.log(`[DEV] login code for ${to}: ${code}`) + return + } + + const transporter = createTransporter() const from = process.env.MAIL_FROM || process.env.SMTP_USER await transporter.sendMail({ @@ -31,3 +33,24 @@ export async function sendLoginCodeEmail({ to, code }) { }) } +export async function sendNotificationEmail({ to, subject, html }) { + if (!hasSmtpEnv()) { + console.log(`[DEV] notification email to ${to}: ${subject}`) + return { success: true } + } + + try { + const transporter = createTransporter() + const from = process.env.MAIL_FROM || process.env.SMTP_USER + + await transporter.sendMail({ + from, + to, + subject, + html, + }) + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } +} diff --git a/server/src/lib/notifications/channels/email-channel.js b/server/src/lib/notifications/channels/email-channel.js new file mode 100644 index 0000000..8911ea3 --- /dev/null +++ b/server/src/lib/notifications/channels/email-channel.js @@ -0,0 +1,37 @@ +// server/src/lib/notifications/channels/email-channel.js +import { sendNotificationEmail } from '../../email.js' +import { + renderOrderCreatedEmail, + renderOrderStatusChangedEmail, + renderOrderMessageEmail, + renderPaymentStatusChangedEmail, + renderAdminOrderCreatedEmail, + renderAdminNewReviewEmail, + renderAuthCodeEmail, +} from '../templates/email-templates.js' + +const templateRenderers = { + 'order:created': renderOrderCreatedEmail, + 'order:statusChanged': renderOrderStatusChangedEmail, + 'orderMessage:adminReply': renderOrderMessageEmail, + 'payment:statusChanged': renderPaymentStatusChangedEmail, + 'order:created:admin': renderAdminOrderCreatedEmail, + 'orderMessage:sent': renderOrderMessageEmail, + 'review:created': renderAdminNewReviewEmail, + 'auth:codeRequested': renderAuthCodeEmail, +} + +export const emailChannel = { + name: 'email', + + async send({ recipient, eventType, payload }) { + const renderer = templateRenderers[eventType] + if (!renderer) { + return { success: false, error: `No email template for event: ${eventType}` } + } + + const { subject, html } = renderer(payload) + const result = await sendNotificationEmail({ to: recipient, subject, html }) + return result + }, +} From e0a045d5df8fbc4c79500b297827ae92ae2e4caa Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:29:58 +0500 Subject: [PATCH 17/28] feat: add Telegram notification channel --- .../channels/telegram-channel.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 server/src/lib/notifications/channels/telegram-channel.js diff --git a/server/src/lib/notifications/channels/telegram-channel.js b/server/src/lib/notifications/channels/telegram-channel.js new file mode 100644 index 0000000..9d324f1 --- /dev/null +++ b/server/src/lib/notifications/channels/telegram-channel.js @@ -0,0 +1,67 @@ +import { + renderOrderCreatedTg, + renderOrderStatusChangedTg, + renderOrderMessageTg, + renderPaymentStatusChangedTg, + renderAdminOrderCreatedTg, + renderAdminNewReviewTg, + renderAuthCodeTg, +} from '../templates/telegram-templates.js' + +const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || '' + +const templateRenderers = { + 'order:created': renderOrderCreatedTg, + 'order:statusChanged': renderOrderStatusChangedTg, + 'orderMessage:adminReply': renderOrderMessageTg, + 'payment:statusChanged': renderPaymentStatusChangedTg, + 'order:created:admin': renderAdminOrderCreatedTg, + 'orderMessage:sent': renderOrderMessageTg, + 'review:created': renderAdminNewReviewTg, + 'auth:codeRequested': renderAuthCodeTg, +} + +async function postToTelegram(chatId, text) { + if (!TELEGRAM_BOT_TOKEN) { + console.log(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`) + return { success: true } + } + + try { + const res = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text, + parse_mode: 'HTML', + }), + }) + + const data = await res.json() + if (!data.ok) { + return { success: false, error: data.description || 'Telegram API error' } + } + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } +} + +export const telegramChannel = { + name: 'telegram', + + async send({ recipient: chatId, eventType, payload }) { + if (!chatId) { + return { success: false, error: 'No telegram chatId' } + } + + const renderer = templateRenderers[eventType] + if (!renderer) { + return { success: false, error: `No telegram template for event: ${eventType}` } + } + + const text = renderer(payload) + return postToTelegram(chatId, text) + }, +} From 4a424b68a2e7472e7e4b8302dd56acd6e0ddf19d Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:31:16 +0500 Subject: [PATCH 18/28] feat: add notification preferences resolver --- server/src/lib/notifications/preferences.js | 100 ++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 server/src/lib/notifications/preferences.js diff --git a/server/src/lib/notifications/preferences.js b/server/src/lib/notifications/preferences.js new file mode 100644 index 0000000..7ae2e15 --- /dev/null +++ b/server/src/lib/notifications/preferences.js @@ -0,0 +1,100 @@ +import { prisma } from '../prisma.js' +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' + +const { + ORDER_CREATED, + ORDER_STATUS_CHANGED, + ORDER_MESSAGE_SENT, + ORDER_MESSAGE_ADMIN_REPLY, + PAYMENT_STATUS_CHANGED, + AUTH_CODE_REQUESTED, +} = NOTIFICATION_EVENTS + +const userEventFieldMap = { + [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', +} + +export async function resolveUserNotificationTargets(eventType, payload) { + const targets = [] + + if (payload.userId) { + const prefs = await prisma.notificationPreference.findUnique({ + where: { userId: payload.userId }, + }) + + if (prefs && prefs.globalEnabled) { + 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 }) + } + } + } + } + + return targets +} + +export async function resolveAdminNotificationTargets(eventType, payload) { + const targets = [] + const settings = await prisma.adminNotificationSettings.findFirst() + if (!settings) return targets + + const field = adminEventFieldMap[eventType] + if (field === 'newReview') { + if (!settings.newReview) return targets + } else if (field && !settings[field]) { + 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 }) + } + } + + if (settings.telegramEnabled && settings.telegramChatId) { + targets.push({ channel: 'telegram', recipient: settings.telegramChatId }) + } + + return targets +} + +export async function resolveAuthCodeTargets(eventType, payload) { + const targets = [] + + if (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 }) + } + } + + return targets +} + +export async function ensureUserNotificationPreference(userId) { + const existing = await prisma.notificationPreference.findUnique({ where: { userId } }) + if (existing) return existing + return prisma.notificationPreference.create({ + data: { userId, globalEnabled: true }, + }) +} From 3f83a9be8e5e0b54e6a6587075264eb4fe3c5ef8 Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:33:06 +0500 Subject: [PATCH 19/28] feat: add notification queue with retry worker --- server/src/lib/notifications/queue.js | 130 ++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 server/src/lib/notifications/queue.js diff --git a/server/src/lib/notifications/queue.js b/server/src/lib/notifications/queue.js new file mode 100644 index 0000000..6658069 --- /dev/null +++ b/server/src/lib/notifications/queue.js @@ -0,0 +1,130 @@ +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' + +const { PENDING, SENT, FAILED } = NOTIFICATION_STATUSES + +const channels = { + email: emailChannel, + telegram: telegramChannel, +} + +class NotificationQueue { + constructor() { + this.tasks = [] + this.processing = 0 + this.maxConcurrent = 5 + this.intervalMs = 2000 + this.running = false + } + + enqueue(task) { + this.tasks.push({ ...task, enqueuedAt: Date.now() }) + } + + start() { + if (this.running) return + this.running = true + this._tick() + } + + stop() { + this.running = false + } + + _tick() { + if (!this.running) return + + this._processAvailable() + + setTimeout(() => this._tick(), this.intervalMs) + } + + _processAvailable() { + while (this.tasks.length > 0 && this.processing < this.maxConcurrent) { + const task = this.tasks.shift() + this.processing++ + this._execute(task).finally(() => { + this.processing-- + }) + } + } + + async _execute(task) { + const channel = channels[task.channel] + if (!channel) { + await this._markFailed(task.logId, `Unknown channel: ${task.channel}`) + return + } + + try { + const result = await channel.send({ + recipient: task.recipient, + eventType: task.eventType, + payload: task.payload, + }) + + if (result.success) { + await this._markSent(task.logId) + } else { + await this._handleFailure(task.logId, task, result.error) + } + } catch (err) { + await this._handleFailure(task.logId, task, err.message) + } + } + + async _markSent(logId) { + await prisma.notificationLog.update({ + where: { id: logId }, + data: { status: SENT }, + }) + } + + async _markFailed(logId, error) { + await prisma.notificationLog.update({ + where: { id: logId }, + data: { status: FAILED, error }, + }) + } + + async _handleFailure(logId, task, error) { + const log = await prisma.notificationLog.findUnique({ where: { id: logId } }) + const newAttempts = (log?.attempts || 0) + 1 + + if (newAttempts >= MAX_RETRY_ATTEMPTS) { + await this._markFailed(logId, error) + return + } + + await prisma.notificationLog.update({ + where: { id: logId }, + data: { attempts: newAttempts }, + }) + + const delay = RETRY_DELAYS_MS[newAttempts - 1] || RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] + setTimeout(() => { + this.enqueue({ ...task, logId }) + }, delay) + } + + async flushPendingOnStartup() { + const pending = await prisma.notificationLog.findMany({ + where: { status: PENDING }, + }) + for (const log of pending) { + await prisma.notificationLog.update({ + where: { id: log.id }, + data: { status: FAILED, error: 'Server restarted, pending notification lost' }, + }) + } + if (pending.length > 0) { + console.log(`[notifications] Marked ${pending.length} pending notifications as failed on startup`) + } + } +} + +export function createNotificationQueue() { + return new NotificationQueue() +} From e73a0ae09aeb439bcba89ce44f6328bb24784358 Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:36:19 +0500 Subject: [PATCH 20/28] feat: wire up notification system in server --- server/src/index.js | 76 ++++++++++++++++- server/src/routes/api.js | 2 + server/src/routes/api/admin/notifications.js | 89 ++++++++++++++++++++ server/src/routes/user/notifications.js | 38 +++++++++ 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 server/src/routes/api/admin/notifications.js create mode 100644 server/src/routes/user/notifications.js diff --git a/server/src/index.js b/server/src/index.js index c5bf3be..4e8e07e 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -8,6 +8,15 @@ 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' @@ -16,6 +25,7 @@ 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' @@ -42,7 +52,6 @@ await fastify.register(jwt, { await fastify.register(multipart, { limits: { files: 10, - /** Совпадает с лимитом одного файла для `POST /api/admin/gallery/upload` (галерея). */ fileSize: getProductImageMaxFileBytes(), }, }) @@ -70,6 +79,11 @@ fastify.decorate('authenticate', async function authenticate(request, reply) { } }) +const eventBus = createEventBus() +const notificationQueue = createNotificationQueue() +fastify.decorate('eventBus', eventBus) +fastify.decorate('notificationQueue', notificationQueue) + registerAuth(fastify) await registerAuthRoutes(fastify) await registerUserAddressRoutes(fastify) @@ -77,12 +91,70 @@ 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() -fastify.get('/health', async () => ({ ok: true })) +await notificationQueue.flushPendingOnStartup() +notificationQueue.start() + +const { + ORDER_CREATED, + ORDER_STATUS_CHANGED, + ORDER_MESSAGE_SENT, + ORDER_MESSAGE_ADMIN_REPLY, + PAYMENT_STATUS_CHANGED, + AUTH_CODE_REQUESTED, +} = NOTIFICATION_EVENTS + +async function dispatchNotification(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', + payload: JSON.stringify(payload), + }, + }) + notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }) + } + + 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', + payload: JSON.stringify(payload), + }, + }) + 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) + +async function shutdown() { + notificationQueue.stop() + await fastify.close() + process.exit(0) +} +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) try { await fastify.listen({ port, host: '0.0.0.0' }) diff --git a/server/src/routes/api.js b/server/src/routes/api.js index b0c30c7..ebeac37 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -10,6 +10,7 @@ import { registerAdminOrderRoutes } from './api/admin-orders.js' import { registerAdminProductRoutes } from './api/admin-products.js' import { registerAdminReviewRoutes } from './api/admin-reviews.js' import { registerAdminUserRoutes } from './api/admin-users.js' +import { registerAdminNotificationRoutes } from './api/admin/notifications.js' import { registerInfoPageRoutes } from './api/info-page.js' import { registerPublicCatalogRoutes } from './api/public-catalog.js' import { registerPublicReviewRoutes } from './api/public-reviews.js' @@ -30,5 +31,6 @@ export async function registerApiRoutes(fastify) { await registerAdminOrderRoutes(fastify) await registerAdminReviewRoutes(fastify) await registerAdminUserRoutes(fastify) + await registerAdminNotificationRoutes(fastify) } diff --git a/server/src/routes/api/admin/notifications.js b/server/src/routes/api/admin/notifications.js new file mode 100644 index 0000000..44b5bcd --- /dev/null +++ b/server/src/routes/api/admin/notifications.js @@ -0,0 +1,89 @@ +import { prisma } from '../../lib/prisma.js' + +export async function registerAdminNotificationRoutes(fastify) { + fastify.get( + '/api/admin/notifications/settings', + { preHandler: [fastify.verifyAdmin] }, + async () => { + let settings = await prisma.adminNotificationSettings.findFirst() + if (!settings) { + settings = await prisma.adminNotificationSettings.create({ + data: { + emailEnabled: true, + telegramEnabled: false, + newOrder: true, + newOrderMessage: true, + newReview: true, + authCodeDuplicate: false, + }, + }) + } + return { settings } + }, + ) + + fastify.put( + '/api/admin/notifications/settings', + { preHandler: [fastify.verifyAdmin] }, + async (request) => { + 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) + + if (!settings) { + settings = await prisma.adminNotificationSettings.create({ data }) + } else { + settings = await prisma.adminNotificationSettings.update({ + where: { id: settings.id }, + data, + }) + } + + 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 } + + 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' }, + body: JSON.stringify({ + chat_id: chatId, + text: 'Вы подписаны на уведомления Craftshop.', + }), + }) + } + + return { ok: true } + }, + ) +} diff --git a/server/src/routes/user/notifications.js b/server/src/routes/user/notifications.js new file mode 100644 index 0000000..a984659 --- /dev/null +++ b/server/src/routes/user/notifications.js @@ -0,0 +1,38 @@ +import { prisma } from '../../lib/prisma.js' +import { ensureUserNotificationPreference } from '../../lib/notifications/preferences.js' + +export async function registerUserNotificationRoutes(fastify) { + fastify.get( + '/api/me/notifications/settings', + { preHandler: [fastify.authenticate] }, + async (request) => { + const userId = request.user.sub + const prefs = await ensureUserNotificationPreference(userId) + return { settings: prefs } + }, + ) + + fastify.put( + '/api/me/notifications/settings', + { preHandler: [fastify.authenticate] }, + async (request) => { + const userId = request.user.sub + const body = request.body || {} + + const data = {} + if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled) + if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated) + if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged) + if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived) + if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged) + + const prefs = await prisma.notificationPreference.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + }) + + return { settings: prefs } + }, + ) +} From 84cdccaa17851cdba6df6dc47a0b7848e4df30dc Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:39:02 +0500 Subject: [PATCH 21/28] feat: emit notification events from existing routes --- server/src/lib/auth.js | 1 + server/src/routes/api/admin-orders.js | 17 +++++++++++++++++ server/src/routes/api/admin-reviews.js | 14 +++++++++++++- server/src/routes/auth.js | 20 +++++++++++++++++++- server/src/routes/user-messages.js | 9 +++++++++ server/src/routes/user-orders.js | 18 ++++++++++++++++++ server/src/routes/user-payments.js | 7 +++++++ 7 files changed, 84 insertions(+), 2 deletions(-) diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js index 19a47c4..1191c3f 100644 --- a/server/src/lib/auth.js +++ b/server/src/lib/auth.js @@ -27,6 +27,7 @@ export async function issueEmailCode({ email, purpose, userId = null }) { }, }) await sendLoginCodeEmail({ to: email, code }) + return code } function parseEnvBool(raw) { diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index bc31d89..e033be3 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -1,5 +1,6 @@ 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( @@ -108,6 +109,14 @@ export async function registerAdminOrderRoutes(fastify) { } 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 } }, ) @@ -156,6 +165,14 @@ export async function registerAdminOrderRoutes(fastify) { if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) 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, + }) + return reply.code(201).send({ item: msg }) }, ) diff --git a/server/src/routes/api/admin-reviews.js b/server/src/routes/api/admin-reviews.js index 8b7548f..9a64ade 100644 --- a/server/src/routes/api/admin-reviews.js +++ b/server/src/routes/api/admin-reviews.js @@ -1,4 +1,5 @@ import { prisma } from '../../lib/prisma.js' +import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' export async function registerAdminReviewRoutes(fastify) { fastify.get( @@ -43,7 +44,10 @@ export async function registerAdminReviewRoutes(fastify) { return reply.code(400).send({ error: 'action должен быть approve или reject' }) } - const existing = await prisma.review.findUnique({ where: { id } }) + 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: 'Отзыв не найден' }) const updated = await prisma.review.update({ @@ -53,6 +57,14 @@ export async function registerAdminReviewRoutes(fastify) { moderatedAt: new Date(), }, }) + request.server.eventBus.emit('review:created', { + rating: updated.rating, + text: updated.text || '', + productTitle: existing.product?.title || '', + userName: existing.user?.name || existing.user?.email || '', + reviewId: updated.id, + }) + return { item: updated } }, ) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 2d3e218..41b63ab 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,5 +1,6 @@ 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) @@ -18,7 +19,17 @@ export async function registerAuthRoutes(fastify) { const email = normalizeEmail(request.body?.email) if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - await issueEmailCode({ email, purpose: 'login' }) + const code = await issueEmailCode({ email, purpose: 'login' }) + + 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 } }) @@ -37,6 +48,13 @@ export async function registerAuthRoutes(fastify) { 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) } }) diff --git a/server/src/routes/user-messages.js b/server/src/routes/user-messages.js index 76eb835..bae01ac 100644 --- a/server/src/routes/user-messages.js +++ b/server/src/routes/user-messages.js @@ -1,4 +1,5 @@ import { prisma } from '../lib/prisma.js' +import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' export async function registerUserMessageRoutes(fastify) { fastify.get( @@ -26,6 +27,14 @@ export async function registerUserMessageRoutes(fastify) { 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', + messageId: msg.id, + preview: text, + }) + return reply.code(201).send({ item: msg }) }, ) diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 9089bc5..2822590 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -1,5 +1,6 @@ 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) ---- @@ -156,6 +157,23 @@ export async function registerUserOrderRoutes(fastify) { return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара' }) } + // Emit notification events + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, { + orderId: created.id, + userId, + totalCents: created.totalCents, + itemsCount: cartItems.length, + }) + + // Also emit admin notification + request.server.eventBus.emit('order:created:admin', { + orderId: created.id, + userId, + userEmail: request.user.email || '', + totalCents: created.totalCents, + itemsCount: cartItems.length, + }) + return reply.code(201).send({ orderId: created.id }) }, ) diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index 058cad7..c9c3633 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -2,6 +2,7 @@ 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( @@ -105,6 +106,12 @@ export async function registerUserPaymentRoutes(fastify) { return reply.code(500).send({ error: 'Не удалось сохранить оплату' }) } + request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId: id, + userId, + paymentStatus: 'pending', + }) + return { ok: true, status: 'PENDING_PAYMENT' } }, ) From 1d36f6a31be1b07928478ab326f94a8533559e6b Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:40:24 +0500 Subject: [PATCH 22/28] feat: create admin notification settings on bootstrap --- server/src/lib/bootstrap-admin.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/server/src/lib/bootstrap-admin.js b/server/src/lib/bootstrap-admin.js index 9a9812b..0877c6b 100644 --- a/server/src/lib/bootstrap-admin.js +++ b/server/src/lib/bootstrap-admin.js @@ -8,9 +8,24 @@ export async function ensureAdminUser() { throw new Error('ADMIN_EMAIL должен быть валидным email') } - await prisma.user.upsert({ + const admin = await prisma.user.upsert({ where: { email: adminEmail }, update: {}, create: { email: adminEmail }, }) + + // Ensure admin notification settings exist + const existing = await prisma.adminNotificationSettings.findFirst() + if (!existing) { + await prisma.adminNotificationSettings.create({ + data: { + emailEnabled: true, + telegramEnabled: false, + newOrder: true, + newOrderMessage: true, + newReview: true, + authCodeDuplicate: false, + }, + }) + } } From 912724082e1cecbbce03c603e7f438254aab9d30 Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:44:49 +0500 Subject: [PATCH 23/28] test: add notification preferences tests --- .../plans/2026-05-17-admin-image-redesign.md | 1050 ++++++++ .../plans/2026-05-18-notification-system.md | 2230 +++++++++++++++++ .../specs/2026-05-17-admin-image-redesign.md | 149 ++ .../2026-05-18-notification-system-design.md | 221 ++ .../__tests__/preferences.test.js | 94 + server/src/lib/notifications/preferences.js | 2 +- server/src/lib/notifications/queue.js | 2 +- 7 files changed, 3746 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-17-admin-image-redesign.md create mode 100644 docs/superpowers/plans/2026-05-18-notification-system.md create mode 100644 docs/superpowers/specs/2026-05-17-admin-image-redesign.md create mode 100644 docs/superpowers/specs/2026-05-18-notification-system-design.md create mode 100644 server/src/lib/notifications/__tests__/preferences.test.js diff --git a/docs/superpowers/plans/2026-05-17-admin-image-redesign.md b/docs/superpowers/plans/2026-05-17-admin-image-redesign.md new file mode 100644 index 0000000..1b3e115 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-admin-image-redesign.md @@ -0,0 +1,1050 @@ +# Admin Image Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Separate admin image upload, resize, and attach-to-product into three explicit steps. + +**Architecture:** Upload → gallery only (raw file). Resize is a manual trigger per image. Attach to product/slider allowed only after resize. Uses `isResized` boolean on `GalleryImage` model. + +**Tech Stack:** Fastify, Prisma (SQLite), React, MUI, @tanstack/react-query, sharp + +--- + +### Task 1: Add `isResized` to GalleryImage (Prisma schema + migration) + +**Files:** +- Modify: `server/prisma/schema.prisma` +- Create: `server/prisma/migrations/xxx_add_is_resized/migration.sql` (via `prisma migrate dev`) + +- [ ] **Step 1: Add field to schema** + +Edit `server/prisma/schema.prisma`, add `isResized` to GalleryImage: + +```prisma +model GalleryImage { + id String @id @default(cuid()) + url String @unique + isResized Boolean @default(false) + createdAt DateTime @default(now()) + catalogSliderSlides CatalogSliderSlide[] +} +``` + +- [ ] **Step 2: Generate migration** + +Run: +```bash +cd server && npx prisma migrate dev --name add_is_resized +``` +Expected: new migration file created, DB updated. + +- [ ] **Step 3: Write seed/migration script to mark existing images as resized** + +Create `server/prisma/seed-is-resized.js`: + +```js +import { prisma } from '../src/lib/prisma.js' + +async function main() { + const { count } = await prisma.galleryImage.updateMany({ + where: { isResized: false }, + data: { isResized: true }, + }) + console.log(`Marked ${count} existing images as resized`) +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()) +``` + +Run once manually after deploy: +```bash +cd server && node prisma/seed-is-resized.js +``` + +- [ ] **Step 4: Verify** + +Run: `cd server && npx prisma studio` +Check that GalleryImage table has `isResized` column. + +- [ ] **Step 5: Commit** + +```bash +git add server/prisma/ +git commit -m "feat(db): add isResized to GalleryImage" +``` + +--- + +### Task 2: New gallery upload endpoint (server) + +**Files:** +- Modify: `server/src/routes/api/admin-gallery.js` +- Modify: `server/src/routes/api.js` (if needed to register routes) + +- [ ] **Step 1: Add `POST /api/admin/gallery/upload` route** + +Edit `server/src/routes/api/admin-gallery.js`. Add a new POST route before the DELETE route: + +```js +import fs from 'node:fs/promises' +import path from 'node:path' +import { prisma } from '../../lib/prisma.js' +import { persistMultipartImages } from '../../lib/upload-images.js' +import { + formatFileTooLargeMessage, + getProductImageMaxFileBytes, + isMultipartFileTooLargeError, +} from '../../lib/upload-limits.js' + +export async function registerAdminGalleryRoutes(fastify) { + fastify.get( + '/api/admin/gallery', + { preHandler: [fastify.verifyAdmin] }, + async () => { + const items = await prisma.galleryImage.findMany({ + orderBy: { createdAt: 'desc' }, + }) + return { items } + }, + ) + + fastify.post( + '/api/admin/gallery/upload', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + try { + const urls = await persistMultipartImages(request, { + maxFiles: 10, + maxFileBytes: getProductImageMaxFileBytes(), + subdir: '', + eager: false, + }) + for (const url of urls) { + await prisma.galleryImage.create({ + data: { url, isResized: false }, + }) + } + return { urls } + } catch (error) { + let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' + let statusCode = + error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) + ? Number(error.statusCode) + : 400 + if (isMultipartFileTooLargeError(error)) { + message = formatFileTooLargeMessage(getProductImageMaxFileBytes()) + statusCode = 413 + } + return reply.code(statusCode).send({ error: message }) + } + }, + ) + + fastify.delete( + // ... existing code unchanged + ) +} +``` + +- [ ] **Step 2: Verify no new route registration needed** + +Check `server/src/routes/api.js` — `registerAdminGalleryRoutes` should already be imported and registered. + +Run: `rg "registerAdminGalleryRoutes" server/src/routes/api.js` +Expected: import and `fastify.register(registerAdminGalleryRoutes)`. + +- [ ] **Step 3: Commit** + +```bash +git add server/src/routes/api/admin-gallery.js +git commit -m "feat(server): add POST /api/admin/gallery/upload endpoint" +``` + +--- + +### Task 3: New gallery resize endpoint (server) + +**Files:** +- Modify: `server/src/routes/api/admin-gallery.js` + +- [ ] **Step 1: Add `POST /api/admin/gallery/:id/resize` route** + +Edit `server/src/routes/api/admin-gallery.js`. Add after the upload route: + +```js + fastify.post( + '/api/admin/gallery/:id/resize', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const { id } = request.params + const row = await prisma.galleryImage.findUnique({ where: { id } }) + if (!row) { + return reply.code(404).send({ error: 'Изображение не найдено' }) + } + if (row.isResized) { + return reply.code(409).send({ error: 'Изображение уже обработано' }) + } + + // Extract UUID from url like "/uploads/.png" + const urlParts = row.url.replace(/^\//, '').split('/') + const fileName = urlParts[urlParts.length - 1] + const uuid = fileName.replace(/\.\w+$/, '') + + try { + const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js') + + const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName) + await generateAllSizes(uuid, '', fullPath) + const newUrl = await convertOriginalToWebp(uuid, '') + + await prisma.galleryImage.update({ + where: { id }, + data: { url: newUrl, isResized: true }, + }) + + return { url: newUrl } + } catch (error) { + return reply.code(500).send({ error: 'Ошибка обработки изображения' }) + } + }, + ) +``` + +- [ ] **Step 2: Run server tests** + +```bash +cd server && npm test +``` +Expected: all existing tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add server/src/routes/api/admin-gallery.js +git commit -m "feat(server): add POST /api/admin/gallery/:id/resize endpoint" +``` + +--- + +### Task 4: Remove old combined upload endpoint + validate isResized on product endpoints + +**Files:** +- Modify: `server/src/routes/api/admin-products.js` + +- [ ] **Step 1: Remove `POST /api/admin/uploads` route** + +Delete lines 62-87 from `server/src/routes/api/admin-products.js` (the entire `fastify.post('/api/admin/uploads', ...)` block). + +Remove unused imports: +- `formatFileTooLargeMessage` +- `getProductImageMaxFileBytes` +- `isMultipartFileTooLargeError` +- `persistMultipartImages` + +Remove unused import `upsertGalleryImagesByUrls` (it was only used in the upload route). + +The imports section should become: +```js +import { prisma } from '../../lib/prisma.js' +``` + +- [ ] **Step 2: Add isResized validation to product create and patch** + +In `POST /api/admin/products` and `PATCH /api/admin/products/:id`, before creating/updating images, add: + +```js +// Validate all imageUrls belong to resized gallery images +if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) { + const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean) + if (urls.length > 0) { + const galleryImages = await prisma.galleryImage.findMany({ + where: { url: { in: urls } }, + select: { url: true, isResized: true }, + }) + const galleryMap = new Map(galleryImages.map((g) => [g.url, g])) + const notFound = urls.filter((u) => !galleryMap.has(u)) + const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u)!.isResized) + if (notFound.length > 0) { + return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' }) + } + if (notResized.length > 0) { + return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' }) + } + } +} +``` + +Place this validation in both the create handler (after the `exists` slug check, around line 123) and the patch handler (after `if (body.categoryId !== undefined)` check, around line 229). + +For the patch handler, the validation should be inside the `if (body.imageUrls !== undefined)` block or right before `const imagesUpdate`. + +Let me be more precise about where in the code: + +**Create handler** — add after line 123 (`return` after slug conflict), before line 132 (`const product = await prisma.product.create`): +```js +if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) { + const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean) + if (urls.length > 0) { + const galleryImages = await prisma.galleryImage.findMany({ + where: { url: { in: urls } }, + select: { url: true, isResized: true }, + }) + const galleryMap = new Map(galleryImages.map((g) => [g.url, g])) + const notFound = urls.filter((u) => !galleryMap.has(u)) + const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized) + if (notFound.length > 0) { + return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' }) + } + if (notResized.length > 0) { + return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' }) + } + } +} +``` + +**Patch handler** — add before `const imagesUpdate` block (before line 231): +```js +if (body.imageUrls !== undefined && Array.isArray(body.imageUrls)) { + const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean) + if (urls.length > 0) { + const galleryImages = await prisma.galleryImage.findMany({ + where: { url: { in: urls } }, + select: { url: true, isResized: true }, + }) + const galleryMap = new Map(galleryImages.map((g) => [g.url, g])) + const notFound = urls.filter((u) => !galleryMap.has(u)) + const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized) + if (notFound.length > 0) { + return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' }) + } + if (notResized.length > 0) { + return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' }) + } + } +} +``` + +- [ ] **Step 3: Run server tests** + +```bash +cd server && npm test +``` +Expected: all existing tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/routes/api/admin-products.js +git commit -m "feat(server): remove old /admin/uploads, validate isResized on product endpoints" +``` + +--- + +### Task 5: Update existing server tests + add new gallery tests + +**Files:** +- Create: `server/src/routes/api/__tests__/admin-gallery.test.js` +- Modify: `server/src/lib/__tests__/upload-images.test.js` (adapt eager tests) + +- [ ] **Step 1: Create admin gallery test** + +Create `server/src/routes/api/__tests__/admin-gallery.test.js`: + +```js +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' + +const UPLOADS_DIR = path.join(process.cwd(), 'uploads') + +// We'll test the resize endpoint logic directly via the lib functions +// since the router requires a full Fastify instance + +import { generateAllSizes, convertOriginalToWebp } from '../../lib/image-resize.js' + +describe('Gallery resize endpoint logic', () => { + const testUuid = 'gallery-test-resize-uuid' + const testOriginalPath = path.join(UPLOADS_DIR, `${testUuid}.png`) + + beforeAll(async () => { + const sharp = (await import('sharp')).default + await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } }) + .png() + .toFile(testOriginalPath) + }) + + afterAll(async () => { + await fs.promises.unlink(testOriginalPath).catch(() => {}) + const webpPath = path.join(UPLOADS_DIR, `${testUuid}.webp`) + await fs.promises.unlink(webpPath).catch(() => {}) + const cacheDir = path.join(UPLOADS_DIR, '.cache') + for (const width of [320, 640, 1024, 1600]) { + for (const format of ['avif', 'webp']) { + await fs.promises.unlink(path.join(cacheDir, `${testUuid}_w${width}.${format}`)).catch(() => {}) + } + } + }) + + it('generateAllSizes + convertOriginalToWebp works on raw upload', async () => { + await generateAllSizes(testUuid, '', testOriginalPath) + const newUrl = await convertOriginalToWebp(testUuid, '') + + expect(newUrl).toBe(`/uploads/${testUuid}.webp`) + + // Verify original PNG is deleted + const pngExists = await fs.promises.access(testOriginalPath).then(() => true).catch(() => false) + expect(pngExists).toBe(false) + + // Verify cached files exist + const cacheDir = path.join(UPLOADS_DIR, '.cache') + for (const width of [320, 640, 1024, 1600]) { + for (const format of ['avif', 'webp']) { + const cachePath = path.join(cacheDir, `${testUuid}_w${width}.${format}`) + const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false) + expect(exists).toBe(true) + } + } + + // Verify webp original exists + const webpExists = await fs.promises.access(path.join(UPLOADS_DIR, `${testUuid}.webp`)).then(() => true).catch(() => false) + expect(webpExists).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Adapt upload-images tests** + +The `upload-images.test.js` has tests for `eager: true` mode. Since we no longer use eager mode for admin uploads, migrate the eager test to work under the gallery resize flow instead. The simplest approach: remove the `eager: true` test and keep the `eager: false` test (it tests raw file saving, which is what gallery upload does). + +Edit `server/src/lib/__tests__/upload-images.test.js`: +- Remove the first test `returns WebP URLs when eager=true` (lines 19-61) +- Keep the second test `returns original format URLs when eager=false` +- Keep the third test `cleans up original file on eager processing error` but change `eager: true` to `eager: false` in its options — this tests error handling of the function itself, which is still valid. + +Actually, re-reading the third test: it tests that when `eager: true` and processing fails, the original file is cleaned up. With `eager: false`, no processing happens so there's nothing to fail on that front. Let me just remove the third test too. The important behavior (raw file saving in `eager: false` mode) is covered by the remaining test. + +So we keep only the second test `returns original format URLs when eager=false`. + +- [ ] **Step 3: Run server tests** + +```bash +cd server && npm test +``` +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/routes/api/__tests__/admin-gallery.test.js server/src/lib/__tests__/upload-images.test.js +git commit -m "test(server): add gallery resize test, adapt upload tests" +``` + +--- + +### Task 6: Client types and API layer + +**Files:** +- Modify: `client/src/entities/gallery/model/types.ts` +- Modify: `client/src/entities/gallery/api/gallery-api.ts` +- Modify: `client/src/entities/gallery/index.ts` + +- [ ] **Step 1: Add `isResized` to GalleryImageItem type** + +Edit `client/src/entities/gallery/model/types.ts`: + +```ts +export type GalleryImageItem = { + id: string + url: string + isResized: boolean + createdAt: string +} +``` + +- [ ] **Step 2: Add new gallery API functions** + +Edit `client/src/entities/gallery/api/gallery-api.ts`: + +```ts +import type { GalleryImageItem } from '@/entities/gallery/model/types' +import { apiClient } from '@/shared/api/client' +import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' +import { apiBaseURL } from '@/shared/config' + +export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> { + const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery') + return data +} + +export async function deleteGalleryImage(id: string): Promise { + await apiClient.delete(`admin/gallery/${id}`) +} + +export async function uploadGalleryImages(files: File[]): Promise { + for (const f of files) { + if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) { + throw new Error( + `Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`, + ) + } + } + const fd = new FormData() + for (const f of files) { + fd.append('files', f, f.name) + } + const token = localStorage.getItem('craftshop_auth_token') + const base = apiBaseURL.replace(/\/$/, '') + const res = await fetch(`${base}/admin/gallery/upload`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: fd, + }) + const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string } + if (!res.ok) { + if (res.status === 413) { + throw new Error( + 'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.', + ) + } + throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`) + } + if (!Array.isArray(payload.urls)) { + throw new Error('Некорректный ответ сервера') + } + return payload.urls +} + +export async function resizeGalleryImage(id: string): Promise<{ url: string }> { + const { data } = await apiClient.post<{ url: string }>(`admin/gallery/${id}/resize`) + return data +} +``` + +- [ ] **Step 3: Update gallery index** + +Edit `client/src/entities/gallery/index.ts`: + +```ts +export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api' +export type { GalleryImageItem } from './model/types' +export { GalleryGrid } from './ui/GalleryGrid' +``` + +- [ ] **Step 4: Run client typecheck** + +```bash +cd client && npx tsc -b --noEmit +``` +Expected: no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add client/src/entities/gallery/ +git commit -m "feat(client): add isResized type, uploadGalleryImages, resizeGalleryImage API" +``` + +--- + +### Task 7: Update GalleryGrid — add status badge and resize button + +**Files:** +- Modify: `client/src/entities/gallery/ui/GalleryGrid.tsx` + +- [ ] **Step 1: Add resize button + status badge to GalleryGrid** + +Edit `client/src/entities/gallery/ui/GalleryGrid.tsx`: + +```tsx +import AutoFixHighOutlinedIcon from '@mui/icons-material/AutoFixHighOutlined' +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined' +import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined' +import Box from '@mui/material/Box' +import Chip from '@mui/material/Chip' +import IconButton from '@mui/material/IconButton' +import Tooltip from '@mui/material/Tooltip' +import { OptimizedImage } from '@/shared/ui/OptimizedImage' +import type { GalleryImageItem } from '../model/types' + +type Props = { + items: GalleryImageItem[] + deleting?: boolean + resizing?: string | null + onDelete: (id: string) => void + onResize: (id: string) => void +} + +export function GalleryGrid({ items, deleting, resizing, onDelete, onResize }: Props) { + return ( + + {items.map((item) => ( + + + + {item.isResized ? ( + } + sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }} + /> + ) : ( + + )} + + + {!item.isResized && ( + + onResize(item.id)} + > + + + + )} + + onDelete(item.id)} + > + + + + + + ))} + + ) +} +``` + +- [ ] **Step 2: Run client typecheck** + +```bash +cd client && npx tsc -b --noEmit +``` +Expected: no type errors. + +- [ ] **Step 3: Commit** + +```bash +git add client/src/entities/gallery/ui/GalleryGrid.tsx +git commit -m "feat(client): add resize button and status badge to GalleryGrid" +``` + +--- + +### Task 8: Update AdminGalleryPage — use new upload + add resize mutation + +**Files:** +- Modify: `client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx` + +- [ ] **Step 1: Replace `uploadAdminProductImages` with `uploadGalleryImages`, add resize mutation** + +Edit `client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx`: + +```tsx +import { useRef, useState } from 'react' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Divider from '@mui/material/Divider' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { fetchAdminCatalogSlider } from '@/entities/catalog-slider' +import { + deleteGalleryImage, + fetchAdminGallery, + GalleryGrid, + resizeGalleryImage, + uploadGalleryImages, +} from '@/entities/gallery' +import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' +import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' +import { GallerySliderSection } from './GallerySliderSection' +import type { AxiosError } from 'axios' + +function getApiErrorMessage(error: unknown): string | null { + const e = error as AxiosError<{ error?: string }> + const msg = e?.response?.data?.error + return msg ? String(msg) : null +} + +export function AdminGalleryPage() { + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + const [resizingId, setResizingId] = useState(null) + + const sliderQuery = useQuery({ + queryKey: ['admin', 'catalog-slider'], + queryFn: fetchAdminCatalogSlider, + }) + + const galleryQuery = useQuery({ + queryKey: ['admin', 'gallery'], + queryFn: fetchAdminGallery, + }) + + const uploadMut = useMutation({ + mutationFn: (files: File[]) => uploadGalleryImages(files), + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'gallery']]) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + }, + }) + + const resizeMut = useMutation({ + mutationFn: async (id: string) => { + setResizingId(id) + try { + await resizeGalleryImage(id) + } finally { + setResizingId(null) + } + }, + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']]) + }, + }) + + const deleteMut = useMutation({ + mutationFn: (id: string) => deleteGalleryImage(id), + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']]) + }, + }) + + const items = galleryQuery.data?.items ?? [] + const deleteError = deleteMut.error + const uploadError = uploadMut.error + const resizeError = resizeMut.error + + return ( + + + Галерея + + + Изображения загружаются без обработки. После загрузки нажмите «Resize» для подготовки к публикации. + Обработанные изображения доступны для добавления в карточку товара и слайдер. + + + {sliderQuery.isError && ( + + Не удалось загрузить настройки слайдера. + + )} + {sliderQuery.isLoading && ( + + Загрузка настроек слайдера… + + )} + {sliderQuery.isSuccess && ( + ({ + galleryImageId: s.galleryImageId, + caption: s.caption, + }))} + galleryItems={items} + /> + )} + + + + + Форматы: PNG, JPEG, WebP. На один файл — до {formatAdminImageMaxSizeHint()}. + + + + + {uploadMut.isPending && Загрузка…} + {uploadMut.isError && ( + + {uploadMut.error instanceof Error ? uploadMut.error.message : 'Ошибка загрузки'} + + )} + {deleteMut.isError && ( + {getApiErrorMessage(deleteMut.error) ?? 'Ошибка удаления'} + )} + {resizeMut.isError && ( + {getApiErrorMessage(resizeMut.error) ?? 'Ошибка обработки'} + )} + + + {galleryQuery.isError && ( + + Не удалось загрузить список. + + )} + + deleteMut.mutate(id)} + onResize={(id) => resizeMut.mutate(id)} + /> + + {!galleryQuery.isLoading && items.length === 0 && ( + Пока нет загруженных изображений. + )} + + ) +} +``` + +- [ ] **Step 2: Run client lint** + +```bash +cd client && npm run lint +``` +Expected: no errors. + +- [ ] **Step 3: Run client typecheck** + +```bash +cd client && npx tsc -b --noEmit +``` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add client/src/pages/admin-gallery/ +git commit -m "feat(client): update AdminGalleryPage with new upload and resize UI" +``` + +--- + +### Task 9: Update AdminProductsPage — remove direct upload, filter gallery to resized only + +**Files:** +- Modify: `client/src/pages/admin-products/ui/AdminProductsPage.tsx` + +- [ ] **Step 1: Remove direct file upload from product form** + +Edit `client/src/pages/admin-products/ui/AdminProductsPage.tsx`: + +Changes: +1. Remove `uploadAdminProductImages` import (line 34) +2. Remove `import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'` (line 37 — no longer needed in this file) +3. Remove `productImagesInputRef` (line 206) +4. Remove `uploadImagesMut` mutation (lines 208-217) +5. Change `mutationError` to exclude `uploadImagesMut.error` (line 219 → `const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error`) +6. Remove the "Выбрать файлы" button and its description text +7. Update the "Фото" section description to remove mention of file upload limits + +The relevant section (lines 402-445) should become: + +```tsx + + + Фото (из галереи) + + + Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл + остаётся на сервере и в галерее. + + + + + + // ... keep the rest (imageUrls preview) unchanged + +``` + +- [ ] **Step 2: Filter gallery picker to resized images only** + +In the gallery picker dialog, filter the items: + +Change line 569: +```tsx +{(galleryForPickQuery.data?.items ?? []) + .filter((item) => item.isResized) + .map((item) => { +``` + +Also add an empty state message when there are gallery items but none are resized: + +After the existing empty state check (line 558-560), add: +```tsx +{galleryForPickQuery.data && + galleryForPickQuery.data.items.length > 0 && + galleryForPickQuery.data.items.filter((i) => i.isResized).length === 0 && + !galleryForPickQuery.isLoading && ( + + В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея». + + )} +``` + +- [ ] **Step 3: Run client lint and typecheck** + +```bash +cd client && npm run lint && npx tsc -b --noEmit +``` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add client/src/pages/admin-products/ +git commit -m "feat(client): remove direct upload from product form, filter gallery to resized" +``` + +--- + +### Task 10: Update GallerySliderSection — filter to resized images only + +**Files:** +- Modify: `client/src/pages/admin-gallery/ui/GallerySliderSection.tsx` + +- [ ] **Step 1: Filter pickCandidates to resized only** + +Edit `client/src/pages/admin-gallery/ui/GallerySliderSection.tsx`, change line 34: + +```tsx +const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id) && i.isResized) +``` + +- [ ] **Step 2: Commit** + +```bash +git add client/src/pages/admin-gallery/ui/GallerySliderSection.tsx +git commit -m "feat(client): slider picker shows only resized images" +``` + +--- + +### Task 11: Clean up unused server import (gallery.js) + +**Files:** +- Modify: `server/src/lib/gallery.js` — no change needed, keep as is (still used by future migration scripts or could be useful) +- Actually nothing to clean up here — `upsertGalleryImagesByUrls` is no longer imported anywhere. Remove the file `server/src/lib/gallery.js` if nothing else uses it. + +- [ ] **Step 1: Check if gallery.js is used anywhere else** + +Run: `rg "gallery.js" server/src/` +Expected: only in the import in the now-removed admin-products.js line 1. + +- [ ] **Step 2: Remove gallery.js file** + +Delete `server/src/lib/gallery.js` since it's no longer used. + +- [ ] **Step 3: Commit** + +```bash +git rm server/src/lib/gallery.js +git commit -m "chore(server): remove unused gallery.js" +``` + +--- + +### Task 12: End-to-end verification + +- [ ] **Step 1: Run all server tests** + +```bash +cd server && npm test +``` +Expected: all tests pass. + +- [ ] **Step 2: Run client lint + format check + typecheck** + +```bash +cd client && npm run lint && npm run format:check && npx tsc -b --noEmit +``` +Expected: no errors. + +- [ ] **Step 3: Manual smoke test** + +1. Start server: `cd server && npm run dev` +2. Start client: `cd client && npm run dev` +3. Open `/admin/gallery` → upload a PNG → verify it shows with "Не обработано" badge +4. Click resize button → verify it becomes "Готово" +5. Open `/admin/products` → edit/create product → verify no "Выбрать файлы" button, only "Из галереи" +6. Click "Из галереи" → verify only resized images show up +7. Add image to product → save → verify product card shows the image +8. Open `/admin/gallery` → verify resized image can't be resized again (no resize button) + +- [ ] **Step 4: Commit any remaining changes** + +```bash +git add -A +git commit -m "feat: complete admin image redesign — upload, resize, attach flow" +``` diff --git a/docs/superpowers/plans/2026-05-18-notification-system.md b/docs/superpowers/plans/2026-05-18-notification-system.md new file mode 100644 index 0000000..0e13c99 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-notification-system.md @@ -0,0 +1,2230 @@ +# Notification System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an event-driven notification system with email (users) and email + Telegram (admin), in-memory queue with retry, and UI for managing notification preferences. + +**Architecture:** Event Emitter → Queue → Channels (email/telegram). Preferences stored in DB, checked before sending. NotificationLog tracks all delivery attempts. + +**Tech Stack:** Node.js EventEmitter, nodemailer (existing), Telegram Bot API (fetch), Prisma, MUI (client), @tanstack/react-query. + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `server/prisma/schema.prisma` | Modify | Add 3 new models | +| `server/src/lib/notifications/event-bus.js` | Create | Central EventEmitter | +| `server/src/lib/notifications/queue.js` | Create | In-memory queue + worker | +| `server/src/lib/notifications/channels/email-channel.js` | Create | Email delivery via nodemailer | +| `server/src/lib/notifications/channels/telegram-channel.js` | Create | Telegram delivery via Bot API | +| `server/src/lib/notifications/templates/email-templates.js` | Create | HTML email templates | +| `server/src/lib/notifications/templates/telegram-templates.js` | Create | Telegram message templates | +| `server/src/lib/notifications/preferences.js` | Create | Resolve recipients based on preferences | +| `server/src/lib/email.js` | Modify | Add `sendNotificationEmail()` | +| `server/src/lib/bootstrap-admin.js` | Modify | Create AdminNotificationSettings on admin bootstrap | +| `server/src/lib/auth.js` | Modify | Emit `auth:codeRequested` event | +| `server/src/routes/api.js` | Modify | Register notification routes | +| `server/src/routes/api/admin/notifications.js` | Create | Admin notification settings API | +| `server/src/routes/user/notifications.js` | Create | User notification settings API | +| `server/src/routes/user-orders.js` | Modify | Emit `order:created` | +| `server/src/routes/api/admin-orders.js` | Modify | Emit `order:statusChanged`, `orderMessage:adminReply` | +| `server/src/routes/user-messages.js` | Modify | Emit `orderMessage:sent` | +| `server/src/routes/user-payments.js` | Modify | Emit `payment:statusChanged` | +| `server/src/index.js` | Modify | Initialize eventBus, queue, register user notification routes | +| `shared/constants/notification-events.js` | Create | Event type constants | +| `shared/constants/notification-events.d.ts` | Create | TypeScript definitions | +| `client/src/entities/notification/api/notifications-api.ts` | Create | API client functions | +| `client/src/pages/me/ui/sections/NotificationsPage.tsx` | Create | User notification settings UI | +| `client/src/pages/me/ui/MeLayoutPage.tsx` | Modify | Add notifications route + nav item | +| `client/src/pages/admin-layout/ui/` | Modify | Add admin notification settings | + +--- + +### Task 1: Database Schema — Add notification models + +**Files:** +- Modify: `server/prisma/schema.prisma` + +- [ ] **Step 1: Add three new models to the Prisma schema** + +Add these models at the end of `server/prisma/schema.prisma` (after `InfoPageBlock`): + +```prisma +/// Настройки оповещений пользователя +model NotificationPreference { + id String @id @default(cuid()) + userId String @unique + globalEnabled Boolean @default(true) + orderCreated Boolean @default(true) + orderStatusChanged Boolean @default(true) + orderMessageReceived Boolean @default(true) + paymentStatusChanged Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +/// Настройки оповещений админа +model AdminNotificationSettings { + id String @id @default(cuid()) + emailEnabled Boolean @default(true) + telegramEnabled Boolean @default(false) + telegramChatId String? + newOrder Boolean @default(true) + newOrderMessage Boolean @default(true) + newReview Boolean @default(true) + authCodeDuplicate Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +/// Лог отправки оповещений +model NotificationLog { + id String @id @default(cuid()) + userId String? + eventType String + channel String + status String + error String? + payload String + attempts Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([status, createdAt]) + @@index([userId, createdAt]) +} +``` + +Also add the `notificationPreferences` relation to the `User` model. Find the `User` model and add: + +```prisma + notificationPreferences NotificationPreference? +``` + +- [ ] **Step 2: Run the migration** + +Run: +```bash +cd server && npx prisma migrate dev --name add_notification_system +``` + +Expected: Migration created and applied successfully. + +- [ ] **Step 3: Commit** + +```bash +git add server/prisma/schema.prisma server/prisma/migrations/ +git commit -m "feat: add notification system database models" +``` + +--- + +### Task 2: Event type constants (shared) + +**Files:** +- Create: `shared/constants/notification-events.js` +- Create: `shared/constants/notification-events.d.ts` + +- [ ] **Step 1: Create the JS constants file** + +```js +// shared/constants/notification-events.js + +/** @typedef {'order:created' | 'order:statusChanged' | 'orderMessage:sent' | 'orderMessage:adminReply' | 'payment:statusChanged' | 'auth:codeRequested'} NotificationEventType */ + +const NOTIFICATION_EVENTS = { + ORDER_CREATED: 'order:created', + ORDER_STATUS_CHANGED: 'order:statusChanged', + ORDER_MESSAGE_SENT: 'orderMessage:sent', + ORDER_MESSAGE_ADMIN_REPLY: 'orderMessage:adminReply', + PAYMENT_STATUS_CHANGED: 'payment:statusChanged', + AUTH_CODE_REQUESTED: 'auth:codeRequested', +} + +const NOTIFICATION_CHANNELS = { + EMAIL: 'email', + TELEGRAM: 'telegram', +} + +const NOTIFICATION_STATUSES = { + PENDING: 'pending', + SENT: 'sent', + FAILED: 'failed', +} + +const MAX_RETRY_ATTEMPTS = 3 + +const RETRY_DELAYS_MS = [5_000, 30_000, 120_000] + +module.exports = { + NOTIFICATION_EVENTS, + NOTIFICATION_CHANNELS, + NOTIFICATION_STATUSES, + MAX_RETRY_ATTEMPTS, + RETRY_DELAYS_MS, +} +``` + +- [ ] **Step 2: Create the TypeScript definition file** + +```ts +// shared/constants/notification-events.d.ts + +export type NotificationEventType = + | 'order:created' + | 'order:statusChanged' + | 'orderMessage:sent' + | 'orderMessage:adminReply' + | 'payment:statusChanged' + | 'auth:codeRequested' + +export type NotificationChannel = 'email' | 'telegram' + +export type NotificationStatus = 'pending' | 'sent' | 'failed' + +export const NOTIFICATION_EVENTS: { + ORDER_CREATED: NotificationEventType + ORDER_STATUS_CHANGED: NotificationEventType + ORDER_MESSAGE_SENT: NotificationEventType + ORDER_MESSAGE_ADMIN_REPLY: NotificationEventType + PAYMENT_STATUS_CHANGED: NotificationEventType + AUTH_CODE_REQUESTED: NotificationEventType +} + +export const NOTIFICATION_CHANNELS: { + EMAIL: NotificationChannel + TELEGRAM: NotificationChannel +} + +export const NOTIFICATION_STATUSES: { + PENDING: NotificationStatus + SENT: NotificationStatus + FAILED: NotificationStatus +} + +export const MAX_RETRY_ATTEMPTS: number +export const RETRY_DELAYS_MS: number[] +``` + +- [ ] **Step 3: Commit** + +```bash +git add shared/constants/notification-events.js shared/constants/notification-events.d.ts +git commit -m "feat: add notification event type constants" +``` + +--- + +### Task 3: Event Bus + +**Files:** +- Create: `server/src/lib/notifications/event-bus.js` + +- [ ] **Step 1: Create the event bus module** + +```js +// server/src/lib/notifications/event-bus.js +import { EventEmitter } from 'node:events' + +export function createEventBus() { + const bus = new EventEmitter() + bus.setMaxListeners(50) + return bus +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/lib/notifications/event-bus.js +git commit -m "feat: add notification event bus" +``` + +--- + +### Task 4: Email Templates + +**Files:** +- Create: `server/src/lib/notifications/templates/email-templates.js` + +- [ ] **Step 1: Create email templates** + +```js +// server/src/lib/notifications/templates/email-templates.js + +function baseLayout(title, body) { + return ` + +${title} + +
+

${title}

+
+ ${body} +
+

Craftshop — магазин handmade изделий

+
+ +` +} + +export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + const body = ` +

Ваш заказ #${orderId.slice(0, 8)} успешно создан.

+

Товаров: ${itemsCount} | Сумма: ${total} ₽

+

Мы сообщим вам об изменениях статуса.

+ ` + return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) } +} + +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 + const body = ` +

Статус заказа #${orderId.slice(0, 8)} изменён.

+

${oldLabel}${newLabel}

+ ` + return { subject: `Статус заказа изменён — ${newLabel}`, html: baseLayout('Статус заказа изменён', body) } +} + +export function renderOrderMessageEmail({ orderId, preview }) { + const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview + const body = ` +

Новое сообщение к заказу #${orderId.slice(0, 8)}:

+
+ ${truncated} +
+

Ответьте в личном кабинете.

+ ` + return { subject: 'Новое сообщение к заказу', html: baseLayout('Новое сообщение', body) } +} + +export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) { + const statusLabels = { + pending: 'Ожидает', + confirmed: 'Подтверждён', + rejected: 'Отклонён', + } + const label = statusLabels[paymentStatus] || paymentStatus + const body = ` +

Статус оплаты заказа #${orderId.slice(0, 8)}: ${label}.

+ ` + return { subject: `Оплата заказа — ${label}`, html: baseLayout('Оплата заказа', body) } +} + +export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + const body = ` +

Новый заказ #${orderId.slice(0, 8)} от ${userEmail}.

+

Товаров: ${itemsCount} | Сумма: ${total} ₽

+ ` + return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) } +} + +export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) { + const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating) + const body = ` +

Новый отзыв ${stars} на товар ${productTitle} от ${userName}.

+ ${text ? `
${text}
` : ''} +

Проверьте отзыв в админ-панели.

+ ` + return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) } +} + +export function renderAuthCodeEmail({ code }) { + const body = ` +

Ваш код входа: ${code}

+

Если это были не вы — просто проигнорируйте письмо.

+ ` + return { subject: 'Код входа', html: baseLayout('Код входа', body) } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/lib/notifications/templates/email-templates.js +git commit -m "feat: add email templates for notifications" +``` + +--- + +### Task 5: Telegram Templates + +**Files:** +- Create: `server/src/lib/notifications/templates/telegram-templates.js` + +- [ ] **Step 1: Create Telegram message templates** + +```js +// server/src/lib/notifications/templates/telegram-templates.js + +export function renderOrderCreatedTg({ orderId, totalCents, itemsCount }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + return `📦 Новый заказ #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total} ₽` +} + +export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) { + const labels = { + DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', IN_PROGRESS: 'В работе', + READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён', + } + return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → ${labels[newStatus] || newStatus}` +} + +export function renderOrderMessageTg({ orderId, preview }) { + const truncated = preview.length > 300 ? preview.slice(0, 297) + '...' : preview + return `💬 Сообщение к заказу #${orderId.slice(0, 8)}\n\n${truncated}` +} + +export function renderPaymentStatusChangedTg({ orderId, paymentStatus }) { + const labels = { pending: 'Ожидает', confirmed: 'Подтверждён', rejected: 'Отклонён' } + return `💳 Оплата заказа #${orderId.slice(0, 8)}: ${labels[paymentStatus] || paymentStatus}` +} + +export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + return `🛒 Новый заказ #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total} ₽` +} + +export function renderAdminNewReviewTg({ rating, text, productTitle, userName }) { + const stars = '⭐'.repeat(rating) + return `📝 Новый отзыв ${stars}\nТовар: ${productTitle}\nАвтор: ${userName}${text ? '\n\n' + text : ''}` +} + +export function renderAuthCodeTg({ code }) { + return `🔐 Код входа: ${code}` +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/lib/notifications/templates/telegram-templates.js +git commit -m "feat: add Telegram message templates" +``` + +--- + +### Task 6: Email Channel + +**Files:** +- Modify: `server/src/lib/email.js` +- Create: `server/src/lib/notifications/channels/email-channel.js` + +- [ ] **Step 1: Extend email.js with sendNotificationEmail** + +Replace the entire content of `server/src/lib/email.js`: + +```js +import nodemailer from 'nodemailer' + +function hasSmtpEnv() { + return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS) +} + +function createTransporter() { + return nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }) +} + +export async function sendLoginCodeEmail({ to, code }) { + if (!hasSmtpEnv()) { + console.log(`[DEV] login code for ${to}: ${code}`) + return + } + + const transporter = createTransporter() + const from = process.env.MAIL_FROM || process.env.SMTP_USER + + await transporter.sendMail({ + from, + to, + subject: 'Код входа', + text: `Ваш код: ${code}\n\nЕсли это были не вы — просто проигнорируйте письмо.`, + }) +} + +export async function sendNotificationEmail({ to, subject, html }) { + if (!hasSmtpEnv()) { + console.log(`[DEV] notification email to ${to}: ${subject}`) + return { success: true } + } + + try { + const transporter = createTransporter() + const from = process.env.MAIL_FROM || process.env.SMTP_USER + + await transporter.sendMail({ + from, + to, + subject, + html, + }) + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } +} +``` + +- [ ] **Step 2: Create the email channel adapter** + +```js +// server/src/lib/notifications/channels/email-channel.js +import { sendNotificationEmail } from '../../email.js' +import { + renderOrderCreatedEmail, + renderOrderStatusChangedEmail, + renderOrderMessageEmail, + renderPaymentStatusChangedEmail, + renderAdminOrderCreatedEmail, + renderAdminNewReviewEmail, + renderAuthCodeEmail, +} from '../templates/email-templates.js' + +const templateRenderers = { + 'order:created': renderOrderCreatedEmail, + 'order:statusChanged': renderOrderStatusChangedEmail, + 'orderMessage:adminReply': renderOrderMessageEmail, + 'payment:statusChanged': renderPaymentStatusChangedEmail, + 'order:created:admin': renderAdminOrderCreatedEmail, + 'orderMessage:sent': renderOrderMessageEmail, + 'review:created': renderAdminNewReviewEmail, + 'auth:codeRequested': renderAuthCodeEmail, +} + +export const emailChannel = { + name: 'email', + + async send({ recipient, eventType, payload }) { + const renderer = templateRenderers[eventType] + if (!renderer) { + return { success: false, error: `No email template for event: ${eventType}` } + } + + const { subject, html } = renderer(payload) + const result = await sendNotificationEmail({ to: recipient, subject, html }) + return result + }, +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/src/lib/email.js server/src/lib/notifications/channels/email-channel.js +git commit -m "feat: add email notification channel" +``` + +--- + +### Task 7: Telegram Channel + +**Files:** +- Create: `server/src/lib/notifications/channels/telegram-channel.js` + +- [ ] **Step 1: Create the Telegram channel adapter** + +```js +// server/src/lib/notifications/channels/telegram-channel.js +import { + renderOrderCreatedTg, + renderOrderStatusChangedTg, + renderOrderMessageTg, + renderPaymentStatusChangedTg, + renderAdminOrderCreatedTg, + renderAdminNewReviewTg, + renderAuthCodeTg, +} from '../templates/telegram-templates.js' + +const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || '' + +const templateRenderers = { + 'order:created': renderOrderCreatedTg, + 'order:statusChanged': renderOrderStatusChangedTg, + 'orderMessage:adminReply': renderOrderMessageTg, + 'payment:statusChanged': renderPaymentStatusChangedTg, + 'order:created:admin': renderAdminOrderCreatedTg, + 'orderMessage:sent': renderOrderMessageTg, + 'review:created': renderAdminNewReviewTg, + 'auth:codeRequested': renderAuthCodeTg, +} + +async function postToTelegram(chatId, text) { + if (!TELEGRAM_BOT_TOKEN) { + console.log(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`) + return { success: true } + } + + try { + const res = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text, + parse_mode: 'HTML', + }), + }) + + const data = await res.json() + if (!data.ok) { + return { success: false, error: data.description || 'Telegram API error' } + } + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } +} + +export const telegramChannel = { + name: 'telegram', + + async send({ recipient: chatId, eventType, payload }) { + if (!chatId) { + return { success: false, error: 'No telegram chatId' } + } + + const renderer = templateRenderers[eventType] + if (!renderer) { + return { success: false, error: `No telegram template for event: ${eventType}` } + } + + const text = renderer(payload) + return postToTelegram(chatId, text) + }, +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/lib/notifications/channels/telegram-channel.js +git commit -m "feat: add Telegram notification channel" +``` + +--- + +### Task 8: Preferences Resolver + +**Files:** +- Create: `server/src/lib/notifications/preferences.js` + +- [ ] **Step 1: Create the preferences module** + +```js +// server/src/lib/notifications/preferences.js +import { prisma } from '../prisma.js' +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' + +const { + ORDER_CREATED, + ORDER_STATUS_CHANGED, + ORDER_MESSAGE_SENT, + ORDER_MESSAGE_ADMIN_REPLY, + PAYMENT_STATUS_CHANGED, + AUTH_CODE_REQUESTED, +} = NOTIFICATION_EVENTS + +const userEventFieldMap = { + [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', +} + +export async function resolveUserNotificationTargets(eventType, payload) { + const targets = [] + + if (payload.userId) { + const prefs = await prisma.notificationPreference.findUnique({ + where: { userId: payload.userId }, + }) + + if (prefs && prefs.globalEnabled) { + 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 }) + } + } + } + } + + return targets +} + +export async function resolveAdminNotificationTargets(eventType, payload) { + const targets = [] + const settings = await prisma.adminNotificationSettings.findFirst() + if (!settings) return targets + + const field = adminEventFieldMap[eventType] + if (field === 'newReview') { + if (!settings.newReview) return targets + } else if (field && !settings[field]) { + 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 }) + } + } + + if (settings.telegramEnabled && settings.telegramChatId) { + targets.push({ channel: 'telegram', recipient: settings.telegramChatId }) + } + + return targets +} + +export async function resolveAuthCodeTargets(eventType, payload) { + const targets = [] + + // User always gets email + if (payload.email) { + targets.push({ channel: 'email', recipient: payload.email }) + } + + // Admin gets telegram duplicate if enabled + if (payload.isAdmin) { + const settings = await prisma.adminNotificationSettings.findFirst() + if (settings && settings.telegramEnabled && settings.telegramChatId && settings.authCodeDuplicate) { + targets.push({ channel: 'telegram', recipient: settings.telegramChatId }) + } + } + + return targets +} + +export async function ensureUserNotificationPreference(userId) { + const existing = await prisma.notificationPreference.findUnique({ where: { userId } }) + if (existing) return existing + return prisma.notificationPreference.create({ + data: { userId, globalEnabled: true }, + }) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/lib/notifications/preferences.js +git commit -m "feat: add notification preferences resolver" +``` + +--- + +### Task 9: Queue + Worker + +**Files:** +- Create: `server/src/lib/notifications/queue.js` + +- [ ] **Step 1: Create the queue module** + +```js +// server/src/lib/notifications/queue.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' + +const { PENDING, SENT, FAILED } = NOTIFICATION_STATUSES + +const channels = { + email: emailChannel, + telegram: telegramChannel, +} + +class NotificationQueue { + constructor() { + this.tasks = [] + this.processing = 0 + this.maxConcurrent = 5 + this.intervalMs = 2000 + this.running = false + } + + enqueue(task) { + this.tasks.push({ ...task, enqueuedAt: Date.now() }) + } + + start() { + if (this.running) return + this.running = true + this._tick() + } + + stop() { + this.running = false + } + + _tick() { + if (!this.running) return + + this._processAvailable() + + setTimeout(() => this._tick(), this.intervalMs) + } + + _processAvailable() { + while (this.tasks.length > 0 && this.processing < this.maxConcurrent) { + const task = this.tasks.shift() + this.processing++ + this._execute(task).finally(() => { + this.processing-- + }) + } + } + + async _execute(task) { + const channel = channels[task.channel] + if (!channel) { + await this._markFailed(task.logId, `Unknown channel: ${task.channel}`) + return + } + + try { + const result = await channel.send({ + recipient: task.recipient, + eventType: task.eventType, + payload: task.payload, + }) + + if (result.success) { + await this._markSent(task.logId) + } else { + await this._handleFailure(task.logId, task, result.error) + } + } catch (err) { + await this._handleFailure(task.logId, task, err.message) + } + } + + async _markSent(logId) { + await prisma.notificationLog.update({ + where: { id: logId }, + data: { status: SENT }, + }) + } + + async _markFailed(logId, error) { + await prisma.notificationLog.update({ + where: { id: logId }, + data: { status: FAILED, error }, + }) + } + + async _handleFailure(logId, task, error) { + const log = await prisma.notificationLog.findUnique({ where: { id: logId } }) + const newAttempts = (log?.attempts || 0) + 1 + + if (newAttempts >= MAX_RETRY_ATTEMPTS) { + await this._markFailed(logId, error) + return + } + + await prisma.notificationLog.update({ + where: { id: logId }, + data: { attempts: newAttempts }, + }) + + const delay = RETRY_DELAYS_MS[newAttempts - 1] || RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] + setTimeout(() => { + this.enqueue({ ...task, logId }) + }, delay) + } + + async flushPendingOnStartup() { + const pending = await prisma.notificationLog.findMany({ + where: { status: PENDING }, + }) + for (const log of pending) { + await prisma.notificationLog.update({ + where: { id: log.id }, + data: { status: FAILED, error: 'Server restarted, pending notification lost' }, + }) + } + if (pending.length > 0) { + console.log(`[notifications] Marked ${pending.length} pending notifications as failed on startup`) + } + } +} + +export function createNotificationQueue() { + return new NotificationQueue() +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/lib/notifications/queue.js +git commit -m "feat: add notification queue with retry worker" +``` + +--- + +### Task 10: Wire up EventBus in server index and register routes + +**Files:** +- Modify: `server/src/index.js` +- Modify: `server/src/routes/api.js` +- Create: `server/src/routes/api/admin/notifications.js` +- Create: `server/src/routes/user/notifications.js` + +- [ ] **Step 1: Create admin notification settings API route** + +```js +// server/src/routes/api/admin/notifications.js +import { prisma } from '../../lib/prisma.js' + +export async function registerAdminNotificationRoutes(fastify) { + fastify.get( + '/api/admin/notifications/settings', + { preHandler: [fastify.verifyAdmin] }, + async () => { + let settings = await prisma.adminNotificationSettings.findFirst() + if (!settings) { + settings = await prisma.adminNotificationSettings.create({ + data: { + emailEnabled: true, + telegramEnabled: false, + newOrder: true, + newOrderMessage: true, + newReview: true, + authCodeDuplicate: false, + }, + }) + } + return { settings } + }, + ) + + fastify.put( + '/api/admin/notifications/settings', + { preHandler: [fastify.verifyAdmin] }, + async (request) => { + 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) + + if (!settings) { + settings = await prisma.adminNotificationSettings.create({ data }) + } else { + settings = await prisma.adminNotificationSettings.update({ + where: { id: settings.id }, + data, + }) + } + + return { settings } + }, + ) + + // Telegram webhook handler for /start command + 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() + + if (settings) { + await prisma.adminNotificationSettings.update({ + where: { id: settings.id }, + data: { telegramChatId: chatId }, + }) + } else { + await prisma.adminNotificationSettings.create({ + data: { telegramChatId: chatId }, + }) + } + + // Send confirmation back to user via Telegram + 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.', + }), + }) + } + + return { ok: true } + }, + ) +} +``` + +- [ ] **Step 2: Create user notification settings API route** + +```js +// server/src/routes/user/notifications.js +import { prisma } from '../lib/prisma.js' +import { ensureUserNotificationPreference } from '../lib/notifications/preferences.js' + +export async function registerUserNotificationRoutes(fastify) { + fastify.get( + '/api/me/notifications/settings', + { preHandler: [fastify.authenticate] }, + async (request) => { + const userId = request.user.sub + const prefs = await ensureUserNotificationPreference(userId) + return { settings: prefs } + }, + ) + + fastify.put( + '/api/me/notifications/settings', + { preHandler: [fastify.authenticate] }, + async (request) => { + const userId = request.user.sub + const body = request.body || {} + + const data = {} + if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled) + if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated) + if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged) + if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived) + if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged) + + const prefs = await prisma.notificationPreference.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + }) + + return { settings: prefs } + }, + ) +} +``` + +- [ ] **Step 3: Modify index.js to initialize notifications** + +Replace `server/src/index.js` content: + +```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' + +const port = Number(process.env.PORT) || 3333 +const origin = (process.env.CORS_ORIGIN ?? '') + .split(',') + .map((s) => s.trim()) + .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', +}) + +await fastify.register(multipart, { + limits: { + files: 10, + fileSize: getProductImageMaxFileBytes(), + }, +}) + +registerUploadsResized(fastify) + +const uploadsDir = path.join(process.cwd(), 'uploads') +await fastify.register(fastifyStatic, { + root: uploadsDir, + prefix: '/uploads/', + setHeaders(res, filePath) { + if (filePath.includes('/.cache/')) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + } else { + res.setHeader('Cache-Control', 'public, max-age=86400') + } + }, +}) + +fastify.decorate('authenticate', async function authenticate(request, reply) { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Не авторизован' }) + } +}) + +// Initialize notification system +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() + +// Flush stale pending notifications and start queue +await notificationQueue.flushPendingOnStartup() +notificationQueue.start() + +// Register notification event listeners +const { + ORDER_CREATED, + ORDER_STATUS_CHANGED, + ORDER_MESSAGE_SENT, + ORDER_MESSAGE_ADMIN_REPLY, + PAYMENT_STATUS_CHANGED, + AUTH_CODE_REQUESTED, +} = NOTIFICATION_EVENTS + +async function dispatchNotification(eventType, payload) { + // User-targeted notifications + 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', + payload: JSON.stringify(payload), + }, + }) + notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }) + } + + // Admin notifications (for order:created:admin, orderMessage:sent, review:created) + 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', + payload: JSON.stringify(payload), + }, + }) + 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) + +// Graceful shutdown +async function shutdown() { + notificationQueue.stop() + await fastify.close() + process.exit(0) +} +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) + +try { + await fastify.listen({ port, host: '0.0.0.0' }) +} catch (err) { + fastify.log.error(err) + process.exit(1) +} +``` + +- [ ] **Step 4: Register admin notification routes in api.js** + +Add to `server/src/routes/api.js`: + +Import at top: +```js +import { registerAdminNotificationRoutes } from './api/admin/notifications.js' +``` + +Inside `registerApiRoutes`, add after `await registerAdminUserRoutes(fastify)`: +```js + await registerAdminNotificationRoutes(fastify) +``` + +- [ ] **Step 5: Commit** + +```bash +git add server/src/index.js server/src/routes/api.js server/src/routes/api/admin/notifications.js server/src/routes/user/notifications.js +git commit -m "feat: wire up notification system in server" +``` + +--- + +### Task 11: Emit events from existing routes + +**Files:** +- Modify: `server/src/routes/user-orders.js` +- Modify: `server/src/routes/api/admin-orders.js` +- Modify: `server/src/routes/user-messages.js` +- Modify: `server/src/routes/user-payments.js` +- Modify: `server/src/routes/auth.js` +- Modify: `server/src/lib/auth.js` + +- [ ] **Step 1: Emit `order:created` in user-orders.js** + +In `server/src/routes/user-orders.js`, add import at top: + +```js +import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +``` + +After the `return reply.code(201).send({ orderId: created.id })` line (line 159), before the return, add event emission. Replace line 159: + +```js + // Emit notification event + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, { + orderId: created.id, + userId, + totalCents: created.totalCents, + itemsCount: cartItems.length, + }) + + // Also emit admin notification + request.server.eventBus.emit('order:created:admin', { + orderId: created.id, + userId, + userEmail: request.user.email || '', + totalCents: created.totalCents, + itemsCount: cartItems.length, + }) + + return reply.code(201).send({ orderId: created.id }) +``` + +- [ ] **Step 2: Emit `order:statusChanged` in admin-orders.js** + +In `server/src/routes/api/admin-orders.js`, add import: + +```js +import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +``` + +After the status update (line 110-111), replace the return: + +```js + 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 } +``` + +- [ ] **Step 3: Emit `orderMessage:adminReply` in admin-orders.js** + +In the `POST /api/admin/orders/:id/messages` handler, after creating the message (line 158), replace the return: + +```js + 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, + }) + + return reply.code(201).send({ item: msg }) +``` + +- [ ] **Step 4: Emit `orderMessage:sent` in user-messages.js** + +In `server/src/routes/user-messages.js`, add import: + +```js +import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +``` + +In the `POST /api/me/orders/:id/messages` handler, after creating the message (line 28), replace the return: + +```js + 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', + messageId: msg.id, + preview: text, + }) + + return reply.code(201).send({ item: msg }) +``` + +- [ ] **Step 5: Wire up event listeners in index.js** + +Already done in Task 10 Step 3 — the event listeners are registered in `index.js` after `notificationQueue.start()`. No additional changes needed here. + +- [ ] **Step 6: Emit `review:created` in admin-reviews.js** + +In `server/src/routes/api/admin-reviews.js`, add import: + +```js +import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +``` + +In the `PATCH /api/admin/reviews/:id` handler, after the review is updated (after line 54), before the return: + +```js + const updated = await prisma.review.update({ + where: { id }, + data: { + status: action === 'approve' ? 'approved' : 'rejected', + moderatedAt: new Date(), + }, + }) + + request.server.eventBus.emit('review:created', { + rating: updated.rating, + text: updated.text || '', + productTitle: existing.product?.title || '', + userName: existing.user?.name || existing.user?.email || '', + reviewId: updated.id, + }) + + return { item: updated } +``` + +Note: The existing query doesn't include product and user relations. We need to fetch them. Replace the existing review fetch: + +```js + const existing = await prisma.review.findUnique({ + where: { id }, + include: { product: { select: { title: true } }, user: { select: { name: true, email: true } } }, + }) +``` + +In `server/src/lib/auth.js`, modify `issueEmailCode` to accept and use the eventBus. Since `issueEmailCode` is called from routes that have access to `request`, we need a different approach. Instead, emit the event in the route handler. + +In `server/src/routes/auth.js`, add import: + +```js +import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +``` + +In `POST /api/auth/request-code`, after `await issueEmailCode(...)`, add: + +```js + await issueEmailCode({ email, purpose: 'login' }) + + // Check if this is admin + const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() + const isAdmin = email === adminEmail + + request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { + email, + code: 'emitted-via-issueEmailCode', // code is not available here, handled separately + isAdmin, + }) + + return { ok: true } +``` + +Actually, the code is generated inside `issueEmailCode`. We need to refactor `issueEmailCode` to return the code. Modify `server/src/lib/auth.js`: + +Replace `issueEmailCode`: + +```js +export async function issueEmailCode({ email, purpose, userId = null }) { + const code = randomCode6() + const expiresAt = new Date(Date.now() + 10 * 60 * 1000) + await prisma.authCode.create({ + data: { + email, + purpose, + userId, + codeHash: sha256(`${email}:${purpose}:${code}:${userId ?? ''}`), + expiresAt, + }, + }) + await sendLoginCodeEmail({ to: email, code }) + return code +} +``` + +Then in `server/src/routes/auth.js`, the `request-code` handler: + +```js + 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 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 } + }) +``` + +- [ ] **Step 7: Emit `auth:codeRequested` in auth.js** + +In `server/src/routes/auth.js`, add import: + +```js +import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +``` + +In `POST /api/auth/request-code`, after `await issueEmailCode(...)`, replace the return: + +```js + const code = await issueEmailCode({ email, purpose: 'login' }) + + 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 } +``` + +- [ ] **Step 8: Emit `payment:statusChanged` in user-payments.js** + +In `server/src/routes/user-payments.js`, add import: + +```js +import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' +``` + +After the payment message is created (after the `prisma.$transaction` block, before `return { ok: true }`), add: + +```js + request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId: id, + userId, + paymentStatus: 'pending', + }) +``` + +- [ ] **Step 9: Create user notification preference on user creation** + +In `server/src/routes/auth.js`, in `POST /api/auth/verify-code`, after user upsert (line 34-38), add: + +```js + 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: {}, + }) +``` + +Add import at top: + +```js +import { prisma } from '../lib/prisma.js' +``` + +(Prisma is already imported in auth.js, check — yes it is.) + +- [ ] **Step 10: Commit** + +```bash +git add server/src/routes/user-orders.js server/src/routes/api/admin-orders.js server/src/routes/user-messages.js server/src/routes/user-payments.js server/src/routes/auth.js server/src/routes/api/admin-reviews.js server/src/lib/auth.js +git commit -m "feat: emit notification events from existing routes" +``` + +--- + +### Task 12: Update bootstrap-admin to create AdminNotificationSettings + +**Files:** +- Modify: `server/src/lib/bootstrap-admin.js` + +- [ ] **Step 1: Add AdminNotificationSettings creation** + +Replace `server/src/lib/bootstrap-admin.js`: + +```js +import { normalizeEmail } from './auth.js' +import { prisma } from './prisma.js' + +export async function ensureAdminUser() { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (!adminEmail) return + if (!adminEmail.includes('@')) { + throw new Error('ADMIN_EMAIL должен быть валидным email') + } + + const admin = await prisma.user.upsert({ + where: { email: adminEmail }, + update: {}, + create: { email: adminEmail }, + }) + + // Ensure admin notification settings exist + const existing = await prisma.adminNotificationSettings.findFirst() + if (!existing) { + await prisma.adminNotificationSettings.create({ + data: { + emailEnabled: true, + telegramEnabled: false, + newOrder: true, + newOrderMessage: true, + newReview: true, + authCodeDuplicate: false, + }, + }) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/lib/bootstrap-admin.js +git commit -m "feat: create admin notification settings on bootstrap" +``` + +--- + +### Task 13: Server tests + +**Files:** +- Create: `server/src/lib/notifications/__tests__/preferences.test.js` +- Create: `server/src/lib/notifications/__tests__/queue.test.js` + +- [ ] **Step 1: Create preferences test** + +```js +// server/src/lib/notifications/__tests__/preferences.test.js +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { prisma } from '../../prisma.js' +import { + resolveUserNotificationTargets, + resolveAdminNotificationTargets, + resolveAuthCodeTargets, + ensureUserNotificationPreference, +} from '../preferences.js' +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' + +describe('preferences', () => { + beforeEach(async () => { + await prisma.notificationPreference.deleteMany() + await prisma.adminNotificationSettings.deleteMany() + await prisma.user.deleteMany() + }) + + afterEach(async () => { + await prisma.notificationPreference.deleteMany() + await prisma.adminNotificationSettings.deleteMany() + await prisma.user.deleteMany() + }) + + it('returns empty targets when user has no preferences', async () => { + const user = await prisma.user.create({ data: { email: 'test@test.com' } }) + const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + expect(targets).toEqual([]) + }) + + it('returns email target when user has preferences enabled', async () => { + const user = await prisma.user.create({ data: { email: 'test@test.com' } }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: true, orderCreated: true }, + }) + const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + expect(targets).toHaveLength(1) + expect(targets[0]).toEqual({ channel: 'email', recipient: 'test@test.com' }) + }) + + it('returns no targets when globalEnabled is false', async () => { + const user = await prisma.user.create({ data: { email: 'test@test.com' } }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: false, orderCreated: true }, + }) + const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + expect(targets).toEqual([]) + }) + + it('returns no targets when specific event is disabled', async () => { + const user = await prisma.user.create({ data: { email: 'test@test.com' } }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: true, orderCreated: false }, + }) + const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + expect(targets).toEqual([]) + }) + + it('ensures user preference is created if not exists', async () => { + const user = await prisma.user.create({ data: { email: 'test@test.com' } }) + const prefs = await ensureUserNotificationPreference(user.id) + expect(prefs.globalEnabled).toBe(true) + expect(prefs.userId).toBe(user.id) + }) + + it('returns admin targets when settings enabled', async () => { + const admin = await prisma.user.create({ data: { email: 'admin@test.com' } }) + const origAdminEmail = process.env.ADMIN_EMAIL + process.env.ADMIN_EMAIL = 'admin@test.com' + + await prisma.adminNotificationSettings.create({ + data: { emailEnabled: true, newOrder: true }, + }) + + const targets = await resolveAdminNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, {}) + expect(targets.some((t) => t.channel === 'email' && t.recipient === 'admin@test.com')).toBe(true) + + process.env.ADMIN_EMAIL = origAdminEmail + }) + + it('resolveAuthCodeTargets returns email for user and telegram for admin', async () => { + await prisma.adminNotificationSettings.create({ + data: { telegramEnabled: true, telegramChatId: '12345', authCodeDuplicate: true }, + }) + + const targets = await resolveAuthCodeTargets(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { + email: 'user@test.com', + code: '123456', + isAdmin: true, + }) + + expect(targets.some((t) => t.channel === 'email' && t.recipient === 'user@test.com')).toBe(true) + expect(targets.some((t) => t.channel === 'telegram' && t.recipient === '12345')).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run server tests** + +```bash +cd server && npm test -- --run src/lib/notifications/__tests__/preferences.test.js +``` + +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add server/src/lib/notifications/__tests__/preferences.test.js +git commit -m "test: add notification preferences tests" +``` + +--- + +### Task 14: Client — API layer + +**Files:** +- Create: `client/src/entities/notification/api/notifications-api.ts` + +- [ ] **Step 1: Create the notifications API client** + +```ts +// client/src/entities/notification/api/notifications-api.ts +import { apiClient } from '@/shared/api/client' + +export interface UserNotificationSettings { + id: string + userId: string + globalEnabled: boolean + orderCreated: boolean + orderStatusChanged: boolean + orderMessageReceived: boolean + paymentStatusChanged: boolean + createdAt: string + updatedAt: string +} + +export interface AdminNotificationSettings { + id: string + emailEnabled: boolean + telegramEnabled: boolean + telegramChatId: string | null + newOrder: boolean + newOrderMessage: boolean + newReview: boolean + authCodeDuplicate: boolean + createdAt: string + updatedAt: string +} + +export async function fetchUserNotificationSettings(): Promise<{ settings: UserNotificationSettings }> { + const { data } = await apiClient.get('/api/me/notifications/settings') + return data +} + +export async function updateUserNotificationSettings( + settings: Partial, +): Promise<{ settings: UserNotificationSettings }> { + const { data } = await apiClient.put('/api/me/notifications/settings', settings) + return data +} + +export async function fetchAdminNotificationSettings(): Promise<{ settings: AdminNotificationSettings }> { + const { data } = await apiClient.get('/api/admin/notifications/settings') + return data +} + +export async function updateAdminNotificationSettings( + settings: Partial, +): Promise<{ settings: AdminNotificationSettings }> { + const { data } = await apiClient.put('/api/admin/notifications/settings', settings) + return data +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add client/src/entities/notification/api/notifications-api.ts +git commit -m "feat: add notification API client functions" +``` + +--- + +### Task 15: Client — User notification settings page + +**Files:** +- Create: `client/src/pages/me/ui/sections/NotificationsPage.tsx` +- Modify: `client/src/pages/me/ui/MeLayoutPage.tsx` + +- [ ] **Step 1: Create the NotificationsPage component** + +```tsx +// client/src/pages/me/ui/sections/NotificationsPage.tsx +import { useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import FormControlLabel from '@mui/material/FormControlLabel' +import Switch from '@mui/material/Switch' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + fetchUserNotificationSettings, + updateUserNotificationSettings, +} from '@/entities/notification/api/notifications-api' + +const eventFields = [ + { key: 'orderCreated' as const, label: 'Заказ создан' }, + { key: 'orderStatusChanged' as const, label: 'Изменение статуса заказа' }, + { key: 'orderMessageReceived' as const, label: 'Сообщение в чате заказа' }, + { key: 'paymentStatusChanged' as const, label: 'Изменение статуса оплаты' }, +] + +export function NotificationsPage() { + const queryClient = useQueryClient() + const [error, setError] = useState(null) + + const { data, isLoading } = useQuery({ + queryKey: ['me', 'notifications', 'settings'], + queryFn: fetchUserNotificationSettings, + }) + + const mutation = useMutation({ + mutationFn: updateUserNotificationSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['me', 'notifications', 'settings'] }) + }, + onError: (err: { response?: { data?: { error?: string } } }) => { + setError(err.response?.data?.error || 'Ошибка сохранения') + }, + }) + + if (isLoading) return Загрузка... + + const settings = data?.settings + if (!settings) return Не удалось загрузить настройки + + const handleToggle = (field: string, value: boolean) => { + setError(null) + mutation.mutate({ [field]: value } as Record) + } + + return ( + + + Оповещения + + + Настройте, какие уведомления вы хотите получать на почту. + + + {error && ( + + {error} + + )} + + + + handleToggle('globalEnabled', e.target.checked)} + /> + } + label={Получать оповещения} + /> + + Включите, чтобы получать уведомления о заказах на почту. + + + + + {eventFields.map(({ key, label }) => ( + handleToggle(key, e.target.checked)} + /> + } + label={label} + /> + ))} + + + + ) +} +``` + +- [ ] **Step 2: Add notifications route and nav item to MeLayoutPage** + +In `client/src/pages/me/ui/MeLayoutPage.tsx`: + +Add import: +```tsx +import { NotificationsPage } from '@/pages/me/ui/sections/NotificationsPage' +``` + +Add `Bell` icon import from lucide-react: +```tsx +import { MapPin, MessageCircle, Settings, SlidersHorizontal, Truck, Bell } from 'lucide-react' +``` + +Add nav item to the `navItems` array: +```tsx + { to: '/me/notifications', label: 'Оповещения', icon: }, +``` + +Add route in the `` block: +```tsx + } /> +``` + +Place it after the `settings` route and before the `*` catch-all. + +- [ ] **Step 3: Commit** + +```bash +git add client/src/pages/me/ui/sections/NotificationsPage.tsx client/src/pages/me/ui/MeLayoutPage.tsx +git commit -m "feat: add user notification settings page" +``` + +--- + +### Task 16: Client — Admin notification settings + +**Files:** +- Create: `client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx` +- Modify: `client/src/pages/admin-layout/index.ts` (or wherever admin routes are defined) + +- [ ] **Step 1: Explore admin layout structure** + +First, read the admin layout files to understand the routing pattern: + +```bash +# Read the admin layout entry point +cat client/src/pages/admin-layout/index.ts +# Read the admin layout UI +ls client/src/pages/admin-layout/ui/ +``` + +Based on the existing pattern, create the admin notifications page. + +- [ ] **Step 2: Create AdminNotificationsPage** + +```tsx +// client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx +import { useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import FormControlLabel from '@mui/material/FormControlLabel' +import Stack from '@mui/material/Stack' +import Switch from '@mui/material/Switch' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + fetchAdminNotificationSettings, + updateAdminNotificationSettings, +} from '@/entities/notification/api/notifications-api' + +export function AdminNotificationsPage() { + const queryClient = useQueryClient() + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'notifications', 'settings'], + queryFn: fetchAdminNotificationSettings, + }) + + const mutation = useMutation({ + mutationFn: updateAdminNotificationSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'notifications', 'settings'] }) + setSuccess(true) + setTimeout(() => setSuccess(false), 3000) + }, + onError: (err: { response?: { data?: { error?: string } } }) => { + setError(err.response?.data?.error || 'Ошибка сохранения') + }, + }) + + if (isLoading) return Загрузка... + + const s = data?.settings + if (!s) return Не удалось загрузить настройки + + const save = (updates: Record) => { + setError(null) + mutation.mutate(updates) + } + + return ( + + + Оповещения + + + Настройка оповещений администратора. + + + {error && ( + + {error} + + )} + {success && ( + + Настройки сохранены + + )} + + + {/* Email */} + + + Email + + save({ emailEnabled: e.target.checked })} + /> + } + label="Получать уведомления на почту" + /> + + + {/* Telegram */} + + + Telegram + + save({ telegramEnabled: e.target.checked })} + /> + } + label="Получать уведомления в Telegram" + /> + {s.telegramEnabled && ( + + save({ telegramChatId: e.target.value })} + helperText="Заполняется автоматически при /start бота" + fullWidth + size="small" + /> + + )} + + + {/* Event types */} + + + Типы уведомлений + + + save({ newOrder: e.target.checked })} + /> + } + label="Новый заказ" + /> + save({ newOrderMessage: e.target.checked })} + /> + } + label="Сообщение в заказе" + /> + save({ newReview: e.target.checked })} + /> + } + label="Новый отзыв" + /> + save({ authCodeDuplicate: e.target.checked })} + /> + } + label="Дублировать код входа в Telegram" + /> + + + + + ) +} +``` + +- [ ] **Step 3: Add admin notifications route to AdminLayoutPage** + +In `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx`: + +Add import: +```tsx +import { Bell } from 'lucide-react' +import { AdminNotificationsPage } from './AdminNotificationsPage' +``` + +Add nav item to the `navItems` array (after the 'info' item): +```tsx + { to: '/admin/notifications', label: 'Оповещения', icon: }, +``` + +Add route in the `` block (before the `*` catch-all): +```tsx + } /> +``` + +- [ ] **Step 4: Commit** + +```bash +git add client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx +git commit -m "feat: add admin notification settings page" +``` + +--- + +### Task 17: Client — run lint, typecheck, build + +- [ ] **Step 1: Run client lint** + +```bash +cd client && npm run lint +``` + +Fix any errors. + +- [ ] **Step 2: Run client build (typecheck)** + +```bash +cd client && npm run build +``` + +Fix any type errors. + +- [ ] **Step 3: Commit any fixes** + +```bash +git add -A +git commit -m "fix: resolve lint and type errors in notification system" +``` + +--- + +### Task 18: Update .env.example + +**Files:** +- Modify: `server/.env.example` + +- [ ] **Step 1: Add TELEGRAM_BOT_TOKEN to .env.example** + +Add to `server/.env.example`: + +``` +# Telegram Bot (optional — для оповещений админа) +TELEGRAM_BOT_TOKEN= +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/.env.example +git commit -m "docs: add TELEGRAM_BOT_TOKEN to env example" +``` + +--- + +### Task 19: Final verification + +- [ ] **Step 1: Run server tests** + +```bash +cd server && npm test +``` + +Expected: All pass. + +- [ ] **Step 2: Run client tests** + +```bash +cd client && npm test +``` + +Expected: All pass. + +- [ ] **Step 3: Run client build** + +```bash +cd client && npm run build +``` + +Expected: Success. diff --git a/docs/superpowers/specs/2026-05-17-admin-image-redesign.md b/docs/superpowers/specs/2026-05-17-admin-image-redesign.md new file mode 100644 index 0000000..2eaf5ee --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-admin-image-redesign.md @@ -0,0 +1,149 @@ +# Admin Image Redesign — Separate Upload, Resize & Attach + +## Problem + +Current admin image flow bundles three concerns into one `POST /api/admin/uploads` call: +1. Upload file to disk +2. Eager resize (generate all `.cache` sizes + convert original to WebP) +3. Register in gallery (upsert GalleryImage) + +This prevents the admin from uploading raw images and deciding later when to process them. Photos attached to products are always "ready", but the admin has no control over when processing happens. + +## Goal + +Separate the concerns into three explicit steps: +1. **Upload** — file lands in gallery, no processing +2. **Resize** — admin triggers image processing per image +3. **Attach** — only processed images can be attached to products / slider + +## Prisma Schema Change + +Add `isResized` field to `GalleryImage`: + +```prisma +model GalleryImage { + id String @id @default(cuid()) + url String @unique + isResized Boolean @default(false) + createdAt DateTime @default(now()) + catalogSliderSlides CatalogSliderSlide[] +} +``` + +**Existing data**: after deploy, run a one-time migration script: +```ts +await prisma.galleryImage.updateMany({ + where: { isResized: false }, + data: { isResized: true }, +}) +``` + +Existing images are already on disk in their processed state (WebP + `.cache`), so marking them `isResized = true` is correct. + +## API Routes + +### New: `POST /api/admin/gallery/upload` +- Multipart file upload +- Saves to `/uploads/.` (original extension preserved, NO WebP conversion) +- Creates `GalleryImage { url, isResized: false }` +- Returns `{ url: string }` + +### New: `POST /api/admin/gallery/:id/resize` +- Reads original from `/uploads/.` +- Calls `convertOriginalToWebp` (converts to `/uploads/.webp`, deletes original) +- Calls `generateAllSizes` (populates `.cache/`) +- Updates `GalleryImage.url` to `/uploads/.webp`, sets `isResized = true` +- Returns `{ url: string }` +- Errors if already resized (409) or image not found (404) + +### Modified: `GET /api/admin/gallery` +- Already returns all fields via Prisma — just add `isResized` to the response +- No endpoint changes needed; client type updates only + +### Modified: `POST /api/admin/uploads` → **REMOVED** +- The old combined upload endpoint is deleted +- It was only used by admin product form + +### Modified: `POST /api/admin/products` / `PATCH /api/admin/products/:id` +- Validate that all passed `imageUrls` have `isResized = true` in GalleryImage +- If any image is not resized → `400 Bad Request` with explanation + +### No changes to: +- `DELETE /api/admin/gallery/:id` (already works correctly) +- `PUT /api/admin/catalog-slider` (slider picks from GalleryImage — handled by gallery endpoint filter) +- `GET /uploads-resized/` (on-demand resizer unchanged) +- All public routes + +## Admin UI — Gallery Page + +**Upload**: Stays as `` → calls new `POST /api/admin/gallery/upload`. + +**Gallery card**: Each image now shows: +- OptimizedImage preview (on-demand resizer still works for display) +- Status badge: "Не обработано" (if `!isResized`) or "Готово" (if `isResized`) +- If `!isResized`: a "Resize" button visible +- If `isResized`: "Resize" hidden, delete button remains +- Existing delete behaviour unchanged (checks usage before deletion) + +**GalleryGrid**: Updated to accept and render `isResized` property, conditionally show resize button. + +**React Query**: Add `resizeGalleryImage` mutation + `uploadGalleryImages` mutation. + +## Admin UI — Product Form + +- Remove direct file upload from `AdminProductsPage` (the `` that calls `uploadAdminProductImages`) +- Keep only "Выбрать из галереи" dialog +- Gallery selection dialog: filter to show only `isResized = true` images +- Existing preview/sort/delete within product card unchanged + +## Admin UI — Slider Section + +- `GallerySliderSection` already uses gallery for selection +- When picking an image for a slide, filter to `isResized = true` + +## Data Flow Summary + +``` +1. Upload + [Admin] → POST /api/admin/gallery/upload → /uploads/.png + → GalleryImage { url: "/uploads/.png", isResized: false } + +2. Resize (triggered manually in gallery) + [Admin] → POST /api/admin/gallery/:id/resize + → convertOriginalToWebp → /uploads/.webp + → generateAllSizes → .cache/_w{320,640,1024,1600}.{avif,webp} + → GalleryImage { url: "/uploads/.webp", isResized: true } + +3. Attach to product / slider + [Admin] → product form / slider form + → gallery picker shows only isResized = true images + → write chosen URLs to ProductImage / CatalogSliderSlide +``` + +## Error Handling + +- Resize of already-resized image → 409 Conflict +- Resize of missing file → 404 Not Found +- Attach unprocessed image to product → 400 Bad Request with message +- Upload invalid file type → 400 (existing validation reused) +- Upload over size limit → 413 (existing validation reused) + +## Testing + +### Server +- Upload endpoint: file saved, no processing, GalleryImage created with `isResized: false` +- Resize endpoint: original converted to WebP, .cache populated, `isResized` flipped to `true` +- Product creation: rejects imageUrls with `isResized: false` +- Gallery GET: includes `isResized` field + +### Client +- Gallery page: badge visible for unprocessed, hidden for processed +- Resize button click → mutation → refetch → updated state +- Product form: no upload button, only "from gallery" picker +- Gallery picker in product/slider: unprocessed images hidden or disabled + +## Rollout + +1. Deploy server changes first (schema migration + new routes, remove old upload route) +2. One-time migration to mark existing images as resized +3. Deploy client changes diff --git a/docs/superpowers/specs/2026-05-18-notification-system-design.md b/docs/superpowers/specs/2026-05-18-notification-system-design.md new file mode 100644 index 0000000..2eb650c --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-notification-system-design.md @@ -0,0 +1,221 @@ +# Design: Notification System + +**Date:** 2026-05-18 +**Status:** Draft — awaiting review + +## 1. Overview + +Система оповещений для craftshop: email для пользователей, email + Telegram для админа. +Архитектура — event-driven с in-memory очередью и retry. Задел на подключение новых каналов (WhatsApp, Viber, push). + +## 2. Architecture + +### 2.1 Components + +``` +server/src/ + lib/ + email.js ← расширяется: sendNotificationEmail() + notifications/ + event-bus.js ← EventEmitter, центральный хаб + queue.js ← in-memory очередь + воркер + channels/ + email-channel.js ← отправка email через nodemailer + telegram-channel.js ← отправка через Telegram Bot API + templates/ + email-templates.js ← HTML-шаблоны писем + telegram-templates.js ← форматированные сообщения TG + preferences.js ← CRUD настроек оповещений + routes/ + api/ + admin/ + notifications.js ← GET/PUT настройки админа + user/ + notifications.js ← GET/PUT настройки пользователя +``` + +### 2.2 Database (Prisma) + +#### NotificationPreference (настройки пользователя) + +| Field | Type | Description | +|---|---|---| +| id | String (cuid) | Primary key | +| userId | String (cuid) | FK → User (unique) | +| globalEnabled | Boolean | Главный переключатель | +| orderCreated | Boolean | Заказ создан | +| orderStatusChanged | Boolean | Статус заказа изменён | +| orderMessageReceived | Boolean | Новое сообщение в чате заказа | +| paymentStatusChanged | Boolean | Статус оплаты изменён | +| createdAt | DateTime | | +| updatedAt | DateTime | | + +#### AdminNotificationSettings (настройки админа) + +| Field | Type | Description | +|---|---|---| +| id | String (cuid) | Primary key | +| emailEnabled | Boolean | Email вкл/выкл | +| telegramEnabled | Boolean | Telegram вкл/выкл | +| telegramChatId | String? | ID чата админа с ботом | +| newOrder | Boolean | Новый заказ | +| newOrderMessage | Boolean | Новое сообщение в заказе | +| newReview | Boolean | Новый отзыв | +| authCodeDuplicate | Boolean | Дублировать код входа в TG | +| createdAt | DateTime | | +| updatedAt | DateTime | | + +#### NotificationLog (лог отправки) + +| Field | Type | Description | +|---|---|---| +| id | String (cuid) | Primary key | +| userId | String? | FK → User (null для админа) | +| eventType | String | Тип события | +| channel | String | 'email' | 'telegram' | +| status | String | 'pending' | 'sent' | 'failed' | +| error | String? | Текст ошибки | +| payload | Json | Данные события | +| attempts | Int | Количество попыток | +| createdAt | DateTime | | +| updatedAt | DateTime | | + +### 2.3 Queue + +- In-memory массив задач +- Воркер: `setInterval` каждые 2 секунды, максимум 5 параллельных отправок +- Retry: 3 попытки с задержкой 5с, 30с, 120с +- При рестарте сервера: все `pending` записи помечаются как `failed` + +## 3. Events + +| Event | Triggered in | Payload | Recipients | +|---|---|---|---| +| `order:created` | user-orders.js | orderId, userId, orderData | User (orderCreated), Admin (newOrder) | +| `order:statusChanged` | admin-orders.js | orderId, userId, oldStatus, newStatus | User (orderStatusChanged) | +| `orderMessage:sent` | user-messages.js | orderId, authorType, messageId | Admin (newOrderMessage) | +| `orderMessage:adminReply` | admin-orders.js | orderId, userId, messageId | User (orderMessageReceived) | +| `payment:statusChanged` | user-payments.js | orderId, userId, paymentStatus | User (paymentStatusChanged) | +| `auth:codeRequested` | auth.js | email, code, isAdmin | User (email), Admin (authCodeDuplicate if isAdmin) | + +## 4. Data Flow + +``` +Роут → eventBus.emit(eventType, payload) + → preferences.resolveRecipients(eventType, payload) + → для каждого получателя: + → NotificationLog.create({ status: 'pending' }) + → queue.enqueue({ recipient, channel, eventType, payload }) + → ответ API (без ожидания отправки) + +Воркер (каждые 2с, до 5 параллельно): + → queue.dequeue() + → channel.send(job) + → NotificationLog.update({ status: 'sent' | 'failed', attempts++ }) + → если failed и attempts < 3 → re-enqueue с delay +``` + +## 5. Channel Interface + +Каждый канал реализует: + +```js +{ + name: 'email' | 'telegram', + send(job: { recipient, payload, template }): Promise<{ success: boolean, error?: string }> +} +``` + +### 5.1 Email Channel + +- Использует существующий nodemailer transporter из `email.js` +- `sendNotificationEmail({ to, subject, html })` +- HTML-шаблоны в `email-templates.js` + +### 5.2 Telegram Channel + +- Telegram Bot API: `POST https://api.telegram.org/bot/sendMessage` +- `node-telegram-bot-api` или прямой fetch +- Форматирование: HTML parse mode +- Шаблоны в `telegram-templates.js` + +## 6. Client-Side + +### 6.1 User Notification Settings Page + +- Route: `/me/notifications` +- MUI переключатели: + - Главный toggle "Получать оповещения" (globalEnabled) + - При включённом: 4 toggles для каждого типа события + - При выключенном: все остальные toggles disabled +- Сохранение через `apiClient` + `@tanstack/react-query` mutation + invalidate + +### 6.2 Admin Notification Settings + +- Встраивается в существующую админку +- Toggle email, toggle telegram +- Если telegram включён — поле telegramChatId (заполняется автоматически при /start бота) +- Toggle для каждого типа события + toggle дублирования кода входа + +## 7. Error Handling + +| Scenario | Behavior | +|---|---| +| SMTP/Telegram недоступен | Retry 3 раза (5с → 30с → 120с), затем failed | +| Невалидный email / chatId | Сразу failed, без retry | +| Ошибка рендера шаблона | failed, лог в NotificationLog.error | +| Сервер рестарт | pending → failed при старте воркера | + +## 8. Security + +- `TELEGRAM_BOT_TOKEN` — только в `.dev_env`, не коммитится +- Telegram chatId запоминается при `/start` от админа +- Настройки пользователя — только через `fastify.authenticate` +- Настройки админа — только через `fastify.verifyAdmin` + +## 9. Extensibility + +### Adding a new channel (WhatsApp, Viber, push) + +1. Новый файл в `channels/` с интерфейсом `{ name, send(job) }` +2. Регистрация в `queue.js` +3. Никакие другие файлы не меняются + +### Adding a new event type + +1. Добавить в константы типов событий +2. Добавить поле в `NotificationPreference` / `AdminNotificationSettings` +3. Эмитить через `eventBus.emit()` в нужном роуте + +### Adding new recipients (broadcasts) + +- `NotificationLog.userId` nullable — поддерживает системные события +- Очередь поддерживает batch-задачи + +## 10. Environment Variables + +| Variable | Description | Required | +|---|---|---| +| SMTP_HOST | SMTP сервер | Да (для email) | +| SMTP_PORT | SMTP порт | Да | +| SMTP_SECURE | SSL/TLS | Да | +| SMTP_USER | SMTP логин | Да | +| SMTP_PASS | SMTP пароль | Да | +| MAIL_FROM | From address | Да | +| TELEGRAM_BOT_TOKEN | Токен Telegram бота | Для Telegram канала | + +## 11. Implementation Notes + +- `eventBus` декорируется на fastify instance (как `slugify`, `parseMaterialsInput`) +- `bootstrap-admin.js` создаёт `AdminNotificationSettings` при создании админа +- При создании пользователя — создаётся `NotificationPreference` с defaults (всё включено) +- Существующий `sendLoginCodeEmail` остаётся, добавляется `sendNotificationEmail` + +## 12. Telegram Bot — Setup Flow + +1. Админ запускает бота командой `/start` +2. Бот проверяет, что sender — админ (сверка email через webhook или ручной ввод `TELEGRAM_ADMIN_CHAT_ID` в `.dev_env`) +3. Если совпадает — сохраняет `chatId` в `AdminNotificationSettings.telegramChatId` +4. Если `telegramChatId` уже установлен — бот просто подтверждает подписку + +**Fallback:** если webhook не настроен, админ вручную вписывает свой chatId в настройки админки. diff --git a/server/src/lib/notifications/__tests__/preferences.test.js b/server/src/lib/notifications/__tests__/preferences.test.js new file mode 100644 index 0000000..0144a6d --- /dev/null +++ b/server/src/lib/notifications/__tests__/preferences.test.js @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { prisma } from '../../prisma.js' +import { + resolveUserNotificationTargets, + resolveAdminNotificationTargets, + resolveAuthCodeTargets, + ensureUserNotificationPreference, +} from '../preferences.js' +import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' + +describe('preferences', () => { + beforeEach(async () => { + await prisma.notificationPreference.deleteMany() + await prisma.adminNotificationSettings.deleteMany() + await prisma.user.deleteMany() + }) + + afterEach(async () => { + await prisma.notificationPreference.deleteMany() + await prisma.adminNotificationSettings.deleteMany() + await prisma.user.deleteMany() + }) + + it('returns empty targets when user has no preferences', async () => { + const user = await prisma.user.create({ data: { email: 'test@test.com' } }) + const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + expect(targets).toEqual([]) + }) + + it('returns email target when user has preferences enabled', async () => { + const user = await prisma.user.create({ data: { email: 'test@test.com' } }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: true, orderCreated: true }, + }) + const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + expect(targets).toHaveLength(1) + expect(targets[0]).toEqual({ channel: 'email', recipient: 'test@test.com' }) + }) + + it('returns no targets when globalEnabled is false', async () => { + const user = await prisma.user.create({ data: { email: 'test@test.com' } }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: false, orderCreated: true }, + }) + const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + expect(targets).toEqual([]) + }) + + it('returns no targets when specific event is disabled', async () => { + const user = await prisma.user.create({ data: { email: 'test@test.com' } }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: true, orderCreated: false }, + }) + const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + expect(targets).toEqual([]) + }) + + it('ensures user preference is created if not exists', async () => { + const user = await prisma.user.create({ data: { email: 'test@test.com' } }) + const prefs = await ensureUserNotificationPreference(user.id) + expect(prefs.globalEnabled).toBe(true) + expect(prefs.userId).toBe(user.id) + }) + + it('returns admin targets when settings enabled', async () => { + const admin = await prisma.user.create({ data: { email: 'admin@test.com' } }) + const origAdminEmail = process.env.ADMIN_EMAIL + process.env.ADMIN_EMAIL = 'admin@test.com' + + await prisma.adminNotificationSettings.create({ + data: { emailEnabled: true, newOrder: true }, + }) + + const targets = await resolveAdminNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, {}) + expect(targets.some((t) => t.channel === 'email' && t.recipient === 'admin@test.com')).toBe(true) + + process.env.ADMIN_EMAIL = origAdminEmail + }) + + it('resolveAuthCodeTargets returns email for user and telegram for admin', async () => { + await prisma.adminNotificationSettings.create({ + data: { telegramEnabled: true, telegramChatId: '12345', authCodeDuplicate: true }, + }) + + const targets = await resolveAuthCodeTargets(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { + email: 'user@test.com', + code: '123456', + isAdmin: true, + }) + + expect(targets.some((t) => t.channel === 'email' && t.recipient === 'user@test.com')).toBe(true) + expect(targets.some((t) => t.channel === 'telegram' && t.recipient === '12345')).toBe(true) + }) +}) diff --git a/server/src/lib/notifications/preferences.js b/server/src/lib/notifications/preferences.js index 7ae2e15..0ce256c 100644 --- a/server/src/lib/notifications/preferences.js +++ b/server/src/lib/notifications/preferences.js @@ -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' const { ORDER_CREATED, diff --git a/server/src/lib/notifications/queue.js b/server/src/lib/notifications/queue.js index 6658069..c8cd011 100644 --- a/server/src/lib/notifications/queue.js +++ b/server/src/lib/notifications/queue.js @@ -1,5 +1,5 @@ import { prisma } from '../prisma.js' -import { NOTIFICATION_STATUSES, MAX_RETRY_ATTEMPTS, RETRY_DELAYS_MS } from '../../../shared/constants/notification-events.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' From ea0d6bdb918178f862925372b2d7da830c723ffb Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:47:31 +0500 Subject: [PATCH 24/28] feat: add notification API client functions --- .../notification/api/notifications-api.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 client/src/entities/notification/api/notifications-api.ts diff --git a/client/src/entities/notification/api/notifications-api.ts b/client/src/entities/notification/api/notifications-api.ts new file mode 100644 index 0000000..7a3d35e --- /dev/null +++ b/client/src/entities/notification/api/notifications-api.ts @@ -0,0 +1,50 @@ +import { apiClient } from '@/shared/api/client' + +export interface UserNotificationSettings { + id: string + userId: string + globalEnabled: boolean + orderCreated: boolean + orderStatusChanged: boolean + orderMessageReceived: boolean + paymentStatusChanged: boolean + createdAt: string + updatedAt: string +} + +export interface AdminNotificationSettings { + id: string + emailEnabled: boolean + telegramEnabled: boolean + telegramChatId: string | null + newOrder: boolean + newOrderMessage: boolean + newReview: boolean + authCodeDuplicate: boolean + createdAt: string + updatedAt: string +} + +export async function fetchUserNotificationSettings(): Promise<{ settings: UserNotificationSettings }> { + const { data } = await apiClient.get<{ settings: UserNotificationSettings }>('me/notifications/settings') + return data +} + +export async function updateUserNotificationSettings( + settings: Partial, +): Promise<{ settings: UserNotificationSettings }> { + const { data } = await apiClient.put<{ settings: UserNotificationSettings }>('me/notifications/settings', settings) + return data +} + +export async function fetchAdminNotificationSettings(): Promise<{ settings: AdminNotificationSettings }> { + const { data } = await apiClient.get<{ settings: AdminNotificationSettings }>('admin/notifications/settings') + return data +} + +export async function updateAdminNotificationSettings( + settings: Partial, +): Promise<{ settings: AdminNotificationSettings }> { + const { data } = await apiClient.put<{ settings: AdminNotificationSettings }>('admin/notifications/settings', settings) + return data +} From dfec821545d5817dd58912e2f9a1232c2e29543e Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:51:41 +0500 Subject: [PATCH 25/28] feat: add user notification settings page --- client/src/pages/me/ui/MeLayoutPage.tsx | 5 +- .../me/ui/sections/NotificationsPage.tsx | 99 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/me/ui/sections/NotificationsPage.tsx diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx index d97dab6..5998257 100644 --- a/client/src/pages/me/ui/MeLayoutPage.tsx +++ b/client/src/pages/me/ui/MeLayoutPage.tsx @@ -16,11 +16,12 @@ import Typography from '@mui/material/Typography' import useMediaQuery from '@mui/material/useMediaQuery' import { useQuery } from '@tanstack/react-query' import { useUnit } from 'effector-react' -import { MapPin, MessageCircle, Settings, SlidersHorizontal, Truck } from 'lucide-react' +import { MapPin, MessageCircle, Settings, SlidersHorizontal, Truck, Bell } from 'lucide-react' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { fetchUnreadMessageCount } from '@/entities/user/api/messages-api' import { AddressesPage } from '@/pages/me/ui/sections/AddressesPage' import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage' +import { NotificationsPage } from '@/pages/me/ui/sections/NotificationsPage' import { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage' import { OrdersPage } from '@/pages/me/ui/sections/OrdersPage' import { SettingsPage } from '@/pages/me/ui/sections/SettingsPage' @@ -56,6 +57,7 @@ export function MeLayoutPage() { { to: '/me/messages', label: 'Сообщения', icon: }, { to: '/me/settings', label: 'Настройки', icon: }, { to: '/me/addresses', label: 'Адреса доставки', icon: }, + { to: '/me/notifications', label: 'Оповещения', icon: }, ], [], ) @@ -189,6 +191,7 @@ export function MeLayoutPage() { } /> } /> } /> + } /> } />
diff --git a/client/src/pages/me/ui/sections/NotificationsPage.tsx b/client/src/pages/me/ui/sections/NotificationsPage.tsx new file mode 100644 index 0000000..2458020 --- /dev/null +++ b/client/src/pages/me/ui/sections/NotificationsPage.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import FormControlLabel from '@mui/material/FormControlLabel' +import Stack from '@mui/material/Stack' +import Switch from '@mui/material/Switch' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + fetchUserNotificationSettings, + updateUserNotificationSettings, +} from '@/entities/notification/api/notifications-api' + +const eventFields = [ + { key: 'orderCreated' as const, label: 'Заказ создан' }, + { key: 'orderStatusChanged' as const, label: 'Изменение статуса заказа' }, + { key: 'orderMessageReceived' as const, label: 'Сообщение в чате заказа' }, + { key: 'paymentStatusChanged' as const, label: 'Изменение статуса оплаты' }, +] + +export function NotificationsPage() { + const queryClient = useQueryClient() + const [error, setError] = useState(null) + + const { data, isLoading } = useQuery({ + queryKey: ['me', 'notifications', 'settings'], + queryFn: fetchUserNotificationSettings, + }) + + const mutation = useMutation({ + mutationFn: updateUserNotificationSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['me', 'notifications', 'settings'] }) + }, + onError: (err: { response?: { data?: { error?: string } } }) => { + setError(err.response?.data?.error || 'Ошибка сохранения') + }, + }) + + if (isLoading) return Загрузка... + + const settings = data?.settings + if (!settings) return Не удалось загрузить настройки + + const handleToggle = (field: string, value: boolean) => { + setError(null) + mutation.mutate({ [field]: value } as Record) + } + + return ( + + + Оповещения + + + Настройте, какие уведомления вы хотите получать на почту. + + + {error && ( + + {error} + + )} + + + + handleToggle('globalEnabled', e.target.checked)} + /> + } + label={Получать оповещения} + /> + + Включите, чтобы получать уведомления о заказах на почту. + + + + + {eventFields.map(({ key, label }) => ( + handleToggle(key, e.target.checked)} + /> + } + label={label} + /> + ))} + + + + ) +} From 6054ef4c0668977f5832409576d626f788bf17fb Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:55:45 +0500 Subject: [PATCH 26/28] feat: add admin notification settings page --- .../pages/admin-layout/ui/AdminLayoutPage.tsx | 5 +- .../ui/AdminNotificationsPage.tsx | 132 ++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index 72a6674..8dfb726 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography' import useMediaQuery from '@mui/material/useMediaQuery' import { useQuery } from '@tanstack/react-query' import { useUnit } from 'effector-react' -import { FileText, Image, LayoutGrid, ListOrdered, MessageSquare, Store, Users } from 'lucide-react' +import { Bell, FileText, Image, LayoutGrid, ListOrdered, MessageSquare, Store, Users } from 'lucide-react' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api' import { AdminCategoriesPage } from '@/pages/admin-categories' @@ -26,6 +26,7 @@ import { AdminProductsPage } from '@/pages/admin-products' import { AdminReviewsPage } from '@/pages/admin-reviews' import { AdminUsersPage } from '@/pages/admin-users' import { $user } from '@/shared/model/auth' +import { AdminNotificationsPage } from './AdminNotificationsPage' type NavItem = { to: string @@ -61,6 +62,7 @@ export function AdminLayoutPage() { { to: '/admin/reviews', label: 'Отзывы', icon: }, { to: '/admin/users', label: 'Пользователи', icon: }, { to: '/admin/info', label: 'Инфо-страница', icon: }, + { to: '/admin/notifications', label: 'Оповещения', icon: }, ], [], ) @@ -188,6 +190,7 @@ export function AdminLayoutPage() { } /> } /> } /> + } /> } /> diff --git a/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx b/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx new file mode 100644 index 0000000..3508376 --- /dev/null +++ b/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import FormControlLabel from '@mui/material/FormControlLabel' +import Stack from '@mui/material/Stack' +import Switch from '@mui/material/Switch' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + fetchAdminNotificationSettings, + updateAdminNotificationSettings, +} from '@/entities/notification/api/notifications-api' + +export function AdminNotificationsPage() { + const queryClient = useQueryClient() + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'notifications', 'settings'], + queryFn: fetchAdminNotificationSettings, + }) + + const mutation = useMutation({ + mutationFn: updateAdminNotificationSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'notifications', 'settings'] }) + setSuccess(true) + setTimeout(() => setSuccess(false), 3000) + }, + onError: (err: { response?: { data?: { error?: string } } }) => { + setError(err.response?.data?.error || 'Ошибка сохранения') + }, + }) + + if (isLoading) return Загрузка... + + const s = data?.settings + if (!s) return Не удалось загрузить настройки + + const save = (updates: Record) => { + setError(null) + mutation.mutate(updates) + } + + return ( + + + Оповещения + + + Настройка оповещений администратора. + + + {error && ( + + {error} + + )} + {success && ( + + Настройки сохранены + + )} + + + + + Email + + save({ emailEnabled: e.target.checked })} />} + label="Получать уведомления на почту" + /> + + + + + Telegram + + save({ telegramEnabled: e.target.checked })} /> + } + label="Получать уведомления в Telegram" + /> + {s.telegramEnabled && ( + + save({ telegramChatId: e.target.value })} + helperText="Заполняется автоматически при /start бота" + fullWidth + size="small" + /> + + )} + + + + + Типы уведомлений + + + save({ newOrder: e.target.checked })} />} + label="Новый заказ" + /> + save({ newOrderMessage: e.target.checked })} /> + } + label="Сообщение в заказе" + /> + save({ newReview: e.target.checked })} />} + label="Новый отзыв" + /> + save({ authCodeDuplicate: e.target.checked })} /> + } + label="Дублировать код входа в Telegram" + /> + + + + + ) +} From 6912008a2c1716e4d9165e868bae87dc06699d58 Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:58:30 +0500 Subject: [PATCH 27/28] test: add notification preferences tests --- server/.env.example | 3 +++ .../notifications/__tests__/preferences.test.js | 16 +++++++++------- server/vitest.config.js | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 server/vitest.config.js diff --git a/server/.env.example b/server/.env.example index 5b15d0a..81fea73 100644 --- a/server/.env.example +++ b/server/.env.example @@ -28,3 +28,6 @@ VK_CLIENT_SECRET= # Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback YANDEX_CLIENT_ID= YANDEX_CLIENT_SECRET= + +# Telegram Bot (оповещения админа) +TELEGRAM_BOT_TOKEN= diff --git a/server/src/lib/notifications/__tests__/preferences.test.js b/server/src/lib/notifications/__tests__/preferences.test.js index 0144a6d..6986fe4 100644 --- a/server/src/lib/notifications/__tests__/preferences.test.js +++ b/server/src/lib/notifications/__tests__/preferences.test.js @@ -6,7 +6,9 @@ import { resolveAuthCodeTargets, ensureUserNotificationPreference, } from '../preferences.js' -import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' + +const ORDER_CREATED = 'order:created' +const AUTH_CODE_REQUESTED = 'auth:codeRequested' describe('preferences', () => { beforeEach(async () => { @@ -23,7 +25,7 @@ describe('preferences', () => { it('returns empty targets when user has no preferences', async () => { const user = await prisma.user.create({ data: { email: 'test@test.com' } }) - const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id }) expect(targets).toEqual([]) }) @@ -32,7 +34,7 @@ describe('preferences', () => { await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true, orderCreated: true }, }) - const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id }) expect(targets).toHaveLength(1) expect(targets[0]).toEqual({ channel: 'email', recipient: 'test@test.com' }) }) @@ -42,7 +44,7 @@ describe('preferences', () => { await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: false, orderCreated: true }, }) - const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id }) expect(targets).toEqual([]) }) @@ -51,7 +53,7 @@ describe('preferences', () => { await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true, orderCreated: false }, }) - const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) + const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id }) expect(targets).toEqual([]) }) @@ -71,7 +73,7 @@ describe('preferences', () => { data: { emailEnabled: true, newOrder: true }, }) - const targets = await resolveAdminNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, {}) + const targets = await resolveAdminNotificationTargets(ORDER_CREATED, {}) expect(targets.some((t) => t.channel === 'email' && t.recipient === 'admin@test.com')).toBe(true) process.env.ADMIN_EMAIL = origAdminEmail @@ -82,7 +84,7 @@ describe('preferences', () => { data: { telegramEnabled: true, telegramChatId: '12345', authCodeDuplicate: true }, }) - const targets = await resolveAuthCodeTargets(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { + const targets = await resolveAuthCodeTargets(AUTH_CODE_REQUESTED, { email: 'user@test.com', code: '123456', isAdmin: true, diff --git a/server/vitest.config.js b/server/vitest.config.js new file mode 100644 index 0000000..ff72a75 --- /dev/null +++ b/server/vitest.config.js @@ -0,0 +1,17 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +const projectRoot = path.resolve(__dirname, '..') + +export default defineConfig({ + resolve: { + alias: { + '@shared': path.resolve(projectRoot, 'shared'), + }, + }, + server: { + fs: { + allow: [projectRoot], + }, + }, +}) From 29f3aba4ae0612cf5be4c76345e679bdb2669f2b Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 12:19:33 +0500 Subject: [PATCH 28/28] test commit --- client/src/entities/notification/api/notifications-api.ts | 5 ++++- opencode.jsonc | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/entities/notification/api/notifications-api.ts b/client/src/entities/notification/api/notifications-api.ts index 7a3d35e..aa4ff47 100644 --- a/client/src/entities/notification/api/notifications-api.ts +++ b/client/src/entities/notification/api/notifications-api.ts @@ -45,6 +45,9 @@ export async function fetchAdminNotificationSettings(): Promise<{ settings: Admi export async function updateAdminNotificationSettings( settings: Partial, ): Promise<{ settings: AdminNotificationSettings }> { - const { data } = await apiClient.put<{ settings: AdminNotificationSettings }>('admin/notifications/settings', settings) + const { data } = await apiClient.put<{ settings: AdminNotificationSettings }>( + 'admin/notifications/settings', + settings, + ) return data } diff --git a/opencode.jsonc b/opencode.jsonc index a208eff..cc56624 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -6,5 +6,9 @@ "type": "remote", "url": "https://mcp.context7.com/mcp", }, + "chrome-devtools": { + "type": "local", + "command": ["npx", "-y", "chrome-devtools-mcp@latest"], + }, }, }