Compare commits

...

6 Commits

Author SHA1 Message Date
morteza c439fdd6f9 ai question list
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-23 11:41:09 +03:30
morteza 27c45974f4 ai module 2026-07-23 10:52:29 +03:30
morteza 549da39473 add group to user
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-22 11:52:10 +03:30
morteza 096297d1d1 fix notif
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 19:46:49 +03:30
morteza 9fa55cc6b5 perms
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 11:33:05 +03:30
morteza d1f85d78f4 add payment
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 10:18:33 +03:30
32 changed files with 1052 additions and 42 deletions
+14
View File
@@ -0,0 +1,14 @@
curl --location 'https://arvancloudai.ir/gateway/models/DeepSeek-V4-Flash/GShkYZ-j5g8GPs2hizngJUlGKcqhDw14rUWb0wu-eLcYjfqUVlvxAhbcBdjg5iH_FSs0uHY0ypJj94AIKmr-gotsFuKm2FqCGbNXJcBi7-2lTu3Ep_cQp_eCSvmo9y4GWUU8j26WQN7pvm-MgH-L9aH-wEdU-qsyqgSslgaPdPigDJz29XNbVmBX5uw3fMpyrJi_I8myhEAoQ9IwpPblDvyHJHgaMAHFX5NZ77PWfyrxCUWkCnlsLZwUUnQ3YEjujmNm77mDqQQ/v1/chat/completions' \
-H 'Authorization: apikey 4fe6d150-7951-58f5-b813-2e4bd7e74734' \
-H 'Content-Type: application/json' \
-d '{
"model":"DeepSeek-V4-Flash-f5unr",
"messages":[
{
"role":"user",
"content":"چرا آسمان آبی است؟"
}
],
"max_tokens":1000,
"temperature":0.8
}'
+8
View File
@@ -0,0 +1,8 @@
{
"folders": [
{
"path": ".",
},
],
"settings": {},
}
@@ -0,0 +1,59 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260723090000_addAiChats extends Migration {
override async up(): Promise<void> {
this.addSql(`
create table "ai_chats" (
"id" char(26) not null,
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now(),
"deleted_at" timestamptz null,
"restaurant_id" char(26) not null,
"user_id" char(26) null,
"question" text not null,
"consumed_tokens" int not null,
"cost" numeric(10,0) not null,
constraint "ai_chats_pkey" primary key ("id")
);
`);
this.addSql(`create index "ai_chats_created_at_index" on "ai_chats" ("created_at");`);
this.addSql(`create index "ai_chats_deleted_at_index" on "ai_chats" ("deleted_at");`);
this.addSql(`create index "ai_chats_restaurant_id_index" on "ai_chats" ("restaurant_id");`);
this.addSql(`
alter table "ai_chats"
add constraint "ai_chats_restaurant_id_foreign"
foreign key ("restaurant_id") references "restaurants" ("id") on update cascade;
`);
this.addSql(`
alter table "ai_chats"
add constraint "ai_chats_user_id_foreign"
foreign key ("user_id") references "users" ("id") on update cascade on delete set null;
`);
this.addSql(`
alter table "restaurant_credit_transactions"
drop constraint if exists "restaurant_credit_transactions_reason_check";
`);
this.addSql(`
alter table "restaurant_credit_transactions"
add constraint "restaurant_credit_transactions_reason_check"
check ("reason" in ('sms_send', 'deposit', 'adjustment', 'ai_chat'));
`);
}
override async down(): Promise<void> {
this.addSql(`
alter table "restaurant_credit_transactions"
drop constraint if exists "restaurant_credit_transactions_reason_check";
`);
this.addSql(`
alter table "restaurant_credit_transactions"
add constraint "restaurant_credit_transactions_reason_check"
check ("reason" in ('sms_send', 'deposit', 'adjustment'));
`);
this.addSql(`alter table "ai_chats" drop constraint if exists "ai_chats_user_id_foreign";`);
this.addSql(`alter table "ai_chats" drop constraint if exists "ai_chats_restaurant_id_foreign";`);
this.addSql(`drop table if exists "ai_chats" cascade;`);
}
}
+2
View File
@@ -25,6 +25,7 @@ import { ContactModule } from './modules/contact/contact.module';
import { InventoryModule } from './modules/inventory/inventory.module';
import { IconsModule } from './modules/icons/icons.module';
import { SliderModule } from './modules/slider/slider.module';
import { AiModule } from './modules/ai/ai.module';
import { CacheModule } from '@nestjs/cache-manager';
import { cacheConfig } from './config/cache.config';
@@ -61,6 +62,7 @@ import { cacheConfig } from './config/cache.config';
InventoryModule,
IconsModule,
SliderModule,
AiModule,
],
controllers: [],
providers: [],
+4
View File
@@ -600,6 +600,8 @@ export const enum SettingMessageEnum {
export const enum AiMessage {
NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI',
LLM_SERVICE_ERROR = 'سرویس هوش مصنوعی موقتاً در دسترس نیست',
INSUFFICIENT_CREDIT = 'اعتبار کیف پول رستوران برای استفاده از هوش مصنوعی کافی نیست',
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید',
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست',
DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است',
@@ -710,6 +712,8 @@ export const enum OrderMessage {
REFUND_AMOUNT_EXCEEDS_PAID = 'مبلغ بازگشت وجه بیشتر از مبلغ پرداخت‌شده است',
REFUND_AMOUNT_EXCEEDS_OVERPAYMENT = 'مبلغ بازگشت وجه بیشتر از مانده منفی سفارش است',
NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد',
ADD_PAYMENT_NOT_ALLOWED = 'امکان افزودن پرداخت برای این سفارش وجود ندارد',
ADD_PAYMENT_NO_BALANCE = 'مانده‌ای برای پرداخت وجود ندارد',
}
export const enum CartMessage {
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { JwtModule } from '@nestjs/jwt';
import { AiController } from './controllers/ai.controller';
import { AiService } from './providers/ai.service';
import { AiChatRepository } from './repositories/ai-chat.repository';
import { AiChat } from './entities/ai-chat.entity';
import { RestaurantsModule } from '../restaurants/restaurants.module';
import { AiCrone } from './crone/ai.crone';
@Module({
imports: [MikroOrmModule.forFeature([AiChat]), JwtModule, RestaurantsModule],
controllers: [AiController],
providers: [AiService, AiChatRepository, AiCrone],
exports: [AiChatRepository],
})
export class AiModule {}
@@ -0,0 +1,79 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { AiService } from '../providers/ai.service';
import { ChatDto } from '../dto/chat.dto';
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto';
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
import { OptionalAuthGuard } from '../../auth/guards/optinalAuth.guard';
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
import { RestId } from 'src/common/decorators/rest-id.decorator';
import { UserId } from 'src/common/decorators/user-id.decorator';
import { RestSlug } from 'src/common/decorators/rest-slug.decorator';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum';
import { API_HEADER_SLUG } from 'src/common/constants';
@ApiTags('ai')
@Controller()
export class AiController {
constructor(private readonly aiService: AiService) {}
@UseGuards(OptionalAuthGuard)
@Post('public/ai/chat')
@ApiOperation({ summary: 'Ask the restaurant AI assistant about the menu' })
@ApiHeader(API_HEADER_SLUG)
@ApiHeader({
name: 'Authorization',
required: false,
description: 'Bearer token (optional authentication)',
})
@ApiBearerAuth()
@ApiBody({ type: ChatDto })
async chat(
@Body() dto: ChatDto,
@RestSlug() slug: string,
@RestId() restId?: string,
@UserId() userId?: string,
) {
return this.aiService.chat(dto, {
restId: restId || undefined,
userId: userId || undefined,
slug,
});
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.UPDATE_RESTAURANT)
@Get('admin/ai/chats')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get paginated list of AI chats for the restaurant' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
async findAllPaginated(@RestId() restId: string, @Query() dto: FindAiChatsDto) {
return this.aiService.findAllPaginated(restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_FOODS)
@Post('admin/ai/food-description')
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate a two-line food description from a food name' })
@ApiBody({ type: GenerateFoodDescriptionDto })
async generateFoodDescription(@Body() dto: GenerateFoodDescriptionDto, @RestId() restId: string) {
return this.aiService.generateFoodDescription(dto, restId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_FOODS)
@Post('admin/ai/food-content')
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate food ingredients/contents from a food name' })
@ApiBody({ type: GenerateFoodContentDto })
async generateFoodContent(@Body() dto: GenerateFoodContentDto, @RestId() restId: string) {
return this.aiService.generateFoodContent(dto, restId);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { CreateRequestContext } from '@mikro-orm/core';
import { EntityManager } from '@mikro-orm/postgresql';
import { AiChat } from '../entities/ai-chat.entity';
@Injectable()
export class AiCrone {
private readonly logger = new Logger(AiCrone.name);
constructor(private readonly em: EntityManager) {}
// run every day at 03:00 (3:00 AM)
@Cron('0 3 * * *', {
name: 'deleteOldAiChats',
timeZone: 'UTC',
})
@CreateRequestContext()
async handleCron() {
try {
this.logger.debug('Starting daily AI chat cleanup (removing questions older than 1 month)');
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
const oldAiChats = await this.em.find(AiChat, {
createdAt: { $lt: oneMonthAgo },
});
if (!oldAiChats || oldAiChats.length === 0) {
this.logger.debug('No old AI chats found to delete');
return;
}
this.logger.log(`Found ${oldAiChats.length} AI chats older than 1 month to delete`);
await this.em.removeAndFlush(oldAiChats);
this.logger.log(`Successfully deleted ${oldAiChats.length} old AI chats`);
} catch (err) {
this.logger.error(`AiCrone failed: ${err?.message}`, err);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChatDto {
@IsNotEmpty()
@IsString()
@MaxLength(2000)
@ApiProperty({
example: 'کدام غذا برای گیاه‌خواران مناسب است؟',
description: 'User question for the restaurant AI assistant',
})
question!: string;
}
+37
View File
@@ -0,0 +1,37 @@
import { IsOptional, IsNumber, IsString, IsIn, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
const sortOrderOptions = ['asc', 'desc'] as const;
type SortOrder = (typeof sortOrderOptions)[number];
export class FindAiChatsDto {
@IsOptional()
@IsNumber()
@Min(1)
@Type(() => Number)
@ApiPropertyOptional({ example: 1, description: 'Page number', minimum: 1, default: 1 })
page?: number = 1;
@IsOptional()
@IsNumber()
@Min(1)
@Type(() => Number)
@ApiPropertyOptional({ example: 10, description: 'Items per page', minimum: 1, default: 10 })
limit?: number = 10;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'گیاه‌خوار', description: 'Search in question text' })
search?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'createdAt', description: 'Field to sort by', default: 'createdAt' })
orderBy?: string = 'createdAt';
@IsOptional()
@IsIn(sortOrderOptions)
@ApiPropertyOptional({ example: 'desc', enum: sortOrderOptions, default: 'desc' })
order?: SortOrder = 'desc';
}
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class GenerateFoodContentDto {
@IsNotEmpty()
@IsString()
@MaxLength(200)
@ApiProperty({
example: 'چلو کباب کوبیده',
description: 'Food name to generate ingredients/contents for',
})
foodName!: string;
}
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class GenerateFoodDescriptionDto {
@IsNotEmpty()
@IsString()
@MaxLength(200)
@ApiProperty({
example: 'چلو کباب کوبیده',
description: 'Food name to generate a short description for',
})
foodName!: string;
}
+23
View File
@@ -0,0 +1,23 @@
import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity';
@Entity({ tableName: 'ai_chats' })
@Index({ properties: ['restaurant'] })
export class AiChat extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@ManyToOne(() => User, { nullable: true })
user?: User | null;
@Property({ type: 'text' })
question!: string;
@Property({ type: 'int' })
consumedTokens!: number;
@Property({ type: 'decimal', precision: 10, scale: 0 })
cost!: number;
}
@@ -0,0 +1,27 @@
export interface AiChatCompletionUsage {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
export interface AiChatCompletionChoice {
finish_reason?: string | null;
message?: {
role?: string;
content?: string | null;
reasoning_content?: string | null;
};
}
export interface AiChatCompletionResponse {
choices?: AiChatCompletionChoice[];
usage?: AiChatCompletionUsage;
}
export interface AiChatResult {
answer: string;
consumedTokens: number;
cost: number;
promptTokens: number;
completionTokens: number;
}
+282
View File
@@ -0,0 +1,282 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { EntityManager } from '@mikro-orm/postgresql';
import { AxiosError } from 'axios';
import { catchError, firstValueFrom, throwError } from 'rxjs';
import { ChatDto } from '../dto/chat.dto';
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto';
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
import { AiChat } from '../entities/ai-chat.entity';
import { AiChatRepository } from '../repositories/ai-chat.repository';
import { AiChatCompletionResponse, AiChatResult } from '../interface/ai-chat.interface';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity';
import { Food } from '../../foods/entities/food.entity';
import { RestaurantWalletService } from '../../restaurants/providers/restaurant-wallet.service';
import {
RestaurantCreditTransactionReason,
RestaurantCreditTransactionType,
} from '../../restaurants/interface/restaurant-credit-transaction.interface';
import { AiMessage, RestMessage } from 'src/common/enums/message.enum';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(
private readonly em: EntityManager,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
private readonly restaurantWalletService: RestaurantWalletService,
private readonly aiChatRepository: AiChatRepository,
) { }
findAllPaginated(restId: string, dto: FindAiChatsDto): Promise<PaginatedResult<AiChat>> {
return this.aiChatRepository.findAllPaginated(restId, dto);
}
async chat(dto: ChatDto, params: { restId?: string; userId?: string; slug?: string }): Promise<AiChatResult> {
const restaurant = await this.resolveRestaurant(params.restId, params.slug);
const user = params.userId ? await this.em.findOne(User, { id: params.userId }) : null;
await this.ensureHasCredit(restaurant.id);
const foods = await this.em.find(
Food,
{ restaurant: restaurant.id, isActive: true },
{ fields: ['title', 'desc', 'content'], orderBy: { order: 'asc' } },
);
const menuContext = this.buildMenuContext(foods);
const aiResponse = await this.callAiApi({
systemPrompt: [
'تو دستیار هوشمند منوی رستوران هستی.',
'فقط بر اساس منوی زیر به سوالات کاربر پاسخ بده.',
'اگر اطلاعات کافی در منو نیست، صادقانه بگو.',
'خیلی کوتاه جواب بده',
'',
'منوی رستوران:',
menuContext,
].join('\n'),
userPrompt: dto.question,
});
return this.persistAiUsage({
restaurant,
user,
question: dto.question,
aiResponse,
});
}
async generateFoodDescription(dto: GenerateFoodDescriptionDto, restId: string): Promise<AiChatResult> {
const restaurant = await this.resolveRestaurant(restId);
await this.ensureHasCredit(restaurant.id);
const aiResponse = await this.callAiApi({
systemPrompt: [
'تو نویسنده منوی رستوران هستی.',
'برای نام غذای داده‌شده، دقیقاً یک توضیح کوتاه دو خطی به فارسی بنویس.',
'فقط همان دو خط را برگردان؛ بدون عنوان، بدون بولت و بدون توضیح اضافه.',
'لحن باید جذاب، مختصر و مناسب منوی دیجیتال باشد.',
].join('\n'),
userPrompt: `نام غذا: ${dto.foodName}`,
maxTokens: 20000,
temperature: 0.7,
});
return this.persistAiUsage({
restaurant,
question: `تولید توضیح برای غذا: ${dto.foodName}`,
aiResponse,
});
}
async generateFoodContent(dto: GenerateFoodContentDto, restId: string): Promise<AiChatResult> {
const restaurant = await this.resolveRestaurant(restId);
await this.ensureHasCredit(restaurant.id);
const aiResponse = await this.callAiApi({
systemPrompt: [
'تو نویسنده منوی رستوران هستی.',
'برای نام غذای داده‌شده، محتویات و مواد تشکیل‌دهنده را به فارسی بنویس.',
'فقط لیست مواد را با ویرگول فارسی (،) جدا کن؛ بدون عنوان، بدون بولت و بدون توضیح اضافه.',
'مواد باید رایج، مختصر و مناسب منوی دیجیتال باشد.',
].join('\n'),
userPrompt: `نام غذا: ${dto.foodName}`,
maxTokens: 20000,
temperature: 0.7,
});
return this.persistAiUsage({
restaurant,
question: `تولید محتویات برای غذا: ${dto.foodName}`,
aiResponse,
});
}
private async ensureHasCredit(restaurantId: string): Promise<void> {
const balance = await this.restaurantWalletService.getCurrentBalance(restaurantId);
if (balance <= 0) {
throw new BadRequestException(AiMessage.INSUFFICIENT_CREDIT);
}
}
private async persistAiUsage(params: {
restaurant: Restaurant;
user?: User | null;
question: string;
aiResponse: AiChatCompletionResponse;
}): Promise<AiChatResult> {
const { restaurant, user = null, question, aiResponse } = params;
const promptTokens = Number(aiResponse.usage?.prompt_tokens ?? 0);
const completionTokens = Number(aiResponse.usage?.completion_tokens ?? 0);
const consumedTokens = Number(aiResponse.usage?.total_tokens ?? promptTokens + completionTokens);
const choice = aiResponse.choices?.[0];
const answer = choice?.message?.content?.trim();
if (!answer) {
this.logger.error(
`Empty AI content. finish_reason=${choice?.finish_reason ?? 'unknown'} ` +
`reasoning_tokens=${choice?.message?.reasoning_content?.length ?? 0} ` +
`usage=${JSON.stringify(aiResponse.usage ?? null)}`,
);
throw new ServiceUnavailableException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
}
// Prices in .env are per 1,000,000 tokens
const promptTokenPricePerMillion = Number(this.configService.getOrThrow<string>('AI_PROMPT_TOKEN_PRICE_PER_MILLION'));
const completionTokenPricePerMillion = Number(
this.configService.getOrThrow<string>('AI_COMPLETION_TOKEN_PRICE_PER_MILLION'),
);
const cost = Math.ceil(
(promptTokens * promptTokenPricePerMillion + completionTokens * completionTokenPricePerMillion) /
1_000_000,
);
await this.em.transactional(async (em) => {
const aiChat = em.create(AiChat, {
restaurant,
user,
question,
consumedTokens,
cost,
});
await em.persistAndFlush(aiChat);
if (cost > 0) {
await this.restaurantWalletService.createTransaction({
restaurant,
amount: cost,
type: RestaurantCreditTransactionType.DEBIT,
reason: RestaurantCreditTransactionReason.AI_CHAT,
insufficientBalanceMessage: AiMessage.INSUFFICIENT_CREDIT,
em,
});
}
});
return {
answer,
consumedTokens,
cost,
promptTokens,
completionTokens,
};
}
private async resolveRestaurant(restId?: string, slug?: string): Promise<Restaurant> {
if (!restId && !slug) {
throw new BadRequestException('شناسه یا اسلاگ رستوران الزامی است');
}
const restaurant = await this.em.findOne(Restaurant, {
...(restId ? { id: restId } : {}),
...(slug ? { slug } : {}),
});
if (!restaurant) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
return restaurant;
}
private buildMenuContext(foods: Array<Pick<Food, 'title' | 'desc' | 'content'>>): string {
if (!foods.length) {
return 'منوی رستوران در حال حاضر خالی است.';
}
return foods
.map((food, index) => {
const contents = food.content?.length ? food.content.join('، ') : 'نامشخص';
return [
`${index + 1}. نام: ${food.title ?? 'بدون نام'}`,
`توضیحات: ${food.desc ?? 'ندارد'}`,
`محتویات: ${contents}`,
].join('\n');
})
.join('\n\n');
}
private async callAiApi(params: {
systemPrompt: string;
userPrompt: string;
maxTokens?: number;
temperature?: number;
}): Promise<AiChatCompletionResponse> {
const apiUrl = this.configService.getOrThrow<string>('AI_API_URL');
const apiKey = this.configService.getOrThrow<string>('AI_API_KEY');
const model = this.configService.getOrThrow<string>('AI_MODEL');
const maxTokens = params.maxTokens ?? Number(this.configService.get('AI_MAX_TOKENS') ?? 1000);
const temperature = params.temperature ?? Number(this.configService.get('AI_TEMPERATURE') ?? 0.8);
try {
const { data } = await firstValueFrom(
this.httpService
.post<AiChatCompletionResponse>(
apiUrl,
{
model,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
max_tokens: maxTokens,
temperature,
// DeepSeek V4 thinking is on by default; CoT can exhaust max_tokens and leave content empty.
thinking: { type: 'disabled' },
},
{
headers: {
Authorization: `apikey ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 60000,
},
)
.pipe(
catchError((err: AxiosError) => {
this.logger.error(`AI API request failed: ${err.message}`, err.stack);
return throwError(() => err);
}),
),
);
return data;
} catch (error: unknown) {
this.logger.error(`AI API error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw new ServiceUnavailableException(AiMessage.LLM_SERVICE_ERROR);
}
}
}
@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { FilterQuery } from '@mikro-orm/core';
import { AiChat } from '../entities/ai-chat.entity';
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
@Injectable()
export class AiChatRepository extends EntityRepository<AiChat> {
constructor(readonly em: EntityManager) {
super(em, AiChat);
}
async findAllPaginated(restaurantId: string, dto: FindAiChatsDto): Promise<PaginatedResult<AiChat>> {
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc' } = dto;
const offset = (page - 1) * limit;
const where: FilterQuery<AiChat> = {
restaurant: { id: restaurantId },
};
if (search) {
where.question = { $ilike: `%${search}%` };
}
const [data, total] = await this.findAndCount(where, {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
populate: ['user'],
});
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
}
@@ -177,6 +177,19 @@ export class NotificationsController {
return { smsCount };
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Post('admin/notifications/test-notis')
@ApiOperation({ summary: 'Send a test in-app notification (socket) to the current admin' })
async createTestNotis(@RestId() restaurantId: string, @AdminId() adminId: string) {
const notification = await this.notificationService.sendTestInAppNotification(adminId, restaurantId);
return {
message: 'Test in-app notification queued',
notification,
};
}
// super admin endpoints
@UseGuards(SuperAdminAuthGuard)
@@ -37,10 +37,19 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
) {}
async handleConnection(client: Socket) {
this.logger.log(
`[WS] connection attempt socketId=${client.id} transport=${client.conn.transport.name} ` +
`hasAuthToken=${Boolean(client.handshake.auth?.token || client.handshake.auth?.authorization)} ` +
`hasQueryToken=${Boolean(client.handshake.query?.token || client.handshake.query?.authorization)} ` +
`hasHeaderAuth=${Boolean(client.handshake.headers.authorization)}`,
);
try {
await this.authenticateConnection(client);
const authenticatedClient = client as AuthenticatedSocket;
this.logger.log(`Admin connected: ${authenticatedClient.adminId}`);
this.logger.log(
`[WS] connected socketId=${client.id} adminId=${authenticatedClient.adminId} restId=${authenticatedClient.restId}`,
);
this.handleJoinRoom(authenticatedClient);
// Start ping interval for this client
@@ -51,7 +60,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
// this.pingIntervals.set(client.id, intervalId);
} catch (error) {
this.logger.error(
`Connection authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
`[WS] connection auth failed socketId=${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
void client.emit('error', { message: 'Authentication failed' });
client.disconnect();
@@ -59,8 +68,11 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
this.handleLeaveRoom(client);
const authenticatedClient = client as AuthenticatedSocket;
this.logger.log(
`[WS] disconnect socketId=${client.id} adminId=${authenticatedClient.adminId ?? 'n/a'} restId=${authenticatedClient.restId ?? 'n/a'}`,
);
this.handleLeaveRoom(authenticatedClient);
// Clear ping interval for this client
// const intervalId = this.pingIntervals.get(client.id);
@@ -72,13 +84,41 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) {
const room = this.getRoom(repipient.adminId, repipient.restaurantId);
const adapter = this.server.sockets?.adapter ?? this.server.adapter;
const socketsMap = this.server.sockets?.sockets ?? this.server.sockets;
const socketsInRoom = adapter?.rooms?.get(room);
const roomSize = socketsInRoom?.size ?? 0;
const connectedSocketIds =
socketsMap instanceof Map ? [...socketsMap.keys()] : [...(Object.keys(socketsMap ?? {}) as string[])];
const allRooms = adapter?.rooms ? [...adapter.rooms.keys()] : [];
this.logger.log(
`[WS] emit start room=${room} adminId=${repipient.adminId} restaurantId=${repipient.restaurantId} ` +
`notificationId=${payload.notificationId} subject=${payload.subject} roomSize=${roomSize} ` +
`namespaceConnected=${connectedSocketIds.length}`,
);
if (roomSize === 0) {
this.logger.warn(
`[WS] emit to EMPTY room=${room} — no client joined this room. ` +
`connectedSockets=[${connectedSocketIds.join(', ') || 'none'}] ` +
`allRooms=[${allRooms.join(', ') || 'none'}]`,
);
} else {
this.logger.log(`[WS] room members socketIds=[${[...(socketsInRoom ?? [])].join(', ')}]`);
}
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
this.server.to(room).emit('notifications', payload, async () => {
this.logger.log(
`[WS] ACK received room=${room} notificationId=${payload.notificationId} (client acknowledged)`,
);
// 3. ACK (delivered)
// await this.notificationService.markAsDelivered(payload.notificationId);
});
this.logger.log(`In app notification sent to admin: ${repipient.adminId}`);
this.logger.log(
`[WS] emit queued event=notifications room=${room} roomSize=${roomSize} notificationId=${payload.notificationId}`,
);
}
private getRoom(adminId: string, restaurantId: string) {
@@ -86,16 +126,30 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
}
handleLeaveRoom(client: AuthenticatedSocket) {
const room = this.getRoom(client.adminId!, client.restId!);
if (!client.adminId || !client.restId) {
this.logger.warn(`[WS] leave skipped socketId=${client.id} — missing adminId/restId`);
return;
}
const room = this.getRoom(client.adminId, client.restId);
void client.leave(room);
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
this.logger.log(`[WS] left room=${room} adminId=${client.adminId} socketId=${client.id}`);
void client.emit('left', { room, message: 'Successfully Admin left room' });
}
handleJoinRoom(client: AuthenticatedSocket) {
const room = this.getRoom(client.adminId!, client.restId!);
if (!client.adminId || !client.restId) {
this.logger.warn(`[WS] join skipped socketId=${client.id} — missing adminId/restId`);
return;
}
const room = this.getRoom(client.adminId, client.restId);
void client.join(room);
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
const adapter = this.server.sockets?.adapter ?? this.server.adapter;
const roomSize = adapter?.rooms?.get(room)?.size ?? 0;
this.logger.log(
`[WS] joined room=${room} adminId=${client.adminId} restId=${client.restId} socketId=${client.id} roomSize=${roomSize}`,
);
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
}
@@ -2,9 +2,7 @@ import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { CreateRequestContext } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityManager } from '@mikro-orm/postgresql';
import { Notification } from '../entities/notification.entity';
import { InAppNotificationQueueJob } from '../interfaces/jobs-queue.interface';
import { NotificationQueueNameEnum } from '../constants/queue';
import { NotificationsGateway } from '../notifications.gateway';
@@ -14,7 +12,6 @@ export class InAppProcessor extends WorkerHost {
private readonly logger = new Logger(InAppProcessor.name);
constructor(
@InjectRepository(Notification)
private readonly em: EntityManager,
private readonly notificationsGateway: NotificationsGateway,
) {
@@ -25,10 +22,18 @@ export class InAppProcessor extends WorkerHost {
async process(job: Job<InAppNotificationQueueJob>): Promise<void> {
const { recipient, subject, body, notificationId } = job.data;
this.logger.log(`Processing InApp notification - Recipient: ${JSON.stringify(recipient)}, subject: ${subject}`);
this.logger.log(
`[InAppProcessor] processing jobId=${job.id} attempt=${job.attemptsMade + 1} ` +
`recipient=${JSON.stringify(recipient)} subject=${subject} notificationId=${notificationId}`,
);
if (!recipient.adminId) {
this.logger.warn(`Skipping in-app notification job ${job.id}: missing adminId`);
if (!recipient?.adminId) {
this.logger.warn(`[InAppProcessor] skipping job ${job.id}: missing adminId`);
return;
}
if (!recipient.restaurantId) {
this.logger.warn(`[InAppProcessor] skipping job ${job.id}: missing restaurantId`);
return;
}
@@ -38,20 +43,30 @@ export class InAppProcessor extends WorkerHost {
{ subject, body, notificationId },
);
this.logger.log(`InApp notification sent successfully to ${recipient.adminId}`);
this.logger.log(
`[InAppProcessor] gateway emit done for admin=${recipient.adminId} restaurant=${recipient.restaurantId}`,
);
} catch (error) {
this.logger.error(`Error processing InApp notification job ${job.id}:`, error);
this.logger.error(`[InAppProcessor] error processing job ${job.id}:`, error);
throw error;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.log(`[InAppProcessor] job ${job.id} became active`);
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.log(`Notification job ${job.id} completed`);
this.logger.log(`[InAppProcessor] job ${job.id} completed`);
}
@OnWorkerEvent('failed')
onFailed(job: Job, error: Error) {
this.logger.error(`Notification job ${job.id} failed:`, error);
this.logger.error(
`[InAppProcessor] job ${job.id} failed after ${job.attemptsMade} attempt(s): ${error.message}`,
error.stack,
);
}
}
@@ -119,6 +119,12 @@ export class NotificationQueueService {
}
async addBulkInAppNotifications(jobs: InAppNotificationQueueJob[]): Promise<void> {
try {
this.logger.log(
`[addBulkInAppNotifications] queueing ${jobs.length} job(s): ${jobs
.map(j => `admin=${j.recipient.adminId} rest=${j.recipient.restaurantId} notif=${j.notificationId}`)
.join(' | ')}`,
);
const queueJobs = jobs.map(job => ({
name: 'in-app-notification',
data: job,
@@ -132,10 +138,12 @@ export class NotificationQueueService {
},
}));
await this.inAppQueue.addBulk(queueJobs);
this.logger.log(`Added ${jobs.length} InApp notification jobs to queue`);
const added = await this.inAppQueue.addBulk(queueJobs);
this.logger.log(
`[addBulkInAppNotifications] added ${added.length} job(s) to queue "${NotificationQueueNameEnum.IN_APP}": ids=${added.map(j => j.id).join(', ')}`,
);
} catch (error) {
this.logger.error(`Failed to add bulk notifications to queue:`, error);
this.logger.error(`[addBulkInAppNotifications] failed to add jobs to queue:`, error);
throw error;
}
}
@@ -24,6 +24,11 @@ export class NotificationService {
async sendNotification(params: NotifRequest): Promise<Notification[]> {
const { recipients, message, metadata, restaurantId } = params;
this.logger.log(
`[sendNotification] start restaurant=${restaurantId} title=${message.title} recipients=${recipients.length} ` +
`admins=${recipients.filter(r => 'adminId' in r).length} users=${recipients.filter(r => 'userId' in r).length}`,
);
// create Database notifications
const notifications = await this.createAdminBulkNotifications(
recipients.map(recipient => ({
@@ -35,18 +40,35 @@ export class NotificationService {
})),
);
this.logger.log(`[sendNotification] created ${notifications.length} DB notification(s)`);
// get admin prefrences
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, message.title);
if (preference?.channels?.length === 0) {
this.logger.log(
`[sendNotification] preference=${preference ? `id=${preference.id} channels=${JSON.stringify(preference.channels)}` : 'NOT FOUND'}`,
);
if (!preference) {
this.logger.warn(
`[sendNotification] skipping channels — no preference for restaurant ${restaurantId}, title ${message.title}`,
);
return notifications;
}
if (preference.channels?.length === 0) {
this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${message.title}`);
return notifications;
}
// send in app notification (admins only — user records must not emit to admin socket rooms)
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
if (preference.channels?.includes(NotifChannelEnum.IN_APP)) {
this.logger.log(`[sendNotification] IN_APP channel enabled, building jobs`);
const inAppJobs = recipients.flatMap((recipient, index) => {
if (!('adminId' in recipient) || !recipient.adminId) {
this.logger.debug(
`[sendNotification] skipping recipient index=${index} (not admin): ${JSON.stringify(recipient)}`,
);
return [];
}
@@ -61,16 +83,23 @@ export class NotificationService {
});
if (inAppJobs.length > 0) {
this.logger.log(
`[sendNotification] queueing ${inAppJobs.length} in-app job(s): ${inAppJobs.map(j => j.recipient.adminId).join(', ')}`,
);
await this.queueService.addBulkInAppNotifications(inAppJobs);
} else {
this.logger.warn(
`No admin recipients for in-app notification: restaurant ${restaurantId}, title ${message.title}`,
);
}
} else {
this.logger.warn(
`[sendNotification] IN_APP channel NOT enabled for restaurant ${restaurantId}, title ${message.title}, channels=${JSON.stringify(preference.channels)}`,
);
}
// add sms notifications to queue
if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
if (preference.channels?.includes(NotifChannelEnum.SMS)) {
await this.queueService.addBulkSmsNotifications(
recipients.map(recipient => ({
recipient,
@@ -293,4 +322,40 @@ export class NotificationService {
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
return this.smsLogRepository.getSmsCountByRestaurantId(restaurantId);
}
async sendTestInAppNotification(adminId: string, restaurantId: string): Promise<Notification> {
const title = NotifTitleEnum.ORDER_CREATED;
const content = 'Test in-app notification';
this.logger.log(`[sendTestInAppNotification] start adminId=${adminId} restaurantId=${restaurantId}`);
const [notification] = await this.createAdminBulkNotifications([
{
restaurantId,
title,
content,
adminId,
userId: null,
},
]);
this.logger.log(
`[sendTestInAppNotification] created notification id=${notification.id}, queueing in-app job`,
);
await this.queueService.addBulkInAppNotifications([
{
recipient: { adminId, restaurantId },
subject: title,
body: content,
notificationId: notification.id,
},
]);
this.logger.log(
`[sendTestInAppNotification] queued successfully admin=${adminId} restaurant=${restaurantId} notificationId=${notification.id}`,
);
return notification;
}
}
@@ -18,6 +18,7 @@ import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
import { FoodOrderReportDto } from '../dto/food-order-report.dto';
import { DailyOrderReportDto } from '../dto/daily-order-report.dto';
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
@ApiTags('orders')
@ApiBearerAuth()
@@ -155,6 +156,20 @@ export class OrdersController {
return this.ordersService.removeOrderItem(orderId, restId, itemId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Post('admin/orders/:orderId/payments')
@ApiOperation({ summary: 'Add a cash payment to an existing order' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: AdminAddPaymentDto })
addPayment(
@Param('orderId') orderId: string,
@Body() dto: AdminAddPaymentDto,
@RestId() restId: string,
) {
return this.ordersService.addPayment(orderId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Post('admin/orders/:orderId/refund')
@@ -0,0 +1,16 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class AdminAddPaymentDto {
@ApiProperty({ description: 'Payment amount', minimum: 1 })
@Type(() => Number)
@IsNumber()
@Min(1)
amount!: number;
@ApiPropertyOptional({ description: 'Payment description' })
@IsOptional()
@IsString()
description?: string;
}
@@ -29,6 +29,7 @@ import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto';
import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto';
import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto';
import { CashShiftsService } from './cash-shifts.service';
@@ -545,6 +546,52 @@ export class OrdersService {
return order;
}
async addPayment(orderId: string, restId: string, dto: AdminAddPaymentDto): Promise<Order> {
return this.em.transactional(async em => {
const order = await em.findOne(
Order,
{ id: orderId, restaurant: { id: restId } },
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
);
if (!order) {
throw new NotFoundException(OrderMessage.NOT_FOUND);
}
if (order.status === OrderStatus.CANCELED) {
throw new BadRequestException(OrderMessage.ADD_PAYMENT_NOT_ALLOWED);
}
const balance = Number(order.balance ?? 0);
if (balance <= 0) {
throw new BadRequestException(OrderMessage.ADD_PAYMENT_NO_BALANCE);
}
const amount = Number(dto.amount);
if (!amount || amount <= 0) {
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
}
const payment = em.create(Payment, {
order,
amount,
method: PaymentMethodEnum.Cash,
gateway: null,
status: PaymentStatusEnum.Paid,
paidAt: new Date(),
description: dto.description?.trim() || null,
});
em.persist(payment);
await em.flush();
return em.findOneOrFail(
Order,
{ id: orderId },
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
);
});
}
async refundOrder(orderId: string, restId: string, dto: AdminRefundOrderDto): Promise<Order> {
return this.em.transactional(async em => {
const order = await em.findOne(
@@ -7,4 +7,5 @@ export enum RestaurantCreditTransactionReason {
SMS_SEND = 'sms_send',
DEPOSIT = 'deposit',
ADJUSTMENT = 'adjustment',
AI_CHAT = 'ai_chat',
}
@@ -33,15 +33,11 @@ export class RolesController {
@Get('admin/roles/permissions')
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all permissions that the admin has' })
async findAllPermissions(@AdminId() adminId: string, @RestId() restId: string) {
const adminPermissionNames = await this.permissionService.getAdminFullPermissions(adminId, restId);
return adminPermissionNames;
@ApiOperation({ summary: 'Get all permissions ' })
async findAllPermissions() {
return this.permissionService.getPermissions();
}
@Post('admin/roles')
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ROLES)
@@ -41,6 +41,10 @@ export class PermissionsService {
return permissionNames;
}
async getPermissions() {
return this.permissionRepository.findAll()
}
/**
* Refresh admin permissions in Redis (used after login or role changes)
*/
@@ -215,6 +215,17 @@ export class UsersController {
return this.userService.adminUpdateUser(userId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS)
@ApiHeader(API_HEADER_SLUG)
@ApiOperation({ summary: 'Get user groups for a customer (admin)' })
@ApiParam({ name: 'userId', description: 'User ID' })
@Get('admin/users/:userId/groups')
async adminGetUserGroups(@Param('userId') userId: string, @RestId() restId: string) {
return this.userService.getUserGroups(userId, restId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS)
+12 -1
View File
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsMobilePhone, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsArray, IsBoolean, IsMobilePhone, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@ApiProperty({ description: "User's phone number", example: '09121234567' })
@@ -37,4 +37,15 @@ export class CreateUserDto {
@IsOptional()
@IsString()
avatarUrl?: string;
@ApiPropertyOptional({
description: 'User group IDs to assign the customer to',
type: [String],
example: ['01HXABCDEFGHIJKLMNOPQRSTUV'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
groupIds?: string[];
}
+12 -1
View File
@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsBoolean } from 'class-validator';
import { IsString, IsOptional, IsBoolean, IsArray, IsNotEmpty } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateUserDto {
@@ -32,4 +32,15 @@ export class UpdateUserDto {
@IsOptional()
@IsString()
avatarUrl?: string;
@ApiPropertyOptional({
description: 'User group IDs to assign the customer to (replaces current groups)',
type: [String],
example: ['01HXABCDEFGHIJKLMNOPQRSTUV'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
groupIds?: string[];
}
@@ -138,4 +138,42 @@ export class UserGroupService {
group.count = Math.max(0, group.count - 1);
return group;
}
async assignUserToGroups(restId: string, userId: string, groupIds: string[]): Promise<void> {
const uniqueGroupIds = [...new Set(groupIds)];
for (const groupId of uniqueGroupIds) {
await this.addUsers(restId, groupId, { userIds: [userId] });
}
}
async getGroupsForUser(restId: string, userId: string): Promise<UserGroup[]> {
const links = await this.userGroupUserRepository.find(
{
user: { id: userId },
userGroup: { restaurant: { id: restId }, deletedAt: null },
},
{ populate: ['userGroup'], orderBy: { createdAt: 'asc' } },
);
return links.map(link => link.userGroup);
}
async syncUserGroups(restId: string, userId: string, groupIds: string[]): Promise<void> {
const targetGroupIds = [...new Set(groupIds)];
const currentGroups = await this.getGroupsForUser(restId, userId);
const currentGroupIds = new Set(currentGroups.map(group => group.id));
const targetGroupIdsSet = new Set(targetGroupIds);
for (const group of currentGroups) {
if (!targetGroupIdsSet.has(group.id)) {
await this.removeUser(restId, group.id, userId);
}
}
for (const groupId of targetGroupIds) {
if (!currentGroupIds.has(groupId)) {
await this.addUsers(restId, groupId, { userIds: [userId] });
}
}
}
}
+33 -7
View File
@@ -25,6 +25,7 @@ import { UserRestaurant } from '../entities/user-restuarant.entity';
import { ImportUsersResult } from '../interfaces/import-users.interface';
import { assertExcelMimeType, parseUsersExcel } from '../utils/import-users-excel.util';
import { AuthMessage, UserImportMessage } from 'src/common/enums/message.enum';
import { UserGroupService } from './user-group.service';
@Injectable()
export class UserService {
@@ -35,6 +36,7 @@ export class UserService {
private readonly walletService: WalletService,
private readonly em: EntityManager,
private readonly userRestaurantRepository: UserRestaurantRepository,
private readonly userGroupService: UserGroupService,
) { }
async findOrCreateByPhone(phone: string): Promise<User> {
@@ -189,6 +191,10 @@ export class UserService {
this.em.persist(userRestaurant);
await this.em.flush();
if (dto.groupIds?.length) {
await this.userGroupService.assignUserToGroups(restId, user.id, dto.groupIds);
}
await this.em.populate(userRestaurant, ['user']);
return userRestaurant;
}
@@ -202,7 +208,26 @@ export class UserService {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
return this.updateUser(userId, restId, dto);
const { groupIds, ...userDto } = dto;
const user = await this.updateUser(userId, restId, userDto);
if (groupIds !== undefined) {
await this.userGroupService.syncUserGroups(restId, userId, groupIds);
}
return user;
}
async getUserGroups(userId: string, restId: string) {
const userRestaurant = await this.userRestaurantRepository.findOne({
user: { id: userId },
restaurant: { id: restId },
});
if (!userRestaurant) {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
return this.userGroupService.getGroupsForUser(restId, userId);
}
async updateUser(userId: string, restId: string, dto: UpdateUserDto): Promise<User> {
@@ -211,15 +236,16 @@ export class UserService {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
const { groupIds: _groupIds, ...userFields } = dto;
// Normalize date strings into Date objects if provided
const assignData: Partial<User> = { ...dto } as Partial<User>;
if (dto.birthDate) {
assignData.birthDate = new Date(dto.birthDate);
const assignData: Partial<User> = { ...userFields } as Partial<User>;
if (userFields.birthDate) {
assignData.birthDate = new Date(userFields.birthDate);
}
if (dto.marriageDate) {
assignData.marriageDate = new Date(dto.marriageDate);
if (userFields.marriageDate) {
assignData.marriageDate = new Date(userFields.marriageDate);
}
// get restuarant
this.em.assign(user, assignData);
await this.em.flush();