Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 21:06:30 +03:30
co-authored by Cursor
commit 29a040c21d
195 changed files with 39430 additions and 0 deletions
@@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ActivitiesService } from './activities.service';
import {
CreateActivityDto,
ListActivitiesQueryDto,
UpdateActivityDto,
} from './dto';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('activities')
@UseGuards(JwtAuthGuard)
export class ActivitiesController {
constructor(private readonly activitiesService: ActivitiesService) {}
@Get('summary')
summary(@CurrentUser() user: AuthUser) {
return this.activitiesService.summary(user.organizationId, user);
}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query() query: ListActivitiesQueryDto,
) {
return this.activitiesService.list(
user.organizationId,
user,
query.filter ?? 'today',
query.q,
query.page ?? 1,
query.limit ?? 50,
{
personId: query.personId,
companyId: query.companyId,
dealId: query.dealId,
},
{
from: query.from,
to: query.to,
},
);
}
@Get(':id')
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.activitiesService.get(user.organizationId, id, user);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateActivityDto) {
return this.activitiesService.create(
user.organizationId,
user.userId,
dto,
user,
);
}
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateActivityDto,
) {
return this.activitiesService.update(user.organizationId, id, user, dto);
}
@Patch(':id/complete')
complete(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.activitiesService.complete(user.organizationId, id, user);
}
@Delete(':id')
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.activitiesService.remove(user.organizationId, id, user);
return { ok: true };
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [ActivitiesController],
providers: [ActivitiesService],
exports: [ActivitiesService],
})
export class ActivitiesModule {}
@@ -0,0 +1,428 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
ActivityDto,
ActivityFilter,
ActivitySummaryDto,
CreateActivityRequest,
PaginatedResponse,
UpdateActivityRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope';
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
type ActivityRow = {
id: string;
type: ActivityDto['type'];
status: ActivityDto['status'];
title: string;
description: string | null;
dueAt: Date | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
assignee: { id: string; fullName: string } | null;
};
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
assignee: { select: { id: true, fullName: true } },
} satisfies Prisma.ActivityInclude;
@Injectable()
export class ActivitiesService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async summary(
organizationId: string,
actor: ActivityActor,
): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds();
const scope = assigneeWhere(actor);
const [today, overdue, upcoming] = await Promise.all([
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
},
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
take: 20,
}),
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
},
include,
orderBy: [{ dueAt: 'asc' }],
take: 20,
}),
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
},
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
take: 10,
}),
]);
return {
today: today.map((row) => this.toDto(row)),
overdue: overdue.map((row) => this.toDto(row)),
upcoming: upcoming.map((row) => this.toDto(row)),
todayCount: today.length,
overdueCount: overdue.length,
};
}
async list(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter = 'today',
q?: string,
page = 1,
limit = 50,
entity?: {
personId?: string;
companyId?: string;
dealId?: string;
},
range?: {
from?: string;
to?: string;
},
): Promise<PaginatedResponse<ActivityDto>> {
const where = this.buildWhere(
organizationId,
actor,
filter,
q,
entity,
range,
);
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take,
take,
}),
this.prisma.activity.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit: take,
};
}
async get(
organizationId: string,
id: string,
actor: ActivityActor,
): Promise<ActivityDto> {
const row = await this.prisma.activity.findFirst({
where: { id, organizationId, ...assigneeWhere(actor) },
include,
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
return this.toDto(row);
}
async create(
organizationId: string,
userId: string,
dto: CreateActivityRequest,
actor?: ActivityActor,
): Promise<ActivityDto> {
const linkActor = actor ?? { userId, role: 'owner' };
await this.assertLinks(organizationId, linkActor, dto);
const assigneeId = emptyToNull(dto.assigneeId ?? null) ?? userId;
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
const row = await this.prisma.activity.create({
data: {
organizationId,
title: dto.title.trim(),
type: dto.type ?? 'task',
description: emptyToNull(dto.description),
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
assigneeId,
},
include,
});
if (assigneeId) {
await this.notifications.create({
organizationId,
userId: assigneeId,
type: 'activity_assigned',
title: 'فعالیت جدید برای شما',
body: row.title,
link: `/activities/${row.id}`,
entityType: 'activity',
entityId: row.id,
});
}
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
actor: ActivityActor,
dto: UpdateActivityRequest,
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
await this.assertLinks(organizationId, actor, dto);
const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.type !== undefined) data.type = dto.type;
if ('description' in dto) data.description = emptyToNull(dto.description);
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('assigneeId' in dto) {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
data.assigneeId = assigneeId;
}
if (dto.status !== undefined) {
data.status = dto.status;
data.completedAt =
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
}
const row = await this.prisma.activity.update({
where: { id },
data,
include,
});
return this.toDto(row);
}
async complete(
organizationId: string,
id: string,
actor: ActivityActor,
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
const row = await this.prisma.activity.update({
where: { id },
data: { status: 'done', completedAt: new Date() },
include,
});
return this.toDto(row);
}
async remove(
organizationId: string,
id: string,
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
await this.prisma.activity.delete({ where: { id } });
}
private buildWhere(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter,
q?: string,
entity?: {
personId?: string;
companyId?: string;
dealId?: string;
},
range?: {
from?: string;
to?: string;
},
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const base: Prisma.ActivityWhereInput = {
organizationId,
...assigneeWhere(actor),
...(entity?.personId ? { personId: entity.personId } : {}),
...(entity?.companyId ? { companyId: entity.companyId } : {}),
...(entity?.dealId ? { dealId: entity.dealId } : {}),
...(q?.trim()
? {
OR: [
{ title: { contains: q.trim(), mode: 'insensitive' } },
{ description: { contains: q.trim(), mode: 'insensitive' } },
{
person: {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
],
},
},
{ deal: { title: { contains: q.trim(), mode: 'insensitive' } } },
],
}
: {}),
};
const fromDate = range?.from ? new Date(range.from) : null;
const toDate = range?.to ? new Date(range.to) : null;
if (fromDate || toDate) {
return {
...base,
dueAt: {
...(fromDate && !Number.isNaN(fromDate.getTime())
? { gte: fromDate }
: {}),
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}),
},
};
}
switch (filter) {
case 'today':
return { ...base, status: 'open', dueAt: { gte: start, lt: end } };
case 'overdue':
return { ...base, status: 'open', dueAt: { lt: start } };
case 'upcoming':
return {
...base,
status: 'open',
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
};
case 'done':
return { ...base, status: 'done' };
case 'all':
default:
return base;
}
}
private async ensureExists(
organizationId: string,
id: string,
actor: ActivityActor,
) {
const found = await this.prisma.activity.findFirst({
where: { id, organizationId, ...assigneeWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('فعالیت پیدا نشد');
}
private async assertUserInOrg(organizationId: string, userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId },
select: { id: true },
});
if (!user) throw new BadRequestException('مسئول انتخاب‌شده معتبر نیست');
}
private async assertLinks(
organizationId: string,
actor: ActivityActor,
dto: {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
},
) {
const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null);
const scope = ownerWhere(actor);
if (personId) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId, ...scope },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص انتخاب‌شده معتبر نیست');
}
if (companyId) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, organizationId, ...scope },
select: { id: true },
});
if (!company) throw new BadRequestException('شرکت انتخاب‌شده معتبر نیست');
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...scope },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
}
}
private toDto(row: ActivityRow): ActivityDto {
return {
id: row.id,
type: row.type,
status: row.status,
title: row.title,
description: row.description,
dueAt: row.dueAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
assignee: row.assignee,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function dayBounds(now = new Date()) {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { start, end };
}
+149
View File
@@ -0,0 +1,149 @@
import { Type } from 'class-transformer';
import {
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const;
const ACTIVITY_FILTERS = ['today', 'overdue', 'upcoming', 'done', 'all'] as const;
export class ListActivitiesQueryDto {
@IsOptional()
@IsIn(ACTIVITY_FILTERS)
filter?: (typeof ACTIVITY_FILTERS)[number] = 'today';
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
companyId?: string;
@IsOptional()
@IsString()
dealId?: string;
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
@IsOptional()
@IsDateString()
from?: string;
/** Exclusive end (ISO). */
@IsOptional()
@IsDateString()
to?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit?: number = 50;
}
export class CreateActivityDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsDateString()
dueAt?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
assigneeId?: string | null;
}
export class UpdateActivityDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@IsIn(ACTIVITY_STATUSES)
status?: (typeof ACTIVITY_STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsDateString()
dueAt?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
assigneeId?: string | null;
}
+54
View File
@@ -0,0 +1,54 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { AuthModule } from './auth/auth.module';
import { OrganizationsModule } from './organizations/organizations.module';
import { ContactsModule } from './contacts/contacts.module';
import { PipelinesModule } from './pipelines/pipelines.module';
import { ActivitiesModule } from './activities/activities.module';
import { NotesModule } from './notes/notes.module';
import { CustomFieldsModule } from './custom-fields/custom-fields.module';
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 { LeadFormsModule } from './lead-forms/lead-forms.module';
import { QuotesModule } from './quotes/quotes.module';
import { InvoicesModule } from './invoices/invoices.module';
import { SalesModule } from './sales/sales.module';
import { TicketsModule } from './tickets/tickets.module';
import { FaqModule } from './faq/faq.module';
import { PortalModule } from './portal/portal.module';
import { ReportsModule } from './reports/reports.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env', '../../.env'],
}),
PrismaModule,
StorageModule,
HealthModule,
AuthModule,
UsersModule,
OrganizationsModule,
ContactsModule,
PipelinesModule,
ActivitiesModule,
NotesModule,
CustomFieldsModule,
ProductsModule,
NotificationsModule,
LeadFormsModule,
QuotesModule,
InvoicesModule,
SalesModule,
TicketsModule,
FaqModule,
PortalModule,
ReportsModule,
],
})
export class AppModule {}
+26
View File
@@ -0,0 +1,26 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto, RegisterOrganizationDto } from './dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
register(@Body() dto: RegisterOrganizationDto) {
return this.authService.registerOrganization(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: { userId: string }) {
return this.authService.me(user.userId);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { RolesGuard } from './roles.guard';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET') ?? 'dev-secret',
signOptions: {
expiresIn: (config.get<string>('JWT_EXPIRES_IN') ?? '7d') as `${number}d`,
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, RolesGuard],
exports: [AuthService, RolesGuard, JwtModule],
})
export class AuthModule {}
+121
View File
@@ -0,0 +1,121 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import type { AuthUserDto, LoginResponse } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto, RegisterOrganizationDto } from './dto';
function slugify(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^\w\u0600-\u06FF]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || `org-${Date.now()}`;
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
async registerOrganization(dto: RegisterOrganizationDto): Promise<LoginResponse> {
const baseSlug = slugify(dto.organizationName);
let slug = baseSlug;
let i = 1;
while (await this.prisma.organization.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${i++}`;
}
const existing = await this.prisma.user.findFirst({
where: { email: dto.email.toLowerCase() },
});
if (existing) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
const passwordHash = await bcrypt.hash(dto.password, 10);
const organization = await this.prisma.organization.create({
data: {
name: dto.organizationName,
slug,
users: {
create: {
email: dto.email.toLowerCase(),
fullName: dto.fullName,
passwordHash,
role: 'owner',
},
},
},
include: { users: true },
});
const user = organization.users[0];
return this.buildLoginResponse(user);
}
async login(dto: LoginDto): Promise<LoginResponse> {
const user = await this.prisma.user.findFirst({
where: { email: dto.email.toLowerCase() },
});
if (!user) {
throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است');
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است');
}
return this.buildLoginResponse(user);
}
async me(userId: string): Promise<AuthUserDto> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException();
}
return this.toAuthUser(user);
}
private buildLoginResponse(user: {
id: string;
email: string;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
}): LoginResponse {
const payload = {
sub: user.id,
organizationId: user.organizationId,
role: user.role,
};
return {
accessToken: this.jwt.sign(payload),
user: this.toAuthUser(user),
};
}
private toAuthUser(user: {
id: string;
email: string;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
}): AuthUserDto {
return {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
organizationId: user.organizationId,
};
}
}
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
+27
View File
@@ -0,0 +1,27 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(6)
password!: string;
}
export class RegisterOrganizationDto {
@IsString()
@MinLength(2)
organizationName!: string;
@IsString()
@MinLength(2)
fullName!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(6)
password!: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+32
View File
@@ -0,0 +1,32 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
export type JwtPayload = {
sub: string;
organizationId: string;
role: string;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET') ?? 'dev-secret',
});
}
validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
if (payload.typ === 'portal') {
throw new UnauthorizedException();
}
return {
userId: payload.sub,
organizationId: payload.organizationId,
role: payload.role,
};
}
}
+6
View File
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import type { UserRole } from '@mento/shared';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+31
View File
@@ -0,0 +1,31 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { UserRole } from '@mento/shared';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!roles?.length) return true;
const request = context.switchToHttp().getRequest<{
user?: { role?: string };
}>();
const role = request.user?.role as UserRole | undefined;
if (!role || !roles.includes(role)) {
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
}
return true;
}
}
+54
View File
@@ -0,0 +1,54 @@
import { CURRENCY_CATALOG } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type RateMap = {
baseCurrency: string;
/** currency code → how many base units for 1 unit of that currency */
rates: Map<string, number>;
};
export function isKnownCurrency(code: string): boolean {
return CURRENCY_CATALOG.some((c) => c.code === code);
}
export async function loadOrgRates(
prisma: PrismaService,
organizationId: string,
): Promise<RateMap> {
const org = await prisma.organization.findUnique({
where: { id: organizationId },
select: {
baseCurrency: true,
exchangeRates: { select: { currency: true, rateToBase: true } },
},
});
const baseCurrency = org?.baseCurrency || 'IRR';
const rates = new Map<string, number>();
rates.set(baseCurrency, 1);
for (const row of org?.exchangeRates ?? []) {
rates.set(row.currency, Number(row.rateToBase));
}
return { baseCurrency, rates };
}
export function toBaseAmount(
amount: number | null | undefined,
currency: string | null | undefined,
map: RateMap,
): number {
if (amount == null || Number.isNaN(amount)) return 0;
const code = (currency || map.baseCurrency).trim() || map.baseCurrency;
if (code === map.baseCurrency) return amount;
const rate = map.rates.get(code);
if (rate == null || rate <= 0) return amount;
return amount * rate;
}
export function decimalToBase(
amount: Prisma.Decimal | null | undefined,
currency: string | null | undefined,
map: RateMap,
): number {
return toBaseAmount(amount == null ? 0 : Number(amount), currency, map);
}
+14
View File
@@ -0,0 +1,14 @@
import { Prisma } from '@prisma/client';
export function asCustomFields(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
export function toJsonCustomFields(
value?: Record<string, unknown> | null,
): Prisma.InputJsonValue {
return (value ?? {}) as Prisma.InputJsonValue;
}
+70
View File
@@ -0,0 +1,70 @@
import type { UserRole } from '@mento/shared';
import { canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
export type Actor = {
userId: string;
role: string;
organizationId: string;
};
export function elevated(role: string | null | undefined): boolean {
return canViewAllRecords(role);
}
/** فیلتر مالکیت برای Deal / Quote / Invoice / Person / Company */
export function ownerWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): { ownerId: string } | Record<string, never> {
if (elevated(actor.role)) return {};
return { ownerId: actor.userId };
}
/** فعالیت‌ها بر اساس مسئول */
export function assigneeWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): { assigneeId: string } | Record<string, never> {
if (elevated(actor.role)) return {};
return { assigneeId: actor.userId };
}
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت */
export function noteAccessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): Prisma.NoteWhereInput {
if (elevated(actor.role)) return {};
return {
OR: [
{ authorId: actor.userId },
{ deal: { ownerId: actor.userId } },
{ person: { ownerId: actor.userId } },
{ company: { ownerId: actor.userId } },
],
};
}
export function resolveOwnerId(
actor: Pick<Actor, 'userId' | 'role'>,
requested?: string | null,
): string {
if (elevated(actor.role) && requested?.trim()) {
return requested.trim();
}
return actor.userId;
}
/** تیکت: مسئول یا ثبت‌کننده */
export function ticketAccessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): Prisma.TicketWhereInput {
if (elevated(actor.role)) return {};
return {
OR: [{ assigneeId: actor.userId }, { reporterId: actor.userId }],
};
}
export function canReassignOwner(role: string | null | undefined): boolean {
return elevated(role);
}
export type OrgRole = UserRole;
+275
View File
@@ -0,0 +1,275 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/** پنل ترِز / رایگان — همان الگوی montime */
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
const SMS_TREZ_DEFAULT_SEND_PATH = '/SendMessageWithCode.ashx';
const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx';
const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
export type SmsSendResult = {
ok: boolean;
transport: 'trez' | 'soap' | 'link';
reason?: string;
};
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly config: ConfigService) {}
isConfigured(): boolean {
const username = this.config.get<string>('SMS_USERNAME');
const password = this.getPassword();
const sender = this.config.get<string>('SMS_SENDER');
return Boolean(username && password && sender);
}
normalizePhoneForSms(mobile: string): string {
const digits = String(mobile ?? '').replace(/\D/g, '');
if (digits.startsWith('98') && digits.length === 12) {
return `0${digits.slice(2)}`;
}
if (digits.startsWith('0') && digits.length === 11) {
return digits;
}
if (digits.length === 10 && digits.startsWith('9')) {
return `0${digits}`;
}
return digits;
}
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
const template =
this.config.get<string>('SMS_OTP_TEMPLATE') ??
'کد ورود پرتال منتو: {code}';
const text = template.replace('{code}', code);
const result = await this.sendSmsDetailed(mobile, text);
return result.ok;
}
async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> {
const username = this.config.get<string>('SMS_USERNAME');
const password = this.getPassword();
const sender = this.config.get<string>('SMS_SENDER');
if (!username || !password || !sender) {
return { ok: false, transport: 'trez', reason: 'SMS not configured' };
}
const to = this.normalizePhoneForSms(phone);
const provider = this.resolveProvider();
if (provider === 'trez') {
return this.sendViaTrez(username, password, sender, to, text);
}
const transport = this.resolve0098Transport();
if (transport === 'link') {
return this.sendVia0098Link(username, password, sender, to, text);
}
const soapResult = await this.sendVia0098Soap(
username,
password,
sender,
to,
text,
);
if (soapResult.ok) return soapResult;
this.logger.warn(
`0098 SOAP failed to=${this.maskPhone(to)} reason=${soapResult.reason}; link fallback`,
);
return this.sendVia0098Link(username, password, sender, to, text);
}
private resolveProvider(): 'trez' | '0098' {
const raw = (this.config.get<string>('SMS_PROVIDER') ?? 'trez').toLowerCase();
return raw === '0098' ? '0098' : 'trez';
}
private resolve0098Transport(): 'soap' | 'link' {
const raw = (this.config.get<string>('SMS_TRANSPORT') ?? 'soap').toLowerCase();
return raw === 'link' ? 'link' : 'soap';
}
private async sendViaTrez(
username: string,
password: string,
sender: string,
to: string,
text: string,
): Promise<SmsSendResult> {
const baseUrl = (
this.config.get<string>('SMS_BASE_URL') ?? SMS_TREZ_DEFAULT_BASE_URL
).replace(/\/$/, '');
const path =
this.config.get<string>('SMS_SEND_PATH') ?? SMS_TREZ_DEFAULT_SEND_PATH;
const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
const body = new URLSearchParams({
PhoneNumber: sender,
Username: username,
Password: password,
Mobile: to,
Message: text,
});
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(this.readTimeoutMs()),
});
const responseBody = (await res.text()).trim();
if (!res.ok) {
return { ok: false, transport: 'trez', reason: `HTTP ${res.status}` };
}
const parsed = this.parseTrezResponse(responseBody);
if (!parsed.ok) {
this.logger.warn(
`Trez rejected to=${this.maskPhone(to)} reason=${parsed.reason}`,
);
}
return { ...parsed, transport: 'trez' };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
this.logger.error(`Trez failed to=${this.maskPhone(to)}: ${message}`);
return { ok: false, transport: 'trez', reason: 'Trez request failed' };
}
}
private parseTrezResponse(body: string): { ok: boolean; reason: string } {
if (!body) return { ok: true, reason: 'accepted (empty response)' };
const trimmed = body.trim();
const lower = trimmed.toLowerCase();
if (
lower.includes('error') ||
lower.includes('fail') ||
lower.includes('invalid') ||
lower.includes('خطا')
) {
return { ok: false, reason: trimmed.slice(0, 120) };
}
if (/^-?\d+$/.test(trimmed)) {
const n = Number(trimmed);
if (n === 1 || n > 1) return { ok: true, reason: 'accepted' };
if (n === 0) return { ok: false, reason: 'provider code 0' };
if (n < 0) return { ok: false, reason: `provider code ${trimmed}` };
}
if (lower === 'true' || lower === 'ok' || lower === 'success') {
return { ok: true, reason: 'accepted' };
}
if (lower === 'false') return { ok: false, reason: 'provider returned false' };
return { ok: true, reason: 'accepted' };
}
private async sendVia0098Soap(
username: string,
password: string,
sender: string,
to: string,
text: string,
): Promise<SmsSendResult> {
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<SendSMS xmlns="http://tempuri.org/">
<username>${this.escapeXml(username)}</username>
<password>${this.escapeXml(password)}</password>
<text>${this.escapeXml(text)}</text>
<mobileno>${this.escapeXml(to)}</mobileno>
<pnlno>${this.escapeXml(sender)}</pnlno>
</SendSMS>
</soap:Body>
</soap:Envelope>`;
try {
const res = await fetch(SMS_0098_SOAP_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: 'http://tempuri.org/SendSMS',
},
body: soapBody,
signal: AbortSignal.timeout(this.readTimeoutMs()),
});
const body = (await res.text()).trim();
if (!res.ok) return { ok: false, transport: 'soap', reason: `HTTP ${res.status}` };
const code =
body.match(/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/i)?.[1]?.trim() ??
null;
const n = code != null ? Number(code) : NaN;
if (Number.isFinite(n) && n >= 0) return { ok: true, transport: 'soap' };
return {
ok: false,
transport: 'soap',
reason: code ? `provider code ${code}` : 'unknown SOAP response',
};
} catch {
return { ok: false, transport: 'soap', reason: 'SOAP request failed' };
}
}
private async sendVia0098Link(
username: string,
password: string,
sender: string,
to: string,
text: string,
): Promise<SmsSendResult> {
const domain = this.config.get<string>('SMS_DOMAIN') ?? '0098';
const params = new URLSearchParams({
FROM: sender,
TO: to,
TEXT: text,
USERNAME: username,
PASSWORD: password,
DOMAIN: domain,
});
try {
const res = await fetch(`${SMS_0098_LINK_URL}?${params}`, {
signal: AbortSignal.timeout(this.readTimeoutMs()),
});
const body = (await res.text()).trim();
if (!res.ok) return { ok: false, transport: 'link', reason: `HTTP ${res.status}` };
const lower = body.toLowerCase();
if (lower.includes('error') || lower.includes('fail')) {
return { ok: false, transport: 'link', reason: body.slice(0, 120) };
}
return { ok: true, transport: 'link' };
} catch {
return { ok: false, transport: 'link', reason: 'link request failed' };
}
}
private escapeXml(value: string): string {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
private readTimeoutMs(): number {
const raw = this.config.get<string>('SMS_TIMEOUT_MS');
const n = raw ? Number.parseInt(raw, 10) : 20_000;
return Number.isFinite(n) && n > 0 ? n : 20_000;
}
private maskPhone(phone: string): string {
if (phone.length <= 6) return '***';
return `${phone.slice(0, 4)}****${phone.slice(-2)}`;
}
private getPassword(): string | undefined {
return (
this.config.get<string>('SMS_PASSWORD') ??
this.config.get<string>('SMS_API_KEY')
);
}
}
@@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
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 { CompaniesService } from './companies.service';
import { CreateCompanyDto, ListQueryDto, UpdateCompanyDto } from './dto';
import { ContactsExcelService } from './contacts-excel.service';
@Controller('companies')
@UseGuards(JwtAuthGuard)
export class CompaniesController {
constructor(
private readonly companiesService: CompaniesService,
private readonly excelService: ContactsExcelService,
) {}
@Get()
list(@CurrentUser() user: Actor, @Query() query: ListQueryDto) {
return this.companiesService.list(
user,
query.q,
query.page ?? 1,
query.limit ?? 20,
);
}
@Get('export')
async export(
@CurrentUser() user: Actor,
@Query('q') q: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const buffer = await this.excelService.exportCompanies(user, q);
res.set({
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="mento-companies.xlsx"',
});
return new StreamableFile(buffer);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.importCompanies(user, file);
}
@Get(':id')
get(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.companiesService.get(user, id);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreateCompanyDto) {
return this.companiesService.create(user, dto);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateCompanyDto,
) {
return this.companiesService.update(user, id, dto);
}
@Delete(':id')
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.companiesService.remove(user, id);
return { ok: true };
}
}
+238
View File
@@ -0,0 +1,238 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CompanyDto,
CreateCompanyRequest,
PaginatedResponse,
UpdateCompanyRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
import {
asCustomFields,
toJsonCustomFields,
} from '../common/custom-fields';
import {
type Actor,
canReassignOwner,
elevated,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
const companyInclude = {
_count: { select: { people: true } },
owner: { select: { id: true, fullName: true } },
} satisfies Prisma.CompanyInclude;
type CompanyRow = {
id: string;
name: string;
nationalId: string | null;
phone: string | null;
email: string | null;
website: string | null;
address: string | null;
notes: string | null;
customFields: unknown;
createdAt: Date;
updatedAt: Date;
_count: { people: number };
owner: { id: string; fullName: string } | null;
};
@Injectable()
export class CompaniesService {
constructor(private readonly prisma: PrismaService) {}
async list(
actor: Actor,
q?: string,
page = 1,
limit = 20,
): Promise<PaginatedResponse<CompanyDto>> {
const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(q?.trim()
? {
OR: [
{ name: { contains: q.trim(), mode: 'insensitive' } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
{ phone: { contains: q.trim() } },
{ nationalId: { contains: q.trim() } },
],
}
: {}),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.company.findMany({
where,
include: companyInclude,
orderBy: [{ name: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.company.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
async get(actor: Actor, id: string): Promise<CompanyDto> {
const row = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
include: companyInclude,
});
if (!row) throw new NotFoundException('شرکت پیدا نشد');
return this.toDto(row);
}
async create(actor: Actor, dto: CreateCompanyRequest): Promise<CompanyDto> {
const data: Prisma.CompanyUncheckedCreateInput = {
organizationId: actor.organizationId,
name: dto.name.trim(),
nationalId: emptyToNull(dto.nationalId),
phone: normalizePhone(dto.phone),
email: normalizeEmail(dto.email),
website: emptyToNull(dto.website),
address: emptyToNull(dto.address),
notes: emptyToNull(dto.notes),
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
};
await this.assertNoDuplicate(actor.organizationId, data);
const row = await this.prisma.company.create({
data,
include: companyInclude,
});
return this.toDto(row);
}
async update(
actor: Actor,
id: string,
dto: UpdateCompanyRequest,
): Promise<CompanyDto> {
await this.ensureExists(actor.organizationId, id, actor);
const data: Prisma.CompanyUncheckedUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if ('nationalId' in dto) data.nationalId = emptyToNull(dto.nationalId);
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('address' in dto) data.address = emptyToNull(dto.address);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
}
await this.assertNoDuplicate(actor.organizationId, data, id);
const row = await this.prisma.company.update({
where: { id },
data,
include: companyInclude,
});
return this.toDto(row);
}
async remove(actor: Actor, id: string): Promise<void> {
await this.ensureExists(actor.organizationId, id, actor);
await this.prisma.company.delete({ where: { id } });
}
async ensureInOrg(
organizationId: string,
companyId: string,
actor?: Pick<Actor, 'userId' | 'role'>,
): Promise<void> {
await this.ensureExists(organizationId, companyId, actor);
}
private async ensureExists(
organizationId: string,
id: string,
actor?: Pick<Actor, 'userId' | 'role'>,
) {
const found = await this.prisma.company.findFirst({
where: {
id,
organizationId,
...(actor && !elevated(actor.role)
? { ownerId: actor.userId }
: {}),
},
select: { id: true },
});
if (!found) throw new NotFoundException('شرکت پیدا نشد');
}
private async assertNoDuplicate(
organizationId: string,
data: { email?: unknown; phone?: unknown; nationalId?: unknown },
excludeId?: string,
) {
const email = typeof data.email === 'string' ? data.email : null;
const phone = typeof data.phone === 'string' ? data.phone : null;
const nationalId =
typeof data.nationalId === 'string' ? data.nationalId : null;
const or: Prisma.CompanyWhereInput[] = [];
if (email) or.push({ email });
if (phone) or.push({ phone });
if (nationalId) or.push({ nationalId });
if (!or.length) return;
const clash = await this.prisma.company.findFirst({
where: {
organizationId,
OR: or,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!clash) return;
if (email && clash.email === email) {
throw new ConflictException('شرکتی با این ایمیل از قبل وجود دارد');
}
if (phone && clash.phone === phone) {
throw new ConflictException('شرکتی با این تلفن از قبل وجود دارد');
}
if (nationalId && clash.nationalId === nationalId) {
throw new ConflictException('شرکتی با این شناسه ملی از قبل وجود دارد');
}
throw new ConflictException('شرکت تکراری است');
}
private toDto(row: CompanyRow): CompanyDto {
return {
id: row.id,
name: row.name,
nationalId: row.nationalId,
phone: row.phone,
email: row.email,
website: row.website,
address: row.address,
notes: row.notes,
customFields: asCustomFields(row.customFields),
peopleCount: row._count.people,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,312 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import ExcelJS from 'exceljs';
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 { 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 normalizeHeader(raw: string) {
return raw.trim().toLowerCase().replace(/\s+/g, '');
}
const PERSON_ALIASES: Record<string, string> = {
firstname: 'firstName',
نام: 'firstName',
lastname: 'lastName',
نامخانوادگی: 'lastName',
'نام خانوادگی': 'lastName',
email: 'email',
ایمیل: 'email',
phone: 'phone',
تلفن: 'phone',
mobile: 'mobile',
موبایل: 'mobile',
position: 'position',
سمت: 'position',
company: 'companyName',
companyname: 'companyName',
شرکت: 'companyName',
notes: 'notes',
یادداشت: 'notes',
};
const COMPANY_ALIASES: Record<string, string> = {
name: 'name',
نام: 'name',
نامشرکت: 'name',
'نام شرکت': 'name',
nationalid: 'nationalId',
شناسهملی: 'nationalId',
'شناسه ملی': 'nationalId',
phone: 'phone',
تلفن: 'phone',
email: 'email',
ایمیل: 'email',
website: 'website',
وبسایت: 'website',
address: 'address',
آدرس: 'address',
notes: 'notes',
یادداشت: 'notes',
};
@Injectable()
export class ContactsExcelService {
constructor(
private readonly prisma: PrismaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
) {}
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: '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 ?? '',
notes: p.notes ?? '',
});
}
const buf = await wb.xlsx.writeBuffer();
return Buffer.from(buf);
}
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({
name: c.name,
nationalId: c.nationalId ?? '',
phone: c.phone ?? '',
email: c.email ?? '',
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: [],
};
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: 'نام و نام خانوادگی الزامی است',
});
continue;
}
let companyId: string | null = null;
const companyName = row.companyName?.trim();
if (companyName) {
companyId = await this.findOrCreateCompanyId(actor, companyName);
}
try {
await this.people.create(actor, {
firstName,
lastName,
email: row.email || undefined,
phone: row.phone || undefined,
mobile: row.mobile || undefined,
position: row.position || undefined,
notes: row.notes || undefined,
companyId,
});
result.created += 1;
} catch (e) {
if (e instanceof ConflictException) {
result.skipped += 1;
} else {
result.errors.push({
row: rowNum,
message: e instanceof Error ? e.message : 'خطای ناشناخته',
});
}
}
}
return result;
}
async importCompanies(
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: [],
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const rowNum = i + 2;
const name = row.name?.trim() ?? '';
if (!name) {
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
continue;
}
try {
await this.companies.create(actor, {
name,
nationalId: row.nationalId || undefined,
phone: row.phone || undefined,
email: row.email || undefined,
website: row.website || undefined,
address: row.address || undefined,
notes: row.notes || undefined,
});
result.created += 1;
} catch (e) {
if (e instanceof ConflictException) {
result.skipped += 1;
} else {
result.errors.push({
row: rowNum,
message: e instanceof Error ? e.message : 'خطای ناشناخته',
});
}
}
}
return result;
}
private async findOrCreateCompanyId(
actor: Actor,
name: string,
): Promise<string> {
const existing = await this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...ownerWhere(actor),
},
select: { id: true },
});
if (existing) return existing.id;
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;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
@Module({
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService],
exports: [PeopleService, CompaniesService],
})
export class ContactsModule {}
+229
View File
@@ -0,0 +1,229 @@
import { Type } from 'class-transformer';
import {
IsEmail,
IsInt,
IsObject,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class ListQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
export class CreateCompanyDto {
@IsString()
@MinLength(2)
@MaxLength(160)
name!: string;
@IsOptional()
@IsString()
@MaxLength(32)
nationalId?: string;
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MaxLength(200)
website?: string;
@IsOptional()
@IsString()
@MaxLength(400)
address?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
export class UpdateCompanyDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(160)
name?: string;
@IsOptional()
@IsString()
@MaxLength(32)
nationalId?: string;
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MaxLength(200)
website?: string;
@IsOptional()
@IsString()
@MaxLength(400)
address?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
export class CreatePersonDto {
@IsString()
@MinLength(1)
@MaxLength(80)
firstName!: string;
@IsString()
@MinLength(1)
@MaxLength(80)
lastName!: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string;
@IsOptional()
@IsString()
@MaxLength(40)
mobile?: string;
@IsOptional()
@IsString()
@MaxLength(120)
position?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
export class UpdatePersonDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
lastName?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string;
@IsOptional()
@IsString()
@MaxLength(40)
mobile?: string;
@IsOptional()
@IsString()
@MaxLength(120)
position?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
+19
View File
@@ -0,0 +1,19 @@
export function emptyToNull(value?: string | null): string | null {␍
if (value == null) return null;␍
const trimmed = value.trim();␍
return trimmed.length ? trimmed : null;␍
}␍
␍
export function normalizeEmail(value?: string | null): string | null {␍
const v = emptyToNull(value);␍
return v ? v.toLowerCase() : null;␍
}␍
␍
/** Keep digits and leading + for Iranian/international numbers. */␍
export function normalizePhone(value?: string | null): string | null {␍
const v = emptyToNull(value);␍
if (!v) return null;␍
const cleaned = v.replace(/[^\d+]/g, '');␍
return cleaned.length ? cleaned : null;␍
}␍
␍
@@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
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 { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
@Controller('people')
@UseGuards(JwtAuthGuard)
export class PeopleController {
constructor(
private readonly peopleService: PeopleService,
private readonly excelService: ContactsExcelService,
) {}
@Get()
list(@CurrentUser() user: Actor, @Query() query: ListQueryDto) {
return this.peopleService.list(
user,
query.q,
query.page ?? 1,
query.limit ?? 20,
);
}
@Get('export')
async export(
@CurrentUser() user: Actor,
@Query('q') q: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const buffer = await this.excelService.exportPeople(user, q);
res.set({
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="mento-people.xlsx"',
});
return new StreamableFile(buffer);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.importPeople(user, file);
}
@Get(':id')
get(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.peopleService.get(user, id);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreatePersonDto) {
return this.peopleService.create(user, dto);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdatePersonDto,
) {
return this.peopleService.update(user, id, dto);
}
@Delete(':id')
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.peopleService.remove(user, id);
return { ok: true };
}
}
+238
View File
@@ -0,0 +1,238 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreatePersonRequest,
PaginatedResponse,
PersonDto,
UpdatePersonRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CompaniesService } from './companies.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
import {
asCustomFields,
toJsonCustomFields,
} from '../common/custom-fields';
import {
type Actor,
canReassignOwner,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
const personInclude = {
company: { select: { id: true, name: true } },
owner: { select: { id: true, fullName: true } },
} satisfies Prisma.PersonInclude;
type PersonRow = {
id: string;
firstName: string;
lastName: string;
email: string | null;
phone: string | null;
mobile: string | null;
position: string | null;
notes: string | null;
customFields: unknown;
createdAt: Date;
updatedAt: Date;
company: { id: string; name: string } | null;
owner: { id: string; fullName: string } | null;
};
@Injectable()
export class PeopleService {
constructor(
private readonly prisma: PrismaService,
private readonly companies: CompaniesService,
) {}
async list(
actor: Actor,
q?: string,
page = 1,
limit = 20,
): Promise<PaginatedResponse<PersonDto>> {
const where: Prisma.PersonWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(q?.trim()
? {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
{ mobile: { contains: q.trim() } },
{ phone: { contains: q.trim() } },
{ company: { name: { contains: q.trim(), mode: 'insensitive' } } },
],
}
: {}),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.person.findMany({
where,
include: personInclude,
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.person.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
async get(actor: Actor, id: string): Promise<PersonDto> {
const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
include: personInclude,
});
if (!row) throw new NotFoundException('شخص پیدا نشد');
return this.toDto(row);
}
async create(actor: Actor, dto: CreatePersonRequest): Promise<PersonDto> {
const companyId = emptyToNull(dto.companyId ?? null);
if (companyId) {
await this.companies.ensureInOrg(actor.organizationId, companyId, actor);
}
const data: Prisma.PersonUncheckedCreateInput = {
organizationId: actor.organizationId,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone),
mobile: normalizePhone(dto.mobile),
position: emptyToNull(dto.position),
notes: emptyToNull(dto.notes),
companyId,
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
};
await this.assertNoDuplicate(actor.organizationId, data);
const row = await this.prisma.person.create({
data,
include: personInclude,
});
return this.toDto(row);
}
async update(
actor: Actor,
id: string,
dto: UpdatePersonRequest,
): Promise<PersonDto> {
await this.ensureExists(actor, id);
const data: Prisma.PersonUncheckedUpdateInput = {};
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
if ('position' in dto) data.position = emptyToNull(dto.position);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
if ('companyId' in dto) {
const nextCompanyId = emptyToNull(dto.companyId ?? null);
if (nextCompanyId) {
await this.companies.ensureInOrg(
actor.organizationId,
nextCompanyId,
actor,
);
}
data.companyId = nextCompanyId;
}
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
}
await this.assertNoDuplicate(actor.organizationId, data, id);
const row = await this.prisma.person.update({
where: { id },
data,
include: personInclude,
});
return this.toDto(row);
}
async remove(actor: Actor, id: string): Promise<void> {
await this.ensureExists(actor, id);
await this.prisma.person.delete({ where: { id } });
}
private async ensureExists(actor: Actor, id: string) {
const found = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('شخص پیدا نشد');
}
private async assertNoDuplicate(
organizationId: string,
data: { email?: unknown; mobile?: unknown },
excludeId?: string,
) {
const email = typeof data.email === 'string' ? data.email : null;
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email });
if (mobile) or.push({ mobile });
if (!or.length) return;
const clash = await this.prisma.person.findFirst({
where: {
organizationId,
OR: or,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!clash) return;
if (email && clash.email === email) {
throw new ConflictException('شخصی با این ایمیل از قبل وجود دارد');
}
if (mobile && clash.mobile === mobile) {
throw new ConflictException('شخصی با این موبایل از قبل وجود دارد');
}
throw new ConflictException('شخص تکراری است');
}
private toDto(row: PersonRow): PersonDto {
return {
id: row.id,
firstName: row.firstName,
lastName: row.lastName,
fullName: `${row.firstName} ${row.lastName}`.trim(),
email: row.email,
phone: row.phone,
mobile: row.mobile,
position: row.position,
notes: row.notes,
customFields: asCustomFields(row.customFields),
company: row.company,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
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 {
CreateCustomFieldDto,
ListCustomFieldsQueryDto,
UpdateCustomFieldDto,
} from './dto';
import { CustomFieldsService } from './custom-fields.service';
@Controller('custom-fields')
@UseGuards(JwtAuthGuard, RolesGuard)
export class CustomFieldsController {
constructor(private readonly customFieldsService: CustomFieldsService) {}
@Get()
list(
@CurrentUser() user: { organizationId: string },
@Query() query: ListCustomFieldsQueryDto,
) {
return this.customFieldsService.list(user.organizationId, {
entityType: query.entityType,
activeOnly: query.activeOnly,
});
}
@Post()
@Roles('owner', 'admin')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateCustomFieldDto,
) {
return this.customFieldsService.create(user.organizationId, dto);
}
@Patch(':id')
@Roles('owner', 'admin')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateCustomFieldDto,
) {
return this.customFieldsService.update(user.organizationId, id, dto);
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.customFieldsService.remove(user.organizationId, id);
return { ok: true };
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CustomFieldsController } from './custom-fields.controller';
import { CustomFieldsService } from './custom-fields.service';
@Module({
imports: [AuthModule],
controllers: [CustomFieldsController],
providers: [CustomFieldsService],
exports: [CustomFieldsService],
})
export class CustomFieldsModule {}
@@ -0,0 +1,181 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateCustomFieldRequest,
CustomFieldDefinitionDto,
CustomFieldEntity,
UpdateCustomFieldRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
type DefinitionRow = {
id: string;
entityType: CustomFieldEntity;
key: string;
label: string;
fieldType: CustomFieldDefinitionDto['fieldType'];
options: string[];
required: boolean;
position: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
};
@Injectable()
export class CustomFieldsService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
opts?: { entityType?: CustomFieldEntity; activeOnly?: boolean },
): Promise<CustomFieldDefinitionDto[]> {
const rows = await this.prisma.customFieldDefinition.findMany({
where: {
organizationId,
...(opts?.entityType ? { entityType: opts.entityType } : {}),
...(opts?.activeOnly ? { isActive: true } : {}),
},
orderBy: [{ entityType: 'asc' }, { position: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
dto: CreateCustomFieldRequest,
): Promise<CustomFieldDefinitionDto> {
const label = dto.label.trim();
if (!label) throw new BadRequestException('عنوان فیلد الزامی است');
const key = this.normalizeKey(dto.key) || this.keyFromLabel(label);
const fieldType = dto.fieldType ?? 'text';
const options =
fieldType === 'select'
? (dto.options ?? []).map((o) => o.trim()).filter(Boolean)
: [];
if (fieldType === 'select' && options.length === 0) {
throw new BadRequestException('برای فیلد انتخابی حداقل یک گزینه لازم است');
}
const maxPos = await this.prisma.customFieldDefinition.aggregate({
where: { organizationId, entityType: dto.entityType },
_max: { position: true },
});
try {
const row = await this.prisma.customFieldDefinition.create({
data: {
organizationId,
entityType: dto.entityType,
key,
label,
fieldType,
options,
required: dto.required ?? false,
position: dto.position ?? (maxPos._max.position ?? -1) + 1,
isActive: dto.isActive ?? true,
},
});
return this.toDto(row);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('کلید این فیلد قبلاً برای همین موجودیت ثبت شده');
}
throw e;
}
}
async update(
organizationId: string,
id: string,
dto: UpdateCustomFieldRequest,
): Promise<CustomFieldDefinitionDto> {
const existing = await this.prisma.customFieldDefinition.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('فیلد سفارشی پیدا نشد');
const data: Prisma.CustomFieldDefinitionUpdateInput = {};
if (dto.label !== undefined) {
const label = dto.label.trim();
if (!label) throw new BadRequestException('عنوان فیلد الزامی است');
data.label = label;
}
if (dto.fieldType !== undefined) data.fieldType = dto.fieldType;
if (dto.options !== undefined) {
data.options = dto.options.map((o) => o.trim()).filter(Boolean);
}
if (dto.required !== undefined) data.required = dto.required;
if (dto.position !== undefined) data.position = dto.position;
if (dto.isActive !== undefined) data.isActive = dto.isActive;
const nextType = dto.fieldType ?? existing.fieldType;
const nextOptions =
dto.options !== undefined
? dto.options.map((o) => o.trim()).filter(Boolean)
: existing.options;
if (nextType === 'select' && nextOptions.length === 0) {
throw new BadRequestException('برای فیلد انتخابی حداقل یک گزینه لازم است');
}
if (nextType !== 'select') {
data.options = [];
}
const row = await this.prisma.customFieldDefinition.update({
where: { id },
data,
});
return this.toDto(row);
}
async remove(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.customFieldDefinition.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!existing) throw new NotFoundException('فیلد سفارشی پیدا نشد');
await this.prisma.customFieldDefinition.delete({ where: { id } });
}
private normalizeKey(raw?: string) {
if (!raw?.trim()) return '';
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
private keyFromLabel(label: string) {
const latin = this.normalizeKey(label);
if (latin) return latin.slice(0, 48);
return `cf_${Date.now().toString(36)}`;
}
private toDto(row: DefinitionRow): CustomFieldDefinitionDto {
return {
id: row.id,
entityType: row.entityType,
key: row.key,
label: row.label,
fieldType: row.fieldType,
options: row.options,
required: row.required,
position: row.position,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+107
View File
@@ -0,0 +1,107 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export enum CustomFieldEntityDto {
person = 'person',
company = 'company',
deal = 'deal',
}
export enum CustomFieldTypeDto {
text = 'text',
number = 'number',
date = 'date',
boolean = 'boolean',
select = 'select',
}
export class ListCustomFieldsQueryDto {
@IsOptional()
@IsEnum(CustomFieldEntityDto)
entityType?: CustomFieldEntityDto;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
activeOnly?: boolean;
}
export class CreateCustomFieldDto {
@IsEnum(CustomFieldEntityDto)
entityType!: CustomFieldEntityDto;
@IsString()
@MinLength(1)
@MaxLength(120)
label!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(64)
key?: string;
@IsOptional()
@IsEnum(CustomFieldTypeDto)
fieldType?: CustomFieldTypeDto;
@IsOptional()
@IsArray()
@IsString({ each: true })
options?: string[];
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateCustomFieldDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
label?: string;
@IsOptional()
@IsEnum(CustomFieldTypeDto)
fieldType?: CustomFieldTypeDto;
@IsOptional()
@IsArray()
@IsString({ each: true })
options?: string[];
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
+87
View File
@@ -0,0 +1,87 @@
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
export class CreateFaqArticleDto {
@IsString()
@MinLength(2)
@MaxLength(500)
question!: string;
@IsString()
@MinLength(2)
@MaxLength(8000)
answer!: string;
@IsOptional()
@IsString()
@MaxLength(100)
category?: string;
@IsOptional()
@IsBoolean()
isPublished?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
}
export class UpdateFaqArticleDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(500)
question?: string;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(8000)
answer?: string;
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@IsOptional()
@IsBoolean()
isPublished?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
}
export class ListFaqQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
category?: string;
/** اگر true فقط مقالات منتشرشده */
@IsOptional()
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
if (value === true || value === 'true' || value === '1') return true;
if (value === false || value === 'false' || value === '0') return false;
return Boolean(value);
})
@IsBoolean()
publishedOnly?: boolean;
}
+77
View File
@@ -0,0 +1,77 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
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 {
CreateFaqArticleDto,
ListFaqQueryDto,
UpdateFaqArticleDto,
} from './dto';
import { FaqService } from './faq.service';
@Controller('faq')
@UseGuards(JwtAuthGuard, RolesGuard)
export class FaqController {
constructor(private readonly faqService: FaqService) {}
@Get()
list(
@CurrentUser() user: { organizationId: string; role: string },
@Query() query: ListFaqQueryDto,
) {
const canManage = user.role === 'owner' || user.role === 'admin';
return this.faqService.list(user.organizationId, {
q: query.q,
category: query.category,
publishedOnly: query.publishedOnly ?? !canManage,
});
}
@Get(':id')
get(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
return this.faqService.get(user.organizationId, id);
}
@Post()
@Roles('owner', 'admin')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateFaqArticleDto,
) {
return this.faqService.create(user.organizationId, dto);
}
@Patch(':id')
@Roles('owner', 'admin')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateFaqArticleDto,
) {
return this.faqService.update(user.organizationId, id, dto);
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.faqService.remove(user.organizationId, id);
return { ok: true };
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { FaqController } from './faq.controller';
import { FaqService } from './faq.service';
@Module({
imports: [AuthModule],
controllers: [FaqController],
providers: [FaqService],
exports: [FaqService],
})
export class FaqModule {}
+121
View File
@@ -0,0 +1,121 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type {
CreateFaqArticleRequest,
FaqArticleDto,
UpdateFaqArticleRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class FaqService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
opts: { q?: string; category?: string; publishedOnly?: boolean },
): Promise<FaqArticleDto[]> {
const where: Prisma.FaqArticleWhereInput = {
organizationId,
...(opts.publishedOnly ? { isPublished: true } : {}),
...(opts.category?.trim()
? { category: opts.category.trim() }
: {}),
...(opts.q?.trim()
? {
OR: [
{ question: { contains: opts.q.trim(), mode: 'insensitive' } },
{ answer: { contains: opts.q.trim(), mode: 'insensitive' } },
],
}
: {}),
};
const rows = await this.prisma.faqArticle.findMany({
where,
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
});
return rows.map(toDto);
}
async get(organizationId: string, id: string): Promise<FaqArticleDto> {
const row = await this.prisma.faqArticle.findFirst({
where: { id, organizationId },
});
if (!row) throw new NotFoundException('مقاله FAQ پیدا نشد');
return toDto(row);
}
async create(
organizationId: string,
dto: CreateFaqArticleRequest,
): Promise<FaqArticleDto> {
const row = await this.prisma.faqArticle.create({
data: {
organizationId,
question: dto.question.trim(),
answer: dto.answer.trim(),
category: emptyToNull(dto.category ?? null),
isPublished: dto.isPublished ?? true,
position: dto.position ?? 0,
},
});
return toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateFaqArticleRequest,
): Promise<FaqArticleDto> {
await this.ensureExists(organizationId, id);
const data: Prisma.FaqArticleUpdateInput = {};
if (dto.question !== undefined) data.question = dto.question.trim();
if (dto.answer !== undefined) data.answer = dto.answer.trim();
if ('category' in dto) data.category = emptyToNull(dto.category ?? null);
if (dto.isPublished !== undefined) data.isPublished = dto.isPublished;
if (dto.position !== undefined) data.position = dto.position;
const row = await this.prisma.faqArticle.update({ where: { id }, data });
return toDto(row);
}
async remove(organizationId: string, id: string): Promise<void> {
await this.ensureExists(organizationId, id);
await this.prisma.faqArticle.delete({ where: { id } });
}
private async ensureExists(organizationId: string, id: string) {
const found = await this.prisma.faqArticle.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!found) throw new NotFoundException('مقاله FAQ پیدا نشد');
}
}
function toDto(row: {
id: string;
question: string;
answer: string;
category: string | null;
isPublished: boolean;
position: number;
createdAt: Date;
updatedAt: Date;
}): FaqArticleDto {
return {
id: row.id,
question: row.question,
answer: row.answer,
category: row.category,
isPublished: row.isPublished,
position: row.position,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
+14
View File
@@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import type { HealthResponse } from '@mento/shared';
@Controller('health')
export class HealthController {
@Get()
getHealth(): HealthResponse {
return {
status: 'ok',
service: 'mento-api',
timestamp: new Date().toISOString(),
};
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}
+257
View File
@@ -0,0 +1,257 @@
import {
ArrayMinSize,
IsArray,
IsIn,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
const INVOICE_STATUSES = [
'draft',
'issued',
'partially_paid',
'paid',
'canceled',
] as const;
const PAYMENT_METHODS = [
'cash',
'transfer',
'card',
'check',
'other',
] as const;
export class InvoiceLineItemInputDto {
@IsOptional()
@IsString()
productId?: string | null;
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
quantity!: number;
@Type(() => Number)
@IsNumber()
@Min(0)
unitPrice!: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
}
export class InvoiceInstallmentInputDto {
@IsString()
dueDate!: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
amount!: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
}
export class CreateInvoiceDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@IsIn(INVOICE_STATUSES)
status?: (typeof INVOICE_STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string;
@IsOptional()
@IsString()
issuedAt?: string | null;
@IsOptional()
@IsString()
dueDate?: string | null;
@IsOptional()
@IsString()
personId?: string | null;
@IsOptional()
@IsString()
companyId?: string | null;
@IsOptional()
@IsString()
dealId?: string | null;
@IsOptional()
@IsString()
quoteId?: string | null;
@IsOptional()
@IsString()
fromDealId?: string | null;
@IsOptional()
@IsString()
fromQuoteId?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceLineItemInputDto)
@ArrayMinSize(0)
lineItems?: InvoiceLineItemInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceInstallmentInputDto)
installments?: InvoiceInstallmentInputDto[];
}
export class UpdateInvoiceDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@IsIn(INVOICE_STATUSES)
status?: (typeof INVOICE_STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string | null;
@IsOptional()
@IsString()
issuedAt?: string | null;
@IsOptional()
@IsString()
dueDate?: string | null;
@IsOptional()
@IsString()
personId?: string | null;
@IsOptional()
@IsString()
companyId?: string | null;
@IsOptional()
@IsString()
dealId?: string | null;
@IsOptional()
@IsString()
quoteId?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceLineItemInputDto)
lineItems?: InvoiceLineItemInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceInstallmentInputDto)
installments?: InvoiceInstallmentInputDto[];
}
export class CreatePaymentDto {
@Type(() => Number)
@IsNumber()
@Min(0.01)
amount!: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsIn(PAYMENT_METHODS)
method?: (typeof PAYMENT_METHODS)[number];
@IsOptional()
@IsString()
paidAt?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsString()
installmentId?: string | null;
}
export class ListInvoicesQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsIn(INVOICE_STATUSES)
status?: (typeof INVOICE_STATUSES)[number];
@IsOptional()
@IsString()
dealId?: string;
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
quoteId?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number;
}
@@ -0,0 +1,97 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateInvoiceDto,
CreatePaymentDto,
ListInvoicesQueryDto,
UpdateInvoiceDto,
} from './dto';
import { InvoicesService } from './invoices.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('invoices')
@UseGuards(JwtAuthGuard)
export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query() query: ListInvoicesQueryDto,
) {
return this.invoicesService.list(user.organizationId, user, {
q: query.q,
status: query.status,
dealId: query.dealId,
personId: query.personId,
quoteId: query.quoteId,
page: query.page ?? 1,
limit: query.limit ?? 50,
});
}
@Get(':id')
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.invoicesService.get(user.organizationId, id, user);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateInvoiceDto) {
return this.invoicesService.create(user.organizationId, user.userId, dto);
}
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateInvoiceDto,
) {
return this.invoicesService.update(user.organizationId, id, user, dto);
}
@Delete(':id')
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.invoicesService.remove(user.organizationId, id, user);
return { ok: true };
}
@Post(':id/payments')
addPayment(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: CreatePaymentDto,
) {
return this.invoicesService.addPayment(
user.organizationId,
id,
user,
dto,
);
}
@Delete(':id/payments/:paymentId')
removePayment(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('paymentId') paymentId: string,
) {
return this.invoicesService.removePayment(
user.organizationId,
id,
paymentId,
user,
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
@Module({
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
})
export class InvoicesModule {}
+641
View File
@@ -0,0 +1,641 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateInvoiceRequest,
CreatePaymentRequest,
DealLineItemInput,
InvoiceDto,
InvoiceInstallmentInput,
PaginatedResponse,
PaymentDto,
UpdateInvoiceRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
quote: { select: { id: true, number: true, title: true } },
owner: { select: { id: true, fullName: true } },
lineItems: { orderBy: { position: 'asc' as const } },
payments: { orderBy: { paidAt: 'desc' as const } },
installments: {
orderBy: { position: 'asc' as const },
include: { payment: { select: { id: true } } },
},
} satisfies Prisma.InvoiceInclude;
type InvoiceRow = Prisma.InvoiceGetPayload<{ include: typeof include }>;
@Injectable()
export class InvoicesService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
actor: InvoiceActor,
opts: {
q?: string;
status?: string;
dealId?: string;
personId?: string;
quoteId?: string;
page?: number;
limit?: number;
},
): Promise<PaginatedResponse<InvoiceDto>> {
const page = opts.page ?? 1;
const limit = opts.limit ?? 50;
const where: Prisma.InvoiceWhereInput = {
organizationId,
...ownerWhere(actor),
...(opts.status ? { status: opts.status as never } : {}),
...(opts.dealId ? { dealId: opts.dealId } : {}),
...(opts.personId ? { personId: opts.personId } : {}),
...(opts.quoteId ? { quoteId: opts.quoteId } : {}),
...(opts.q?.trim()
? {
OR: [
{ title: { contains: opts.q.trim(), mode: 'insensitive' } },
{ number: { contains: opts.q.trim(), mode: 'insensitive' } },
{ notes: { contains: opts.q.trim(), mode: 'insensitive' } },
],
}
: {}),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.invoice.findMany({
where,
include,
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.invoice.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
async get(
organizationId: string,
id: string,
actor: InvoiceActor,
): Promise<InvoiceDto> {
const row = await this.prisma.invoice.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
include,
});
if (!row) throw new NotFoundException('فاکتور پیدا نشد');
return this.toDto(row);
}
async create(
organizationId: string,
userId: string,
dto: CreateInvoiceRequest,
): Promise<InvoiceDto> {
let title = dto.title.trim();
let personId = emptyToNull(dto.personId ?? null);
let companyId = emptyToNull(dto.companyId ?? null);
let dealId = emptyToNull(dto.dealId ?? null);
let quoteId = emptyToNull(dto.quoteId ?? null);
let lineItems = dto.lineItems ?? [];
let currency = (dto.currency ?? 'IRR').trim() || 'IRR';
let notes = emptyToNull(dto.notes ?? null);
let dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
let issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
let status = (dto.status as never) || 'draft';
const fromQuoteId = emptyToNull(dto.fromQuoteId ?? null);
if (fromQuoteId) {
const quote = await this.prisma.quote.findFirst({
where: { id: fromQuoteId, organizationId },
include: { lineItems: { orderBy: { position: 'asc' } } },
});
if (!quote) throw new BadRequestException('پیش‌فاکتور مبدأ معتبر نیست');
quoteId = quote.id;
dealId = dealId ?? quote.dealId;
personId = personId ?? quote.personId;
companyId = companyId ?? quote.companyId;
if (!title || title === 'فاکتور') {
title = `فاکتور — ${quote.title}`;
}
currency = quote.currency || currency;
notes = notes ?? quote.notes;
if (!lineItems.length && quote.lineItems.length) {
lineItems = quote.lineItems.map((item) => ({
productId: item.productId,
name: item.name,
quantity: Number(item.quantity),
unitPrice: Number(item.unitPrice),
currency: item.currency,
}));
}
}
const fromDealId = emptyToNull(dto.fromDealId ?? null);
if (fromDealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: fromDealId, organizationId },
include: { lineItems: { orderBy: { position: 'asc' } } },
});
if (!deal) throw new BadRequestException('معامله مبدأ معتبر نیست');
dealId = deal.id;
personId = personId ?? deal.personId;
companyId = companyId ?? deal.companyId;
if (!title || title === 'فاکتور') {
title = `فاکتور — ${deal.title}`;
}
currency = deal.currency || currency;
notes = notes ?? deal.notes;
if (!lineItems.length && deal.lineItems.length) {
lineItems = deal.lineItems.map((item) => ({
productId: item.productId,
name: item.name,
quantity: Number(item.quantity),
unitPrice: Number(item.unitPrice),
currency: item.currency,
}));
}
}
if (status !== 'draft' && !issuedAt) {
issuedAt = new Date();
}
await this.assertLinks(
organizationId,
personId,
companyId,
dealId,
quoteId,
);
if (lineItems.length) {
await this.assertProducts(organizationId, lineItems);
}
const number = await this.nextNumber(organizationId);
const installments = dto.installments ?? [];
const row = await this.prisma.invoice.create({
data: {
organizationId,
number,
title,
status,
currency,
notes,
issuedAt,
dueDate,
personId,
companyId,
dealId,
quoteId,
ownerId: userId,
...(lineItems.length
? {
lineItems: {
create: lineItems.map((item, index) =>
this.lineCreate(item, index, currency),
),
},
}
: {}),
...(installments.length
? {
installments: {
create: installments.map((item, index) =>
this.installmentCreate(item, index, currency),
),
},
}
: {}),
},
include,
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
actor: InvoiceActor,
dto: UpdateInvoiceRequest,
): Promise<InvoiceDto> {
const existing = await this.prisma.invoice.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
select: { id: true, status: true, currency: true },
});
if (!existing) throw new NotFoundException('فاکتور پیدا نشد');
if (
'personId' in dto ||
'companyId' in dto ||
'dealId' in dto ||
'quoteId' in dto
) {
await this.assertLinks(
organizationId,
emptyToNull(dto.personId ?? null),
emptyToNull(dto.companyId ?? null),
emptyToNull(dto.dealId ?? null),
emptyToNull(dto.quoteId ?? null),
);
}
if (dto.lineItems?.length) {
await this.assertProducts(organizationId, dto.lineItems);
}
const data: Prisma.InvoiceUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.status !== undefined) data.status = dto.status as never;
if (dto.currency !== undefined) {
data.currency = dto.currency.trim() || 'IRR';
}
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if ('issuedAt' in dto) {
data.issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
}
if ('dueDate' in dto) {
data.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('quoteId' in dto) data.quoteId = emptyToNull(dto.quoteId ?? null);
if (
dto.status &&
dto.status !== 'draft' &&
dto.status !== 'canceled' &&
!('issuedAt' in dto)
) {
data.issuedAt = new Date();
}
const currency =
(typeof data.currency === 'string' && data.currency) ||
existing.currency ||
'IRR';
await this.prisma.$transaction(async (tx) => {
if (dto.lineItems) {
await tx.invoiceLineItem.deleteMany({ where: { invoiceId: id } });
data.lineItems = {
create: dto.lineItems.map((item, index) =>
this.lineCreate(item, index, currency),
),
};
}
if (dto.installments) {
const linkedPayments = await tx.payment.count({
where: { invoiceId: id, installmentId: { not: null } },
});
if (linkedPayments > 0) {
throw new BadRequestException(
'برای تغییر اقساط، ابتدا پرداخت‌های متصل به قسط را حذف کنید',
);
}
await tx.invoiceInstallment.deleteMany({ where: { invoiceId: id } });
data.installments = {
create: dto.installments.map((item, index) =>
this.installmentCreate(item, index, currency),
),
};
}
if (Object.keys(data).length) {
await tx.invoice.update({ where: { id }, data });
}
});
if (dto.status === undefined || dto.status === 'issued') {
await this.recomputeStatus(id);
}
return this.get(organizationId, id, actor);
}
async remove(
organizationId: string,
id: string,
actor: InvoiceActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
await this.prisma.invoice.delete({ where: { id } });
}
async addPayment(
organizationId: string,
invoiceId: string,
actor: InvoiceActor,
dto: CreatePaymentRequest,
): Promise<InvoiceDto> {
const invoice = await this.prisma.invoice.findFirst({
where: { id: invoiceId, organizationId, ...ownerWhere(actor) },
select: { id: true, status: true, currency: true },
});
if (!invoice) throw new NotFoundException('فاکتور پیدا نشد');
if (invoice.status === 'canceled') {
throw new BadRequestException('روی فاکتور لغوشده نمی‌توان پرداخت ثبت کرد');
}
const installmentId = emptyToNull(dto.installmentId ?? null);
if (installmentId) {
const installment = await this.prisma.invoiceInstallment.findFirst({
where: { id: installmentId, invoiceId },
include: { payment: true },
});
if (!installment) throw new BadRequestException('قسط معتبر نیست');
if (installment.payment) {
throw new BadRequestException('برای این قسط قبلاً پرداخت ثبت شده');
}
}
await this.prisma.$transaction(async (tx) => {
await tx.payment.create({
data: {
invoiceId,
amount: new Prisma.Decimal(dto.amount),
currency: (dto.currency ?? invoice.currency).trim() || 'IRR',
method: (dto.method as never) || 'transfer',
paidAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
notes: emptyToNull(dto.notes ?? null),
installmentId,
},
});
if (installmentId) {
await tx.invoiceInstallment.update({
where: { id: installmentId },
data: {
status: 'paid',
paidAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
},
});
}
if (invoice.status === 'draft') {
await tx.invoice.update({
where: { id: invoiceId },
data: { status: 'issued', issuedAt: new Date() },
});
}
});
await this.recomputeStatus(invoiceId);
return this.get(organizationId, invoiceId, actor);
}
async removePayment(
organizationId: string,
invoiceId: string,
paymentId: string,
actor: InvoiceActor,
): Promise<InvoiceDto> {
await this.ensureExists(organizationId, invoiceId, actor);
const payment = await this.prisma.payment.findFirst({
where: { id: paymentId, invoiceId },
});
if (!payment) throw new NotFoundException('پرداخت پیدا نشد');
await this.prisma.$transaction(async (tx) => {
if (payment.installmentId) {
await tx.invoiceInstallment.update({
where: { id: payment.installmentId },
data: { status: 'pending', paidAt: null },
});
}
await tx.payment.delete({ where: { id: paymentId } });
});
await this.recomputeStatus(invoiceId);
return this.get(organizationId, invoiceId, actor);
}
private async recomputeStatus(invoiceId: string) {
const invoice = await this.prisma.invoice.findFirst({
where: { id: invoiceId },
include: {
lineItems: true,
payments: true,
},
});
if (!invoice || invoice.status === 'canceled' || invoice.status === 'draft') {
return;
}
const total = invoice.lineItems.reduce(
(sum, item) => sum + Number(item.quantity) * Number(item.unitPrice),
0,
);
const paid = invoice.payments.reduce(
(sum, item) => sum + Number(item.amount),
0,
);
let status: 'issued' | 'partially_paid' | 'paid' = 'issued';
if (paid <= 0) status = 'issued';
else if (paid + 0.0001 >= total && total > 0) status = 'paid';
else if (paid > 0) status = 'partially_paid';
else status = 'issued';
if (invoice.status !== status) {
await this.prisma.invoice.update({
where: { id: invoiceId },
data: { status },
});
}
}
private lineCreate(
item: DealLineItemInput,
index: number,
currency: string,
) {
return {
productId: emptyToNull(item.productId ?? null),
name: item.name.trim(),
quantity: new Prisma.Decimal(item.quantity),
unitPrice: new Prisma.Decimal(item.unitPrice),
currency: (item.currency ?? currency).trim() || 'IRR',
position: index,
};
}
private installmentCreate(
item: InvoiceInstallmentInput,
index: number,
currency: string,
) {
return {
position: index,
dueDate: new Date(item.dueDate),
amount: new Prisma.Decimal(item.amount),
currency: (item.currency ?? currency).trim() || 'IRR',
status: 'pending' as const,
};
}
private async nextNumber(organizationId: string): Promise<string> {
const count = await this.prisma.invoice.count({ where: { organizationId } });
return `FA-${String(count + 1).padStart(4, '0')}`;
}
private async ensureExists(
organizationId: string,
id: string,
actor: InvoiceActor,
) {
const found = await this.prisma.invoice.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('فاکتور پیدا نشد');
}
private async assertLinks(
organizationId: string,
personId: string | null,
companyId: string | null,
dealId: string | null,
quoteId: string | null,
) {
if (personId) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص معتبر نیست');
}
if (companyId) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, organizationId },
select: { id: true },
});
if (!company) throw new BadRequestException('شرکت معتبر نیست');
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله معتبر نیست');
}
if (quoteId) {
const quote = await this.prisma.quote.findFirst({
where: { id: quoteId, organizationId },
select: { id: true },
});
if (!quote) throw new BadRequestException('پیش‌فاکتور معتبر نیست');
}
}
private async assertProducts(
organizationId: string,
lines: DealLineItemInput[],
) {
const ids = [
...new Set(
lines
.map((l) => emptyToNull(l.productId ?? null))
.filter((id): id is string => Boolean(id)),
),
];
if (!ids.length) return;
const count = await this.prisma.product.count({
where: { organizationId, id: { in: ids } },
});
if (count !== ids.length) {
throw new BadRequestException('یکی از محصولات معتبر نیست');
}
}
private toDto(row: InvoiceRow): InvoiceDto {
const lineItems = row.lineItems.map((item) => {
const quantity = Number(item.quantity);
const unitPrice = Number(item.unitPrice);
return {
id: item.id,
productId: item.productId,
name: item.name,
quantity,
unitPrice,
currency: item.currency,
position: item.position,
lineTotal: quantity * unitPrice,
};
});
const totalAmount = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
const payments: PaymentDto[] = row.payments.map((p) => ({
id: p.id,
amount: Number(p.amount),
currency: p.currency,
method: p.method,
paidAt: p.paidAt.toISOString(),
notes: p.notes,
installmentId: p.installmentId,
createdAt: p.createdAt.toISOString(),
}));
const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0);
const installments = row.installments.map((item) => ({
id: item.id,
position: item.position,
dueDate: item.dueDate.toISOString(),
amount: Number(item.amount),
currency: item.currency,
status: item.status,
paidAt: item.paidAt?.toISOString() ?? null,
paymentId: item.payment?.id ?? null,
}));
return {
id: row.id,
number: row.number,
title: row.title,
status: row.status,
currency: row.currency,
notes: row.notes,
issuedAt: row.issuedAt?.toISOString() ?? null,
dueDate: row.dueDate?.toISOString() ?? null,
totalAmount,
paidAmount,
remainingAmount: Math.max(0, totalAmount - paidAmount),
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
quote: row.quote,
owner: row.owner,
lineItems,
payments,
installments,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
+155
View File
@@ -0,0 +1,155 @@
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
const FIELD_TYPES = ['text', 'email', 'phone', 'textarea'] as const;
const MAP_TO = [
'person.firstName',
'person.lastName',
'person.email',
'person.mobile',
'person.phone',
'person.position',
'deal.title',
'deal.notes',
'notes',
] as const;
export class LeadFormFieldInputDto {
@IsOptional()
@IsString()
@MaxLength(64)
key?: string;
@IsString()
@MinLength(1)
@MaxLength(120)
label!: string;
@IsIn(FIELD_TYPES)
fieldType!: (typeof FIELD_TYPES)[number];
@IsOptional()
@IsBoolean()
required?: boolean;
@IsIn(MAP_TO)
mapTo!: (typeof MAP_TO)[number];
@IsOptional()
position?: number;
}
export class CreateLeadFormDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsBoolean()
createPerson?: boolean;
@IsOptional()
@IsBoolean()
createDeal?: boolean;
@IsOptional()
@IsString()
pipelineId?: string | null;
@IsOptional()
@IsString()
@MaxLength(80)
source?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
thankYouMessage?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
redirectUrl?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(50)
assigneeIds?: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeadFormFieldInputDto)
@ArrayMaxSize(30)
fields?: LeadFormFieldInputDto[];
}
export class UpdateLeadFormDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsBoolean()
createPerson?: boolean;
@IsOptional()
@IsBoolean()
createDeal?: boolean;
@IsOptional()
@IsString()
pipelineId?: string | null;
@IsOptional()
@IsString()
@MaxLength(80)
source?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
thankYouMessage?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
redirectUrl?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(50)
assigneeIds?: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeadFormFieldInputDto)
@ArrayMaxSize(30)
fields?: LeadFormFieldInputDto[];
}
export class SubmitLeadFormDto {
@IsObject()
values!: Record<string, string>;
}
@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
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 { CreateLeadFormDto, UpdateLeadFormDto } from './dto';
import { LeadFormsService } from './lead-forms.service';
@Controller('lead-forms')
@UseGuards(JwtAuthGuard, RolesGuard)
export class LeadFormsController {
constructor(private readonly leadFormsService: LeadFormsService) {}
@Get()
@Roles('owner', 'admin')
list(@CurrentUser() user: { organizationId: string }) {
return this.leadFormsService.list(user.organizationId);
}
@Get(':id')
@Roles('owner', 'admin')
get(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
return this.leadFormsService.get(user.organizationId, id);
}
@Post()
@Roles('owner', 'admin')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateLeadFormDto,
) {
return this.leadFormsService.create(user.organizationId, dto);
}
@Patch(':id')
@Roles('owner', 'admin')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateLeadFormDto,
) {
return this.leadFormsService.update(user.organizationId, id, dto);
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.leadFormsService.remove(user.organizationId, id);
return { ok: true };
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { LeadFormsController } from './lead-forms.controller';
import { LeadFormsService } from './lead-forms.service';
import { PublicLeadFormsController } from './public-lead-forms.controller';
@Module({
imports: [AuthModule],
controllers: [LeadFormsController, PublicLeadFormsController],
providers: [LeadFormsService],
exports: [LeadFormsService],
})
export class LeadFormsModule {}
@@ -0,0 +1,525 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateLeadFormRequest,
LeadFormDto,
LeadFormFieldInput,
PublicLeadFormDto,
SubmitLeadFormResponse,
UpdateLeadFormRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
emptyToNull,
normalizeEmail,
normalizePhone,
} from '../contacts/normalize';
const DEFAULT_FIELDS: LeadFormFieldInput[] = [
{
key: 'firstName',
label: 'نام',
fieldType: 'text',
required: true,
mapTo: 'person.firstName',
position: 0,
},
{
key: 'lastName',
label: 'نام خانوادگی',
fieldType: 'text',
required: true,
mapTo: 'person.lastName',
position: 1,
},
{
key: 'mobile',
label: 'موبایل',
fieldType: 'phone',
required: true,
mapTo: 'person.mobile',
position: 2,
},
{
key: 'email',
label: 'ایمیل',
fieldType: 'email',
required: false,
mapTo: 'person.email',
position: 3,
},
{
key: 'message',
label: 'پیام',
fieldType: 'textarea',
required: false,
mapTo: 'deal.notes',
position: 4,
},
];
type FormRow = Prisma.LeadFormGetPayload<{ include: { fields: true } }>;
@Injectable()
export class LeadFormsService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<LeadFormDto[]> {
const rows = await this.prisma.leadForm.findMany({
where: { organizationId },
include: { fields: { orderBy: { position: 'asc' } } },
orderBy: { updatedAt: 'desc' },
});
return rows.map((row) => this.toDto(row));
}
async get(organizationId: string, id: string): Promise<LeadFormDto> {
const row = await this.findInOrg(organizationId, id);
return this.toDto(row);
}
async create(
organizationId: string,
dto: CreateLeadFormRequest,
): Promise<LeadFormDto> {
await this.assertPipeline(organizationId, dto.pipelineId);
await this.assertAssignees(organizationId, dto.assigneeIds);
const fields = normalizeFields(dto.fields?.length ? dto.fields : DEFAULT_FIELDS);
const row = await this.prisma.leadForm.create({
data: {
organizationId,
name: dto.name.trim(),
createPerson: dto.createPerson ?? true,
createDeal: dto.createDeal ?? true,
pipelineId: emptyToNull(dto.pipelineId ?? null),
source: emptyToNull(dto.source ?? null) ?? 'فرم وب',
thankYouMessage:
emptyToNull(dto.thankYouMessage ?? null) ??
'پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.',
redirectUrl: emptyToNull(dto.redirectUrl ?? null),
assigneeIds: dto.assigneeIds ?? [],
fields: {
create: fields.map((f, index) => ({
key: f.key!,
label: f.label,
fieldType: f.fieldType,
required: f.required ?? false,
mapTo: f.mapTo,
position: f.position ?? index,
})),
},
},
include: { fields: { orderBy: { position: 'asc' } } },
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateLeadFormRequest,
): Promise<LeadFormDto> {
await this.findInOrg(organizationId, id);
if ('pipelineId' in dto) {
await this.assertPipeline(organizationId, dto.pipelineId);
}
if (dto.assigneeIds) {
await this.assertAssignees(organizationId, dto.assigneeIds);
}
const data: Prisma.LeadFormUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if (dto.isActive !== undefined) data.isActive = dto.isActive;
if (dto.createPerson !== undefined) data.createPerson = dto.createPerson;
if (dto.createDeal !== undefined) data.createDeal = dto.createDeal;
if ('pipelineId' in dto) data.pipelineId = emptyToNull(dto.pipelineId ?? null);
if ('source' in dto) data.source = emptyToNull(dto.source ?? null);
if ('thankYouMessage' in dto) {
data.thankYouMessage = emptyToNull(dto.thankYouMessage ?? null);
}
if ('redirectUrl' in dto) {
data.redirectUrl = emptyToNull(dto.redirectUrl ?? null);
}
if (dto.assigneeIds !== undefined) data.assigneeIds = dto.assigneeIds;
if (dto.fields) {
const fields = normalizeFields(dto.fields);
await this.prisma.$transaction(async (tx) => {
await tx.leadFormField.deleteMany({ where: { formId: id } });
await tx.leadForm.update({
where: { id },
data: {
...data,
fields: {
create: fields.map((f, index) => ({
key: f.key!,
label: f.label,
fieldType: f.fieldType,
required: f.required ?? false,
mapTo: f.mapTo,
position: f.position ?? index,
})),
},
},
});
});
} else if (Object.keys(data).length) {
await this.prisma.leadForm.update({ where: { id }, data });
}
return this.get(organizationId, id);
}
async remove(organizationId: string, id: string): Promise<void> {
await this.findInOrg(organizationId, id);
await this.prisma.leadForm.delete({ where: { id } });
}
async getPublic(token: string): Promise<PublicLeadFormDto> {
const row = await this.prisma.leadForm.findFirst({
where: { publicToken: token, isActive: true },
include: { fields: { orderBy: { position: 'asc' } } },
});
if (!row) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
return {
name: row.name,
thankYouMessage: row.thankYouMessage,
fields: row.fields.map((f) => ({
key: f.key,
label: f.label,
fieldType: f.fieldType,
required: f.required,
})),
};
}
async submit(
token: string,
values: Record<string, string>,
): Promise<SubmitLeadFormResponse> {
const form = await this.prisma.leadForm.findFirst({
where: { publicToken: token, isActive: true },
include: { fields: { orderBy: { position: 'asc' } } },
});
if (!form) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
const mapped = mapValues(form.fields, values);
if (form.createPerson) {
if (!mapped.firstName?.trim() && !mapped.lastName?.trim()) {
throw new BadRequestException('نام یا نام خانوادگی لازم است');
}
}
const ownerId = await this.nextAssignee(form);
const actorId =
ownerId ??
(
await this.prisma.user.findFirst({
where: { organizationId: form.organizationId, role: 'owner' },
select: { id: true },
})
)?.id;
if (!actorId) {
throw new BadRequestException('سازمان کاربر فعالی برای ثبت سرنخ ندارد');
}
let personId: string | null = null;
if (form.createPerson) {
personId = await this.upsertPerson(
form.organizationId,
mapped,
ownerId ?? actorId,
);
}
if (form.createDeal) {
await this.createDeal(form, mapped, personId, actorId, ownerId);
} else if (personId && mapped.notes) {
await this.prisma.person.update({
where: { id: personId },
data: {
notes: mapped.notes,
},
});
}
return {
ok: true,
thankYouMessage: form.thankYouMessage,
redirectUrl: form.redirectUrl,
};
}
private async upsertPerson(
organizationId: string,
mapped: MappedValues,
ownerId: string,
): Promise<string> {
const email = normalizeEmail(mapped.email);
const mobile = normalizePhone(mapped.mobile);
const phone = normalizePhone(mapped.phone);
const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email });
if (mobile) or.push({ mobile });
const existing = or.length
? await this.prisma.person.findFirst({
where: { organizationId, OR: or },
})
: null;
if (existing) {
await this.prisma.person.update({
where: { id: existing.id },
data: {
firstName: mapped.firstName?.trim() || existing.firstName,
lastName: mapped.lastName?.trim() || existing.lastName,
email: email ?? existing.email,
mobile: mobile ?? existing.mobile,
phone: phone ?? existing.phone,
position: emptyToNull(mapped.position) ?? existing.position,
notes: emptyToNull(mapped.notes) ?? existing.notes,
ownerId: existing.ownerId ?? ownerId,
},
});
return existing.id;
}
const created = await this.prisma.person.create({
data: {
organizationId,
firstName: (mapped.firstName?.trim() || 'سرنخ').slice(0, 80),
lastName: (mapped.lastName?.trim() || 'جدید').slice(0, 80),
email,
mobile,
phone,
position: emptyToNull(mapped.position),
notes: emptyToNull(mapped.notes),
ownerId,
},
});
return created.id;
}
private async createDeal(
form: FormRow,
mapped: MappedValues,
personId: string | null,
actorId: string,
ownerId: string | null,
) {
const pipeline =
(form.pipelineId
? await this.prisma.pipeline.findFirst({
where: { id: form.pipelineId, organizationId: form.organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
})
: null) ??
(await this.prisma.pipeline.findFirst({
where: { organizationId: form.organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
}));
if (!pipeline?.stages.length) {
throw new BadRequestException('کاریز فعالی برای ثبت معامله وجود ندارد');
}
const stage =
pipeline.stages.find((s) => !s.isWon && !s.isLost) ?? pipeline.stages[0];
const title =
mapped.dealTitle?.trim() ||
[mapped.firstName, mapped.lastName].filter(Boolean).join(' ').trim() ||
`سرنخ از ${form.name}`;
const maxPos = await this.prisma.deal.aggregate({
where: { organizationId: form.organizationId, stageId: stage.id },
_max: { position: true },
});
await this.prisma.deal.create({
data: {
organizationId: form.organizationId,
pipelineId: pipeline.id,
stageId: stage.id,
title: title.slice(0, 200),
source: form.source ?? 'فرم وب',
notes: emptyToNull(mapped.dealNotes ?? mapped.notes),
personId,
ownerId: ownerId ?? actorId,
position: (maxPos._max.position ?? -1) + 1,
status: 'open',
currency: 'IRR',
},
});
}
private async nextAssignee(form: FormRow): Promise<string | null> {
const ids = form.assigneeIds.filter(Boolean);
if (!ids.length) return null;
const index = form.roundRobinIndex % ids.length;
const chosen = ids[index];
await this.prisma.leadForm.update({
where: { id: form.id },
data: { roundRobinIndex: index + 1 },
});
return chosen;
}
private async findInOrg(organizationId: string, id: string): Promise<FormRow> {
const row = await this.prisma.leadForm.findFirst({
where: { id, organizationId },
include: { fields: { orderBy: { position: 'asc' } } },
});
if (!row) throw new NotFoundException('فرم پیدا نشد');
return row;
}
private async assertPipeline(
organizationId: string,
pipelineId?: string | null,
) {
const id = emptyToNull(pipelineId ?? null);
if (!id) return;
const found = await this.prisma.pipeline.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!found) throw new BadRequestException('کاریز انتخاب‌شده معتبر نیست');
}
private async assertAssignees(
organizationId: string,
assigneeIds?: string[],
) {
if (!assigneeIds?.length) return;
const count = await this.prisma.user.count({
where: { organizationId, id: { in: assigneeIds } },
});
if (count !== assigneeIds.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
}
}
private toDto(row: FormRow): LeadFormDto {
return {
id: row.id,
name: row.name,
publicToken: row.publicToken,
isActive: row.isActive,
createPerson: row.createPerson,
createDeal: row.createDeal,
pipelineId: row.pipelineId,
source: row.source,
thankYouMessage: row.thankYouMessage,
redirectUrl: row.redirectUrl,
assigneeIds: row.assigneeIds,
fields: row.fields.map((f) => ({
id: f.id,
key: f.key,
label: f.label,
fieldType: f.fieldType,
required: f.required,
mapTo: f.mapTo,
position: f.position,
})),
publicPath: `/f/${row.publicToken}`,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
type MappedValues = {
firstName?: string;
lastName?: string;
email?: string;
mobile?: string;
phone?: string;
position?: string;
dealTitle?: string;
dealNotes?: string;
notes?: string;
};
function mapValues(
fields: Array<{ key: string; mapTo: string; required: boolean; label: string }>,
values: Record<string, string>,
): MappedValues {
const mapped: MappedValues = {};
for (const field of fields) {
const raw = (values?.[field.key] ?? '').toString().trim();
if (field.required && !raw) {
throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
}
if (!raw) continue;
switch (field.mapTo) {
case 'person.firstName':
mapped.firstName = raw;
break;
case 'person.lastName':
mapped.lastName = raw;
break;
case 'person.email':
mapped.email = raw;
break;
case 'person.mobile':
mapped.mobile = raw;
break;
case 'person.phone':
mapped.phone = raw;
break;
case 'person.position':
mapped.position = raw;
break;
case 'deal.title':
mapped.dealTitle = raw;
break;
case 'deal.notes':
mapped.dealNotes = raw;
break;
case 'notes':
mapped.notes = raw;
break;
default:
break;
}
}
return mapped;
}
function normalizeFields(fields: LeadFormFieldInput[]): LeadFormFieldInput[] {
return fields.map((f, index) => {
const key =
f.key?.trim() ||
slugKey(f.label) ||
`field_${index + 1}`;
return {
...f,
key: key.slice(0, 64),
label: f.label.trim(),
fieldType: f.fieldType || 'text',
required: f.required ?? false,
mapTo: f.mapTo,
position: f.position ?? index,
};
});
}
function slugKey(label: string): string {
return label
.trim()
.toLowerCase()
.replace(/[^\w\u0600-\u06FF]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 40);
}
@@ -0,0 +1,18 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { SubmitLeadFormDto } from './dto';
import { LeadFormsService } from './lead-forms.service';
@Controller('public/forms')
export class PublicLeadFormsController {
constructor(private readonly leadFormsService: LeadFormsService) {}
@Get(':token')
get(@Param('token') token: string) {
return this.leadFormsService.getPublic(token);
}
@Post(':token/submit')
submit(@Param('token') token: string, @Body() dto: SubmitLeadFormDto) {
return this.leadFormsService.submit(token, dto.values ?? {});
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
app.enableCors({
origin: config.get<string>('CORS_ORIGIN') ?? 'http://localhost:5173',
credentials: true,
});
const port = Number(config.get<string>('PORT') ?? 3000);
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`Mento API listening on http://localhost:${port}/api`);
}
bootstrap();
+170
View File
@@ -0,0 +1,170 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const NOTE_TYPES = [
'general',
'call_result',
'meeting',
'important',
'warning',
'next_action',
] as const;
export class ListNotesQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
pinned?: boolean;
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
companyId?: string;
@IsOptional()
@IsString()
dealId?: string;
@IsOptional()
@IsString()
tag?: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 50;
}
export class CreateNoteDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsString()
@MinLength(1)
@MaxLength(8000)
body!: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsOptional()
@IsBoolean()
pinned?: boolean;
@IsOptional()
@IsString()
@MaxLength(32)
color?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsDateString()
reminderAt?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
}
export class UpdateNoteDto {
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(200)
title?: string | null;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(8000)
body?: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsOptional()
@IsBoolean()
pinned?: boolean;
@IsOptional()
@IsString()
@MaxLength(32)
color?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsDateString()
reminderAt?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
}
+147
View File
@@ -0,0 +1,147 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto } from './dto';
import { NotesService } from './notes.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('notes')
@UseGuards(JwtAuthGuard)
export class NotesController {
constructor(private readonly notesService: NotesService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query() query: ListNotesQueryDto,
) {
return this.notesService.list(user.organizationId, user, {
q: query.q,
pinned: query.pinned,
personId: query.personId,
companyId: query.companyId,
dealId: query.dealId,
tag: query.tag,
noteType: query.noteType,
page: query.page ?? 1,
limit: query.limit ?? 50,
});
}
@Get(':id')
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.notesService.get(user.organizationId, id, user);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateNoteDto) {
return this.notesService.create(
user.organizationId,
user.userId,
dto,
user,
);
}
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateNoteDto,
) {
return this.notesService.update(user.organizationId, id, user, dto);
}
@Patch(':id/pin')
togglePin(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.notesService.togglePin(user.organizationId, id, user);
}
@Post(':id/convert-to-task')
convertToTask(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.notesService.convertToTask(
user.organizationId,
user.userId,
id,
user,
);
}
@Post(':id/attachments')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
}),
)
uploadAttachment(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.notesService.addAttachment(
user.organizationId,
user.userId,
id,
file,
user,
);
}
@Get(':id/attachments/:attachmentId/download')
async downloadAttachment(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('attachmentId') attachmentId: string,
): Promise<StreamableFile> {
const { stream, fileName, mimeType } =
await this.notesService.openAttachmentDownload(
user.organizationId,
id,
attachmentId,
user,
);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: mimeType,
disposition: `attachment; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
});
}
@Delete(':id/attachments/:attachmentId')
async removeAttachment(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('attachmentId') attachmentId: string,
) {
await this.notesService.removeAttachment(
user.organizationId,
id,
attachmentId,
user,
);
return { ok: true };
}
@Delete(':id')
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.notesService.remove(user.organizationId, id, user);
return { ok: true };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NotesController } from './notes.controller';
import { NotesService } from './notes.service';
@Module({
controllers: [NotesController],
providers: [NotesService],
exports: [NotesService],
})
export class NotesModule {}
+502
View File
@@ -0,0 +1,502 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateNoteRequest,
NoteAttachmentDto,
NoteDto,
PaginatedResponse,
UpdateNoteRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import {
type Actor,
noteAccessWhere,
ownerWhere,
} from '../common/record-scope';
type NoteActor = Pick<Actor, 'userId' | 'role'>;
type NoteRow = {
id: string;
title: string | null;
body: string;
noteType: string;
pinned: boolean;
color: string | null;
tags: string[];
reminderAt: Date | null;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
author: { id: string; fullName: string } | null;
attachments: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
}>;
};
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
author: { select: { id: true, fullName: true } },
attachments: {
select: {
id: true,
fileName: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' as const },
},
} satisfies Prisma.NoteInclude;
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const MAX_ATTACHMENTS_PER_NOTE = 8;
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip',
'application/x-zip-compressed',
]);
@Injectable()
export class NotesService {
constructor(
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
) {}
async list(
organizationId: string,
actor: NoteActor,
opts: {
q?: string;
pinned?: boolean;
personId?: string;
companyId?: string;
dealId?: string;
tag?: string;
noteType?: string;
page?: number;
limit?: number;
},
): Promise<PaginatedResponse<NoteDto>> {
const page = opts.page ?? 1;
const limit = opts.limit ?? 50;
const access = noteAccessWhere(actor);
const search = opts.q?.trim()
? {
OR: [
{ title: { contains: opts.q.trim(), mode: 'insensitive' as const } },
{ body: { contains: opts.q.trim(), mode: 'insensitive' as const } },
{ tags: { has: opts.q.trim() } },
],
}
: null;
const where: Prisma.NoteWhereInput = {
organizationId,
...(opts.pinned != null ? { pinned: opts.pinned } : {}),
...(opts.personId ? { personId: opts.personId } : {}),
...(opts.companyId ? { companyId: opts.companyId } : {}),
...(opts.dealId ? { dealId: opts.dealId } : {}),
...(opts.noteType ? { noteType: opts.noteType } : {}),
...(opts.tag?.trim() ? { tags: { has: opts.tag.trim() } } : {}),
...(search
? { AND: [access, search] }
: access),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.note.findMany({
where,
include,
orderBy: [{ pinned: 'desc' }, { updatedAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.note.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
async get(
organizationId: string,
id: string,
actor: NoteActor,
): Promise<NoteDto> {
const row = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
include,
});
if (!row) throw new NotFoundException('یادداشت پیدا نشد');
return this.toDto(row);
}
async create(
organizationId: string,
userId: string,
dto: CreateNoteRequest,
actor?: NoteActor,
): Promise<NoteDto> {
await this.assertLinks(
organizationId,
actor ?? { userId, role: 'owner' },
dto,
);
const row = await this.prisma.note.create({
data: {
organizationId,
authorId: userId,
title: emptyToNull(dto.title),
body: dto.body.trim(),
noteType: dto.noteType?.trim() || 'general',
pinned: dto.pinned ?? false,
color: emptyToNull(dto.color) ?? 'default',
tags: normalizeTags(dto.tags),
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
},
include,
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
actor: NoteActor,
dto: UpdateNoteRequest,
): Promise<NoteDto> {
await this.ensureExists(organizationId, id, actor);
await this.assertLinks(organizationId, actor, dto);
const data: Prisma.NoteUncheckedUpdateInput = {};
if ('title' in dto) data.title = emptyToNull(dto.title);
if (dto.body !== undefined) data.body = dto.body.trim();
if (dto.noteType !== undefined) data.noteType = dto.noteType.trim() || 'general';
if (dto.pinned !== undefined) data.pinned = dto.pinned;
if ('color' in dto) data.color = emptyToNull(dto.color) ?? 'default';
if (dto.tags !== undefined) data.tags = normalizeTags(dto.tags);
if ('reminderAt' in dto) {
data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
const row = await this.prisma.note.update({
where: { id },
data,
include,
});
return this.toDto(row);
}
async togglePin(
organizationId: string,
id: string,
actor: NoteActor,
): Promise<NoteDto> {
const existing = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
select: { pinned: true },
});
if (!existing) throw new NotFoundException('یادداشت پیدا نشد');
const row = await this.prisma.note.update({
where: { id },
data: { pinned: !existing.pinned },
include,
});
return this.toDto(row);
}
async convertToTask(
organizationId: string,
userId: string,
id: string,
actor: NoteActor,
): Promise<{ note: NoteDto; activityId: string }> {
const note = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
include,
});
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
const activity = await this.prisma.activity.create({
data: {
organizationId,
type: 'task',
title: (note.title?.trim() || note.body.trim()).slice(0, 200),
description: note.body,
dueAt: note.reminderAt,
personId: note.personId,
companyId: note.companyId,
dealId: note.dealId,
assigneeId: userId,
},
});
return { note: this.toDto(note), activityId: activity.id };
}
async remove(
organizationId: string,
id: string,
actor: NoteActor,
): Promise<void> {
const note = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
include: { attachments: { select: { storageKey: true } } },
});
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
for (const att of note.attachments) {
await this.files.remove(att.storageKey);
}
await this.prisma.note.delete({ where: { id } });
}
async addAttachment(
organizationId: string,
userId: string,
noteId: string,
file: Express.Multer.File,
actor: NoteActor,
): Promise<NoteAttachmentDto> {
await this.ensureExists(organizationId, noteId, actor);
if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است');
}
if (file.size > MAX_ATTACHMENT_BYTES) {
throw new BadRequestException('حداکثر حجم فایل ۱۰ مگابایت است');
}
const mimeType = (file.mimetype || '').toLowerCase();
if (!ALLOWED_MIME.has(mimeType)) {
throw new BadRequestException(
'نوع فایل مجاز نیست. تصویر، PDF، Word، Excel، متن یا ZIP بفرستید.',
);
}
const count = await this.prisma.noteAttachment.count({ where: { noteId } });
if (count >= MAX_ATTACHMENTS_PER_NOTE) {
throw new BadRequestException(
`حداکثر ${MAX_ATTACHMENTS_PER_NOTE} پیوست برای هر یادداشت مجاز است`,
);
}
const fileName = sanitizeFileName(file.originalname);
const ext = path.extname(fileName).slice(0, 12);
const attachmentId = randomUUID().replace(/-/g, '').slice(0, 24);
const storageKey = path
.join(organizationId, 'notes', noteId, `${attachmentId}${ext}`)
.replace(/\\/g, '/');
await this.files.save(storageKey, file.buffer);
try {
const row = await this.prisma.noteAttachment.create({
data: {
id: attachmentId,
organizationId,
noteId,
fileName,
mimeType,
sizeBytes: file.size,
storageKey,
uploadedById: userId,
},
});
return this.toAttachmentDto(row);
} catch (err) {
await this.files.remove(storageKey);
throw err;
}
}
async removeAttachment(
organizationId: string,
noteId: string,
attachmentId: string,
actor: NoteActor,
): Promise<void> {
await this.ensureExists(organizationId, noteId, actor);
const row = await this.prisma.noteAttachment.findFirst({
where: { id: attachmentId, noteId, organizationId },
});
if (!row) throw new NotFoundException('پیوست پیدا نشد');
await this.files.remove(row.storageKey);
await this.prisma.noteAttachment.delete({ where: { id: row.id } });
}
async openAttachmentDownload(
organizationId: string,
noteId: string,
attachmentId: string,
actor: NoteActor,
): Promise<{
stream: ReturnType<LocalFileStorage['openReadStream']>;
fileName: string;
mimeType: string;
}> {
await this.ensureExists(organizationId, noteId, actor);
const row = await this.prisma.noteAttachment.findFirst({
where: { id: attachmentId, noteId, organizationId },
});
if (!row) throw new NotFoundException('پیوست پیدا نشد');
return {
stream: this.files.openReadStream(row.storageKey),
fileName: row.fileName,
mimeType: row.mimeType,
};
}
private async ensureExists(
organizationId: string,
id: string,
actor: NoteActor,
) {
const found = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('یادداشت پیدا نشد');
}
private async assertLinks(
organizationId: string,
actor: NoteActor,
dto: {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
},
) {
const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null);
const scope = ownerWhere(actor);
if (personId) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId, ...scope },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص انتخاب‌شده معتبر نیست');
}
if (companyId) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, organizationId, ...scope },
select: { id: true },
});
if (!company) throw new BadRequestException('شرکت انتخاب‌شده معتبر نیست');
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...scope },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
}
}
private toDto(row: NoteRow): NoteDto {
return {
id: row.id,
title: row.title,
body: row.body,
noteType: row.noteType,
pinned: row.pinned,
color: row.color,
tags: row.tags,
reminderAt: row.reminderAt?.toISOString() ?? null,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
author: row.author,
attachments: (row.attachments ?? []).map((a) => this.toAttachmentDto(a)),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private toAttachmentDto(row: {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
}): NoteAttachmentDto {
return {
id: row.id,
fileName: row.fileName,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt.toISOString(),
};
}
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function normalizeTags(tags?: string[]): string[] {
if (!tags?.length) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const tag of tags) {
const cleaned = tag.trim().replace(/^#/, '');
if (!cleaned || seen.has(cleaned)) continue;
seen.add(cleaned);
result.push(cleaned.slice(0, 40));
}
return result.slice(0, 12);
}
function sanitizeFileName(raw: string): string {
const base = path.basename(raw || 'file').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const trimmed = base.trim() || 'file';
return trimmed.slice(0, 180);
}
@@ -0,0 +1,75 @@
import {
Controller,
Get,
Param,
Patch,
Query,
UseGuards,
} from '@nestjs/common';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { NotificationsService } from './notifications.service';
class ListNotificationsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 30;
}
@Controller('notifications')
@UseGuards(JwtAuthGuard)
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
list(
@CurrentUser() user: { organizationId: string; userId: string },
@Query() query: ListNotificationsQueryDto,
) {
return this.notificationsService.list(
user.organizationId,
user.userId,
query.limit ?? 30,
);
}
@Get('unread-count')
async unreadCount(
@CurrentUser() user: { organizationId: string; userId: string },
) {
const count = await this.notificationsService.unreadCount(
user.organizationId,
user.userId,
);
return { count };
}
@Patch('read-all')
async markAllRead(
@CurrentUser() user: { organizationId: string; userId: string },
) {
const count = await this.notificationsService.markAllRead(
user.organizationId,
user.userId,
);
return { ok: true, count };
}
@Patch(':id/read')
async markRead(
@CurrentUser() user: { organizationId: string; userId: string },
@Param('id') id: string,
) {
const item = await this.notificationsService.markRead(
user.organizationId,
user.userId,
id,
);
return item ?? { ok: false };
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
@Module({
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}
@@ -0,0 +1,213 @@
import { Injectable } from '@nestjs/common';
import type { NotificationDto, NotificationsListDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
export type CreateNotificationInput = {
organizationId: string;
userId: string;
type: string;
title: string;
body?: string | null;
link?: string | null;
entityType?: string | null;
entityId?: string | null;
/** If true, skip when an unread notification with same type+entityId exists for user */
dedupe?: boolean;
};
@Injectable()
export class NotificationsService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
userId: string,
limit = 30,
): Promise<NotificationsListDto> {
await this.syncDueItems(organizationId, userId);
const [items, unreadCount] = await this.prisma.$transaction([
this.prisma.notification.findMany({
where: { organizationId, userId },
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(limit, 1), 100),
}),
this.prisma.notification.count({
where: { organizationId, userId, readAt: null },
}),
]);
return {
items: items.map((row) => this.toDto(row)),
unreadCount,
};
}
async unreadCount(organizationId: string, userId: string): Promise<number> {
await this.syncDueItems(organizationId, userId);
return this.prisma.notification.count({
where: { organizationId, userId, readAt: null },
});
}
async markRead(
organizationId: string,
userId: string,
id: string,
): Promise<NotificationDto | null> {
const existing = await this.prisma.notification.findFirst({
where: { id, organizationId, userId },
});
if (!existing) return null;
if (existing.readAt) return this.toDto(existing);
const row = await this.prisma.notification.update({
where: { id },
data: { readAt: new Date() },
});
return this.toDto(row);
}
async markAllRead(organizationId: string, userId: string): Promise<number> {
const result = await this.prisma.notification.updateMany({
where: { organizationId, userId, readAt: null },
data: { readAt: new Date() },
});
return result.count;
}
async create(input: CreateNotificationInput): Promise<boolean> {
if (input.dedupe && input.entityId) {
const existing = await this.prisma.notification.findFirst({
where: {
userId: input.userId,
type: input.type,
entityId: input.entityId,
},
select: { id: true },
});
if (existing) return false;
}
await this.prisma.notification.create({
data: {
organizationId: input.organizationId,
userId: input.userId,
type: input.type,
title: input.title,
body: input.body ?? null,
link: input.link ?? null,
entityType: input.entityType ?? null,
entityId: input.entityId ?? null,
},
});
return true;
}
/** Create overdue/reminder notifications for the current user (idempotent). */
async syncDueItems(organizationId: string, userId: string): Promise<void> {
await this.collapseDuplicates(userId);
const now = new Date();
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const overdue = await this.prisma.activity.findMany({
where: {
organizationId,
assigneeId: userId,
status: 'open',
dueAt: { lt: startOfToday },
},
select: { id: true, title: true },
take: 20,
});
for (const activity of overdue) {
await this.create({
organizationId,
userId,
type: 'activity_overdue',
title: 'فعالیت عقب‌افتاده',
body: activity.title,
link: `/activities/${activity.id}`,
entityType: 'activity',
entityId: activity.id,
dedupe: true,
});
}
const dueNotes = await this.prisma.note.findMany({
where: {
organizationId,
authorId: userId,
reminderAt: { lte: now },
},
select: { id: true, title: true, body: true },
take: 20,
});
for (const note of dueNotes) {
await this.create({
organizationId,
userId,
type: 'note_reminder',
title: 'یادآوری یادداشت',
body: note.title || note.body.slice(0, 80),
link: '/notes',
entityType: 'note',
entityId: note.id,
dedupe: true,
});
// یادآور فقط یک‌بار شلیک می‌شود؛ تکرار بعد از خواندن نباید رخ دهد
await this.prisma.note.update({
where: { id: note.id },
data: { reminderAt: null },
});
}
}
/** Keep newest notification per type+entity; drop repeated sync spam. */
private async collapseDuplicates(userId: string): Promise<void> {
const rows = await this.prisma.notification.findMany({
where: { userId, entityId: { not: null } },
orderBy: { createdAt: 'desc' },
select: { id: true, type: true, entityId: true },
});
const seen = new Set<string>();
const toDelete: string[] = [];
for (const row of rows) {
const key = `${row.type}:${row.entityId}`;
if (seen.has(key)) toDelete.push(row.id);
else seen.add(key);
}
if (toDelete.length) {
await this.prisma.notification.deleteMany({
where: { id: { in: toDelete } },
});
}
}
private toDto(row: {
id: string;
type: string;
title: string;
body: string | null;
link: string | null;
entityType: string | null;
entityId: string | null;
readAt: Date | null;
createdAt: Date;
}): NotificationDto {
return {
id: row.id,
type: row.type,
title: row.title,
body: row.body,
link: row.link,
entityType: row.entityType,
entityId: row.entityId,
readAt: row.readAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
}
+44
View File
@@ -0,0 +1,44 @@
import {
ArrayMinSize,
IsArray,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class ExchangeRateInputDto {
@IsString()
@MinLength(3)
@MaxLength(8)
currency!: string;
@Type(() => Number)
@IsNumber()
@Min(0.000001)
rateToBase!: number;
}
export class UpdateOrgCurrenciesDto {
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(8)
baseCurrency?: string;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
enabledCurrencies?: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ExchangeRateInputDto)
rates?: ExchangeRateInputDto[];
}
@@ -0,0 +1,35 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { UpdateOrgCurrenciesDto } from './dto';
import { OrganizationsService } from './organizations.service';
@Controller('organizations')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrganizationsController {
constructor(private readonly organizationsService: OrganizationsService) {}
@Get('current')
current(@CurrentUser() user: { organizationId: string }) {
return this.organizationsService.getCurrent(user.organizationId);
}
@Get('currency-catalog')
catalog() {
return this.organizationsService.catalog();
}
@Patch('current/currencies')
@Roles('owner', 'admin')
updateCurrencies(
@CurrentUser() user: { organizationId: string },
@Body() dto: UpdateOrgCurrenciesDto,
) {
return this.organizationsService.updateCurrencies(
user.organizationId,
dto,
);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { OrganizationsController } from './organizations.controller';
import { OrganizationsService } from './organizations.service';
@Module({
controllers: [OrganizationsController],
providers: [OrganizationsService],
})
export class OrganizationsModule {}
@@ -0,0 +1,154 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
OrganizationDto,
UpdateOrgCurrenciesRequest,
} from '@mento/shared';
import { CURRENCY_CATALOG } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { isKnownCurrency } from '../common/currency';
@Injectable()
export class OrganizationsService {
constructor(private readonly prisma: PrismaService) {}
async getCurrent(organizationId: string): Promise<OrganizationDto> {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
include: {
exchangeRates: { orderBy: { currency: 'asc' } },
},
});
if (!org) {
throw new NotFoundException('سازمان پیدا نشد');
}
return this.toDto(org);
}
async updateCurrencies(
organizationId: string,
dto: UpdateOrgCurrenciesRequest,
): Promise<OrganizationDto> {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
});
if (!org) throw new NotFoundException('سازمان پیدا نشد');
const baseCurrency = (
dto.baseCurrency?.trim() ||
org.baseCurrency ||
'IRR'
).toUpperCase();
if (!isKnownCurrency(baseCurrency)) {
throw new BadRequestException('ارز پایه معتبر نیست');
}
let enabled = (
dto.enabledCurrencies?.map((c) => c.trim().toUpperCase()) ??
org.enabledCurrencies
).filter(Boolean);
if (!enabled.includes(baseCurrency)) {
enabled = [baseCurrency, ...enabled];
}
enabled = [...new Set(enabled)];
for (const code of enabled) {
if (!isKnownCurrency(code)) {
throw new BadRequestException(`ارز ${code} پشتیبانی نمی‌شود`);
}
}
if (dto.rates) {
for (const row of dto.rates) {
const code = row.currency.trim().toUpperCase();
if (!isKnownCurrency(code)) {
throw new BadRequestException(`ارز ${code} پشتیبانی نمی‌شود`);
}
if (code === baseCurrency) continue;
if (row.rateToBase <= 0) {
throw new BadRequestException('نرخ باید بزرگ‌تر از صفر باشد');
}
}
}
await this.prisma.$transaction(async (tx) => {
await tx.organization.update({
where: { id: organizationId },
data: {
baseCurrency,
enabledCurrencies: enabled,
},
});
if (dto.rates) {
for (const row of dto.rates) {
const code = row.currency.trim().toUpperCase();
if (code === baseCurrency) {
await tx.exchangeRate.deleteMany({
where: { organizationId, currency: code },
});
continue;
}
await tx.exchangeRate.upsert({
where: {
organizationId_currency: { organizationId, currency: code },
},
create: {
organizationId,
currency: code,
rateToBase: new Prisma.Decimal(row.rateToBase),
},
update: {
rateToBase: new Prisma.Decimal(row.rateToBase),
},
});
}
}
await tx.exchangeRate.deleteMany({
where: {
organizationId,
OR: [
{ currency: baseCurrency },
{ currency: { notIn: enabled } },
],
},
});
});
return this.getCurrent(organizationId);
}
catalog() {
return CURRENCY_CATALOG;
}
private toDto(org: {
id: string;
name: string;
slug: string;
baseCurrency: string;
enabledCurrencies: string[];
exchangeRates: Array<{
currency: string;
rateToBase: Prisma.Decimal;
updatedAt: Date;
}>;
}): OrganizationDto {
return {
id: org.id,
name: org.name,
slug: org.slug,
baseCurrency: org.baseCurrency,
enabledCurrencies: org.enabledCurrencies,
exchangeRates: org.exchangeRates.map((r) => ({
currency: r.currency,
rateToBase: Number(r.rateToBase),
updatedAt: r.updatedAt.toISOString(),
})),
};
}
}
@@ -0,0 +1,56 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
import { DealsService } from './deals.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('deals')
@UseGuards(JwtAuthGuard)
export class DealsController {
constructor(private readonly dealsService: DealsService) {}
@Get(':id')
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.dealsService.get(user.organizationId, id, user);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateDealDto) {
return this.dealsService.create(user.organizationId, user, dto);
}
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateDealDto,
) {
return this.dealsService.update(user.organizationId, id, user, dto);
}
@Patch(':id/move')
move(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: MoveDealDto,
) {
return this.dealsService.move(user.organizationId, id, user, dto);
}
@Delete(':id')
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.dealsService.remove(user.organizationId, id, user);
return { ok: true };
}
}
+398
View File
@@ -0,0 +1,398 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateDealRequest,
DealDto,
DealLineItemInput,
MoveDealRequest,
UpdateDealRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PipelinesService } from './pipelines.service';
import { toJsonCustomFields } from '../common/custom-fields';
import { NotificationsService } from '../notifications/notifications.service';
import {
type Actor,
canReassignOwner,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
const dealInclude = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
owner: { select: { id: true, fullName: true } },
lineItems: { orderBy: { position: 'asc' as const } },
} satisfies Prisma.DealInclude;
type DealActor = Pick<Actor, 'userId' | 'role'>;
@Injectable()
export class DealsService {
constructor(
private readonly prisma: PrismaService,
private readonly pipelines: PipelinesService,
private readonly notifications: NotificationsService,
) {}
async get(
organizationId: string,
id: string,
actor: DealActor,
): Promise<DealDto> {
const row = await this.findDeal(organizationId, id, actor);
return this.pipelines.toDealDto(row);
}
async create(
organizationId: string,
actor: DealActor,
dto: CreateDealRequest,
): Promise<DealDto> {
const pipeline =
dto.pipelineId != null
? await this.prisma.pipeline.findFirst({
where: { id: dto.pipelineId, organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
})
: null;
const resolvedPipeline =
pipeline ??
(await this.prisma.pipeline.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
}));
if (!resolvedPipeline) {
await this.pipelines.ensureDefaultPipeline(organizationId);
return this.create(organizationId, actor, dto);
}
const stage =
(dto.stageId
? resolvedPipeline.stages.find((s) => s.id === dto.stageId)
: resolvedPipeline.stages.find((s) => !s.isWon && !s.isLost)) ??
resolvedPipeline.stages[0];
if (!stage) {
throw new BadRequestException('کاریز مرحله‌ای ندارد');
}
await this.assertLinks(organizationId, actor, dto.personId, dto.companyId);
if (dto.lineItems?.length) {
await this.assertProducts(organizationId, dto.lineItems);
}
const maxPos = await this.prisma.deal.aggregate({
where: { organizationId, stageId: stage.id },
_max: { position: true },
});
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
const amountFromLines = sumLineItems(dto.lineItems);
const currency =
dto.lineItems?.[0]?.currency?.trim() ||
(dto.currency ?? 'IRR').trim() ||
'IRR';
const created = await this.prisma.deal.create({
data: {
organizationId,
pipelineId: resolvedPipeline.id,
stageId: stage.id,
title: dto.title.trim(),
amount:
amountFromLines != null
? new Prisma.Decimal(amountFromLines)
: dto.amount == null || Number.isNaN(Number(dto.amount))
? null
: new Prisma.Decimal(dto.amount),
currency,
status,
source: emptyToNull(dto.source),
notes: emptyToNull(dto.notes),
tags: normalizeTags(dto.tags),
isStale: dto.isStale ?? false,
isRotten: dto.isRotten ?? false,
customFields: toJsonCustomFields(dto.customFields),
expectedCloseAt: dto.expectedCloseAt
? new Date(dto.expectedCloseAt)
: null,
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
ownerId: resolveOwnerId(actor, dto.ownerId),
position: (maxPos._max.position ?? -1) + 1,
...(dto.lineItems?.length
? {
lineItems: {
create: dto.lineItems.map((item, index) => ({
productId: emptyToNull(item.productId ?? null),
name: item.name.trim(),
quantity: new Prisma.Decimal(item.quantity),
unitPrice: new Prisma.Decimal(item.unitPrice),
currency: (item.currency ?? currency).trim() || 'IRR',
position: index,
})),
},
}
: {}),
},
include: dealInclude,
});
return this.pipelines.toDealDto(created);
}
async update(
organizationId: string,
id: string,
actor: DealActor,
dto: UpdateDealRequest,
): Promise<DealDto> {
await this.ensureExists(organizationId, id, actor);
await this.assertLinks(organizationId, actor, dto.personId, dto.companyId);
if (dto.lineItems) {
await this.assertProducts(organizationId, dto.lineItems);
}
const data: Prisma.DealUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if ('amount' in dto && dto.lineItems === undefined) {
data.amount =
dto.amount == null || Number.isNaN(Number(dto.amount))
? null
: new Prisma.Decimal(dto.amount);
}
if (dto.currency !== undefined && dto.lineItems === undefined) {
data.currency = dto.currency.trim() || 'IRR';
}
if ('source' in dto) data.source = emptyToNull(dto.source);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if (dto.tags !== undefined) data.tags = normalizeTags(dto.tags);
if (dto.isStale !== undefined) data.isStale = dto.isStale;
if (dto.isRotten !== undefined) data.isRotten = dto.isRotten;
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
if ('lostReason' in dto) data.lostReason = emptyToNull(dto.lostReason);
if ('expectedCloseAt' in dto) {
data.expectedCloseAt = dto.expectedCloseAt
? new Date(dto.expectedCloseAt)
: null;
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
}
if (dto.lineItems !== undefined) {
const total = sumLineItems(dto.lineItems);
data.amount =
total == null ? null : new Prisma.Decimal(total);
if (dto.lineItems[0]?.currency) {
data.currency = dto.lineItems[0].currency.trim() || 'IRR';
}
}
await this.prisma.$transaction(async (tx) => {
if (dto.lineItems !== undefined) {
await tx.dealLineItem.deleteMany({ where: { dealId: id } });
if (dto.lineItems.length) {
const currency =
(typeof data.currency === 'string' ? data.currency : null) ||
'IRR';
await tx.dealLineItem.createMany({
data: dto.lineItems.map((item, index) => ({
dealId: id,
productId: emptyToNull(item.productId ?? null),
name: item.name.trim(),
quantity: new Prisma.Decimal(item.quantity),
unitPrice: new Prisma.Decimal(item.unitPrice),
currency: (item.currency ?? currency).trim() || 'IRR',
position: index,
})),
});
}
}
await tx.deal.update({ where: { id }, data });
});
return this.get(organizationId, id, actor);
}
async move(
organizationId: string,
id: string,
actor: DealActor,
dto: MoveDealRequest,
): Promise<DealDto> {
const deal = await this.prisma.deal.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
select: {
id: true,
title: true,
pipelineId: true,
stageId: true,
ownerId: true,
},
});
if (!deal) throw new NotFoundException('معامله پیدا نشد');
const stage = await this.prisma.pipelineStage.findFirst({
where: { id: dto.stageId, pipelineId: deal.pipelineId },
});
if (!stage) {
throw new BadRequestException('مرحله در این کاریز معتبر نیست');
}
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
const position =
dto.position ??
((
await this.prisma.deal.aggregate({
where: { organizationId, stageId: stage.id, NOT: { id } },
_max: { position: true },
})
)._max.position ?? -1) + 1;
const row = await this.prisma.deal.update({
where: { id },
data: {
stageId: stage.id,
position,
status,
lostReason: stage.isLost ? undefined : null,
},
include: dealInclude,
});
if (deal.ownerId && deal.stageId !== stage.id) {
await this.notifications.create({
organizationId,
userId: deal.ownerId,
type: 'deal_moved',
title: 'جابه‌جایی معامله در کاریز',
body: `«${deal.title}» به مرحله «${stage.name}» منتقل شد`,
link: `/deals/${deal.id}`,
entityType: 'deal',
entityId: deal.id,
});
}
return this.pipelines.toDealDto(row);
}
async remove(
organizationId: string,
id: string,
actor: DealActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
await this.prisma.deal.delete({ where: { id } });
}
private async ensureExists(
organizationId: string,
id: string,
actor: DealActor,
) {
const found = await this.prisma.deal.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('معامله پیدا نشد');
}
private async findDeal(
organizationId: string,
id: string,
actor: DealActor,
) {
const row = await this.prisma.deal.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
include: dealInclude,
});
if (!row) throw new NotFoundException('معامله پیدا نشد');
return row;
}
private async assertProducts(
organizationId: string,
items: DealLineItemInput[],
) {
const ids = [
...new Set(
items
.map((i) => emptyToNull(i.productId ?? null))
.filter((id): id is string => Boolean(id)),
),
];
if (!ids.length) return;
const count = await this.prisma.product.count({
where: { organizationId, id: { in: ids } },
});
if (count !== ids.length) {
throw new BadRequestException('یکی از محصولات انتخاب‌شده معتبر نیست');
}
}
private async assertLinks(
organizationId: string,
actor: DealActor,
personId?: string | null,
companyId?: string | null,
) {
const pId = emptyToNull(personId ?? null);
const cId = emptyToNull(companyId ?? null);
const scope = ownerWhere(actor);
if (pId) {
const person = await this.prisma.person.findFirst({
where: { id: pId, organizationId, ...scope },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص انتخاب‌شده معتبر نیست');
}
if (cId) {
const company = await this.prisma.company.findFirst({
where: { id: cId, organizationId, ...scope },
select: { id: true },
});
if (!company) throw new BadRequestException('شرکت انتخاب‌شده معتبر نیست');
}
}
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function normalizeTags(tags?: string[]): string[] {
if (!tags?.length) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const raw of tags) {
const tag = raw.trim();
if (!tag) continue;
const key = tag.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(tag.slice(0, 40));
}
return out.slice(0, 20);
}
function sumLineItems(items?: DealLineItemInput[]): number | null {
if (!items?.length) return null;
return items.reduce(
(sum, item) => sum + Number(item.quantity) * Number(item.unitPrice),
0,
);
}
+218
View File
@@ -0,0 +1,218 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDateString,
IsNumber,
IsObject,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
export class CreatePipelineDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
}
export class DealLineItemInputDto {
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
productId?: string | null;
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
quantity!: number;
@Type(() => Number)
@IsNumber()
@Min(0)
unitPrice!: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
}
export class CreateDealDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@IsString()
pipelineId?: string;
@IsOptional()
@IsString()
stageId?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@Type(() => Number)
@IsNumber()
@Min(0)
amount?: number | null;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(120)
source?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsBoolean()
isStale?: boolean;
@IsOptional()
@IsBoolean()
isRotten?: boolean;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsDateString()
expectedCloseAt?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => DealLineItemInputDto)
lineItems?: DealLineItemInputDto[];
}
export class UpdateDealDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@Type(() => Number)
@IsNumber()
@Min(0)
amount?: number | null;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(120)
source?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsBoolean()
isStale?: boolean;
@IsOptional()
@IsBoolean()
isRotten?: boolean;
@IsOptional()
@IsString()
@MaxLength(400)
lostReason?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsDateString()
expectedCloseAt?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => DealLineItemInputDto)
lineItems?: DealLineItemInputDto[];
}
export class MoveDealDto {
@IsString()
stageId!: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
position?: number;
}
@@ -0,0 +1,45 @@
import {
Body,
Controller,
Get,
Post,
Query,
UseGuards,
} from '@nestjs/common';
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 { CreatePipelineDto } from './dto';
import { PipelinesService } from './pipelines.service';
@Controller('pipelines')
@UseGuards(JwtAuthGuard, RolesGuard)
export class PipelinesController {
constructor(private readonly pipelinesService: PipelinesService) {}
@Get()
list(@CurrentUser() user: { organizationId: string }) {
return this.pipelinesService.list(user.organizationId);
}
@Get('board')
board(
@CurrentUser() user: { organizationId: string; userId: string; role: string },
@Query('pipelineId') pipelineId?: string,
) {
return this.pipelinesService.getBoard(user.organizationId, pipelineId, {
userId: user.userId,
role: user.role,
});
}
@Post()
@Roles('owner', 'admin')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreatePipelineDto,
) {
return this.pipelinesService.create(user.organizationId, dto.name);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { DealsController } from './deals.controller';
import { DealsService } from './deals.service';
import { PipelinesController } from './pipelines.controller';
import { PipelinesService } from './pipelines.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [NotificationsModule, AuthModule],
controllers: [PipelinesController, DealsController],
providers: [PipelinesService, DealsService],
exports: [PipelinesService, DealsService],
})
export class PipelinesModule {}
+262
View File
@@ -0,0 +1,262 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type {
DealDto,
PipelineBoardDto,
PipelineDto,
PipelineStageDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { asCustomFields } from '../common/custom-fields';
import { type Actor, ownerWhere } from '../common/record-scope';
import { decimalToBase, loadOrgRates } from '../common/currency';
const DEFAULT_STAGES = [
{ name: 'سرنخ', isWon: false, isLost: false },
{ name: 'تماس اول', isWon: false, isLost: false },
{ name: 'پیشنهاد', isWon: false, isLost: false },
{ name: 'مذاکره', isWon: false, isLost: false },
{ name: 'برنده', isWon: true, isLost: false },
{ name: 'از دست‌رفته', isWon: false, isLost: true },
] as const;
type DealRow = {
id: string;
title: string;
amount: Prisma.Decimal | null;
currency: string;
status: 'open' | 'won' | 'lost';
source: string | null;
lostReason: string | null;
notes: string | null;
tags: string[];
isStale: boolean;
isRotten: boolean;
customFields: unknown;
expectedCloseAt: Date | null;
position: number;
pipelineId: string;
stageId: string;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
owner: { id: string; fullName: string } | null;
lineItems?: Array<{
id: string;
productId: string | null;
name: string;
quantity: Prisma.Decimal;
unitPrice: Prisma.Decimal;
currency: string;
position: number;
}>;
};
@Injectable()
export class PipelinesService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaultPipeline(organizationId: string): Promise<PipelineDto> {
const existing = await this.prisma.pipeline.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
});
if (existing) return this.toPipelineDto(existing);
const created = await this.prisma.pipeline.create({
data: {
organizationId,
name: 'کاریز فروش',
isDefault: true,
stages: {
create: DEFAULT_STAGES.map((s, index) => ({
name: s.name,
position: index,
isWon: s.isWon,
isLost: s.isLost,
})),
},
},
include: { stages: { orderBy: { position: 'asc' } } },
});
return this.toPipelineDto(created);
}
async list(organizationId: string): Promise<PipelineDto[]> {
await this.ensureDefaultPipeline(organizationId);
const rows = await this.prisma.pipeline.findMany({
where: { organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
return rows.map((row) => this.toPipelineDto(row));
}
async create(organizationId: string, name: string): Promise<PipelineDto> {
await this.ensureDefaultPipeline(organizationId);
const created = await this.prisma.pipeline.create({
data: {
organizationId,
name: name.trim(),
isDefault: false,
stages: {
create: DEFAULT_STAGES.map((s, index) => ({
name: s.name,
position: index,
isWon: s.isWon,
isLost: s.isLost,
})),
},
},
include: { stages: { orderBy: { position: 'asc' } } },
});
return this.toPipelineDto(created);
}
async getBoard(
organizationId: string,
pipelineId?: string,
actor?: Pick<Actor, 'userId' | 'role'>,
): Promise<PipelineBoardDto> {
const pipeline = pipelineId
? await this.prisma.pipeline.findFirst({
where: { id: pipelineId, organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
})
: null;
const resolved =
pipeline ??
(await this.prisma.pipeline.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
}));
if (!resolved) {
const created = await this.ensureDefaultPipeline(organizationId);
return this.getBoard(organizationId, created.id, actor);
}
const deals = await this.prisma.deal.findMany({
where: {
organizationId,
pipelineId: resolved.id,
...(actor ? ownerWhere(actor) : {}),
},
include: {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
owner: { select: { id: true, fullName: true } },
},
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
const rateMap = await loadOrgRates(this.prisma, organizationId);
const byStage = new Map<string, DealDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const deal of deals) {
const list = byStage.get(deal.stageId);
if (list) list.push(this.toDealDto(deal));
}
return {
pipeline: this.toPipelineDto(resolved),
stages: resolved.stages.map((stage) => {
const stageDeals = byStage.get(stage.id) ?? [];
return {
id: stage.id,
name: stage.name,
position: stage.position,
isWon: stage.isWon,
isLost: stage.isLost,
deals: stageDeals,
totalAmount: stageDeals.reduce(
(sum, d) =>
sum +
decimalToBase(
d.amount == null ? null : new Prisma.Decimal(d.amount),
d.currency,
rateMap,
),
0,
),
};
}),
};
}
toDealDto(row: DealRow): DealDto {
return {
id: row.id,
title: row.title,
amount: row.amount == null ? null : Number(row.amount),
currency: row.currency,
status: row.status,
source: row.source,
lostReason: row.lostReason,
notes: row.notes,
tags: row.tags ?? [],
isStale: row.isStale,
isRotten: row.isRotten,
customFields: asCustomFields(row.customFields),
expectedCloseAt: row.expectedCloseAt?.toISOString() ?? null,
position: row.position,
pipelineId: row.pipelineId,
stageId: row.stageId,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
owner: row.owner,
lineItems: (row.lineItems ?? []).map((item) => {
const quantity = Number(item.quantity);
const unitPrice = Number(item.unitPrice);
return {
id: item.id,
productId: item.productId,
name: item.name,
quantity,
unitPrice,
currency: item.currency,
position: item.position,
lineTotal: quantity * unitPrice,
};
}),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private toPipelineDto(row: {
id: string;
name: string;
isDefault: boolean;
stages: Array<{
id: string;
name: string;
position: number;
isWon: boolean;
isLost: boolean;
}>;
}): PipelineDto {
return {
id: row.id,
name: row.name,
isDefault: row.isDefault,
stages: row.stages.map(
(s): PipelineStageDto => ({
id: s.id,
name: s.name,
position: s.position,
isWon: s.isWon,
isLost: s.isLost,
}),
),
};
}
}
+65
View File
@@ -0,0 +1,65 @@
import {
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class PortalRequestOtpDto {
@IsString()
@MinLength(10)
@MaxLength(20)
mobile!: string;
}
export class PortalVerifyOtpDto {
@IsString()
@MinLength(10)
@MaxLength(20)
mobile!: string;
@IsString()
@MinLength(4)
@MaxLength(8)
code!: string;
}
export class PortalCreateTicketDto {
@IsString()
@MinLength(2)
@MaxLength(200)
subject!: string;
@IsOptional()
@IsString()
@MaxLength(8000)
description?: string;
@IsOptional()
@IsString()
priority?: string;
}
export class PortalCreateMessageDto {
@IsString()
@MinLength(1)
@MaxLength(8000)
body!: string;
}
export class PortalCsatDto {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(5)
csatScore!: number;
@IsOptional()
@IsString()
@MaxLength(2000)
csatComment?: string | null;
}
+66
View File
@@ -0,0 +1,66 @@
import {
CanActivate,
createParamDecorator,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
export type PortalActor = {
personId: string;
organizationId: string;
mobile: string;
};
@Injectable()
export class PortalAuthGuard implements CanActivate {
constructor(
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<{
headers: { authorization?: string };
portalActor?: PortalActor;
}>();
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
throw new UnauthorizedException('ورود پرتال لازم است');
}
const token = header.slice(7);
try {
const payload = this.jwt.verify<{
sub: string;
organizationId: string;
typ?: string;
mobile?: string;
}>(token, {
secret: this.config.get<string>('JWT_SECRET') ?? 'dev-secret',
});
if (payload.typ !== 'portal' || !payload.sub || !payload.organizationId) {
throw new UnauthorizedException('توکن پرتال نامعتبر است');
}
req.portalActor = {
personId: payload.sub,
organizationId: payload.organizationId,
mobile: payload.mobile ?? '',
};
return true;
} catch {
throw new UnauthorizedException('نشست پرتال منقضی یا نامعتبر است');
}
}
}
export const CurrentPortal = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): PortalActor => {
const req = ctx.switchToHttp().getRequest<{ portalActor?: PortalActor }>();
if (!req.portalActor) {
throw new UnauthorizedException('ورود پرتال لازم است');
}
return req.portalActor;
},
);
+84
View File
@@ -0,0 +1,84 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
PortalCreateMessageDto,
PortalCreateTicketDto,
PortalCsatDto,
PortalRequestOtpDto,
PortalVerifyOtpDto,
} from './dto';
import { CurrentPortal, PortalAuthGuard, type PortalActor } from './portal-auth';
import { PortalService } from './portal.service';
@Controller('public/portal/:slug')
export class PortalController {
constructor(private readonly portalService: PortalService) {}
@Get()
info(@Param('slug') slug: string) {
return this.portalService.getOrgInfo(slug);
}
@Get('faq')
faq(@Param('slug') slug: string) {
return this.portalService.listFaq(slug);
}
@Post('otp/request')
requestOtp(@Param('slug') slug: string, @Body() dto: PortalRequestOtpDto) {
return this.portalService.requestOtp(slug, dto.mobile);
}
@Post('otp/verify')
verifyOtp(@Param('slug') slug: string, @Body() dto: PortalVerifyOtpDto) {
return this.portalService.verifyOtp(slug, dto.mobile, dto.code);
}
@Get('tickets')
@UseGuards(PortalAuthGuard)
listTickets(@CurrentPortal() actor: PortalActor) {
return this.portalService.listTickets(actor);
}
@Get('tickets/:id')
@UseGuards(PortalAuthGuard)
getTicket(@CurrentPortal() actor: PortalActor, @Param('id') id: string) {
return this.portalService.getTicket(actor, id);
}
@Post('tickets')
@UseGuards(PortalAuthGuard)
createTicket(
@CurrentPortal() actor: PortalActor,
@Body() dto: PortalCreateTicketDto,
) {
return this.portalService.createTicket(actor, dto);
}
@Post('tickets/:id/messages')
@UseGuards(PortalAuthGuard)
addMessage(
@CurrentPortal() actor: PortalActor,
@Param('id') id: string,
@Body() dto: PortalCreateMessageDto,
) {
return this.portalService.addMessage(actor, id, dto);
}
@Patch('tickets/:id/csat')
@UseGuards(PortalAuthGuard)
csat(
@CurrentPortal() actor: PortalActor,
@Param('id') id: string,
@Body() dto: PortalCsatDto,
) {
return this.portalService.submitCsat(actor, id, dto);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SmsService } from '../common/sms.service';
import { PortalController } from './portal.controller';
import { PortalService } from './portal.service';
import { PortalAuthGuard } from './portal-auth';
@Module({
imports: [AuthModule],
controllers: [PortalController],
providers: [PortalService, PortalAuthGuard, SmsService],
})
export class PortalModule {}
+410
View File
@@ -0,0 +1,410 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import type {
FaqArticleDto,
PortalCreateMessageRequest,
PortalCreateTicketRequest,
PortalCsatRequest,
PortalLoginResponse,
PortalOrgInfoDto,
PortalRequestOtpResponse,
PortalTicketDto,
TicketPriority,
} from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import type { PortalActor } from './portal-auth';
const OTP_TTL_SECONDS = 5 * 60;
const OTP_LENGTH = 6;
@Injectable()
export class PortalService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly sms: SmsService,
) {}
async getOrgInfo(slug: string): Promise<PortalOrgInfoDto> {
const org = await this.findOrg(slug);
return { name: org.name, slug: org.slug };
}
async listFaq(slug: string): Promise<FaqArticleDto[]> {
const org = await this.findOrg(slug);
const rows = await this.prisma.faqArticle.findMany({
where: { organizationId: org.id, isPublished: true },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => ({
id: row.id,
question: row.question,
answer: row.answer,
category: row.category,
isPublished: row.isPublished,
position: row.position,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}));
}
async requestOtp(
slug: string,
mobileRaw: string,
): Promise<PortalRequestOtpResponse> {
const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const recent = await this.prisma.portalOtp.count({
where: {
organizationId: org.id,
mobile,
createdAt: { gte: new Date(Date.now() - 60 * 60 * 1000) },
},
});
if (recent >= 8) {
throw new BadRequestException(
'درخواست زیاد است؛ کمی بعد دوباره تلاش کنید',
);
}
const code = String(
Math.floor(10 ** (OTP_LENGTH - 1) + Math.random() * 9 * 10 ** (OTP_LENGTH - 1)),
);
const expiresAt = new Date(Date.now() + OTP_TTL_SECONDS * 1000);
await this.prisma.portalOtp.create({
data: {
organizationId: org.id,
mobile,
code,
expiresAt,
},
});
const returnOtp =
this.config.get<string>('PORTAL_SHOW_OTP') === 'true' ||
this.config.get<string>('AUTH_RETURN_OTP') === 'true' ||
(this.config.get<string>('NODE_ENV') !== 'production' &&
this.config.get<string>('PORTAL_SHOW_OTP') !== 'false');
if (this.sms.isConfigured()) {
const sent = await this.sms.sendOtpSms(mobile, code);
if (!sent && !returnOtp) {
throw new BadRequestException(
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
);
}
} else if (!returnOtp) {
throw new BadRequestException('سامانه پیامک پیکربندی نشده است');
} else {
// eslint-disable-next-line no-console
console.log(`[portal-otp] ${org.slug} ${mobile} => ${code}`);
}
return {
ok: true,
expiresInSeconds: OTP_TTL_SECONDS,
...(returnOtp ? { devCode: code } : {}),
};
}
async verifyOtp(
slug: string,
mobileRaw: string,
codeRaw: string,
): Promise<PortalLoginResponse> {
const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw);
const code = codeRaw.trim();
if (!mobile || !code) {
throw new BadRequestException('موبایل یا کد ناقص است');
}
const otp = await this.prisma.portalOtp.findFirst({
where: {
organizationId: org.id,
mobile,
code,
consumedAt: null,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
});
if (!otp) {
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
}
await this.prisma.portalOtp.update({
where: { id: otp.id },
data: { consumedAt: new Date() },
});
let person = await this.prisma.person.findFirst({
where: { organizationId: org.id, mobile },
orderBy: { createdAt: 'asc' },
});
if (!person) {
person = await this.prisma.person.create({
data: {
organizationId: org.id,
firstName: 'مشتری',
lastName: mobile.slice(-4),
mobile,
},
});
}
const accessToken = await this.jwt.signAsync(
{
sub: person.id,
organizationId: org.id,
typ: 'portal',
mobile,
},
{
secret: this.config.get<string>('JWT_SECRET') ?? 'dev-secret',
expiresIn: '7d',
},
);
return {
accessToken,
person: {
id: person.id,
fullName: `${person.firstName} ${person.lastName}`.trim(),
mobile: person.mobile,
},
organization: { name: org.name, slug: org.slug },
};
}
async listTickets(actor: PortalActor): Promise<PortalTicketDto[]> {
const rows = await this.prisma.ticket.findMany({
where: {
organizationId: actor.organizationId,
personId: actor.personId,
},
include: {
messages: {
where: { isInternal: false },
orderBy: { createdAt: 'asc' },
include: { author: { select: { id: true } } },
},
},
orderBy: { updatedAt: 'desc' },
take: 50,
});
return rows.map((row) => this.toPortalTicket(row, actor.personId));
}
async getTicket(actor: PortalActor, id: string): Promise<PortalTicketDto> {
const row = await this.findTicket(actor, id);
return this.toPortalTicket(row, actor.personId);
}
async createTicket(
actor: PortalActor,
dto: PortalCreateTicketRequest,
): Promise<PortalTicketDto> {
const person = await this.prisma.person.findFirst({
where: { id: actor.personId, organizationId: actor.organizationId },
});
if (!person) throw new NotFoundException('پرونده مشتری پیدا نشد');
const count = await this.prisma.ticket.count({
where: { organizationId: actor.organizationId },
});
const number = `TK-${String(count + 1).padStart(4, '0')}`;
const priority = (dto.priority as TicketPriority) || 'normal';
const createdAt = new Date();
const hours = TICKET_SLA_HOURS[priority] ?? TICKET_SLA_HOURS.normal;
const owner =
(await this.prisma.user.findFirst({
where: {
organizationId: actor.organizationId,
role: { in: ['owner', 'admin', 'support'] },
},
orderBy: { createdAt: 'asc' },
})) ?? null;
const row = await this.prisma.ticket.create({
data: {
organizationId: actor.organizationId,
number,
subject: dto.subject.trim(),
description: dto.description?.trim() || null,
status: 'open',
priority: priority as never,
personId: person.id,
companyId: person.companyId,
assigneeId: owner?.id ?? null,
reporterId: null,
createdAt,
slaDueAt: new Date(createdAt.getTime() + hours * 60 * 60 * 1000),
...(dto.description?.trim()
? {
messages: {
create: {
authorId: null,
body: dto.description.trim(),
isInternal: false,
},
},
}
: {}),
},
include: {
messages: {
where: { isInternal: false },
orderBy: { createdAt: 'asc' },
include: { author: { select: { id: true } } },
},
},
});
return this.toPortalTicket(row, actor.personId);
}
async addMessage(
actor: PortalActor,
ticketId: string,
dto: PortalCreateMessageRequest,
): Promise<PortalTicketDto> {
await this.findTicket(actor, ticketId);
await this.prisma.ticketMessage.create({
data: {
ticketId,
authorId: null,
body: dto.body.trim(),
isInternal: false,
},
});
await this.prisma.ticket.update({
where: { id: ticketId },
data: {
updatedAt: new Date(),
status: 'waiting',
},
});
return this.getTicket(actor, ticketId);
}
async submitCsat(
actor: PortalActor,
ticketId: string,
dto: PortalCsatRequest,
): Promise<PortalTicketDto> {
const existing = await this.findTicket(actor, ticketId);
if (existing.status !== 'resolved' && existing.status !== 'closed') {
throw new BadRequestException(
'ثبت رضایت فقط پس از حل یا بستن تیکت ممکن است',
);
}
if (dto.csatScore < 1 || dto.csatScore > 5) {
throw new BadRequestException('امتیاز باید بین ۱ تا ۵ باشد');
}
await this.prisma.ticket.update({
where: { id: ticketId },
data: {
csatScore: dto.csatScore,
csatComment: dto.csatComment?.trim() || null,
csatAt: new Date(),
},
});
return this.getTicket(actor, ticketId);
}
private async findOrg(slug: string) {
const org = await this.prisma.organization.findUnique({
where: { slug },
select: { id: true, name: true, slug: true },
});
if (!org) throw new NotFoundException('سازمان پیدا نشد');
return org;
}
private async findTicket(actor: PortalActor, id: string) {
const row = await this.prisma.ticket.findFirst({
where: {
id,
organizationId: actor.organizationId,
personId: actor.personId,
},
include: {
messages: {
where: { isInternal: false },
orderBy: { createdAt: 'asc' },
include: { author: { select: { id: true } } },
},
},
});
if (!row) throw new NotFoundException('تیکت پیدا نشد');
return row;
}
private toPortalTicket(
row: {
id: string;
number: string;
subject: string;
description: string | null;
status: string;
priority: string;
resolvedAt: Date | null;
csatScore: number | null;
csatComment: string | null;
createdAt: Date;
updatedAt: Date;
messages: Array<{
id: string;
body: string;
createdAt: Date;
author: { id: string } | null;
}>;
},
personId: string,
): PortalTicketDto {
void personId;
return {
id: row.id,
number: row.number,
subject: row.subject,
description: row.description,
status: row.status,
priority: row.priority,
resolvedAt: row.resolvedAt?.toISOString() ?? null,
csatScore: row.csatScore,
csatComment: row.csatComment,
messages: row.messages.map((m) => ({
id: m.id,
body: m.body,
createdAt: m.createdAt.toISOString(),
fromStaff: Boolean(m.author),
})),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
/** نرمال‌سازی موبایل ایران به 09xxxxxxxxx */
export function normalizeMobile(raw: string): string | null {
let s = raw.replace(/[\s\-()]/g, '');
if (s.startsWith('+98')) s = `0${s.slice(3)}`;
if (s.startsWith('98') && s.length === 12) s = `0${s.slice(2)}`;
if (/^09\d{9}$/.test(s)) return s;
return null;
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+13
View File
@@ -0,0 +1,13 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+89
View File
@@ -0,0 +1,89 @@
import { Type } from 'class-transformer';
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateProductDto {
@IsString()
@MinLength(1)
@MaxLength(160)
name!: string;
@IsOptional()
@IsString()
@MaxLength(64)
sku?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@Type(() => Number)
@IsNumber()
@Min(0)
unitPrice?: number | null;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateProductDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(160)
name?: string;
@IsOptional()
@IsString()
@MaxLength(64)
sku?: string | null;
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@Type(() => Number)
@IsNumber()
@Min(0)
unitPrice?: number | null;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class ListProductsQueryDto {
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
activeOnly?: boolean;
@IsOptional()
@IsString()
q?: string;
}
@@ -0,0 +1,63 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
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 { CreateProductDto, ListProductsQueryDto, UpdateProductDto } from './dto';
import { ProductsService } from './products.service';
@Controller('products')
@UseGuards(JwtAuthGuard, RolesGuard)
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
list(
@CurrentUser() user: { organizationId: string },
@Query() query: ListProductsQueryDto,
) {
return this.productsService.list(user.organizationId, {
activeOnly: query.activeOnly,
q: query.q,
});
}
@Post()
@Roles('owner', 'admin')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateProductDto,
) {
return this.productsService.create(user.organizationId, dto);
}
@Patch(':id')
@Roles('owner', 'admin')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateProductDto,
) {
return this.productsService.update(user.organizationId, id, dto);
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.productsService.remove(user.organizationId, id);
return { ok: true };
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
imports: [AuthModule],
controllers: [ProductsController],
providers: [ProductsService],
exports: [ProductsService],
})
export class ProductsModule {}
+141
View File
@@ -0,0 +1,141 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import type {
CreateProductRequest,
ProductDto,
UpdateProductRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
type ProductRow = {
id: string;
name: string;
sku: string | null;
description: string | null;
unitPrice: Prisma.Decimal | null;
currency: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
};
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
opts?: { activeOnly?: boolean; q?: string },
): Promise<ProductDto[]> {
const rows = await this.prisma.product.findMany({
where: {
organizationId,
...(opts?.activeOnly ? { isActive: true } : {}),
...(opts?.q?.trim()
? {
OR: [
{ name: { contains: opts.q.trim(), mode: 'insensitive' } },
{ sku: { contains: opts.q.trim(), mode: 'insensitive' } },
],
}
: {}),
},
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
dto: CreateProductRequest,
): Promise<ProductDto> {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام محصول الزامی است');
const row = await this.prisma.product.create({
data: {
organizationId,
name,
sku: emptyToNull(dto.sku),
description: emptyToNull(dto.description),
unitPrice:
dto.unitPrice == null || Number.isNaN(Number(dto.unitPrice))
? null
: new Prisma.Decimal(dto.unitPrice),
currency: (dto.currency ?? 'IRR').trim() || 'IRR',
isActive: dto.isActive ?? true,
},
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateProductRequest,
): Promise<ProductDto> {
await this.ensureExists(organizationId, id);
const data: Prisma.ProductUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام محصول الزامی است');
data.name = name;
}
if ('sku' in dto) data.sku = emptyToNull(dto.sku);
if ('description' in dto) data.description = emptyToNull(dto.description);
if ('unitPrice' in dto) {
data.unitPrice =
dto.unitPrice == null || Number.isNaN(Number(dto.unitPrice))
? null
: new Prisma.Decimal(dto.unitPrice);
}
if (dto.currency !== undefined) {
data.currency = dto.currency.trim() || 'IRR';
}
if (dto.isActive !== undefined) data.isActive = dto.isActive;
const row = await this.prisma.product.update({ where: { id }, data });
return this.toDto(row);
}
async remove(organizationId: string, id: string): Promise<void> {
await this.ensureExists(organizationId, id);
await this.prisma.product.delete({ where: { id } });
}
async ensureInOrg(organizationId: string, productId: string): Promise<void> {
await this.ensureExists(organizationId, productId);
}
private async ensureExists(organizationId: string, id: string) {
const found = await this.prisma.product.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!found) throw new NotFoundException('محصول پیدا نشد');
}
private toDto(row: ProductRow): ProductDto {
return {
id: row.id,
name: row.name,
sku: row.sku,
description: row.description,
unitPrice: row.unitPrice == null ? null : Number(row.unitPrice),
currency: row.currency,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
+169
View File
@@ -0,0 +1,169 @@
import {
ArrayMinSize,
IsArray,
IsIn,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
const QUOTE_STATUSES = [
'draft',
'sent',
'accepted',
'rejected',
'expired',
] as const;
export class QuoteLineItemInputDto {
@IsOptional()
@IsString()
productId?: string | null;
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
quantity!: number;
@Type(() => Number)
@IsNumber()
@Min(0)
unitPrice!: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
}
export class CreateQuoteDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@IsIn(QUOTE_STATUSES)
status?: (typeof QUOTE_STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string;
@IsOptional()
@IsString()
validUntil?: string | null;
@IsOptional()
@IsString()
personId?: string | null;
@IsOptional()
@IsString()
companyId?: string | null;
@IsOptional()
@IsString()
dealId?: string | null;
@IsOptional()
@IsString()
fromDealId?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => QuoteLineItemInputDto)
@ArrayMinSize(0)
lineItems?: QuoteLineItemInputDto[];
}
export class UpdateQuoteDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@IsIn(QUOTE_STATUSES)
status?: (typeof QUOTE_STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string | null;
@IsOptional()
@IsString()
validUntil?: string | null;
@IsOptional()
@IsString()
personId?: string | null;
@IsOptional()
@IsString()
companyId?: string | null;
@IsOptional()
@IsString()
dealId?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => QuoteLineItemInputDto)
lineItems?: QuoteLineItemInputDto[];
}
export class ListQuotesQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsIn(QUOTE_STATUSES)
status?: (typeof QUOTE_STATUSES)[number];
@IsOptional()
@IsString()
dealId?: string;
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number;
}
+67
View File
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateQuoteDto,
ListQuotesQueryDto,
UpdateQuoteDto,
} from './dto';
import { QuotesService } from './quotes.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('quotes')
@UseGuards(JwtAuthGuard)
export class QuotesController {
constructor(private readonly quotesService: QuotesService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query() query: ListQuotesQueryDto,
) {
return this.quotesService.list(user.organizationId, user, {
q: query.q,
status: query.status,
dealId: query.dealId,
personId: query.personId,
page: query.page ?? 1,
limit: query.limit ?? 50,
});
}
@Get(':id')
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.quotesService.get(user.organizationId, id, user);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateQuoteDto) {
return this.quotesService.create(user.organizationId, user.userId, dto);
}
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateQuoteDto,
) {
return this.quotesService.update(user.organizationId, id, user, dto);
}
@Delete(':id')
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.quotesService.remove(user.organizationId, id, user);
return { ok: true };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { QuotesController } from './quotes.controller';
import { QuotesService } from './quotes.service';
@Module({
controllers: [QuotesController],
providers: [QuotesService],
exports: [QuotesService],
})
export class QuotesModule {}
+367
View File
@@ -0,0 +1,367 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateQuoteRequest,
DealLineItemInput,
PaginatedResponse,
QuoteDto,
UpdateQuoteRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
type QuoteActor = Pick<Actor, 'userId' | 'role'>;
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
owner: { select: { id: true, fullName: true } },
lineItems: { orderBy: { position: 'asc' as const } },
} satisfies Prisma.QuoteInclude;
type QuoteRow = Prisma.QuoteGetPayload<{ include: typeof include }>;
@Injectable()
export class QuotesService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
actor: QuoteActor,
opts: {
q?: string;
status?: string;
dealId?: string;
personId?: string;
page?: number;
limit?: number;
},
): Promise<PaginatedResponse<QuoteDto>> {
const page = opts.page ?? 1;
const limit = opts.limit ?? 50;
const where: Prisma.QuoteWhereInput = {
organizationId,
...ownerWhere(actor),
...(opts.status ? { status: opts.status as never } : {}),
...(opts.dealId ? { dealId: opts.dealId } : {}),
...(opts.personId ? { personId: opts.personId } : {}),
...(opts.q?.trim()
? {
OR: [
{ title: { contains: opts.q.trim(), mode: 'insensitive' } },
{ number: { contains: opts.q.trim(), mode: 'insensitive' } },
{ notes: { contains: opts.q.trim(), mode: 'insensitive' } },
],
}
: {}),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.quote.findMany({
where,
include,
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.quote.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
async get(
organizationId: string,
id: string,
actor: QuoteActor,
): Promise<QuoteDto> {
const row = await this.prisma.quote.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
include,
});
if (!row) throw new NotFoundException('پیش‌فاکتور پیدا نشد');
return this.toDto(row);
}
async create(
organizationId: string,
userId: string,
dto: CreateQuoteRequest,
): Promise<QuoteDto> {
let title = dto.title.trim();
let personId = emptyToNull(dto.personId ?? null);
let companyId = emptyToNull(dto.companyId ?? null);
let dealId = emptyToNull(dto.dealId ?? null);
let lineItems = dto.lineItems ?? [];
let currency = (dto.currency ?? 'IRR').trim() || 'IRR';
let notes = emptyToNull(dto.notes ?? null);
const fromDealId = emptyToNull(dto.fromDealId ?? null);
if (fromDealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: fromDealId, organizationId },
include: { lineItems: { orderBy: { position: 'asc' } } },
});
if (!deal) throw new BadRequestException('معامله مبدأ معتبر نیست');
dealId = deal.id;
personId = personId ?? deal.personId;
companyId = companyId ?? deal.companyId;
if (!title || title === 'پیش‌فاکتور') {
title = `پیش‌فاکتور — ${deal.title}`;
}
currency = deal.currency || currency;
notes = notes ?? deal.notes;
if (!lineItems.length && deal.lineItems.length) {
lineItems = deal.lineItems.map((item) => ({
productId: item.productId,
name: item.name,
quantity: Number(item.quantity),
unitPrice: Number(item.unitPrice),
currency: item.currency,
}));
}
}
await this.assertLinks(organizationId, personId, companyId, dealId);
if (lineItems.length) {
await this.assertProducts(organizationId, lineItems);
}
const number = await this.nextNumber(organizationId);
const row = await this.prisma.quote.create({
data: {
organizationId,
number,
title,
status: (dto.status as never) || 'draft',
currency,
notes,
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
personId,
companyId,
dealId,
ownerId: userId,
...(lineItems.length
? {
lineItems: {
create: lineItems.map((item, index) => ({
productId: emptyToNull(item.productId ?? null),
name: item.name.trim(),
quantity: new Prisma.Decimal(item.quantity),
unitPrice: new Prisma.Decimal(item.unitPrice),
currency: (item.currency ?? currency).trim() || 'IRR',
position: index,
})),
},
}
: {}),
},
include,
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
actor: QuoteActor,
dto: UpdateQuoteRequest,
): Promise<QuoteDto> {
await this.ensureExists(organizationId, id, actor);
if (
'personId' in dto ||
'companyId' in dto ||
'dealId' in dto
) {
await this.assertLinks(
organizationId,
emptyToNull(dto.personId ?? null),
emptyToNull(dto.companyId ?? null),
emptyToNull(dto.dealId ?? null),
);
}
if (dto.lineItems?.length) {
await this.assertProducts(organizationId, dto.lineItems);
}
const data: Prisma.QuoteUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.status !== undefined) data.status = dto.status as never;
if (dto.currency !== undefined) {
data.currency = dto.currency.trim() || 'IRR';
}
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if ('validUntil' in dto) {
data.validUntil = dto.validUntil ? new Date(dto.validUntil) : null;
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if (dto.lineItems) {
const currency =
(typeof data.currency === 'string' && data.currency) ||
(
await this.prisma.quote.findFirst({
where: { id },
select: { currency: true },
})
)?.currency ||
'IRR';
await this.prisma.$transaction([
this.prisma.quoteLineItem.deleteMany({ where: { quoteId: id } }),
this.prisma.quote.update({
where: { id },
data: {
...data,
lineItems: {
create: dto.lineItems.map((item, index) => ({
productId: emptyToNull(item.productId ?? null),
name: item.name.trim(),
quantity: new Prisma.Decimal(item.quantity),
unitPrice: new Prisma.Decimal(item.unitPrice),
currency: (item.currency ?? currency).trim() || 'IRR',
position: index,
})),
},
},
}),
]);
} else if (Object.keys(data).length) {
await this.prisma.quote.update({ where: { id }, data });
}
return this.get(organizationId, id, actor);
}
async remove(
organizationId: string,
id: string,
actor: QuoteActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
await this.prisma.quote.delete({ where: { id } });
}
private async nextNumber(organizationId: string): Promise<string> {
const count = await this.prisma.quote.count({ where: { organizationId } });
return `PF-${String(count + 1).padStart(4, '0')}`;
}
private async ensureExists(
organizationId: string,
id: string,
actor: QuoteActor,
) {
const found = await this.prisma.quote.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('پیش‌فاکتور پیدا نشد');
}
private async assertLinks(
organizationId: string,
personId: string | null,
companyId: string | null,
dealId: string | null,
) {
if (personId) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص معتبر نیست');
}
if (companyId) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, organizationId },
select: { id: true },
});
if (!company) throw new BadRequestException('شرکت معتبر نیست');
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله معتبر نیست');
}
}
private async assertProducts(
organizationId: string,
lines: DealLineItemInput[],
) {
const ids = [
...new Set(
lines
.map((l) => emptyToNull(l.productId ?? null))
.filter((id): id is string => Boolean(id)),
),
];
if (!ids.length) return;
const count = await this.prisma.product.count({
where: { organizationId, id: { in: ids } },
});
if (count !== ids.length) {
throw new BadRequestException('یکی از محصولات معتبر نیست');
}
}
private toDto(row: QuoteRow): QuoteDto {
const lineItems = row.lineItems.map((item) => {
const quantity = Number(item.quantity);
const unitPrice = Number(item.unitPrice);
return {
id: item.id,
productId: item.productId,
name: item.name,
quantity,
unitPrice,
currency: item.currency,
position: item.position,
lineTotal: quantity * unitPrice,
};
});
const totalAmount = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
return {
id: row.id,
number: row.number,
title: row.title,
status: row.status,
currency: row.currency,
notes: row.notes,
validUntil: row.validUntil?.toISOString() ?? null,
totalAmount,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
owner: row.owner,
lineItems,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
+11
View File
@@ -0,0 +1,11 @@
import { IsOptional, IsString } from 'class-validator';
export class ReportsQueryDto {
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
}
@@ -0,0 +1,27 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ReportsQueryDto } from './dto';
import { ReportsService } from './reports.service';
@Controller('reports')
@UseGuards(JwtAuthGuard)
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@Get('dashboard')
dashboard(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Query() query: ReportsQueryDto,
) {
return this.reportsService.dashboard(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
{ from: query.from, to: query.to },
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
imports: [AuthModule],
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}
+271
View File
@@ -0,0 +1,271 @@
import { Injectable } from '@nestjs/common';
import type { ReportsDashboardDto } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
assigneeWhere,
elevated,
ownerWhere,
ticketAccessWhere,
} from '../common/record-scope';
import { decimalToBase, loadOrgRates } from '../common/currency';
@Injectable()
export class ReportsService {
constructor(private readonly prisma: PrismaService) {}
async dashboard(
actor: Actor,
opts: { from?: string; to?: string },
): Promise<ReportsDashboardDto> {
const { start, end } = resolveRange(opts.from, opts.to);
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor);
const activityScope = assigneeWhere(actor);
const ticketScope = ticketAccessWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId);
const [deals, stages, activities, tickets, peopleCreated, users] =
await Promise.all([
this.prisma.deal.findMany({
where: {
organizationId: orgId,
...dealScope,
OR: [
{ createdAt: { gte: start, lte: end } },
{
status: { in: ['won', 'lost'] },
updatedAt: { gte: start, lte: end },
},
{ status: 'open' },
],
},
select: {
id: true,
status: true,
amount: true,
currency: true,
stageId: true,
ownerId: true,
updatedAt: true,
createdAt: true,
},
}),
this.prisma.pipelineStage.findMany({
where: { pipeline: { organizationId: orgId } },
include: { pipeline: { select: { name: true } } },
orderBy: [{ pipelineId: 'asc' }, { position: 'asc' }],
}),
this.prisma.activity.findMany({
where: {
organizationId: orgId,
...activityScope,
createdAt: { gte: start, lte: end },
},
select: {
id: true,
type: true,
status: true,
dueAt: true,
assigneeId: true,
},
}),
this.prisma.ticket.findMany({
where: {
organizationId: orgId,
...ticketScope,
createdAt: { gte: start, lte: end },
},
select: {
status: true,
slaDueAt: true,
firstRespondedAt: true,
csatScore: true,
},
}),
this.prisma.person.count({
where: {
organizationId: orgId,
createdAt: { gte: start, lte: end },
...(elevated(actor.role) ? {} : { ownerId: actor.userId }),
},
}),
this.prisma.user.findMany({
where: { organizationId: orgId },
select: { id: true, fullName: true },
}),
]);
const openDeals = deals.filter((d) => d.status === 'open');
const wonInPeriod = deals.filter(
(d) =>
d.status === 'won' &&
d.updatedAt >= start &&
d.updatedAt <= end,
);
const lostInPeriod = deals.filter(
(d) =>
d.status === 'lost' &&
d.updatedAt >= start &&
d.updatedAt <= end,
);
const sumBase = (
rows: Array<{ amount: Prisma.Decimal | null; currency: string }>,
) =>
rows.reduce(
(acc, row) => acc + decimalToBase(row.amount, row.currency, rates),
0,
);
const closedCount = wonInPeriod.length + lostInPeriod.length;
const winRate = closedCount ? wonInPeriod.length / closedCount : 0;
const openByStage = new Map<string, { count: number; amount: number }>();
for (const d of openDeals) {
const cur = openByStage.get(d.stageId) ?? { count: 0, amount: 0 };
cur.count += 1;
cur.amount += decimalToBase(d.amount, d.currency, rates);
openByStage.set(d.stageId, cur);
}
const funnel = stages.map((s) => {
const agg = openByStage.get(s.id) ?? { count: 0, amount: 0 };
return {
stageId: s.id,
stageName: s.name,
pipelineName: s.pipeline.name,
count: agg.count,
amount: Math.round(agg.amount),
};
});
const now = new Date();
const activityOpen = activities.filter((a) => a.status === 'open').length;
const activityDone = activities.filter((a) => a.status === 'done').length;
const activityOverdue = activities.filter(
(a) =>
a.status === 'open' &&
a.dueAt != null &&
a.dueAt.getTime() < now.getTime(),
).length;
const byTypeMap = new Map<string, number>();
for (const a of activities) {
byTypeMap.set(a.type, (byTypeMap.get(a.type) ?? 0) + 1);
}
const teamMap = new Map<
string,
{ wonAmount: number; wonCount: number; activitiesDone: number }
>();
for (const u of users) {
teamMap.set(u.id, { wonAmount: 0, wonCount: 0, activitiesDone: 0 });
}
for (const d of wonInPeriod) {
if (!d.ownerId) continue;
const t = teamMap.get(d.ownerId)!;
t.wonCount += 1;
t.wonAmount += decimalToBase(d.amount, d.currency, rates);
}
for (const a of activities) {
if (a.status !== 'done' || !a.assigneeId) continue;
const t = teamMap.get(a.assigneeId);
if (t) t.activitiesDone += 1;
}
const ticketOpen = tickets.filter((t) =>
['open', 'in_progress', 'waiting'].includes(t.status),
).length;
const ticketResolved = tickets.filter(
(t) => t.status === 'resolved',
).length;
const ticketClosed = tickets.filter((t) => t.status === 'closed').length;
const slaBreached = tickets.filter(
(t) =>
t.slaDueAt &&
!t.firstRespondedAt &&
['open', 'in_progress', 'waiting'].includes(t.status) &&
t.slaDueAt.getTime() < now.getTime(),
).length;
const csatScores = tickets
.map((t) => t.csatScore)
.filter((s): s is number => s != null);
const avgCsat = csatScores.length
? csatScores.reduce((a, b) => a + b, 0) / csatScores.length
: null;
return {
from: start.toISOString(),
to: end.toISOString(),
currency: rates.baseCurrency,
sales: {
openCount: openDeals.length,
wonCount: wonInPeriod.length,
lostCount: lostInPeriod.length,
openAmount: Math.round(sumBase(openDeals)),
wonAmount: Math.round(sumBase(wonInPeriod)),
lostAmount: Math.round(sumBase(lostInPeriod)),
winRate: Math.round(winRate * 1000) / 10,
},
funnel,
activities: {
open: activityOpen,
done: activityDone,
overdue: activityOverdue,
byType: [...byTypeMap.entries()].map(([type, count]) => ({
type,
count,
})),
},
team: users
.map((u) => {
const t = teamMap.get(u.id)!;
return {
userId: u.id,
fullName: u.fullName,
wonAmount: Math.round(t.wonAmount),
wonCount: t.wonCount,
activitiesDone: t.activitiesDone,
};
})
.filter(
(u) =>
elevated(actor.role) || u.wonCount > 0 || u.activitiesDone > 0,
)
.sort((a, b) => b.wonAmount - a.wonAmount),
tickets: {
open: ticketOpen,
resolved: ticketResolved,
closed: ticketClosed,
slaBreached,
avgCsat: avgCsat == null ? null : Math.round(avgCsat * 10) / 10,
csatCount: csatScores.length,
},
peopleCreated,
};
}
}
function resolveRange(from?: string, to?: string): { start: Date; end: Date } {
const end = to ? endOfDay(parseDay(to)) : endOfDay(new Date());
const start = from
? startOfDay(parseDay(from))
: startOfDay(new Date(end.getFullYear(), end.getMonth(), 1));
return { start, end };
}
function parseDay(value: string): Date {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return new Date();
return d;
}
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
function endOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
}
+105
View File
@@ -0,0 +1,105 @@
import {
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateSalesGoalDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsString()
periodStart!: string;
@IsString()
periodEnd!: string;
@Type(() => Number)
@IsNumber()
@Min(0)
targetAmount!: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsString()
userId?: string | null;
}
export class UpdateSalesGoalDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
periodStart?: string;
@IsOptional()
@IsString()
periodEnd?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
targetAmount?: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string | null;
@IsOptional()
@IsString()
userId?: string | null;
}
export class ForecastQueryDto {
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
@IsOptional()
@IsString()
userId?: string;
}
export class ListGoalsQueryDto {
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
@IsOptional()
@IsString()
userId?: string;
}
+125
View File
@@ -0,0 +1,125 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateSalesGoalDto,
ForecastQueryDto,
ListGoalsQueryDto,
UpdateSalesGoalDto,
} from './dto';
import { SalesService } from './sales.service';
@Controller('sales')
@UseGuards(JwtAuthGuard)
export class SalesController {
constructor(private readonly salesService: SalesService) {}
@Get('forecast')
forecast(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Query() query: ForecastQueryDto,
) {
return this.salesService.forecast(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
{ from: query.from, to: query.to, userId: query.userId },
);
}
@Get('goals')
listGoals(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Query() query: ListGoalsQueryDto,
) {
return this.salesService.listGoals(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
{ from: query.from, to: query.to, userId: query.userId },
);
}
@Get('goals/:id')
getGoal(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Param('id') id: string,
) {
return this.salesService.getGoal(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
id,
);
}
@Post('goals')
createGoal(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Body() dto: CreateSalesGoalDto,
) {
return this.salesService.createGoal(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
dto,
);
}
@Patch('goals/:id')
updateGoal(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Param('id') id: string,
@Body() dto: UpdateSalesGoalDto,
) {
return this.salesService.updateGoal(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
id,
dto,
);
}
@Delete('goals/:id')
async removeGoal(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Param('id') id: string,
) {
await this.salesService.removeGoal(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
id,
);
return { ok: true };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SalesController } from './sales.controller';
import { SalesService } from './sales.service';
@Module({
controllers: [SalesController],
providers: [SalesService],
exports: [SalesService],
})
export class SalesModule {}
+338
View File
@@ -0,0 +1,338 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateSalesGoalRequest,
PaginatedResponse,
SalesForecastDto,
SalesGoalDto,
UpdateSalesGoalRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
elevated,
ownerWhere,
} from '../common/record-scope';
import { decimalToBase, loadOrgRates } from '../common/currency';
const include = {
user: { select: { id: true, fullName: true } },
} satisfies Prisma.SalesGoalInclude;
type GoalRow = Prisma.SalesGoalGetPayload<{ include: typeof include }>;
const OPEN_WEIGHT = 0.4;
@Injectable()
export class SalesService {
constructor(private readonly prisma: PrismaService) {}
async listGoals(
actor: Actor,
opts: { from?: string; to?: string; userId?: string } = {},
): Promise<PaginatedResponse<SalesGoalDto>> {
const { start, end } = resolvePeriod(opts.from, opts.to);
const where: Prisma.SalesGoalWhereInput = {
organizationId: actor.organizationId,
periodStart: { lte: end },
periodEnd: { gte: start },
...this.goalVisibility(actor, opts.userId),
};
const rows = await this.prisma.salesGoal.findMany({
where,
include,
orderBy: [{ periodStart: 'desc' }, { createdAt: 'desc' }],
take: 100,
});
return {
items: rows.map((row) => this.toGoalDto(row)),
total: rows.length,
page: 1,
limit: 100,
};
}
async getGoal(actor: Actor, id: string): Promise<SalesGoalDto> {
const row = await this.prisma.salesGoal.findFirst({
where: {
id,
organizationId: actor.organizationId,
...this.goalVisibility(actor),
},
include,
});
if (!row) throw new NotFoundException('هدف پیدا نشد');
return this.toGoalDto(row);
}
async createGoal(
actor: Actor,
dto: CreateSalesGoalRequest,
): Promise<SalesGoalDto> {
const periodStart = parseDate(dto.periodStart, 'شروع دوره');
const periodEnd = parseDate(dto.periodEnd, 'پایان دوره');
if (periodEnd < periodStart) {
throw new BadRequestException('پایان دوره باید بعد از شروع باشد');
}
const userId = await this.resolveGoalUserId(actor, dto.userId);
const row = await this.prisma.salesGoal.create({
data: {
organizationId: actor.organizationId,
userId,
title: dto.title.trim(),
periodStart,
periodEnd,
targetAmount: new Prisma.Decimal(dto.targetAmount),
currency: (dto.currency ?? 'IRR').trim() || 'IRR',
notes: emptyToNull(dto.notes ?? null),
},
include,
});
return this.toGoalDto(row);
}
async updateGoal(
actor: Actor,
id: string,
dto: UpdateSalesGoalRequest,
): Promise<SalesGoalDto> {
const existing = await this.prisma.salesGoal.findFirst({
where: {
id,
organizationId: actor.organizationId,
...this.goalVisibility(actor),
},
});
if (!existing) throw new NotFoundException('هدف پیدا نشد');
if (!elevated(actor.role) && existing.userId !== actor.userId) {
throw new ForbiddenException('اجازه ویرایش این هدف را ندارید');
}
const data: Prisma.SalesGoalUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.periodStart !== undefined) {
data.periodStart = parseDate(dto.periodStart, 'شروع دوره');
}
if (dto.periodEnd !== undefined) {
data.periodEnd = parseDate(dto.periodEnd, 'پایان دوره');
}
if (dto.targetAmount !== undefined) {
data.targetAmount = new Prisma.Decimal(dto.targetAmount);
}
if (dto.currency !== undefined) {
data.currency = dto.currency.trim() || 'IRR';
}
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if ('userId' in dto) {
data.userId = await this.resolveGoalUserId(actor, dto.userId);
}
const start =
data.periodStart instanceof Date
? data.periodStart
: existing.periodStart;
const end =
data.periodEnd instanceof Date ? data.periodEnd : existing.periodEnd;
if (end < start) {
throw new BadRequestException('پایان دوره باید بعد از شروع باشد');
}
const row = await this.prisma.salesGoal.update({
where: { id },
data,
include,
});
return this.toGoalDto(row);
}
async removeGoal(actor: Actor, id: string): Promise<void> {
const existing = await this.prisma.salesGoal.findFirst({
where: {
id,
organizationId: actor.organizationId,
...this.goalVisibility(actor),
},
});
if (!existing) throw new NotFoundException('هدف پیدا نشد');
if (!elevated(actor.role) && existing.userId !== actor.userId) {
throw new ForbiddenException('اجازه حذف این هدف را ندارید');
}
await this.prisma.salesGoal.delete({ where: { id } });
}
async forecast(
actor: Actor,
opts: { from?: string; to?: string; userId?: string } = {},
): Promise<SalesForecastDto> {
const { start, end } = resolvePeriod(opts.from, opts.to);
const dealOwner =
elevated(actor.role) && opts.userId
? { ownerId: opts.userId }
: ownerWhere(actor);
const goalsWhere: Prisma.SalesGoalWhereInput = {
organizationId: actor.organizationId,
periodStart: { lte: end },
periodEnd: { gte: start },
...this.goalVisibility(actor, opts.userId),
};
const [goals, wonDeals, openDeals, rateMap] = await Promise.all([
this.prisma.salesGoal.findMany({
where: goalsWhere,
include,
orderBy: { periodStart: 'asc' },
}),
this.prisma.deal.findMany({
where: {
organizationId: actor.organizationId,
status: 'won',
updatedAt: { gte: start, lte: end },
...dealOwner,
},
select: { amount: true, currency: true },
}),
this.prisma.deal.findMany({
where: {
organizationId: actor.organizationId,
status: 'open',
...dealOwner,
},
select: { amount: true, currency: true },
}),
loadOrgRates(this.prisma, actor.organizationId),
]);
const targetAmount = goals.reduce(
(sum, g) =>
sum + decimalToBase(g.targetAmount, g.currency, rateMap),
0,
);
const wonAmount = wonDeals.reduce(
(sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap),
0,
);
const openPipelineAmount = openDeals.reduce(
(sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap),
0,
);
const forecastAmount = wonAmount + openPipelineAmount * OPEN_WEIGHT;
const progressPercent =
targetAmount > 0
? Math.round((wonAmount / targetAmount) * 1000) / 10
: null;
return {
periodStart: start.toISOString(),
periodEnd: end.toISOString(),
currency: rateMap.baseCurrency,
targetAmount,
wonAmount,
openPipelineAmount,
forecastAmount,
progressPercent,
openDealsCount: openDeals.length,
wonDealsCount: wonDeals.length,
goals: goals.map((g) => this.toGoalDto(g)),
};
}
private goalVisibility(
actor: Actor,
filterUserId?: string,
): Prisma.SalesGoalWhereInput {
if (elevated(actor.role)) {
if (filterUserId === 'org') return { userId: null };
if (filterUserId) return { userId: filterUserId };
return {};
}
return {
OR: [{ userId: actor.userId }, { userId: null }],
};
}
private async resolveGoalUserId(
actor: Actor,
requested?: string | null,
): Promise<string | null> {
if (!elevated(actor.role)) {
return actor.userId;
}
if (requested === undefined) return null;
if (requested == null || requested === '' || requested === 'org') {
return null;
}
const user = await this.prisma.user.findFirst({
where: { id: requested, organizationId: actor.organizationId },
select: { id: true },
});
if (!user) throw new BadRequestException('کاربر هدف معتبر نیست');
return user.id;
}
private toGoalDto(row: GoalRow): SalesGoalDto {
return {
id: row.id,
title: row.title,
periodStart: row.periodStart.toISOString(),
periodEnd: row.periodEnd.toISOString(),
targetAmount: Number(row.targetAmount),
currency: row.currency,
notes: row.notes,
user: row.user,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function resolvePeriod(from?: string, to?: string): { start: Date; end: Date } {
if (from || to) {
const start = from
? parseDate(from, 'از تاریخ')
: startOfMonth(new Date());
const end = to ? endOfDay(parseDate(to, 'تا تاریخ')) : endOfMonth(start);
return { start, end };
}
const now = new Date();
return { start: startOfMonth(now), end: endOfMonth(now) };
}
function parseDate(value: string, label: string): Date {
const d = new Date(value);
if (Number.isNaN(d.getTime())) {
throw new BadRequestException(`${label} معتبر نیست`);
}
return d;
}
function startOfMonth(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0));
}
function endOfMonth(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0, 23, 59, 59, 999),
);
}
function endOfDay(d: Date): Date {
const x = new Date(d);
x.setUTCHours(23, 59, 59, 999);
return x;
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
@@ -0,0 +1,48 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createReadStream, promises as fs } from 'fs';
import * as path from 'path';
import type { Readable } from 'stream';
@Injectable()
export class LocalFileStorage implements OnModuleInit {
private readonly root: string;
constructor(private readonly config: ConfigService) {
this.root =
this.config.get<string>('UPLOAD_DIR')?.trim() ||
path.join(process.cwd(), 'uploads');
}
async onModuleInit() {
await fs.mkdir(this.root, { recursive: true });
}
absolutePath(storageKey: string): string {
const resolved = path.resolve(this.root, storageKey);
if (!resolved.startsWith(path.resolve(this.root))) {
throw new Error('Invalid storage key');
}
return resolved;
}
async save(storageKey: string, buffer: Buffer): Promise<void> {
const abs = this.absolutePath(storageKey);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buffer);
}
async remove(storageKey: string): Promise<void> {
const abs = this.absolutePath(storageKey);
try {
await fs.unlink(abs);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') throw err;
}
}
openReadStream(storageKey: string): Readable {
return createReadStream(this.absolutePath(storageKey));
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { LocalFileStorage } from './local-file.storage';
@Global()
@Module({
providers: [LocalFileStorage],
exports: [LocalFileStorage],
})
export class StorageModule {}
+157
View File
@@ -0,0 +1,157 @@
import {
IsBoolean,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
const STATUSES = [
'open',
'in_progress',
'waiting',
'resolved',
'closed',
] as const;
const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const;
export class CreateTicketDto {
@IsString()
@MinLength(2)
@MaxLength(200)
subject!: string;
@IsOptional()
@IsString()
@MaxLength(8000)
description?: string;
@IsOptional()
@IsIn(STATUSES)
status?: (typeof STATUSES)[number];
@IsOptional()
@IsIn(PRIORITIES)
priority?: (typeof PRIORITIES)[number];
@IsOptional()
@IsString()
personId?: string | null;
@IsOptional()
@IsString()
companyId?: string | null;
@IsOptional()
@IsString()
dealId?: string | null;
@IsOptional()
@IsString()
assigneeId?: string | null;
}
export class UpdateTicketDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
subject?: string;
@IsOptional()
@IsString()
@MaxLength(8000)
description?: string | null;
@IsOptional()
@IsIn(STATUSES)
status?: (typeof STATUSES)[number];
@IsOptional()
@IsIn(PRIORITIES)
priority?: (typeof PRIORITIES)[number];
@IsOptional()
@IsString()
personId?: string | null;
@IsOptional()
@IsString()
companyId?: string | null;
@IsOptional()
@IsString()
dealId?: string | null;
@IsOptional()
@IsString()
assigneeId?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(5)
csatScore?: number | null;
@IsOptional()
@IsString()
@MaxLength(2000)
csatComment?: string | null;
}
export class CreateTicketMessageDto {
@IsString()
@MinLength(1)
@MaxLength(8000)
body!: string;
@IsOptional()
@IsBoolean()
isInternal?: boolean;
}
export class ListTicketsQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsIn(STATUSES)
status?: (typeof STATUSES)[number];
@IsOptional()
@IsIn(PRIORITIES)
priority?: (typeof PRIORITIES)[number];
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
assigneeId?: string;
@IsOptional()
@IsIn(['overdue'])
sla?: 'overdue';
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number;
}
+136
View File
@@ -0,0 +1,136 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateTicketDto,
CreateTicketMessageDto,
ListTicketsQueryDto,
UpdateTicketDto,
} from './dto';
import { TicketsService } from './tickets.service';
@Controller('tickets')
@UseGuards(JwtAuthGuard)
export class TicketsController {
constructor(private readonly ticketsService: TicketsService) {}
@Get()
list(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Query() query: ListTicketsQueryDto,
) {
return this.ticketsService.list(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
{
q: query.q,
status: query.status,
priority: query.priority,
personId: query.personId,
assigneeId: query.assigneeId,
sla: query.sla,
page: query.page ?? 1,
limit: query.limit ?? 50,
},
);
}
@Get(':id')
get(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Param('id') id: string,
) {
return this.ticketsService.get(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
id,
);
}
@Post()
create(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Body() dto: CreateTicketDto,
) {
return this.ticketsService.create(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
dto,
);
}
@Patch(':id')
update(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Param('id') id: string,
@Body() dto: UpdateTicketDto,
) {
return this.ticketsService.update(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
id,
dto,
);
}
@Delete(':id')
async remove(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Param('id') id: string,
) {
await this.ticketsService.remove(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
id,
);
return { ok: true };
}
@Post(':id/messages')
addMessage(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Param('id') id: string,
@Body() dto: CreateTicketMessageDto,
) {
return this.ticketsService.addMessage(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
id,
dto,
);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
@Module({
imports: [NotificationsModule],
controllers: [TicketsController],
providers: [TicketsService],
exports: [TicketsService],
})
export class TicketsModule {}
+485
View File
@@ -0,0 +1,485 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateTicketMessageRequest,
CreateTicketRequest,
PaginatedResponse,
TicketDto,
TicketPriority,
UpdateTicketRequest,
} from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
elevated,
ticketAccessWhere,
} from '../common/record-scope';
import { NotificationsService } from '../notifications/notifications.service';
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
assignee: { select: { id: true, fullName: true } },
reporter: { select: { id: true, fullName: true } },
messages: {
orderBy: { createdAt: 'asc' as const },
include: { author: { select: { id: true, fullName: true } } },
},
} satisfies Prisma.TicketInclude;
type TicketRow = Prisma.TicketGetPayload<{ include: typeof include }>;
@Injectable()
export class TicketsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async list(
actor: Actor,
opts: {
q?: string;
status?: string;
priority?: string;
personId?: string;
assigneeId?: string;
sla?: 'overdue';
page?: number;
limit?: number;
},
): Promise<PaginatedResponse<TicketDto>> {
const page = opts.page ?? 1;
const limit = opts.limit ?? 50;
const now = new Date();
const access = ticketAccessWhere(actor);
const where: Prisma.TicketWhereInput = {
organizationId: actor.organizationId,
...access,
...(opts.status ? { status: opts.status as never } : {}),
...(opts.priority ? { priority: opts.priority as never } : {}),
...(opts.personId ? { personId: opts.personId } : {}),
...(opts.assigneeId && elevated(actor.role)
? { assigneeId: opts.assigneeId }
: {}),
...(opts.sla === 'overdue'
? {
slaDueAt: { lt: now },
firstRespondedAt: null,
status: { in: ['open', 'in_progress', 'waiting'] },
}
: {}),
...(opts.q?.trim()
? {
AND: [
access,
{
OR: [
{ subject: { contains: opts.q.trim(), mode: 'insensitive' } },
{ number: { contains: opts.q.trim(), mode: 'insensitive' } },
{
description: {
contains: opts.q.trim(),
mode: 'insensitive',
},
},
],
},
],
}
: {}),
};
// When q is set, avoid double-spreading access at top level
const finalWhere: Prisma.TicketWhereInput = opts.q?.trim()
? {
organizationId: actor.organizationId,
...(opts.status ? { status: opts.status as never } : {}),
...(opts.priority ? { priority: opts.priority as never } : {}),
...(opts.personId ? { personId: opts.personId } : {}),
...(opts.assigneeId && elevated(actor.role)
? { assigneeId: opts.assigneeId }
: {}),
...(opts.sla === 'overdue'
? {
slaDueAt: { lt: now },
firstRespondedAt: null,
status: { in: ['open', 'in_progress', 'waiting'] },
}
: {}),
AND: [
access,
{
OR: [
{ subject: { contains: opts.q.trim(), mode: 'insensitive' } },
{ number: { contains: opts.q.trim(), mode: 'insensitive' } },
{
description: {
contains: opts.q.trim(),
mode: 'insensitive',
},
},
],
},
],
}
: where;
const [rows, total] = await this.prisma.$transaction([
this.prisma.ticket.findMany({
where: finalWhere,
include,
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.ticket.count({ where: finalWhere }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
async get(actor: Actor, id: string): Promise<TicketDto> {
const row = await this.prisma.ticket.findFirst({
where: {
id,
organizationId: actor.organizationId,
...ticketAccessWhere(actor),
},
include,
});
if (!row) throw new NotFoundException('تیکت پیدا نشد');
return this.toDto(row);
}
async create(actor: Actor, dto: CreateTicketRequest): Promise<TicketDto> {
const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null);
let assigneeId = emptyToNull(dto.assigneeId ?? null) ?? actor.userId;
await this.assertLinks(actor.organizationId, personId, companyId, dealId);
if (assigneeId) {
await this.assertUser(actor.organizationId, assigneeId);
}
if (!elevated(actor.role) && assigneeId && assigneeId !== actor.userId) {
assigneeId = actor.userId;
}
const number = await this.nextNumber(actor.organizationId);
const status = (dto.status as never) || 'open';
const priority = ((dto.priority as TicketPriority) || 'normal') as TicketPriority;
const createdAt = new Date();
const row = await this.prisma.ticket.create({
data: {
organizationId: actor.organizationId,
number,
subject: dto.subject.trim(),
description: emptyToNull(dto.description ?? null),
status,
priority: priority as never,
personId,
companyId,
dealId,
assigneeId,
reporterId: actor.userId,
createdAt,
slaDueAt: computeSlaDueAt(createdAt, priority),
resolvedAt:
status === 'resolved' || status === 'closed' ? createdAt : null,
...(dto.description?.trim()
? {
messages: {
create: {
authorId: actor.userId,
body: dto.description.trim(),
isInternal: false,
},
},
}
: {}),
},
include,
});
if (assigneeId && assigneeId !== actor.userId) {
await this.notifications.create({
organizationId: actor.organizationId,
userId: assigneeId,
type: 'ticket_assigned',
title: 'تیکت جدید ارجاع شد',
body: row.subject,
link: `/tickets/${row.id}`,
entityType: 'ticket',
entityId: row.id,
});
}
return this.toDto(row);
}
async update(
actor: Actor,
id: string,
dto: UpdateTicketRequest,
): Promise<TicketDto> {
const existing = await this.prisma.ticket.findFirst({
where: {
id,
organizationId: actor.organizationId,
...ticketAccessWhere(actor),
},
});
if (!existing) throw new NotFoundException('تیکت پیدا نشد');
if (
'personId' in dto ||
'companyId' in dto ||
'dealId' in dto
) {
await this.assertLinks(
actor.organizationId,
emptyToNull(dto.personId ?? null),
emptyToNull(dto.companyId ?? null),
emptyToNull(dto.dealId ?? null),
);
}
const data: Prisma.TicketUncheckedUpdateInput = {};
if (dto.subject !== undefined) data.subject = dto.subject.trim();
if ('description' in dto) {
data.description = emptyToNull(dto.description ?? null);
}
if (dto.status !== undefined) {
data.status = dto.status as never;
if (dto.status === 'resolved' || dto.status === 'closed') {
data.resolvedAt = existing.resolvedAt ?? new Date();
} else {
data.resolvedAt = null;
}
}
if (dto.priority !== undefined) {
data.priority = dto.priority as never;
if (!existing.firstRespondedAt) {
data.slaDueAt = computeSlaDueAt(
existing.createdAt,
dto.priority as TicketPriority,
);
}
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('assigneeId' in dto) {
const next = emptyToNull(dto.assigneeId ?? null);
if (next) await this.assertUser(actor.organizationId, next);
if (!elevated(actor.role) && next && next !== actor.userId) {
throw new BadRequestException('فقط می‌توانید تیکت را به خودتان ارجاع دهید');
}
data.assigneeId = next;
if (next && next !== existing.assigneeId && next !== actor.userId) {
await this.notifications.create({
organizationId: actor.organizationId,
userId: next,
type: 'ticket_assigned',
title: 'تیکت به شما ارجاع شد',
body: existing.subject,
link: `/tickets/${existing.id}`,
entityType: 'ticket',
entityId: existing.id,
});
}
}
const nextStatus = (dto.status ?? existing.status) as string;
if ('csatScore' in dto || 'csatComment' in dto) {
if (nextStatus !== 'resolved' && nextStatus !== 'closed') {
throw new BadRequestException(
'ثبت رضایت فقط برای تیکت حل‌شده یا بسته مجاز است',
);
}
if (dto.csatScore !== undefined && dto.csatScore !== null) {
if (dto.csatScore < 1 || dto.csatScore > 5) {
throw new BadRequestException('امتیاز رضایت باید بین ۱ تا ۵ باشد');
}
data.csatScore = dto.csatScore;
data.csatAt = new Date();
}
if ('csatComment' in dto) {
data.csatComment = emptyToNull(dto.csatComment ?? null);
if (!data.csatAt && (dto.csatComment || existing.csatScore)) {
data.csatAt = new Date();
}
}
}
await this.prisma.ticket.update({ where: { id }, data });
return this.get(actor, id);
}
async remove(actor: Actor, id: string): Promise<void> {
await this.ensureExists(actor, id);
await this.prisma.ticket.delete({ where: { id } });
}
async addMessage(
actor: Actor,
ticketId: string,
dto: CreateTicketMessageRequest,
): Promise<TicketDto> {
const existing = await this.prisma.ticket.findFirst({
where: {
id: ticketId,
organizationId: actor.organizationId,
...ticketAccessWhere(actor),
},
select: {
id: true,
firstRespondedAt: true,
reporterId: true,
},
});
if (!existing) throw new NotFoundException('تیکت پیدا نشد');
const isInternal = dto.isInternal ?? false;
await this.prisma.ticketMessage.create({
data: {
ticketId,
authorId: actor.userId,
body: dto.body.trim(),
isInternal,
},
});
const patch: Prisma.TicketUncheckedUpdateInput = { updatedAt: new Date() };
if (
!isInternal &&
!existing.firstRespondedAt &&
actor.userId !== existing.reporterId
) {
patch.firstRespondedAt = new Date();
}
await this.prisma.ticket.update({ where: { id: ticketId }, data: patch });
return this.get(actor, ticketId);
}
private async nextNumber(organizationId: string): Promise<string> {
const count = await this.prisma.ticket.count({ where: { organizationId } });
return `TK-${String(count + 1).padStart(4, '0')}`;
}
private async ensureExists(actor: Actor, id: string) {
const found = await this.prisma.ticket.findFirst({
where: {
id,
organizationId: actor.organizationId,
...ticketAccessWhere(actor),
},
select: { id: true },
});
if (!found) throw new NotFoundException('تیکت پیدا نشد');
}
private async assertUser(organizationId: string, userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId },
select: { id: true },
});
if (!user) throw new BadRequestException('کاربر مسئول معتبر نیست');
}
private async assertLinks(
organizationId: string,
personId: string | null,
companyId: string | null,
dealId: string | null,
) {
if (personId) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص معتبر نیست');
}
if (companyId) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, organizationId },
select: { id: true },
});
if (!company) throw new BadRequestException('شرکت معتبر نیست');
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله معتبر نیست');
}
}
private toDto(row: TicketRow): TicketDto {
const openStatuses = new Set(['open', 'in_progress', 'waiting']);
const slaBreached = Boolean(
row.slaDueAt &&
!row.firstRespondedAt &&
openStatuses.has(row.status) &&
row.slaDueAt.getTime() < Date.now(),
);
return {
id: row.id,
number: row.number,
subject: row.subject,
description: row.description,
status: row.status,
priority: row.priority,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
assignee: row.assignee,
reporter: row.reporter,
resolvedAt: row.resolvedAt?.toISOString() ?? null,
slaDueAt: row.slaDueAt?.toISOString() ?? null,
firstRespondedAt: row.firstRespondedAt?.toISOString() ?? null,
slaBreached,
csatScore: row.csatScore,
csatComment: row.csatComment,
csatAt: row.csatAt?.toISOString() ?? null,
messages: row.messages.map((m) => ({
id: m.id,
body: m.body,
isInternal: m.isInternal,
author: m.author,
createdAt: m.createdAt.toISOString(),
})),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function computeSlaDueAt(from: Date, priority: TicketPriority): Date {
const hours = TICKET_SLA_HOURS[priority] ?? TICKET_SLA_HOURS.normal;
return new Date(from.getTime() + hours * 60 * 60 * 1000);
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
+33
View File
@@ -0,0 +1,33 @@
import {
IsEmail,
IsIn,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
const INVITE_ROLES = ['admin', 'sales', 'support'] as const;
const ALL_ROLES = ['owner', 'admin', 'sales', 'support'] as const;
export class InviteUserDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(2)
@MaxLength(120)
fullName!: string;
@IsString()
@MinLength(8)
@MaxLength(72)
password!: string;
@IsIn(INVITE_ROLES)
role!: (typeof INVITE_ROLES)[number];
}
export class UpdateUserRoleDto {
@IsIn(ALL_ROLES)
role!: (typeof ALL_ROLES)[number];
}
+51
View File
@@ -0,0 +1,51 @@
import {
Body,
Controller,
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 { InviteUserDto, UpdateUserRoleDto } from './dto';
import { UsersService } from './users.service';
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
list(@CurrentUser() user: { organizationId: string }) {
return this.usersService.list(user.organizationId);
}
@Post()
@Roles('owner', 'admin')
invite(
@CurrentUser() user: { organizationId: string; role: UserRole },
@Body() dto: InviteUserDto,
) {
return this.usersService.invite(user.organizationId, user.role, dto);
}
@Patch(':id/role')
@Roles('owner', 'admin')
updateRole(
@CurrentUser() user: { organizationId: string; userId: string; role: UserRole },
@Param('id') id: string,
@Body() dto: UpdateUserRoleDto,
) {
return this.usersService.updateRole(
user.organizationId,
{ userId: user.userId, role: user.role },
id,
dto.role,
);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [AuthModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+113
View File
@@ -0,0 +1,113 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<OrgUserDto[]> {
const rows = await this.prisma.user.findMany({
where: { organizationId },
orderBy: [{ role: 'asc' }, { fullName: 'asc' }],
});
return rows.map((u) => this.toDto(u));
}
async invite(
organizationId: string,
actorRole: UserRole,
dto: InviteUserRequest,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند کاربر اضافه کند');
}
const email = dto.email.trim().toLowerCase();
const existing = await this.prisma.user.findFirst({
where: { organizationId, email },
});
if (existing) {
throw new ConflictException('این ایمیل در سازمان شما وجود دارد');
}
const global = await this.prisma.user.findFirst({ where: { email } });
if (global) {
throw new ConflictException('این ایمیل قبلاً در سیستم ثبت شده است');
}
const passwordHash = await bcrypt.hash(dto.password, 10);
const row = await this.prisma.user.create({
data: {
organizationId,
email,
fullName: dto.fullName.trim(),
passwordHash,
role: dto.role,
},
});
return this.toDto(row);
}
async updateRole(
organizationId: string,
actor: { userId: string; role: UserRole },
targetId: string,
nextRole: UserRole,
): Promise<OrgUserDto> {
if (actor.role !== 'owner' && actor.role !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش را تغییر دهد');
}
const target = await this.prisma.user.findFirst({
where: { id: targetId, organizationId },
});
if (!target) throw new NotFoundException('کاربر پیدا نشد');
if (actor.role === 'admin') {
if (target.role === 'owner' || nextRole === 'owner') {
throw new ForbiddenException('مدیر نمی‌تواند نقش مالک را تغییر دهد');
}
}
if (target.role === 'owner' && nextRole !== 'owner') {
const owners = await this.prisma.user.count({
where: { organizationId, role: 'owner' },
});
if (owners <= 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
);
}
}
const row = await this.prisma.user.update({
where: { id: target.id },
data: { role: nextRole },
});
return this.toDto(row);
}
private toDto(row: {
id: string;
email: string;
fullName: string;
role: UserRole;
createdAt: Date;
}): OrgUserDto {
return {
id: row.id,
email: row.email,
fullName: row.fullName,
role: row.role,
createdAt: row.createdAt.toISOString(),
};
}
}