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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 21:06:30 +03:30
co-authored by Cursor
commit 29a040c21d
195 changed files with 39430 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import {
ArrayMinSize,
IsArray,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class ExchangeRateInputDto {
@IsString()
@MinLength(3)
@MaxLength(8)
currency!: string;
@Type(() => Number)
@IsNumber()
@Min(0.000001)
rateToBase!: number;
}
export class UpdateOrgCurrenciesDto {
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(8)
baseCurrency?: string;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
enabledCurrencies?: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ExchangeRateInputDto)
rates?: ExchangeRateInputDto[];
}
@@ -0,0 +1,35 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { UpdateOrgCurrenciesDto } from './dto';
import { OrganizationsService } from './organizations.service';
@Controller('organizations')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrganizationsController {
constructor(private readonly organizationsService: OrganizationsService) {}
@Get('current')
current(@CurrentUser() user: { organizationId: string }) {
return this.organizationsService.getCurrent(user.organizationId);
}
@Get('currency-catalog')
catalog() {
return this.organizationsService.catalog();
}
@Patch('current/currencies')
@Roles('owner', 'admin')
updateCurrencies(
@CurrentUser() user: { organizationId: string },
@Body() dto: UpdateOrgCurrenciesDto,
) {
return this.organizationsService.updateCurrencies(
user.organizationId,
dto,
);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { OrganizationsController } from './organizations.controller';
import { OrganizationsService } from './organizations.service';
@Module({
controllers: [OrganizationsController],
providers: [OrganizationsService],
})
export class OrganizationsModule {}
@@ -0,0 +1,154 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
OrganizationDto,
UpdateOrgCurrenciesRequest,
} from '@mento/shared';
import { CURRENCY_CATALOG } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { isKnownCurrency } from '../common/currency';
@Injectable()
export class OrganizationsService {
constructor(private readonly prisma: PrismaService) {}
async getCurrent(organizationId: string): Promise<OrganizationDto> {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
include: {
exchangeRates: { orderBy: { currency: 'asc' } },
},
});
if (!org) {
throw new NotFoundException('سازمان پیدا نشد');
}
return this.toDto(org);
}
async updateCurrencies(
organizationId: string,
dto: UpdateOrgCurrenciesRequest,
): Promise<OrganizationDto> {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
});
if (!org) throw new NotFoundException('سازمان پیدا نشد');
const baseCurrency = (
dto.baseCurrency?.trim() ||
org.baseCurrency ||
'IRR'
).toUpperCase();
if (!isKnownCurrency(baseCurrency)) {
throw new BadRequestException('ارز پایه معتبر نیست');
}
let enabled = (
dto.enabledCurrencies?.map((c) => c.trim().toUpperCase()) ??
org.enabledCurrencies
).filter(Boolean);
if (!enabled.includes(baseCurrency)) {
enabled = [baseCurrency, ...enabled];
}
enabled = [...new Set(enabled)];
for (const code of enabled) {
if (!isKnownCurrency(code)) {
throw new BadRequestException(`ارز ${code} پشتیبانی نمی‌شود`);
}
}
if (dto.rates) {
for (const row of dto.rates) {
const code = row.currency.trim().toUpperCase();
if (!isKnownCurrency(code)) {
throw new BadRequestException(`ارز ${code} پشتیبانی نمی‌شود`);
}
if (code === baseCurrency) continue;
if (row.rateToBase <= 0) {
throw new BadRequestException('نرخ باید بزرگ‌تر از صفر باشد');
}
}
}
await this.prisma.$transaction(async (tx) => {
await tx.organization.update({
where: { id: organizationId },
data: {
baseCurrency,
enabledCurrencies: enabled,
},
});
if (dto.rates) {
for (const row of dto.rates) {
const code = row.currency.trim().toUpperCase();
if (code === baseCurrency) {
await tx.exchangeRate.deleteMany({
where: { organizationId, currency: code },
});
continue;
}
await tx.exchangeRate.upsert({
where: {
organizationId_currency: { organizationId, currency: code },
},
create: {
organizationId,
currency: code,
rateToBase: new Prisma.Decimal(row.rateToBase),
},
update: {
rateToBase: new Prisma.Decimal(row.rateToBase),
},
});
}
}
await tx.exchangeRate.deleteMany({
where: {
organizationId,
OR: [
{ currency: baseCurrency },
{ currency: { notIn: enabled } },
],
},
});
});
return this.getCurrent(organizationId);
}
catalog() {
return CURRENCY_CATALOG;
}
private toDto(org: {
id: string;
name: string;
slug: string;
baseCurrency: string;
enabledCurrencies: string[];
exchangeRates: Array<{
currency: string;
rateToBase: Prisma.Decimal;
updatedAt: Date;
}>;
}): OrganizationDto {
return {
id: org.id,
name: org.name,
slug: org.slug,
baseCurrency: org.baseCurrency,
enabledCurrencies: org.enabledCurrencies,
exchangeRates: org.exchangeRates.map((r) => ({
currency: r.currency,
rateToBase: Number(r.rateToBase),
updatedAt: r.updatedAt.toISOString(),
})),
};
}
}