From 912724082e1cecbbce03c603e7f438254aab9d30 Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 18 May 2026 11:44:49 +0500 Subject: [PATCH] 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'