Multi-environment and multi-user construction

This commit is contained in:
2026-09-15 21:37:58 +03:30
parent e61fad95c6
commit d3f97b9dc4
37 changed files with 1349 additions and 201 deletions
@@ -272,7 +272,10 @@ export class ActivitiesExcelService {
const n = name?.trim();
if (!n) return null;
const row = await this.prisma.user.findFirst({
where: { organizationId, fullName: { contains: n, mode: 'insensitive' } },
where: {
fullName: { contains: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
return row?.id ?? null;
@@ -420,7 +420,7 @@ export class ActivitiesService {
private async assertUserInOrg(organizationId: string, userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId },
where: { id: userId, memberships: { some: { organizationId } } },
select: { id: true },
});
if (!user) throw new BadRequestException('مسئول انتخاب‌شده معتبر نیست');
+25 -3
View File
@@ -19,6 +19,8 @@ import {
LoginOtpVerifyDto,
RegisterOrganizationDto,
RegisterVerifyOtpDto,
SelectOrganizationDto,
SwitchOrganizationDto,
UpdateProfileDto,
} from './dto';
import { JwtAuthGuard } from './jwt-auth.guard';
@@ -58,16 +60,36 @@ export class AuthController {
return this.authService.verifyLoginOtp(dto);
}
@Post('select-organization')
selectOrganization(@Body() dto: SelectOrganizationDto) {
return this.authService.selectOrganization(dto.selectionToken, dto.organizationId);
}
@UseGuards(JwtAuthGuard)
@Post('switch-organization')
switchOrganization(
@CurrentUser() user: AuthActor,
@Body() dto: SwitchOrganizationDto,
) {
return this.authService.switchOrganization(user.userId, dto.organizationId);
}
@UseGuards(JwtAuthGuard)
@Get('organizations')
listOrganizations(@CurrentUser() user: AuthActor) {
return this.authService.listMyOrganizations(user.userId);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: AuthActor) {
return this.authService.me(user.userId);
return this.authService.me(user.userId, user.organizationId);
}
@UseGuards(JwtAuthGuard)
@Patch('me')
updateMe(@CurrentUser() user: AuthActor, @Body() dto: UpdateProfileDto) {
return this.authService.updateMe(user.userId, dto);
return this.authService.updateMe(user.userId, user.organizationId, dto);
}
/** عکس پروفایل کاربر جاری */
@@ -96,6 +118,6 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@Delete('me/avatar')
removeAvatar(@CurrentUser() user: AuthActor) {
return this.authService.removeAvatar(user.userId);
return this.authService.removeAvatar(user.userId, user.organizationId);
}
}
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MembershipsModule } from '../memberships/memberships.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
@@ -11,6 +12,7 @@ import { SmsService } from '../common/sms.service';
@Module({
imports: [
ConfigModule,
MembershipsModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
+127 -45
View File
@@ -10,11 +10,12 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import * as path from 'path';
import type { AuthUserDto, LoginResponse } from '@mento/shared';
import type { AuthUserDto, LoginResponse, LoginResult } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service';
import {
LoginDto,
LoginOtpRequestDto,
@@ -95,6 +96,7 @@ export class AuthService {
private readonly config: ConfigService,
private readonly sms: SmsService,
private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService,
) {}
/**
@@ -249,6 +251,16 @@ export class AuthService {
include: { users: true },
});
const owner = organization.users[0];
if (!owner) {
throw new BadRequestException('ساخت حساب ناموفق بود');
}
await this.memberships.createMembership({
userId: owner.id,
organizationId: organization.id,
role: 'owner',
});
await this.prisma.authChallenge.update({
where: { id: challenge.id },
data: {
@@ -258,11 +270,11 @@ export class AuthService {
},
});
return this.buildLoginResponse(organization.users[0]);
return this.issueLogin(organization.users[0]!.id, organization.id);
}
/** ورود با رمز: ایمیل یا موبایل + رمز → JWT (بدون OTP) */
async loginWithPassword(dto: LoginDto): Promise<LoginResponse> {
async loginWithPassword(dto: LoginDto): Promise<LoginResult> {
const identifier = (dto.identifier ?? dto.email ?? '').trim();
const isEmail = looksLikeEmail(identifier);
const email = isEmail ? identifier.toLowerCase() : null;
@@ -287,7 +299,7 @@ export class AuthService {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
return this.buildLoginResponse(user);
return this.finishLogin(user.id);
}
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -332,7 +344,7 @@ export class AuthService {
};
}
async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise<LoginResponse> {
async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise<LoginResult> {
const challenge = await this.prisma.authChallenge.findUnique({
where: { id: dto.challengeId },
});
@@ -374,18 +386,19 @@ export class AuthService {
});
}
return this.buildLoginResponse(user);
return this.finishLogin(user.id);
}
async me(userId: string): Promise<AuthUserDto> {
async me(userId: string, organizationId: string): Promise<AuthUserDto> {
await this.memberships.requireActive(userId, organizationId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException();
}
return this.toAuthUserDto(user);
return this.toAuthUserDto(user, organizationId);
}
async updateMe(userId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام باید حداقل دو حرف باشد');
@@ -403,7 +416,7 @@ export class AuthService {
if (email !== existing.email) {
const taken = await this.prisma.user.findFirst({
where: { organizationId: existing.organizationId, email, id: { not: userId } },
where: { email, id: { not: userId } },
});
if (taken) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
@@ -423,7 +436,7 @@ export class AuthService {
where: { id: userId },
data: { fullName, email, mobile },
});
return this.toAuthUserDto(user);
return this.toAuthUserDto(user, organizationId);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
@@ -464,10 +477,10 @@ export class AuthService {
await this.files.remove(prevKey);
}
await this.writeAvatarKey(userId, storageKey);
return this.toAuthUserDto(existing);
return this.toAuthUserDto(existing, organizationId);
}
async removeAvatar(userId: string): Promise<AuthUserDto> {
async removeAvatar(userId: string, organizationId: string): Promise<AuthUserDto> {
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
if (!existing) {
throw new UnauthorizedException();
@@ -475,7 +488,7 @@ export class AuthService {
const prevKey = await this.readAvatarKey(userId);
if (prevKey) await this.files.remove(prevKey);
await this.writeAvatarKey(userId, null);
return this.toAuthUserDto(existing);
return this.toAuthUserDto(existing, organizationId);
}
async openAvatar(userId: string) {
@@ -510,16 +523,106 @@ export class AuthService {
`;
}
private async toAuthUserDto(user: {
id: string;
email: string;
mobile?: string | null;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
}): Promise<AuthUserDto> {
async listMyOrganizations(userId: string) {
const rows = await this.memberships.listActive(userId);
return this.memberships.toChoices(rows);
}
async selectOrganization(
selectionToken: string,
organizationId: string,
): Promise<LoginResponse> {
let payload: { sub?: string; typ?: string };
try {
payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken);
} catch {
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
}
if (payload.typ !== 'org-select' || !payload.sub) {
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
}
return this.issueLogin(payload.sub, organizationId);
}
async switchOrganization(
userId: string,
organizationId: string,
): Promise<LoginResponse> {
return this.issueLogin(userId, organizationId);
}
private async finishLogin(userId: string): Promise<LoginResult> {
const rows = await this.memberships.listActive(userId);
if (!rows.length) {
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
}
if (rows.length === 1) {
return this.issueLogin(userId, rows[0]!.organization.id);
}
return {
requiresOrganizationChoice: true,
selectionToken: this.jwt.sign(
{ sub: userId, typ: 'org-select' },
{ expiresIn: '10m' },
),
organizations: this.memberships.toChoices(rows),
};
}
private async issueLogin(
userId: string,
organizationId: string,
): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.isActive === false) {
throw new UnauthorizedException('حساب کاربری غیرفعال است');
}
await this.memberships.syncUserLastOrg(
userId,
organizationId,
membership.role,
);
await this.prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() },
});
const accessToken = this.jwt.sign({
sub: userId,
organizationId,
role: membership.role,
});
return {
accessToken,
user: await this.toAuthUserDto(
{ ...user, role: membership.role, organizationId },
organizationId,
),
};
}
private async toAuthUserDto(
user: {
id: string;
email: string;
mobile?: string | null;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
},
organizationId: string,
): Promise<AuthUserDto> {
const membership = await this.prisma.membership.findFirst({
where: { userId: user.id, organizationId, isActive: true },
include: { organization: { select: { name: true } } },
});
const role = membership?.role ?? user.role;
const orgName = membership?.organization.name;
return this.toAuthUser({
...user,
role,
organizationId,
organizationName: orgName,
avatarStorageKey: await this.readAvatarKey(user.id),
});
}
@@ -596,29 +699,6 @@ export class AuthService {
this.logger.log(`[auth-otp-sms] ${mobile} => ${code}`);
}
private async buildLoginResponse(user: {
id: string;
email: string;
mobile?: string | null;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
}): Promise<LoginResponse> {
const payload = {
sub: user.id,
organizationId: user.organizationId,
role: user.role,
};
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return {
accessToken: this.jwt.sign(payload),
user: await this.toAuthUserDto(user),
};
}
private toAuthUser(user: {
id: string;
email: string;
@@ -626,6 +706,7 @@ export class AuthService {
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
organizationName?: string;
avatarStorageKey?: string | null;
}): AuthUserDto {
return {
@@ -634,6 +715,7 @@ export class AuthService {
fullName: user.fullName,
role: user.role,
organizationId: user.organizationId,
organizationName: user.organizationName,
hasAvatar: Boolean(user.avatarStorageKey),
...(user.mobile ? { mobile: user.mobile } : {}),
};
+16
View File
@@ -107,3 +107,19 @@ export class UpdateProfileDto {
})
mobile!: string;
}
export class SelectOrganizationDto {
@IsString()
@MinLength(10)
selectionToken!: string;
@IsString()
@MinLength(1)
organizationId!: string;
}
export class SwitchOrganizationDto {
@IsString()
@MinLength(1)
organizationId!: string;
}
+18 -4
View File
@@ -24,20 +24,34 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
if (payload.typ === 'portal') {
if (payload.typ === 'portal' || payload.typ === 'org-select') {
throw new UnauthorizedException();
}
if (!payload.sub || !payload.organizationId) {
throw new UnauthorizedException();
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, organizationId: true, role: true, isActive: true },
select: { id: true, isActive: true },
});
if (!user || user.isActive === false) {
throw new UnauthorizedException();
}
const membership = await this.prisma.membership.findFirst({
where: {
userId: user.id,
organizationId: payload.organizationId,
isActive: true,
},
select: { organizationId: true, role: true },
});
if (!membership) {
throw new UnauthorizedException();
}
return {
userId: user.id,
organizationId: user.organizationId,
role: user.role,
organizationId: membership.organizationId,
role: membership.role,
};
}
}
@@ -350,7 +350,10 @@ export class AutomationsService {
if (wantSms) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId: ctx.organizationId },
where: {
id: userId,
memberships: { some: { organizationId: ctx.organizationId } },
},
select: { mobile: true },
});
await this.sendAutomationSms(user?.mobile, smsText);
@@ -96,10 +96,24 @@ export class ChequeRemindersService {
const staff = await this.prisma.user.findMany({
where: {
organizationId: row.organizationId,
OR: [
{ role: { in: ['owner', 'admin'] } },
...(row.ownerId ? [{ id: row.ownerId }] : []),
{
memberships: {
some: {
organizationId: row.organizationId,
role: { in: ['owner', 'admin'] },
isActive: true,
},
},
},
...(row.ownerId
? [
{
id: row.ownerId,
memberships: { some: { organizationId: row.organizationId } },
},
]
: []),
],
},
select: { id: true, mobile: true },
+11 -1
View File
@@ -75,7 +75,17 @@ export class CommissionsService {
const fixed = invoice.commissionFixedAmount;
const overridePercent = invoice.commissionPercent;
const defaultPercent = Number(invoice.owner?.commissionPercent ?? 0);
const membership = invoice.ownerId
? await this.prisma.membership.findFirst({
where: {
userId: invoice.ownerId,
organizationId,
isActive: true,
},
select: { commissionPercent: true },
})
: null;
const defaultPercent = Number(membership?.commissionPercent ?? 0);
const percent =
fixed != null
? payable > 0
@@ -242,7 +242,11 @@ export class LeadFormsService {
ownerId ??
(
await this.prisma.user.findFirst({
where: { organizationId: form.organizationId, role: 'owner' },
where: {
memberships: {
some: { organizationId: form.organizationId, role: 'owner' },
},
},
select: { id: true },
})
)?.id;
@@ -447,7 +451,10 @@ export class LeadFormsService {
) {
if (!assigneeIds?.length) return;
const count = await this.prisma.user.count({
where: { organizationId, id: { in: assigneeIds } },
where: {
id: { in: assigneeIds },
memberships: { some: { organizationId } },
},
});
if (count !== assigneeIds.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MembershipsService } from './memberships.service';
@Module({
providers: [MembershipsService],
exports: [MembershipsService],
})
export class MembershipsModule {}
@@ -0,0 +1,161 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import type { OrganizationMembershipDto, UserRole } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type MembershipActor = {
userId: string;
organizationId: string;
role: UserRole;
};
@Injectable()
export class MembershipsService {
constructor(private readonly prisma: PrismaService) {}
async listActive(userId: string) {
return this.prisma.membership.findMany({
where: { userId, isActive: true },
include: {
organization: { select: { id: true, name: true } },
orgRole: { select: { name: true } },
},
orderBy: { createdAt: 'asc' },
});
}
async requireActive(
userId: string,
organizationId: string,
): Promise<MembershipActor> {
const row = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
select: { userId: true, organizationId: true, role: true },
});
if (!row) {
throw new ForbiddenException('به این محیط دسترسی ندارید');
}
return row;
}
toChoices(
rows: Array<{
organization: { id: string; name: string };
role: UserRole;
orgRole: { name: string } | null;
}>,
): OrganizationMembershipDto[] {
return rows.map((row) => ({
id: row.organization.id,
name: row.organization.name,
role: row.role,
orgRoleName: row.orgRole?.name,
}));
}
async createMembership(data: {
userId: string;
organizationId: string;
role: UserRole;
orgRoleId?: string | null;
commissionPercent?: number;
isActive?: boolean;
}) {
return this.prisma.membership.create({
data: {
userId: data.userId,
organizationId: data.organizationId,
role: data.role,
orgRoleId: data.orgRoleId ?? null,
isActive: data.isActive ?? true,
commissionPercent:
data.commissionPercent == null
? undefined
: new Prisma.Decimal(data.commissionPercent),
},
});
}
async findInOrg(organizationId: string, userId: string) {
return this.prisma.membership.findFirst({
where: { organizationId, userId },
include: {
user: true,
orgRole: { select: { id: true, name: true, color: true } },
},
});
}
async listInOrg(organizationId: string) {
return this.prisma.membership.findMany({
where: { organizationId },
include: {
user: true,
orgRole: { select: { id: true, name: true, color: true } },
},
orderBy: [{ user: { lastLoginAt: 'desc' } }, { user: { fullName: 'asc' } }],
});
}
async countActiveOwners(organizationId: string, exceptUserId?: string) {
return this.prisma.membership.count({
where: {
organizationId,
role: 'owner',
isActive: true,
...(exceptUserId ? { userId: { not: exceptUserId } } : {}),
},
});
}
async syncUserLastOrg(
userId: string,
organizationId: string,
role: UserRole,
orgRoleId?: string | null,
) {
await this.prisma.user.update({
where: { id: userId },
data: {
organizationId,
role,
...(orgRoleId !== undefined ? { orgRoleId } : {}),
},
});
}
async findUserInOrgById(organizationId: string, userId: string) {
const row = await this.findInOrg(organizationId, userId);
if (!row) throw new NotFoundException('کاربر پیدا نشد');
return row;
}
async findUserInOrgByIdentity(
organizationId: string,
where: { id?: string; email?: string; mobile?: string },
) {
const user = await this.prisma.user.findFirst({
where: {
...(where.id ? { id: where.id } : {}),
...(where.email ? { email: where.email } : {}),
...(where.mobile ? { mobile: where.mobile } : {}),
},
select: { id: true },
});
if (!user) return null;
return this.prisma.membership.findFirst({
where: { organizationId, userId: user.id },
include: { user: true },
});
}
}
export function unauthorizedIfMissing<T>(value: T | null | undefined): T {
if (!value) throw new UnauthorizedException();
return value;
}
@@ -384,7 +384,10 @@ export class MessageAutomationsService {
}
if (userId && wantSms) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId: job.organizationId },
where: {
id: userId,
memberships: { some: { organizationId: job.organizationId } },
},
select: { mobile: true },
});
await this.sendSms(user?.mobile, smsText);
+28 -32
View File
@@ -23,13 +23,24 @@ type RoleRow = {
color: string;
permissions: string[];
systemKey: string | null;
users: Array<{
id: string;
fullName: string;
avatarStorageKey: string | null;
memberships: Array<{
user: {
id: string;
fullName: string;
avatarStorageKey: string | null;
};
}>;
};
const ROLE_MEMBERS_INCLUDE = {
memberships: {
include: {
user: { select: { id: true, fullName: true, avatarStorageKey: true } },
},
orderBy: { createdAt: 'asc' as const },
},
};
@Injectable()
export class OrgRolesService {
constructor(private readonly prisma: PrismaService) {}
@@ -38,12 +49,7 @@ export class OrgRolesService {
await this.ensureDefaultRoles(organizationId);
const rows = await this.prisma.orgRole.findMany({
where: { organizationId },
include: {
users: {
select: { id: true, fullName: true, avatarStorageKey: true },
orderBy: { fullName: 'asc' },
},
},
include: ROLE_MEMBERS_INCLUDE,
orderBy: [{ systemKey: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
@@ -73,12 +79,7 @@ export class OrgRolesService {
color: dto.color ?? 'teal',
permissions: dto.permissions?.length ? dto.permissions : [...ALL_ROLE_PERMISSIONS],
},
include: {
users: {
select: { id: true, fullName: true, avatarStorageKey: true },
orderBy: { fullName: 'asc' },
},
},
include: ROLE_MEMBERS_INCLUDE,
});
return this.toDto(row);
} catch (e) {
@@ -122,12 +123,7 @@ export class OrgRolesService {
const row = await this.prisma.orgRole.update({
where: { id: existing.id },
data,
include: {
users: {
select: { id: true, fullName: true, avatarStorageKey: true },
orderBy: { fullName: 'asc' },
},
},
include: ROLE_MEMBERS_INCLUDE,
});
return this.toDto(row);
} catch (e) {
@@ -189,15 +185,15 @@ export class OrgRolesService {
byKey.set(seed.systemKey, created);
}
const unassigned = await this.prisma.user.findMany({
const unassigned = await this.prisma.membership.findMany({
where: { organizationId, orgRoleId: null },
select: { id: true, role: true },
});
for (const user of unassigned) {
const role = byKey.get(user.role);
for (const membership of unassigned) {
const role = byKey.get(membership.role);
if (!role) continue;
await this.prisma.user.update({
where: { id: user.id },
await this.prisma.membership.update({
where: { id: membership.id },
data: { orgRoleId: role.id },
});
}
@@ -253,11 +249,11 @@ export class OrgRolesService {
color: row.color,
permissions: sanitizePermissions(row.permissions),
systemKey,
userCount: row.users.length,
members: row.users.map((user) => ({
id: user.id,
fullName: user.fullName,
hasAvatar: Boolean(user.avatarStorageKey),
userCount: row.memberships.length,
members: row.memberships.map((membership) => ({
id: membership.user.id,
fullName: membership.user.fullName,
hasAvatar: Boolean(membership.user.avatarStorageKey),
})),
};
}
+11
View File
@@ -45,6 +45,17 @@ export class UpdateOrgCurrenciesDto {
rates?: ExchangeRateInputDto[];
}
export class CreateOrganizationDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsIn([...BUSINESS_FIELDS])
businessField?: (typeof BUSINESS_FIELDS)[number];
}
export class UpdateOrganizationDto {
@IsOptional()
@IsString()
@@ -1,15 +1,32 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Patch, Post, 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, UpdateOrganizationDto } from './dto';
import { AuthService } from '../auth/auth.service';
import {
CreateOrganizationDto,
UpdateOrgCurrenciesDto,
UpdateOrganizationDto,
} from './dto';
import { OrganizationsService } from './organizations.service';
@Controller('organizations')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrganizationsController {
constructor(private readonly organizationsService: OrganizationsService) {}
constructor(
private readonly organizationsService: OrganizationsService,
private readonly authService: AuthService,
) {}
@Post()
async create(
@CurrentUser() user: { userId: string },
@Body() dto: CreateOrganizationDto,
) {
const org = await this.organizationsService.createForUser(user.userId, dto);
return this.authService.switchOrganization(user.userId, org.id);
}
@Get('current')
current(@CurrentUser() user: { organizationId: string }) {
@@ -1,8 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MembershipsModule } from '../memberships/memberships.module';
import { OrgRolesModule } from '../org-roles/org-roles.module';
import { PipelinesModule } from '../pipelines/pipelines.module';
import { OrganizationsController } from './organizations.controller';
import { OrganizationsService } from './organizations.service';
@Module({
imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule],
controllers: [OrganizationsController],
providers: [OrganizationsService],
})
@@ -2,6 +2,7 @@ import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import type {
OrganizationDto,
@@ -11,10 +12,74 @@ import { CURRENCY_CATALOG, isBusinessField } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { isKnownCurrency } from '../common/currency';
import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
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 OrganizationsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly memberships: MembershipsService,
private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService,
) {}
async createForUser(
userId: string,
dto: { name: string; businessField?: string },
): Promise<{ id: string }> {
const name = dto.name.trim();
if (name.length < 2) {
throw new BadRequestException('نام محیط معتبر نیست');
}
const businessField =
dto.businessField && isBusinessField(dto.businessField)
? dto.businessField
: 'personal';
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, isActive: true },
});
if (!user || user.isActive === false) {
throw new UnauthorizedException();
}
const baseSlug = slugify(name);
let slug = baseSlug;
let i = 1;
while (await this.prisma.organization.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${i++}`;
}
const organization = await this.prisma.organization.create({
data: {
name,
slug,
businessField,
},
});
await this.memberships.createMembership({
userId,
organizationId: organization.id,
role: 'owner',
});
await this.orgRoles.ensureDefaultRoles(organization.id);
await this.pipelines.ensureDefaultPipeline(organization.id);
return { id: organization.id };
}
async getCurrent(organizationId: string): Promise<OrganizationDto> {
const org = await this.prisma.organization.findUnique({
@@ -311,7 +311,10 @@ export class DealsExcelService {
const n = name?.trim();
if (!n) return null;
const row = await this.prisma.user.findFirst({
where: { organizationId, fullName: { contains: n, mode: 'insensitive' } },
where: {
fullName: { contains: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
return row?.id ?? null;
+7 -2
View File
@@ -234,8 +234,13 @@ export class PortalService {
const owner =
(await this.prisma.user.findFirst({
where: {
organizationId: actor.organizationId,
role: { in: ['owner', 'admin', 'support'] },
memberships: {
some: {
organizationId: actor.organizationId,
role: { in: ['owner', 'admin', 'support'] },
isActive: true,
},
},
},
orderBy: { createdAt: 'asc' },
})) ?? null;
@@ -258,7 +258,10 @@ export class ProjectCardsExcelService {
const n = name?.trim();
if (!n) return null;
const row = await this.prisma.user.findFirst({
where: { organizationId, fullName: { contains: n, mode: 'insensitive' } },
where: {
fullName: { contains: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
return row?.id ?? null;
+1 -1
View File
@@ -129,7 +129,7 @@ export class ReportsService {
select: { mood: true, createdAt: true },
}),
this.prisma.user.findMany({
where: { organizationId: orgId },
where: { memberships: { some: { organizationId: orgId } } },
select: { id: true, fullName: true },
}),
this.prisma.invoice.findMany({
+4 -1
View File
@@ -425,7 +425,10 @@ export class SalesService {
return null;
}
const user = await this.prisma.user.findFirst({
where: { id: requested, organizationId: actor.organizationId },
where: {
id: requested,
memberships: { some: { organizationId: actor.organizationId } },
},
select: { id: true },
});
if (!user) throw new BadRequestException('کاربر هدف معتبر نیست');
+1 -1
View File
@@ -432,7 +432,7 @@ export class TicketsService {
private async assertUser(organizationId: string, userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId },
where: { id: userId, memberships: { some: { organizationId } } },
select: { id: true },
});
if (!user) throw new BadRequestException('کاربر مسئول معتبر نیست');
+4 -1
View File
@@ -10,6 +10,7 @@ import {
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const INVITE_ROLES = ['admin', 'sales', 'support'] as const;
@@ -27,10 +28,12 @@ export class InviteUserDto {
@MaxLength(120)
fullName!: string;
@IsOptional()
@ValidateIf((_, value) => typeof value === 'string' && value.length > 0)
@IsString()
@MinLength(8)
@MaxLength(72)
password!: string;
password?: string;
@IsOptional()
@IsIn(INVITE_ROLES)
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MembershipsModule } from '../memberships/memberships.module';
import { OrgRolesModule } from '../org-roles/org-roles.module';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [AuthModule, OrgRolesModule],
imports: [AuthModule, OrgRolesModule, MembershipsModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
+131 -74
View File
@@ -11,6 +11,7 @@ import { Prisma } from '@prisma/client';
import { normalizeMobile } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { MembershipsService } from '../memberships/memberships.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import * as path from 'path';
@@ -26,19 +27,22 @@ const AVATAR_EXT: Record<string, string> = {
gif: 'image/gif',
};
type UserRow = {
id: string;
email: string;
mobile: string | null;
fullName: string;
type MembershipRow = {
role: UserRole;
orgRoleId: string | null;
isActive: boolean;
commissionPercent: { toString(): string } | number | null;
createdAt: Date;
isActive: boolean;
lastLoginAt: Date | null;
orgRoleId: string | null;
avatarStorageKey: string | null;
orgRole: { id: string; name: string; color: string } | null;
user: {
id: string;
email: string;
mobile: string | null;
fullName: string;
lastLoginAt: Date | null;
avatarStorageKey: string | null;
organizationId: string;
};
};
@Injectable()
@@ -46,17 +50,14 @@ export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly orgRoles: OrgRolesService,
private readonly memberships: MembershipsService,
private readonly files: LocalFileStorage,
) {}
async list(organizationId: string): Promise<OrgUserDto[]> {
await this.orgRoles.ensureDefaultRoles(organizationId);
const rows = await this.prisma.user.findMany({
where: { organizationId },
include: { orgRole: { select: { id: true, name: true, color: true } } },
orderBy: [{ lastLoginAt: 'desc' }, { fullName: 'asc' }],
});
return rows.map((u) => this.toDto(u));
const rows = await this.memberships.listInOrg(organizationId);
return rows.map((row) => this.toDto(row));
}
async invite(
@@ -73,19 +74,6 @@ export class UsersService {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const existingMobile = await this.prisma.user.findFirst({ where: { mobile } });
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const email = inviteEmailFromMobile(mobile);
const existingEmail = await this.prisma.user.findFirst({
where: { organizationId, email },
});
if (existingEmail) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const resolved = await this.orgRoles.resolveInviteRole(organizationId, {
orgRoleId: dto.orgRoleId,
role: dto.role,
@@ -94,9 +82,27 @@ export class UsersService {
throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند');
}
const passwordHash = await bcrypt.hash(dto.password, 10);
const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
if (existingUser) {
return this.addExistingUser(organizationId, existingUser.id, resolved);
}
const password = dto.password?.trim() ?? '';
if (password.length < 8) {
throw new BadRequestException(
'برای کاربر جدید رمز عبور حداقل ۸ کاراکتر لازم است',
);
}
const email = inviteEmailFromMobile(mobile);
const existingEmail = await this.prisma.user.findFirst({ where: { email } });
if (existingEmail) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const row = await this.prisma.user.create({
const created = await this.prisma.user.create({
data: {
organizationId,
email,
@@ -107,9 +113,14 @@ export class UsersService {
orgRoleId: resolved.orgRoleId,
isActive: true,
},
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
await this.memberships.createMembership({
userId: created.id,
organizationId,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
});
return this.dtoOf(organizationId, created.id);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
@@ -130,16 +141,18 @@ export class UsersService {
if (percent < 0 || percent > 100) {
throw new BadRequestException('درصد پورسانت باید بین ۰ تا ۱۰۰ باشد');
}
const target = await this.prisma.user.findFirst({
where: { id: targetId, organizationId },
});
if (!target) throw new NotFoundException('کاربر پیدا نشد');
const row = await this.prisma.user.update({
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
await this.prisma.membership.update({
where: { id: target.id },
data: { commissionPercent: new Prisma.Decimal(percent) },
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
if (target.user.organizationId === organizationId) {
await this.prisma.user.update({
where: { id: target.user.id },
data: { commissionPercent: new Prisma.Decimal(percent) },
});
}
return this.dtoOf(organizationId, targetId);
}
async updateRole(
@@ -152,10 +165,7 @@ export class UsersService {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش را تغییر دهد');
}
const target = await this.prisma.user.findFirst({
where: { id: targetId, organizationId },
});
if (!target) throw new NotFoundException('کاربر پیدا نشد');
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
let nextRole = dto.role ?? target.role;
let nextOrgRoleId = dto.orgRoleId ?? target.orgRoleId;
@@ -186,22 +196,30 @@ export class UsersService {
}
if (target.role === 'owner' && nextRole !== 'owner') {
const owners = await this.prisma.user.count({
where: { organizationId, role: 'owner' },
});
if (owners <= 1) {
const owners = await this.memberships.countActiveOwners(
organizationId,
target.userId,
);
if (owners < 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
);
}
}
const row = await this.prisma.user.update({
await this.prisma.membership.update({
where: { id: target.id },
data: { role: nextRole, orgRoleId: nextOrgRoleId },
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
if (target.user.organizationId === organizationId) {
await this.memberships.syncUserLastOrg(
target.userId,
organizationId,
nextRole,
nextOrgRoleId,
);
}
return this.dtoOf(organizationId, targetId);
}
async updateStatus(
@@ -216,58 +234,97 @@ export class UsersService {
if (actor.userId === targetId && !isActive) {
throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید');
}
const target = await this.prisma.user.findFirst({
where: { id: targetId, organizationId },
});
if (!target) throw new NotFoundException('کاربر پیدا نشد');
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner' && !isActive && actor.role !== 'owner') {
throw new ForbiddenException('مدیر نمی‌تواند مالک را غیرفعال کند');
}
if (target.role === 'owner' && !isActive) {
const activeOwners = await this.prisma.user.count({
where: { organizationId, role: 'owner', isActive: true, id: { not: targetId } },
});
const activeOwners = await this.memberships.countActiveOwners(
organizationId,
targetId,
);
if (activeOwners < 1) {
throw new BadRequestException('حداقل یک مالک فعال لازم است');
}
}
const row = await this.prisma.user.update({
await this.prisma.membership.update({
where: { id: target.id },
data: { isActive },
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
return this.dtoOf(organizationId, targetId);
}
async openAvatar(organizationId: string, userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId },
select: { id: true, avatarStorageKey: true },
});
if (!user?.avatarStorageKey) {
const membership = await this.memberships.findInOrg(organizationId, userId);
if (!membership) throw new NotFoundException('کاربر پیدا نشد');
const key = membership.user.avatarStorageKey;
if (!key) {
throw new NotFoundException('عکس پروفایل تنظیم نشده');
}
const ext = path.extname(user.avatarStorageKey).slice(1).toLowerCase();
const ext = path.extname(key).slice(1).toLowerCase();
const mime = AVATAR_EXT[ext] ?? 'image/jpeg';
return {
stream: this.files.openReadStream(user.avatarStorageKey),
stream: this.files.openReadStream(key),
mimeType: mime,
};
}
private toDto(row: UserRow): OrgUserDto {
private async addExistingUser(
organizationId: string,
userId: string,
resolved: { role: UserRole; orgRoleId: string },
): Promise<OrgUserDto> {
const existing = await this.prisma.membership.findFirst({
where: { userId, organizationId },
});
if (existing?.isActive) {
throw new ConflictException('این کاربر هم‌اکنون عضو این محیط است');
}
if (existing) {
await this.prisma.membership.update({
where: { id: existing.id },
data: {
isActive: true,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
},
});
return this.dtoOf(organizationId, userId);
}
try {
await this.memberships.createMembership({
userId,
organizationId,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این کاربر هم‌اکنون عضو این محیط است');
}
throw e;
}
return this.dtoOf(organizationId, userId);
}
private async dtoOf(organizationId: string, userId: string): Promise<OrgUserDto> {
const row = await this.memberships.findUserInOrgById(organizationId, userId);
return this.toDto(row);
}
private toDto(row: MembershipRow & { userId?: string }): OrgUserDto {
return {
id: row.id,
email: row.email,
mobile: row.mobile ?? null,
fullName: row.fullName,
id: row.user.id,
email: row.user.email,
mobile: row.user.mobile ?? null,
fullName: row.user.fullName,
role: row.role,
orgRoleId: row.orgRoleId,
orgRoleName: row.orgRole?.name,
orgRoleColor: row.orgRole?.color ?? this.orgRoles.defaultColorFor(row.role),
isActive: row.isActive,
lastLoginAt: row.lastLoginAt ? row.lastLoginAt.toISOString() : null,
hasAvatar: Boolean(row.avatarStorageKey),
lastLoginAt: row.user.lastLoginAt ? row.user.lastLoginAt.toISOString() : null,
hasAvatar: Boolean(row.user.avatarStorageKey),
commissionPercent: Number(row.commissionPercent ?? 0),
createdAt: row.createdAt.toISOString(),
};