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
+26
View File
@@ -0,0 +1,26 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto, RegisterOrganizationDto } from './dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
register(@Body() dto: RegisterOrganizationDto) {
return this.authService.registerOrganization(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: { userId: string }) {
return this.authService.me(user.userId);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { RolesGuard } from './roles.guard';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET') ?? 'dev-secret',
signOptions: {
expiresIn: (config.get<string>('JWT_EXPIRES_IN') ?? '7d') as `${number}d`,
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, RolesGuard],
exports: [AuthService, RolesGuard, JwtModule],
})
export class AuthModule {}
+121
View File
@@ -0,0 +1,121 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import type { AuthUserDto, LoginResponse } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto, RegisterOrganizationDto } from './dto';
function slugify(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^\w\u0600-\u06FF]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || `org-${Date.now()}`;
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
async registerOrganization(dto: RegisterOrganizationDto): Promise<LoginResponse> {
const baseSlug = slugify(dto.organizationName);
let slug = baseSlug;
let i = 1;
while (await this.prisma.organization.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${i++}`;
}
const existing = await this.prisma.user.findFirst({
where: { email: dto.email.toLowerCase() },
});
if (existing) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
const passwordHash = await bcrypt.hash(dto.password, 10);
const organization = await this.prisma.organization.create({
data: {
name: dto.organizationName,
slug,
users: {
create: {
email: dto.email.toLowerCase(),
fullName: dto.fullName,
passwordHash,
role: 'owner',
},
},
},
include: { users: true },
});
const user = organization.users[0];
return this.buildLoginResponse(user);
}
async login(dto: LoginDto): Promise<LoginResponse> {
const user = await this.prisma.user.findFirst({
where: { email: dto.email.toLowerCase() },
});
if (!user) {
throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است');
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است');
}
return this.buildLoginResponse(user);
}
async me(userId: string): Promise<AuthUserDto> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException();
}
return this.toAuthUser(user);
}
private buildLoginResponse(user: {
id: string;
email: string;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
}): LoginResponse {
const payload = {
sub: user.id,
organizationId: user.organizationId,
role: user.role,
};
return {
accessToken: this.jwt.sign(payload),
user: this.toAuthUser(user),
};
}
private toAuthUser(user: {
id: string;
email: string;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
}): AuthUserDto {
return {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
organizationId: user.organizationId,
};
}
}
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
+27
View File
@@ -0,0 +1,27 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(6)
password!: string;
}
export class RegisterOrganizationDto {
@IsString()
@MinLength(2)
organizationName!: string;
@IsString()
@MinLength(2)
fullName!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(6)
password!: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+32
View File
@@ -0,0 +1,32 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
export type JwtPayload = {
sub: string;
organizationId: string;
role: string;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET') ?? 'dev-secret',
});
}
validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
if (payload.typ === 'portal') {
throw new UnauthorizedException();
}
return {
userId: payload.sub,
organizationId: payload.organizationId,
role: payload.role,
};
}
}
+6
View File
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import type { UserRole } from '@mento/shared';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+31
View File
@@ -0,0 +1,31 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { UserRole } from '@mento/shared';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!roles?.length) return true;
const request = context.switchToHttp().getRequest<{
user?: { role?: string };
}>();
const role = request.user?.role as UserRole | undefined;
if (!role || !roles.includes(role)) {
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
}
return true;
}
}