Multi-environment and multi-user construction
This commit is contained in:
@@ -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('مسئول انتخابشده معتبر نیست');
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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('کاربر هدف معتبر نیست');
|
||||
|
||||
@@ -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('کاربر مسئول معتبر نیست');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user