diff --git a/.gitignore b/.gitignore index edf35e7..75259df 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ server/prisma/dev.db-journal # Image resize cache uploads/.cache/ + +# Server uploads directory (images) +server/uploads/ + +# Plans and design docs +.opencode/plans/ diff --git a/.opencode/plans/2026-05-15-product-redesign-design.md b/.opencode/plans/2026-05-15-product-redesign-design.md deleted file mode 100644 index 87322e0..0000000 --- a/.opencode/plans/2026-05-15-product-redesign-design.md +++ /dev/null @@ -1,174 +0,0 @@ -# Design: Доработка товара — удаление «под заказ», обязательные quantity и категория - -**Дата:** 2026-05-15 -**Статус:** На согласовании - -## Цель - -Упростить модель товара: убрать концепцию «под заказ», сделать количество и категорию обязательными полями. Категория «Не указано» остаётся технической заглушкой для переноса товаров при удалении категории, но не видна в каталоге и не выбирается при редактировании. - -## Архитектура изменений - -### 1. База данных (Prisma) - -**Миграция:** -- Перед удалением полей: все товары с `inStock = false` получают `quantity = 0` -- Удалить поля `inStock` и `leadTimeDays` из модели `Product` -- Статус наличия определяется исключительно по `quantity`: - - `quantity > 0` → «В наличии» - - `quantity = 0` → «Нет в наличии» - -**`server/prisma/schema.prisma`:** -```prisma -model Product { - // ... остальные поля без изменений ... - quantity Int @default(0) - // УДАЛЕНО: inStock Boolean @default(true) - // УДАЛЕНО: leadTimeDays Int? - category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict) - categoryId String - // ... -} -``` - -### 2. Сервер — валидация и CRUD - -**`server/src/routes/api/admin-products.js`:** - -**CREATE (POST):** -- `quantity` — required, `Int >= 0` (было nullable) -- `categoryId` — required (было: при пустом → авто-назначение «Не указано») -- Удалить валидацию `leadTimeDays` при `!inStock` -- Удалить принудительную установку `quantity = 1` для «под заказ» -- Вернуть 400: `'Укажите категорию'` если `categoryId` отсутствует - -**UPDATE (PATCH):** -- `quantity` — required, `Int >= 0` (было nullable) -- `categoryId` — required (было: при пустом → «Не указано») -- Удалить логику очистки `leadTimeDays` при `inStock = true` -- Удалить принудительную установку `quantity = 1` -- Вернуть 400 при отсутствии `categoryId` - -**JSON Schema:** -- `CREATE_PRODUCT_SCHEMA`: убрать `leadTimeDays`, сделать `quantity` required (убрать `nullable`) -- `PATCH_PRODUCT_SCHEMA`: убрать `leadTimeDays`, `quantity` — если передан, то `>= 0` - -**`server/src/routes/api/public-catalog.js`:** -- Удалить ветку `availability === 'in_stock'` и `availability === 'made_to_order'` -- Фильтрация «в наличии» больше не нужна — все товары в каталоге - -### 3. Клиент — админка (две страницы) - -**`client/src/pages/admin/ui/AdminPage.tsx`** и **`client/src/pages/admin-products/ui/AdminProductsPage.tsx`:** - -**FormState:** -- Удалить `inStock: boolean` и `leadTimeDays: string` -- `quantity: string` — без nullable-семантики - -**UI:** -- Удалить Switch «В наличии / Под заказ» -- Удалить TextField «Срок исполнения, дней» -- TextField «Количество»: - - Без helper «Оставьте пустым...» - - Новый helper: «0 = нет в наличии» - - Валидация: не может быть пустым, `parseInt >= 0` -- Select «Категория»: - - Удалить `` с «Не указано» - - Валидация: не даёт сохранить без выбранной категории - - Показать ошибку при попытке сохранить без категории - -**Submit-валидация:** -- Удалить проверку `leadTimeDays` при `!inStock` -- Добавить проверку: `categoryId` не пустой → blocking error -- Добавить проверку: `quantity` не пустой → blocking error - -### 4. Клиент — каталог - -**`client/src/entities/product/ui/ProductCard.tsx`:** -- Удалить логику `'Под заказ · {leadTimeDays} дн.'` -- Новый статус: - - `quantity > 0` → «В наличии» (зелёный) - - `quantity === 0` → «Нет в наличии» (серый/red) - -**`client/src/pages/product/ui/ProductPage.tsx`:** -- Удалить chip `'Под заказ · {leadTimeDays} дн.'` -- Удалить alert `'Этот товар изготавливается под заказ...'` -- Статус определяется по `quantity` - -**`client/src/pages/checkout/ui/CheckoutPage.tsx`:** -- Удалить определение made-to-order товаров в корзине -- Удалить info alert о доставке после изготовления - -### 5. Клиент — фильтры - -**`client/src/pages/home/lib/use-product-filters.ts`:** -- Удалить `availability: 'all' | 'in_stock' | 'made_to_order'` из state -- Удалить `availability` из параметров `fetchPublicProducts()` - -**`client/src/pages/home/ui/ProductFilters.tsx`:** -- Удалить `ToggleButtonGroup` с `'all'`, `'in_stock'`, `'made_to_order'` -- Удалить отображение категории «Не указано» из списка чипов (фильтр `cat.slug !== 'ne-ukazano'`) - -### 6. Категория «Не указано» — что остаётся - -| Где | Что происходит | -|---|---| -| `server/src/lib/default-category.js` | **Остаётся** — функция `getOrCreateUnspecifiedCategory()` | -| `server/src/index.js` | **Остаётся** — вызов при старте | -| `server/src/routes/api/admin-categories.js` | **Остаётся** — нельзя удалить/переименовать; при удалении категории товары переезжают в «Не указано» | -| Админка категорий | **Остаётся** — кнопка удаления заблокирована | -| Фильтры каталога | **Скрыта** — не показывается в чипах | -| Форма товара | **Скрыта** — не выбирается в Select | - -## Статус товара — новая логика - -``` -quantity > 0 → «В наличии» (зелёный chip/badge) -quantity = 0 → «Нет в наличии» (серый chip/badge) -``` - -Никаких других статусов. Поле `inStock` больше не существует. - -## Файлы для изменения - -### Сервер -| Файл | Изменения | -|---|---| -| `server/prisma/schema.prisma` | Удалить `inStock`, `leadTimeDays` | -| `server/src/routes/api/admin-products.js` | Валидация, schema, убрать логику под заказ | -| `server/src/routes/api/public-catalog.js` | Убрать фильтр availability | - -### Клиент -| Файл | Изменения | -|---|---| -| `client/src/pages/admin/ui/AdminPage.tsx` | FormState, UI, валидация | -| `client/src/pages/admin-products/ui/AdminProductsPage.tsx` | FormState, UI, валидация | -| `client/src/entities/product/ui/ProductCard.tsx` | Статус по quantity | -| `client/src/pages/product/ui/ProductPage.tsx` | Убрать под заказ UI | -| `client/src/pages/checkout/ui/CheckoutPage.tsx` | Убрать made-to-order detection | -| `client/src/pages/home/ui/ProductFilters.tsx` | Убрать availability toggle, скрыть «Не указано» | -| `client/src/pages/home/lib/use-product-filters.ts` | Убрать `availability` | - -## Миграция данных - -```javascript -// В Prisma migration: -// 1. UPDATE Product SET quantity = 0 WHERE inStock = false -// 2. ALTER TABLE Product DROP COLUMN inStock -// 3. ALTER TABLE Product DROP COLUMN leadTimeDays -``` - -## Тестирование - -**Сервер:** -- CREATE без categoryId → 400 -- CREATE без quantity → 400 -- CREATE с quantity = 0 → OK -- PATCH без categoryId → 400 -- PATCH с quantity = 0 → OK - -**Клиент:** -- Форма не сохраняется без категории -- Форма не сохраняется без количества -- Фильтры не содержат «Под заказ» и «Не указано» -- Карточка товара показывает «Нет в наличии» при quantity = 0 diff --git a/.opencode/plans/2026-05-15-product-redesign-plan.md b/.opencode/plans/2026-05-15-product-redesign-plan.md deleted file mode 100644 index 672871c..0000000 --- a/.opencode/plans/2026-05-15-product-redesign-plan.md +++ /dev/null @@ -1,1294 +0,0 @@ -# Доработка товара — 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:** Удалить логику «под заказ», сделать quantity и categoryId обязательными, скрыть «Не указано» из UI каталога и формы товара. - -**Architecture:** Миграция БД → серверная валидация → клиентская админка → каталог/фильтры → типы/API. Сервер-first, затем клиент. - -**Tech Stack:** Prisma (SQLite), Fastify (ajv schema), React + MUI + react-hook-form, axios - ---- - -### Task 1: Prisma migration — удалить inStock и leadTimeDays - -**Files:** -- Modify: `server/prisma/schema.prisma` - -- [ ] **Step 1: Удалить поля inStock и leadTimeDays из модели Product** - -Открыть `server/prisma/schema.prisma` и удалить строки 33-34: - -```prisma - inStock Boolean @default(true) - leadTimeDays Int? -``` - -Перед этим — миграция данных: все товары с `inStock = false` должны получить `quantity = 0`. -Создаём миграцию с raw SQL: - -```bash -cd server -npx prisma migrate dev --name remove_instock_leadtime -``` - -Prisma автоматически создаст migration файл. Нужно убедиться, что migration содержит: -```sql --- Перед удалением колонок, установить quantity = 0 для товаров под заказ -UPDATE Product SET quantity = 0 WHERE inStock = 0; -``` - -Если Prisma не добавит это автоматически, нужно отредактировать созданный migration файл в `server/prisma/migrations/_remove_instock_leadtime/migration.sql`: - -```sql -UPDATE Product SET quantity = 0 WHERE inStock = 0; -ALTER TABLE Product DROP COLUMN inStock; -ALTER TABLE Product DROP COLUMN leadTimeDays; -``` - -- [ ] **Step 2: Применить миграцию** - -```bash -cd server -npx prisma migrate dev -``` - -Expected: Migration applied successfully, no errors. - -- [ ] **Step 3: Перегенерировать Prisma Client** - -```bash -cd server -npx prisma generate -``` - -Expected: Generated Prisma Client output. - ---- - -### Task 2: Сервер — обновить JSON Schema и валидацию CREATE - -**Files:** -- Modify: `server/src/routes/api/admin-products.js` - -- [ ] **Step 1: Обновить CREATE_PRODUCT_SCHEMA (строки 11-31)** - -Заменить schema на: - -```javascript -const CREATE_PRODUCT_SCHEMA = { - body: { - type: 'object', - required: ['title', 'priceCents', 'quantity', 'categoryId'], - properties: { - title: { type: 'string', minLength: 1 }, - slug: { type: 'string' }, - categoryId: { type: 'string', minLength: 1 }, - priceCents: { type: 'number', minimum: 0 }, - quantity: { type: 'number', minimum: 0 }, - shortDescription: { type: 'string', nullable: true }, - description: { type: 'string', nullable: true }, - materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, - imageUrl: { type: 'string', nullable: true }, - imageUrls: { type: 'array', items: { type: 'string' } }, - published: { type: 'boolean' }, - }, - }, -} -``` - -Изменения: -- Удалены: `inStock`, `leadTimeDays` -- `quantity` — убран `nullable: true` -- `categoryId` — добавлен `minLength: 1` -- `required` массив теперь включает `'quantity'` и `'categoryId'` - -- [ ] **Step 2: Обновить логику CREATE handler (строки 93-179)** - -Заменить handler на: - -```javascript -fastify.post( - '/api/admin/products', - { preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA }, - async (request, reply) => { - const body = request.body ?? {} - const title = String(body.title ?? '').trim() - if (!title) { - reply.code(400).send({ error: 'Укажите название' }) - return - } - const slug = String(body.slug ?? '').trim() || request.server.slugify(title) || `item-${Date.now()}` - const categoryId = String(body.categoryId ?? '').trim() - if (!categoryId) { - reply.code(400).send({ error: 'Укажите категорию' }) - return - } - const cat = await prisma.category.findUnique({ where: { id: categoryId } }) - if (!cat) { - reply.code(400).send({ error: 'Категория не найдена' }) - return - } - const priceCents = Number(body.priceCents) - if (!Number.isFinite(priceCents) || priceCents < 0) { - reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' }) - return - } - const exists = await prisma.product.findUnique({ where: { slug } }) - if (exists) { - reply.code(409).send({ error: 'Такой slug уже занят' }) - return - } - - const n = Number(body.quantity) - if (!Number.isFinite(n) || n < 0) { - reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) - return - } - const quantity = Math.floor(n) - - const product = await prisma.product.create({ - data: { - title, - slug, - shortDescription: body.shortDescription ? String(body.shortDescription) : null, - description: body.description ? String(body.description) : null, - quantity, - materials: JSON.stringify(request.server.parseMaterialsInput(body.materials)), - priceCents: Math.round(priceCents), - imageUrl: body.imageUrl ? String(body.imageUrl) : null, - published: Boolean(body.published), - categoryId, - images: Array.isArray(body.imageUrls) - ? { - create: body.imageUrls - .map((u) => String(u || '').trim()) - .filter(Boolean) - .slice(0, 10) - .map((u, idx) => ({ url: u, sort: idx })), - } - : undefined, - }, - include: { category: true, images: { orderBy: { sort: 'asc' } } }, - }) - reply.code(201).send(request.server.mapProductForApi(product)) - }, -) -``` - -Удалён import `getOrCreateUnspecifiedCategory` если он больше не используется в этом файле (проверить после PATCH handler). - ---- - -### Task 3: Сервер — обновить PATCH handler - -**Files:** -- Modify: `server/src/routes/api/admin-products.js` - -- [ ] **Step 1: Обновить PATCH_PRODUCT_SCHEMA (строки 33-52)** - -Заменить на: - -```javascript -const PATCH_PRODUCT_SCHEMA = { - body: { - type: 'object', - properties: { - title: { type: 'string', minLength: 1 }, - slug: { type: 'string' }, - categoryId: { type: 'string', minLength: 1 }, - priceCents: { type: 'number', minimum: 0 }, - quantity: { type: 'number', minimum: 0 }, - shortDescription: { type: 'string', nullable: true }, - description: { type: 'string', nullable: true }, - materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, - imageUrl: { type: 'string', nullable: true }, - imageUrls: { type: 'array', items: { type: 'string' } }, - published: { type: 'boolean' }, - }, - }, -} -``` - -Изменения: -- Удалены: `inStock`, `leadTimeDays` -- `quantity` — убран `nullable: true` -- `categoryId` — добавлен `minLength: 1` - -- [ ] **Step 2: Обновить PATCH handler (строки 181-299)** - -Заменить handler на: - -```javascript -fastify.patch( - '/api/admin/products/:id', - { preHandler: [fastify.verifyAdmin], schema: PATCH_PRODUCT_SCHEMA }, - async (request, reply) => { - const { id } = request.params - const body = request.body ?? {} - const existing = await prisma.product.findUnique({ where: { id } }) - if (!existing) { - reply.code(404).send({ error: 'Товар не найден' }) - return - } - const data = {} - if (body.title !== undefined) data.title = String(body.title).trim() - if (body.slug !== undefined) { - const s = String(body.slug).trim() - if (s && s !== existing.slug) { - const clash = await prisma.product.findFirst({ where: { slug: s, NOT: { id } } }) - if (clash) { - reply.code(409).send({ error: 'Такой slug уже занят' }) - return - } - data.slug = s - } - } - if (body.shortDescription !== undefined) { - data.shortDescription = body.shortDescription ? String(body.shortDescription) : null - } - if (body.description !== undefined) { - data.description = body.description ? String(body.description) : null - } - if (body.quantity !== undefined) { - const n = Number(body.quantity) - if (!Number.isFinite(n) || n < 0) { - reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) - return - } - data.quantity = Math.floor(n) - } - if (body.materials !== undefined) { - data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials)) - } - if (body.priceCents !== undefined) { - const p = Number(body.priceCents) - if (!Number.isFinite(p) || p < 0) { - reply.code(400).send({ error: 'Некорректная цена' }) - return - } - data.priceCents = Math.round(p) - } - if (body.imageUrl !== undefined) { - data.imageUrl = body.imageUrl ? String(body.imageUrl) : null - } - if (body.published !== undefined) data.published = Boolean(body.published) - if (body.categoryId !== undefined) { - const cid = String(body.categoryId).trim() - if (!cid) { - reply.code(400).send({ error: 'Укажите категорию' }) - return - } - const cat = await prisma.category.findUnique({ where: { id: cid } }) - if (!cat) { - reply.code(400).send({ error: 'Категория не найдена' }) - return - } - data.categoryId = cid - } - - const imagesUpdate = - body.imageUrls !== undefined - ? { - deleteMany: {}, - create: Array.isArray(body.imageUrls) - ? body.imageUrls - .map((u) => String(u || '').trim()) - .filter(Boolean) - .slice(0, 10) - .map((u, idx) => ({ url: u, sort: idx })) - : [], - } - : undefined - - const product = await prisma.product.update({ - where: { id }, - data: { ...data, images: imagesUpdate }, - include: { category: true, images: { orderBy: { sort: 'asc' } } }, - }) - return request.server.mapProductForApi(product) - }, -) -``` - -- [ ] **Step 3: Удалить неиспользуемый import** - -В начале файла удалить строку: -```javascript -import { getOrCreateUnspecifiedCategory } from '../../lib/default-category.js' -``` - ---- - -### Task 4: Сервер — убрать фильтр availability из public-catalog - -**Files:** -- Modify: `server/src/routes/api/public-catalog.js` - -- [ ] **Step 1: Удалить availability из PUBLIC_PRODUCTS_QUERY_SCHEMA (строки 3-17)** - -Заменить schema на: - -```javascript -const PUBLIC_PRODUCTS_QUERY_SCHEMA = { - querystring: { - type: 'object', - properties: { - categorySlug: { type: 'string' }, - q: { type: 'string' }, - sort: { type: 'string', enum: ['', 'price_asc', 'price_desc'] }, - page: { type: 'integer', minimum: 1 }, - pageSize: { type: 'integer', minimum: 1, maximum: 100 }, - priceMin: { type: 'number', minimum: 0 }, - priceMax: { type: 'number', minimum: 0 }, - }, - }, -} -``` - -Удалена строка: `availability: { type: 'string', enum: ['all', 'in_stock', 'made_to_order'] }` - -- [ ] **Step 2: Удалить логику availability из GET /api/products handler (строки 82-159)** - -Удалить строки 87-88: -```javascript -const availabilityRaw = request.query?.availability -const availability = typeof availabilityRaw === 'string' ? availabilityRaw.trim() : '' -``` - -Удалить строки 116-123 (весь блок if/else if для availability): -```javascript -if (availability === 'in_stock') { - where.inStock = true - where.quantity = { gt: 0 } -} else if (availability === 'made_to_order') { - where.inStock = false -} else if (availability && availability !== 'all') { - return reply.code(400).send({ error: 'availability должен быть all | in_stock | made_to_order' }) -} -``` - ---- - -### Task 5: Клиент — обновить типы Product - -**Files:** -- Modify: `client/src/entities/product/model/types.ts` - -- [ ] **Step 1: Удалить inStock и leadTimeDays из типа Product** - -Заменить тип Product на: - -```typescript -export type Product = { - id: string - title: string - slug: string - shortDescription: string | null - description: string | null - quantity: number - materials?: string[] - priceCents: number - imageUrl: string | null - imageUrls?: string[] - published: boolean - categoryId: string - createdAt: string - updatedAt: string - category?: Category - images?: { id: string; url: string; sort: number }[] - reviewsSummary?: ProductReviewsSummary | null -} -``` - -Удалены поля: `inStock: boolean` и `leadTimeDays: number | null` - ---- - -### Task 6: Клиент — обновить API layer - -**Files:** -- Modify: `client/src/entities/product/api/product-api.ts` - -- [ ] **Step 1: Удалить availability из fetchPublicProducts** - -```typescript -export async function fetchPublicProducts(params?: { - categorySlug?: string - q?: string - sort?: 'price_asc' | 'price_desc' | '' - page?: number - pageSize?: number - priceMinCents?: number - priceMaxCents?: number -}): Promise { - const { data } = await apiClient.get('products', { - params: { - categorySlug: params?.categorySlug || undefined, - q: params?.q || undefined, - sort: params?.sort || undefined, - page: params?.page || undefined, - pageSize: params?.pageSize || undefined, - priceMin: params?.priceMinCents ?? undefined, - priceMax: params?.priceMaxCents ?? undefined, - }, - }) - return data -} -``` - -Удалены: `availability` из params type и из params object. - -- [ ] **Step 2: Удалить inStock и leadTimeDays из createProduct** - -```typescript -export async function createProduct(body: { - title: string - slug?: string - shortDescription?: string | null - description?: string | null - quantity: number - materials?: string[] - priceCents: number - imageUrl?: string | null - imageUrls?: string[] - published: boolean - categoryId: string -}): Promise { - const { data } = await apiClient.post('admin/products', body) - return data -} -``` - -- [ ] **Step 3: Удалить inStock и leadTimeDays из updateProduct** - -```typescript -export async function updateProduct( - id: string, - body: Partial<{ - title: string - slug: string - shortDescription: string | null - description: string | null - quantity: number - materials: string[] - priceCents: number - imageUrl: string | null - imageUrls: string[] - published: boolean - categoryId: string - }>, -): Promise { - const { data } = await apiClient.patch(`admin/products/${id}`, body) - return data -} -``` - ---- - -### Task 7: Клиент — AdminProductsPage (основная админка товаров) - -**Files:** -- Modify: `client/src/pages/admin-products/ui/AdminProductsPage.tsx` - -- [ ] **Step 1: Обновить FormState и emptyForm** - -Удалить import Switch (если не используется elsewhere). -Заменить FormState: - -```typescript -type FormState = { - title: string - slug: string - shortDescription: string - description: string - quantity: string - materials: string - priceRub: string - imageUrls: string[] - published: boolean - categoryId: string -} -``` - -Заменить emptyForm: - -```typescript -const emptyForm = (): FormState => ({ - title: '', - slug: '', - shortDescription: '', - description: '', - quantity: '0', - materials: '', - priceRub: '', - imageUrls: [], - published: true, - categoryId: '', -}) -``` - -- [ ] **Step 2: Удалить inStockValue watch** - -Удалить строку: -```typescript -const inStockValue = productForm.watch('inStock') -``` - -- [ ] **Step 3: Обновить openEdit** - -```typescript -const openEdit = (p: Product) => { - openEditDialog(p) - const urls = - (p.images ?? []) - .slice() - .sort((a, b) => a.sort - b.sort) - .map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : []) - productForm.reset({ - title: p.title, - slug: p.slug, - shortDescription: p.shortDescription ?? '', - description: p.description ?? '', - quantity: String(p.quantity), - materials: (p.materials ?? []).join(', '), - priceRub: String(p.priceCents / 100), - imageUrls: urls, - published: p.published, - categoryId: p.categoryId, - }) -} -``` - -- [ ] **Step 4: Обновить createMut** - -```typescript -const createMut = useMutation({ - mutationFn: async () => { - const form = productForm.getValues() - const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) - if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') - if (!form.categoryId) throw new Error('Выберите категорию') - const qty = form.quantity.trim() - if (!qty) throw new Error('Укажите количество') - const qtyNum = Number(qty) - if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') - const materials = form.materials - .split(',') - .map((x) => x.trim()) - .filter(Boolean) - await createProduct({ - title: form.title.trim(), - slug: form.slug.trim() || undefined, - shortDescription: form.shortDescription.trim() || null, - description: form.description.trim() || null, - quantity: Math.floor(qtyNum), - materials, - priceCents, - imageUrls: form.imageUrls, - published: form.published, - categoryId: form.categoryId, - }) - }, - onSuccess: () => { - void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) - closeDialog() - }, -}) -``` - -- [ ] **Step 5: Обновить updateMut** - -```typescript -const updateMut = useMutation({ - mutationFn: async () => { - const form = productForm.getValues() - const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) - if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') - if (!form.categoryId) throw new Error('Выберите категорию') - const qty = form.quantity.trim() - if (!qty) throw new Error('Укажите количество') - const qtyNum = Number(qty) - if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') - const materials = form.materials - .split(',') - .map((x) => x.trim()) - .filter(Boolean) - await updateProduct(editing!.id, { - title: form.title.trim(), - slug: form.slug.trim(), - shortDescription: form.shortDescription.trim() || null, - description: form.description.trim() || null, - quantity: Math.floor(qtyNum), - materials, - priceCents, - imageUrls: form.imageUrls, - published: form.published, - categoryId: form.categoryId, - }) - }, - onSuccess: () => { - void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) - closeDialog() - }, -}) -``` - -- [ ] **Step 6: Обновить UI — количество, категория, удалить inStock/leadTimeDays** - -TextField «Количество» (строки 363-375): - -```tsx - ( - - )} -/> -``` - -Select «Категория» (строки 472-489) — удалить MenuItem «Не указано»: - -```tsx - ( - - Категория - - {!field.value && Выберите категорию} - - )} -/> -``` - -Удалить Switch inStock (строки 501-510) и conditional leadTimeDays (строки 511-517). - -- [ ] **Step 7: Обновить disabled кнопку сохранения** - -```tsx - -``` - -- [ ] **Step 8: Удалить неиспользуемые импорты** - -Удалить: `Switch` из `@mui/material/Switch` - ---- - -### Task 8: Клиент — AdminPage (унифицированная админка) - -**Files:** -- Modify: `client/src/pages/admin/ui/AdminPage.tsx` - -Те же изменения что и в Task 7, но для AdminPage.tsx. - -- [ ] **Step 1: Обновить FormState и emptyForm** - -```typescript -type FormState = { - title: string - slug: string - shortDescription: string - description: string - quantity: string - materials: string - priceRub: string - imageUrls: string[] - published: boolean - categoryId: string -} - -const emptyForm = (): FormState => ({ - title: '', - slug: '', - shortDescription: '', - description: '', - quantity: '0', - materials: '', - priceRub: '', - imageUrls: [], - published: true, - categoryId: '', -}) -``` - -- [ ] **Step 2: Удалить inStockValue watch** - -Удалить строку: `const inStockValue = productForm.watch('inStock')` - -- [ ] **Step 3: Обновить openEdit** - -```typescript -const openEdit = (p: Product) => { - openEditDialog(p) - const urls = - (p.images ?? []) - .slice() - .sort((a, b) => a.sort - b.sort) - .map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : []) - productForm.reset({ - title: p.title, - slug: p.slug, - shortDescription: p.shortDescription ?? '', - description: p.description ?? '', - quantity: String(p.quantity), - materials: (p.materials ?? []).join(', '), - priceRub: String(p.priceCents / 100), - imageUrls: urls, - published: p.published, - categoryId: p.categoryId, - }) -} -``` - -- [ ] **Step 4: Обновить createMut** - -```typescript -const createMut = useMutation({ - mutationFn: async () => { - const form = productForm.getValues() - const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) - if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') - if (!form.categoryId) throw new Error('Выберите категорию') - const qty = form.quantity.trim() - if (!qty) throw new Error('Укажите количество') - const qtyNum = Number(qty) - if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') - const materials = form.materials - .split(',') - .map((x) => x.trim()) - .filter(Boolean) - await createProduct({ - title: form.title.trim(), - slug: form.slug.trim() || undefined, - shortDescription: form.shortDescription.trim() || null, - description: form.description.trim() || null, - quantity: Math.floor(qtyNum), - materials, - priceCents, - imageUrls: form.imageUrls, - published: form.published, - categoryId: form.categoryId, - }) - }, - onSuccess: () => { - void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) - closeDialog() - }, -}) -``` - -- [ ] **Step 5: Обновить updateMut** - -```typescript -const updateMut = useMutation({ - mutationFn: async () => { - const form = productForm.getValues() - const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) - if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') - if (!form.categoryId) throw new Error('Выберите категорию') - const qty = form.quantity.trim() - if (!qty) throw new Error('Укажите количество') - const qtyNum = Number(qty) - if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') - const materials = form.materials - .split(',') - .map((x) => x.trim()) - .filter(Boolean) - await updateProduct(editing!.id, { - title: form.title.trim(), - slug: form.slug.trim(), - shortDescription: form.shortDescription.trim() || null, - description: form.description.trim() || null, - quantity: Math.floor(qtyNum), - materials, - priceCents, - imageUrls: form.imageUrls, - published: form.published, - categoryId: form.categoryId, - }) - }, - onSuccess: () => { - void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) - closeDialog() - }, -}) -``` - -- [ ] **Step 6: Обновить UI — количество** - -```tsx - ( - - )} -/> -``` - -- [ ] **Step 7: Обновить UI — категория (удалить «Не указано»)** - -```tsx - ( - - Категория - - {!field.value && Выберите категорию} - - )} -/> -``` - -- [ ] **Step 8: Удалить Switch inStock и conditional leadTimeDays** - -Удалить строки 654-670 (Controller inStock + conditional leadTimeDays). - -- [ ] **Step 9: Обновить disabled кнопку** - -```tsx - -``` - -- [ ] **Step 10: Удалить неиспользуемые импорты** - -Удалить: `Switch` из `@mui/material/Switch` - ---- - -### Task 9: Клиент — ProductCard (статус по quantity) - -**Files:** -- Modify: `client/src/entities/product/ui/ProductCard.tsx` - -- [ ] **Step 1: Заменить stockLabel логику** - -Заменить строки 47-52: - -```typescript -const stockLabel = - product.quantity > 0 - ? null - : { label: 'Нет в наличии', color: 'default' as const } -``` - -Старая логика (удалить): -```typescript -const stockLabel = - product.inStock && product.quantity === 0 - ? { label: 'Нет в наличии', color: 'default' as const } - : !product.inStock - ? { label: `Под заказ · ${product.leadTimeDays ?? '—'} дн.`, color: 'warning' as const } - : null -``` - ---- - -### Task 10: Клиент — ProductPage (убрать «под заказ» UI) - -**Files:** -- Modify: `client/src/pages/product/ui/ProductPage.tsx` - -- [ ] **Step 1: Обновить chip статуса (строка 134)** - -Заменить: -```tsx - -``` - -На: -```tsx -{p.quantity > 0 && } -{p.quantity === 0 && } -``` - -- [ ] **Step 2: Обновить условие ToggleCartIcon (строка 157)** - -Заменить: -```tsx -{!isAdmin && !(p.inStock && p.quantity === 0) ? : null} -``` - -На: -```tsx -{!isAdmin && p.quantity > 0 ? : null} -``` - -- [ ] **Step 3: Удалить alert «под заказ» (строки 159-163)** - -Удалить: -```tsx -{!p.inStock && ( - - Этот товар изготавливается под заказ. Доставка будет после изготовления (~{p.leadTimeDays ?? '—'} дн.). - -)} -``` - -- [ ] **Step 4: Удалить неиспользуемый import Alert** - -Если Alert больше не используется в файле — удалить import. (Проверить: Alert может использоваться для других целей — оставить если используется.) - ---- - -### Task 11: Клиент — CheckoutPage (убрать made-to-order detection) - -**Files:** -- Modify: `client/src/pages/checkout/ui/CheckoutPage.tsx` - -- [ ] **Step 1: Удалить hasMadeToOrder (строка 84)** - -Удалить: -```typescript -const hasMadeToOrder = items.some((x) => !x.product.inStock) -``` - -- [ ] **Step 2: Обновить hasOverLimit (строка 83)** - -Заменить: -```typescript -const hasOverLimit = items.some((x) => x.qty > x.product.quantity) -``` - -Старая логика: -```typescript -const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1)) -``` - -- [ ] **Step 3: Обновить available в списке позиций (строка 108)** - -Заменить: -```typescript -const available = x.product.quantity -``` - -Старая логика: -```typescript -const available = x.product.inStock ? x.product.quantity : 1 -``` - -- [ ] **Step 4: Удалить alert made-to-order (строки 130-134)** - -Удалить: -```tsx -{hasMadeToOrder && ( - - В заказе есть товары «под заказ». Доставка будет после изготовления (срок указан в карточке товара). - -)} -``` - ---- - -### Task 12: Клиент — use-product-filters (убрать availability) - -**Files:** -- Modify: `client/src/pages/home/lib/use-product-filters.ts` - -- [ ] **Step 1: Удалить availability state и handler** - -```typescript -export function useProductFilters() { - const [categorySlug, setCategorySlug] = useState('') - const [qInput, setQInput] = useState('') - const [q, setQ] = useState('') - const [moreOpen, setMoreOpen] = useState(false) - const [sort, setSort] = useState<'price_asc' | 'price_desc' | ''>('') - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(12) - const [priceMinRub, setPriceMinRub] = useState('') - const [priceMaxRub, setPriceMaxRub] = useState('') - const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90) -``` - -Удалить: `const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')` - -- [ ] **Step 2: Удалить handleAvailabilityChange** - -Удалить функцию: -```typescript -const handleAvailabilityChange = (v: string) => { - if (v === 'all' || v === 'in_stock' || v === 'made_to_order') { - setAvailability(v) - setPage(1) - } -} -``` - -- [ ] **Step 3: Обновить resetFilters** - -```typescript -const resetFilters = () => { - setCategorySlug('') - setQInput('') - setSort('') - setPriceMinRub('') - setPriceMaxRub('') - setPageSize(12) - setCardScale(90) - setMoreOpen(false) -} -``` - -Удалить: `setAvailability('all')` - -- [ ] **Step 4: Обновить return object** - -```typescript -return { - categorySlug, - qInput, - q, - moreOpen, - sort, - page, - pageSize, - priceMinRub, - priceMaxRub, - cardScale, - setPage, - setQInput, - setMoreOpen, - handleCategoryChange, - handleSortChange, - handlePageSizeChange, - handlePriceMinChange, - handlePriceMaxChange, - handleCardScaleChange, - resetFilters, - toCents, -} -``` - -Удалить: `availability` и `handleAvailabilityChange` - ---- - -### Task 13: Клиент — ProductFilters (убрать availability toggle, скрыть «Не указано») - -**Files:** -- Modify: `client/src/pages/home/ui/ProductFilters.tsx` - -- [ ] **Step 1: Удалить availability из Props destructuring** - -```typescript -export function ProductFilters({ - categorySlug, - qInput, - moreOpen, - sort, - pageSize, - priceMinRub, - priceMaxRub, - cardScale, - categories, - categoriesLoading, - setQInput, - setMoreOpen, - handleCategoryChange, - handleSortChange, - handlePageSizeChange, - handlePriceMinChange, - handlePriceMaxChange, - handleCardScaleChange, - resetFilters, -}: Props) { -``` - -Удалить: `availability` и `handleAvailabilityChange` из destructuring. - -- [ ] **Step 2: Скрыть «Не указано» из категорий** - -Обновить categoriesForFilter (строки 50-57): - -```typescript -const categoriesForFilter = useMemo(() => { - const list = (categories ?? []).filter((c) => c.slug !== 'ne-ukazano') - return [...list].sort((a, b) => a.sort - b.sort || a.name.localeCompare(b.name, 'ru')) -}, [categories]) -``` - -- [ ] **Step 3: Удалить ToggleButtonGroup availability (строки 128-146)** - -Удалить весь блок: -```tsx - handleAvailabilityChange(v)} - sx={{ ... }} -> - Все - В наличии - Под заказ - -``` - -- [ ] **Step 4: Удалить неиспользуемые импорты** - -Удалить: `ToggleButton`, `ToggleButtonGroup` из `@mui/material` - ---- - -### Task 14: Клиент — HomePage (убрать availability из query) - -**Files:** -- Modify: `client/src/pages/home/ui/HomePage.tsx` - -- [ ] **Step 1: Обновить queryKey и fetchPublicProducts вызов** - -```typescript -const productsQuery = useQuery({ - queryKey: [ - 'products', - 'public', - { - categorySlug: filters.categorySlug || 'all', - q: filters.q, - sort: filters.sort, - page: filters.page, - pageSize: filters.pageSize, - priceMinRub: filters.priceMinRub, - priceMaxRub: filters.priceMaxRub, - }, - ], - queryFn: () => - fetchPublicProducts({ - categorySlug: filters.categorySlug || undefined, - q: filters.q || undefined, - sort: filters.sort || '', - page: filters.page, - pageSize: filters.pageSize, - priceMinCents: filters.toCents(filters.priceMinRub), - priceMaxCents: filters.toCents(filters.priceMaxRub), - }), -}) -``` - -Удалить: `availability: filters.availability` из queryKey и `availability` из fetchPublicProducts params. - -- [ ] **Step 2: Обновить ToggleCartIcon в ProductCard actions** - -```tsx -actions={ - !isAdmin && p.quantity > 0 ? : undefined -} -``` - ---- - -### Task 15: Запустить серверные тесты - -**Files:** -- Test: `server/` tests - -- [ ] **Step 1: Запустить серверные тесты** - -```bash -cd server && npm test -``` - -Expected: All tests pass. Если есть тесты, проверяющие inStock/leadTimeDays — обновить их. - ---- - -### Task 16: Запустить клиентские линт и тесты - -**Files:** -- Test: `client/` tests - -- [ ] **Step 1: Запустить линт** - -```bash -cd client && npm run lint -``` - -Expected: No errors. - -- [ ] **Step 2: Запустить форматирование** - -```bash -cd client && npm run format:check -``` - -Expected: All files formatted correctly. - -- [ ] **Step 3: Запустить тесты** - -```bash -cd client && npm test -``` - -Expected: All tests pass. - ---- - -### Task 17: Сборка клиента - -**Files:** -- Build: `client/` - -- [ ] **Step 1: Запустить сборку** - -```bash -cd client && npm run build -``` - -Expected: Build succeeds with no TypeScript errors. - ---- diff --git a/server/src/lib/__tests__/upload-images.test.js b/server/src/lib/__tests__/upload-images.test.js index d4ba6b9..ee02537 100644 --- a/server/src/lib/__tests__/upload-images.test.js +++ b/server/src/lib/__tests__/upload-images.test.js @@ -14,19 +14,18 @@ describe('persistMultipartImages with eager mode', () => { await fs.promises.unlink(path.join(UPLOADS_DIR, file)).catch(() => {}) } } - const cacheDir = path.join(UPLOADS_DIR, '.cache') - await fs.promises.rm(cacheDir, { recursive: true, force: true }).catch(() => {}) }) it('returns WebP URLs when eager=true', async () => { const sharp = (await import('sharp')).default const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original.png`) + + const filesBefore = await fs.promises.readdir(UPLOADS_DIR) + await sharp({ create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } } }) .png() .toFile(testImagePath) - const filesBefore = await fs.promises.readdir(UPLOADS_DIR) - const mockRequest = { isMultipart: () => true, parts: async function* () { @@ -51,7 +50,13 @@ describe('persistMultipartImages with eager mode', () => { // Verify the intermediate PNG file written by persistMultipartImages was deleted const filesAfter = await fs.promises.readdir(UPLOADS_DIR) - const newPngFiles = filesAfter.filter((f) => !filesBefore.includes(f) && f.endsWith('.png')) + const newPngFiles = filesAfter.filter( + (f) => + !filesBefore.includes(f) && + f.endsWith('.png') && + f !== path.basename(testImagePath) && + !f.startsWith('test-eager-uuid-'), + ) expect(newPngFiles).toHaveLength(0) }) diff --git a/server/uploads/.cache/3e43e7ab-82e3-4877-a267-9f2145c8981f_w320.avif b/server/uploads/.cache/3e43e7ab-82e3-4877-a267-9f2145c8981f_w320.avif deleted file mode 100644 index f8e3eff..0000000 Binary files a/server/uploads/.cache/3e43e7ab-82e3-4877-a267-9f2145c8981f_w320.avif and /dev/null differ diff --git a/server/uploads/.cache/51957c26-33fc-4bce-806b-0ebb4456ab5c_w320.avif b/server/uploads/.cache/51957c26-33fc-4bce-806b-0ebb4456ab5c_w320.avif deleted file mode 100644 index a2e99f0..0000000 Binary files a/server/uploads/.cache/51957c26-33fc-4bce-806b-0ebb4456ab5c_w320.avif and /dev/null differ diff --git a/server/uploads/.cache/64b49aa8-a33a-4702-8c59-0ac5facbdaa7_w1600.avif b/server/uploads/.cache/64b49aa8-a33a-4702-8c59-0ac5facbdaa7_w1600.avif deleted file mode 100644 index 765e0a4..0000000 Binary files a/server/uploads/.cache/64b49aa8-a33a-4702-8c59-0ac5facbdaa7_w1600.avif and /dev/null differ diff --git a/server/uploads/.cache/64b49aa8-a33a-4702-8c59-0ac5facbdaa7_w320.avif b/server/uploads/.cache/64b49aa8-a33a-4702-8c59-0ac5facbdaa7_w320.avif deleted file mode 100644 index 447d532..0000000 Binary files a/server/uploads/.cache/64b49aa8-a33a-4702-8c59-0ac5facbdaa7_w320.avif and /dev/null differ diff --git a/server/uploads/.cache/6943e879-2917-4049-9901-71b184857428_w320.avif b/server/uploads/.cache/6943e879-2917-4049-9901-71b184857428_w320.avif deleted file mode 100644 index 8a57c37..0000000 Binary files a/server/uploads/.cache/6943e879-2917-4049-9901-71b184857428_w320.avif and /dev/null differ diff --git a/server/uploads/.cache/a9db83b0-dd2c-4910-96b4-03c49dde16ca_w320.avif b/server/uploads/.cache/a9db83b0-dd2c-4910-96b4-03c49dde16ca_w320.avif deleted file mode 100644 index b81a24f..0000000 Binary files a/server/uploads/.cache/a9db83b0-dd2c-4910-96b4-03c49dde16ca_w320.avif and /dev/null differ diff --git a/server/uploads/.cache/ce802fe2-062b-4310-b93b-a1b0f24e4cd5_w1600.avif b/server/uploads/.cache/ce802fe2-062b-4310-b93b-a1b0f24e4cd5_w1600.avif deleted file mode 100644 index ad07579..0000000 Binary files a/server/uploads/.cache/ce802fe2-062b-4310-b93b-a1b0f24e4cd5_w1600.avif and /dev/null differ diff --git a/server/uploads/.cache/ce802fe2-062b-4310-b93b-a1b0f24e4cd5_w320.avif b/server/uploads/.cache/ce802fe2-062b-4310-b93b-a1b0f24e4cd5_w320.avif deleted file mode 100644 index 983af73..0000000 Binary files a/server/uploads/.cache/ce802fe2-062b-4310-b93b-a1b0f24e4cd5_w320.avif and /dev/null differ diff --git a/server/uploads/.cache/d26c640b-6016-44f2-a94d-65a94fd5934e_w1600.avif b/server/uploads/.cache/d26c640b-6016-44f2-a94d-65a94fd5934e_w1600.avif deleted file mode 100644 index d06665b..0000000 Binary files a/server/uploads/.cache/d26c640b-6016-44f2-a94d-65a94fd5934e_w1600.avif and /dev/null differ diff --git a/server/uploads/.cache/d26c640b-6016-44f2-a94d-65a94fd5934e_w320.avif b/server/uploads/.cache/d26c640b-6016-44f2-a94d-65a94fd5934e_w320.avif deleted file mode 100644 index 0902327..0000000 Binary files a/server/uploads/.cache/d26c640b-6016-44f2-a94d-65a94fd5934e_w320.avif and /dev/null differ diff --git a/server/uploads/.cache/df304f0b-92d2-47ee-a676-64d6f88ea6f0_w1600.avif b/server/uploads/.cache/df304f0b-92d2-47ee-a676-64d6f88ea6f0_w1600.avif deleted file mode 100644 index 2c69835..0000000 Binary files a/server/uploads/.cache/df304f0b-92d2-47ee-a676-64d6f88ea6f0_w1600.avif and /dev/null differ diff --git a/server/uploads/.cache/df304f0b-92d2-47ee-a676-64d6f88ea6f0_w320.avif b/server/uploads/.cache/df304f0b-92d2-47ee-a676-64d6f88ea6f0_w320.avif deleted file mode 100644 index 94d69ed..0000000 Binary files a/server/uploads/.cache/df304f0b-92d2-47ee-a676-64d6f88ea6f0_w320.avif and /dev/null differ diff --git a/server/uploads/.cache/e5d29325-e363-46f8-8270-82be6a5a19e7_w320.avif b/server/uploads/.cache/e5d29325-e363-46f8-8270-82be6a5a19e7_w320.avif deleted file mode 100644 index 626e8ae..0000000 Binary files a/server/uploads/.cache/e5d29325-e363-46f8-8270-82be6a5a19e7_w320.avif and /dev/null differ diff --git a/server/uploads/.cache/ef9087a6-83e5-4ce8-8f2d-c3c286a47d87_w320.avif b/server/uploads/.cache/ef9087a6-83e5-4ce8-8f2d-c3c286a47d87_w320.avif deleted file mode 100644 index 4599737..0000000 Binary files a/server/uploads/.cache/ef9087a6-83e5-4ce8-8f2d-c3c286a47d87_w320.avif and /dev/null differ diff --git a/server/uploads/.gitkeep b/server/uploads/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/server/uploads/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/uploads/3e43e7ab-82e3-4877-a267-9f2145c8981f.png b/server/uploads/3e43e7ab-82e3-4877-a267-9f2145c8981f.png deleted file mode 100644 index bc602a3..0000000 Binary files a/server/uploads/3e43e7ab-82e3-4877-a267-9f2145c8981f.png and /dev/null differ diff --git a/server/uploads/51957c26-33fc-4bce-806b-0ebb4456ab5c.png b/server/uploads/51957c26-33fc-4bce-806b-0ebb4456ab5c.png deleted file mode 100644 index b465987..0000000 Binary files a/server/uploads/51957c26-33fc-4bce-806b-0ebb4456ab5c.png and /dev/null differ diff --git a/server/uploads/64b49aa8-a33a-4702-8c59-0ac5facbdaa7.png b/server/uploads/64b49aa8-a33a-4702-8c59-0ac5facbdaa7.png deleted file mode 100644 index 33c255a..0000000 Binary files a/server/uploads/64b49aa8-a33a-4702-8c59-0ac5facbdaa7.png and /dev/null differ diff --git a/server/uploads/6943e879-2917-4049-9901-71b184857428.png b/server/uploads/6943e879-2917-4049-9901-71b184857428.png deleted file mode 100644 index 604951f..0000000 Binary files a/server/uploads/6943e879-2917-4049-9901-71b184857428.png and /dev/null differ diff --git a/server/uploads/a9db83b0-dd2c-4910-96b4-03c49dde16ca.png b/server/uploads/a9db83b0-dd2c-4910-96b4-03c49dde16ca.png deleted file mode 100644 index b3f1a2b..0000000 Binary files a/server/uploads/a9db83b0-dd2c-4910-96b4-03c49dde16ca.png and /dev/null differ diff --git a/server/uploads/ce802fe2-062b-4310-b93b-a1b0f24e4cd5.png b/server/uploads/ce802fe2-062b-4310-b93b-a1b0f24e4cd5.png deleted file mode 100644 index c3a9bdc..0000000 Binary files a/server/uploads/ce802fe2-062b-4310-b93b-a1b0f24e4cd5.png and /dev/null differ diff --git a/server/uploads/d26c640b-6016-44f2-a94d-65a94fd5934e.png b/server/uploads/d26c640b-6016-44f2-a94d-65a94fd5934e.png deleted file mode 100644 index 64a0d57..0000000 Binary files a/server/uploads/d26c640b-6016-44f2-a94d-65a94fd5934e.png and /dev/null differ diff --git a/server/uploads/df304f0b-92d2-47ee-a676-64d6f88ea6f0.png b/server/uploads/df304f0b-92d2-47ee-a676-64d6f88ea6f0.png deleted file mode 100644 index b2b3d19..0000000 Binary files a/server/uploads/df304f0b-92d2-47ee-a676-64d6f88ea6f0.png and /dev/null differ diff --git a/server/uploads/e5d29325-e363-46f8-8270-82be6a5a19e7.png b/server/uploads/e5d29325-e363-46f8-8270-82be6a5a19e7.png deleted file mode 100644 index b150fbe..0000000 Binary files a/server/uploads/e5d29325-e363-46f8-8270-82be6a5a19e7.png and /dev/null differ diff --git a/server/uploads/ef9087a6-83e5-4ce8-8f2d-c3c286a47d87.png b/server/uploads/ef9087a6-83e5-4ce8-8f2d-c3c286a47d87.png deleted file mode 100644 index 87c25bd..0000000 Binary files a/server/uploads/ef9087a6-83e5-4ce8-8f2d-c3c286a47d87.png and /dev/null differ