diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts index 3e9dc67..078b6a7 100644 --- a/apps/api/src/conversations/conversations.controller.ts +++ b/apps/api/src/conversations/conversations.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { RequireOrgModule } from '../billing/org-module.decorator'; @@ -6,7 +6,6 @@ import { OrgModuleGuard } from '../billing/org-module.guard'; import { CreateConversationDmDto, CreateConversationGroupDto, - UpdateConversationSettingsDto, } from './dto'; import { ConversationsService } from './conversations.service'; @@ -21,19 +20,6 @@ export class ConversationsController { return this.conversations.getAccess(user.userId, user.organizationId, user.role); } - @Get('settings') - settings(@CurrentUser() user: { organizationId: string; role: string }) { - return this.conversations.getSettings(user.organizationId); - } - - @Patch('settings') - updateSettings( - @CurrentUser() user: { organizationId: string; role: string }, - @Body() dto: UpdateConversationSettingsDto, - ) { - return this.conversations.updateSettings(user.organizationId, user.role, dto); - } - @Get('session') session(@CurrentUser() user: { userId: string; organizationId: string; role: string }) { return this.conversations.getSession(user.userId, user.organizationId, user.role); diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 569cec0..44bf7b6 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -12,9 +12,7 @@ import type { ConversationSettingsDto, PlatformConversationStatsDto, UpdateConversationSettingsRequest, - UserRole, } from '@mento/shared'; -import { canManageSettings } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { TahesabCryptoService } from '../integrations/tahesab/tahesab-crypto.service'; import { ChatExternalClient, type ExternalRoomRow } from './chat-external.client'; @@ -59,14 +57,17 @@ export class ConversationsService { }; } - async updateSettings( + /** ذخیره تنظیمات اتصال چت — فقط از مسیر سوپرادمین */ + async upsertSettingsForOrganization( organizationId: string, - role: UserRole | string, dto: UpdateConversationSettingsRequest, ): Promise { - if (!canManageSettings(role)) { - throw new ForbiddenException('فقط مدیر می‌تواند تنظیمات گفتگو را تغییر دهد'); - } + const org = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + select: { id: true }, + }); + if (!org) throw new NotFoundException('محیط پیدا نشد'); + const host = this.chat.normalizeHost(dto.chatHost); if (!host) throw new BadRequestException('آدرس سرویس گفتگو نامعتبر است'); diff --git a/apps/api/src/leads/leads-import.service.ts b/apps/api/src/leads/leads-import.service.ts index 511e1fe..2ce998a 100644 --- a/apps/api/src/leads/leads-import.service.ts +++ b/apps/api/src/leads/leads-import.service.ts @@ -3,9 +3,66 @@ import { Cron } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import type { LeadImportJobDto } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; +import { + parseExcelMapping, + previewExcel, + readMappedRows, + type ExcelFieldOption, + type ExcelPreviewResult, +} from '../common/excel'; import { LeadStagesService } from './lead-stages.service'; import { LeadsService } from './leads.service'; +const LEAD_ALIASES: Record = { + title: 'title', + name: 'title', + shop: 'title', + نام: 'title', + نامفروشگاه: 'title', + فروشگاه: 'title', + عنوان: 'title', + contactname: 'contactName', + owner: 'contactName', + مالک: 'contactName', + مسئول: 'contactName', + ناممالک: 'contactName', + ناممخاطب: 'contactName', + mobile: 'mobile', + phone: 'mobile', + tel: 'mobile', + موبایل: 'mobile', + شماره: 'mobile', + شمارهتماس: 'mobile', + تلفن: 'phone', + تلفنثابت: 'phone', + city: 'city', + شهر: 'city', + region: 'region', + منطقه: 'region', + محله: 'region', + trade: 'trade', + صنف: 'trade', + رسته: 'trade', + source: 'source', + منبع: 'source', + منبعلید: 'source', + notes: 'notes', + توضیحات: 'notes', + یادداشت: 'notes', +}; + +const LEAD_FIELDS: ExcelFieldOption[] = [ + { key: 'title', label: 'نام / فروشگاه', required: true }, + { key: 'mobile', label: 'موبایل', required: true }, + { key: 'contactName', label: 'نام مخاطب / مالک' }, + { key: 'phone', label: 'تلفن' }, + { key: 'city', label: 'شهر' }, + { key: 'region', label: 'منطقه' }, + { key: 'trade', label: 'صنف' }, + { key: 'source', label: 'منبع' }, + { key: 'notes', label: 'توضیحات' }, +]; + @Injectable() export class LeadsImportService { private readonly logger = new Logger(LeadsImportService.name); @@ -17,6 +74,22 @@ export class LeadsImportService { private readonly leads: LeadsService, ) {} + preview(file: Express.Multer.File): Promise { + return previewExcel(file, LEAD_ALIASES, LEAD_FIELDS); + } + + async rowsFromFile( + file: Express.Multer.File, + mappingRaw?: string, + ): Promise>> { + const rows = await readMappedRows( + file, + LEAD_ALIASES, + parseExcelMapping(mappingRaw), + ); + return rows.map((r) => this.normalizeRow(r)); + } + async enqueue( organizationId: string, createdById: string, diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts index 429590e..a175a91 100644 --- a/apps/api/src/leads/leads.controller.ts +++ b/apps/api/src/leads/leads.controller.ts @@ -25,7 +25,6 @@ import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; import { OrgModuleGuard } from '../billing/org-module.guard'; import { RequireOrgModule } from '../billing/org-module.decorator'; -import { cellStr } from '../common/excel'; import { brand } from '@mento/shared'; import { AssignLeadsDto, @@ -139,36 +138,43 @@ export class LeadsController { ); } + @Post('import/preview') + @UseGuards(RolesGuard) + @Roles('owner', 'admin') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 15_000_000 }, + }), + ) + importPreview(@UploadedFile() file?: Express.Multer.File) { + if (!file?.buffer?.length) { + throw new BadRequestException('فایل ارسال نشده'); + } + return this.imports.preview(file); + } + @Post('import/file') @UseGuards(RolesGuard) @Roles('owner', 'admin') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 15_000_000 }, + }), + ) async importFile( @CurrentUser() user: Actor, @UploadedFile() file?: Express.Multer.File, + @Body('mapping') mapping?: string, ) { if (!file?.buffer?.length) { throw new BadRequestException('فایل ارسال نشده'); } - const wb = new ExcelJS.Workbook(); - await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer); - const sheet = wb.worksheets[0]; - if (!sheet) throw new BadRequestException('برگه اکسل خالی است'); - const headerRow = sheet.getRow(1); - const headers: string[] = []; - headerRow.eachCell({ includeEmpty: true }, (cell, col) => { - headers[col - 1] = cellStr(cell.value); - }); - const rows: Array> = []; - sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { - if (rowNumber === 1) return; - const obj: Record = {}; - headers.forEach((h, i) => { - if (!h) return; - obj[h] = cellStr(row.getCell(i + 1).value); - }); - if (Object.values(obj).some((v) => v.trim())) rows.push(obj); - }); + const rows = await this.imports.rowsFromFile(file, mapping); + if (!rows.length) { + throw new BadRequestException('ردیفی برای ایمپورت نیست'); + } return this.imports.enqueue( user.organizationId, user.userId, diff --git a/apps/api/src/platform/platform.controller.ts b/apps/api/src/platform/platform.controller.ts index 1da39ab..9a3d7fe 100644 --- a/apps/api/src/platform/platform.controller.ts +++ b/apps/api/src/platform/platform.controller.ts @@ -20,6 +20,7 @@ import { UpdatePlatformOrganizationDto, UpdatePlatformStatusDto, } from './dto'; +import { UpdateConversationSettingsDto } from '../conversations/dto'; import { PlatformAuthGuard } from './platform-auth.guard'; import type { PlatformActor } from './platform-jwt.strategy'; import { PlatformService } from './platform.service'; @@ -129,6 +130,19 @@ export class PlatformController { return this.platform.updateOrganizationModules(id, dto); } + @Get('organizations/:id/conversations/settings') + getOrganizationConversationSettings(@Param('id') id: string) { + return this.conversations.getSettings(id); + } + + @Patch('organizations/:id/conversations/settings') + updateOrganizationConversationSettings( + @Param('id') id: string, + @Body() dto: UpdateConversationSettingsDto, + ) { + return this.conversations.upsertSettingsForOrganization(id, dto); + } + @Patch('memberships/:id') setMembershipActive( @Param('id') id: string, diff --git a/apps/web/src/components/ExcelImportFlow.vue b/apps/web/src/components/ExcelImportFlow.vue index 3544443..036b14a 100644 --- a/apps/web/src/components/ExcelImportFlow.vue +++ b/apps/web/src/components/ExcelImportFlow.vue @@ -13,7 +13,7 @@ const props = defineProps<{ const emit = defineEmits<{ start: []; - done: [ImportContactsResult]; + done: [ImportContactsResult | Record]; fail: [string]; settled: []; }>(); @@ -64,9 +64,13 @@ async function postImport(file: File, map?: Record) { if (map && Object.values(map).some(Boolean)) { form.append('mapping', JSON.stringify(map)); } - const { data } = await api.post(props.importPath, form, { - headers: { 'Content-Type': undefined }, - }); + const { data } = await api.post>( + props.importPath, + form, + { + headers: { 'Content-Type': undefined }, + }, + ); return data; } diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 3ae1246..2bc3763 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -64,7 +64,8 @@ const salesActive = computed(() => ); const activitiesActive = computed(() => pathIn(['/activities'])); const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/')); -const supportActive = computed(() => pathIn(['/tickets', '/conversations'])); +const conversationsActive = computed(() => pathIn(['/conversations'])); +const supportActive = computed(() => pathIn(['/tickets'])); const automationsActive = computed( () => route.path.startsWith('/settings/automations') || @@ -91,13 +92,13 @@ const settingsActive = computed(() => '/settings/tahesab', '/settings/woocommerce', '/settings/voip', - '/settings/conversations', '/settings/faq', ]), ); const moreSectionActive = computed( () => notesActive.value || + conversationsActive.value || supportActive.value || loyaltyActive.value || reportsActive.value || @@ -492,6 +493,18 @@ onUnmounted(() => { + + + گفتگو + + { یادداشت‌ها + + + گفتگو +