production

This commit is contained in:
2026-01-10 11:14:27 +03:30
parent b5bc94c8e5
commit a1ca2a62c8
19 changed files with 64 additions and 6081 deletions
File diff suppressed because it is too large Load Diff
@@ -1,27 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260105085439 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "point_transactions" drop constraint if exists "point_transactions_type_check";`);
this.addSql(`alter table "categories" add column "order" int null;`);
this.addSql(`alter table "point_transactions" add constraint "point_transactions_type_check" check("type" in (''));`);
}
override async down(): Promise<void> {
this.addSql(`create table "user_wallets" ("id" char(26) not null, "created_at" timestamptz(6) not null default now(), "updated_at" timestamptz(6) not null default now(), "deleted_at" timestamptz(6) null, "restaurant_id" char(26) not null, "user_id" char(26) not null, "wallet" int4 not null default 0, "points" int4 not null default 0, constraint "user_wallets_pkey" primary key ("id"));`);
this.addSql(`alter table "user_wallets" add constraint "unique_user_restaurant" unique ("user_id", "restaurant_id");`);
this.addSql(`create index "user_wallets_created_at_index" on "user_wallets" ("created_at");`);
this.addSql(`create index "user_wallets_deleted_at_index" on "user_wallets" ("deleted_at");`);
this.addSql(`create index "user_wallets_restaurant_id_index" on "user_wallets" ("restaurant_id");`);
this.addSql(`create index "user_wallets_user_id_index" on "user_wallets" ("user_id");`);
this.addSql(`create index "user_wallets_user_id_restaurant_id_index" on "user_wallets" ("user_id", "restaurant_id");`);
this.addSql(`alter table "point_transactions" drop constraint if exists "point_transactions_type_check";`);
this.addSql(`alter table "point_transactions" add constraint "point_transactions_type_check" check("type" in (''));`);
}
}
@@ -1,23 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260106054151 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "restaurants" alter column "subscription_id" type varchar(255) using ("subscription_id"::varchar(255));`);
this.addSql(`alter table "restaurants" alter column "subscription_id" drop not null;`);
this.addSql(`alter table "restaurants" alter column "subscription_end_date" type timestamptz using ("subscription_end_date"::timestamptz);`);
this.addSql(`alter table "restaurants" alter column "subscription_end_date" drop not null;`);
this.addSql(`alter table "restaurants" alter column "subscription_start_date" type timestamptz using ("subscription_start_date"::timestamptz);`);
this.addSql(`alter table "restaurants" alter column "subscription_start_date" drop not null;`);
}
override async down(): Promise<void> {
this.addSql(`alter table "restaurants" alter column "subscription_id" type varchar(255) using ("subscription_id"::varchar(255));`);
this.addSql(`alter table "restaurants" alter column "subscription_id" set not null;`);
this.addSql(`alter table "restaurants" alter column "subscription_end_date" type timestamptz using ("subscription_end_date"::timestamptz);`);
this.addSql(`alter table "restaurants" alter column "subscription_end_date" set not null;`);
this.addSql(`alter table "restaurants" alter column "subscription_start_date" type timestamptz using ("subscription_start_date"::timestamptz);`);
this.addSql(`alter table "restaurants" alter column "subscription_start_date" set not null;`);
}
}
@@ -1,14 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260106192946 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "contacts" add column "restaurant_id" char(26) null;`);
this.addSql(`alter table "contacts" add constraint "contacts_restaurant_id_foreign" foreign key ("restaurant_id") references "restaurants" ("id") on update cascade on delete set null;`);
}
override async down(): Promise<void> {
this.addSql(`alter table "contacts" drop constraint "contacts_restaurant_id_foreign";`);
}
}
+4 -14
View File
@@ -1,25 +1,15 @@
import { Index, PrimaryKey, Property, Filter, OptionalProps } from '@mikro-orm/core';
import { ulid } from 'ulid';
import { Index, Property, Filter, OptionalProps } from '@mikro-orm/core';
@Filter({ name: 'notDeleted', cond: { deletedAt: null }, default: true })
@Index({ properties: ['deletedAt'] })
@Index({ properties: ['createdAt'] })
export abstract class BaseEntity {
[OptionalProps]?: 'id' | 'createdAt' | 'updatedAt' | 'deletedAt';
@PrimaryKey({ type: 'string', columnType: 'char(26)' })
id: string = ulid();
[OptionalProps]?: 'createdAt' | 'deletedAt';
@Property({ defaultRaw: 'now()', columnType: 'timestamptz' })
createdAt: Date = new Date();
@Property({
onUpdate: () => new Date(),
defaultRaw: 'now()',
columnType: 'timestamptz',
})
updatedAt: Date = new Date();
@Property({ nullable: true })
@Property({ nullable: true, columnType: 'timestamptz' })
deletedAt?: Date;
}
@@ -1,89 +0,0 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { CategoryService } from '../providers/category.service';
import { CreateCategoryDto } from '../dto/create-category.dto';
import { UpdateCategoryDto } from '../dto/update-category.dto';
import {
ApiTags,
ApiOperation,
ApiCreatedResponse,
ApiOkResponse,
ApiNotFoundResponse,
ApiParam,
ApiBody,
ApiBearerAuth,
ApiHeader,
} from '@nestjs/swagger';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { UseGuards } from '@nestjs/common';
import { RestId } from 'src/common/decorators';
import { Permission } from 'src/common/enums/permission.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { API_HEADER_SLUG } from 'src/common/constants';
@ApiTags('category')
@Controller()
export class CategoryController {
constructor(private readonly categoryService: CategoryService) { }
@Get('public/categories/restaurant/:slug')
@ApiOperation({ summary: 'Get all categories by restaurant slug' })
@ApiHeader(API_HEADER_SLUG)
@ApiParam({ name: 'slug', required: true, description: 'Restaurant Slug' })
@ApiOkResponse({ description: 'List of all categories for the restaurant' })
findAllByRestaurant(@Param('slug') slug: string) {
return this.categoryService.findAllByRestaurant(slug);
}
/*** Admin ***/
@ApiOperation({ summary: 'Create category' })
@ApiBody({ type: CreateCategoryDto })
@Permissions(Permission.MANAGE_CATEGORIES)
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Post('admin/categories')
create(@Body() dto: CreateCategoryDto, @RestId() restId: string) {
return this.categoryService.create(restId, dto);
}
@Permissions(Permission.MANAGE_CATEGORIES)
@ApiOperation({ summary: 'my restaurant categories' })
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/categories')
findAll(@RestId() restId: string) {
return this.categoryService.findAllByRestaurantId(restId);
}
@Permissions(Permission.MANAGE_CATEGORIES)
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/categories/:id')
@ApiOperation({ summary: 'Get category' })
@ApiParam({ name: 'id', required: true, type: String })
findOne(@Param('id') id: string, @RestId() restId: string) {
return this.categoryService.findOne(restId, id);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_CATEGORIES)
@Patch('admin/categories/:id')
@ApiOperation({ summary: 'Update category' })
@ApiParam({ name: 'id' })
@ApiBody({ type: UpdateCategoryDto })
update(@Param('id') id: string, @Body() dto: UpdateCategoryDto, @RestId() restId: string) {
return this.categoryService.update(restId, id, dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_CATEGORIES)
@Delete('admin/categories/:id')
@ApiOperation({ summary: 'Delete category' })
@ApiParam({ name: 'id' })
@ApiOkResponse({ description: 'Deleted' })
remove(@Param('id') id: string, @RestId() restId: string) {
return this.categoryService.remove(restId, id);
}
}
@@ -1,45 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { EntityManager } from '@mikro-orm/postgresql';
import { Inventory } from '../../inventory/entities/inventory.entity';
@Injectable()
export class productStockCrone {
private readonly logger = new Logger(productStockCrone.name);
constructor(private readonly em: EntityManager) { }
// run every day at 00:03
@Cron('3 0 * * *', {
name: 'resetAvailableStock',
timeZone: 'UTC',
})
async handleCron() {
try {
this.logger.debug('Starting daily inventory reset (availableStock = totalStock)');
const inventories = await this.em.find(Inventory, {});
if (!inventories || inventories.length === 0) {
this.logger.debug('No inventory records found to reset');
return;
}
this.logger.log(`Resetting available stock for ${inventories.length} inventory records`);
await this.em.transactional(async em => {
for (const inv of inventories) {
// reload inside transaction to avoid concurrency issues
const record = await em.findOne(Inventory, { id: inv.id });
if (!record) continue;
record.availableStock = record.totalStock;
em.persist(record);
}
await em.flush();
});
this.logger.log('Daily inventory reset completed');
} catch (err) {
this.logger.error(`productStockCrone failed: ${err?.message}`, err);
}
}
}
@@ -0,0 +1,22 @@
import { Entity, Property, ManyToOne } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Attribute } from './attribute.entity';
@Entity({ tableName: 'attribute_values' })
export class AttributeValue extends BaseEntity {
@Property({ primary: true })
id: bigint;
@Property()
value: string;
@ManyToOne(() => Attribute)
attribute: Attribute;
@Property({ type: 'bigint' })
attributeId: bigint;
@Property({ type: 'int', nullable: true })
sortOrder?: number;
}
@@ -0,0 +1,19 @@
import { Entity, Enum, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { AttributeType } from '../interface/product.interface';
@Entity({ tableName: 'attributes' })
export class Attribute extends BaseEntity {
@Property({ primary: true })
id: bigint;
@Property()
name: string;
@Enum()
type: AttributeType;
@Property({ type: 'boolean', default: false })
isRequired: boolean = false;
}
@@ -1,27 +0,0 @@
import { Entity, Index, Property, Collection, OneToMany, ManyToOne } from '@mikro-orm/core';
import { product } from './product.entity';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
@Entity({ tableName: 'categories' })
@Index({ properties: ['restaurant', 'isActive'] })
@Index({ properties: ['isActive'] })
export class Category extends BaseEntity {
@Property()
title!: string;
@OneToMany(() => product, product => product.category)
products = new Collection<product>(this);
@Property({ default: true })
isActive: boolean = true;
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property({ nullable: true })
avatarUrl?: string;
@Property({ type: 'int', nullable: true })
order?: number;
}
@@ -1,14 +0,0 @@
import { Entity, ManyToOne, Unique } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { User } from '../../user/entities/user.entity';
import { product } from '../../products/entities/product.entity';
@Entity({ tableName: 'favorites' })
@Unique({ properties: ['user', 'product'] })
export class Favorite extends BaseEntity {
@ManyToOne(() => User)
user: User;
@ManyToOne(() => product)
product: product;
}
@@ -1,23 +0,0 @@
import { Entity, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
@Entity({ tableName: 'products' })
export class product extends BaseEntity {
@Property({ nullable: true })
title?: string;
@Property({ type: 'text', nullable: true })
linkUrl?: string;
@Property({ type: 'boolean', default: true })
isActive: boolean = true;
@Property({ type: 'json', nullable: true })
images?: string[];
}
@@ -4,20 +4,19 @@ import { BaseEntity } from '../../../common/entities/base.entity';
@Entity({ tableName: 'products' })
export class product extends BaseEntity {
@Property({ primary: true })
id: bigint
@Property({ nullable: true })
title?: string;
@Property()
title: string;
@Property({ type: 'text', nullable: true })
linkUrl?: string;
@Property({ type: 'boolean', default: true })
isActive: boolean = true;
@Property({ type: 'json', nullable: true })
images?: string[];
}
@@ -1,6 +1,7 @@
export enum MealType {
BREAKFAST = 'breakfast',
LUNCH = 'lunch',
DINNER = 'dinner',
SNACK = 'snack',
}
export enum AttributeType{
SELECT='select',
TEXT='text',
NUMBER='number',
CHECKBOX='checkbox',
BOOLEAN='boolean',
}
+6 -9
View File
@@ -2,14 +2,12 @@ import { Module } from '@nestjs/common';
import { productService } from './providers/product.service';
import { productStockCrone } from './crone/product.crone';
import { productController } from './controllers/product.controller';
import { CategoryController } from './controllers/category.controller';
import { CategoryService } from './providers/category.service';
import { productRepository } from './repositories/product.repository';
import { CategoryRepository } from './repositories/category.repository';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Category } from './entities/category.entity';
import { product } from './entities/product.entity';
import { RestaurantsModule } from '../restaurants/restaurants.module';
import { Attribute } from './entities/attribute.entity';
import { AttributeValue } from './entities/attribute-value.entity';
import { AuthModule } from '../auth/auth.module';
import { JwtModule } from '@nestjs/jwt';
import { UtilsModule } from '../util/utils.module';
@@ -17,14 +15,13 @@ import { Favorite } from './entities/favorite.entity';
@Module({
imports: [
MikroOrmModule.forFeature([product, Category, Favorite]),
RestaurantsModule,
MikroOrmModule.forFeature([product, Category, Favorite, Attribute, AttributeValue]),
AuthModule,
JwtModule,
UtilsModule,
],
controllers: [productController, CategoryController],
providers: [productService, CategoryService, productRepository, CategoryRepository, productStockCrone],
exports: [productRepository, CategoryRepository],
controllers: [productController,],
providers: [productService, productRepository, productStockCrone],
exports: [productRepository,],
})
export class productModule { }
@@ -1,111 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateCategoryDto } from '../dto/create-category.dto';
import { UpdateCategoryDto } from '../dto/update-category.dto';
import { CategoryRepository } from '../repositories/category.repository';
import { EntityManager } from '@mikro-orm/postgresql';
import { RequiredEntityData } from '@mikro-orm/core';
import { Category } from '../entities/category.entity';
import { CategoryMessage, RestMessage } from 'src/common/enums/message.enum';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
@Injectable()
export class CategoryService {
constructor(
private readonly categoryRepository: CategoryRepository,
private readonly em: EntityManager,
) { }
async create(restId: string, dto: CreateCategoryDto): Promise<Category> {
const restaurant = await this.em.findOne(Restaurant, { id: restId });
if (!restaurant) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
const data: RequiredEntityData<Category> = {
title: dto.title,
isActive: dto.isActive ?? true,
restaurant: restaurant,
avatarUrl: dto.avatarUrl,
};
const category = this.categoryRepository.create(data);
await this.em.persistAndFlush(category);
return category;
}
async findAllByRestaurant(slug: string): Promise<Category[]> {
const restaurant = await this.em.findOne(Restaurant, { slug });
if (!restaurant || !restaurant.id) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
return this.categoryRepository.find({ restaurant: restaurant, isActive: true });
}
async findAllByRestaurantId(restId: string): Promise<Category[]> {
return this.categoryRepository.find({ restaurant: { id: restId } });
}
// async findAll(opts?: { restId?: string; isActive?: boolean }): Promise<Category[]> {
// const where: FilterQuery<Category> = {};
// if (opts?.restId) where.restId = opts.restId;
// if (opts && typeof opts.isActive === 'boolean') where.isActive = opts.isActive;
// const cats = await this.categoryRepository.find(where, { populate: ['products'] });
// // Return plain objects to avoid circular references during JSON serialization
// return cats.map(cat => ({
// id: cat.id,
// title: cat.title,
// isActive: cat.isActive,
// restId: cat.restId,
// avatarUrl: cat.avatarUrl,
// createdAt: cat.createdAt,
// updatedAt: cat.updatedAt,
// products: cat.products.getItems().map(f => ({ id: f.id, title: f.title })),
// })) as unknown as Category[];
// }
async findOne(restId: string, id: string): Promise<Category> {
const cat = await this.categoryRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['products'] });
if (!cat) throw new NotFoundException(CategoryMessage.NOT_FOUND);
return {
id: cat.id,
title: cat.title,
isActive: cat.isActive,
restaurant: cat.restaurant,
avatarUrl: cat.avatarUrl,
createdAt: cat.createdAt,
updatedAt: cat.updatedAt,
products: cat.products.getItems().map(f => ({ id: f.id, title: f.title })),
} as unknown as Category;
}
async findRestaurantCategories(restId: string): Promise<Category> {
const cat = await this.categoryRepository.findOne({ restaurant: { id: restId } }, { populate: ['products'] });
if (!cat) throw new NotFoundException(CategoryMessage.NOT_FOUND);
return {
id: cat.id,
title: cat.title,
isActive: cat.isActive,
restaurant: cat.restaurant,
avatarUrl: cat.avatarUrl,
createdAt: cat.createdAt,
updatedAt: cat.updatedAt,
} as unknown as Category;
}
async update(restId: string, id: string, dto: UpdateCategoryDto): Promise<Category> {
const cat = await this.categoryRepository.findOne({ id, restaurant: { id: restId } });
if (!cat) throw new NotFoundException(CategoryMessage.NOT_FOUND);
this.em.assign(cat, dto);
await this.em.persistAndFlush(cat);
return cat;
}
async remove(restId: string, id: string): Promise<void> {
const cat = await this.categoryRepository.findOne({ id, restaurant: { id: restId } });
if (!cat) throw new NotFoundException(CategoryMessage.NOT_FOUND);
cat.deletedAt = new Date();
await this.em.persistAndFlush(cat);
}
}
@@ -2,29 +2,19 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateproductDto } from '../dto/create-product.dto';
import { FindproductsDto } from '../dto/find-products.dto';
import { productRepository } from '../repositories/product.repository';
import { CategoryRepository } from '../repositories/category.repository';
import { EntityManager } from '@mikro-orm/postgresql';
import { RequiredEntityData, FilterQuery } from '@mikro-orm/core';
import { product } from '../entities/product.entity';
import { CategoryMessage, productMessage, RestMessage } from 'src/common/enums/message.enum';
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
import { CacheService } from '../../util/cache.service';
import { Favorite } from '../entities/favorite.entity';
import { MealType } from '../interface/product.interface';
import { Inventory } from 'src/modules/inventory/entities/inventory.entity';
@Injectable()
export class productService {
private readonly productS_BY_RESTAURANT_CACHE_KEY_PREFIX = 'products:restaurant:';
private readonly productS_CACHE_TTL = 600; // 10 minutes in seconds
constructor(
private readonly productRepository: productRepository,
private readonly categoryRepository: CategoryRepository,
private readonly restRepository: RestRepository,
private readonly em: EntityManager,
private readonly cacheService: CacheService,
) { }
) { }
async create(restId: string, createproductDto: CreateproductDto) {
const { categoryId, dailyStock = 0, ...rest } = createproductDto;
@@ -1,10 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { Category } from '../entities/category.entity';
@Injectable()
export class CategoryRepository extends EntityRepository<Category> {
constructor(readonly em: EntityManager) {
super(em, Category);
}
}
+1
View File
@@ -1,3 +1,4 @@
export interface CategoryData {
title: string;
restaurantSlug: string;