feat: Реализована функциональность списка желаний с бэкенд API, базой данных и пользовательским интерфейсом.
This commit is contained in:
3
backend/.gitignore
vendored
Normal file
3
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
29
backend/migrations/0001_dashing_molten_man.sql
Normal file
29
backend/migrations/0001_dashing_molten_man.sql
Normal 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;
|
||||
311
backend/migrations/meta/0001_snapshot.json
Normal file
311
backend/migrations/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
129
backend/seed.ts
Normal 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);
|
||||
});
|
||||
@@ -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 { }
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
41
backend/src/wishlist/categories.controller.ts
Normal file
41
backend/src/wishlist/categories.controller.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
145
backend/src/wishlist/categories.service.ts
Normal file
145
backend/src/wishlist/categories.service.ts
Normal 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 || '∞'}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
backend/src/wishlist/dto/category.dto.ts
Normal file
16
backend/src/wishlist/dto/category.dto.ts
Normal 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>;
|
||||
17
backend/src/wishlist/dto/wishlist-item.dto.ts
Normal file
17
backend/src/wishlist/dto/wishlist-item.dto.ts
Normal 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>;
|
||||
67
backend/src/wishlist/wishlist.controller.ts
Normal file
67
backend/src/wishlist/wishlist.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
40
backend/src/wishlist/wishlist.module.ts
Normal file
40
backend/src/wishlist/wishlist.module.ts
Normal 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 { }
|
||||
159
backend/src/wishlist/wishlist.service.ts
Normal file
159
backend/src/wishlist/wishlist.service.ts
Normal 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
1
backend/uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Keep this directory in git but ignore uploaded files
|
||||
Reference in New Issue
Block a user