231 lines
5.6 KiB
TypeScript
231 lines
5.6 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
HttpCode,
|
|
HttpException,
|
|
HttpStatus,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
|
import { Type } from 'class-transformer';
|
|
import { CurrentUser } from '../auth/current-user.decorator';
|
|
import type { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { buildSearchWhere, ci } from '../common/search';
|
|
import { LeadsService } from '../leads/leads.service';
|
|
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
|
|
|
class CreateExternalLeadDto {
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(80)
|
|
firstName?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(80)
|
|
lastName?: string;
|
|
|
|
@IsString()
|
|
@MinLength(8)
|
|
@MaxLength(20)
|
|
mobile!: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(200)
|
|
storeName?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(200)
|
|
source?: string;
|
|
}
|
|
|
|
const LEAD_RATE_WINDOW_MS = 60_000;
|
|
const LEAD_RATE_MAX = 60;
|
|
const leadHits = new Map<string, number[]>();
|
|
|
|
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;
|
|
}
|
|
|
|
@Controller('public/v1')
|
|
@UseGuards(ApiKeyAuthGuard)
|
|
export class PublicApiV1Controller {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly leads: LeadsService,
|
|
) {}
|
|
|
|
@Post('leads')
|
|
@HttpCode(200)
|
|
createLead(
|
|
@CurrentUser()
|
|
user: { organizationId: string; apiKeyId?: string; apiKeyName?: string },
|
|
@Body() body: CreateExternalLeadDto,
|
|
) {
|
|
const bucket = user.apiKeyId || user.organizationId;
|
|
const now = Date.now();
|
|
const prev = (leadHits.get(bucket) ?? []).filter((t) => now - t < LEAD_RATE_WINDOW_MS);
|
|
if (prev.length >= LEAD_RATE_MAX) {
|
|
throw new HttpException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.', HttpStatus.TOO_MANY_REQUESTS);
|
|
}
|
|
prev.push(now);
|
|
leadHits.set(bucket, prev);
|
|
return this.leads.createFromExternal({
|
|
organizationId: user.organizationId,
|
|
siteName: user.apiKeyName?.trim() || 'سایت بیرونی',
|
|
firstName: body.firstName,
|
|
lastName: body.lastName,
|
|
mobile: body.mobile,
|
|
storeName: body.storeName,
|
|
source: body.source,
|
|
});
|
|
}
|
|
|
|
@Get('people')
|
|
async listPeople(
|
|
@CurrentUser() user: { organizationId: string },
|
|
@Query() query: ListQueryDto,
|
|
) {
|
|
const page = query.page ?? 1;
|
|
const limit = query.limit ?? 20;
|
|
const search = buildSearchWhere<Prisma.PersonWhereInput>(query.q, (t) => [
|
|
{ firstName: ci(t) },
|
|
{ lastName: ci(t) },
|
|
{ mobile: { contains: t } },
|
|
{ phone: { contains: t } },
|
|
{ email: ci(t) },
|
|
]);
|
|
const where: Prisma.PersonWhereInput = {
|
|
organizationId: user.organizationId,
|
|
...(search ? { AND: [search] } : {}),
|
|
};
|
|
const [total, items] = await Promise.all([
|
|
this.prisma.person.count({ where }),
|
|
this.prisma.person.findMany({
|
|
where,
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
mobile: true,
|
|
phone: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
orderBy: { updatedAt: 'desc' },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
}),
|
|
]);
|
|
return {
|
|
total,
|
|
page,
|
|
limit,
|
|
items: items.map((p) => ({
|
|
...p,
|
|
fullName: `${p.firstName} ${p.lastName}`.trim(),
|
|
createdAt: p.createdAt.toISOString(),
|
|
updatedAt: p.updatedAt.toISOString(),
|
|
})),
|
|
};
|
|
}
|
|
|
|
@Get('people/:id')
|
|
async getPerson(
|
|
@CurrentUser() user: { organizationId: string },
|
|
@Param('id') id: string,
|
|
) {
|
|
const p = await this.prisma.person.findFirst({
|
|
where: { id, organizationId: user.organizationId },
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
mobile: true,
|
|
phone: true,
|
|
notes: true,
|
|
tags: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
});
|
|
if (!p) return null;
|
|
return {
|
|
...p,
|
|
fullName: `${p.firstName} ${p.lastName}`.trim(),
|
|
createdAt: p.createdAt.toISOString(),
|
|
updatedAt: p.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
@Get('deals')
|
|
async listDeals(
|
|
@CurrentUser() user: { organizationId: string },
|
|
@Query() query: ListQueryDto,
|
|
) {
|
|
const page = query.page ?? 1;
|
|
const limit = query.limit ?? 20;
|
|
const search = buildSearchWhere<Prisma.DealWhereInput>(query.q, (t) => [{ title: ci(t) }]);
|
|
const where: Prisma.DealWhereInput = {
|
|
organizationId: user.organizationId,
|
|
...(search ? { AND: [search] } : {}),
|
|
};
|
|
const [total, items] = await Promise.all([
|
|
this.prisma.deal.count({ where }),
|
|
this.prisma.deal.findMany({
|
|
where,
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
status: true,
|
|
amount: true,
|
|
currency: true,
|
|
source: true,
|
|
stageId: true,
|
|
pipelineId: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
orderBy: { updatedAt: 'desc' },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
}),
|
|
]);
|
|
return {
|
|
total,
|
|
page,
|
|
limit,
|
|
items: items.map((d) => ({
|
|
...d,
|
|
amount: d.amount == null ? null : Number(d.amount),
|
|
createdAt: d.createdAt.toISOString(),
|
|
updatedAt: d.updatedAt.toISOString(),
|
|
})),
|
|
};
|
|
}
|
|
}
|