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
+54
View File
@@ -0,0 +1,54 @@
import { CURRENCY_CATALOG } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type RateMap = {
baseCurrency: string;
/** currency code → how many base units for 1 unit of that currency */
rates: Map<string, number>;
};
export function isKnownCurrency(code: string): boolean {
return CURRENCY_CATALOG.some((c) => c.code === code);
}
export async function loadOrgRates(
prisma: PrismaService,
organizationId: string,
): Promise<RateMap> {
const org = await prisma.organization.findUnique({
where: { id: organizationId },
select: {
baseCurrency: true,
exchangeRates: { select: { currency: true, rateToBase: true } },
},
});
const baseCurrency = org?.baseCurrency || 'IRR';
const rates = new Map<string, number>();
rates.set(baseCurrency, 1);
for (const row of org?.exchangeRates ?? []) {
rates.set(row.currency, Number(row.rateToBase));
}
return { baseCurrency, rates };
}
export function toBaseAmount(
amount: number | null | undefined,
currency: string | null | undefined,
map: RateMap,
): number {
if (amount == null || Number.isNaN(amount)) return 0;
const code = (currency || map.baseCurrency).trim() || map.baseCurrency;
if (code === map.baseCurrency) return amount;
const rate = map.rates.get(code);
if (rate == null || rate <= 0) return amount;
return amount * rate;
}
export function decimalToBase(
amount: Prisma.Decimal | null | undefined,
currency: string | null | undefined,
map: RateMap,
): number {
return toBaseAmount(amount == null ? 0 : Number(amount), currency, map);
}
+14
View File
@@ -0,0 +1,14 @@
import { Prisma } from '@prisma/client';
export function asCustomFields(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
export function toJsonCustomFields(
value?: Record<string, unknown> | null,
): Prisma.InputJsonValue {
return (value ?? {}) as Prisma.InputJsonValue;
}
+70
View File
@@ -0,0 +1,70 @@
import type { UserRole } from '@mento/shared';
import { canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
export type Actor = {
userId: string;
role: string;
organizationId: string;
};
export function elevated(role: string | null | undefined): boolean {
return canViewAllRecords(role);
}
/** فیلتر مالکیت برای Deal / Quote / Invoice / Person / Company */
export function ownerWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): { ownerId: string } | Record<string, never> {
if (elevated(actor.role)) return {};
return { ownerId: actor.userId };
}
/** فعالیت‌ها بر اساس مسئول */
export function assigneeWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): { assigneeId: string } | Record<string, never> {
if (elevated(actor.role)) return {};
return { assigneeId: actor.userId };
}
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت */
export function noteAccessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): Prisma.NoteWhereInput {
if (elevated(actor.role)) return {};
return {
OR: [
{ authorId: actor.userId },
{ deal: { ownerId: actor.userId } },
{ person: { ownerId: actor.userId } },
{ company: { ownerId: actor.userId } },
],
};
}
export function resolveOwnerId(
actor: Pick<Actor, 'userId' | 'role'>,
requested?: string | null,
): string {
if (elevated(actor.role) && requested?.trim()) {
return requested.trim();
}
return actor.userId;
}
/** تیکت: مسئول یا ثبت‌کننده */
export function ticketAccessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): Prisma.TicketWhereInput {
if (elevated(actor.role)) return {};
return {
OR: [{ assigneeId: actor.userId }, { reporterId: actor.userId }],
};
}
export function canReassignOwner(role: string | null | undefined): boolean {
return elevated(role);
}
export type OrgRole = UserRole;
+275
View File
@@ -0,0 +1,275 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/** پنل ترِز / رایگان — همان الگوی montime */
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
const SMS_TREZ_DEFAULT_SEND_PATH = '/SendMessageWithCode.ashx';
const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx';
const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
export type SmsSendResult = {
ok: boolean;
transport: 'trez' | 'soap' | 'link';
reason?: string;
};
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly config: ConfigService) {}
isConfigured(): boolean {
const username = this.config.get<string>('SMS_USERNAME');
const password = this.getPassword();
const sender = this.config.get<string>('SMS_SENDER');
return Boolean(username && password && sender);
}
normalizePhoneForSms(mobile: string): string {
const digits = String(mobile ?? '').replace(/\D/g, '');
if (digits.startsWith('98') && digits.length === 12) {
return `0${digits.slice(2)}`;
}
if (digits.startsWith('0') && digits.length === 11) {
return digits;
}
if (digits.length === 10 && digits.startsWith('9')) {
return `0${digits}`;
}
return digits;
}
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
const template =
this.config.get<string>('SMS_OTP_TEMPLATE') ??
'کد ورود پرتال منتو: {code}';
const text = template.replace('{code}', code);
const result = await this.sendSmsDetailed(mobile, text);
return result.ok;
}
async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> {
const username = this.config.get<string>('SMS_USERNAME');
const password = this.getPassword();
const sender = this.config.get<string>('SMS_SENDER');
if (!username || !password || !sender) {
return { ok: false, transport: 'trez', reason: 'SMS not configured' };
}
const to = this.normalizePhoneForSms(phone);
const provider = this.resolveProvider();
if (provider === 'trez') {
return this.sendViaTrez(username, password, sender, to, text);
}
const transport = this.resolve0098Transport();
if (transport === 'link') {
return this.sendVia0098Link(username, password, sender, to, text);
}
const soapResult = await this.sendVia0098Soap(
username,
password,
sender,
to,
text,
);
if (soapResult.ok) return soapResult;
this.logger.warn(
`0098 SOAP failed to=${this.maskPhone(to)} reason=${soapResult.reason}; link fallback`,
);
return this.sendVia0098Link(username, password, sender, to, text);
}
private resolveProvider(): 'trez' | '0098' {
const raw = (this.config.get<string>('SMS_PROVIDER') ?? 'trez').toLowerCase();
return raw === '0098' ? '0098' : 'trez';
}
private resolve0098Transport(): 'soap' | 'link' {
const raw = (this.config.get<string>('SMS_TRANSPORT') ?? 'soap').toLowerCase();
return raw === 'link' ? 'link' : 'soap';
}
private async sendViaTrez(
username: string,
password: string,
sender: string,
to: string,
text: string,
): Promise<SmsSendResult> {
const baseUrl = (
this.config.get<string>('SMS_BASE_URL') ?? SMS_TREZ_DEFAULT_BASE_URL
).replace(/\/$/, '');
const path =
this.config.get<string>('SMS_SEND_PATH') ?? SMS_TREZ_DEFAULT_SEND_PATH;
const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
const body = new URLSearchParams({
PhoneNumber: sender,
Username: username,
Password: password,
Mobile: to,
Message: text,
});
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(this.readTimeoutMs()),
});
const responseBody = (await res.text()).trim();
if (!res.ok) {
return { ok: false, transport: 'trez', reason: `HTTP ${res.status}` };
}
const parsed = this.parseTrezResponse(responseBody);
if (!parsed.ok) {
this.logger.warn(
`Trez rejected to=${this.maskPhone(to)} reason=${parsed.reason}`,
);
}
return { ...parsed, transport: 'trez' };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
this.logger.error(`Trez failed to=${this.maskPhone(to)}: ${message}`);
return { ok: false, transport: 'trez', reason: 'Trez request failed' };
}
}
private parseTrezResponse(body: string): { ok: boolean; reason: string } {
if (!body) return { ok: true, reason: 'accepted (empty response)' };
const trimmed = body.trim();
const lower = trimmed.toLowerCase();
if (
lower.includes('error') ||
lower.includes('fail') ||
lower.includes('invalid') ||
lower.includes('خطا')
) {
return { ok: false, reason: trimmed.slice(0, 120) };
}
if (/^-?\d+$/.test(trimmed)) {
const n = Number(trimmed);
if (n === 1 || n > 1) return { ok: true, reason: 'accepted' };
if (n === 0) return { ok: false, reason: 'provider code 0' };
if (n < 0) return { ok: false, reason: `provider code ${trimmed}` };
}
if (lower === 'true' || lower === 'ok' || lower === 'success') {
return { ok: true, reason: 'accepted' };
}
if (lower === 'false') return { ok: false, reason: 'provider returned false' };
return { ok: true, reason: 'accepted' };
}
private async sendVia0098Soap(
username: string,
password: string,
sender: string,
to: string,
text: string,
): Promise<SmsSendResult> {
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<SendSMS xmlns="http://tempuri.org/">
<username>${this.escapeXml(username)}</username>
<password>${this.escapeXml(password)}</password>
<text>${this.escapeXml(text)}</text>
<mobileno>${this.escapeXml(to)}</mobileno>
<pnlno>${this.escapeXml(sender)}</pnlno>
</SendSMS>
</soap:Body>
</soap:Envelope>`;
try {
const res = await fetch(SMS_0098_SOAP_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: 'http://tempuri.org/SendSMS',
},
body: soapBody,
signal: AbortSignal.timeout(this.readTimeoutMs()),
});
const body = (await res.text()).trim();
if (!res.ok) return { ok: false, transport: 'soap', reason: `HTTP ${res.status}` };
const code =
body.match(/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/i)?.[1]?.trim() ??
null;
const n = code != null ? Number(code) : NaN;
if (Number.isFinite(n) && n >= 0) return { ok: true, transport: 'soap' };
return {
ok: false,
transport: 'soap',
reason: code ? `provider code ${code}` : 'unknown SOAP response',
};
} catch {
return { ok: false, transport: 'soap', reason: 'SOAP request failed' };
}
}
private async sendVia0098Link(
username: string,
password: string,
sender: string,
to: string,
text: string,
): Promise<SmsSendResult> {
const domain = this.config.get<string>('SMS_DOMAIN') ?? '0098';
const params = new URLSearchParams({
FROM: sender,
TO: to,
TEXT: text,
USERNAME: username,
PASSWORD: password,
DOMAIN: domain,
});
try {
const res = await fetch(`${SMS_0098_LINK_URL}?${params}`, {
signal: AbortSignal.timeout(this.readTimeoutMs()),
});
const body = (await res.text()).trim();
if (!res.ok) return { ok: false, transport: 'link', reason: `HTTP ${res.status}` };
const lower = body.toLowerCase();
if (lower.includes('error') || lower.includes('fail')) {
return { ok: false, transport: 'link', reason: body.slice(0, 120) };
}
return { ok: true, transport: 'link' };
} catch {
return { ok: false, transport: 'link', reason: 'link request failed' };
}
}
private escapeXml(value: string): string {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
private readTimeoutMs(): number {
const raw = this.config.get<string>('SMS_TIMEOUT_MS');
const n = raw ? Number.parseInt(raw, 10) : 20_000;
return Number.isFinite(n) && n > 0 ? n : 20_000;
}
private maskPhone(phone: string): string {
if (phone.length <= 6) return '***';
return `${phone.slice(0, 4)}****${phone.slice(-2)}`;
}
private getPassword(): string | undefined {
return (
this.config.get<string>('SMS_PASSWORD') ??
this.config.get<string>('SMS_API_KEY')
);
}
}