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:
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? {});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user