Creating import&export excel

This commit is contained in:
2026-09-15 17:33:05 +03:30
parent 9ae176bec0
commit 44f9849d0e
26 changed files with 3682 additions and 474 deletions
@@ -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;
+24
View File
@@ -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 {
@@ -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<string, string> = {
_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<ActivityType, string> = {
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<Buffer> {
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<ImportContactsResult> {
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<string, string>) {
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;
}
}
@@ -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 };
}
+2 -1
View File
@@ -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 {}
+2
View File
@@ -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,
+13
View File
@@ -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),
+16 -5
View File
@@ -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,
};
}
}
+230
View File
@@ -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(/<br\s*\/?>/gi, '\n')
.replace(/&nbsp;/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<string, string>,
): Promise<Record<string, string>[]> {
const sheet = await workbookFromUpload(file);
const headerRow = sheet.getRow(1);
const map = new Map<number, string>();
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<string, string>[] = [];
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const item: Record<string, string> = {};
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<Record<string, string | number | null | undefined>>,
): Promise<Buffer> {
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);
}
+236 -152
View File
@@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
یادداشت: '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<Buffer> {
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<string, string>;
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<Buffer> {
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<ImportContactsResult> {
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<string, string> = {};
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<ImportContactsResult> {
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<string> {
private async findOrCreateCompanyId(actor: Actor, name: string): Promise<string> {
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<string, string>,
): Promise<Record<string, string>[]> {
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<number, string>();
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<string, string>[] = [];
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const item: Record<string, string> = {};
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;
}
}
+77
View File
@@ -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[];
}
@@ -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);
}
}
@@ -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 {}
+264
View File
@@ -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<OrgRoleDto[]> {
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<OrgRoleDto> {
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<OrgRoleDto> {
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<OrgRoleDto[]> {
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<UserRole, 'owner'> },
): 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),
})),
};
}
}
+69
View File
@@ -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';
}
@@ -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 };
}
@@ -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 {}
@@ -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<string, string> = {
_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<Buffer> {
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<ImportContactsResult> {
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<string, string>) {
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;
}
}
+20 -2
View File
@@ -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 {
+32 -2
View File
@@ -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,
);
}
}
+2 -1
View File
@@ -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],
+130 -14
View File
@@ -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<string, string> = {
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<OrgUserDto[]> {
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<OrgUserDto> {
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<OrgUserDto> {
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(),
};
+101 -1
View File
@@ -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<string | null>(null);
const busy = ref(false);
const importMsg = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const counts = ref<Record<ActivityFilter, number>>({
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<Blob>('/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<ImportContactsResult>('/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(() => {
<p class="lead">تماس، جلسه و وظیفه — امروز و عقب‌افتاده‌ها یکجا.</p>
</div>
<div class="head-actions">
<button class="btn ghost excel-btn" type="button" :disabled="busy" @click.stop="exportExcel">
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</button>
<button class="btn ghost excel-btn" type="button" :disabled="busy" @click.stop="pickImportFile">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</button>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<UiButton type="button" @click="activityModal = {}">
<UiIcon name="plus" :size="16" />
فعالیت جدید
@@ -345,7 +424,8 @@ onMounted(() => {
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="listEmpty" class="empty-card">
<div class="art" aria-hidden="true">
@@ -632,11 +712,24 @@ h1 {
color: #b91c1c;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.head-actions .excel-btn .btn-full {
display: inline;
}
.head-actions .excel-btn .btn-short {
display: none;
}
.head-actions,
.tools {
display: flex;
gap: 0.55rem;
align-items: center;
flex-wrap: wrap;
}
.btn {
@@ -1172,6 +1265,13 @@ time.note {
flex: 1;
}
.head-actions .excel-btn .btn-full {
display: none;
}
.head-actions .excel-btn .btn-short {
display: inline;
}
h1 {
font-size: 1.55rem;
}
+85 -1
View File
@@ -6,10 +6,12 @@ import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue';
import type {
ImportContactsResult,
ProjectBoardBoardDto,
ProjectBoardDto,
ProjectCardDto,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { canManageSettings } from '@/lib/roles';
const route = useRoute();
@@ -25,6 +27,9 @@ const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
const showCreate = ref(false);
const newName = ref('');
const busy = ref(false);
const importMsg = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const selectedBoardId = computed(() => {
const q = route.query.boardId;
@@ -132,6 +137,65 @@ function openCard(id: string) {
void router.push({ name: 'project-card-detail', params: { id } });
}
async function exportExcel() {
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const { data } = await api.get<Blob>('/project-cards/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = `${brand.filePrefix}-project-cards.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<ImportContactsResult>('/project-cards/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 load();
} 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.boardId,
(id, prev) => {
@@ -171,6 +235,19 @@ onMounted(load);
</label>
<div class="toolbar-actions">
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
خروجی اکسل
</button>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
ورود از اکسل
</button>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<button class="btn-ghost-sm" type="button" :disabled="loading" @click="load">
بروزرسانی
</button>
@@ -215,7 +292,8 @@ onMounted(load);
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !board" class="muted">در حال بارگذاری…</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="loading && !board" class="muted">در حال بارگذاری…</p>
<div v-if="board" class="board">
<div
@@ -411,6 +489,12 @@ h1 {
color: #b91c1c;
}
.ok {
margin: 0;
color: #0f766e;
font-weight: 600;
}
.board {
display: flex;
gap: 0.75rem;
File diff suppressed because it is too large Load Diff
+69 -4
View File
@@ -44,23 +44,88 @@ export interface OrgUserDto {
mobile?: string | null;
fullName: string;
role: UserRole;
orgRoleId?: string | null;
orgRoleName?: string;
orgRoleColor?: string;
isActive?: boolean;
lastLoginAt?: string | null;
hasAvatar?: boolean;
commissionPercent?: number;
createdAt: string;
}
export interface UpdateUserCommissionRequest {
commissionPercent: number;
export type RolePermissionKey =
| "customers"
| "sales"
| "billing"
| "products"
| "activities"
| "notes"
| "reports";
export const ROLE_PERMISSION_ITEMS: Array<{
key: RolePermissionKey;
label: string;
}> = [
{ key: "customers", label: "مشتریان" },
{ key: "sales", label: "فروش و معاملات" },
{ key: "billing", label: "پیش‌فاکتورها و فاکتورها" },
{ key: "products", label: "محصولات و خدمات" },
{ key: "activities", label: "فعالیت‌ها" },
{ key: "notes", label: "یادداشت‌ها" },
{ key: "reports", label: "گزارش‌ها" },
];
export interface OrgRoleMemberDto {
id: string;
fullName: string;
hasAvatar: boolean;
}
export interface OrgRoleDto {
id: string;
name: string;
description: string;
color: string;
permissions: RolePermissionKey[];
systemKey: UserRole | null;
userCount: number;
members: OrgRoleMemberDto[];
}
export interface CreateOrgRoleRequest {
name: string;
description?: string;
color?: string;
permissions?: RolePermissionKey[];
}
export interface UpdateOrgRoleRequest {
name?: string;
description?: string;
color?: string;
permissions?: RolePermissionKey[];
}
export interface InviteUserRequest {
mobile: string;
fullName: string;
password: string;
role: Exclude<UserRole, "owner">;
role?: Exclude<UserRole, "owner">;
orgRoleId?: string;
}
export interface UpdateUserRoleRequest {
role: UserRole;
role?: UserRole;
orgRoleId?: string;
}
export interface UpdateUserStatusRequest {
isActive: boolean;
}
export interface UpdateUserCommissionRequest {
commissionPercent: number;
}
export interface UpdateProfileRequest {