diff --git a/apps/api/prisma/migrations/20260915190000_memberships/migration.sql b/apps/api/prisma/migrations/20260915190000_memberships/migration.sql
new file mode 100644
index 0000000..699e7bf
--- /dev/null
+++ b/apps/api/prisma/migrations/20260915190000_memberships/migration.sql
@@ -0,0 +1,55 @@
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "Membership" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "role" "UserRole" NOT NULL DEFAULT 'sales',
+ "orgRoleId" TEXT,
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "commissionPercent" DECIMAL(8,2) NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS "Membership_userId_organizationId_key" ON "Membership"("userId", "organizationId");
+CREATE INDEX IF NOT EXISTS "Membership_organizationId_role_idx" ON "Membership"("organizationId", "role");
+CREATE INDEX IF NOT EXISTS "Membership_userId_idx" ON "Membership"("userId");
+CREATE INDEX IF NOT EXISTS "Membership_orgRoleId_idx" ON "Membership"("orgRoleId");
+
+ALTER TABLE "Membership" DROP CONSTRAINT IF EXISTS "Membership_userId_fkey";
+ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "Membership" DROP CONSTRAINT IF EXISTS "Membership_organizationId_fkey";
+ALTER TABLE "Membership" ADD CONSTRAINT "Membership_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "Membership" DROP CONSTRAINT IF EXISTS "Membership_orgRoleId_fkey";
+ALTER TABLE "Membership" ADD CONSTRAINT "Membership_orgRoleId_fkey" FOREIGN KEY ("orgRoleId") REFERENCES "OrgRole"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+INSERT INTO "Membership" (
+ "id",
+ "userId",
+ "organizationId",
+ "role",
+ "orgRoleId",
+ "isActive",
+ "commissionPercent",
+ "createdAt",
+ "updatedAt"
+)
+SELECT
+ md5(random()::text || clock_timestamp()::text || u."id")::text,
+ u."id",
+ u."organizationId",
+ u."role",
+ u."orgRoleId",
+ COALESCE(u."isActive", true),
+ COALESCE(u."commissionPercent", 0),
+ u."createdAt",
+ CURRENT_TIMESTAMP
+FROM "User" u
+WHERE NOT EXISTS (
+ SELECT 1 FROM "Membership" m
+ WHERE m."userId" = u."id" AND m."organizationId" = u."organizationId"
+);
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index c08dcb1..0b1827d 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -150,6 +150,7 @@ model Organization {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
+ memberships Membership[]
orgRoles OrgRole[]
companies Company[]
people Person[]
@@ -227,6 +228,7 @@ model User {
reportedTickets Ticket[] @relation("TicketReporter")
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
+ memberships Membership[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -248,6 +250,7 @@ model OrgRole {
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
systemKey String?
users User[]
+ memberships Membership[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -255,6 +258,26 @@ model OrgRole {
@@index([organizationId])
}
+model Membership {
+ id String @id @default(cuid())
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ role UserRole @default(sales)
+ orgRoleId String?
+ orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull)
+ isActive Boolean @default(true)
+ commissionPercent Decimal @default(0) @db.Decimal(8, 2)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([userId, organizationId])
+ @@index([organizationId, role])
+ @@index([userId])
+ @@index([orgRoleId])
+}
+
model Company {
id String @id @default(cuid())
organizationId String
diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts
index 57775bc..c95bad0 100644
--- a/apps/api/prisma/seed.ts
+++ b/apps/api/prisma/seed.ts
@@ -139,6 +139,27 @@ async function main() {
console.log('Seeded admin user:', adminEmail, '/ demo1234');
}
+ async function ensureMembership(
+ userId: string,
+ organizationId: string,
+ role: 'owner' | 'admin' | 'sales' | 'support',
+ ) {
+ await prisma.membership.upsert({
+ where: { userId_organizationId: { userId, organizationId } },
+ create: { userId, organizationId, role },
+ update: { role },
+ });
+ }
+
+ const ownerRow = await prisma.user.findFirst({
+ where: { email, organizationId: org.id },
+ });
+ if (ownerRow) await ensureMembership(ownerRow.id, org.id, 'owner');
+ const salesRow = await prisma.user.findFirst({ where: { email: salesEmail } });
+ if (salesRow) await ensureMembership(salesRow.id, org.id, 'sales');
+ const adminRow = await prisma.user.findFirst({ where: { email: adminEmail } });
+ if (adminRow) await ensureMembership(adminRow.id, org.id, 'admin');
+
let peopleCount = await prisma.person.count({
where: { organizationId: org.id },
});
diff --git a/apps/api/src/activities/activities-excel.service.ts b/apps/api/src/activities/activities-excel.service.ts
index 444b43f..c236d25 100644
--- a/apps/api/src/activities/activities-excel.service.ts
+++ b/apps/api/src/activities/activities-excel.service.ts
@@ -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;
diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts
index c82f87e..dcc0f5a 100644
--- a/apps/api/src/activities/activities.service.ts
+++ b/apps/api/src/activities/activities.service.ts
@@ -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('مسئول انتخابشده معتبر نیست');
diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts
index 20d4a3f..700313d 100644
--- a/apps/api/src/auth/auth.controller.ts
+++ b/apps/api/src/auth/auth.controller.ts
@@ -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);
}
}
diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts
index 09ab0bc..d03650b 100644
--- a/apps/api/src/auth/auth.module.ts
+++ b/apps/api/src/auth/auth.module.ts
@@ -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],
diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts
index 1e63461..d65de97 100644
--- a/apps/api/src/auth/auth.service.ts
+++ b/apps/api/src/auth/auth.service.ts
@@ -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 {
+ async loginWithPassword(dto: LoginDto): Promise {
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 {
+ async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise {
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 {
+ async me(userId: string, organizationId: string): Promise {
+ 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 {
+ async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise {
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 {
+ async removeAvatar(userId: string, organizationId: string): Promise {
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 {
+ async listMyOrganizations(userId: string) {
+ const rows = await this.memberships.listActive(userId);
+ return this.memberships.toChoices(rows);
+ }
+
+ async selectOrganization(
+ selectionToken: string,
+ organizationId: string,
+ ): Promise {
+ 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 {
+ return this.issueLogin(userId, organizationId);
+ }
+
+ private async finishLogin(userId: string): Promise {
+ 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 {
+ 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 {
+ 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 {
- 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 } : {}),
};
diff --git a/apps/api/src/auth/dto.ts b/apps/api/src/auth/dto.ts
index 869b9a5..03e9478 100644
--- a/apps/api/src/auth/dto.ts
+++ b/apps/api/src/auth/dto.ts
@@ -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;
+}
diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts
index 8261724..3ca78ff 100644
--- a/apps/api/src/auth/jwt.strategy.ts
+++ b/apps/api/src/auth/jwt.strategy.ts
@@ -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,
};
}
}
diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts
index 292a4bf..f0feabf 100644
--- a/apps/api/src/automations/automations.service.ts
+++ b/apps/api/src/automations/automations.service.ts
@@ -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);
diff --git a/apps/api/src/finance/cheque-reminders.service.ts b/apps/api/src/finance/cheque-reminders.service.ts
index 1b1ea5c..4d30e64 100644
--- a/apps/api/src/finance/cheque-reminders.service.ts
+++ b/apps/api/src/finance/cheque-reminders.service.ts
@@ -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 },
diff --git a/apps/api/src/finance/commissions.service.ts b/apps/api/src/finance/commissions.service.ts
index e84d48f..e392da5 100644
--- a/apps/api/src/finance/commissions.service.ts
+++ b/apps/api/src/finance/commissions.service.ts
@@ -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
diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts
index 39ce02b..7053df4 100644
--- a/apps/api/src/lead-forms/lead-forms.service.ts
+++ b/apps/api/src/lead-forms/lead-forms.service.ts
@@ -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('یکی از مسئولان انتخابشده معتبر نیست');
diff --git a/apps/api/src/memberships/memberships.module.ts b/apps/api/src/memberships/memberships.module.ts
new file mode 100644
index 0000000..0d59ad9
--- /dev/null
+++ b/apps/api/src/memberships/memberships.module.ts
@@ -0,0 +1,8 @@
+import { Module } from '@nestjs/common';
+import { MembershipsService } from './memberships.service';
+
+@Module({
+ providers: [MembershipsService],
+ exports: [MembershipsService],
+})
+export class MembershipsModule {}
diff --git a/apps/api/src/memberships/memberships.service.ts b/apps/api/src/memberships/memberships.service.ts
new file mode 100644
index 0000000..9ef165c
--- /dev/null
+++ b/apps/api/src/memberships/memberships.service.ts
@@ -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 {
+ 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(value: T | null | undefined): T {
+ if (!value) throw new UnauthorizedException();
+ return value;
+}
diff --git a/apps/api/src/message-automations/message-automations.service.ts b/apps/api/src/message-automations/message-automations.service.ts
index d63f7a7..718046e 100644
--- a/apps/api/src/message-automations/message-automations.service.ts
+++ b/apps/api/src/message-automations/message-automations.service.ts
@@ -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);
diff --git a/apps/api/src/org-roles/org-roles.service.ts b/apps/api/src/org-roles/org-roles.service.ts
index ca5e713..3aa108b 100644
--- a/apps/api/src/org-roles/org-roles.service.ts
+++ b/apps/api/src/org-roles/org-roles.service.ts
@@ -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),
})),
};
}
diff --git a/apps/api/src/organizations/dto.ts b/apps/api/src/organizations/dto.ts
index dd5bc11..5fd09d9 100644
--- a/apps/api/src/organizations/dto.ts
+++ b/apps/api/src/organizations/dto.ts
@@ -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()
diff --git a/apps/api/src/organizations/organizations.controller.ts b/apps/api/src/organizations/organizations.controller.ts
index a9a3145..10e8331 100644
--- a/apps/api/src/organizations/organizations.controller.ts
+++ b/apps/api/src/organizations/organizations.controller.ts
@@ -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 }) {
diff --git a/apps/api/src/organizations/organizations.module.ts b/apps/api/src/organizations/organizations.module.ts
index b18a788..4b4da7d 100644
--- a/apps/api/src/organizations/organizations.module.ts
+++ b/apps/api/src/organizations/organizations.module.ts
@@ -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],
})
diff --git a/apps/api/src/organizations/organizations.service.ts b/apps/api/src/organizations/organizations.service.ts
index 12901a1..99cd17d 100644
--- a/apps/api/src/organizations/organizations.service.ts
+++ b/apps/api/src/organizations/organizations.service.ts
@@ -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 {
const org = await this.prisma.organization.findUnique({
diff --git a/apps/api/src/pipelines/deals-excel.service.ts b/apps/api/src/pipelines/deals-excel.service.ts
index 85d5fc6..0b78551 100644
--- a/apps/api/src/pipelines/deals-excel.service.ts
+++ b/apps/api/src/pipelines/deals-excel.service.ts
@@ -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;
diff --git a/apps/api/src/portal/portal.service.ts b/apps/api/src/portal/portal.service.ts
index f77d771..3c9b62e 100644
--- a/apps/api/src/portal/portal.service.ts
+++ b/apps/api/src/portal/portal.service.ts
@@ -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;
diff --git a/apps/api/src/project-boards/project-cards-excel.service.ts b/apps/api/src/project-boards/project-cards-excel.service.ts
index 7866c1b..ed1ca1c 100644
--- a/apps/api/src/project-boards/project-cards-excel.service.ts
+++ b/apps/api/src/project-boards/project-cards-excel.service.ts
@@ -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;
diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts
index 4241e7d..a555e72 100644
--- a/apps/api/src/reports/reports.service.ts
+++ b/apps/api/src/reports/reports.service.ts
@@ -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({
diff --git a/apps/api/src/sales/sales.service.ts b/apps/api/src/sales/sales.service.ts
index 28078b3..cf04435 100644
--- a/apps/api/src/sales/sales.service.ts
+++ b/apps/api/src/sales/sales.service.ts
@@ -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('کاربر هدف معتبر نیست');
diff --git a/apps/api/src/tickets/tickets.service.ts b/apps/api/src/tickets/tickets.service.ts
index 38ee321..a80bd58 100644
--- a/apps/api/src/tickets/tickets.service.ts
+++ b/apps/api/src/tickets/tickets.service.ts
@@ -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('کاربر مسئول معتبر نیست');
diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts
index 584f11d..1d8099b 100644
--- a/apps/api/src/users/dto.ts
+++ b/apps/api/src/users/dto.ts
@@ -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)
diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts
index 25aaf5a..fa052aa 100644
--- a/apps/api/src/users/users.module.ts
+++ b/apps/api/src/users/users.module.ts
@@ -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],
diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts
index e9de29b..1a6b2e9 100644
--- a/apps/api/src/users/users.service.ts
+++ b/apps/api/src/users/users.service.ts
@@ -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 = {
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 {
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 {
+ 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 {
+ 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(),
};
diff --git a/apps/web/src/components/OrgSwitcher.vue b/apps/web/src/components/OrgSwitcher.vue
new file mode 100644
index 0000000..62e1607
--- /dev/null
+++ b/apps/web/src/components/OrgSwitcher.vue
@@ -0,0 +1,352 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue
index c3a76a9..4caf264 100644
--- a/apps/web/src/layouts/AppLayout.vue
+++ b/apps/web/src/layouts/AppLayout.vue
@@ -7,7 +7,7 @@ import { formatJalaliShort, toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
import QuickCreate from '@/components/QuickCreate.vue';
-import BrandName from '@/components/brand/BrandName.vue';
+import OrgSwitcher from '@/components/OrgSwitcher.vue';
import NavHubPanel from '@/components/nav/NavHubPanel.vue';
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
import type {
@@ -523,10 +523,13 @@ onUnmounted(() => {
-
+