From 44f9849d0ee5aa1c00fbf5fdd7d31053e7db4c46 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 15 Sep 2026 17:33:05 +0330 Subject: [PATCH] Creating import&export excel --- .../migration.sql | 29 + apps/api/prisma/schema.prisma | 24 + .../activities/activities-excel.service.ts | 280 +++ .../src/activities/activities.controller.ts | 59 +- apps/api/src/activities/activities.module.ts | 3 +- apps/api/src/app.module.ts | 2 + apps/api/src/auth/auth.service.ts | 13 + apps/api/src/auth/jwt.strategy.ts | 21 +- apps/api/src/common/excel.ts | 230 ++ .../src/contacts/contacts-excel.service.ts | 388 ++-- apps/api/src/org-roles/dto.ts | 77 + .../api/src/org-roles/org-roles.controller.ts | 56 + apps/api/src/org-roles/org-roles.module.ts | 12 + apps/api/src/org-roles/org-roles.service.ts | 264 +++ apps/api/src/org-roles/role-defaults.ts | 69 + .../project-boards.controller.ts | 61 +- .../project-boards/project-boards.module.ts | 3 +- .../project-cards-excel.service.ts | 258 +++ apps/api/src/users/dto.ts | 22 +- apps/api/src/users/users.controller.ts | 34 +- apps/api/src/users/users.module.ts | 3 +- apps/api/src/users/users.service.ts | 144 +- apps/web/src/views/ActivitiesView.vue | 102 +- apps/web/src/views/ProjectBoardView.vue | 86 +- apps/web/src/views/TeamSettingsView.vue | 1843 ++++++++++++++--- packages/shared/src/index.ts | 73 +- 26 files changed, 3682 insertions(+), 474 deletions(-) create mode 100644 apps/api/prisma/migrations/20260915140000_org_roles_user_status/migration.sql create mode 100644 apps/api/src/activities/activities-excel.service.ts create mode 100644 apps/api/src/common/excel.ts create mode 100644 apps/api/src/org-roles/dto.ts create mode 100644 apps/api/src/org-roles/org-roles.controller.ts create mode 100644 apps/api/src/org-roles/org-roles.module.ts create mode 100644 apps/api/src/org-roles/org-roles.service.ts create mode 100644 apps/api/src/org-roles/role-defaults.ts create mode 100644 apps/api/src/project-boards/project-cards-excel.service.ts diff --git a/apps/api/prisma/migrations/20260915140000_org_roles_user_status/migration.sql b/apps/api/prisma/migrations/20260915140000_org_roles_user_status/migration.sql new file mode 100644 index 0000000..8799922 --- /dev/null +++ b/apps/api/prisma/migrations/20260915140000_org_roles_user_status/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE IF NOT EXISTS "OrgRole" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "color" TEXT NOT NULL DEFAULT 'teal', + "permissions" TEXT[] DEFAULT ARRAY[]::TEXT[], + "systemKey" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrgRole_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "OrgRole_organizationId_name_key" ON "OrgRole"("organizationId", "name"); +CREATE INDEX IF NOT EXISTS "OrgRole_organizationId_idx" ON "OrgRole"("organizationId"); + +ALTER TABLE "OrgRole" DROP CONSTRAINT IF EXISTS "OrgRole_organizationId_fkey"; +ALTER TABLE "OrgRole" ADD CONSTRAINT "OrgRole_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "lastLoginAt" TIMESTAMP(3); +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "orgRoleId" TEXT; + +CREATE INDEX IF NOT EXISTS "User_orgRoleId_idx" ON "User"("orgRoleId"); + +ALTER TABLE "User" DROP CONSTRAINT IF EXISTS "User_orgRoleId_fkey"; +ALTER TABLE "User" ADD CONSTRAINT "User_orgRoleId_fkey" FOREIGN KEY ("orgRoleId") REFERENCES "OrgRole"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 438547c..c08dcb1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -150,6 +150,7 @@ model Organization { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt users User[] + orgRoles OrgRole[] companies Company[] people Person[] pipelines Pipeline[] @@ -199,6 +200,10 @@ model User { role UserRole @default(sales) /// مسیر فایل آواتار در فضای آپلود avatarStorageKey String? + isActive Boolean @default(true) + lastLoginAt DateTime? + orgRoleId String? + orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull) emailVerifiedAt DateTime? mobileVerifiedAt DateTime? organizationId String @@ -229,6 +234,25 @@ model User { @@unique([mobile]) @@index([organizationId]) @@index([email]) + @@index([orgRoleId]) +} + +model OrgRole { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + description String @default("") + color String @default("teal") + permissions String[] @default([]) + /// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند + systemKey String? + users User[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, name]) + @@index([organizationId]) } model Company { diff --git a/apps/api/src/activities/activities-excel.service.ts b/apps/api/src/activities/activities-excel.service.ts new file mode 100644 index 0000000..444b43f --- /dev/null +++ b/apps/api/src/activities/activities-excel.service.ts @@ -0,0 +1,280 @@ +import { Injectable } from '@nestjs/common'; +import type { ActivityType, ImportContactsResult } from '@mento/shared'; +import { PrismaService } from '../prisma/prisma.service'; +import type { Actor } from '../common/record-scope'; +import { assigneeWhere } from '../common/record-scope'; +import { normalizePhone } from '../contacts/normalize'; +import { + formatClock, + formatJalaliDate, + parseJalaliDateTime, + readMappedRows, + stripHtml, + writeXlsx, +} from '../common/excel'; + +const ACTIVITY_COLUMNS = [ + { header: '_type', key: '_type', width: 12 }, + { header: 'نوع فعالیت', key: 'kind', width: 16 }, + { header: 'عنوان فعالیت', key: 'title', width: 24 }, + { header: 'متن فعالیت', key: 'body', width: 36 }, + { header: 'نتیجه فعالیت', key: 'result', width: 20 }, + { header: 'مسئول اجرا فعالیت', key: 'assignee', width: 18 }, + { header: 'ثبت کننده فعالیت', key: 'creator', width: 18 }, + { header: 'تاریخ برنامه ریزی شده اجرا فعالیت', key: 'dueDate', width: 18 }, + { header: 'زمان برنامه ریزی شده اجرا فعالیت', key: 'dueTime', width: 14 }, + { header: 'وضعیت اجرای فعالیت', key: 'status', width: 16 }, + { header: 'تاریخ انجام شدن فعالیت', key: 'doneDate', width: 16 }, + { header: 'کد معامله فعالیت', key: 'dealCode', width: 14 }, + { header: 'کد کارت فعالیت', key: 'cardCode', width: 14 }, + { header: 'زمان انجام شدن فعالیت', key: 'doneTime', width: 14 }, + { header: 'اشخاص فعالیت', key: 'personName', width: 18 }, + { header: 'کد اشخاص فعالیت', key: 'personCode', width: 14 }, + { header: 'موبایل اشخاص فعالیت', key: 'personMobile', width: 16 }, + { header: 'شرکت های فعالیت', key: 'companyName', width: 20 }, + { header: 'کد شرکت های فعالیت', key: 'companyCode', width: 14 }, + { header: 'معامله فعالیت_1', key: 'dealTitle', width: 24 }, + { header: 'کارت فعالیت_1', key: 'cardTitle', width: 20 }, + { header: 'هشتگ', key: 'hashtag', width: 16 }, +]; + +const ACTIVITY_ALIASES: Record = { + _type: '_type', + type: '_type', + نوعفعالیت: 'kind', + عنوانفعالیت: 'title', + عنوان: 'title', + متنفعالیت: 'body', + نتیجهفعالیت: 'result', + مسئولاجرافعالیت: 'assignee', + ثبتکنندهفعالیت: 'creator', + تاریخبرنامهریزیشدهاجرافعالیت: 'dueDate', + زمانبرنامهریزیشدهاجرافعالیت: 'dueTime', + وضعیتاجرایفعالیت: 'status', + تاریخانجامشدنفعالیت: 'doneDate', + زمانانجامشدنفعالیت: 'doneTime', + کدمعاملهفعالیت: 'dealCode', + کدکارتفعالیت: 'cardCode', + اشخاصفعالیت: 'personName', + کداشخاصفعالیت: 'personCode', + موبایلاشخاصفعالیت: 'personMobile', + شرکتهایفعالیت: 'companyName', + کدشرکتهایفعالیت: 'companyCode', + معاملهفعالیت1: 'dealTitle', + کارتفعالیت1: 'cardTitle', + هشتگ: 'hashtag', +}; + +const TYPE_LABEL: Record = { + call: 'تماس با مشتری', + meeting: 'جلسه', + task: 'پیگیری', + note: 'یادداشت', +}; + +function parseType(raw?: string): ActivityType { + const v = (raw ?? '').trim(); + if (/تماس|call/i.test(v)) return 'call'; + if (/جلسه|ملاقات|meeting/i.test(v)) return 'meeting'; + if (/یادداشت|پیام|note/i.test(v)) return 'note'; + return 'task'; +} + +function parseStatus(raw?: string): 'open' | 'done' | 'canceled' { + const v = (raw ?? '').trim(); + if (/انجام\s*شده|done/i.test(v)) return 'done'; + if (/لغو|canceled|کنسل/i.test(v)) return 'canceled'; + return 'open'; +} + +function statusLabel(status: string) { + if (status === 'done') return 'انجام شده'; + if (status === 'canceled') return 'لغو شده'; + return 'انجام نشده'; +} + +@Injectable() +export class ActivitiesExcelService { + constructor(private readonly prisma: PrismaService) {} + + async export(actor: Actor): Promise { + const rows = await this.prisma.activity.findMany({ + where: { organizationId: actor.organizationId, ...assigneeWhere(actor) }, + include: { + person: { select: { id: true, firstName: true, lastName: true, mobile: true } }, + company: { select: { id: true, name: true } }, + deal: { select: { id: true, title: true } }, + assignee: { select: { fullName: true } }, + createdBy: { select: { fullName: true } }, + }, + orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }], + take: 10000, + }); + + return writeXlsx( + 'Activity', + ACTIVITY_COLUMNS, + rows.map((row) => ({ + _type: 'Activity', + kind: TYPE_LABEL[row.type], + title: row.title, + body: row.description ?? '', + result: '', + assignee: row.assignee?.fullName ?? '', + creator: row.createdBy?.fullName ?? '', + dueDate: formatJalaliDate(row.dueAt), + dueTime: formatClock(row.dueAt), + status: statusLabel(row.status), + doneDate: formatJalaliDate(row.completedAt), + dealCode: row.deal?.id ?? '', + cardCode: '', + doneTime: formatClock(row.completedAt), + personName: row.person + ? `${row.person.firstName} ${row.person.lastName}`.trim() + : '', + personCode: row.person?.id ?? '', + personMobile: row.person?.mobile ?? '', + companyName: row.company?.name ?? '', + companyCode: row.company?.id ?? '', + dealTitle: row.deal?.title ?? '', + cardTitle: '', + hashtag: '', + })), + ); + } + + async import(actor: Actor, file: Express.Multer.File): Promise { + const rows = await readMappedRows(file, ACTIVITY_ALIASES); + const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; + const rowNum = i + 2; + if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'activity') { + continue; + } + const title = (row.title || row.kind || '').trim(); + if (title.length < 2) { + result.errors.push({ row: rowNum, message: 'عنوان فعالیت الزامی است' }); + continue; + } + + const body = stripHtml([row.body, row.result, row.hashtag, row.cardTitle] + .filter(Boolean) + .join('\n')); + const dueAt = parseJalaliDateTime(row.dueDate, row.dueTime); + const doneAt = parseJalaliDateTime(row.doneDate, row.doneTime); + const status = parseStatus(row.status); + + try { + const personId = await this.findPersonId(actor.organizationId, row); + const companyId = await this.findCompanyId(actor.organizationId, row.companyName); + const dealId = await this.findDealId(actor.organizationId, row.dealTitle); + const assigneeId = + (await this.findUserId(actor.organizationId, row.assignee)) ?? actor.userId; + const creatorId = + (await this.findUserId(actor.organizationId, row.creator)) ?? actor.userId; + + const duplicate = dueAt + ? await this.prisma.activity.findFirst({ + where: { + organizationId: actor.organizationId, + title, + dueAt, + personId: personId ?? null, + }, + select: { id: true }, + }) + : null; + if (duplicate) { + result.skipped += 1; + continue; + } + + await this.prisma.activity.create({ + data: { + organizationId: actor.organizationId, + title, + type: parseType(row.kind), + status, + description: body || null, + dueAt, + completedAt: status === 'done' ? doneAt ?? new Date() : null, + reminderNotifyApp: false, + reminderNotifySms: false, + personId, + companyId, + dealId, + assigneeId, + createdById: creatorId, + }, + }); + result.created += 1; + } catch (e) { + result.errors.push({ + row: rowNum, + message: e instanceof Error ? e.message : 'خطای ناشناخته', + }); + } + } + + return result; + } + + private async findPersonId(organizationId: string, row: Record) { + const mobile = normalizePhone(row.personMobile); + if (mobile) { + const byMobile = await this.prisma.person.findFirst({ + where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } }, + select: { id: true }, + }); + if (byMobile) return byMobile.id; + } + const name = (row.personName ?? '').trim(); + if (name) { + const parts = name.split(/\s+/); + const byName = await this.prisma.person.findFirst({ + where: { + organizationId, + OR: [ + { lastName: { contains: name, mode: 'insensitive' } }, + { firstName: { contains: parts[0]!, mode: 'insensitive' } }, + ], + }, + select: { id: true }, + }); + if (byName) return byName.id; + } + return null; + } + + private async findCompanyId(organizationId: string, name?: string) { + const n = name?.trim(); + if (!n) return null; + const row = await this.prisma.company.findFirst({ + where: { organizationId, name: { equals: n, mode: 'insensitive' } }, + select: { id: true }, + }); + return row?.id ?? null; + } + + private async findDealId(organizationId: string, title?: string) { + const t = title?.trim(); + if (!t) return null; + const row = await this.prisma.deal.findFirst({ + where: { organizationId, title: { equals: t, mode: 'insensitive' } }, + select: { id: true }, + }); + return row?.id ?? null; + } + + private async findUserId(organizationId: string, name?: string) { + const n = name?.trim(); + if (!n) return null; + const row = await this.prisma.user.findFirst({ + where: { organizationId, fullName: { contains: n, mode: 'insensitive' } }, + select: { id: true }, + }); + return row?.id ?? null; + } +} diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 1f997d7..35436a6 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -7,32 +7,43 @@ import { Patch, Post, Query, + Res, + StreamableFile, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; +import type { Actor } from '../common/record-scope'; import { ActivitiesService } from './activities.service'; +import { ActivitiesExcelService } from './activities-excel.service'; import { CreateActivityDto, ListActivitiesQueryDto, UpdateActivityDto, } from './dto'; - -type AuthUser = { organizationId: string; userId: string; role: string }; +import { brand } from '@mento/shared'; @Controller('activities') @UseGuards(JwtAuthGuard) export class ActivitiesController { - constructor(private readonly activitiesService: ActivitiesService) {} + constructor( + private readonly activitiesService: ActivitiesService, + private readonly excelService: ActivitiesExcelService, + ) {} @Get('summary') - summary(@CurrentUser() user: AuthUser) { + summary(@CurrentUser() user: Actor) { return this.activitiesService.summary(user.organizationId, user); } @Get() list( - @CurrentUser() user: AuthUser, + @CurrentUser() user: Actor, @Query() query: ListActivitiesQueryDto, ) { return this.activitiesService.list( @@ -54,13 +65,41 @@ export class ActivitiesController { ); } + @Get('export') + async export( + @CurrentUser() user: Actor, + @Res({ passthrough: true }) res: Response, + ) { + const buffer = await this.excelService.export(user); + res.set({ + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${brand.filePrefix}-activities.xlsx"`, + }); + return new StreamableFile(buffer); + } + + @Post('import') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 15_000_000 }, + }), + ) + import( + @CurrentUser() user: Actor, + @UploadedFile() file: Express.Multer.File, + ) { + return this.excelService.import(user, file); + } + @Get(':id') - get(@CurrentUser() user: AuthUser, @Param('id') id: string) { + get(@CurrentUser() user: Actor, @Param('id') id: string) { return this.activitiesService.get(user.organizationId, id, user); } @Post() - create(@CurrentUser() user: AuthUser, @Body() dto: CreateActivityDto) { + create(@CurrentUser() user: Actor, @Body() dto: CreateActivityDto) { return this.activitiesService.create( user.organizationId, user.userId, @@ -71,7 +110,7 @@ export class ActivitiesController { @Patch(':id') update( - @CurrentUser() user: AuthUser, + @CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: UpdateActivityDto, ) { @@ -79,12 +118,12 @@ export class ActivitiesController { } @Patch(':id/complete') - complete(@CurrentUser() user: AuthUser, @Param('id') id: string) { + complete(@CurrentUser() user: Actor, @Param('id') id: string) { return this.activitiesService.complete(user.organizationId, id, user); } @Delete(':id') - async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { + async remove(@CurrentUser() user: Actor, @Param('id') id: string) { await this.activitiesService.remove(user.organizationId, id, user); return { ok: true }; } diff --git a/apps/api/src/activities/activities.module.ts b/apps/api/src/activities/activities.module.ts index 53de307..8ad52b8 100644 --- a/apps/api/src/activities/activities.module.ts +++ b/apps/api/src/activities/activities.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ActivitiesController } from './activities.controller'; import { ActivitiesService } from './activities.service'; +import { ActivitiesExcelService } from './activities-excel.service'; import { NotificationsModule } from '../notifications/notifications.module'; import { AutomationsModule } from '../automations/automations.module'; import { MessageAutomationsModule } from '../message-automations/message-automations.module'; @@ -8,7 +9,7 @@ import { MessageAutomationsModule } from '../message-automations/message-automat @Module({ imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule], controllers: [ActivitiesController], - providers: [ActivitiesService], + providers: [ActivitiesService, ActivitiesExcelService], exports: [ActivitiesService], }) export class ActivitiesModule {} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ddee91a..d69ccaf 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -14,6 +14,7 @@ import { ProductsModule } from './products/products.module'; import { NotificationsModule } from './notifications/notifications.module'; import { StorageModule } from './storage/storage.module'; import { UsersModule } from './users/users.module'; +import { OrgRolesModule } from './org-roles/org-roles.module'; import { LeadFormsModule } from './lead-forms/lead-forms.module'; import { QuotesModule } from './quotes/quotes.module'; import { InvoicesModule } from './invoices/invoices.module'; @@ -45,6 +46,7 @@ import { FinanceModule } from './finance/finance.module'; HealthModule, AuthModule, UsersModule, + OrgRolesModule, OrganizationsModule, ContactsModule, PipelinesModule, diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index f69ddcc..1e63461 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -278,6 +278,9 @@ export class AuthService { if (!user) { throw new UnauthorizedException('اطلاعات ورود نادرست است'); } + if (user.isActive === false) { + throw new UnauthorizedException('حساب کاربری غیرفعال است'); + } const ok = await bcrypt.compare(dto.password, user.passwordHash); if (!ok) { @@ -298,6 +301,9 @@ export class AuthService { if (!user) { throw new UnauthorizedException('حسابی با این موبایل یافت نشد'); } + if (user.isActive === false) { + throw new UnauthorizedException('حساب کاربری غیرفعال است'); + } await this.throttleChallenges('login', user.email, mobile); @@ -352,6 +358,9 @@ export class AuthService { if (!user) { throw new UnauthorizedException(); } + if (user.isActive === false) { + throw new UnauthorizedException('حساب کاربری غیرفعال است'); + } const now = new Date(); await this.prisma.authChallenge.update({ @@ -600,6 +609,10 @@ export class AuthService { organizationId: user.organizationId, role: user.role, }; + await this.prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); return { accessToken: this.jwt.sign(payload), user: await this.toAuthUserDto(user), diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index 50ad896..8261724 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PrismaService } from '../prisma/prisma.service'; export type JwtPayload = { sub: string; @@ -11,7 +12,10 @@ export type JwtPayload = { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(config: ConfigService) { + constructor( + config: ConfigService, + private readonly prisma: PrismaService, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -19,14 +23,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - validate(payload: JwtPayload & { typ?: string; mobile?: string }) { + async validate(payload: JwtPayload & { typ?: string; mobile?: string }) { if (payload.typ === 'portal') { throw new UnauthorizedException(); } + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: { id: true, organizationId: true, role: true, isActive: true }, + }); + if (!user || user.isActive === false) { + throw new UnauthorizedException(); + } return { - userId: payload.sub, - organizationId: payload.organizationId, - role: payload.role, + userId: user.id, + organizationId: user.organizationId, + role: user.role, }; } } diff --git a/apps/api/src/common/excel.ts b/apps/api/src/common/excel.ts new file mode 100644 index 0000000..96044a8 --- /dev/null +++ b/apps/api/src/common/excel.ts @@ -0,0 +1,230 @@ +import { BadRequestException } from '@nestjs/common'; +import ExcelJS from 'exceljs'; + +export function cellStr(value: ExcelJS.CellValue | undefined): string { + if (value == null) return ''; + if (typeof value === 'string' || typeof value === 'number') { + return String(value).trim(); + } + if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) { + return value.richText.map((part) => part.text).join('').trim(); + } + if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') { + return value.text.trim(); + } + if (typeof value === 'object' && 'result' in value && value.result != null) { + return String(value.result).trim(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return String(value).trim(); +} + +export function normalizeHeader(raw: string) { + return raw + .trim() + .toLowerCase() + .replace(/[_-]+/g, '') + .replace(/‌/g, '') + .replace(/\s+/g, '') + .replace(/[()]/g, ''); +} + +export function stripHtml(raw: string) { + return raw + .replace(//gi, '\n') + .replace(/ /gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function toAsciiDigits(value: string) { + return value + .replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))) + .replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d))); +} + +function div(a: number, b: number) { + return Math.trunc(a / b); +} + +function mod(a: number, b: number) { + return a - Math.trunc(a / b) * b; +} + +export function toJalali(gy: number, gm: number, gd: number) { + const gdm = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; + const gy2 = gm > 2 ? gy + 1 : gy; + let days = + 355666 + + 365 * gy + + div(gy2 + 3, 4) - + div(gy2 + 99, 100) + + div(gy2 + 399, 400) + + gd + + gdm[gm - 1]; + let jy = -1595 + 33 * div(days, 12053); + days %= 12053; + jy += 4 * div(days, 1461); + days %= 1461; + if (days > 365) { + jy += div(days - 1, 365); + days = (days - 1) % 365; + } + const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30); + const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30); + return { jy, jm, jd }; +} + +export function toGregorian(jy: number, jm: number, jd: number) { + const jy2 = jy + 1595; + let days = + -355668 + + 365 * jy2 + + div(jy2, 33) * 8 + + div(mod(jy2, 33) + 3, 4) + + jd + + (jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186); + let gy = 400 * div(days, 146097); + days %= 146097; + if (days > 36524) { + gy += 100 * div(--days, 36524); + days %= 36524; + if (days >= 365) days += 1; + } + gy += 4 * div(days, 1461); + days %= 1461; + if (days > 365) { + gy += div(days - 1, 365); + days = (days - 1) % 365; + } + const gd = days + 1; + const salA = [ + 0, + 31, + (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + let gm = 0; + let gDay = gd; + for (let i = 1; i <= 12; i += 1) { + const v = salA[i]!; + if (gDay <= v) { + gm = i; + break; + } + gDay -= v; + } + return { gy, gm, gd: gDay }; +} + +export function formatJalaliDate(d: Date | string | null | undefined) { + if (!d) return ''; + const date = typeof d === 'string' ? new Date(d) : d; + if (Number.isNaN(date.getTime())) return ''; + const { jy, jm, jd } = toJalali(date.getFullYear(), date.getMonth() + 1, date.getDate()); + return `${jy}/${String(jm).padStart(2, '0')}/${String(jd).padStart(2, '0')}`; +} + +export function formatClock(d: Date | string | null | undefined) { + if (!d) return ''; + const date = typeof d === 'string' ? new Date(d) : d; + if (Number.isNaN(date.getTime())) return ''; + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +} + +export function parseJalaliDateTime(dateRaw?: string, timeRaw?: string): Date | null { + const dateText = toAsciiDigits(dateRaw ?? '').trim(); + if (!dateText) return null; + const match = /^(\d{3,4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/.exec(dateText); + if (!match) { + const fallback = new Date(dateText); + return Number.isNaN(fallback.getTime()) ? null : fallback; + } + const jy = Number(match[1]); + const jm = Number(match[2]); + const jd = Number(match[3]); + const { gy, gm, gd } = toGregorian(jy, jm, jd); + let hh = 0; + let mm = 0; + const timeText = toAsciiDigits(timeRaw ?? '').trim(); + const tm = /^(\d{1,2}):(\d{2})/.exec(timeText); + if (tm) { + hh = Number(tm[1]); + mm = Number(tm[2]); + } + return new Date(gy, gm - 1, gd, hh, mm, 0, 0); +} + +export async function workbookFromUpload(file: Express.Multer.File | undefined) { + 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('شیت اکسل پیدا نشد'); + return sheet; +} + +export async function readMappedRows( + file: Express.Multer.File, + aliases: Record, +): Promise[]> { + const sheet = await workbookFromUpload(file); + const headerRow = sheet.getRow(1); + const map = new Map(); + headerRow.eachCell((cell, col) => { + const raw = cellStr(cell.value); + if (!raw) return; + const compact = normalizeHeader(raw); + const key = aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)]; + if (key) map.set(col, key); + }); + if (!map.size) { + throw new BadRequestException( + 'ستون‌های فایل شناسایی نشد. از قالب خروجی همین سیستم یا خروجی دیدار استفاده کنید.', + ); + } + + const rows: Record[] = []; + sheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; + const item: Record = {}; + let any = false; + for (const [col, key] of map) { + const value = cellStr(row.getCell(col).value); + if (value) any = true; + if (value || item[key] == null) item[key] = value; + } + if (any) rows.push(item); + }); + if (!rows.length) { + throw new BadRequestException('هیچ ردیف داده‌ای در فایل نیست'); + } + return rows; +} + +export async function writeXlsx( + sheetName: string, + columns: Array<{ header: string; key: string; width?: number }>, + rows: Array>, +): Promise { + const wb = new ExcelJS.Workbook(); + const sheet = wb.addWorksheet(sheetName); + sheet.columns = columns; + for (const row of rows) sheet.addRow(row); + const buf = await wb.xlsx.writeBuffer(); + return Buffer.from(buf); +} diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts index ced8026..00f83fd 100644 --- a/apps/api/src/contacts/contacts-excel.service.ts +++ b/apps/api/src/contacts/contacts-excel.service.ts @@ -1,27 +1,17 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; -import ExcelJS from 'exceljs'; import type { ImportContactsResult, PersonMood } from '@mento/shared'; import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; import { ownerWhere } from '../common/record-scope'; +import { + formatJalaliDate, + readMappedRows, + writeXlsx, +} from '../common/excel'; import { CompaniesService } from './companies.service'; import { PeopleService } from './people.service'; -function cellStr(value: ExcelJS.CellValue | undefined): string { - if (value == null) return ''; - if (typeof value === 'string' || typeof value === 'number') { - return String(value).trim(); - } - if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') { - return value.text.trim(); - } - if (typeof value === 'object' && 'result' in value && value.result != null) { - return String(value.result).trim(); - } - return String(value).trim(); -} - function parseMood(raw?: string): PersonMood | undefined { const value = raw?.trim(); if (!value) return undefined; @@ -29,45 +19,103 @@ function parseMood(raw?: string): PersonMood | undefined { return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value); } -function normalizeHeader(raw: string) { - return raw.trim().toLowerCase().replace(/\s+/g, ''); +function splitDidarName(firstName: string, lastName: string, title?: string) { + const first = firstName.trim(); + const last = lastName.trim(); + if (first && last) return { firstName: first.slice(0, 80), lastName: last.slice(0, 80) }; + const full = (first || last || title || '').trim(); + const parts = full.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return { + firstName: parts[0]!.slice(0, 80), + lastName: parts.slice(1).join(' ').slice(0, 80), + }; + } + if (full) return { firstName: full.slice(0, 80), lastName: full.slice(0, 80) }; + return null; } const PERSON_ALIASES: Record = { firstname: 'firstName', نام: 'firstName', + ناممشتری: 'firstName', lastname: 'lastName', نامخانوادگی: 'lastName', - 'نام خانوادگی': 'lastName', + نامخانوادگیمشتری: 'lastName', + عنوانمشتری: 'title', email: 'email', ایمیل: 'email', + ایمیلمشتری: 'email', phone: 'phone', تلفن: 'phone', + تلفنثابت: 'phone', + تلفنثابتمشتری: 'phone', mobile: 'mobile', موبایل: 'mobile', + تلفنهمراهمشتری: 'mobile', position: 'position', سمت: 'position', + سمت1: 'position', company: 'companyName', companyname: 'companyName', + نامشرکت: 'companyName', شرکت: 'companyName', notes: 'notes', یادداشت: 'notes', + توضیحاتشخص: 'notes', mood: 'mood', وضعیت: 'mood', وضعیتمشتری: 'mood', - 'وضعیت مشتری': 'mood', + گروهبندیشخص: 'mood', + گروهمشتری: 'group', + آدرس: 'address', + آدرس1: 'address', + آدرسمنزل: 'homeAddress', + آدرمنزل: 'homeAddress', + کدپستی: 'postalCode', + کدپستی1: 'postalCode', + استانفیلدپیشفرض: 'province', + استان: 'province', + شهرستان: 'city', + شهر1: 'city', + کدمشتری: 'customerCode', + کددیدارمشتری: 'didarId', + کدملیمشتری: 'nationalId', + تاریخثبتمشتری: 'registeredAt', + تاریختولدمشتری: 'birthDate', + تاریختولد1: 'birthDate', + تاریختولد: 'birthDate', + منبعاطلاعات: 'source', + نوعمشتری: 'customerType', + type: '_type', + _type: '_type', + آیدیاینستاگرام: 'instagram', + معرف: 'referrer', + سایتهایمشتری: 'website', + شمارهفکس: 'fax', + تلفن2: 'phone2', + تلفنهایدیگرمشتری: 'otherPhones', + مسئولمشتری: 'ownerName', + تلفنثابتشرکت: 'companyPhone', + کددیدارشرکت: 'companyDidarId', + مجوزمشاهدهمشتری: 'visibility', + ایمیلهایدیگرمشتری: 'otherEmails', + کد1: 'customerCode', + گروه1: 'group', + تماسهمکار: 'colleagueContact', + شرکتقبلی: 'previousCompany', + ستاره: 'star', }; const COMPANY_ALIASES: Record = { name: 'name', نام: 'name', نامشرکت: 'name', - 'نام شرکت': 'name', nationalid: 'nationalId', شناسهملی: 'nationalId', - 'شناسه ملی': 'nationalId', phone: 'phone', تلفن: 'phone', + تلفنثابتشرکت: 'phone', email: 'email', ایمیل: 'email', website: 'website', @@ -78,6 +126,83 @@ const COMPANY_ALIASES: Record = { یادداشت: 'notes', }; +const CONTACT_COLUMNS = [ + { header: '_type', key: '_type', width: 12 }, + { header: 'کد دیدار مشتری', key: 'didarId', width: 16 }, + { header: 'کد مشتری', key: 'customerCode', width: 14 }, + { header: 'عنوان مشتری', key: 'title', width: 16 }, + { header: 'نام مشتری', key: 'firstName', width: 16 }, + { header: 'نام خانوادگی مشتری', key: 'lastName', width: 22 }, + { header: 'ایمیل مشتری', key: 'email', width: 24 }, + { header: 'شماره فکس', key: 'fax', width: 14 }, + { header: 'سمت', key: 'position', width: 16 }, + { header: 'کد پستی', key: 'postalCode', width: 12 }, + { header: 'توضیحات شخص', key: 'notes', width: 32 }, + { header: 'تلفن همراه مشتری', key: 'mobile', width: 16 }, + { header: 'استان (فیلد پیش فرض)', key: 'province', width: 16 }, + { header: 'شهرستان', key: 'city', width: 14 }, + { header: 'تلفن ثابت مشتری', key: 'phone', width: 16 }, + { header: 'تلفن ثابت شرکت', key: 'companyPhone', width: 16 }, + { header: 'نام شرکت', key: 'companyName', width: 24 }, + { header: 'کد دیدار شرکت', key: 'companyDidarId', width: 14 }, + { header: 'مسئول مشتری', key: 'ownerName', width: 16 }, + { header: 'مجوز مشاهده مشتری', key: 'visibility', width: 18 }, + { header: 'کد ملی مشتری', key: 'nationalId', width: 16 }, + { header: 'تاریخ ثبت مشتری', key: 'registeredAt', width: 16 }, + { header: 'تاریخ تولد مشتری', key: 'birthDate', width: 16 }, + { header: 'گروه بندی شخص', key: 'mood', width: 16 }, + { header: 'تلفن های دیگر مشتری', key: 'otherPhones', width: 18 }, + { header: 'آدرس', key: 'address', width: 28 }, + { header: 'سایت های مشتری', key: 'website', width: 20 }, + { header: 'ایمیل های دیگر مشتری', key: 'otherEmails', width: 20 }, + { header: 'سمت_1', key: 'position2', width: 14 }, + { header: 'شهر_1', key: 'city2', width: 12 }, + { header: 'کد_1', key: 'code2', width: 12 }, + { header: 'گروه_1', key: 'group2', width: 12 }, + { header: 'آدرس_1', key: 'address2', width: 20 }, + { header: 'تلفن2', key: 'phone2', width: 14 }, + { header: 'کدپستی', key: 'postalCode2', width: 12 }, + { header: 'معرف', key: 'referrer', width: 14 }, + { header: 'آدرس منزل', key: 'homeAddress', width: 20 }, + { header: 'تاریخ تولد_1', key: 'birthDate2', width: 14 }, + { header: 'آی دی اینستاگرام', key: 'instagram', width: 16 }, + { header: 'گروه مشتری', key: 'group', width: 16 }, + { header: 'تماس همکار', key: 'colleagueContact', width: 16 }, + { header: 'شرکت قبلی', key: 'previousCompany', width: 16 }, + { header: 'نوع مشتری', key: 'customerType', width: 14 }, + { header: 'ستاره', key: 'star', width: 10 }, + { header: 'منبع اطلاعات', key: 'source', width: 16 }, +]; + +const CUSTOM_KEYS = [ + 'didarId', + 'customerCode', + 'title', + 'fax', + 'postalCode', + 'province', + 'city', + 'address', + 'nationalId', + 'birthDate', + 'group', + 'otherPhones', + 'website', + 'otherEmails', + 'instagram', + 'referrer', + 'source', + 'customerType', + 'homeAddress', + 'colleagueContact', + 'previousCompany', + 'star', + 'phone2', + 'companyDidarId', + 'companyPhone', + 'visibility', +] as const; + @Injectable() export class ContactsExcelService { constructor( @@ -88,51 +213,76 @@ export class ContactsExcelService { async exportPeople(actor: Actor, q?: string): Promise { const { items } = await this.people.list(actor, q, 1, 5000); - const wb = new ExcelJS.Workbook(); - const sheet = wb.addWorksheet('اشخاص'); - sheet.columns = [ - { header: 'نام', key: 'firstName', width: 16 }, - { header: 'نام خانوادگی', key: 'lastName', width: 18 }, - { header: 'موبایل', key: 'mobile', width: 16 }, - { header: 'تلفن', key: 'phone', width: 16 }, - { header: 'ایمیل', key: 'email', width: 28 }, - { header: 'سمت', key: 'position', width: 18 }, - { header: 'شرکت', key: 'companyName', width: 24 }, - { header: 'وضعیت مشتری', key: 'mood', width: 18 }, - { header: 'یادداشت', key: 'notes', width: 32 }, - ]; - for (const p of items) { - sheet.addRow({ - firstName: p.firstName, - lastName: p.lastName, - mobile: p.mobile ?? '', - phone: p.phone ?? '', - email: p.email ?? '', - position: p.position ?? '', - companyName: p.company?.name ?? '', - mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'], - notes: p.notes ?? '', - }); - } - const buf = await wb.xlsx.writeBuffer(); - return Buffer.from(buf); + return writeXlsx( + 'Contact', + CONTACT_COLUMNS, + items.map((p) => { + const extra = (p.customFields ?? {}) as Record; + return { + _type: 'Contact', + didarId: extra.didarId ?? p.id, + customerCode: extra.customerCode ?? '', + title: extra.title ?? '', + firstName: p.firstName, + lastName: p.lastName, + email: p.email ?? '', + fax: extra.fax ?? '', + position: p.position ?? extra.position ?? '', + postalCode: extra.postalCode ?? '', + notes: p.notes ?? '', + mobile: p.mobile ?? '', + province: extra.province ?? '', + city: extra.city ?? '', + phone: p.phone ?? '', + companyPhone: extra.companyPhone ?? '', + companyName: p.company?.name ?? '', + companyDidarId: extra.companyDidarId ?? '', + ownerName: p.owner?.fullName ?? '', + visibility: 'همه افراد شرکت', + nationalId: extra.nationalId ?? '', + registeredAt: formatJalaliDate(p.createdAt), + birthDate: extra.birthDate ?? '', + mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'], + otherPhones: extra.otherPhones ?? '', + address: extra.address ?? '', + website: extra.website ?? '', + otherEmails: extra.otherEmails ?? '', + position2: p.position ?? extra.position ?? '', + city2: extra.city ?? '', + code2: extra.customerCode ?? '', + group2: extra.group ?? '', + address2: extra.address ?? '', + phone2: extra.phone2 ?? p.phone ?? '', + postalCode2: extra.postalCode ?? '', + referrer: extra.referrer ?? '', + homeAddress: extra.homeAddress ?? '', + birthDate2: extra.birthDate ?? '', + instagram: extra.instagram ?? '', + group: extra.group ?? '', + colleagueContact: extra.colleagueContact ?? '', + previousCompany: extra.previousCompany ?? '', + customerType: extra.customerType ?? '', + star: extra.star ?? '', + source: extra.source ?? '', + }; + }), + ); } async exportCompanies(actor: Actor, q?: string): Promise { const { items } = await this.companies.list(actor, q, 1, 5000); - const wb = new ExcelJS.Workbook(); - const sheet = wb.addWorksheet('شرکت‌ها'); - sheet.columns = [ - { header: 'نام شرکت', key: 'name', width: 28 }, - { header: 'شناسه ملی', key: 'nationalId', width: 16 }, - { header: 'تلفن', key: 'phone', width: 16 }, - { header: 'ایمیل', key: 'email', width: 28 }, - { header: 'وب‌سایت', key: 'website', width: 24 }, - { header: 'آدرس', key: 'address', width: 32 }, - { header: 'یادداشت', key: 'notes', width: 32 }, - ]; - for (const c of items) { - sheet.addRow({ + return writeXlsx( + 'شرکت‌ها', + [ + { header: 'نام شرکت', key: 'name', width: 28 }, + { header: 'شناسه ملی', key: 'nationalId', width: 16 }, + { header: 'تلفن', key: 'phone', width: 16 }, + { header: 'ایمیل', key: 'email', width: 28 }, + { header: 'وب‌سایت', key: 'website', width: 24 }, + { header: 'آدرس', key: 'address', width: 32 }, + { header: 'یادداشت', key: 'notes', width: 32 }, + ], + items.map((c) => ({ name: c.name, nationalId: c.nationalId ?? '', phone: c.phone ?? '', @@ -140,36 +290,26 @@ export class ContactsExcelService { website: c.website ?? '', address: c.address ?? '', notes: c.notes ?? '', - }); - } - const buf = await wb.xlsx.writeBuffer(); - return Buffer.from(buf); + })), + ); } async importPeople( actor: Actor, file: Express.Multer.File, ): Promise { - if (!file?.buffer?.length) { - throw new BadRequestException('فایل اکسل انتخاب نشده است'); - } - const rows = await this.readRows(file, PERSON_ALIASES); - const result: ImportContactsResult = { - created: 0, - skipped: 0, - errors: [], - }; + const rows = await readMappedRows(file, PERSON_ALIASES); + const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; for (let i = 0; i < rows.length; i++) { const row = rows[i]!; const rowNum = i + 2; - const firstName = row.firstName?.trim() ?? ''; - const lastName = row.lastName?.trim() ?? ''; - if (!firstName || !lastName) { - result.errors.push({ - row: rowNum, - message: 'نام و نام خانوادگی الزامی است', - }); + if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') { + continue; + } + const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title); + if (!names) { + result.errors.push({ row: rowNum, message: 'نام مشتری الزامی است' }); continue; } @@ -179,17 +319,24 @@ export class ContactsExcelService { companyId = await this.findOrCreateCompanyId(actor, companyName); } + const customFields: Record = {}; + for (const key of CUSTOM_KEYS) { + const value = row[key]?.trim(); + if (value) customFields[key] = value; + } + try { await this.people.create(actor, { - firstName, - lastName, + firstName: names.firstName, + lastName: names.lastName, email: row.email || undefined, - phone: row.phone || undefined, + phone: row.phone || row.phone2 || undefined, mobile: row.mobile || undefined, position: row.position || undefined, notes: row.notes || undefined, - mood: parseMood(row.mood), + mood: parseMood(row.mood) ?? parseMood(row.group), companyId, + customFields, }); result.created += 1; } catch (e) { @@ -211,15 +358,8 @@ export class ContactsExcelService { actor: Actor, file: Express.Multer.File, ): Promise { - if (!file?.buffer?.length) { - throw new BadRequestException('فایل اکسل انتخاب نشده است'); - } - const rows = await this.readRows(file, COMPANY_ALIASES); - const result: ImportContactsResult = { - created: 0, - skipped: 0, - errors: [], - }; + const rows = await readMappedRows(file, COMPANY_ALIASES); + const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; for (let i = 0; i < rows.length; i++) { const row = rows[i]!; @@ -229,7 +369,6 @@ export class ContactsExcelService { result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' }); continue; } - try { await this.companies.create(actor, { name, @@ -256,10 +395,7 @@ export class ContactsExcelService { return result; } - private async findOrCreateCompanyId( - actor: Actor, - name: string, - ): Promise { + private async findOrCreateCompanyId(actor: Actor, name: string): Promise { const existing = await this.prisma.company.findFirst({ where: { organizationId: actor.organizationId, @@ -272,56 +408,4 @@ export class ContactsExcelService { const created = await this.companies.create(actor, { name }); return created.id; } - - private async readRows( - file: Express.Multer.File, - aliases: Record, - ): Promise[]> { - if (!file?.buffer?.length) { - throw new BadRequestException('فایل اکسل خالی است'); - } - - const wb = new ExcelJS.Workbook(); - // exceljs accepts Node Buffer - 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 map = new Map(); - headerRow.eachCell((cell, col) => { - const raw = cellStr(cell.value); - if (!raw) return; - const key = - aliases[normalizeHeader(raw)] ?? - aliases[raw.trim()] ?? - aliases[normalizeHeader(raw.replace(/‌/g, ''))]; - if (key) map.set(col, key); - }); - - if (!map.size) { - throw new BadRequestException( - 'ستون‌های فایل شناسایی نشد. از قالب خروجی همین سیستم استفاده کنید.', - ); - } - - const rows: Record[] = []; - sheet.eachRow((row, rowNumber) => { - if (rowNumber === 1) return; - const item: Record = {}; - let any = false; - for (const [col, key] of map) { - const value = cellStr(row.getCell(col).value); - if (value) any = true; - item[key] = value; - } - if (any) rows.push(item); - }); - - if (!rows.length) { - throw new BadRequestException('هیچ ردیف داده‌ای در فایل نیست'); - } - - return rows; - } } diff --git a/apps/api/src/org-roles/dto.ts b/apps/api/src/org-roles/dto.ts new file mode 100644 index 0000000..adc41e8 --- /dev/null +++ b/apps/api/src/org-roles/dto.ts @@ -0,0 +1,77 @@ +import { Type } from 'class-transformer'; +import { + ArrayUnique, + IsArray, + IsIn, + IsOptional, + IsString, + MaxLength, + MinLength, + ValidateNested, +} from 'class-validator'; +import { ROLE_PERMISSION_ITEMS, type RolePermissionKey } from '@mento/shared'; +import { ROLE_COLORS } from './role-defaults'; + +const PERMISSION_KEYS = ROLE_PERMISSION_ITEMS.map((item) => item.key); + +export class CreateOrgRoleDto { + @IsString() + @MinLength(2) + @MaxLength(80) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(280) + description?: string; + + @IsOptional() + @IsIn([...ROLE_COLORS]) + color?: (typeof ROLE_COLORS)[number]; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsIn(PERMISSION_KEYS, { each: true }) + permissions?: RolePermissionKey[]; +} + +export class UpdateOrgRoleDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(80) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(280) + description?: string; + + @IsOptional() + @IsIn([...ROLE_COLORS]) + color?: (typeof ROLE_COLORS)[number]; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsIn(PERMISSION_KEYS, { each: true }) + permissions?: RolePermissionKey[]; +} + +export class RolePermissionPatchItemDto { + @IsString() + id!: string; + + @IsArray() + @ArrayUnique() + @IsIn(PERMISSION_KEYS, { each: true }) + permissions!: RolePermissionKey[]; +} + +export class BulkUpdatePermissionsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RolePermissionPatchItemDto) + items!: RolePermissionPatchItemDto[]; +} diff --git a/apps/api/src/org-roles/org-roles.controller.ts b/apps/api/src/org-roles/org-roles.controller.ts new file mode 100644 index 0000000..9cf7125 --- /dev/null +++ b/apps/api/src/org-roles/org-roles.controller.ts @@ -0,0 +1,56 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import type { UserRole } from '@mento/shared'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { Roles } from '../auth/roles.decorator'; +import { RolesGuard } from '../auth/roles.guard'; +import { BulkUpdatePermissionsDto, CreateOrgRoleDto, UpdateOrgRoleDto } from './dto'; +import { OrgRolesService } from './org-roles.service'; + +@Controller('org-roles') +@UseGuards(JwtAuthGuard, RolesGuard) +export class OrgRolesController { + constructor(private readonly orgRoles: OrgRolesService) {} + + @Get() + list(@CurrentUser() user: { organizationId: string }) { + return this.orgRoles.list(user.organizationId); + } + + @Post() + @Roles('owner', 'admin') + create( + @CurrentUser() user: { organizationId: string; role: UserRole }, + @Body() dto: CreateOrgRoleDto, + ) { + return this.orgRoles.create(user.organizationId, user.role, dto); + } + + @Patch('permissions') + @Roles('owner', 'admin') + bulkPermissions( + @CurrentUser() user: { organizationId: string; role: UserRole }, + @Body() dto: BulkUpdatePermissionsDto, + ) { + return this.orgRoles.bulkUpdatePermissions(user.organizationId, user.role, dto.items); + } + + @Patch(':id') + @Roles('owner', 'admin') + update( + @CurrentUser() user: { organizationId: string; role: UserRole }, + @Param('id') id: string, + @Body() dto: UpdateOrgRoleDto, + ) { + return this.orgRoles.update(user.organizationId, user.role, id, dto); + } + + @Delete(':id') + @Roles('owner', 'admin') + remove( + @CurrentUser() user: { organizationId: string; role: UserRole }, + @Param('id') id: string, + ) { + return this.orgRoles.remove(user.organizationId, user.role, id); + } +} diff --git a/apps/api/src/org-roles/org-roles.module.ts b/apps/api/src/org-roles/org-roles.module.ts new file mode 100644 index 0000000..f8adda1 --- /dev/null +++ b/apps/api/src/org-roles/org-roles.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { OrgRolesController } from './org-roles.controller'; +import { OrgRolesService } from './org-roles.service'; + +@Module({ + imports: [AuthModule], + controllers: [OrgRolesController], + providers: [OrgRolesService], + exports: [OrgRolesService], +}) +export class OrgRolesModule {} diff --git a/apps/api/src/org-roles/org-roles.service.ts b/apps/api/src/org-roles/org-roles.service.ts new file mode 100644 index 0000000..ca5e713 --- /dev/null +++ b/apps/api/src/org-roles/org-roles.service.ts @@ -0,0 +1,264 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { OrgRoleDto, RolePermissionKey, UserRole } from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { + ALL_ROLE_PERMISSIONS, + defaultColorForSystemKey, + sanitizePermissions, + SYSTEM_ROLE_SEEDS, + systemKeyToUserRole, +} from './role-defaults'; + +type RoleRow = { + id: string; + name: string; + description: string; + color: string; + permissions: string[]; + systemKey: string | null; + users: Array<{ + id: string; + fullName: string; + avatarStorageKey: string | null; + }>; +}; + +@Injectable() +export class OrgRolesService { + constructor(private readonly prisma: PrismaService) {} + + async list(organizationId: string): Promise { + await this.ensureDefaultRoles(organizationId); + const rows = await this.prisma.orgRole.findMany({ + where: { organizationId }, + include: { + users: { + select: { id: true, fullName: true, avatarStorageKey: true }, + orderBy: { fullName: 'asc' }, + }, + }, + orderBy: [{ systemKey: 'asc' }, { createdAt: 'asc' }], + }); + return rows.map((row) => this.toDto(row)); + } + + async create( + organizationId: string, + actorRole: UserRole, + dto: { + name: string; + description?: string; + color?: string; + permissions?: RolePermissionKey[]; + }, + ): Promise { + this.assertManager(actorRole); + const name = dto.name.trim(); + if (name.length < 2) { + throw new BadRequestException('نام نقش باید حداقل دو حرف باشد'); + } + try { + const row = await this.prisma.orgRole.create({ + data: { + organizationId, + name, + description: (dto.description ?? '').trim(), + color: dto.color ?? 'teal', + permissions: dto.permissions?.length ? dto.permissions : [...ALL_ROLE_PERMISSIONS], + }, + include: { + users: { + select: { id: true, fullName: true, avatarStorageKey: true }, + orderBy: { fullName: 'asc' }, + }, + }, + }); + return this.toDto(row); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new ConflictException('نقشی با این نام از قبل وجود دارد'); + } + throw e; + } + } + + async update( + organizationId: string, + actorRole: UserRole, + id: string, + dto: { + name?: string; + description?: string; + color?: string; + permissions?: RolePermissionKey[]; + }, + ): Promise { + this.assertManager(actorRole); + const existing = await this.prisma.orgRole.findFirst({ + where: { id, organizationId }, + }); + if (!existing) throw new NotFoundException('نقش پیدا نشد'); + + const data: Prisma.OrgRoleUpdateInput = {}; + if (dto.name !== undefined) { + const name = dto.name.trim(); + if (name.length < 2) { + throw new BadRequestException('نام نقش باید حداقل دو حرف باشد'); + } + data.name = name; + } + if (dto.description !== undefined) data.description = dto.description.trim(); + if (dto.color !== undefined) data.color = dto.color; + if (dto.permissions !== undefined) data.permissions = sanitizePermissions(dto.permissions); + + try { + const row = await this.prisma.orgRole.update({ + where: { id: existing.id }, + data, + include: { + users: { + select: { id: true, fullName: true, avatarStorageKey: true }, + orderBy: { fullName: 'asc' }, + }, + }, + }); + return this.toDto(row); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new ConflictException('نقشی با این نام از قبل وجود دارد'); + } + throw e; + } + } + + async bulkUpdatePermissions( + organizationId: string, + actorRole: UserRole, + items: Array<{ id: string; permissions: RolePermissionKey[] }>, + ): Promise { + this.assertManager(actorRole); + await this.ensureDefaultRoles(organizationId); + await this.prisma.$transaction( + items.map((item) => + this.prisma.orgRole.updateMany({ + where: { id: item.id, organizationId }, + data: { permissions: sanitizePermissions(item.permissions) }, + }), + ), + ); + return this.list(organizationId); + } + + async remove(organizationId: string, actorRole: UserRole, id: string): Promise<{ ok: true }> { + this.assertManager(actorRole); + const existing = await this.prisma.orgRole.findFirst({ + where: { id, organizationId }, + }); + if (!existing) throw new NotFoundException('نقش پیدا نشد'); + if (existing.systemKey) { + throw new BadRequestException('نقش‌های سیستمی قابل حذف نیستند'); + } + await this.prisma.orgRole.delete({ where: { id: existing.id } }); + return { ok: true }; + } + + async ensureDefaultRoles(organizationId: string) { + const existing = await this.prisma.orgRole.findMany({ + where: { organizationId }, + }); + const byKey = new Map(existing.filter((r) => r.systemKey).map((r) => [r.systemKey!, r])); + for (const seed of SYSTEM_ROLE_SEEDS) { + if (byKey.has(seed.systemKey)) continue; + const created = await this.prisma.orgRole.create({ + data: { + organizationId, + name: seed.name, + description: seed.description, + color: seed.color, + permissions: seed.permissions, + systemKey: seed.systemKey, + }, + }); + byKey.set(seed.systemKey, created); + } + + const unassigned = await this.prisma.user.findMany({ + where: { organizationId, orgRoleId: null }, + select: { id: true, role: true }, + }); + for (const user of unassigned) { + const role = byKey.get(user.role); + if (!role) continue; + await this.prisma.user.update({ + where: { id: user.id }, + data: { orgRoleId: role.id }, + }); + } + } + + async resolveInviteRole( + organizationId: string, + dto: { orgRoleId?: string; role?: Exclude }, + ): Promise<{ orgRoleId: string; role: UserRole }> { + await this.ensureDefaultRoles(organizationId); + if (dto.orgRoleId) { + const orgRole = await this.prisma.orgRole.findFirst({ + where: { id: dto.orgRoleId, organizationId }, + }); + if (!orgRole) throw new BadRequestException('نقش انتخاب‌شده معتبر نیست'); + return { + orgRoleId: orgRole.id, + role: systemKeyToUserRole(orgRole.systemKey), + }; + } + const role = dto.role ?? 'sales'; + const orgRole = await this.prisma.orgRole.findFirst({ + where: { organizationId, systemKey: role }, + }); + if (!orgRole) { + throw new BadRequestException('نقش پیش‌فرض پیدا نشد'); + } + return { orgRoleId: orgRole.id, role }; + } + + defaultColorFor(role: UserRole) { + return defaultColorForSystemKey(role); + } + + private assertManager(actorRole: UserRole) { + if (actorRole !== 'owner' && actorRole !== 'admin') { + throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش‌ها را مدیریت کند'); + } + } + + private toDto(row: RoleRow): OrgRoleDto { + const systemKey = + row.systemKey === 'owner' || + row.systemKey === 'admin' || + row.systemKey === 'sales' || + row.systemKey === 'support' + ? row.systemKey + : null; + return { + id: row.id, + name: row.name, + description: row.description, + color: row.color, + permissions: sanitizePermissions(row.permissions), + systemKey, + userCount: row.users.length, + members: row.users.map((user) => ({ + id: user.id, + fullName: user.fullName, + hasAvatar: Boolean(user.avatarStorageKey), + })), + }; + } +} diff --git a/apps/api/src/org-roles/role-defaults.ts b/apps/api/src/org-roles/role-defaults.ts new file mode 100644 index 0000000..38da870 --- /dev/null +++ b/apps/api/src/org-roles/role-defaults.ts @@ -0,0 +1,69 @@ +import type { RolePermissionKey, UserRole } from '@mento/shared'; +import { ROLE_PERMISSION_ITEMS } from '@mento/shared'; + +export const ALL_ROLE_PERMISSIONS: RolePermissionKey[] = ROLE_PERMISSION_ITEMS.map( + (item) => item.key, +); + +export const ROLE_COLORS = ['teal', 'violet', 'rose', 'sky', 'amber', 'slate', 'pink'] as const; +export type RoleColor = (typeof ROLE_COLORS)[number]; + +export function defaultColorForSystemKey(key: UserRole): RoleColor { + if (key === 'owner') return 'violet'; + if (key === 'admin') return 'rose'; + if (key === 'support') return 'sky'; + return 'teal'; +} + +export const SYSTEM_ROLE_SEEDS: Array<{ + systemKey: UserRole; + name: string; + description: string; + color: RoleColor; + permissions: RolePermissionKey[]; +}> = [ + { + systemKey: 'owner', + name: 'مدیر سیستم', + description: 'دسترسی کامل به تمام بخش‌های سامانه.', + color: 'violet', + permissions: [...ALL_ROLE_PERMISSIONS], + }, + { + systemKey: 'admin', + name: 'مدیر', + description: 'مدیریت کاربران، تنظیمات و همه بخش‌های سازمان.', + color: 'rose', + permissions: [...ALL_ROLE_PERMISSIONS], + }, + { + systemKey: 'sales', + name: 'کارشناس فروش', + description: 'امکان دسترسی به بخش‌های فروش و مشتریان برای انجام فعالیت‌های روزمره.', + color: 'teal', + permissions: [...ALL_ROLE_PERMISSIONS], + }, + { + systemKey: 'support', + name: 'کارشناس پشتیبانی', + description: 'پاسخگویی به مشتریان و پیگیری تیکت‌ها و فعالیت‌ها.', + color: 'sky', + permissions: ['customers', 'activities', 'notes', 'reports'], + }, +]; + +export function isRolePermissionKey(value: string): value is RolePermissionKey { + return ALL_ROLE_PERMISSIONS.includes(value as RolePermissionKey); +} + +export function sanitizePermissions(input: unknown): RolePermissionKey[] { + if (!Array.isArray(input)) return []; + return [...new Set(input.filter((item): item is RolePermissionKey => isRolePermissionKey(String(item))))]; +} + +export function systemKeyToUserRole(key: string | null | undefined): UserRole { + if (key === 'owner' || key === 'admin' || key === 'sales' || key === 'support') { + return key; + } + return 'sales'; +} diff --git a/apps/api/src/project-boards/project-boards.controller.ts b/apps/api/src/project-boards/project-boards.controller.ts index 8b9a993..aa5ef25 100644 --- a/apps/api/src/project-boards/project-boards.controller.ts +++ b/apps/api/src/project-boards/project-boards.controller.ts @@ -7,12 +7,20 @@ import { Patch, Post, Query, + Res, + StreamableFile, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; +import type { Actor } from '../common/record-scope'; import { CreateProjectBoardDto, CreateProjectCardDto, @@ -20,22 +28,25 @@ import { UpdateProjectCardDto, } from './dto'; import { ProjectBoardsService } from './project-boards.service'; - -type AuthUser = { organizationId: string; userId: string; role: string }; +import { ProjectCardsExcelService } from './project-cards-excel.service'; +import { brand } from '@mento/shared'; @Controller() @UseGuards(JwtAuthGuard, RolesGuard) export class ProjectBoardsController { - constructor(private readonly boards: ProjectBoardsService) {} + constructor( + private readonly boards: ProjectBoardsService, + private readonly excelService: ProjectCardsExcelService, + ) {} @Get('project-boards') - listBoards(@CurrentUser() user: AuthUser) { + listBoards(@CurrentUser() user: Actor) { return this.boards.list(user.organizationId); } @Get('project-boards/board') board( - @CurrentUser() user: AuthUser, + @CurrentUser() user: Actor, @Query('boardId') boardId?: string, ) { return this.boards.getBoard(user.organizationId, boardId, user); @@ -44,20 +55,48 @@ export class ProjectBoardsController { @Post('project-boards') @Roles('owner', 'admin') createBoard( - @CurrentUser() user: AuthUser, + @CurrentUser() user: Actor, @Body() dto: CreateProjectBoardDto, ) { return this.boards.create(user.organizationId, dto.name); } + @Get('project-cards/export') + async exportCards( + @CurrentUser() user: Actor, + @Res({ passthrough: true }) res: Response, + ) { + const buffer = await this.excelService.export(user); + res.set({ + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${brand.filePrefix}-project-cards.xlsx"`, + }); + return new StreamableFile(buffer); + } + + @Post('project-cards/import') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 15_000_000 }, + }), + ) + importCards( + @CurrentUser() user: Actor, + @UploadedFile() file: Express.Multer.File, + ) { + return this.excelService.import(user, file); + } + @Get('project-cards/:id') - getCard(@CurrentUser() user: AuthUser, @Param('id') id: string) { + getCard(@CurrentUser() user: Actor, @Param('id') id: string) { return this.boards.getCard(user.organizationId, id, user); } @Post('project-cards') createCard( - @CurrentUser() user: AuthUser, + @CurrentUser() user: Actor, @Body() dto: CreateProjectCardDto, ) { return this.boards.createCard(user.organizationId, user, dto); @@ -65,7 +104,7 @@ export class ProjectBoardsController { @Patch('project-cards/:id') updateCard( - @CurrentUser() user: AuthUser, + @CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: UpdateProjectCardDto, ) { @@ -74,7 +113,7 @@ export class ProjectBoardsController { @Patch('project-cards/:id/move') moveCard( - @CurrentUser() user: AuthUser, + @CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: MoveProjectCardDto, ) { @@ -82,7 +121,7 @@ export class ProjectBoardsController { } @Delete('project-cards/:id') - async removeCard(@CurrentUser() user: AuthUser, @Param('id') id: string) { + async removeCard(@CurrentUser() user: Actor, @Param('id') id: string) { await this.boards.removeCard(user.organizationId, id, user); return { ok: true }; } diff --git a/apps/api/src/project-boards/project-boards.module.ts b/apps/api/src/project-boards/project-boards.module.ts index 43426e0..8a309f8 100644 --- a/apps/api/src/project-boards/project-boards.module.ts +++ b/apps/api/src/project-boards/project-boards.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { ProjectBoardsController } from './project-boards.controller'; import { ProjectBoardsService } from './project-boards.service'; +import { ProjectCardsExcelService } from './project-cards-excel.service'; @Module({ imports: [AuthModule], controllers: [ProjectBoardsController], - providers: [ProjectBoardsService], + providers: [ProjectBoardsService, ProjectCardsExcelService], exports: [ProjectBoardsService], }) export class ProjectBoardsModule {} diff --git a/apps/api/src/project-boards/project-cards-excel.service.ts b/apps/api/src/project-boards/project-cards-excel.service.ts new file mode 100644 index 0000000..4235508 --- /dev/null +++ b/apps/api/src/project-boards/project-cards-excel.service.ts @@ -0,0 +1,258 @@ +import { Injectable } from '@nestjs/common'; +import type { ImportContactsResult } from '@mento/shared'; +import { PrismaService } from '../prisma/prisma.service'; +import type { Actor } from '../common/record-scope'; +import { ownerWhere } from '../common/record-scope'; +import { normalizePhone } from '../contacts/normalize'; +import { + formatJalaliDate, + parseJalaliDateTime, + readMappedRows, + stripHtml, + writeXlsx, +} from '../common/excel'; +import { ProjectBoardsService } from './project-boards.service'; + +const CASE_COLUMNS = [ + { header: '_type', key: '_type', width: 12 }, + { header: 'کد دیدار کارت', key: 'didarId', width: 14 }, + { header: 'عنوان کارت', key: 'title', width: 28 }, + { header: 'توضیحات کارت', key: 'notes', width: 32 }, + { header: 'وضعیت کارت', key: 'status', width: 12 }, + { header: 'مسئول کارت', key: 'ownerName', width: 16 }, + { header: 'مجوز مشاهده کارت', key: 'visibility', width: 18 }, + { header: 'تاریخ ثبت کارت', key: 'createdAt', width: 16 }, + { header: 'نام خانوادگی شخص کارت', key: 'personName', width: 22 }, + { header: 'کد دیدار شخص', key: 'personCode', width: 14 }, + { header: 'موبایل شخص کارت', key: 'personMobile', width: 16 }, + { header: 'کاریز کارت', key: 'boardName', width: 20 }, + { header: 'مرحله کاریز کارت', key: 'stageName', width: 18 }, + { header: 'تاریخ انجام کارت', key: 'doneDate', width: 16 }, + { header: 'برچسب', key: 'tag', width: 16 }, +]; + +const CASE_ALIASES: Record = { + _type: '_type', + type: '_type', + کددیدارکارت: 'didarId', + عنوانکارت: 'title', + عنوان: 'title', + توضیحاتکارت: 'notes', + وضعیتکارت: 'status', + مسئولکارت: 'ownerName', + مجوزمشاهدهکارت: 'visibility', + تاریخثبتکارت: 'createdAt', + نامخانوادگیشخصکارت: 'personName', + کددیدارشخص: 'personCode', + موبایلشخصکارت: 'personMobile', + کاریزکارت: 'boardName', + مرحلهکاریزکارت: 'stageName', + تاریخانجامکارت: 'doneDate', + برچسب: 'tag', +}; + +function isClosedStatus(raw?: string) { + return /بسته|انجام|done|closed/i.test(raw ?? ''); +} + +@Injectable() +export class ProjectCardsExcelService { + constructor( + private readonly prisma: PrismaService, + private readonly boards: ProjectBoardsService, + ) {} + + async export(actor: Actor): Promise { + const rows = await this.prisma.projectCard.findMany({ + where: { organizationId: actor.organizationId, ...ownerWhere(actor) }, + include: { + person: { + select: { id: true, firstName: true, lastName: true, mobile: true }, + }, + owner: { select: { fullName: true } }, + board: { select: { name: true } }, + stage: { select: { name: true } }, + }, + orderBy: [{ createdAt: 'desc' }], + take: 10000, + }); + + return writeXlsx( + 'Case', + CASE_COLUMNS, + rows.map((row) => { + const closed = /انجام|بسته|done/i.test(row.stage.name); + return { + _type: 'Case', + didarId: row.id, + title: row.title, + notes: row.notes ?? '', + status: closed ? 'بسته' : 'باز', + ownerName: row.owner?.fullName ?? '', + visibility: 'همه افراد شرکت', + createdAt: formatJalaliDate(row.createdAt), + personName: row.person + ? `${row.person.firstName} ${row.person.lastName}`.trim() + : '', + personCode: row.person?.id ?? '', + personMobile: row.person?.mobile ?? '', + boardName: row.board.name, + stageName: row.stage.name, + doneDate: closed ? formatJalaliDate(row.updatedAt) : '', + tag: '', + }; + }), + ); + } + + async import(actor: Actor, file: Express.Multer.File): Promise { + const rows = await readMappedRows(file, CASE_ALIASES); + const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; + const rowNum = i + 2; + if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'case') { + continue; + } + const title = (row.title || '').trim(); + if (title.length < 2) { + result.errors.push({ row: rowNum, message: 'عنوان کارت الزامی است' }); + continue; + } + + try { + const { boardId, stageId } = await this.resolveBoardAndStage( + actor.organizationId, + row.boardName, + row.stageName, + isClosedStatus(row.status), + ); + const duplicate = await this.prisma.projectCard.findFirst({ + where: { organizationId: actor.organizationId, boardId, title }, + select: { id: true }, + }); + if (duplicate) { + result.skipped += 1; + continue; + } + + const personId = await this.findPersonId(actor.organizationId, row); + const ownerId = await this.findUserId(actor.organizationId, row.ownerName); + const notes = [stripHtml(row.notes ?? ''), row.tag, row.didarId ? `کد دیدار: ${row.didarId}` : ''] + .map((part) => part.trim()) + .filter(Boolean) + .join('\n'); + const doneAt = parseJalaliDateTime(row.doneDate); + const notesWithDone = doneAt + ? `${notes}${notes ? '\n' : ''}تاریخ انجام: ${row.doneDate}`.trim() + : notes; + + await this.boards.createCard(actor.organizationId, actor, { + title, + boardId, + stageId, + notes: notesWithDone || undefined, + personId, + ownerId, + }); + result.created += 1; + } catch (e) { + result.errors.push({ + row: rowNum, + message: e instanceof Error ? e.message : 'خطای ناشناخته', + }); + } + } + + return result; + } + + private async resolveBoardAndStage( + organizationId: string, + boardName?: string, + stageName?: string, + closed = false, + ) { + const name = boardName?.trim() || 'پروژه داخلی'; + const wanted = stageName?.trim(); + let board = await this.prisma.projectBoard.findFirst({ + where: { organizationId, name: { equals: name, mode: 'insensitive' } }, + include: { stages: { orderBy: { position: 'asc' } } }, + }); + if (!board) { + const stageCreates = wanted + ? [{ name: wanted, position: 0 }] + : [ + { name: 'صف', position: 0 }, + { name: 'در حال انجام', position: 1 }, + { name: 'بازبینی', position: 2 }, + { name: 'انجام‌شده', position: 3 }, + ]; + board = await this.prisma.projectBoard.create({ + data: { + organizationId, + name, + isDefault: false, + stages: { create: stageCreates }, + }, + include: { stages: { orderBy: { position: 'asc' } } }, + }); + } + + if (wanted) { + const existing = board.stages.find( + (s) => s.name.trim().toLowerCase() === wanted.toLowerCase(), + ); + if (existing) return { boardId: board.id, stageId: existing.id }; + const maxPos = board.stages.reduce((m, s) => Math.max(m, s.position), -1); + const stage = await this.prisma.projectBoardStage.create({ + data: { boardId: board.id, name: wanted, position: maxPos + 1 }, + }); + return { boardId: board.id, stageId: stage.id }; + } + + const stage = closed + ? board.stages[board.stages.length - 1] + : board.stages[0]; + if (!stage) throw new Error('برد مرحله‌ای ندارد'); + return { boardId: board.id, stageId: stage.id }; + } + + private async findPersonId(organizationId: string, row: Record) { + const mobile = normalizePhone(row.personMobile); + if (mobile) { + const needle = mobile.replace(/^\+98|^98|^0/, ''); + const byMobile = await this.prisma.person.findFirst({ + where: { organizationId, mobile: { contains: needle } }, + select: { id: true }, + }); + if (byMobile) return byMobile.id; + } + const name = (row.personName ?? '').trim(); + if (name) { + const byName = await this.prisma.person.findFirst({ + where: { + organizationId, + OR: [ + { lastName: { contains: name, mode: 'insensitive' } }, + { firstName: { contains: name.split(/\s+/)[0]!, mode: 'insensitive' } }, + ], + }, + select: { id: true }, + }); + if (byName) return byName.id; + } + return null; + } + + private async findUserId(organizationId: string, name?: string) { + const n = name?.trim(); + if (!n) return null; + const row = await this.prisma.user.findFirst({ + where: { organizationId, fullName: { contains: n, mode: 'insensitive' } }, + select: { id: true }, + }); + return row?.id ?? null; + } +} diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index 5aca532..584f11d 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -1,7 +1,9 @@ import { Type } from 'class-transformer'; import { + IsBoolean, IsIn, IsNumber, + IsOptional, IsString, Matches, Max, @@ -30,13 +32,29 @@ export class InviteUserDto { @MaxLength(72) password!: string; + @IsOptional() @IsIn(INVITE_ROLES) - role!: (typeof INVITE_ROLES)[number]; + role?: (typeof INVITE_ROLES)[number]; + + @IsOptional() + @IsString() + orgRoleId?: string; } export class UpdateUserRoleDto { + @IsOptional() @IsIn(ALL_ROLES) - role!: (typeof ALL_ROLES)[number]; + role?: (typeof ALL_ROLES)[number]; + + @IsOptional() + @IsString() + orgRoleId?: string; +} + +export class UpdateUserStatusDto { + @Type(() => Boolean) + @IsBoolean() + isActive!: boolean; } export class UpdateUserCommissionDto { diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 14a248f..c8370f2 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -5,6 +5,7 @@ import { Param, Patch, Post, + StreamableFile, UseGuards, } from '@nestjs/common'; import type { UserRole } from '@mento/shared'; @@ -12,7 +13,12 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; -import { InviteUserDto, UpdateUserCommissionDto, UpdateUserRoleDto } from './dto'; +import { + InviteUserDto, + UpdateUserCommissionDto, + UpdateUserRoleDto, + UpdateUserStatusDto, +} from './dto'; import { UsersService } from './users.service'; @Controller('users') @@ -25,6 +31,15 @@ export class UsersController { return this.usersService.list(user.organizationId); } + @Get(':id/avatar') + async avatar( + @CurrentUser() user: { organizationId: string }, + @Param('id') id: string, + ) { + const file = await this.usersService.openAvatar(user.organizationId, id); + return new StreamableFile(file.stream, { type: file.mimeType }); + } + @Post() @Roles('owner', 'admin') invite( @@ -49,6 +64,21 @@ export class UsersController { ); } + @Patch(':id/status') + @Roles('owner', 'admin') + updateStatus( + @CurrentUser() user: { organizationId: string; userId: string; role: UserRole }, + @Param('id') id: string, + @Body() dto: UpdateUserStatusDto, + ) { + return this.usersService.updateStatus( + user.organizationId, + { userId: user.userId, role: user.role }, + id, + dto.isActive, + ); + } + @Patch(':id/role') @Roles('owner', 'admin') updateRole( @@ -60,7 +90,7 @@ export class UsersController { user.organizationId, { userId: user.userId, role: user.role }, id, - dto.role, + dto, ); } } diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 3ad353d..25aaf5a 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { OrgRolesModule } from '../org-roles/org-roles.module'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; @Module({ - imports: [AuthModule], + imports: [AuthModule, OrgRolesModule], controllers: [UsersController], providers: [UsersService], exports: [UsersService], diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 36596b5..e9de29b 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -10,19 +10,51 @@ import * as bcrypt from 'bcryptjs'; import { Prisma } from '@prisma/client'; import { normalizeMobile } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; +import { OrgRolesService } from '../org-roles/org-roles.service'; +import { LocalFileStorage } from '../storage/local-file.storage'; +import * as path from 'path'; function inviteEmailFromMobile(mobile: string): string { return `${mobile}@users.mento.local`; } +const AVATAR_EXT: Record = { + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', +}; + +type UserRow = { + id: string; + email: string; + mobile: string | null; + fullName: string; + role: UserRole; + commissionPercent: { toString(): string } | number | null; + createdAt: Date; + isActive: boolean; + lastLoginAt: Date | null; + orgRoleId: string | null; + avatarStorageKey: string | null; + orgRole: { id: string; name: string; color: string } | null; +}; + @Injectable() export class UsersService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly orgRoles: OrgRolesService, + private readonly files: LocalFileStorage, + ) {} async list(organizationId: string): Promise { + await this.orgRoles.ensureDefaultRoles(organizationId); const rows = await this.prisma.user.findMany({ where: { organizationId }, - orderBy: [{ role: 'asc' }, { fullName: 'asc' }], + include: { orgRole: { select: { id: true, name: true, color: true } } }, + orderBy: [{ lastLoginAt: 'desc' }, { fullName: 'asc' }], }); return rows.map((u) => this.toDto(u)); } @@ -54,6 +86,14 @@ export class UsersService { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); } + const resolved = await this.orgRoles.resolveInviteRole(organizationId, { + orgRoleId: dto.orgRoleId, + role: dto.role, + }); + if (resolved.role === 'owner' && actorRole !== 'owner') { + throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند'); + } + const passwordHash = await bcrypt.hash(dto.password, 10); try { const row = await this.prisma.user.create({ @@ -63,8 +103,11 @@ export class UsersService { mobile, fullName: dto.fullName.trim(), passwordHash, - role: dto.role, + role: resolved.role, + orgRoleId: resolved.orgRoleId, + isActive: true, }, + include: { orgRole: { select: { id: true, name: true, color: true } } }, }); return this.toDto(row); } catch (e) { @@ -94,6 +137,7 @@ export class UsersService { const row = await this.prisma.user.update({ where: { id: target.id }, data: { commissionPercent: new Prisma.Decimal(percent) }, + include: { orgRole: { select: { id: true, name: true, color: true } } }, }); return this.toDto(row); } @@ -102,7 +146,7 @@ export class UsersService { organizationId: string, actor: { userId: string; role: UserRole }, targetId: string, - nextRole: UserRole, + dto: { role?: UserRole; orgRoleId?: string }, ): Promise { if (actor.role !== 'owner' && actor.role !== 'admin') { throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش را تغییر دهد'); @@ -113,6 +157,28 @@ export class UsersService { }); if (!target) throw new NotFoundException('کاربر پیدا نشد'); + let nextRole = dto.role ?? target.role; + let nextOrgRoleId = dto.orgRoleId ?? target.orgRoleId; + + if (dto.orgRoleId) { + const orgRole = await this.prisma.orgRole.findFirst({ + where: { id: dto.orgRoleId, organizationId }, + }); + if (!orgRole) throw new BadRequestException('نقش انتخاب‌شده معتبر نیست'); + nextOrgRoleId = orgRole.id; + nextRole = orgRole.systemKey + ? (['owner', 'admin', 'sales', 'support'].includes(orgRole.systemKey) + ? (orgRole.systemKey as UserRole) + : 'sales') + : 'sales'; + } else if (dto.role) { + await this.orgRoles.ensureDefaultRoles(organizationId); + const orgRole = await this.prisma.orgRole.findFirst({ + where: { organizationId, systemKey: dto.role }, + }); + nextOrgRoleId = orgRole?.id ?? nextOrgRoleId; + } + if (actor.role === 'admin') { if (target.role === 'owner' || nextRole === 'owner') { throw new ForbiddenException('مدیر نمی‌تواند نقش مالک را تغییر دهد'); @@ -132,26 +198,76 @@ export class UsersService { const row = await this.prisma.user.update({ where: { id: target.id }, - data: { role: nextRole }, + data: { role: nextRole, orgRoleId: nextOrgRoleId }, + include: { orgRole: { select: { id: true, name: true, color: true } } }, }); return this.toDto(row); } - private toDto(row: { - id: string; - email: string; - mobile?: string | null; - fullName: string; - role: UserRole; - commissionPercent?: { toString(): string } | number; - createdAt: Date; - }): OrgUserDto { + async updateStatus( + organizationId: string, + actor: { userId: string; role: UserRole }, + targetId: string, + isActive: boolean, + ): Promise { + if (actor.role !== 'owner' && actor.role !== 'admin') { + throw new ForbiddenException('فقط مالک یا مدیر می‌تواند وضعیت کاربر را تغییر دهد'); + } + if (actor.userId === targetId && !isActive) { + throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید'); + } + const target = await this.prisma.user.findFirst({ + where: { id: targetId, organizationId }, + }); + if (!target) throw new NotFoundException('کاربر پیدا نشد'); + if (target.role === 'owner' && !isActive && actor.role !== 'owner') { + throw new ForbiddenException('مدیر نمی‌تواند مالک را غیرفعال کند'); + } + if (target.role === 'owner' && !isActive) { + const activeOwners = await this.prisma.user.count({ + where: { organizationId, role: 'owner', isActive: true, id: { not: targetId } }, + }); + if (activeOwners < 1) { + throw new BadRequestException('حداقل یک مالک فعال لازم است'); + } + } + const row = await this.prisma.user.update({ + where: { id: target.id }, + data: { isActive }, + include: { orgRole: { select: { id: true, name: true, color: true } } }, + }); + return this.toDto(row); + } + + async openAvatar(organizationId: string, userId: string) { + const user = await this.prisma.user.findFirst({ + where: { id: userId, organizationId }, + select: { id: true, avatarStorageKey: true }, + }); + if (!user?.avatarStorageKey) { + throw new NotFoundException('عکس پروفایل تنظیم نشده'); + } + const ext = path.extname(user.avatarStorageKey).slice(1).toLowerCase(); + const mime = AVATAR_EXT[ext] ?? 'image/jpeg'; + return { + stream: this.files.openReadStream(user.avatarStorageKey), + mimeType: mime, + }; + } + + private toDto(row: UserRow): OrgUserDto { return { id: row.id, email: row.email, mobile: row.mobile ?? null, fullName: row.fullName, role: row.role, + orgRoleId: row.orgRoleId, + orgRoleName: row.orgRole?.name, + orgRoleColor: row.orgRole?.color ?? this.orgRoles.defaultColorFor(row.role), + isActive: row.isActive, + lastLoginAt: row.lastLoginAt ? row.lastLoginAt.toISOString() : null, + hasAvatar: Boolean(row.avatarStorageKey), commissionPercent: Number(row.commissionPercent ?? 0), createdAt: row.createdAt.toISOString(), }; diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index 348c2e8..e8dfc45 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -18,8 +18,10 @@ import type { ActivityDto, ActivityFilter, ActivityType, + ImportContactsResult, PaginatedResponse, } from '@mento/shared'; +import { brand } from '@mento/shared'; type TypeFilter = 'all' | ActivityType; type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled'; @@ -70,6 +72,9 @@ const activityModal = ref<{ title?: string; } | null>(null); const menuId = ref(null); +const busy = ref(false); +const importMsg = ref(''); +const fileInput = ref(null); const counts = ref>({ today: 0, overdue: 0, @@ -260,6 +265,65 @@ function closePopovers() { menuId.value = null; } +async function exportExcel() { + busy.value = true; + importMsg.value = ''; + error.value = ''; + try { + const { data } = await api.get('/activities/export', { responseType: 'blob' }); + const url = URL.createObjectURL(data); + const a = document.createElement('a'); + a.href = url; + a.download = `${brand.filePrefix}-activities.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } catch { + error.value = 'خروجی اکسل ناموفق بود.'; + } finally { + busy.value = false; + } +} + +function pickImportFile() { + fileInput.value?.click(); +} + +async function onImportFile(ev: Event) { + const input = ev.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file) return; + busy.value = true; + importMsg.value = ''; + error.value = ''; + try { + const form = new FormData(); + form.append('file', file); + const { data } = await api.post('/activities/import', form, { + headers: { 'Content-Type': undefined }, + }); + importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${ + data.errors.length ? ` · خطا: ${data.errors.length}` : '' + }`; + if (data.errors.length) { + error.value = data.errors + .slice(0, 3) + .map((e) => `ردیف ${e.row}: ${e.message}`) + .join(' | '); + } + await Promise.all([load(), loadCounts()]); + } catch (e) { + if (axios.isAxiosError(e)) { + const msg = (e.response?.data as { message?: string | string[] })?.message; + error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ایمپورت ناموفق بود'); + } else { + error.value = 'ایمپورت ناموفق بود'; + } + } finally { + busy.value = false; + } +} + watch( () => [route.query.filter, route.query.q, route.query.page], () => { @@ -289,6 +353,21 @@ onMounted(() => {

تماس، جلسه و وظیفه — امروز و عقب‌افتاده‌ها یکجا.

+ + + فعالیت جدید @@ -345,7 +424,8 @@ onMounted(() => {

{{ error }}

-

در حال بارگذاری…

+

{{ importMsg }}

+

در حال بارگذاری…