feat: Реализована функциональность списка желаний с бэкенд API, базой данных и пользовательским интерфейсом.

This commit is contained in:
2025-12-06 11:08:07 +03:00
parent 07c1285bb9
commit 7eb4fb731b
42 changed files with 1610 additions and 44 deletions

3
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
uploads/*
!uploads/.gitkeep

View File

@@ -0,0 +1,29 @@
CREATE TABLE "wishlist_categories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"min_price" integer DEFAULT 0 NOT NULL,
"max_price" integer,
"color" text,
"icon" text,
"order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "wishlist_categories_name_unique" UNIQUE("name"),
CONSTRAINT "wishlist_categories_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "wishlist_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"description" text,
"price" integer NOT NULL,
"currency" text DEFAULT 'RUB' NOT NULL,
"link" text,
"images" text[] DEFAULT ARRAY[]::text[] NOT NULL,
"category_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_category_id_wishlist_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."wishlist_categories"("id") ON DELETE restrict ON UPDATE no action;

View File

@@ -0,0 +1,311 @@
{
"id": "172d78f6-8d17-4bc0-882d-c85b984f0c22",
"prevId": "852b1a6a-31c7-4f44-bdef-a378a18e2c7d",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.events": {
"name": "events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true
},
"month": {
"name": "month",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"day": {
"name": "day",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"start_year": {
"name": "start_year",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"end_month": {
"name": "end_month",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"end_day": {
"name": "end_day",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"end_year": {
"name": "end_year",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"tags": {
"name": "tags",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.wishlist_categories": {
"name": "wishlist_categories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"min_price": {
"name": "min_price",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"max_price": {
"name": "max_price",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"wishlist_categories_name_unique": {
"name": "wishlist_categories_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
},
"wishlist_categories_slug_unique": {
"name": "wishlist_categories_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.wishlist_items": {
"name": "wishlist_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price": {
"name": "price",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'RUB'"
},
"link": {
"name": "link",
"type": "text",
"primaryKey": false,
"notNull": false
},
"images": {
"name": "images",
"type": "text[]",
"primaryKey": false,
"notNull": true,
"default": "ARRAY[]::text[]"
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"wishlist_items_category_id_wishlist_categories_id_fk": {
"name": "wishlist_items_category_id_wishlist_categories_id_fk",
"tableFrom": "wishlist_items",
"tableTo": "wishlist_categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1764421778123,
"tag": "0000_lyrical_gabe_jones",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765007898910,
"tag": "0001_dashing_molten_man",
"breakpoints": true
}
]
}

View File

@@ -25,7 +25,8 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-express": "^11.1.9",
"@nestjs/serve-static": "^5.0.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"date-fns": "^4.1.0",
@@ -41,6 +42,7 @@
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.0.0",
"@types/node": "^22.19.1",
"@types/supertest": "^6.0.2",
"drizzle-kit": "^0.31.7",
@@ -51,6 +53,7 @@
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3"
},
"jest": {

129
backend/seed.ts Normal file
View File

@@ -0,0 +1,129 @@
import { DatabaseService } from './src/database/database.service';
import { wishlistCategories, wishlistItems } from './src/database/schema';
async function seed() {
const db = new DatabaseService();
console.log('🌱 Seeding wishlist categories...');
// Создать категории
const categories = await db.database
.insert(wishlistCategories)
.values([
{
name: 'БЮДЖЕТНО',
slug: 'tier-1',
minPrice: 0,
maxPrice: 150000, // 1500 руб
color: '#00ff41',
icon: '🟢',
order: 1,
},
{
name: 'СРЕДНИЙ',
slug: 'tier-2',
minPrice: 150001,
maxPrice: 500000, // 5000 руб
color: '#00cc33',
icon: '🟡',
order: 2,
},
{
name: 'ТОП',
slug: 'tier-3',
minPrice: 500001,
maxPrice: null, // без ограничения
color: '#009922',
icon: '🔴',
order: 3,
},
])
.returning();
console.log(`✅ Created ${categories.length} categories`);
console.log('🌱 Seeding wishlist items...');
// Создать примерные товары
const items = await db.database
.insert(wishlistItems)
.values([
{
title: 'Зерновой Кофе (Эфиопия)',
description: 'Люблю светлую обжарку. Желательно Эфиопия или Кения. Нужен именно в зернах.',
price: 85000, // 850 руб
currency: 'RUB',
link: 'https://ozon.ru',
images: [
'url:https://images.unsplash.com/photo-1497935586351-b67a49e012bf?w=500',
],
categoryId: categories[0].id, // БЮДЖЕТНО
},
{
title: 'Молескин в точку',
description: 'Черный, классический. Обязательно в точку, а не в линейку.',
price: 120000, // 1200 руб
currency: 'RUB',
link: 'https://wildberries.ru',
images: [
'url:https://images.unsplash.com/photo-1531346878377-a513bc957374?w=500',
],
categoryId: categories[0].id, // БЮДЖЕТНО
},
{
title: 'Винил: Daft Punk',
description: 'Альбом "Random Access Memories". Мечтаю послушать его на проигрывателе.',
price: 350000, // 3500 руб
currency: 'RUB',
link: 'https://market.yandex.ru',
images: [
'url:https://images.unsplash.com/photo-1603048588665-791ca8aea617?w=500',
],
categoryId: categories[1].id, // СРЕДНИЙ
},
{
title: 'D&D Стартовый набор',
description: '5-я редакция. Хочу попробовать поиграть с друзьями.',
price: 290000, // 2900 руб
currency: 'RUB',
link: 'https://hobbygames.ru',
images: [
'url:https://images.unsplash.com/photo-1632501641765-e568d90e09b2?w=500',
],
categoryId: categories[1].id, // СРЕДНИЙ
},
{
title: 'LEGO Speed Champions',
description: 'Любая машинка из этой серии, желательно Porsche или Ferrari.',
price: 250000, // 2500 руб
currency: 'RUB',
link: 'https://detmir.ru',
images: [
'url:https://images.unsplash.com/photo-1585366119957-e9730b6d0f60?w=500',
],
categoryId: categories[1].id, // СРЕДНИЙ
},
{
title: 'Keychron K2',
description: 'Механическая клавиатура. Свичи Red или Brown. Нужна подсветка.',
price: 900000, // 9000 руб
currency: 'RUB',
link: 'https://geekboards.ru',
images: [
'url:https://images.unsplash.com/photo-1595225476474-87563907a212?w=500',
],
categoryId: categories[2].id, // ТОП
},
])
.returning();
console.log(`✅ Created ${items.length} wishlist items`);
console.log('✨ Seeding completed!');
process.exit(0);
}
seed().catch((error) => {
console.error('❌ Seeding failed:', error);
process.exit(1);
});

View File

@@ -1,15 +1,24 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { DatabaseModule } from './database/database.module';
import { EventsModule } from './events/events.module';
import { WishlistModule } from './wishlist/wishlist.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/uploads',
}),
DatabaseModule,
EventsModule,
WishlistModule,
],
})
export class AppModule {}
export class AppModule { }

View File

@@ -37,3 +37,80 @@ export const events = pgTable('events', {
export type Event = typeof events.$inferSelect;
export type NewEvent = typeof events.$inferInsert;
// Wishlist Categories Table
export const wishlistCategories = pgTable('wishlist_categories', {
id: uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
// Основная информация
name: text('name').notNull().unique(), // "БЮДЖЕТНО", "СРЕДНИЙ", "ТОП"
slug: text('slug').notNull().unique(), // "tier-1", "tier-2", "tier-3"
// Диапазон цен (в копейках)
minPrice: integer('min_price').notNull().default(0),
maxPrice: integer('max_price'), // null = без ограничения
// Визуальные настройки
color: text('color'), // hex цвет для UI
icon: text('icon'), // emoji или класс иконки
order: integer('order').notNull().default(0), // порядок сортировки
// Служебные поля
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});
export type WishlistCategory = typeof wishlistCategories.$inferSelect;
export type NewWishlistCategory = typeof wishlistCategories.$inferInsert;
// Wishlist Items Table
export const wishlistItems = pgTable('wishlist_items', {
id: uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
// Основная информация
title: text('title').notNull(),
description: text('description'),
price: integer('price').notNull(), // в копейках
currency: text('currency').notNull().default('RUB'),
// Ссылка на товар
link: text('link'),
// Множественные изображения (массив строк)
// Формат: ["url:https://...", "uploaded:/uploads/wishlist/file.jpg", ...]
images: text('images').array().notNull().default(sql`ARRAY[]::text[]`),
// Связь с категорией
categoryId: uuid('category_id')
.notNull()
.references(() => wishlistCategories.id, { onDelete: 'restrict' }),
// Служебные поля
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});
export type WishlistItem = typeof wishlistItems.$inferSelect;
export type NewWishlistItem = typeof wishlistItems.$inferInsert;
// Helper type for image objects
export type WishlistImage = {
type: 'url' | 'uploaded';
path: string;
alt?: string;
};

View File

@@ -0,0 +1,41 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, UsePipes } from '@nestjs/common';
import { WishlistCategoriesService } from './categories.service';
import { CreateWishlistCategoryDto, UpdateWishlistCategoryDto } from './dto/category.dto';
import { ZodValidationPipe } from '../pipes/zod-validation.pipe';
import { CreateWishlistCategorySchema, UpdateWishlistCategorySchema } from './dto/category.dto';
@Controller('api/wishlist/categories')
export class WishlistCategoriesController {
constructor(private readonly categoriesService: WishlistCategoriesService) { }
@Get()
async findAll() {
return this.categoriesService.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.categoriesService.findOne(id);
}
@Post()
@UsePipes(new ZodValidationPipe(CreateWishlistCategorySchema))
async create(@Body() dto: CreateWishlistCategoryDto) {
return this.categoriesService.create(dto);
}
@Patch(':id')
@UsePipes(new ZodValidationPipe(UpdateWishlistCategorySchema))
async update(
@Param('id') id: string,
@Body() dto: UpdateWishlistCategoryDto
) {
return this.categoriesService.update(id, dto);
}
@Delete(':id')
async delete(@Param('id') id: string) {
await this.categoriesService.delete(id);
return { message: 'Category deleted successfully' };
}
}

View File

@@ -0,0 +1,145 @@
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
import { eq, and, lte, gte, or, isNull, asc } from 'drizzle-orm';
import { wishlistCategories, WishlistCategory, NewWishlistCategory } from '../database/schema';
import { CreateWishlistCategoryDto, UpdateWishlistCategoryDto } from './dto/category.dto';
@Injectable()
export class WishlistCategoriesService {
constructor(private readonly db: DatabaseService) { }
async findAll(): Promise<WishlistCategory[]> {
return this.db.database
.select()
.from(wishlistCategories)
.orderBy(asc(wishlistCategories.order));
}
async findOne(id: string): Promise<WishlistCategory> {
const [category] = await this.db.database
.select()
.from(wishlistCategories)
.where(eq(wishlistCategories.id, id))
.limit(1);
if (!category) {
throw new NotFoundException(`Category with ID ${id} not found`);
}
return category;
}
async findBySlug(slug: string): Promise<WishlistCategory | null> {
const [category] = await this.db.database
.select()
.from(wishlistCategories)
.where(eq(wishlistCategories.slug, slug))
.limit(1);
return category || null;
}
async create(dto: CreateWishlistCategoryDto): Promise<WishlistCategory> {
// Проверить уникальность slug
const existing = await this.findBySlug(dto.slug);
if (existing) {
throw new ConflictException(`Category with slug "${dto.slug}" already exists`);
}
// Проверить пересечение диапазонов цен
await this.validatePriceRange(dto.minPrice, dto.maxPrice);
const newCategory: NewWishlistCategory = {
...dto,
createdAt: new Date(),
updatedAt: new Date(),
};
const [category] = await this.db.database
.insert(wishlistCategories)
.values(newCategory)
.returning();
return category;
}
async update(id: string, dto: UpdateWishlistCategoryDto): Promise<WishlistCategory> {
const category = await this.findOne(id);
// Проверить уникальность slug если изменяется
if (dto.slug && dto.slug !== category.slug) {
const existing = await this.findBySlug(dto.slug);
if (existing) {
throw new ConflictException(`Category with slug "${dto.slug}" already exists`);
}
}
// Проверить пересечение диапазонов цен если изменяются
if (dto.minPrice !== undefined || dto.maxPrice !== undefined) {
const minPrice = dto.minPrice ?? category.minPrice;
const maxPrice = dto.maxPrice ?? category.maxPrice;
await this.validatePriceRange(minPrice, maxPrice, id);
}
const [updated] = await this.db.database
.update(wishlistCategories)
.set({ ...dto, updatedAt: new Date() })
.where(eq(wishlistCategories.id, id))
.returning();
return updated;
}
async delete(id: string): Promise<void> {
await this.findOne(id);
await this.db.database
.delete(wishlistCategories)
.where(eq(wishlistCategories.id, id));
}
async findCategoryByPrice(price: number): Promise<WishlistCategory | null> {
const [category] = await this.db.database
.select()
.from(wishlistCategories)
.where(
and(
lte(wishlistCategories.minPrice, price),
or(
gte(wishlistCategories.maxPrice, price),
isNull(wishlistCategories.maxPrice)
)
)
)
.orderBy(asc(wishlistCategories.order))
.limit(1);
return category || null;
}
private async validatePriceRange(
minPrice: number,
maxPrice: number | null | undefined,
excludeId?: string
): Promise<void> {
const categories = await this.findAll();
for (const cat of categories) {
if (excludeId && cat.id === excludeId) continue;
const catMin = cat.minPrice;
const catMax = cat.maxPrice;
// Проверить пересечение диапазонов
const hasOverlap =
(maxPrice === null || maxPrice === undefined || catMax === null || catMax === undefined)
? true // Если хотя бы один диапазон открыт сверху
: (minPrice <= catMax && maxPrice >= catMin);
if (hasOverlap && !(maxPrice !== null && maxPrice !== undefined && maxPrice < catMin)) {
throw new ConflictException(
`Price range [${minPrice}, ${maxPrice || '∞'}] overlaps with category "${cat.name}" [${catMin}, ${catMax || '∞'}]`
);
}
}
}
}

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
export const CreateWishlistCategorySchema = z.object({
name: z.string().min(1).max(100),
slug: z.string().regex(/^[a-z0-9-]+$/),
minPrice: z.number().int().min(0).default(0),
maxPrice: z.number().int().positive().optional(),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
icon: z.string().optional(),
order: z.number().int().default(0),
});
export const UpdateWishlistCategorySchema = CreateWishlistCategorySchema.partial();
export type CreateWishlistCategoryDto = z.infer<typeof CreateWishlistCategorySchema>;
export type UpdateWishlistCategoryDto = z.infer<typeof UpdateWishlistCategorySchema>;

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const CreateWishlistItemSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
price: z.number().int().positive(),
currency: z.string().default('RUB'),
link: z.string().url().optional().or(z.literal('')),
// Images будут обрабатываться отдельно (файлы + URL)
imageUrls: z.array(z.string().url()).optional().default([]),
categoryId: z.string().uuid(),
});
export const UpdateWishlistItemSchema = CreateWishlistItemSchema.partial();
export type CreateWishlistItemDto = z.infer<typeof CreateWishlistItemSchema>;
export type UpdateWishlistItemDto = z.infer<typeof UpdateWishlistItemSchema>;

View File

@@ -0,0 +1,67 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseInterceptors,
UploadedFiles,
UsePipes,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { WishlistService } from './wishlist.service';
import { type CreateWishlistItemDto, type UpdateWishlistItemDto } from './dto/wishlist-item.dto';
import { ZodValidationPipe } from '../pipes/zod-validation.pipe';
import { CreateWishlistItemSchema, UpdateWishlistItemSchema } from './dto/wishlist-item.dto';
@Controller('api/wishlist')
export class WishlistController {
constructor(private readonly wishlistService: WishlistService) { }
@Get()
async findAll(@Query('categoryId') categoryId?: string) {
return this.wishlistService.findAll(categoryId);
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.wishlistService.findOne(id);
}
@Post()
@UseInterceptors(FilesInterceptor('images', 5))
@UsePipes(new ZodValidationPipe(CreateWishlistItemSchema))
async create(
@Body() dto: CreateWishlistItemDto,
@UploadedFiles() files?: Express.Multer.File[]
) {
return this.wishlistService.create(dto, files);
}
@Patch(':id')
@UseInterceptors(FilesInterceptor('images', 5))
@UsePipes(new ZodValidationPipe(UpdateWishlistItemSchema))
async update(
@Param('id') id: string,
@Body() dto: UpdateWishlistItemDto,
@UploadedFiles() files?: Express.Multer.File[]
) {
return this.wishlistService.update(id, dto, files);
}
@Delete(':id')
async delete(@Param('id') id: string) {
await this.wishlistService.delete(id);
return { message: 'Wishlist item deleted successfully' };
}
@Post('upload')
@UseInterceptors(FilesInterceptor('images', 5))
async uploadImages(@UploadedFiles() files: Express.Multer.File[]) {
const paths = await this.wishlistService.uploadImages(files);
return { images: paths };
}
}

View File

@@ -0,0 +1,40 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { WishlistController } from './wishlist.controller';
import { WishlistCategoriesController } from './categories.controller';
import { WishlistService } from './wishlist.service';
import { WishlistCategoriesService } from './categories.service';
import { DatabaseModule } from '../database/database.module';
@Module({
imports: [
DatabaseModule,
MulterModule.register({
storage: diskStorage({
destination: './uploads/wishlist',
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = extname(file.originalname);
cb(null, `${uniqueSuffix}${ext}`);
},
}),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
fileFilter: (req, file, cb) => {
// Разрешить только изображения
if (file.mimetype.match(/^image\//)) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'), false);
}
},
}),
],
controllers: [WishlistController, WishlistCategoriesController],
providers: [WishlistService, WishlistCategoriesService],
exports: [WishlistService, WishlistCategoriesService],
})
export class WishlistModule { }

View File

@@ -0,0 +1,159 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DatabaseService } from '../database/database.service';
import { wishlistItems, WishlistItem, NewWishlistItem } from '../database/schema';
import { CreateWishlistItemDto, UpdateWishlistItemDto } from './dto/wishlist-item.dto';
import { WishlistCategoriesService } from './categories.service';
import * as fs from 'fs/promises';
import * as path from 'path';
@Injectable()
export class WishlistService {
constructor(
private readonly db: DatabaseService,
private readonly categoriesService: WishlistCategoriesService,
) { }
async findAll(categoryId?: string): Promise<WishlistItem[]> {
const query = this.db.database.select().from(wishlistItems);
if (categoryId) {
query.where(eq(wishlistItems.categoryId, categoryId));
}
return query;
}
async findOne(id: string): Promise<WishlistItem> {
const [item] = await this.db.database
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (!item) {
throw new NotFoundException(`Wishlist item with ID ${id} not found`);
}
return item;
}
async create(
dto: CreateWishlistItemDto,
uploadedFiles?: Express.Multer.File[]
): Promise<WishlistItem> {
// Проверить существование категории
await this.categoriesService.findOne(dto.categoryId);
// Собрать массив изображений
const images: string[] = [];
// Добавить загруженные файлы
if (uploadedFiles && uploadedFiles.length > 0) {
for (const file of uploadedFiles) {
images.push(`uploaded:/uploads/wishlist/${file.filename}`);
}
}
// Добавить URL изображения
if (dto.imageUrls && dto.imageUrls.length > 0) {
for (const url of dto.imageUrls) {
images.push(`url:${url}`);
}
}
const newItem: NewWishlistItem = {
title: dto.title,
description: dto.description,
price: dto.price,
currency: dto.currency,
link: dto.link,
images,
categoryId: dto.categoryId,
createdAt: new Date(),
updatedAt: new Date(),
};
const [item] = await this.db.database
.insert(wishlistItems)
.values(newItem)
.returning();
return item;
}
async update(
id: string,
dto: UpdateWishlistItemDto,
uploadedFiles?: Express.Multer.File[]
): Promise<WishlistItem> {
const item = await this.findOne(id);
// Проверить существование категории если изменяется
if (dto.categoryId) {
await this.categoriesService.findOne(dto.categoryId);
}
// Обновить массив изображений
let images = [...item.images];
// Добавить новые загруженные файлы
if (uploadedFiles && uploadedFiles.length > 0) {
for (const file of uploadedFiles) {
images.push(`uploaded:/uploads/wishlist/${file.filename}`);
}
}
// Добавить новые URL
if (dto.imageUrls && dto.imageUrls.length > 0) {
for (const url of dto.imageUrls) {
if (!images.includes(`url:${url}`)) {
images.push(`url:${url}`);
}
}
}
const [updated] = await this.db.database
.update(wishlistItems)
.set({
...dto,
images,
updatedAt: new Date(),
})
.where(eq(wishlistItems.id, id))
.returning();
return updated;
}
async delete(id: string): Promise<void> {
const item = await this.findOne(id);
// Удалить загруженные файлы
await this.deleteUploadedImages(item.images);
await this.db.database
.delete(wishlistItems)
.where(eq(wishlistItems.id, id));
}
async uploadImages(files: Express.Multer.File[]): Promise<string[]> {
return files.map(file => `/uploads/wishlist/${file.filename}`);
}
private async deleteUploadedImages(images: string[]): Promise<void> {
const uploadedImages = images.filter(img => img.startsWith('uploaded:'));
for (const img of uploadedImages) {
const filePath = img.replace('uploaded:', '');
const fullPath = path.join(process.cwd(), filePath);
try {
await fs.unlink(fullPath);
} catch (error) {
// Игнорировать ошибки удаления (файл может не существовать)
console.warn(`Failed to delete file ${fullPath}:`, error);
}
}
}
}

1
backend/uploads/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# Keep this directory in git but ignore uploaded files