Folder Structure ---------------- prisma/ types/ src/ ├── ├── api-keys/ ├── │ ├── dto/ ├── │ └── guards/ ├── ├── auth/ ├── │ ├── dto/ ├── │ ├── guards/ ├── │ └── strategies/ ├── ├── campaigns/ ├── │ └── dto/ ├── ├── contacts/ ├── │ └── dto/ ├── ├── file-tree/ ├── ├── groups/ ├── │ └── dto/ ├── ├── guards/ ├── ├── interceptors/ ├── ├── lang/ ├── │ ├── en/ ├── │ └── fr/ ├── ├── lists/ ├── │ └── dto/ ├── ├── logger/ ├── ├── memberships/ ├── │ └── dto/ ├── ├── organizations/ ├── │ └── dto/ ├── ├── permissions/ ├── │ └── dto/ ├── ├── prisma/ ├── ├── roles/ ├── │ └── dto/ ├── ├── sample/ ├── ├── senders/ ├── │ └── dto/ ├── ├── shared/ ├── │ └── constants/ ├── └── users/ ├── └── dto/ Source Code ----------- File: prisma/schema.prisma datasource db { provider = "postgresql" // or "mysql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } /// ───────────────────────────────────────────────────────────── /// Enums /// ───────────────────────────────────────────────────────────── enum UserTypes { PLATFORM AGENCY CLIENT } enum OrgType { PLATFORM AGENCY CLIENT } enum UserStatus { ACTIVE INACTIVE UNVERIFIED SUSPENDED CLOSED DELETED } enum OrgStatus { ACTIVE INACTIVE INREVIEW SUSPENDED CLOSED DELETED } enum GroupType { list segment fields broadcast node } enum MembershipStatus { ACTIVE INVITED DECLINED SUSPENDED REMOVED } enum AssignmentType { ORGANIZATION USER } enum CampaignStatus { DRAFT SCHEDULED RUNNING PAUSED COMPLETED CANCELLED FAILED } enum CampaignChannel { EMAIL SMS WHATSAPP PUSH } enum CampaignMessageStatus { PENDING SENT FAILED DELIVERED } enum SenderType { EMAIL SMS WHATSAPP PUSH } /// ───────────────────────────────────────────────────────────── /// Organization Model /// ───────────────────────────────────────────────────────────── model Organization { id Int @id @default(autoincrement()) name String? organizationType OrgType @default(CLIENT) locked Boolean @default(false) packageId Int? parentId Int? status OrgStatus @default(ACTIVE) industry String? email String? phone String? address String? city String? state String? country String? postalCode String? timezone String? currency String? logo String? website String? socialLinks Json? doingBusinessSince DateTime? organizationSize String? isDeleted Boolean @default(false) parent Organization? @relation("OrgHierarchy", fields: [parentId], references: [id]) children Organization[] @relation("OrgHierarchy") memberships Membership[] lists List[] contacts Contact[] groups Group[] apiKeys ApiKey[] roles Role[] senders Sender[] @relation("OrganizationSenders") campaigns Campaign[] @relation("OrganizationCampaigns") createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@index([organizationType]) @@index([parentId]) @@map("organizations") } /// ───────────────────────────────────────────────────────────── /// User Model /// ───────────────────────────────────────────────────────────── model User { id Int @id @default(autoincrement()) email String? @unique hashedPassword String? firstName String? lastName String? status UserStatus @default(ACTIVE) userType UserTypes @default(CLIENT) lastLoginAt DateTime? regIpAddress String? lastLoginIpAddress String? timezone String? preferredLanguage String? acceptsMarketing Boolean @default(true) emailVerified Boolean @default(false) verificationToken String? passwordResetToken String? // Memberships & Roles memberships Membership[] userOnRolesAsOwner UserOnRole[] @relation("UserOnRoleUser") // Created relations for various models listsCreated List[] @relation("ListCreatedBy") contactsCreated Contact[] @relation("ContactCreatedBy") groupsCreated Group[] @relation("GroupCreatedBy") apiKeysCreated ApiKey[] @relation("ApiKeyCreatedBy") campaignsCreated Campaign[] @relation("CampaignCreatedBy") createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@index([email]) @@index([status]) @@index([userType]) @@map("users") } /// ───────────────────────────────────────────────────────────── /// Membership (Pivot: User <-> Organization) /// ───────────────────────────────────────────────────────────── model Membership { userId Int organizationId Int roleId Int? status MembershipStatus @default(ACTIVE) invitedByUserId Int? user User @relation(fields: [userId], references: [id]) organization Organization @relation(fields: [organizationId], references: [id]) role Role? @relation(fields: [roleId], references: [id]) dateJoined DateTime @default(now()) @@id([userId, organizationId]) @@map("memberships") } /// ───────────────────────────────────────────────────────────── /// UserOnRole (Pivot: User <-> Role) /// ───────────────────────────────────────────────────────────── model UserOnRole { userId Int roleId Int user User @relation(name: "UserOnRoleUser", fields: [userId], references: [id]) role Role @relation(fields: [roleId], references: [id]) @@id([userId, roleId]) @@map("users_on_roles") } /// ───────────────────────────────────────────────────────────── /// Role Model /// ───────────────────────────────────────────────────────────── model Role { id Int @id @default(autoincrement()) name String? icon String? description String? organizationId Int? // If null, it's a global role assignmentType AssignmentType @default(ORGANIZATION) OrgType OrgType @default(CLIENT) organization Organization? @relation(fields: [organizationId], references: [id]) permissions RoleOnPermission[] memberships Membership[] users UserOnRole[] apiKeys ApiKey[] createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@index([organizationId]) @@index([OrgType]) @@map("roles") } /// ───────────────────────────────────────────────────────────── /// Permission Model /// ───────────────────────────────────────────────────────────── model Permission { id Int @id @default(autoincrement()) tag String @unique name String category String? description String? scope UserTypes @default(CLIENT) assignmentType AssignmentType @default(ORGANIZATION) roles RoleOnPermission[] createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@index([tag]) @@map("permissions") } /// ───────────────────────────────────────────────────────────── /// RoleOnPermission (Pivot: Role <-> Permission) /// ───────────────────────────────────────────────────────────── model RoleOnPermission { roleId Int permissionId Int role Role @relation(fields: [roleId], references: [id]) permission Permission @relation(fields: [permissionId], references: [id]) @@id([roleId, permissionId]) @@map("roles_on_permissions") } /// ───────────────────────────────────────────────────────────── /// List Model (Owned by an Organization) /// ───────────────────────────────────────────────────────────── model List { id Int @id @default(autoincrement()) name String groupId Int? group Group? @relation(fields: [groupId], references: [id]) contacts Int @default(0) fields String[] contactsBreakup Json isDeleted Boolean @default(false) isBlocked Boolean @default(false) settings Json disableImport Boolean @default(false) disableEdit Boolean @default(false) sortOrder Int @default(0) organizationId Int organization Organization @relation(fields: [organizationId], references: [id]) contactLinks ContactOnList[] // A List can be associated with many campaigns via CampaignList campaignLists CampaignList[] createdByUserId Int? createdBy User? @relation("ListCreatedBy", fields: [createdByUserId], references: [id]) updatedByUserId Int? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@index([groupId]) @@index([organizationId]) @@index([isDeleted]) @@index([createdByUserId]) @@map("lists") } /// ───────────────────────────────────────────────────────────── /// Contact Model (Owned by an Organization) /// ───────────────────────────────────────────────────────────── model Contact { id Int @id @default(autoincrement()) email String mobile String? whatsapp String? firstName String? lastName String? organizationId Int organization Organization @relation(fields: [organizationId], references: [id]) contactLinks ContactOnList[] campaignMessages CampaignMessage[] createdByUserId Int? createdBy User? @relation("ContactCreatedBy", fields: [createdByUserId], references: [id]) updatedByUserId Int? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@index([email]) @@index([organizationId]) @@index([createdByUserId]) @@map("contacts") } /// ───────────────────────────────────────────────────────────── /// ContactOnList (Pivot: Contact <-> List) /// ───────────────────────────────────────────────────────────── model ContactOnList { contactId Int listId Int contact Contact @relation(fields: [contactId], references: [id]) list List @relation(fields: [listId], references: [id]) subscriptionStatus String? addedAt DateTime @default(now()) @@id([contactId, listId]) @@map("contacts_on_lists") } /// ───────────────────────────────────────────────────────────── /// Group Model (for grouping/segmenting lists, etc.) /// ───────────────────────────────────────────────────────────── model Group { id Int @id @default(autoincrement()) name String type GroupType @default(list) parentId Int? @default(0) sortOrder Int @default(0) organizationId Int organization Organization @relation(fields: [organizationId], references: [id]) lists List[] createdByUserId Int? createdBy User? @relation("GroupCreatedBy", fields: [createdByUserId], references: [id]) updatedByUserId Int? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@index([parentId]) @@index([createdByUserId]) @@map("groups") } /// ───────────────────────────────────────────────────────────── /// ApiKey Model (for user or role-based API keys) /// ───────────────────────────────────────────────────────────── model ApiKey { id Int @id @default(autoincrement()) name String? key String @unique status String @default("active") expiresAt DateTime? organizationId Int organization Organization @relation(fields: [organizationId], references: [id]) roleId Int? role Role? @relation(fields: [roleId], references: [id]) createdByUserId Int? createdByUser User? @relation("ApiKeyCreatedBy", fields: [createdByUserId], references: [id]) updatedByUserId Int? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@index([key]) @@index([organizationId]) @@index([createdByUserId]) @@map("api_keys") } /// ───────────────────────────────────────────────────────────── /// New Models for Omnichannel Campaign Management /// ───────────────────────────────────────────────────────────── model Sender { id Int @id @default(autoincrement()) organizationId Int name String type SenderType // e.g. EMAIL, SMS, WHATSAPP, PUSH config Json // API credentials, SMTP details, etc. isActive Boolean @default(true) organization Organization @relation("OrganizationSenders", fields: [organizationId], references: [id]) campaignSenders CampaignSender[] campaignMessages CampaignMessage[] @relation("SenderCampaignMessages") createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@index([organizationId]) @@map("senders") } model Campaign { id Int @id @default(autoincrement()) organizationId Int name String status CampaignStatus @default(DRAFT) // Using enum channel CampaignChannel // Using enum for channel scheduleTime DateTime? // When the campaign is scheduled to run createdByUserId Int? updatedByUserId Int? organization Organization @relation("OrganizationCampaigns", fields: [organizationId], references: [id]) // A campaign can be associated with multiple lists via CampaignList and multiple senders via CampaignSender campaignLists CampaignList[] campaignSenders CampaignSender[] campaignMessages CampaignMessage[] createdBy User? @relation("CampaignCreatedBy", fields: [createdByUserId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@index([organizationId]) @@map("campaigns") } model CampaignSender { id Int @id @default(autoincrement()) organizationId Int campaignId Int senderId Int campaign Campaign @relation(fields: [campaignId], references: [id]) sender Sender @relation(fields: [senderId], references: [id]) createdAt DateTime @default(now()) @@index([campaignId]) @@index([senderId]) @@index([organizationId]) @@map("campaign_senders") } model CampaignMessage { id Int @id @default(autoincrement()) organizationId Int campaignId Int contactId Int senderId Int channel CampaignChannel // Using enum for channel status CampaignMessageStatus @default(PENDING) // Using enum for status sentAt DateTime? campaign Campaign @relation(fields: [campaignId], references: [id]) contact Contact @relation(fields: [contactId], references: [id]) sender Sender @relation("SenderCampaignMessages", fields: [senderId], references: [id]) createdAt DateTime @default(now()) @@index([campaignId]) @@index([contactId]) @@index([senderId]) @@index([organizationId]) @@map("campaign_messages") } /// ───────────────────────────────────────────────────────────── /// CampaignList (Pivot: Campaign <-> List) /// ───────────────────────────────────────────────────────────── model CampaignList { campaignId Int listId Int addedAt DateTime @default(now()) campaign Campaign @relation(fields: [campaignId], references: [id]) list List @relation(fields: [listId], references: [id]) @@id([campaignId, listId]) @@map("campaign_lists") } File: types/global.d.ts export {}; declare global { interface Window { nexusPixel: { init: (trackingId: string) => void; trackCustomEvent: (eventName: string, eventData: any) => void; }; } } File: types/winston.d.ts import 'winston'; declare module 'winston' { export interface Logger { success(message: string, meta?: any): Logger; fatal(message: string, meta?: any): Logger; } } File: src/api-keys/api-keys.controller.ts import { Controller, Get, Post, Body, Delete, Param, ParseIntPipe, Req, HttpCode, HttpStatus, Patch, UsePipes, ValidationPipe, ForbiddenException, } from '@nestjs/common'; import { ApiKeysService } from './api-keys.service'; import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { UpdateApiKeyDto } from './dto/update-api-key.dto'; import { Permissions } from '../auth/permissions.decorator'; @Controller('auth/apikeys') export class ApiKeysController { constructor(private readonly apiKeysService: ApiKeysService) {} /** * Create a new API key * => Needs apikeys.create */ @Post() @Permissions('apikeys.create') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async createApiKey(@Body() dto: CreateApiKeyDto, @Req() req: any) { const userId = req.user.userId; // orgId might come from something else if you do multi-org membership logic // For now, we just call the service const key = await this.apiKeysService.createApiKey(userId, dto); return { message: 'API key created', data: key }; } /** * Get API keys * => Needs apikeys.read.any or apikeys.read.own */ @Get() @Permissions('apikeys.read.any', 'apikeys.read.own') async findAllApiKeys(@Req() req: any) { const userId = req.user.userId; const userPerms = req.user.permissions || []; const canReadAny = userPerms.includes('apikeys.read.any'); const canReadOwn = userPerms.includes('apikeys.read.own'); if (!canReadAny && !canReadOwn) { throw new ForbiddenException('Not allowed to read API keys'); } const apiKeys = await this.apiKeysService.findAllApiKeys( canReadAny, userId, ); return { message: 'List of API keys', data: apiKeys }; } /** * Update an existing API key * => Needs apikeys.edit.any or apikeys.edit.own */ @Patch(':id') @Permissions('apikeys.edit.any', 'apikeys.edit.own') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async updateKey( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateApiKeyDto, @Req() req: any, ) { const userId = req.user.userId; const userPerms = req.user.permissions || []; const canEditAny = userPerms.includes('apikeys.edit.any'); const canEditOwn = userPerms.includes('apikeys.edit.own'); if (!canEditAny && !canEditOwn) { throw new ForbiddenException('Not allowed to edit API keys'); } // The service will re-check if the key is owned by this user // if only "own" is present const updated = await this.apiKeysService.updateApiKey( id, dto, userId, canEditAny, ); return { message: `API key with ID ${id} updated`, data: updated }; } /** * Delete (revoke) an API key by ID * => Needs apikeys.delete.any or apikeys.delete.own */ @Delete(':id') @Permissions('apikeys.delete.any', 'apikeys.delete.own') @HttpCode(HttpStatus.NO_CONTENT) async removeKey(@Param('id', ParseIntPipe) id: number, @Req() req: any) { const userId = req.user.userId; const userPerms = req.user.permissions || []; const canDeleteAny = userPerms.includes('apikeys.delete.any'); const canDeleteOwn = userPerms.includes('apikeys.delete.own'); if (!canDeleteAny && !canDeleteOwn) { throw new ForbiddenException('Not allowed to delete API keys'); } await this.apiKeysService.removeApiKey(id, userId, canDeleteAny); // no JSON body because of NO_CONTENT } } File: src/api-keys/api-keys.module.ts import { Module } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module'; import { ApiKeysService } from './api-keys.service'; import { ApiKeysController } from './api-keys.controller'; @Module({ imports: [PrismaModule], providers: [ApiKeysService], controllers: [ApiKeysController], exports: [ApiKeysService], }) export class ApiKeysModule {} File: src/api-keys/api-keys.service.ts import { Injectable, NotFoundException, ForbiddenException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { UpdateApiKeyDto } from './dto/update-api-key.dto'; import * as crypto from 'crypto'; @Injectable() export class ApiKeysService { constructor(private readonly prisma: PrismaService) {} /** * Generate a new API Key and save to DB. */ async createApiKey(userId: number, dto: CreateApiKeyDto) { // 1. Generate the key const rawKey = crypto.randomBytes(32).toString('hex'); // 2. Decide the orgId to store, if not provided. // You can fetch from membership or pass as param. For example: const orgId = 1; // or your own logic // 3. Create DB record => must provide organizationId const apiKey = await this.prisma.apiKey.create({ data: { organizationId: orgId, createdByUserId: userId, key: rawKey, roleId: dto.roleId ?? null, name: dto.name ?? null, }, }); // Return the raw key return { id: apiKey.id, key: rawKey, roleId: apiKey.roleId, createdByUserId: userId, name: apiKey.name, status: apiKey.status, organizationId: orgId, }; } /** * List API keys. * @param canReadAny If true => return all API keys. If false => only own. */ async findAllApiKeys(canReadAny: boolean, currentUserId: number) { if (canReadAny) { return this.prisma.apiKey.findMany(); } // only own => filter by createdByUserId return this.prisma.apiKey.findMany({ where: { createdByUserId: currentUserId }, }); } /** * Update an API key by ID * @param canEditAny If true => can edit any key. If false => must be owner */ async updateApiKey( apiKeyId: number, dto: UpdateApiKeyDto, currentUserId: number, canEditAny: boolean, ) { const keyRecord = await this.prisma.apiKey.findUnique({ where: { id: apiKeyId }, }); if (!keyRecord) { throw new NotFoundException('API key not found'); } if (!canEditAny && keyRecord.createdByUserId !== currentUserId) { throw new ForbiddenException('You do not own this API key'); } return this.prisma.apiKey.update({ where: { id: apiKeyId }, data: { name: dto.name ?? undefined, roleId: dto.roleId ?? undefined, // if you handle status, do it here }, }); } /** * Revoke (delete) an API key by ID * @param canDeleteAny If true => can delete any key. If false => must be owner */ async removeApiKey( apiKeyId: number, currentUserId: number, canDeleteAny: boolean, ) { const keyRecord = await this.prisma.apiKey.findUnique({ where: { id: apiKeyId }, }); if (!keyRecord) { throw new NotFoundException('API key not found'); } if (!canDeleteAny && keyRecord.createdByUserId !== currentUserId) { throw new ForbiddenException('You do not own this API key'); } await this.prisma.apiKey.delete({ where: { id: apiKeyId } }); return true; } /** * Validate an API key (used in a custom guard) */ async validateApiKey(rawKey: string) { const keyRecord = await this.prisma.apiKey.findUnique({ where: { key: rawKey }, include: { createdByUser: true, role: { include: { permissions: { include: { permission: true } }, }, }, organization: true, }, }); if (!keyRecord) return null; // Gather tags from role->permissions const permTags: string[] = []; if (keyRecord.role) { for (const rp of keyRecord.role.permissions) { permTags.push(rp.permission.tag); } } return { userId: keyRecord.createdByUserId, organizationId: keyRecord.organizationId, roleId: keyRecord.roleId, userType: keyRecord.createdByUser?.userType, permissions: permTags, }; } } File: src/api-keys/dto/create-api-key.dto.ts import { IsOptional, IsString, IsInt } from 'class-validator'; export class CreateApiKeyDto { @IsInt() @IsOptional() roleId?: number; @IsString() @IsOptional() name?: string; } File: src/api-keys/dto/update-api-key.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateApiKeyDto } from './create-api-key.dto'; import { IsBoolean, IsOptional } from 'class-validator'; export class UpdateApiKeyDto extends PartialType(CreateApiKeyDto) { @IsOptional() @IsBoolean() isActive?: boolean; } File: src/api-keys/guards/api-key-auth.guard.ts import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, } from '@nestjs/common'; import { ApiKeysService } from '../api-keys.service'; /** * This guard checks for X-API-KEY in the request headers. * If present, validates it and attaches user info to req.user. */ @Injectable() export class ApiKeyAuthGuard implements CanActivate { constructor(private readonly apiKeysService: ApiKeysService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const apiKey = request.headers['x-api-key']; if (!apiKey || typeof apiKey !== 'string') { throw new UnauthorizedException('Missing or invalid X-API-KEY header'); } const userData = await this.apiKeysService.validateApiKey(apiKey); if (!userData) { throw new UnauthorizedException('Invalid API Key'); } // Attach user data to request so roles/other guards can use it request.user = userData; return true; } } File: src/api-keys/guards/union-auth.guard.ts import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { ApiKeyAuthGuard } from './api-key-auth.guard'; import { Reflector } from '@nestjs/core'; import { IS_PUBLIC_KEY } from 'src/auth/public.decorator'; @Injectable() export class UnionAuthGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly jwtAuthGuard: JwtAuthGuard, private readonly apiKeyAuthGuard: ApiKeyAuthGuard, ) {} async canActivate(context: ExecutionContext): Promise { // 1) Check if route has @Public() decorator const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), ]); if (isPublic) { // If it's marked public, skip union auth checks return true; } // 2) Try JWT auth try { const jwtCanActivate = await this.jwtAuthGuard.canActivate(context); if (jwtCanActivate) return true; } catch { // ignore } // 3) Try API key auth try { const apiKeyCanActivate = await this.apiKeyAuthGuard.canActivate(context); if (apiKeyCanActivate) return true; } catch { // ignore } return false; } } File: src/app.controller.ts import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() getHello(): string { return this.appService.getHello(); } } File: src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; import { AcceptLanguageResolver, HeaderResolver, I18nJsonLoader, I18nModule, QueryResolver, } from 'nestjs-i18n'; import * as path from 'path'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; import { RolesModule } from './roles/roles.module'; import { PermissionsModule } from './permissions/permissions.module'; import { ApiKeysModule } from './api-keys/api-keys.module'; import { FileTreeModule } from './file-tree/file-tree.module'; import { NexusLoggerService } from './logger/nexus-logger.service'; import { NexusLoggerModule } from './logger/nexus-logger.module'; import { GuardsModule } from './guards/guards.module'; import { UnionAuthGuard } from './api-keys/guards/union-auth.guard'; import { MembershipsModule } from './memberships/memberships.module'; import { OrganizationsModule } from './organizations/organizations.module'; import { SampleYupController } from './sample/yup.controller'; import { CampaignsModule } from './campaigns/campaigns.module'; import { ContactsModule } from './contacts/contacts.module'; import { ListsModule } from './lists/lists.module'; import { SendersModule } from './senders/senders.module'; import { GroupsModule } from './groups/groups.module'; @Module({ imports: [ I18nModule.forRootAsync({ imports: [ConfigModule], inject: [], useFactory: () => ({ fallbackLanguage: 'en', loader: I18nJsonLoader, loaderOptions: { path: path.join(__dirname, '..', 'src', 'lang'), watch: true, }, }), resolvers: [ { use: QueryResolver, options: ['lang'] }, // ?lang=xx takes highest priority new HeaderResolver(['x-lang']), AcceptLanguageResolver, ], }), ConfigModule.forRoot({ isGlobal: true }), UsersModule, AuthModule, RolesModule, PermissionsModule, ApiKeysModule, NexusLoggerModule, FileTreeModule, GuardsModule, MembershipsModule, OrganizationsModule, ListsModule, CampaignsModule, ContactsModule, SendersModule, GroupsModule, ], controllers: [AppController, SampleYupController], providers: [ AppService, NexusLoggerService, { provide: APP_GUARD, useClass: UnionAuthGuard, }, ], }) export class AppModule {} File: src/app.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { getHello(): string { return 'Hello World!'; } } File: src/auth/auth.controller.ts import { Body, Controller, Post, Req, UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; import { AuthService } from './auth.service'; import { LocalAuthGuard } from './guards/local-auth.guard'; import { LoginDto } from './dto/login.dto'; import { Public } from './public.decorator'; import { SkipPermissions } from './skip-permissions.decorator'; @Controller('auth') export class AuthController { // Inject AuthService for business logic, // I18nService for translations, and NexusLoggerService for logging. constructor( private readonly authService: AuthService, private readonly i18n: I18nService, private readonly logger: NexusLoggerService, ) {} /** * POST /auth/login * * - Uses the local strategy (LocalAuthGuard) to validate user credentials (email/password). * - Returns a JWT token upon successful authentication. * - The language is determined using configured resolvers (e.g., query, x-lang header, Accept-Language). */ @Post('login') @Public() @SkipPermissions() @UseGuards(LocalAuthGuard) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async login( @Body() _loginDto: LoginDto, @Req() req: any, @I18nLang() lang: string, // Language resolved from the request ) { // LocalAuthGuard attaches the validated user object to req.user. this.logger.info(`Login attempt initiated. Detected language: ${lang}.`); // Execute loginUser and translate the success message concurrently. // Here we pass the lang parameter so that loginUser (and any subsequent translation calls) use it. const [result, msg] = await Promise.all([ this.authService.loginUser(req.user), this.i18n.translate('auth.success.logged_in'), ]); this.logger.success(`User with ID ${req.user.id} logged in successfully.`); return { message: msg, ...result }; } } File: src/auth/auth.module.ts import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { UsersModule } from '../users/users.module'; import { LocalStrategy } from './strategies/local.strategy'; import { JwtStrategy } from './strategies/jwt.strategy'; import { PrismaModule } from '../prisma/prisma.module'; @Module({ imports: [ // Import the UsersModule to access user records. UsersModule, // Import ConfigModule to access environment variables. ConfigModule, // PassportModule provides authentication strategies. PassportModule, // PrismaModule for database access. PrismaModule, // JwtModule registers JWT functionality asynchronously. JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => ({ // Use the JWT secret from configuration or a fallback value. secret: configService.get('JWT_SECRET') || 'fallbackSecret', signOptions: { // Set token expiration from configuration or default to '1d'. expiresIn: configService.get('JWT_EXPIRES_IN') || '1d', }, }), }), ], providers: [ // AuthService contains business logic for authentication. AuthService, // LocalStrategy and JwtStrategy handle the authentication mechanisms. LocalStrategy, JwtStrategy, ], controllers: [AuthController], // Export AuthService for use in other modules if necessary. exports: [AuthService], }) export class AuthModule {} File: src/auth/auth.service.ts import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { I18nContext } from 'nestjs-i18n'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; import { AssignmentType } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; /** * AuthService * * - Provides methods for validating a user's credentials (email/password) via LocalStrategy. * - In loginUser(), it loads membership-based roles (organization-level) and user-based roles from the pivot, * storing them separately in the JWT payload. * - Uses I18nService for multilingual translations of error and success messages. * - Uses NexusLoggerService for detailed logging of authentication events. */ @Injectable() export class AuthService { // Inject dependencies: UsersService, PrismaService, JwtService, I18nService, and NexusLoggerService. constructor( private readonly usersService: UsersService, private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly logger: NexusLoggerService, ) {} /** * validateUser * * - Called by LocalStrategy when a user attempts to log in using email and password. * - Returns the user record if credentials are valid; otherwise, throws an UnauthorizedException. * * Note: Since this method is invoked by LocalStrategy (which does not pass a lang parameter), * we use I18nContext.current() to obtain the current language from the request context. */ async validateUser(email: string, pass: string) { // 1) Look up the user by email. const user = await this.usersService.findByEmail(email); const i18nContext = I18nContext.current(); // Get the current i18n context (language from the request) if (!user) { // Translate error message for "user not found" using the current language. const [errorMsg] = await Promise.all([ i18nContext.translate('auth.errors.invalid_credentials'), ]); this.logger.warn(`User validation failed: ${errorMsg}`); throw new UnauthorizedException(errorMsg); } // 2) Compare the provided password with the stored hashed password. const isPasswordValid = await bcrypt.compare( pass, user.hashedPassword || '', ); if (!isPasswordValid) { // Translate error message for "bad password" using the current language. const [errorMsg] = await Promise.all([ i18nContext.translate('auth.errors.invalid_credentials'), ]); this.logger.warn( `Password validation failed for user ID ${user.id}: ${errorMsg}`, ); throw new UnauthorizedException(errorMsg); } // 3) Log successful validation and return the user record. this.logger.info(`User with ID ${user.id} validated successfully.`); return user; } /** * loginUser * * - Called after validateUser() passes. * - Loads membership-based roles (organization-level) and user-based roles, * and builds a JWT payload that keeps these roles separate. * - Uses the provided lang parameter for translations in this method. * * @param user The validated user object (attached by LocalStrategy) * @param lang The language code resolved from the request for translating messages. */ async loginUser(user: any) { this.logger.info(`Attempting login for user ID ${user.id}`); // 1) Load membership-based roles (organization-level) const memberships = await this.prisma.membership.findMany({ where: { userId: user.id }, include: { organization: true, // Include organization details (e.g., name or type) role: { include: { // Include permissions from the role pivot. permissions: { include: { permission: true }, }, }, }, }, }); // 2) Convert membership records into an array of membership objects. const membershipArray = memberships.map((m) => { const permTags = m.role?.permissions.map((rp) => rp.permission.tag) || []; return { orgId: m.organizationId, organizationType: m.organization?.organizationType, roleId: m.roleId, roleName: m.role?.name || null, permissions: permTags, }; }); // 3) Load user-based roles (only those with assignmentType=USER) from the pivot. const userOnRoles = await this.prisma.userOnRole.findMany({ where: { userId: user.id, role: { assignmentType: AssignmentType.USER }, }, include: { role: { include: { permissions: { include: { permission: true }, }, }, }, }, }); // 4) Convert the userOnRole records into an array of role objects. const userRolesArray = userOnRoles.map((ur) => { const rolePermTags = ur.role.permissions.map((rp) => rp.permission.tag) || []; return { roleId: ur.roleId, roleName: ur.role?.name || null, permissions: rolePermTags, }; }); // 5) Build the JWT payload, keeping membership-based and user-based roles separate. const payload = { sub: user.id, email: user.email, userType: user.userType, memberships: membershipArray, userRoles: userRolesArray, }; // 6) Sign the JWT and log token generation. const token = this.jwtService.sign(payload); this.logger.success( `User with ID ${user.id} logged in successfully. JWT generated.`, ); return { access_token: token, }; } } File: src/auth/dto/login.dto.ts import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; /** * LoginDto * * - Represents the data transfer object for a login request. * - Uses validation decorators to ensure: * - The 'email' field is a valid email address. * - The 'password' field is a non-empty string. * * No logging or translation functionality is needed in this file. */ export class LoginDto { @IsEmail() email: string; @IsString() @IsNotEmpty() password: string; } File: src/auth/guards/jwt-auth.guard.ts import { AuthGuard } from '@nestjs/passport'; import { Injectable } from '@nestjs/common'; /** * JwtAuthGuard * * - Extends Passport's AuthGuard to protect routes using the JWT strategy. * - Automatically extracts and validates the JWT from the request. * * No additional logging or language translation is required. */ @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') {} File: src/auth/guards/local-auth.guard.ts import { AuthGuard } from '@nestjs/passport'; import { Injectable } from '@nestjs/common'; /** * LocalAuthGuard * * - Extends Passport's AuthGuard to protect routes using the local authentication strategy. * - Validates user credentials (email and password) using the LocalStrategy. * * No additional logging or language translation is needed here. */ @Injectable() export class LocalAuthGuard extends AuthGuard('local') {} File: src/auth/permissions.decorator.ts import { SetMetadata } from '@nestjs/common'; export const PERMISSIONS_KEY = 'permissions'; /** * Permissions Decorator * * - Used to attach permission metadata to a route handler. * - Example usage: @Permissions('users.create', 'users.read.any', ...) * * No logging or translation is required. */ export const Permissions = (...permissions: string[]) => { return SetMetadata(PERMISSIONS_KEY, permissions); }; File: src/auth/public.decorator.ts import { SetMetadata } from '@nestjs/common'; export const IS_PUBLIC_KEY = 'isPublic'; /** * Public Decorator * * - Marks a route as public (i.e., no authentication required). * * No logging or language translation is added. */ export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); File: src/auth/skip-permissions.decorator.ts import { SetMetadata } from '@nestjs/common'; export const SKIP_PERMISSIONS_KEY = 'skipPermissions'; /** * SkipPermissions Decorator * * - Indicates that permission checks should be skipped for the decorated route. * * No additional logging or translation is required. */ export const SkipPermissions = () => SetMetadata(SKIP_PERMISSIONS_KEY, true); File: src/auth/strategies/jwt.strategy.ts import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; /** * JwtStrategy * * - Implements the JWT strategy for Passport. * - Extracts the JWT token from the Authorization header (Bearer scheme). * - Validates the token and returns the payload, which is attached to req.user. * * Logging and language translation are not needed in this file. */ @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(private configService: ConfigService) { super({ // Extract the JWT from the Authorization header as a Bearer token. jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, // Retrieve the secret from configuration or use a fallback. secretOrKey: configService.get('JWT_SECRET') || 'fallbackSecret', }); } /** * validate * * - Called if the JWT is valid. * - Returns an object that is attached to req.user. */ async validate(payload: any) { const returnObj: any = { userId: payload.sub, email: payload.email, userType: payload.userType, memberships: payload.memberships || [], userRoles: payload.userRoles || [], }; return returnObj; } } File: src/auth/strategies/local.strategy.ts import { Strategy } from 'passport-local'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { AuthService } from '../auth.service'; /** * LocalStrategy * * - Implements the local authentication strategy for Passport. * - Uses the 'email' field (instead of the default 'username') for authentication. * - Calls AuthService.validateUser to verify credentials. * * Logging and translation are handled within AuthService; therefore, this file remains straightforward. */ @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { constructor(private authService: AuthService) { // Override the default username field with 'email'. super({ usernameField: 'email' }); } /** * validate * * - Automatically invoked by Passport when /auth/login is accessed with LocalAuthGuard. * - If validation fails, an UnauthorizedException is thrown. */ async validate(email: string, password: string): Promise { const user = await this.authService.validateUser(email, password); if (!user) { throw new UnauthorizedException(); } return user; } } File: src/campaigns/campaigns.controller.ts import { Body, Controller, Delete, ForbiddenException, Get, NotFoundException, Param, ParseIntPipe, Patch, Post, Query, Req, UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { CampaignsService } from './campaigns.service'; import { CreateCampaignDto } from './dto/create-campaign.dto'; import { UpdateCampaignDto } from './dto/update-campaign.dto'; import { Permissions } from 'src/auth/permissions.decorator'; import { OrganizationGuard } from 'src/guards/organization.guard'; /** * CampaignsController * * - Route prefix: :orgId/campaigns * - Uses OrganizationGuard + route-level permissions for read/create/edit. */ @UseGuards(OrganizationGuard) @Controller(':orgId/campaigns') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) export class CampaignsController { constructor( private readonly campaignsService: CampaignsService, private readonly i18n: I18nService, ) {} /** * GET /:orgId/campaigns * => 'campaigns.read.any' or 'campaigns.read.own' * If only .own => filter campaigns by createdByUserId = membership.userId */ @Get() @Permissions('campaigns.read.any', 'campaigns.read.own') async findAll( @Param('orgId', ParseIntPipe) orgId: number, @Query('own') ownStr: string, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userId = membership.userId; const userPerms = membership.permissions || []; const canReadAny = userPerms.includes('campaigns.read.any'); const canReadOwn = userPerms.includes('campaigns.read.own'); if (!canReadAny && !canReadOwn) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.forbidden_read', { lang }), ]); throw new ForbiddenException(errorMsg); } // If user can only read own => pass userIdFilter let userIdFilter = undefined; if (!canReadAny && canReadOwn) { userIdFilter = userId; } const [data, msg] = await Promise.all([ this.campaignsService.findAll(orgId, userIdFilter), this.i18n.translate('campaigns.success.list', { lang }), ]); return { message: msg, data }; } /** * GET /:orgId/campaigns/:id * => 'campaigns.read.any' or 'campaigns.read.own' * If only .own => must be createdBy that user */ @Get(':id') @Permissions('campaigns.read.any', 'campaigns.read.own') async findOne( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) campaignId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userId = membership.userId; const userPerms = membership.permissions || []; const canReadAny = userPerms.includes('campaigns.read.any'); // 1) fetch const campaign = await this.campaignsService.findOne(orgId, campaignId); if (!campaign) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.not_found', { lang, args: { id: campaignId }, }), ]); throw new NotFoundException(errorMsg); } // 2) If user doesn't have read.any => check ownership if (!canReadAny && campaign.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.forbidden_read', { lang }), ]); throw new ForbiddenException(errorMsg); } // 3) Return const [msg] = await Promise.all([ this.i18n.translate('campaigns.success.getOne', { lang }), ]); return { message: msg, data: campaign }; } /** * POST /:orgId/campaigns/add * => 'campaigns.create' */ @Post('add') @Permissions('campaigns.create') async create( @Param('orgId', ParseIntPipe) orgId: number, @Body() dto: CreateCampaignDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const [campaign, msg] = await Promise.all([ this.campaignsService.create(orgId, dto, membership.userId), this.i18n.translate('campaigns.success.created', { lang }), ]); return { message: msg, data: campaign }; } /** * PATCH /:orgId/campaigns/edit/:id * => 'campaigns.edit.any' or 'campaigns.edit.own' */ @Patch('edit/:id') @Permissions('campaigns.edit.any', 'campaigns.edit.own') async update( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) campaignId: number, @Body() dto: UpdateCampaignDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userId = membership.userId; const userPerms = membership.permissions || []; const canEditAny = userPerms.includes('campaigns.edit.any'); // 1) check const existing = await this.campaignsService.findOne(orgId, campaignId); if (!existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.not_found', { lang, args: { id: campaignId }, }), ]); throw new NotFoundException(errorMsg); } // 2) if user doesn't have .edit.any => must own it if (!canEditAny && existing.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.forbidden_edit', { lang }), ]); throw new ForbiddenException(errorMsg); } // 3) update const updated = await this.campaignsService.update( orgId, campaignId, dto, userId, ); // 4) respond const [msg] = await Promise.all([ this.i18n.translate('campaigns.success.updated', { lang, args: { id: campaignId }, }), ]); return { message: msg, data: updated }; } /** * DELETE /:orgId/campaigns/delete/:id * => 'campaigns.delete.any' or 'campaigns.delete.own' */ @Delete('delete/:id') @Permissions('campaigns.delete.any', 'campaigns.delete.own') async remove( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) campaignId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userId = membership.userId; const userPerms = membership.permissions || []; const canDeleteAny = userPerms.includes('campaigns.delete.any'); // check existence const existing = await this.campaignsService.findOne(orgId, campaignId); if (!existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.not_found', { lang, args: { id: campaignId }, }), ]); throw new NotFoundException(errorMsg); } if (!canDeleteAny && existing.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('campaigns.errors.forbidden_delete', { lang }), ]); throw new ForbiddenException(errorMsg); } await this.campaignsService.remove(orgId, campaignId); const [msg] = await Promise.all([ this.i18n.translate('campaigns.success.deleted', { lang, args: { id: campaignId }, }), ]); return { message: msg }; } } File: src/campaigns/campaigns.module.ts import { Module } from '@nestjs/common'; import { CampaignsController } from './campaigns.controller'; import { CampaignsService } from './campaigns.service'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; @Module({ imports: [], controllers: [CampaignsController], providers: [CampaignsService, PrismaService, NexusLoggerService], exports: [CampaignsService], }) export class CampaignsModule {} File: src/campaigns/campaigns.service.ts import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { CreateCampaignDto } from './dto/create-campaign.dto'; import { UpdateCampaignDto } from './dto/update-campaign.dto'; import { Prisma } from '@prisma/client'; @Injectable() export class CampaignsService { constructor( private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, ) {} /** * findOne: * Return a single campaign by org + id, or null if not found */ async findOne(orgId: number, campaignId: number) { return this.prisma.campaign.findFirst({ where: { id: campaignId, organizationId: orgId, }, }); } /** * findAll: * Return all campaigns in the org, or filter by createdByUserId if needed. */ async findAll(orgId: number, userIdFilter?: number) { const where: Prisma.CampaignWhereInput = { organizationId: orgId, }; if (userIdFilter) { where.createdByUserId = userIdFilter; } return this.prisma.campaign.findMany({ where, orderBy: { createdAt: 'desc' }, }); } /** * create: * Insert a new campaign for the org, assigned to the given userId. */ async create(orgId: number, dto: CreateCampaignDto, userId: number) { const campaign = await this.prisma.campaign.create({ data: { organizationId: orgId, name: dto.name, status: dto.status, channel: dto.channel, scheduleTime: dto.scheduleTime ?? null, createdByUserId: userId, }, }); this.logger.info( `Campaign #${campaign.id} created in org #${orgId} by user #${userId}.`, ); return campaign; } /** * update: * Update an existing campaign if found. Possibly check if it's "final" or something. */ async update( orgId: number, campaignId: number, dto: UpdateCampaignDto, userId: number, ) { const existing = await this.findOne(orgId, campaignId); if (!existing) { throw new NotFoundException( `Campaign #${campaignId} not found in org #${orgId}.`, ); } // Example rule: if campaign is COMPLETED, don't allow changes if (existing.status === 'COMPLETED') { throw new BadRequestException(`Cannot update a completed campaign.`); } const updated = await this.prisma.campaign.update({ where: { id: campaignId }, data: { name: dto.name ?? existing.name, status: dto.status ?? existing.status, channel: dto.channel ?? existing.channel, scheduleTime: dto.scheduleTime ?? existing.scheduleTime, updatedByUserId: userId, }, }); this.logger.info( `Campaign #${campaignId} updated in org #${orgId} by user #${userId}.`, ); return updated; } /** * remove: * Delete a campaign (if not locked or final). */ async remove(orgId: number, campaignId: number) { const existing = await this.findOne(orgId, campaignId); if (!existing) { throw new NotFoundException( `Campaign #${campaignId} not found in org #${orgId}.`, ); } await this.prisma.campaign.delete({ where: { id: campaignId } }); this.logger.success(`Campaign #${campaignId} deleted from org #${orgId}.`); return true; } } File: src/campaigns/dto/create-campaign.dto.ts import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator'; import { CampaignChannel, CampaignStatus } from '@prisma/client'; /** * CreateCampaignDto * * Defines the shape of data needed to create a new Campaign. */ export class CreateCampaignDto { @IsString() name: string; @IsEnum(CampaignStatus) @IsOptional() status?: CampaignStatus = CampaignStatus.DRAFT; @IsEnum(CampaignChannel) channel: CampaignChannel; @IsDateString() @IsOptional() scheduleTime?: Date; // optional } File: src/campaigns/dto/update-campaign.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateCampaignDto } from './create-campaign.dto'; /** * UpdateCampaignDto * * Extends CreateCampaignDto as a partial so you can update any field or none. */ export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {} File: src/contacts/contacts.controller.ts import { Body, Controller, Delete, ForbiddenException, Get, NotFoundException, Param, ParseIntPipe, Patch, Post, Query, Req, UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { ContactsService } from './contacts.service'; import { CreateContactDto } from './dto/create-contact.dto'; import { UpdateContactDto } from './dto/update-contact.dto'; import { Permissions } from 'src/auth/permissions.decorator'; import { OrganizationGuard } from 'src/guards/organization.guard'; @UseGuards(OrganizationGuard) @Controller(':orgId/contacts') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) export class ContactsController { constructor( private readonly contactsService: ContactsService, private readonly i18n: I18nService, ) {} /** * GET /:orgId/contacts * => 'contacts.read.any' or 'contacts.read.own' */ @Get() @Permissions('contacts.read.any', 'contacts.read.own') async findAll( @Param('orgId', ParseIntPipe) orgId: number, @Query('own') ownStr: string, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userPerms = membership.permissions || []; const userId = membership.userId; const canReadAny = userPerms.includes('contacts.read.any'); const canReadOwn = userPerms.includes('contacts.read.own'); if (!canReadAny && !canReadOwn) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.forbidden_read', { lang }), ]); throw new ForbiddenException(errorMsg); } let userIdFilter = undefined; if (!canReadAny && canReadOwn) { userIdFilter = userId; } const [data, msg] = await Promise.all([ this.contactsService.findAll(orgId, userIdFilter), this.i18n.translate('contacts.success.list', { lang }), ]); return { message: msg, data }; } /** * GET /:orgId/contacts/:id * => 'contacts.read.any' or 'contacts.read.own' */ @Get(':id') @Permissions('contacts.read.any', 'contacts.read.own') async findOne( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) contactId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userPerms = membership.permissions || []; const userId = membership.userId; const canReadAny = userPerms.includes('contacts.read.any'); const contact = await this.contactsService.findOne(orgId, contactId); if (!contact) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.not_found', { lang, args: { id: contactId }, }), ]); throw new NotFoundException(errorMsg); } if (!canReadAny && contact.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.forbidden_read', { lang }), ]); throw new ForbiddenException(errorMsg); } const [msg] = await Promise.all([ this.i18n.translate('contacts.success.getOne', { lang }), ]); return { message: msg, data: contact }; } /** * POST /:orgId/contacts/add * => 'contacts.create' */ @Post('add') @Permissions('contacts.create') async create( @Param('orgId', ParseIntPipe) orgId: number, @Body() dto: CreateContactDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const [contact, msg] = await Promise.all([ this.contactsService.create(orgId, dto, membership.userId), this.i18n.translate('contacts.success.created', { lang }), ]); return { message: msg, data: contact }; } /** * PATCH /:orgId/contacts/edit/:id * => 'contacts.edit.any' or 'contacts.edit.own' */ @Patch('edit/:id') @Permissions('contacts.edit.any', 'contacts.edit.own') async update( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) contactId: number, @Body() dto: UpdateContactDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userPerms = membership.permissions || []; const userId = membership.userId; const canEditAny = userPerms.includes('contacts.edit.any'); const existing = await this.contactsService.findOne(orgId, contactId); if (!existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.not_found', { lang, args: { id: contactId }, }), ]); throw new NotFoundException(errorMsg); } if (!canEditAny && existing.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.forbidden_edit', { lang }), ]); throw new ForbiddenException(errorMsg); } const updated = await this.contactsService.update( orgId, contactId, dto, userId, ); const [msg] = await Promise.all([ this.i18n.translate('contacts.success.updated', { lang, args: { id: contactId }, }), ]); return { message: msg, data: updated }; } /** * DELETE /:orgId/contacts/delete/:id * => 'contacts.delete.any' or 'contacts.delete.own' */ @Delete('delete/:id') @Permissions('contacts.delete.any', 'contacts.delete.own') async remove( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) contactId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userPerms = membership.permissions || []; const userId = membership.userId; const canDeleteAny = userPerms.includes('contacts.delete.any'); const existing = await this.contactsService.findOne(orgId, contactId); if (!existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.not_found', { lang, args: { id: contactId }, }), ]); throw new NotFoundException(errorMsg); } if (!canDeleteAny && existing.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('contacts.errors.forbidden_delete', { lang }), ]); throw new ForbiddenException(errorMsg); } await this.contactsService.remove(orgId, contactId); const [msg] = await Promise.all([ this.i18n.translate('contacts.success.deleted', { lang, args: { id: contactId }, }), ]); return { message: msg }; } } File: src/contacts/contacts.module.ts import { Module } from '@nestjs/common'; import { ContactsController } from './contacts.controller'; import { ContactsService } from './contacts.service'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; @Module({ controllers: [ContactsController], providers: [ContactsService, PrismaService, NexusLoggerService], exports: [ContactsService], }) export class ContactsModule {} File: src/contacts/contacts.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { CreateContactDto } from './dto/create-contact.dto'; import { UpdateContactDto } from './dto/update-contact.dto'; import { Prisma } from '@prisma/client'; @Injectable() export class ContactsService { constructor( private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, ) {} async findOne(orgId: number, contactId: number) { return this.prisma.contact.findFirst({ where: { id: contactId, organizationId: orgId, }, }); } /** * findAll: * If user can readAny => return all, else if only readOwn => filter by createdByUserId */ async findAll(orgId: number, userIdFilter?: number) { const where: Prisma.ContactWhereInput = { organizationId: orgId, }; if (userIdFilter) { where.createdByUserId = userIdFilter; } return this.prisma.contact.findMany({ where, orderBy: { createdAt: 'desc' }, }); } async create(orgId: number, dto: CreateContactDto, userId: number) { const created = await this.prisma.contact.create({ data: { email: dto.email, mobile: dto.mobile ?? null, whatsapp: dto.whatsapp ?? null, firstName: dto.firstName ?? null, lastName: dto.lastName ?? null, organizationId: orgId, createdByUserId: userId, }, }); this.logger.info( `Contact #${created.id} created in org #${orgId} by user #${userId}.`, ); return created; } async update( orgId: number, contactId: number, dto: UpdateContactDto, userId: number, ) { const existing = await this.findOne(orgId, contactId); if (!existing) { throw new NotFoundException( `Contact #${contactId} not found in org #${orgId}.`, ); } // Potential example check: if email is changed, verify uniqueness within org const updated = await this.prisma.contact.update({ where: { id: contactId }, data: { email: dto.email ?? existing.email, mobile: dto.mobile ?? existing.mobile, whatsapp: dto.whatsapp ?? existing.whatsapp, firstName: dto.firstName ?? existing.firstName, lastName: dto.lastName ?? existing.lastName, updatedByUserId: userId, }, }); this.logger.info(`Contact #${contactId} updated in org #${orgId}.`); return updated; } async remove(orgId: number, contactId: number) { const existing = await this.findOne(orgId, contactId); if (!existing) { throw new NotFoundException( `Contact #${contactId} not found in org #${orgId}.`, ); } await this.prisma.contact.delete({ where: { id: contactId } }); this.logger.success(`Contact #${contactId} deleted from org #${orgId}.`); return true; } } File: src/contacts/dto/create-contact.dto.ts import { IsEmail, IsOptional, IsString } from 'class-validator'; export class CreateContactDto { @IsEmail() email: string; @IsString() @IsOptional() mobile?: string; @IsString() @IsOptional() whatsapp?: string; @IsString() @IsOptional() firstName?: string; @IsString() @IsOptional() lastName?: string; } File: src/contacts/dto/update-contact.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateContactDto } from './create-contact.dto'; export class UpdateContactDto extends PartialType(CreateContactDto) {} File: src/file-tree/file-tree.controller.ts import { Controller, Get, Res, HttpStatus, Query } from '@nestjs/common'; import { FileTreeService } from './file-tree.service'; import { Response } from 'express'; import { Permissions } from '../auth/permissions.decorator'; import * as path from 'path'; @Controller('file-tree') export class FileTreeController { constructor(private readonly fileTreeService: FileTreeService) {} /** * Endpoint to generate and store the file tree with source code contents. * * @param res - Express response object. * @param rootDir - (Optional) Query parameter to specify the root directory. Defaults to current working directory. * @param include - (Optional) Query parameter to specify directories to include, separated by commas. * @param exclude - (Optional) Query parameter to specify sub-directories to exclude within included directories, separated by commas. * @returns JSON response confirming the file generation and providing the file path. */ @Get('generate') @Permissions('file-tree.read') async generateFileTree( @Res() res: Response, @Query('rootDir') rootDir?: string, @Query('include') include?: string, @Query('exclude') exclude?: string, ) { // Parse the include and exclude query parameters into arrays const includeDirs = include ? include.split(',').map((dir) => dir.trim()) : []; const excludeDirs = exclude ? exclude.split(',').map((dir) => dir.trim()) : []; try { if (includeDirs.length === 0) { return res.status(HttpStatus.BAD_REQUEST).json({ message: 'At least one directory must be specified in the include parameter.', }); } // Define the output file path const outputFilePath = path.join( process.cwd(), 'storage', 'file-tree.txt', ); // Generate the file tree with source code contents this.fileTreeService.generateFileTreeWithCode( rootDir, includeDirs, excludeDirs, outputFilePath, ); return res.status(HttpStatus.OK).json({ message: 'File tree with source code has been generated successfully.', filePath: outputFilePath, }); } catch (error) { console.error('Error generating file tree:', error); return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ message: 'Failed to generate file tree.', error: error.message, }); } } } File: src/file-tree/file-tree.module.ts import { Module } from '@nestjs/common'; import { FileTreeService } from './file-tree.service'; import { FileTreeController } from './file-tree.controller'; @Module({ providers: [FileTreeService], controllers: [FileTreeController], }) export class FileTreeModule {} File: src/file-tree/file-tree.service.ts import { Injectable } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; export interface CodeContent { filePath: string; content: string; } @Injectable() export class FileTreeService { /** * Generates a text-based tree view of the folder structure and embeds source code contents. * Writes the output to the specified file. * * @param rootDir - The root directory to start traversal from. * @param includeDirs - An array of directory names to include. * @param excludeDirs - An array of sub-directory paths to exclude (relative to each included directory). * @param outputFilePath - The path to the output file where the tree view and source code will be written. */ generateFileTreeWithCode( rootDir: string = process.cwd(), includeDirs: string[] = [], excludeDirs: string[] = [], outputFilePath: string = path.join( process.cwd(), 'storage', 'file-tree.txt', ), ): void { const absoluteRoot = path.isAbsolute(rootDir) ? rootDir : path.join(process.cwd(), rootDir); if (!fs.existsSync(absoluteRoot)) { throw new Error(`The directory "${absoluteRoot}" does not exist.`); } // Ensure the output directory exists const outputDir = path.dirname(outputFilePath); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } const folderStructureLines: string[] = []; const sourceCodeContents: CodeContent[] = []; folderStructureLines.push('Folder Structure'); folderStructureLines.push('----------------'); for (const dir of includeDirs) { const dirPath = path.join(absoluteRoot, dir); if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) { folderStructureLines.push(`${dir}/`); this.buildFolderTree( dirPath, folderStructureLines, '├── ', this.getExclusions(dir, excludeDirs), ); } else { console.warn( `Included directory "${dir}" does not exist under "${absoluteRoot}".`, ); } } folderStructureLines.push(''); folderStructureLines.push('Source Code'); folderStructureLines.push('-----------'); for (const dir of includeDirs) { const dirPath = path.join(absoluteRoot, dir); if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) { this.collectSourceCode( dirPath, sourceCodeContents, this.getExclusions(dir, excludeDirs), ); } } for (const content of sourceCodeContents) { // Relative path for readability const relativePath = path.relative(absoluteRoot, content.filePath); folderStructureLines.push(`File: ${relativePath}`); folderStructureLines.push(content.content); folderStructureLines.push(''); // Blank line for separation } const finalOutput = folderStructureLines.join('\n'); fs.writeFileSync(outputFilePath, finalOutput, 'utf-8'); console.log( `File tree with source code has been written to ${outputFilePath}`, ); } /** * Recursively builds the folder tree structure. * * @param currentPath - Current directory path. * @param lines - Array to collect the structure lines. * @param prefix - Current indentation prefix. * @param exclusions - Sub-directories to exclude within the current directory. */ private buildFolderTree( currentPath: string, lines: string[], prefix: string, exclusions: string[], ): void { const items = fs.readdirSync(currentPath).sort(); const directories = items.filter((item) => { const fullPath = path.join(currentPath, item); return fs.statSync(fullPath).isDirectory() && !exclusions.includes(item); }); const totalDirs = directories.length; directories.forEach((dir, index) => { const isLast = index === totalDirs - 1; const connector = isLast ? '└── ' : '├── '; lines.push(`${prefix}${connector}${dir}/`); const newPrefix = isLast ? `${prefix} ` : `${prefix}│ `; this.buildFolderTree( path.join(currentPath, dir), lines, newPrefix, [], // No further exclusions; adjust if nested exclusions are needed ); }); } /** * Collects source code from all files within the included directories. * * @param currentPath - Current directory path. * @param contents - Array to collect the CodeContent objects. * @param exclusions - Sub-directories to exclude within the current directory. */ private collectSourceCode( currentPath: string, contents: CodeContent[], exclusions: string[], ): void { const items = fs.readdirSync(currentPath).sort(); for (const item of items) { const fullPath = path.join(currentPath, item); const stats = fs.statSync(fullPath); if (stats.isDirectory()) { if (exclusions.includes(item)) { continue; // Skip excluded directories } this.collectSourceCode(fullPath, contents, []); // No further exclusions; adjust if needed } else if (stats.isFile()) { try { const fileContent = fs.readFileSync(fullPath, 'utf-8'); contents.push({ filePath: fullPath, content: fileContent, }); } catch (error) { console.error(`Failed to read file: ${fullPath}`, error); contents.push({ filePath: fullPath, content: '// Failed to read file content.', }); } } } } /** * Processes the excludeDirs to determine which sub-directories to exclude within each included directory. * * @param includeDir - The currently included directory. * @param excludeDirs - An array of sub-directory paths to exclude. * @returns An array of sub-directory names to exclude within the included directory. */ private getExclusions(includeDir: string, excludeDirs: string[]): string[] { return excludeDirs .filter((excludePath) => { const normalizedPath = path.normalize(excludePath); const normalizedInclude = path.normalize(includeDir); return normalizedPath.startsWith(`${normalizedInclude}${path.sep}`); }) .map((excludePath) => path.basename(excludePath)) .filter((subDir) => subDir); // Remove empty strings } } File: src/file-tree/generate-file-tree.ts import { FileTreeService } from './file-tree.service'; import * as path from 'path'; async function generateFileTree() { // Retrieve all command-line arguments except the first two // (which are 'node' and the script filename). const args = process.argv.slice(2); let rootDir: string | undefined; let includeDirs: string[] = []; let excludeDirs: string[] = []; // Simple argument parsing for flags like: // --rootDir=your/path // --include=src,lib // --exclude=node_modules,.git args.forEach((arg) => { if (arg.startsWith('--rootDir=')) { rootDir = arg.replace('--rootDir=', '').trim(); } else if (arg.startsWith('--include=')) { includeDirs = arg .replace('--include=', '') .split(',') .map((dir) => dir.trim()); } else if (arg.startsWith('--exclude=')) { excludeDirs = arg .replace('--exclude=', '') .split(',') .map((dir) => dir.trim()); } }); // Ensure at least one directory is specified in include if (!includeDirs.length) { console.error( 'Error: At least one directory must be specified in the --include parameter.', ); process.exit(1); } // Define the output file path const outputFilePath = path.join(process.cwd(), 'storage', 'file-tree.txt'); // Instantiate your service and call the method to generate the file tree const fileTreeService = new FileTreeService(); try { fileTreeService.generateFileTreeWithCode( rootDir, includeDirs, excludeDirs, outputFilePath, ); console.log('File tree with source code has been generated successfully.'); console.log(`File path: ${outputFilePath}`); } catch (error: any) { console.error('Failed to generate file tree:', error.message); process.exit(1); } } // Run the function if this file is executed directly via Node if (require.main === module) { generateFileTree().then(() => { process.exit(0); }); } File: src/groups/dto/create-group.dto.ts import { IsEnum, IsInt, IsOptional, IsString } from 'class-validator'; import { GroupType } from '@prisma/client'; /** * CreateGroupDto * * Defines the input required to create a new Group. * - name: The name of the group (required) * - type: The type of the group, defaults to "list" * - parentId: Optional parent group ID (default is 0 if not provided) * - sortOrder: Number for display ordering (default is 0) */ export class CreateGroupDto { @IsString() name: string; @IsEnum(GroupType) @IsOptional() type?: GroupType = GroupType.list; @IsOptional() @IsInt() parentId?: number = 0; @IsOptional() @IsInt() sortOrder?: number = 0; } File: src/groups/dto/update-group.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateGroupDto } from './create-group.dto'; /** * UpdateGroupDto * * All fields from CreateGroupDto are optional. */ export class UpdateGroupDto extends PartialType(CreateGroupDto) {} File: src/groups/groups.controller.ts import { Body, Controller, Delete, ForbiddenException, Get, NotFoundException, Param, ParseIntPipe, Patch, Post, Req, UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { GroupsService } from './groups.service'; import { CreateGroupDto } from './dto/create-group.dto'; import { UpdateGroupDto } from './dto/update-group.dto'; import { Permissions } from 'src/auth/permissions.decorator'; import { OrganizationGuard } from 'src/guards/organization.guard'; /** * GroupsController * * Routes are prefixed with :orgId/groups. * Uses OrganizationGuard to ensure the user belongs to the target organization. * Implements endpoints to list, retrieve, create, update, and delete groups. */ @UseGuards(OrganizationGuard) @Controller(':orgId/groups') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) export class GroupsController { constructor( private readonly groupsService: GroupsService, private readonly i18n: I18nService, ) {} /** * GET /:orgId/groups * * Retrieve all groups in an organization. */ @Get() @Permissions('groups.read.any', 'groups.read.own') async findAll( @Param('orgId', ParseIntPipe) orgId: number, @Req() req: any, @I18nLang() lang: string, ) { // OrganizationGuard attaches req.orgMembership. const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('groups.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const data = await this.groupsService.findAll(orgId); const [msg] = await Promise.all([ this.i18n.translate('groups.success.list', { lang }), ]); return { message: msg, data }; } /** * GET /:orgId/groups/:id * * Retrieve a single group by its ID. */ @Get(':id') @Permissions('groups.read.any', 'groups.read.own') async findOne( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) groupId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; const userId = membership.userId; const userPerms = membership.permissions || []; const canReadAny = userPerms.includes('groups.read.any'); return await this.groupsService.findOneGroup( !canReadAny, orgId, groupId, userId, lang, ); } /** * POST /:orgId/groups/add * * Create a new group. */ @Post('add') @Permissions('groups.create') async create( @Param('orgId', ParseIntPipe) orgId: number, @Body() dto: CreateGroupDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; const userId = membership.userId; return await this.groupsService.createGroup(orgId, dto, userId, lang); } /** * PATCH /:orgId/groups/edit/:id * * Update an existing group. */ @Patch('edit/:id') @Permissions('groups.edit.any', 'groups.edit.own') async update2( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) groupId: number, @Body() dto: UpdateGroupDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; const userId = membership.userId; const userPerms = membership.permissions || []; const canEditAny = userPerms.includes('groups.edit.any'); return await this.groupsService.updateGroup( !canEditAny, orgId, groupId, dto, userId, lang, ); } /** * DELETE /:orgId/groups/delete/:id * * Delete a group. */ @Delete('delete/:id') @Permissions('groups.delete.any', 'groups.delete.own') async remove( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) groupId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('groups.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } await this.groupsService.removeGroup(orgId, groupId, lang); const [msg] = await Promise.all([ this.i18n.translate('groups.success.deleted', { lang, args: { id: groupId }, }), ]); return { message: msg }; } } File: src/groups/groups.module.ts import { Module } from '@nestjs/common'; import { GroupsController } from './groups.controller'; import { GroupsService } from './groups.service'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; @Module({ imports: [], controllers: [GroupsController], providers: [GroupsService, PrismaService, NexusLoggerService], exports: [GroupsService], }) export class GroupsModule {} File: src/groups/groups.service.ts import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { CreateGroupDto } from './dto/create-group.dto'; import { UpdateGroupDto } from './dto/update-group.dto'; import { I18nService } from 'nestjs-i18n'; /** * GroupsService * * Provides methods for CRUD operations on Group entities. * Groups are always tied to an organization. */ @Injectable() export class GroupsService { constructor( private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, private readonly i18n: I18nService, ) {} /** * findOne * Retrieves a group by organization and group ID. */ async findOne(orgId: number, groupId: number, lang: string) { const group = await this.prisma.group.findFirst({ where: { id: groupId, organizationId: orgId, }, }); // If group is not found, throw the error if (!group) { const [errorMsg] = await Promise.all([ this.i18n.translate('groups.errors.not_found', { lang, args: { id: groupId, orgId }, }), ]); throw new NotFoundException(errorMsg); } return group; } /** * findAll * Retrieves all groups for an organization. */ async findAll(orgId: number) { return this.prisma.group.findMany({ where: { organization: { id: orgId }, }, orderBy: { sortOrder: 'asc' }, }); } /** * findOneGroup * Retrieves a group by organization and group ID. */ async findOneGroup( ownOnly: boolean, orgId: number, groupId: number, userId: any, lang: string, ) { const group = await this.findOne(orgId, groupId, lang); // If ownOnly is true, user must be the owner if (ownOnly && group.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('groups.errors.forbidden_read', { lang, args: { id: groupId }, }), ]); throw new ForbiddenException(errorMsg); } return group; } /** * createGroup * Creates a new group in the specified organization. * Uses Prisma’s relation "connect" for the organization. */ async createGroup( orgId: number, dto: CreateGroupDto, userId: any, lang: string, ) { // If parentId is provided, ensure it exists in the DB and belongs to the organization if (dto.parentId) { const parent = await this.prisma.group.findFirst({ where: { id: dto.parentId, organizationId: orgId, }, }); if (!parent) { const [errorMsg] = await Promise.all([ this.i18n.translate('groups.errors.parent_not_found', { lang, args: { parentId: dto.parentId, orgId }, }), ]); throw new BadRequestException(errorMsg); } } const created = await this.prisma.group.create({ data: { name: dto.name, type: dto.type, parentId: dto.parentId ?? null, sortOrder: dto.sortOrder ?? 0, organizationId: orgId, createdByUserId: userId, updatedByUserId: userId, }, }); const [i18nMsg] = await Promise.all([ this.i18n.translate('groups.success.created', { lang, args: { name: dto.name }, }), ]); return { message: i18nMsg, data: created }; } /** * updateGroup * Updates an existing group. */ async updateGroup( ownOnly: boolean, orgId: number, groupId: number, dto: UpdateGroupDto, userId: any, lang: string, ) { const existing = await this.findOne(orgId, groupId, lang); if (!existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('groups.errors.not_found', { lang, args: { id: groupId, orgId }, }), ]); throw new NotFoundException(errorMsg); } // If ownOnly is true, user must be the owner if (ownOnly && existing.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('groups.errors.forbidden_edit', { lang, args: { id: groupId }, }), ]); throw new ForbiddenException(errorMsg); } const updated = await this.prisma.group.update({ where: { id: groupId }, data: { name: dto.name ?? existing.name, parentId: dto.parentId ?? existing.parentId, sortOrder: dto.sortOrder ?? existing.sortOrder, updatedByUserId: userId, }, }); const [i18nMsg] = await Promise.all([ this.i18n.translate('groups.success.updated', { lang, args: { id: groupId }, }), ]); return { message: i18nMsg, data: updated }; } /** * removeGroup * Deletes a group. */ async removeGroup(orgId: number, groupId: number, lang: string) { const existing = await this.findOne(orgId, groupId, lang); if (!existing) { throw new NotFoundException( `Group with ID ${groupId} not found in org ${orgId}.`, ); } // Optionally, you could check for child groups or associated lists before deletion. await this.prisma.group.delete({ where: { id: groupId } }); this.logger.success(`Group #${groupId} deleted from org #${orgId}.`); return true; } } File: src/guards/guards.module.ts import { Module } from '@nestjs/common'; import { PrismaModule } from 'src/prisma/prisma.module'; import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; import { LocalAuthGuard } from 'src/auth/guards/local-auth.guard'; import { ApiKeyAuthGuard } from 'src/api-keys/guards/api-key-auth.guard'; import { UnionAuthGuard } from 'src/api-keys/guards/union-auth.guard'; import { ApiKeysModule } from 'src/api-keys/api-keys.module'; import { OrganizationGuard } from 'src/guards/organization.guard'; import { PlatformGuard } from 'src/guards/platform.guard'; import { PermissionsGuard } from 'src/guards/permissions.guard'; import { UserGuard } from 'src/guards/user.guard'; /** * GuardsModule * * - Imports necessary modules (e.g. ApiKeysModule, PrismaModule). * - Provides and exports all the guard classes used for authentication and permission checks. * - No language or logging integration is required at the module level. */ @Module({ imports: [ApiKeysModule, PrismaModule], providers: [ JwtAuthGuard, LocalAuthGuard, ApiKeyAuthGuard, UnionAuthGuard, OrganizationGuard, PlatformGuard, PermissionsGuard, UserGuard, ], exports: [ JwtAuthGuard, LocalAuthGuard, ApiKeyAuthGuard, UnionAuthGuard, OrganizationGuard, PlatformGuard, PermissionsGuard, UserGuard, ], }) export class GuardsModule {} File: src/guards/organization.guard.ts import { Injectable, CanActivate, ExecutionContext, ForbiddenException, NotFoundException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { PrismaService } from 'src/prisma/prisma.service'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; import { PERMISSIONS_KEY } from 'src/auth/permissions.decorator'; import { I18nContext } from 'nestjs-i18n'; @Injectable() export class OrganizationGuard implements CanActivate { constructor( private readonly prisma: PrismaService, private readonly reflector: Reflector, private readonly logger: NexusLoggerService, ) {} async canActivate(context: ExecutionContext): Promise { // Obtain the current internationalization context to resolve language. const i18n = I18nContext.current(); // 1) Retrieve the required permissions from route metadata. const requiredPermissions = this.reflector.getAllAndOverride( PERMISSIONS_KEY, [context.getHandler(), context.getClass()], ); if (!requiredPermissions || requiredPermissions.length === 0) { this.logger.debug('OrganizationGuard: No permissions required; allow.'); return true; } // 2) Retrieve the HTTP request and extract the authenticated user. const request = context.switchToHttp().getRequest(); const user = request.user; if (!user) { this.logger.warn('OrganizationGuard: Request has no user object!'); const [errorMsg] = await Promise.all([ i18n.translate('guards.organization.no_user'), ]); throw new ForbiddenException(errorMsg); } const userId = user.userId; if (!userId) { this.logger.warn('OrganizationGuard: userId is missing on user object!'); const [errorMsg] = await Promise.all([ i18n.translate('guards.organization.invalid_user'), ]); throw new ForbiddenException(errorMsg); } // 3) Retrieve orgId from the route parameters. const orgIdStr = request.params.orgId; if (!orgIdStr) { this.logger.warn('OrganizationGuard: Missing :orgId param in route!'); const [errorMsg] = await Promise.all([ i18n.translate('guards.organization.missing_org_id'), ]); throw new ForbiddenException(errorMsg); } const orgId = parseInt(orgIdStr, 10); if (isNaN(orgId)) { this.logger.warn( `OrganizationGuard: orgId param "${orgIdStr}" is invalid.`, ); const [errorMsg] = await Promise.all([ i18n.translate('guards.organization.invalid_org_id'), ]); throw new ForbiddenException(errorMsg); } // ───────────────────────────────────────────────────────────── // (A) SPECIAL CASE: orgId=0 => PLATFORM SCENARIO // ───────────────────────────────────────────────────────────── if (orgId === 0) { // Ensure only platform users can access global routes (orgId=0). if (user.userType !== 'PLATFORM') { const [errorMsg] = await Promise.all([ i18n.translate('guards.organization.only_platform'), ]); throw new ForbiddenException(errorMsg); } // Gather permission tags from platform memberships. const allTags = new Set(); if (Array.isArray(user.memberships)) { const platformMemberships = user.memberships.filter( (m) => m.organizationType === 'PLATFORM', ); for (const mem of platformMemberships) { for (const t of mem.permissions || []) { allTags.add(t); } } } const hasAnyRequired = requiredPermissions.some((p) => allTags.has(p)); if (!hasAnyRequired) { const [errorMsg] = await Promise.all([ i18n.translate('guards.organization.missing_required_permissions', { args: { permissions: requiredPermissions.join(', ') }, }), ]); throw new ForbiddenException(errorMsg); } // Attach platform membership info to the request. request.orgMembership = { userId, orgId: 0, permissions: Array.from(allTags), }; return true; } // ───────────────────────────────────────────────────────────── // (B) NORMAL CASE: orgId != 0 // ───────────────────────────────────────────────────────────── const ancestorOrgIds = await this.getOrgAncestorChain(orgId); if (ancestorOrgIds.length === 0) { this.logger.error( `OrganizationGuard: Org ${orgId} not found (no ancestors).`, ); const [errorMsg] = await Promise.all([ i18n.translate('guards.organization.not_found', { args: { orgId }, }), ]); throw new NotFoundException(errorMsg); } this.logger.debug( `Ancestor chain for orgId=${orgId}: [${ancestorOrgIds.join(', ')}]`, ); // 5) Filter the user's memberships to those within the ancestor chain. const { memberships } = user; if (!memberships || memberships.length === 0) { this.logger.warn(`OrganizationGuard: User ${userId} has no memberships.`); const [errorMsg] = await Promise.all([ i18n.translate('guards.organization.no_memberships'), ]); throw new ForbiddenException(errorMsg); } const relevantMemberships = memberships.filter((m) => ancestorOrgIds.includes(m.orgId), ); if (relevantMemberships.length === 0) { this.logger.warn( `OrganizationGuard: User ${userId} is not in org ${orgId} nor its parents.`, ); const [errorMsg] = await Promise.all([ i18n.translate('guards.organization.user_not_in_org', { args: { orgId }, }), ]); throw new ForbiddenException(errorMsg); } // 6) Combine all permission tags from the relevant memberships. const allTags = new Set(); for (const mem of relevantMemberships) { const memPerms = mem.permissions || []; for (const tag of memPerms) { allTags.add(tag); } } const effectivePerms = Array.from(allTags); this.logger.debug( `User ${userId} effective perms for org ${orgId}: ${effectivePerms.join(', ')}`, ); // 7) Verify that the user has at least one of the required permissions. const hasAnyRequired = requiredPermissions.some((p) => allTags.has(p)); if (!hasAnyRequired) { this.logger.info( `OrganizationGuard: User ${userId} missing required perms [${requiredPermissions.join(', ')}]`, ); const [errorMsg] = await Promise.all([ i18n.translate('guards.organization.missing_required_permissions', { args: { permissions: requiredPermissions.join(', ') }, }), ]); throw new ForbiddenException(errorMsg); } // 8) Attach organization membership details to the request. request.orgMembership = { userId, orgId, permissions: effectivePerms, }; this.logger.success( `OrganizationGuard: User ${userId} has required perms [${requiredPermissions.join(', ')}] for org ${orgId}.`, ); return true; } /** * getOrgAncestorChain: * Builds an array of organization IDs representing the parent chain (including the current org), * stopping when no parent exists. * * For example, if org 5 has parent 3 and org 3 has parent 1, the chain will be [5, 3, 1]. */ private async getOrgAncestorChain(orgId: number): Promise { const chain: number[] = []; let currentId = orgId; this.logger.debug( `getOrgAncestorChain: Starting chain from orgId: ${currentId}`, ); while (currentId) { const org = await this.prisma.organization.findUnique({ where: { id: currentId }, select: { id: true, parentId: true }, }); this.logger.debug( `getOrgAncestorChain: Fetched org: ${JSON.stringify(org)}`, ); if (!org) { break; } chain.push(org.id); currentId = org.parentId || 0; } return chain; } } File: src/guards/permissions.guard.ts import { CanActivate, ExecutionContext, Injectable, ForbiddenException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { PERMISSIONS_KEY } from 'src/auth/permissions.decorator'; import { I18nContext } from 'nestjs-i18n'; @Injectable() export class PermissionsGuard implements CanActivate { constructor(private reflector: Reflector) {} async canActivate(context: ExecutionContext): Promise { // Obtain the current i18n context. const i18n = I18nContext.current(); // 1) Retrieve the required permissions from route metadata. const requiredPerms = this.reflector.getAllAndOverride( PERMISSIONS_KEY, [context.getHandler(), context.getClass()], ); // If no permissions are required, allow the request. if (!requiredPerms || requiredPerms.length === 0) { return true; } // 2) Retrieve the effective permissions from the request (set by earlier guards). const request = context.switchToHttp().getRequest(); const userPerms = request.effectivePermissions ?? []; // 3) Check if the user has at least one required permission. const hasAny = requiredPerms.some((needed) => userPerms.includes(needed)); if (!hasAny) { const [errorMsg] = await Promise.all([ i18n.translate('guards.permissions.missing', { args: { perms: requiredPerms.join(', ') }, }), ]); throw new ForbiddenException(errorMsg); } return true; } } File: src/guards/platform.guard.ts import { Injectable, CanActivate, ExecutionContext, ForbiddenException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { PrismaService } from 'src/prisma/prisma.service'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; import { PERMISSIONS_KEY } from 'src/auth/permissions.decorator'; import { I18nContext } from 'nestjs-i18n'; /** * PlatformGuard: * * - Checks if the user is a member of the "platform" organization (top-level). * - Gathers that membership's permission tags. * - Checks if they have the route's required permission(s). * - Fails if the user doesn't belong to the platform org or lacks any required tag. * * Assumes there's exactly ONE "platform" org, which we identify by: * - `organizationType = PLATFORM`, or * - a known ID (e.g. orgId = 1). * * Example usage: * @UseGuards(PlatformGuard) * @Controller('permissions') * export class PermissionsController { ... } */ @Injectable() export class PlatformGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, ) {} async canActivate(context: ExecutionContext): Promise { // Obtain the current i18n context to retrieve the language from the request. const i18n = I18nContext.current(); // Retrieve required permissions from metadata. const requiredPermissions = this.reflector.getAllAndOverride( PERMISSIONS_KEY, [context.getHandler(), context.getClass()], ); if (!requiredPermissions) return true; // No perms => allow. const request = context.switchToHttp().getRequest(); const user = request.user; if (!user) { const [errorMsg] = await Promise.all([ i18n.translate('guards.platform.no_user'), ]); throw new ForbiddenException(errorMsg); } const { memberships } = user; if (!memberships) { const [errorMsg] = await Promise.all([ i18n.translate('guards.platform.no_memberships'), ]); throw new ForbiddenException(errorMsg); } // 1) Filter only memberships where organization.organizationType = 'PLATFORM' const platformMemberships = memberships.filter( (m) => m.organizationType === 'PLATFORM', ); if (platformMemberships.length === 0) { // User is not a member of any org with type=PLATFORM. const [errorMsg] = await Promise.all([ i18n.translate('guards.platform.not_member'), ]); throw new ForbiddenException(errorMsg); } // 2) Union all permission tags. const unionTags = new Set(); for (const mem of platformMemberships) { for (const tag of mem.permissions || []) { unionTags.add(tag); } } // 3) Check if user has at least one required permission. const hasAny = requiredPermissions.some((p) => unionTags.has(p)); if (!hasAny) { const [errorMsg] = await Promise.all([ i18n.translate('guards.platform.missing_permissions', { args: { permissions: requiredPermissions.join(', ') }, }), ]); throw new ForbiddenException(errorMsg); } return true; } } File: src/guards/user.guard.ts import { Injectable, CanActivate, ExecutionContext, ForbiddenException, NotFoundException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { PERMISSIONS_KEY } from 'src/auth/permissions.decorator'; import { PrismaService } from 'src/prisma/prisma.service'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; import { I18nContext } from 'nestjs-i18n'; /** * UserGuard: * This guard validates that the requesting user has at least one of the required permissions * for a given route. It supports two methods of permission checking: * 1. User-level permissions from the roles attached to the user (user.userRoles). * 2. Platform-level permissions derived from the user's memberships in platform-level organizations. */ @Injectable() export class UserGuard implements CanActivate { constructor( private readonly prisma: PrismaService, private readonly reflector: Reflector, private readonly logger: NexusLoggerService, ) {} /** * canActivate: * This method is invoked automatically by NestJS before a route handler is called. * It performs all the necessary checks and either allows the request to proceed or throws an error. */ async canActivate(context: ExecutionContext): Promise { // Obtain the current i18n context to resolve the language. const i18n = I18nContext.current(); // 1) Retrieve the required permissions from the route's metadata. const requiredPermissions = this.reflector.getAllAndOverride(PERMISSIONS_KEY, [ context.getHandler(), // method-level permissions context.getClass(), // controller-level permissions ]) || []; // 2) Get the HTTP request and the authenticated user (attached by earlier middleware, e.g., JWT). const request = context.switchToHttp().getRequest(); const user = request.user; if (!user) { this.logger.warn('UserGuard: No user found in request!'); const [errorMsg] = await Promise.all([ i18n.translate('guards.user.no_user'), ]); throw new ForbiddenException(errorMsg); } const requesterUserId = user.userId; if (!requesterUserId) { this.logger.warn('UserGuard: user object missing userId!'); const [errorMsg] = await Promise.all([ i18n.translate('guards.user.missing_user_id'), ]); throw new ForbiddenException(errorMsg); } // 3) If no permissions are required for this route, allow the request. if (requiredPermissions.length === 0) { this.logger.debug('UserGuard: No route-level perms => allow.'); return true; } // 4) Determine if a target user ID is provided in the route (e.g., GET /users/:id). const targetUserIdStr = request.params.id; let targetUserId: number | null = null; if (targetUserIdStr) { const parsed = parseInt(targetUserIdStr, 10); if (!isNaN(parsed)) { targetUserId = parsed; } } // Log the IDs and required permissions. this.logger.debug( `UserGuard => Requester #${requesterUserId} => Target #${targetUserId ?? 'N/A'}. Required perms: [${requiredPermissions.join(', ')}]`, ); // 5) First, check for user-level permissions from the user's roles. const userLevelPerms = this.collectUserRolePerms(user); const hasUserLevel = requiredPermissions.some((perm) => userLevelPerms.has(perm), ); const mergeAllPerms = true; // 6) If user-level permissions are not sufficient, check platform-level permissions. const platformPerms: Set = new Set(); let hasPlatformPerm = false; if (mergeAllPerms) { // Case A: If a target user is specified, use organization ancestor logic. if (targetUserId) { // Fetch the target user along with their memberships (and related organization data). const targetUser = await this.prisma.user.findUnique({ where: { id: targetUserId }, include: { memberships: { include: { organization: true } } }, }); if (!targetUser) { this.logger.warn( `UserGuard: Target user #${targetUserId} not found.`, ); const [errorMsg] = await Promise.all([ i18n.translate('guards.user.target_not_found', { args: { id: targetUserId }, }), ]); throw new NotFoundException(errorMsg); } if (!targetUser.memberships?.length) { this.logger.warn( `UserGuard: Target user #${targetUserId} has no memberships => can't perform ancestor logic.`, ); const [errorMsg] = await Promise.all([ i18n.translate('guards.user.no_memberships_target'), ]); throw new ForbiddenException(errorMsg); } // Build a set of all ancestor organization IDs for the target user. const ancestorOrgSet = new Set(); for (const m of targetUser.memberships) { const chain = await this.getOrgAncestorChain(m.organizationId); chain.forEach((orgId) => ancestorOrgSet.add(orgId)); } this.logger.debug( `UserGuard: Target #${targetUserId} => ancestor org chain => [${[...ancestorOrgSet].join(', ')}]`, ); // Ensure the requesting user has memberships. if (!Array.isArray(user.memberships)) { this.logger.warn( `UserGuard: Requesting user #${requesterUserId} has no memberships.`, ); const [errorMsg] = await Promise.all([ i18n.translate('guards.user.no_memberships'), ]); throw new ForbiddenException(errorMsg); } const relevantPlatformMembs = user.memberships.filter( (m) => ancestorOrgSet.has(m.orgId) && m.organizationType === 'PLATFORM', ); if (relevantPlatformMembs.length > 0) { for (const mem of relevantPlatformMembs) { for (const p of mem.permissions || []) { platformPerms.add(p); } } // Check if any of the required permissions appear in the platform permissions. hasPlatformPerm = requiredPermissions.some((perm) => platformPerms.has(perm), ); } } else { // Case B: For routes without a target user (e.g., GET /users to list all users). if (Array.isArray(user.memberships)) { const platformMemberships = user.memberships.filter( (m) => m.organizationType === 'PLATFORM', ); if (platformMemberships.length > 0) { for (const mem of platformMemberships) { for (const p of mem.permissions || []) { platformPerms.add(p); } } hasPlatformPerm = requiredPermissions.some((perm) => platformPerms.has(perm), ); } } else { this.logger.warn( `UserGuard: Requesting user #${requesterUserId} has no memberships.`, ); } } } // 7) If neither user-level nor platform-level permissions satisfy the requirement, deny access. if (!hasUserLevel && !hasPlatformPerm) { this.logger.warn( `UserGuard: User #${requesterUserId} missing required route-level perms => fail.`, ); const [errorMsg] = await Promise.all([ i18n.translate('guards.user.missing_required', { args: { perms: requiredPermissions.join(', ') }, }), ]); throw new ForbiddenException(errorMsg); } // 8) Merge the permissions from user-level and platform-level sources. // Attach the combined permissions to the request so that downstream code (e.g., the controller) // can differentiate between permissions such as "users.read.any" and "users.read.own". const combined = new Set([...userLevelPerms, ...platformPerms]); request.user.combinedPerms = combined; this.logger.debug( `UserGuard: User #${requesterUserId} combined permissions: [${[...combined].join(', ')}]`, ); return true; } /** * collectUserRolePerms: * This helper method collects permission strings from the user-level roles found on the user object. * It assumes that the user object has a "userRoles" array property. */ private collectUserRolePerms(user: any): Set { const perms = new Set(); if (Array.isArray(user.userRoles)) { for (const role of user.userRoles) { for (const p of role.permissions || []) { perms.add(p); } } } return perms; } /** * getOrgAncestorChain: * This helper method builds an array of organization IDs representing the parent chain, * starting at the provided orgId and working upward until no parent exists. * * For example, if organization 5 has parent 3 and organization 3 has parent 1, * the returned chain will be [5, 3, 1]. */ private async getOrgAncestorChain(orgId: number): Promise { const chain: number[] = []; let currentId = orgId; this.logger.debug( `getOrgAncestorChain: Starting chain from orgId: ${currentId}`, ); while (currentId) { const org = await this.prisma.organization.findUnique({ where: { id: currentId }, select: { id: true, parentId: true }, }); this.logger.debug( `getOrgAncestorChain: Fetched org: ${JSON.stringify(org)}`, ); if (!org) { break; } chain.push(org.id); currentId = org.parentId || 0; } return chain; } } File: src/interceptors/transform-permissions.interceptor.ts /** * @file transform-permissions.interceptor.ts * @description Intercepts responses for the "/permissions" route and, if * ?format=react is specified in the query, transforms the permission data * into a grouped structure by category. */ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; /** Nexus Logger for structured logging. */ import { NexusLoggerService } from 'src/logger/nexus-logger.service'; /** * Intercepts the response from GET /permissions requests if `?format=react` is present, * grouping the returned permission objects by category into a new structure. */ @Injectable() export class TransformPermissionsInterceptor implements NestInterceptor { /** * Creates a new TransformPermissionsInterceptor with a NexusLogger instance. * @param logger - The NexusLoggerService for structured log output. */ constructor(private readonly logger: NexusLoggerService) {} /** * Intercept method called by NestJS before sending the response. * @param context - ExecutionContext providing details of the current request. * @param next - CallHandler to pass control to the next middleware/controller. * @returns An RxJS Observable that we can map to replace/transform the response. */ intercept(context: ExecutionContext, next: CallHandler): Observable { // Extract underlying request information from the context. const request = context.switchToHttp().getRequest(); const { method, url, query, path } = request; // Determine if this route is /permissions (with or without query parameters). // For safety, we handle both 'path' and 'url'. const isPermissionsRoute = path === '/permissions' || url.startsWith('/permissions'); // Check if the user appended ?format=react in the query params. const wantsCustomFormat = query?.format === 'react'; this.logger.debug( `TransformPermissionsInterceptor => ` + `method=${method}, url=${url}, path=${path}, query=${JSON.stringify(query)}`, ); return next.handle().pipe( map((originalResponse) => { // Only transform if: // 1. It's a GET request, // 2. The route is /permissions, // 3. format=react was specified. if (method === 'GET' && isPermissionsRoute && wantsCustomFormat) { this.logger.info( 'TransformPermissionsInterceptor => Transforming permissions for React format.', ); const permissionArray = originalResponse?.data || []; const transformed = buildFrontendPermissions( permissionArray, this.logger, ); // Overwrite the original response with our newly grouped structure. return transformed; } // Otherwise, leave the response as-is. this.logger.debug( 'TransformPermissionsInterceptor => No transform applied, returning original response.', ); return originalResponse; }), ); } } /** * Builds a frontend-friendly structure from an array of permission objects. * * @param permissionArray - The original array of permission objects, typically from { data: [...] }. * @param logger - NexusLoggerService to log grouping details or info. * @returns A new array where each item represents a category grouping. * * The final shape: * [ * { * "title": "CategoryName", * "children": true, * "permissions": [ * { "id": number, "tag": string, "title": string, "description": string, "category": string, "scope": string } * ] * }, * ... * ] */ function buildFrontendPermissions( permissionArray: any[], logger: NexusLoggerService, ): any[] { logger.debug( 'buildFrontendPermissions => Grouping permissions by category...', ); // Use a Map to group permissions by their category field. const categoryMap = new Map(); for (const perm of permissionArray) { const categoryName = perm.category || 'Uncategorized'; // Initialize an empty array for this category if it doesn't exist yet. if (!categoryMap.has(categoryName)) { categoryMap.set(categoryName, []); } // Push a new item that maps "perm.name" -> "title" for the React front-end. categoryMap.get(categoryName).push({ id: perm.id, tag: perm.tag, title: perm.name, // rename "name" -> "title" description: perm.description, category: perm.category, scope: perm.scope, }); } logger.debug( `buildFrontendPermissions => Successfully grouped permissions into ${categoryMap.size} category(ies).`, ); // Convert the Map into an array of objects, each representing a category group. const result: any[] = []; for (const [categoryName, items] of categoryMap.entries()) { logger.debug( `buildFrontendPermissions => Category "${categoryName}" has ${items.length} item(s).`, ); result.push({ title: categoryName, children: true, permissions: items, }); } logger.info( `buildFrontendPermissions => Built final array of size ${result.length}.`, ); return result; } File: src/interceptors/yupvalidation.interceptor.ts import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { I18nService } from 'nestjs-i18n'; import { Observable } from 'rxjs'; import { mergeMap } from 'rxjs/operators'; interface FieldSchema { sectionId: number; name: string; label: string; type: string; required?: boolean; validation?: string; options?: any[]; initialValue?: any; } interface FormSchema { sections: { id: number; label: string }[]; fields: FieldSchema[]; } // The response may be a plain FormSchema or an object with a data property. type ResponseType = FormSchema | { data: FormSchema }; @Injectable() export class YupValidationInterceptor implements NestInterceptor { constructor(private readonly i18n: I18nService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); // Check for "format=react" in headers (using either "format" or "x-format") and query parameters. const headerFormat = ( request.headers['format'] || request.headers['x-format'] ) ?.toString() .toLowerCase(); const queryFormat = request.query.format?.toString().toLowerCase(); if (headerFormat !== 'react' && queryFormat !== 'react') { return next.handle(); } // Use the language already set on the request via @I18nLang() (or fallback to 'en') const lang = request.lang || 'en'; return next.handle().pipe( mergeMap(async (response: ResponseType) => { // Unwrap the schema whether it's wrapped in "data" or not. const schema: FormSchema = 'data' in response ? response.data : response; if (schema && Array.isArray(schema.fields)) { schema.fields = await Promise.all( schema.fields.map(async (field) => { if (field.required && !field.validation) { field.validation = await this.generateYupValidation( field, lang, ); } return field; }), ); } // Always return the plain schema (without a data wrapper) return schema; }), ); } private async generateYupValidation( field: FieldSchema, lang: string, ): Promise { let baseRule = ''; switch (field.type) { case 'text': case 'password': case 'select': baseRule = 'Yup.string()'; break; case 'email': { const invalidEmailMsg = await this.i18n.translate( 'common.form.validation.invalid_email', { lang }, ); baseRule = `Yup.string().email('${invalidEmailMsg}')`; break; } case 'number': baseRule = 'Yup.number()'; break; case 'multiselect': baseRule = 'Yup.array().of(Yup.string())'; break; case 'checkboxes': // Multiple checkboxes represented as an array of strings baseRule = 'Yup.array().of(Yup.string())'; if (field.required) { const requiredMsg = await this.i18n.translate( 'common.form.validation.required', { lang }, ); baseRule += `.min(1, '${requiredMsg}')`; } return baseRule; case 'checkbox': { baseRule = 'Yup.boolean()'; const requiredCheckboxMsg = await this.i18n.translate( 'common.form.validation.requiredCheckbox', { lang }, ); baseRule += `.oneOf([true], '${requiredCheckboxMsg}')`; break; } case 'date': baseRule = 'Yup.date()'; break; default: baseRule = 'Yup.mixed()'; } // For all types except checkboxes (handled above), add the required rule if necessary. if ( field.required && field.type !== 'checkbox' && field.type !== 'checkboxes' ) { const requiredMsg = await this.i18n.translate( 'common.form.validation.required', { lang }, ); baseRule += `.required('${requiredMsg}')`; } return baseRule; } } File: src/lang/en/auth.json { "errors": { "user_not_found": "User not found.", "bad_password": "Incorrect password.", "invalid_credentials": "Invalid credentials." }, "success": { "logged_in": "User logged in successfully." } } File: src/lang/en/campaigns.json { "success": { "list": "Campaigns retrieved successfully.", "getOne": "Campaign details retrieved successfully.", "created": "Campaign created successfully.", "updated": "Campaign with ID {id} updated successfully.", "deleted": "Campaign with ID {id} deleted successfully." }, "errors": { "no_membership": "No valid membership found for campaigns.", "forbidden_read": "Not allowed to read these campaigns.", "forbidden_edit": "Not allowed to edit this campaign.", "forbidden_delete": "Not allowed to delete this campaign.", "not_found": "No campaign found with ID {id}." } } File: src/lang/en/common.json { "errors": { "notFound": "{entity} with ID {id} not found.", "noPermissionRead": "No permission to read {entity} #{id}.", "hasChildren": "This {entity} has child {entity} and cannot be removed." }, "success": { "list": "List of {entities} retrieved successfully.", "getOne": "{entity} with ID {id} retrieved successfully.", "created": "{entity} created successfully.", "updated": "{entity} with ID {id} updated successfully.", "deleted": "{entity} with ID {id} deleted successfully.", "lockStatus": "Lock status updated for {entity} with ID {id} to {locked}." }, "form": { "general": { "name": "{entity} Name", "type": "{entity} Type", "platform": "Platform", "agency": "Agency", "client": "Client", "section_general": "General Information" }, "validation": { "required": "This field is required.", "required_specific": "{entity} name is required.", "invalidType": "Invalid {entity} type.", "invalidEmail": "Invalid email address.", "requiredCheckbox": "You must check this box to proceed." } } } File: src/lang/en/contacts.json { "success": { "list": "Contacts retrieved successfully.", "getOne": "Contact details retrieved successfully.", "created": "Contact created successfully.", "updated": "Contact with ID {id} updated successfully.", "deleted": "Contact with ID {id} deleted successfully." }, "errors": { "no_membership": "No valid membership found for contacts.", "forbidden_read": "Not allowed to read these contacts.", "forbidden_edit": "Not allowed to edit this contact.", "forbidden_delete": "Not allowed to delete this contact.", "not_found": "No contact found with ID {id}." } } File: src/lang/en/en.json { "auth": { "errors": { "user_not_found": "Invalid credentials: user not found", "bad_password": "Invalid credentials: bad password" } }, "greeting": "Hello, {{name}}!" } File: src/lang/en/groups.json { "success": { "group": "Groups retrieved successfully.", "get_one": "Group retrieved successfully.", "created": "Group '{name}' created successfully.", "updated": "Group with ID {id} updated successfully.", "deleted": "Group with ID {id} deleted successfully." }, "errors": { "forbidden_read": "You do not have permission to read group #{id}.", "forbidden_edit": "You do not have permission to edit group #{id}.", "forbidden_delete": "You do not have permission to delete group #{id}.", "not_found": "Group with ID {id} not found in org {orgId}.", "group_not_found": "Group with ID {groupId} not found in organization {orgId}.", "parent_not_found": "Parent group with ID {parentId} not found in organization {orgId}." } } File: src/lang/en/guards.json { "organization": { "no_user": "No user found in request.", "invalid_user": "Invalid user: no userId found.", "missing_org_id": "Organization ID parameter is required.", "invalid_org_id": "Invalid organization ID parameter.", "only_platform": "Only platform users can access global (orgId=0) routes.", "missing_required_permissions": "Missing required permission(s): [{permissions}].", "not_found": "Organization {orgId} not found.", "no_memberships": "User has no memberships.", "user_not_in_org": "User is not a member of organization {orgId} or its parent organizations." }, "permissions": { "missing": "Missing required permission. Need one of: [{perms}]." }, "platform": { "no_user": "No user found for platform check.", "no_memberships": "User has no memberships.", "not_member": "User is not a member of any platform organization.", "missing_permissions": "Missing permission(s): [{permissions}]." }, "user": { "no_user": "Missing user in request.", "missing_user_id": "Invalid user: no userId found.", "target_not_found": "Target user with ID {id} not found.", "no_memberships_target": "Target user has no memberships.", "no_memberships": "User has no memberships.", "missing_required": "Missing required permission(s): [{perms}]." } } File: src/lang/en/lists.json { "success": { "list": "Lists retrieved successfully.", "get_one": "List retrieved successfully.", "created": "List '{name}' created successfully.", "updated": "List with ID {id} updated successfully.", "deleted": "List with ID {id} deleted successfully." }, "errors": { "forbidden_read": "You do not have permission to read lists here.", "forbidden_edit": "You do not have permission to edit list #{id}.", "forbidden_delete": "You do not have permission to delete list #{id}.", "not_found": "List with ID {id} not found in org {orgId}.", "group_not_found": "Group with ID {groupId} not found in organization {orgId}." } } File: src/lang/en/memberships.json { "errors": { "forbidden": "You are not allowed to perform this action on memberships.", "already_exists": "A membership already exists for the user {userId} in the organization {orgId}.", "not_found": "Membership with ID {id} not found or user does not belong to this organization.", "org_not_found": "Organization #{orgId} not found.", "user_not_found": "User #{userId} not found." }, "success": { "list": "List of memberships retrieved successfully.", "getOne": "Membership details for ID {id} retrieved successfully.", "created": "Membership created successfully.", "updated": "Membership for user {id} updated successfully.", "deleted": "Membership for user {id} deleted successfully." } } File: src/lang/en/organizations.json { "errors": { "notFound": "Organization with ID {id} not found.", "noPermissionRead": "No permission to read organization #{id}.", "parentNotFound": "Parent organization with ID {id} not found.", "invalidParentType": "Parent organization must be PLATFORM or AGENCY.", "noPlatform": "No PLATFORM organization found for default parent.", "platformCreate": "You do not have permission to create PLATFORM organizations.", "locked": "This organization is locked and cannot be removed.", "hasChildren": "This organization has child organizations and cannot be removed.", "lockUnchanged": "Organization #{id} is already {locked, select, true{locked} false{unlocked} other{locked/unlocked}}." }, "success": { "found": "Organization with ID {id} found.", "list": "List of organizations retrieved successfully.", "getOne": "Organization with ID {id} retrieved successfully.", "created": "Organization created successfully.", "updated": "Organization with ID {id} updated successfully.", "deleted": "Organization with ID {id} deleted successfully.", "lockStatus": "Lock status updated for organization with ID {id} to {locked}." }, "form": { "name": "Organization Name", "type": "Organization Type", "platform": "Platform", "agency": "Agency", "client": "Client", "section_general": "General Information", "section_contactDetails": "Contact Details", "section_socialProfiles": "Social Profiles", "email": "Email Address", "phone": "Phone Number", "address": "Address", "city": "City", "state": "State", "country": "Country", "postalCode": "Postal/Zip Code", "timezone": "Timezone", "currency": "Currency", "logo": "Logo", "website": "Website", "doingBusinessSince": "Doing Business Since", "organizationSize": "Organization Size", "socialLinks": "Social Links" } } File: src/lang/en/permissions.json { "success": { "created": "Permission created successfully.", "list": "Permissions retrieved successfully.", "getOne": "Permission details retrieved successfully.", "updated": "Permission updated successfully.", "deleted": "Permission deleted successfully." }, "errors": { "platform_only_create": "Only platform users are allowed to create permissions.", "tag_exists": "A permission with tag '{tag}' already exists.", "invalid_scope": "Invalid user scope '{userScope}'.", "not_found": "Permission with ID {id} not found.", "scope_forbidden": "Access denied: Your scope '{userScope}' cannot access a permission with scope '{permissionScope}'.", "platform_only_update": "Only platform users are allowed to update permissions.", "platform_only_delete": "Only platform users are allowed to delete permissions." } } File: src/lang/en/roles.json { "success": { "list": "List of roles retrieved successfully.", "getOne": "Role details retrieved successfully.", "created": "Role created successfully.", "updated": "Role updated successfully.", "deleted": "Role deleted successfully.", "permissions_assigned": "Permissions assigned successfully.", "permissions_fetched": "Role permissions retrieved successfully.", "user_role_created": "User role created successfully.", "user_roles_retrieved": "User roles retrieved successfully.", "user_role_retrieved": "User role retrieved successfully.", "user_role_updated": "User role updated successfully.", "user_role_assigned": "User role assigned successfully.", "user_role_removed": "User role removed successfully.", "user_role_deleted": "User role deleted successfully." }, "errors": { "global_create_forbidden": "Only platform users can create a global role.", "invalid_org_type": "Invalid orgType: {orgType}.", "org_not_found": "Organization {orgId} not found.", "role_not_found": "Role with ID {roleId} not found.", "forbidden_view_global": "This is not a global role.", "forbidden_view_role": "You are not authorized to view this role.", "immutable_change": "Cannot change {field}.", "permission_tags_not_found": "Some permission tags do not exist: {tags}.", "forbidden_assign_permission": "Cannot assign permission \"{tag}\" => requires {requiredScope} scope." } } File: src/lang/en/sample.json { "form": { "section": { "general": "General Information", "additional": "Additional Information" }, "firstName": "First Name", "lastName": "Last Name", "email": "Email Address", "age": "Age", "password": "Password", "country": "Country", "countryOptions": { "us": "United States", "fr": "France", "de": "Germany", "pk": "Pakistan", "gb": "United Kingdom" }, "favoriteColors": "Favorite Colors", "colors": { "red": "Red", "green": "Green", "blue": "Blue" }, "interests": "Interests", "interestOptions": { "sports": "Sports", "music": "Music", "movies": "Movies" }, "gender": "Gender", "genderOptions": { "male": "Male", "female": "Female" }, "subscribeNewsletter": "Subscribe to Newsletter", "birthDate": "Birth Date", "comments": "Comments", "internalId": "Internal ID", "notifications": "Enable Notifications" } } File: src/lang/en/senders.json { "success": { "list": "Senders retrieved successfully.", "getOne": "Sender details retrieved successfully.", "created": "Sender created successfully.", "updated": "Sender with ID {id} updated successfully.", "deleted": "Sender with ID {id} deleted successfully." }, "errors": { "no_membership": "No valid membership found to manage senders.", "not_found": "Sender with ID {id} not found.", "forbidden_read": "Not allowed to read these senders.", "forbidden_edit": "Not allowed to edit this sender.", "forbidden_delete": "Not allowed to delete this sender." } } File: src/lang/en/users.json { "success": { "list": "Users retrieved successfully.", "getOne": "User details retrieved successfully.", "created": "User created successfully.", "updated": "User updated successfully.", "deleted": "User deleted successfully." }, "errors": { "forbidden_read": "You do not have permission to read users.", "not_found": "User #{id} not found.", "email_taken": "Email \"{email}\" is already taken.", "forbidden_edit": "No permission to edit user #{id}.", "forbidden_delete": "No permission to delete user #{id}.", "invalid_migrate_param": "Invalid 'migrateToUserId' parameter.", "memberships_exist_self": "You still have {count} membership(s). Remove them first or pass force=1 to forcibly delete.", "memberships_exist": "User #{id} has {count} existing memberships. Pass force=1 to forcibly delete.", "assets_exist_self": "You have {count} assets. Remove or reassign them before deletion.", "assets_exist": "User #{id} has {count} assets. Provide \"migrateToUserId\" to reassign." } } File: src/lang/fr/auth.json { "errors": { "user_not_found": "Utilisateur non trouvé.", "bad_password": "Mot de passe incorrect.", "invalid_credentials": "Informations d'identification invalides." }, "success": { "logged_in": "L'utilisateur s'est connecté avec succès." } } File: src/lang/fr/common.json { "errors": { "notFound": "{entity} avec ID {id} non trouvé.", "noPermissionRead": "Pas de permission pour lire {entity} #{id}.", "hasChildren": "Cet {entity} a un enfant {entity} et ne peut pas être supprimé." }, "success": { "list": "Liste des {entities} récupérée avec succès.", "getOne": "{entity} avec ID {id} récupéré avec succès.", "created": "{entity} créé avec succès.", "updated": "{entity} avec ID {id} mis à jour avec succès.", "deleted": "{entity} avec ID {id} supprimé avec succès.", "lockStatus": "Statut de verrouillage mis à jour pour {entity} avec ID {id} à {locked}." }, "form": { "general": { "name": "Nom de {entity}", "type": "Type de {entity}", "platform": "Plateforme", "agency": "Agence", "client": "Client", "section_general": "Informations générales" }, "validation": { "required": "Ce champ est requis.", "required_specific": "Le nom de {entity} est requis.", "invalidType": "Type {entity} invalide.", "invalidEmail": "Adresse e-mail invalide.", "requiredCheckbox": "Vous devez cocher cette case pour continuer." } } } File: src/lang/fr/fr.json { "auth": { "errors": { "user_not_found": "Identifiants invalides: utilisateur introuvable", "bad_password": "Identifiants invalides: mauvais mot de passe" } }, "greeting": "Bonjour, {{name}}!" } File: src/lang/fr/guards.json { "organization": { "no_user": "Aucun utilisateur trouvé dans la requête.", "invalid_user": "Utilisateur invalide : aucun userId trouvé.", "missing_org_id": "Le paramètre d'ID d'organisation est requis.", "invalid_org_id": "Paramètre d'ID d'organisation invalide.", "only_platform": "Seuls les utilisateurs de la plateforme peuvent accéder aux routes globales (orgId=0).", "missing_required_permissions": "Permission(s) requise(s) manquante(s) : [{permissions}].", "not_found": "Organisation {orgId} introuvable.", "no_memberships": "L'utilisateur n'a aucune affiliation.", "user_not_in_org": "L'utilisateur n'est pas membre de l'organisation {orgId} ni de ses organisations parentes." }, "permissions": { "missing": "Permission requise manquante. Il faut l'une des suivantes : [{perms}]." }, "platform": { "no_user": "Aucun utilisateur trouvé pour la vérification de la plateforme.", "no_memberships": "L'utilisateur n'a aucune affiliation.", "not_member": "L'utilisateur n'est membre d'aucune organisation de plateforme.", "missing_permissions": "Permission(s) manquante(s) : [{permissions}]." }, "user": { "no_user": "Aucun utilisateur trouvé dans la requête.", "missing_user_id": "Utilisateur invalide : aucun userId trouvé.", "target_not_found": "Utilisateur cible avec l'ID {id} introuvable.", "no_memberships_target": "L'utilisateur cible n'a aucune affiliation.", "no_memberships": "L'utilisateur n'a aucune affiliation.", "missing_required": "Permission(s) requise(s) manquante(s) : [{perms}]." } } File: src/lang/fr/lists.json { "success": { "list": "Listes récupérées avec succès.", "get_one": "Liste récupérée avec succès.", "created": "Liste '{name}' créée avec succès.", "updated": "Liste avec ID {id} mise à jour avec succès.", "deleted": "Liste avec ID {id} supprimée avec succès." }, "errors": { "forbidden_read": "Vous n'avez pas la permission de lire les listes ici.", "forbidden_edit": "Vous n'avez pas la permission de modifier la liste #{id}.", "forbidden_delete": "Vous n'avez pas la permission de supprimer la liste #{id}.", "not_found": "Liste avec ID {id} non trouvée dans l'org {orgId}.", "group_not_found": "Groupe avec ID {groupId} non trouvé dans l'organisation {orgId}." } } File: src/lang/fr/memberships.json { "errors": { "forbidden": "Vous n'êtes pas autorisé à effectuer cette action sur les affiliations.", "already_exists": "Une affiliation existe déjà pour cet utilisateur dans l'organisation spécifiée.", "not_found": "Affiliation avec l'ID {id} introuvable ou l'utilisateur n'appartient pas à cette organisation.", "org_not_found": "Organisation #{orgId} introuvable.", "user_not_found": "Utilisateur #{userId} introuvable." }, "success": { "list": "Liste des affiliations récupérée avec succès.", "getOne": "Détails de l'affiliation avec l'ID {id} récupérés avec succès.", "created": "Affiliation créée avec succès.", "updated": "Affiliation pour l'utilisateur {id} mise à jour avec succès.", "deleted": "Affiliation pour l'utilisateur {id} supprimée avec succès." } } File: src/lang/fr/organizations.json { "errors": { "notFound": "L'organisation avec l'ID {id} est introuvable.", "noPermissionRead": "Aucune autorisation pour consulter l'organisation #{id}.", "parentNotFound": "L'organisation parent avec l'ID {id} est introuvable.", "invalidParentType": "L'organisation parent doit être de type PLATFORM ou AGENCY.", "noPlatform": "Aucune organisation PLATFORM n'a été trouvée pour servir de parent par défaut.", "platformCreate": "Vous n'êtes pas autorisé à créer des organisations PLATFORM.", "locked": "Cette organisation est verrouillée et ne peut pas être supprimée.", "hasChildren": "Cette organisation possède des sous-organisations et ne peut pas être supprimée.", "lockUnchanged": "L'organisation #{id} est déjà {locked, select, true{verrouillée} false{déverrouillée} other{verrouillée/déverrouillée}}." }, "success": { "found": "L'organisation avec l'ID {id} a été trouvée avec succès.", "list": "La liste des organisations a été récupérée avec succès.", "getOne": "L'organisation avec l'ID {id} a été récupérée avec succès.", "created": "L'organisation a été créée avec succès.", "updated": "L'organisation avec l'ID {id} a été mise à jour avec succès.", "deleted": "L'organisation avec l'ID {id} a été supprimée avec succès.", "lockStatus": "L'état de verrouillage de l'organisation avec l'ID {id} est passé à {locked}." }, "form": { "name": "Nom de l'organisation", "type": "Type d'organisation", "platform": "Plateforme", "agency": "Agence", "client": "Client", "section_general": "Informations générales", "section_contactDetails": "Coordonnées", "section_socialProfiles": "Profils sociaux", "email": "Adresse e-mail", "phone": "Numéro de téléphone", "address": "Adresse", "city": "Ville", "state": "État", "country": "Pays", "postalCode": "Code postal", "timezone": "Fuseau horaire", "currency": "Devise", "logo": "Logo", "website": "Site web", "doingBusinessSince": "Date de début d'activité", "organizationSize": "Taille de l'organisation", "socialLinks": "Liens sociaux" } } File: src/lang/fr/permissions.json { "success": { "created": "Permission créée avec succès.", "list": "Les permissions ont été récupérées avec succès.", "getOne": "Les détails de la permission ont été récupérés avec succès.", "updated": "Permission mise à jour avec succès.", "deleted": "Permission supprimée avec succès." }, "errors": { "platform_only_create": "Seuls les utilisateurs de la plateforme sont autorisés à créer des permissions.", "tag_exists": "Une permission avec le tag '{tag}' existe déjà.", "invalid_scope": "Portée utilisateur invalide '{userScope}'.", "not_found": "Permission avec l'ID {id} introuvable.", "scope_forbidden": "Accès refusé : Votre portée '{userScope}' ne peut pas accéder à une permission avec la portée '{permissionScope}'.", "platform_only_update": "Seuls les utilisateurs de la plateforme sont autorisés à mettre à jour des permissions.", "platform_only_delete": "Seuls les utilisateurs de la plateforme sont autorisés à supprimer des permissions." } } File: src/lang/fr/roles.json { "success": { "list": "Liste des rôles récupérée avec succès.", "getOne": "Détails du rôle récupérés avec succès.", "created": "Rôle créé avec succès.", "updated": "Rôle mis à jour avec succès.", "deleted": "Rôle supprimé avec succès.", "permissions_assigned": "Permissions assignées avec succès.", "permissions_fetched": "Permissions du rôle récupérées avec succès.", "user_role_created": "Rôle utilisateur créé avec succès.", "user_roles_retrieved": "Rôles utilisateur récupérés avec succès.", "user_role_retrieved": "Rôle utilisateur récupéré avec succès.", "user_role_updated": "Rôle utilisateur mis à jour avec succès.", "user_role_assigned": "Rôle utilisateur assigné avec succès.", "user_role_removed": "Rôle utilisateur retiré avec succès.", "user_role_deleted": "Rôle utilisateur supprimé avec succès." }, "errors": { "global_create_forbidden": "Seuls les utilisateurs de la plateforme peuvent créer un rôle global.", "invalid_org_type": "Type d'organisation invalide : {orgType}.", "org_not_found": "Organisation {orgId} introuvable.", "role_not_found": "Rôle avec l'ID {roleId} introuvable.", "forbidden_view_global": "Ce n'est pas un rôle global.", "forbidden_view_role": "Vous n'êtes pas autorisé à voir ce rôle.", "immutable_change": "Impossible de modifier {field}.", "permission_tags_not_found": "Certains tags de permission n'existent pas : {tags}.", "forbidden_assign_permission": "Impossible d'assigner la permission \"{tag}\" => nécessite une portée {requiredScope}." } } File: src/lang/fr/sample.json { "form": { "section": { "general": "Informations Générales", "additional": "Informations Supplémentaires" }, "firstName": "Prénom", "lastName": "Nom de famille", "email": "Adresse e-mail", "age": "Âge", "password": "Mot de passe", "country": "Pays", "countryOptions": { "us": "États-Unis", "fr": "France", "de": "Allemagne", "pk": "Pakistan", "gb": "Royaume-Uni" }, "favoriteColors": "Couleurs Préférées", "colors": { "red": "Rouge", "green": "Vert", "blue": "Bleu" }, "interests": "Intérêts", "interestOptions": { "sports": "Sports", "music": "Musique", "movies": "Films" }, "gender": "Genre", "genderOptions": { "male": "Homme", "female": "Femme" }, "subscribeNewsletter": "S'abonner à la newsletter", "birthDate": "Date de naissance", "comments": "Commentaires", "internalId": "ID Interne", "notifications": "Activer les notifications" } } File: src/lang/fr/users.json { "success": { "list": "Les utilisateurs ont été récupérés avec succès.", "getOne": "Les détails de l'utilisateur ont été récupérés avec succès.", "created": "Utilisateur créé avec succès.", "updated": "Utilisateur mis à jour avec succès.", "deleted": "Utilisateur supprimé avec succès." }, "errors": { "forbidden_read": "Vous n'êtes pas autorisé à lire les utilisateurs.", "not_found": "Utilisateur #{id} introuvable.", "email_taken": "L'email \"{email}\" est déjà utilisé.", "forbidden_edit": "Aucune autorisation pour modifier l'utilisateur #{id}.", "forbidden_delete": "Aucune autorisation pour supprimer l'utilisateur #{id}.", "invalid_migrate_param": "Paramètre 'migrateToUserId' invalide.", "memberships_exist_self": "Vous avez encore {count} adhésion(s). Supprimez-les ou passez force=1 pour forcer la suppression.", "memberships_exist": "L'utilisateur #{id} a {count} adhésions existantes. Passez force=1 pour forcer la suppression.", "assets_exist_self": "Vous avez {count} actifs. Supprimez-les ou réaffectez-les avant la suppression.", "assets_exist": "L'utilisateur #{id} a {count} actifs. Fournissez \"migrateToUserId\" pour les réaffecter." } } File: src/lists/dto/create-list.dto.ts import { IsArray, IsBoolean, IsInt, IsNumber, IsObject, IsOptional, IsString, ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; /** * If you want more explicit control over contactsBreakup and settings, * define dedicated classes/interfaces and use `@ValidateNested()`. * For now, we keep them as unknown/any while still marking them as objects. */ class ContactsBreakupDto { @IsOptional() @IsNumber() emails?: number = 0; @IsOptional() @IsNumber() sms?: number = 0; @IsOptional() @IsNumber() whatsapp?: number = 0; } /** * DTO for creating a new List entity. * Reflects the `lists` model in your schema. */ export class CreateListDto { @IsString() name: string; @IsOptional() @IsInt() groupId?: number = null; @IsOptional() @IsInt() contacts?: number = 0; @IsOptional() @IsObject() @ValidateNested() @Type(() => ContactsBreakupDto) contactsBreakup?: ContactsBreakupDto; @IsOptional() @IsArray() @IsString({ each: true }) // Each element must be a string fields?: string[]; @IsOptional() @IsBoolean() isDeleted?: boolean = false; @IsOptional() @IsBoolean() isBlocked?: boolean = false; @IsOptional() @IsInt() userId?: number; // Usually assigned from req.user.userId @IsOptional() @IsObject() settings?: any = {}; @IsOptional() @IsBoolean() disableImport?: boolean = false; @IsOptional() @IsBoolean() disableEdit?: boolean = false; @IsOptional() @IsInt() sortOrder?: number = 0; } File: src/lists/dto/filter.dto.ts import { IsOptional, IsIn, IsString } from 'class-validator'; export class FilterDto { @IsString() field?: string; @IsIn([ 'equals', 'contains', 'startsWith', 'endsWith', 'gt', 'gte', 'lt', 'lte', 'not', ]) operator?: string; @IsOptional() value?: any; } File: src/lists/dto/pagination.dto.ts import { IsOptional, IsInt, Min, Max, IsString, IsArray, ValidateNested, } from 'class-validator'; import { Transform, Type, plainToInstance } from 'class-transformer'; import { FilterDto } from './filter.dto'; export class PaginationDto { @IsOptional() @Transform(({ value }) => parseInt(value, 10)) @IsInt() @Min(1) page: number = 1; @IsOptional() @Transform(({ value }) => parseInt(value, 10)) @IsInt() @Min(1) pageSize: number = 5; @IsOptional() @IsString() sortField?: string; @IsOptional() @IsString() sortOrder: string = 'asc'; @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => FilterDto) @Transform(({ value }) => { if (!value) return []; let filterArray: FilterDto[]; if (typeof value === 'string') { try { filterArray = JSON.parse(value); } catch (err) { console.error('Failed to parse filters:', err); // Log the error return []; } } else if (Array.isArray(value)) { filterArray = value; } else { console.warn('Unexpected filters format, defaulting to empty array'); return []; } // Convert plain objects to FilterDto instances return plainToInstance(FilterDto, filterArray); }) filters: FilterDto[] = []; // Default value to ensure consistency @IsOptional() @Transform(({ value }) => parseInt(value, 10)) @IsInt() userId?: number; @IsOptional() @Transform(({ value }) => parseInt(value, 10)) @IsInt() @Min(0) @Max(1) execution_time: number = 0; } File: src/lists/dto/update-list.dto.ts import { PartialType, OmitType } from '@nestjs/mapped-types'; import { CreateListDto } from './create-list.dto'; /** * DTO for partially updating an existing list. * Inherits from CreateListDto, making all fields optional. */ export class UpdateListDto extends PartialType( OmitType(CreateListDto, [ // Omit fields you do NOT want updatable 'contacts', 'contactsBreakup', 'isDeleted', 'isBlocked', 'disableImport', 'disableEdit', ] as const), ) {} File: src/lists/lists.controller.ts import { Body, Controller, Delete, ForbiddenException, Get, NotFoundException, Param, ParseIntPipe, Patch, Post, Query, Req, UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { ListsService } from './lists.service'; import { PaginationDto } from './dto/pagination.dto'; import { CreateListDto } from './dto/create-list.dto'; import { UpdateListDto } from './dto/update-list.dto'; import { Permissions } from 'src/auth/permissions.decorator'; import { OrganizationGuard } from 'src/guards/organization.guard'; /** * ListsController * * - Handles routes for managing "List" entities inside a specific organization. * - Uses OrganizationGuard to ensure the user belongs to the specified org (orgId). * - Applies permission checks at the route level (e.g., lists.read.any, lists.read.own). * - Uses concurrency patterns (Promise.all) for i18n translations & DB operations. */ @UseGuards(OrganizationGuard) @Controller(':orgId/lists') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) export class ListsController { constructor( private readonly listsService: ListsService, private readonly i18n: I18nService, ) {} /** * GET /:orgId/lists * * List all Lists for an organization with optional pagination, sorting, and filters. * Permissions: * - 'lists.read.any' => can view all lists in this org. * - 'lists.read.own' => can view only their own lists (createdByUserId = userId). */ @Get() @Permissions('lists.read.any', 'lists.read.own') async getAllLists( @Param('orgId', ParseIntPipe) orgId: number, @Query() paginationDto: PaginationDto, @Req() req: any, @I18nLang() lang: string, ) { // orgMembership is attached by OrganizationGuard const membership = req.orgMembership; if (!membership) { // Example of translating an error message const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userId = membership.userId; const userPerms = membership.permissions || []; // Decide if the user can see all or only "own" lists const canReadAny = userPerms.includes('lists.read.any'); const canReadOwn = userPerms.includes('lists.read.own'); if (!canReadAny && !canReadOwn) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.forbidden_read', { lang }), ]); throw new ForbiddenException(errorMsg); } // If user can only read own => filter by createdByUserId if (!canReadAny && canReadOwn) { paginationDto.userId = userId; } // Execute the DB call + fetch translation concurrently const [result, i18nMsg] = await Promise.all([ this.listsService.findAllPagination(orgId, paginationDto), this.i18n.translate('lists.success.list', { lang, // Example: pass {orgId} in case you want to use it in your translation args: { orgId }, }), ]); // Return response return { message: i18nMsg, ...result, }; } /** * GET /:orgId/lists/:id * * Retrieve a single List by ID. Respects 'lists.read.any' or checks if the * user is the creator (lists.read.own). */ @Get(':id') @Permissions('lists.read.any', 'lists.read.own') async getSingleList( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) listId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userId = membership.userId; const userPerms = membership.permissions || []; const canReadAny = userPerms.includes('lists.read.any'); const canReadOwn = userPerms.includes('lists.read.own'); // 1) Fetch the list const list = await this.listsService.findOne(orgId, listId); if (!list) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.not_found', { lang, args: { id: listId, orgId }, }), ]); throw new NotFoundException(errorMsg); } // 2) Check ownership if user cannot read any if (!canReadAny) { // => must check if createdByUserId === userId if (list.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.forbidden_read', { lang, }), ]); throw new ForbiddenException(errorMsg); } } // 3) Return success const [i18nMsg] = await Promise.all([ this.i18n.translate('lists.success.get_one', { lang, args: { id: listId }, }), ]); return { message: i18nMsg, data: list }; } /** * POST /:orgId/lists/add * * Create a new List in the organization. Requires 'lists.create'. */ @Post('add') @Permissions('lists.create') async createList( @Param('orgId', ParseIntPipe) orgId: number, @Body() dto: CreateListDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; const created = await this.listsService.createList( orgId, dto, membership.userId, lang, ); const [msg] = await Promise.all([ this.i18n.translate('lists.success.created', { lang, args: { name: dto.name }, }), ]); return { message: msg, data: created }; } /** * PATCH /:orgId/lists/edit/:id * * Update an existing List. Requires 'lists.edit.any' or 'lists.edit.own' (ownership check). */ @Patch('edit/:id') @Permissions('lists.edit.any', 'lists.edit.own') async updateList( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) listId: number, @Body() dto: UpdateListDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userId = membership.userId; const userPerms = membership.permissions || []; const canEditAny = userPerms.includes('lists.edit.any'); // 1) Check existence const existing = await this.listsService.findOne(orgId, listId); if (!existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.not_found', { lang, args: { id: listId, orgId }, }), ]); throw new NotFoundException(errorMsg); } // 2) If user does not have "any" permission, they must be the owner if (!canEditAny && existing.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.forbidden_edit', { lang, args: { id: listId }, }), ]); throw new ForbiddenException(errorMsg); } // 3) Update const updated = await this.listsService.updateList( orgId, listId, dto, userId, ); // 4) Return success const [i18nMsg] = await Promise.all([ this.i18n.translate('lists.success.updated', { lang, args: { id: listId }, }), ]); return { message: i18nMsg, data: updated }; } /** * DELETE /:orgId/lists/delete/:id * * Delete a List. Requires 'lists.delete.any' or 'lists.delete.own'. */ @Delete('delete/:id') @Permissions('lists.delete.any', 'lists.delete.own') async deleteList( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) listId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const userId = membership.userId; const userPerms = membership.permissions || []; const canDeleteAny = userPerms.includes('lists.delete.any'); // 1) Check existence const existing = await this.listsService.findOne(orgId, listId); if (!existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.not_found', { lang, args: { id: listId, orgId }, }), ]); throw new NotFoundException(errorMsg); } // 2) If not "any", ensure ownership if (!canDeleteAny && existing.createdByUserId !== userId) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.forbidden_delete', { lang, args: { id: listId }, }), ]); throw new ForbiddenException(errorMsg); } // 3) Delete from DB await this.listsService.deleteList(orgId, listId); // 4) Return success const [i18nMsg] = await Promise.all([ this.i18n.translate('lists.success.deleted', { lang, args: { id: listId }, }), ]); return { message: i18nMsg }; } } File: src/lists/lists.module.ts import { Module } from '@nestjs/common'; import { ListsService } from './lists.service'; import { ListsController } from './lists.controller'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; @Module({ imports: [], controllers: [ListsController], providers: [ListsService, PrismaService, NexusLoggerService], exports: [ListsService], }) export class ListsModule {} File: src/lists/lists.service.ts import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateListDto } from './dto/create-list.dto'; import { UpdateListDto } from './dto/update-list.dto'; import { PaginationDto } from './dto/pagination.dto'; import { performance } from 'perf_hooks'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { I18nService } from 'nestjs-i18n'; /** * ListsService * * - Provides DB operations for lists (findAll, findOne, create, update, delete). * - Enforces rules such as "isDeleted" or "isBlocked" checks during update. * - Works with a paginated approach for "findAllPagination". */ @Injectable() export class ListsService { constructor( private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, private readonly i18n: I18nService, ) {} /** * findOne: * Returns a single list by orgId + listId, or null if not found. */ async findOne(orgId: number, listId: number) { return this.prisma.list.findFirst({ where: { id: listId, organizationId: orgId, }, }); } /** * createList: * Creates a new List in the given org, setting createdByUserId for ownership. */ async createList( orgId: number, dto: CreateListDto, userId: number, lang: string, ) { // If groupId is provided, ensure it exists in the DB and belongs to the organization if (dto.groupId) { const group = await this.prisma.group.findFirst({ where: { id: dto.groupId, organizationId: orgId, }, }); if (!group) { const [errorMsg] = await Promise.all([ this.i18n.translate('lists.errors.group_not_found', { lang, args: { groupId: dto.groupId, orgId }, }), ]); throw new BadRequestException(errorMsg); } } const created = await this.prisma.list.create({ data: { name: dto.name, groupId: dto.groupId ?? null, contacts: dto.contacts ?? 0, contactsBreakup: { emails: 0, sms: 0, whatsapp: 0, }, fields: dto.fields ?? [], // settings: dto.settings ? (dto.settings as Prisma.JsonValue) : {}, // For future use may be settings: {}, sortOrder: dto.sortOrder ?? 0, organizationId: orgId, createdByUserId: userId, // You can incorporate isDeleted, isBlocked, etc. as needed }, }); this.logger.info( `List #${created.id} created in org #${orgId} by user #${userId}.`, ); return created; } /** * updateList: * Updates an existing list if it's neither blocked nor deleted. */ async updateList( orgId: number, listId: number, dto: UpdateListDto, userId: number, ) { const existing = await this.findOne(orgId, listId); if (!existing) { throw new NotFoundException( `List #${listId} not found in org #${orgId}.`, ); } // Example of disallowing changes if blocked or deleted if (existing.isBlocked) { throw new BadRequestException('Cannot update a blocked list.'); } if (existing.isDeleted) { throw new BadRequestException('Cannot update a deleted list.'); } const updated = await this.prisma.list.update({ where: { id: listId }, data: { name: dto.name ?? existing.name, fields: dto.fields ?? existing.fields, // settings: dto.settings // ? (dto.settings as Prisma.JsonValue) // : existing.settings, settings: existing.settings, // For now, keep existing settings sortOrder: dto.sortOrder ?? existing.sortOrder, updatedByUserId: userId, }, }); this.logger.info( `List #${listId} updated in org #${orgId} by user #${userId}.`, ); return updated; } /** * deleteList: * Permanently deletes the record from the DB. */ async deleteList(orgId: number, listId: number) { const existing = await this.findOne(orgId, listId); if (!existing) { throw new NotFoundException( `List #${listId} not found in org #${orgId}.`, ); } await this.prisma.list.delete({ where: { id: listId } }); this.logger.success(`List #${listId} deleted from org #${orgId}.`); return true; } /** * findAllPagination: * Returns a paginated response of lists for a given org + optional "userId" filter for "own" logic. */ async findAllPagination(orgId: number, paginationDto: PaginationDto) { const { page, pageSize, sortField, sortOrder, filters, execution_time, userId, } = paginationDto; let startTime: number | undefined; if (execution_time === 1) { startTime = performance.now(); } const currentPage = page || 1; const limit = pageSize || 10; const skip = (currentPage - 1) * limit; const take = limit; const finalSortField = sortField || 'createdAt'; const finalSortOrder = sortOrder || 'asc'; // Build "where" conditions const where: Record = { organizationId: orgId, }; // If userId => filter by createdByUserId if (userId) { where.createdByUserId = userId; } // Additional dynamic filters if (filters && Array.isArray(filters)) { for (const f of filters) { if (!f.field || !f.operator || f.value === undefined) continue; if (['organizationId', 'createdByUserId'].includes(f.field)) continue; where[f.field] = { [f.operator]: f.value }; } } // Execute both queries concurrently const [data, total] = await Promise.all([ this.prisma.list.findMany({ skip, take, where, orderBy: { [finalSortField]: finalSortOrder }, }), this.prisma.list.count({ where }), ]); const totalPages = Math.ceil(total / limit); // Construct pagination links const links = this.buildPaginationLinks(currentPage, totalPages); const response: any = { status: 'success', code: 200, data, payload: { pagination: { page: currentPage, first_page_url: '/?page=1', from: skip + 1, last_page: totalPages, links, next_page_url: currentPage < totalPages ? `/?page=${currentPage + 1}` : null, items_per_page: limit, prev_page_url: currentPage > 1 ? `/?page=${currentPage - 1}` : null, to: skip + data.length, total, }, }, message: '', // Will be overridden in the controller if needed }; if (execution_time === 1 && startTime !== undefined) { const endTime = performance.now(); const elapsedSec = (endTime - startTime) / 1000; response.executionTime = parseFloat(elapsedSec.toFixed(4)); } return response; } private buildPaginationLinks(currentPage: number, totalPages: number) { const links = []; // First links.push({ url: currentPage > 1 ? `/?page=1` : null, label: 'First', active: currentPage === 1, page: 1, }); // Previous links.push({ url: currentPage > 1 ? `/?page=${currentPage - 1}` : null, label: 'Previous', active: currentPage === 1, page: currentPage - 1, }); // Numbered pages for (let i = 1; i <= totalPages; i++) { links.push({ url: `/?page=${i}`, label: String(i), active: i === currentPage, page: i, }); } // Next links.push({ url: currentPage < totalPages ? `/?page=${currentPage + 1}` : null, label: 'Next', active: currentPage === totalPages, page: currentPage + 1, }); // Last links.push({ url: currentPage < totalPages ? `/?page=${totalPages}` : null, label: 'Last', active: currentPage === totalPages, page: totalPages, }); return links; } } File: src/logger/nexus-logger.module.ts import { Module, Global } from '@nestjs/common'; import { NexusLoggerService } from './nexus-logger.service'; @Global() // Makes this module global, so you don't need to import it elsewhere @Module({ providers: [NexusLoggerService], exports: [NexusLoggerService], }) export class NexusLoggerModule {} File: src/logger/nexus-logger.service.ts import { Injectable, LoggerService, Scope, OnModuleInit } from '@nestjs/common'; import * as winston from 'winston'; import 'winston-daily-rotate-file'; import * as path from 'path'; import { ConfigService } from '@nestjs/config'; import { mkdirSync } from 'fs'; // Define custom log levels and their corresponding colors const nexusLogLevels = { levels: { fatal: 0, // Most critical error: 1, warn: 2, info: 3, success: 4, http: 5, verbose: 6, debug: 7, silly: 8, // Least critical }, colors: { fatal: 'bgRed bold', error: 'red bold', warn: 'yellow bold', info: 'brightCyan', success: 'brightGreen bold', http: 'magenta', verbose: 'cyan', debug: 'white', silly: 'grey', }, }; @Injectable({ scope: Scope.DEFAULT }) export class NexusLoggerService implements LoggerService, OnModuleInit { private logger: winston.Logger; constructor(private configService: ConfigService) { // Apply custom colors to Winston winston.addColors(nexusLogLevels.colors); // Define the logs directory and ensure it exists const logsDir = path.join(process.cwd(), 'storage', 'logs'); try { mkdirSync(logsDir, { recursive: true }); } catch (err) { console.error(`Failed to create logs directory at ${logsDir}`, err); } // Determine environment and set the log level accordingly const env = this.configService.get('NODE_ENV') || 'development'; const isDevelopment = env === 'development'; // In development log everything (lowest level is "silly"), otherwise log from "info" and above. const logLevel = isDevelopment ? 'silly' : 'info'; // Initialize the Winston logger with custom levels and formats this.logger = winston.createLogger({ levels: nexusLogLevels.levels, level: logLevel, format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true }), winston.format.colorize({ all: true }), winston.format.printf(({ timestamp, level, message, stack }) => stack ? `${timestamp} [${level}]: ${message} - ${stack}` : `${timestamp} [${level}]: ${message}`, ), ), transports: [ // Daily rotating file transport (logs stored as JSON) new winston.transports.DailyRotateFile({ filename: path.join(logsDir, '%DATE%.log'), datePattern: 'YYYY-MM-DD', zippedArchive: false, maxSize: '20m', maxFiles: '14d', level: logLevel, format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.json(), ), }), // Console transport with timestamp included new winston.transports.Console({ level: logLevel, format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.colorize({ all: true }), winston.format.printf(({ timestamp, level, message, stack }) => stack ? `${timestamp} [${level}]: ${message} - ${stack}` : `${timestamp} [${level}]: ${message}`, ), ), }), ], exitOnError: false, }); } onModuleInit() { const env = this.configService.get('NODE_ENV') || 'development'; this.logger.info(`NexusLogger initialized in ${env} mode.`); } /** * Log an informational message. * @param message The message to log. */ log(message: string) { this.logger.info(message); } /** * Log an error message with an optional stack trace. * @param message The error message to log. * @param trace Optional stack trace. */ error(message: string, trace?: string) { if (trace) { this.logger.error(message, { stack: trace }); } else { this.logger.error(message); } } /** * Log a warning message. * @param message The warning message to log. */ warn(message: string) { this.logger.warn(message); } /** * Log an informational message. * @param message The info message to log. */ info(message: string) { this.logger.info(message); } /** * Log a debug message. * @param message The debug message to log. */ debug(message: string) { this.logger.debug(message); } /** * Log a verbose message. * @param message The verbose message to log. */ verbose(message: string) { this.logger.verbose(message); } /** * Log a success message. * @param message The success message to log. */ success(message: string) { this.logger.success(message); } /** * Log a fatal error message with an optional stack trace. * @param message The fatal error message to log. * @param trace Optional stack trace. */ fatal(message: string, trace?: string) { if (trace) { this.logger.fatal(message, { stack: trace }); } else { this.logger.fatal(message); } } /** * Log an HTTP-related message. * @param message The HTTP message to log. */ http(message: string) { this.logger.http(message); } /** * Log a silly message. * @param message The silly message to log. */ silly(message: string) { this.logger.silly(message); } } File: src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { NexusLoggerService } from './logger/nexus-logger.service'; import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule, { // logger: false, // Disable Nest's default logger }); // Retrieve the NexusLogger instance and use it for logging const nexusLogger = app.get(NexusLoggerService); app.useLogger(nexusLogger); app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true, // If you use forbidNonWhitelisted: true, be sure to handle it. // If you use transformOptions: { enableImplicitConversion: true }, that can help, too. }), ); // Example: set global prefix if desired // app.setGlobalPrefix('api'); // Example: enable CORS if needed app.enableCors(); const port = process.env.PORT || 3000; await app.listen(port); nexusLogger.log(`Application is running on: ${await app.getUrl()}`); // Handle graceful shutdown process.on('SIGTERM', async () => { await app.close(); nexusLogger.log('Application shut down gracefully'); process.exit(0); }); } bootstrap().then(() => { console.log('Application started successfully'); }); File: src/memberships/dto/create-membership.dto.ts import { IsInt, IsNotEmpty } from 'class-validator'; /** * CreateMembershipDto * * - Represents the data structure for creating a new membership. * - Uses validation decorators to ensure required fields are provided. */ export class CreateMembershipDto { @IsInt() @IsNotEmpty() userId: number; @IsInt() @IsNotEmpty() roleId: number; } File: src/memberships/dto/update-membership.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateMembershipDto } from './create-membership.dto'; /** * UpdateMembershipDto * * - Extends CreateMembershipDto as a partial type so that all fields become optional. * - Used for updating existing membership records. */ export class UpdateMembershipDto extends PartialType(CreateMembershipDto) {} File: src/memberships/memberships.controller.ts import { Controller, Get, Post, Body, Patch, Param, ParseIntPipe, Delete, Req, ForbiddenException, UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import { MembershipsService } from './memberships.service'; import { CreateMembershipDto } from './dto/create-membership.dto'; import { UpdateMembershipDto } from './dto/update-membership.dto'; import { Permissions } from '../auth/permissions.decorator'; import { OrganizationGuard } from '../guards/organization.guard'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; /** * MembershipsController * * - Route prefix: /:orgId/memberships * - OrganizationGuard checks user membership in org + route-level perms * (like "memberships.read.any", "memberships.read.own", etc.). */ @Controller(':orgId/memberships') @UseGuards(OrganizationGuard) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) export class MembershipsController { constructor( private readonly membershipsService: MembershipsService, private readonly i18n: I18nService, // For translations private readonly logger: NexusLoggerService, // For logging ) {} /** * GET /:orgId/memberships * => needs "memberships.read.any" or "memberships.read.own" * if read.any => show all in org * if read.own => show only membership(s) for the user in this org */ @Get() @Permissions('memberships.read.any', 'memberships.read.own') async findAll( @Param('orgId', ParseIntPipe) orgId: number, @Req() req: any, @I18nLang() lang: string, ) { this.logger.info( `Received request to list memberships in org #${orgId} by user #${req.orgMembership?.userId}.`, ); const membership = req.orgMembership; if (!membership) { throw new ForbiddenException('No membership context found'); } const userPerms = membership.permissions || []; const userId = membership.userId; const canReadAny = userPerms.includes('memberships.read.any'); const canReadOwn = userPerms.includes('memberships.read.own'); // We can do concurrency for service + success translation if we want: const [members, msg] = await Promise.all([ this.membershipsService.findAll(orgId, canReadAny, canReadOwn, userId), this.i18n.translate('memberships.success.list', { lang }), ]); this.logger.info( `Returning ${members.length} membership(s) for org #${orgId}.`, ); return { message: msg, data: members }; } /** * GET /:orgId/memberships/:userId * => show membership for that user in this org. */ @Get(':userId') @Permissions('memberships.read.any', 'memberships.read.own') async findOne( @Param('orgId', ParseIntPipe) orgId: number, @Param('userId', ParseIntPipe) targetUserId: number, @Req() req: any, @I18nLang() lang: string, ) { this.logger.info( `Received request to get membership for user #${targetUserId} in org #${orgId}.`, ); const membership = req.orgMembership; if (!membership) { throw new ForbiddenException('No membership context found'); } const userPerms = membership.permissions || []; const requesterUserId = membership.userId; const canReadAny = userPerms.includes('memberships.read.any'); const canReadOwn = userPerms.includes('memberships.read.own'); const [member, msg] = await Promise.all([ this.membershipsService.findOne( orgId, targetUserId, canReadAny, canReadOwn, requesterUserId, ), this.i18n.translate('memberships.success.getOne', { lang, args: { id: targetUserId }, }), ]); this.logger.info( `Membership details found for user #${targetUserId} in org #${orgId}.`, ); return { message: msg, data: member }; } /** * POST /:orgId/memberships/add * => membership.create */ @Post('add') @Permissions('memberships.create') async createMembership( @Param('orgId', ParseIntPipe) orgId: number, @Body() dto: CreateMembershipDto, @Req() req: any, @I18nLang() lang: string, ) { this.logger.info( `Received request to create membership in org #${orgId} for user #${dto.userId}.`, ); const createdById = req.orgMembership?.userId; const [membership, msg] = await Promise.all([ this.membershipsService.createMembership(orgId, dto, createdById, lang), this.i18n.translate('memberships.success.created', { lang }), ]); this.logger.success( `Created membership for user #${dto.userId} in org #${orgId}.`, ); return { message: msg, data: membership }; } /** * PATCH /:orgId/memberships/edit/:userId * => memberships.edit.any or memberships.edit.own */ @Patch('edit/:userId') @Permissions('memberships.edit.any', 'memberships.edit.own') async updateMembership( @Param('orgId', ParseIntPipe) orgId: number, @Param('userId', ParseIntPipe) targetUserId: number, @Body() dto: UpdateMembershipDto, @Req() req: any, @I18nLang() lang: string, ) { this.logger.info( `Received request to update membership for user #${targetUserId} in org #${orgId}.`, ); const membership = req.orgMembership; if (!membership) { throw new ForbiddenException('No membership context found'); } const userPerms = membership.permissions || []; const requesterUserId = membership.userId; const canEditAny = userPerms.includes('memberships.edit.any'); const canEditOwn = userPerms.includes('memberships.edit.own'); const [updated, msg] = await Promise.all([ this.membershipsService.updateMembership( orgId, targetUserId, dto, canEditAny, canEditOwn, requesterUserId, lang, ), this.i18n.translate('memberships.success.updated', { lang, args: { id: targetUserId }, }), ]); this.logger.success( `Membership updated for user #${targetUserId} in org #${orgId}.`, ); return { message: msg, data: updated }; } /** * DELETE /:orgId/memberships/delete/:userId * => memberships.delete.any or memberships.delete.own */ @Delete('delete/:userId') @Permissions('memberships.delete.any', 'memberships.delete.own') async removeMembership( @Param('orgId', ParseIntPipe) orgId: number, @Param('userId', ParseIntPipe) targetUserId: number, @Req() req: any, @I18nLang() lang: string, ) { this.logger.info( `Received request to remove membership for user #${targetUserId} in org #${orgId}.`, ); const membership = req.orgMembership; if (!membership) { throw new ForbiddenException('No membership context found'); } const userPerms = membership.permissions || []; const requesterUserId = membership.userId; const canDeleteAny = userPerms.includes('memberships.delete.any'); const canDeleteOwn = userPerms.includes('memberships.delete.own'); await this.membershipsService.removeMembership( orgId, targetUserId, canDeleteAny, canDeleteOwn, requesterUserId, lang, ); const [msg] = await Promise.all([ this.i18n.translate('memberships.success.deleted', { lang, args: { id: targetUserId }, }), ]); this.logger.success( `Membership removed for user #${targetUserId} in org #${orgId}.`, ); return { message: msg }; } } File: src/memberships/memberships.module.ts import { Module } from '@nestjs/common'; import { MembershipsController } from './memberships.controller'; import { MembershipsService } from './memberships.service'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; @Module({ controllers: [MembershipsController], providers: [MembershipsService, PrismaService, NexusLoggerService], exports: [MembershipsService], }) export class MembershipsModule {} File: src/memberships/memberships.service.ts import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateMembershipDto } from './dto/create-membership.dto'; import { UpdateMembershipDto } from './dto/update-membership.dto'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { MembershipStatus } from '@prisma/client'; import { I18nService, I18nContext } from 'nestjs-i18n'; @Injectable() export class MembershipsService { constructor( private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, private readonly i18n: I18nService, // Add I18nService for translations ) {} /** * findAll => list membership records in a given org * if canReadAny => all. if canReadOwn => just your membership if it exists */ async findAll( orgId: number, canReadAny: boolean, canReadOwn: boolean, requesterUserId: number, ) { if (canReadAny) { return this.prisma.membership.findMany({ where: { organizationId: orgId }, include: { user: true, role: true }, }); } else if (canReadOwn) { // just the membership for your user in this org const member = await this.prisma.membership.findUnique({ where: { userId_organizationId: { userId: requesterUserId, organizationId: orgId, }, }, include: { user: true, role: true }, }); return member ? [member] : []; } // Translate forbidden error const i18n = I18nContext.current(); const [errorMsg] = await Promise.all([ i18n.translate('memberships.errors.forbidden'), ]); throw new ForbiddenException(errorMsg); } /** * findOne => membership for targetUser in org */ async findOne( orgId: number, targetUserId: number, canReadAny: boolean, canReadOwn: boolean, requesterUserId: number, ) { const [membership] = await Promise.all([ this.prisma.membership.findUnique({ where: { userId_organizationId: { userId: targetUserId, organizationId: orgId, }, }, include: { user: true, role: true }, }), ]); if (!membership) { // Not found => translate const i18n = I18nContext.current(); const [errorMsg] = await Promise.all([ i18n.translate('memberships.errors.not_found', { args: { id: targetUserId }, }), ]); throw new NotFoundException(errorMsg); } if (canReadAny) { return membership; } if (canReadOwn && membership.userId === requesterUserId) { return membership; } const i18n = I18nContext.current(); const [errorMsg] = await Promise.all([ i18n.translate('memberships.errors.forbidden'), ]); throw new ForbiddenException(errorMsg); } /** * createMembership => link user => org * If user doesn’t exist or org doesn’t exist, throw */ async createMembership( orgId: number, dto: CreateMembershipDto, createdById?: number, lang?: string, // Let’s pass it to translate error messages ) { // check org const org = await this.prisma.organization.findUnique({ where: { id: orgId }, }); if (!org) { const [errorMsg] = await Promise.all([ this.i18n.translate('memberships.errors.org_not_found', { lang, args: { orgId }, }), ]); throw new NotFoundException(errorMsg); } // check user const user = await this.prisma.user.findUnique({ where: { id: dto.userId }, }); if (!user) { const [errorMsg] = await Promise.all([ this.i18n.translate('memberships.errors.user_not_found', { lang, args: { userId: dto.userId }, }), ]); throw new NotFoundException(errorMsg); } // check existing membership const existing = await this.prisma.membership.findUnique({ where: { userId_organizationId: { userId: dto.userId, organizationId: orgId }, }, }); if (existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('memberships.errors.already_exists', { lang, args: { userId: dto.userId, orgId }, }), ]); throw new BadRequestException(errorMsg); } const membership = await this.prisma.membership.create({ data: { userId: dto.userId, organizationId: orgId, roleId: dto.roleId ?? null, status: MembershipStatus.ACTIVE, invitedByUserId: createdById ?? null, }, include: { user: true, role: true }, }); this.logger.info( `User #${dto.userId} joined org #${orgId} with role #${ dto.roleId || 'none' }`, ); return membership; } /** * updateMembership => typically changing roleId or membership status */ async updateMembership( orgId: number, targetUserId: number, dto: UpdateMembershipDto, canEditAny: boolean, canEditOwn: boolean, requesterUserId: number, lang?: string, ) { const membership = await this.prisma.membership.findUnique({ where: { userId_organizationId: { userId: targetUserId, organizationId: orgId }, }, }); if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('memberships.errors.not_found', { lang, args: { id: targetUserId }, }), ]); throw new NotFoundException(errorMsg); } if (canEditAny || (canEditOwn && membership.userId === requesterUserId)) { // proceed } else { const [errorMsg] = await Promise.all([ this.i18n.translate('memberships.errors.forbidden', { lang }), ]); throw new ForbiddenException(errorMsg); } const updated = await this.prisma.membership.update({ where: { userId_organizationId: { userId: targetUserId, organizationId: orgId }, }, data: { roleId: dto.roleId ?? membership.roleId, }, include: { user: true, role: true }, }); this.logger.info( `Membership updated for user #${targetUserId} in org #${orgId}`, ); return updated; } /** * removeMembership => user #targetUserId from org #orgId */ async removeMembership( orgId: number, targetUserId: number, canDeleteAny: boolean, canDeleteOwn: boolean, requesterUserId: number, lang?: string, ) { const membership = await this.prisma.membership.findUnique({ where: { userId_organizationId: { userId: targetUserId, organizationId: orgId }, }, }); if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('memberships.errors.not_found', { lang, args: { id: targetUserId }, }), ]); throw new NotFoundException(errorMsg); } if ( canDeleteAny || (canDeleteOwn && membership.userId === requesterUserId) ) { // proceed } else { const [errorMsg] = await Promise.all([ this.i18n.translate('memberships.errors.forbidden', { lang }), ]); throw new ForbiddenException(errorMsg); } await this.prisma.membership.delete({ where: { userId_organizationId: { userId: targetUserId, organizationId: orgId }, }, }); this.logger.success( `Membership removed: user #${targetUserId} from org #${orgId}`, ); return { message: 'Membership removed successfully' }; } } File: src/organizations/dto/create-organization.dto.ts /** * Data for creating a new Organization record. * - name: required string * - organizationType: optional enum from OrgType * - locked: optional boolean * - packageId: optional integer * - parentId: optional integer (for nested org structures) */ import { IsString, IsOptional, IsEnum, IsInt, IsBoolean, IsEmail, IsDateString, IsDate, IsObject, ValidateNested, } from 'class-validator'; import { OrgType, OrgStatus } from '@prisma/client'; import { Type } from 'class-transformer'; class SocialLinks { [key: string]: string | undefined; @IsOptional() @IsString() facebook?: string = ''; @IsOptional() @IsString() twitter?: string = ''; @IsOptional() @IsString() linkedin?: string = ''; @IsOptional() @IsString() instagram?: string = ''; @IsOptional() @IsString() youtube?: string = ''; } export class CreateOrganizationDto { @IsString() name: string; @IsOptional() @IsEnum(OrgType) organizationType?: OrgType; @IsOptional() @IsBoolean() locked?: boolean; @IsOptional() @IsInt() packageId?: number; @IsOptional() @IsInt() parentId?: number; @IsOptional() @IsEnum(OrgStatus) status?: OrgStatus; @IsOptional() @IsString() industry?: string; @IsOptional() @IsEmail() email?: string; @IsOptional() @IsString() phone?: string; @IsOptional() @IsString() address?: string; @IsOptional() @IsString() city?: string; @IsOptional() @IsString() state?: string; @IsOptional() @IsString() country?: string; @IsOptional() @IsString() postalCode?: string; @IsOptional() @IsString() timezone?: string; @IsOptional() @IsString() currency?: string; @IsOptional() @IsString() logo?: string; @IsOptional() @IsString() website?: string; @IsOptional() @IsObject() @ValidateNested() @Type(() => SocialLinks) socialLinks?: SocialLinks; @IsOptional() @Type(() => Date) @IsDate() doingBusinessSince?: Date; @IsOptional() @IsString() organizationSize?: string; @IsOptional() @IsBoolean() isDeleted?: boolean; } File: src/organizations/dto/update-organization.dto.ts /** * Data for updating an existing Organization record; * extends CreateOrganizationDto with all fields optional. */ import { PartialType } from '@nestjs/mapped-types'; import { CreateOrganizationDto } from './create-organization.dto'; export class UpdateOrganizationDto extends PartialType(CreateOrganizationDto) {} File: src/organizations/organizations.controller.ts /** * OrganizationsController provides CRUD endpoints for Organization records. * Guards ensure only authorized calls proceed, and we apply a ValidationPipe * to sanitize incoming data. */ import { Body, Controller, Delete, ForbiddenException, Get, Param, ParseIntPipe, Patch, Post, Req, UseGuards, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; import { OrganizationsService } from './organizations.service'; import { CreateOrganizationDto } from './dto/create-organization.dto'; import { UpdateOrganizationDto } from './dto/update-organization.dto'; import { UserGuard } from '../guards/user.guard'; import { OrganizationGuard } from '../guards/organization.guard'; import { Permissions } from '../auth/permissions.decorator'; import { YupValidationInterceptor } from '../interceptors/yupvalidation.interceptor'; import { COUNTRIES } from 'src/shared/constants/countries'; @Controller('organizations') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) export class OrganizationsController { constructor( private readonly organizationsService: OrganizationsService, private readonly i18n: I18nService, private readonly logger: NexusLoggerService, ) {} /** * GET /organizations * Uses UserGuard for obtaining combinedPerms. Distinguishes "read any" vs. "read own". */ @Get() @UseGuards(UserGuard) @Permissions('organizations.read.any', 'organizations.read.own') async findAll(@Req() req: any, @I18nLang() lang: string) { this.logger.info( `Request to list organizations for user #${req.user.userId}`, ); const userPerms = (req.user?.combinedPerms as Set) || new Set(); const canReadAny = userPerms.has('organizations.read.any'); const canReadOwn = userPerms.has('organizations.read.own'); if (!canReadAny && !canReadOwn) { this.logger.warn( `User #${req.user.userId} has no permission to read organizations`, ); throw new ForbiddenException('No permission to read organizations'); } const [orgs, msg] = await Promise.all([ this.organizationsService.findAll(!canReadAny, req.user.userId), this.i18n.translate('organizations.success.list', { lang }), ]); this.logger.info( `Returning ${orgs.length} organization(s) for user #${req.user.userId}`, ); return { message: msg, data: orgs }; } /** * GET /organizations/:id * If user has "read any," returns the org. If only "read own," checks membership. */ @Get(':orgId') @UseGuards(OrganizationGuard) @Permissions('organizations.read.any', 'organizations.read.this') async findOne( @Param('orgId', ParseIntPipe) orgId: number, @Req() req: any, @I18nLang() lang: string, ) { this.logger.info( `Request to get organization #${orgId} by user #${req.user.userId}`, ); const [org, msg] = await Promise.all([ this.organizationsService.findOne(orgId, req.user, lang), this.i18n.translate('organizations.success.found', { lang, args: { id: orgId }, }), ]); this.logger.info( `Organization #${orgId} retrieved by user #${req.user.userId}`, ); return { message: msg, data: org }; } /** * POST /organizations/add * Creates a new organization. If the user does not have "organizations.create," it fails. */ @Post('add') @UseGuards(UserGuard) @Permissions('organizations.create') async create( @Body() dto: CreateOrganizationDto, @Req() req: any, @I18nLang() lang: string, ) { return await this.organizationsService.create(dto, req.user, lang); } /** * PATCH /organizations/edit/:orgId * Updates an existing organization. OrganizationGuard ensures permission to update is valid. */ @Patch('edit/:orgId') @UseGuards(OrganizationGuard) @Permissions('organizations.edit.any', 'organizations.edit.this') async update( @Param('orgId', ParseIntPipe) targetOrgId: number, @Body() dto: UpdateOrganizationDto, @Req() req: any, @I18nLang() lang: string, ) { return await this.organizationsService.update( targetOrgId, dto, req.user, lang, ); } /** * DELETE /organizations/delete/:orgId * Deletes an organization. OrganizationGuard ensures permission. The service cleans up memberships and roles first. */ @Delete('delete/:orgId') @UseGuards(OrganizationGuard) @Permissions('organizations.delete.any', 'organizations.delete.this') async remove( @Param('orgId', ParseIntPipe) targetOrgId: number, @Req() req: any, @I18nLang() lang: string, ) { this.logger.info( `Request to delete organization #${targetOrgId} by user #${req.user.userId}`, ); const [result, msg] = await Promise.all([ this.organizationsService.remove(targetOrgId, req.user, lang), this.i18n.translate('organizations.success.deleted', { lang, args: { id: targetOrgId }, }), ]); this.logger.success(`Organization #${targetOrgId} deleted successfully`); return { message: msg, ...result }; } /** * PATCH /organizations/lock/:orgId * Toggles lock status on an organization. OrganizationGuard ensures permission. */ @Patch('lock/:orgId') @UseGuards(OrganizationGuard) @Permissions('organizations.operations.lock') async updateLockStatus( @Param('orgId', ParseIntPipe) orgId: number, @Body() dto: { locked: boolean }, @Req() req: any, @I18nLang() lang: string, ) { this.logger.info( `Request to lock/unlock organization #${orgId} by user #${req.user.userId}`, ); const [updated, msg] = await Promise.all([ this.organizationsService.updateLockStatus(orgId, dto, req.user, lang), this.i18n.translate('organizations.success.lockStatus', { lang, args: { id: orgId, locked: dto.locked }, }), ]); this.logger.success(`Lock status updated for organization #${orgId}`); return { message: msg, data: updated }; } /** * GET /organizations/form/upsert * Returns the form configuration with sections and fields. */ @Get('form/upsert') @Permissions('organizations.create') @UseInterceptors(YupValidationInterceptor) async getOrganizationFormSchema(@I18nLang() lang: string) { // Execute all translations concurrently const [ sectionGeneral, contactDetails, socialProfiles, nameLabel, typeLabel, agencyLabel, clientLabel, emailLabel, phoneLabel, addressLabel, cityLabel, stateLabel, countryLabel, postalCodeLabel, timezoneLabel, currencyLabel, logoLabel, websiteLabel, doingBusinessSinceLabel, organizationSizeLabel, socialLinksLabel, ] = await Promise.all([ this.i18n.translate('organizations.form.section_general', { lang }), this.i18n.translate('organizations.form.section_contactDetails', { lang, }), this.i18n.translate('organizations.form.section_socialProfiles', { lang, }), this.i18n.translate('organizations.form.name', { lang }), this.i18n.translate('organizations.form.type', { lang }), this.i18n.translate('organizations.form.agency', { lang }), this.i18n.translate('organizations.form.client', { lang }), this.i18n.translate('organizations.form.email', { lang }), this.i18n.translate('organizations.form.phone', { lang }), this.i18n.translate('organizations.form.address', { lang }), this.i18n.translate('organizations.form.city', { lang }), this.i18n.translate('organizations.form.state', { lang }), this.i18n.translate('organizations.form.country', { lang }), this.i18n.translate('organizations.form.postalCode', { lang }), this.i18n.translate('organizations.form.timezone', { lang }), this.i18n.translate('organizations.form.currency', { lang }), this.i18n.translate('organizations.form.logo', { lang }), this.i18n.translate('organizations.form.website', { lang }), this.i18n.translate('organizations.form.doingBusinessSince', { lang }), this.i18n.translate('organizations.form.organizationSize', { lang }), this.i18n.translate('organizations.form.socialLinks', { lang }), ]); const formSchema = { sections: [ { id: 1, label: sectionGeneral, }, { id: 2, label: contactDetails, }, { id: 3, label: socialProfiles, }, ], fields: [ { sectionId: 1, name: 'name', label: nameLabel, type: 'text', required: true, initialValue: '', }, { sectionId: 1, name: 'organizationType', label: typeLabel, type: 'select', options: [ { label: agencyLabel, value: 'AGENCY' }, { label: clientLabel, value: 'CLIENT' }, ], required: true, initialValue: 'CLIENT', }, { sectionId: 2, name: 'email', label: emailLabel, type: 'email', required: true, initialValue: '', }, { sectionId: 2, name: 'phone', label: phoneLabel, type: 'text', required: false, initialValue: '', }, { sectionId: 2, name: 'address', label: addressLabel, type: 'text', required: true, initialValue: '', }, { sectionId: 2, name: 'city', label: cityLabel, type: 'text', required: true, initialValue: '', }, { sectionId: 2, name: 'state', label: stateLabel, type: 'text', required: true, initialValue: '', }, { sectionId: 2, name: 'country', label: countryLabel, type: 'select', options: COUNTRIES, required: true, initialValue: 'US', }, { sectionId: 2, name: 'postalCode', label: postalCodeLabel, type: 'text', required: true, initialValue: '', }, { sectionId: 2, name: 'timezone', label: timezoneLabel, type: 'text', required: true, initialValue: '', }, { sectionId: 2, name: 'currency', label: currencyLabel, type: 'text', required: true, initialValue: '', }, { sectionId: 2, name: 'logo', label: logoLabel, type: 'text', required: true, initialValue: '', }, { sectionId: 2, name: 'website', label: websiteLabel, type: 'text', required: true, initialValue: '', }, { sectionId: 2, name: 'doingBusinessSince', label: doingBusinessSinceLabel, type: 'date', required: true, initialValue: '', }, { sectionId: 2, name: 'organizationSize', label: organizationSizeLabel, type: 'text', required: true, initialValue: '', }, { sectionId: 3, name: 'socialLinks', label: socialLinksLabel, type: 'json', required: false, initialValue: '', }, ], }; return { ...formSchema }; } } File: src/organizations/organizations.module.ts /** * OrganizationsModule declares the controller and service for organizational management. * It also provides PrismaService for database access and NexusLoggerService for logging. */ import { Module } from '@nestjs/common'; import { OrganizationsController } from './organizations.controller'; import { OrganizationsService } from './organizations.service'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { RolesModule } from '../roles/roles.module'; @Module({ imports: [RolesModule], controllers: [OrganizationsController], providers: [OrganizationsService, PrismaService, NexusLoggerService], exports: [OrganizationsService], }) export class OrganizationsModule {} File: src/organizations/organizations.service.ts /** * OrganizationsService * * This service handles all core logic for creating, reading, updating, and deleting * organization records. It uses the PrismaService for database operations, the * NexusLoggerService for structured logging, and the I18nService for multilingual * error messages. * * Key Functions: * - findAll(ownOnly, requesterUserId) * Retrieves organizations. If ownOnly=true, we only return orgs the user * is a member of. Otherwise, all orgs. * - findOne(targetOrgId, user, lang) * Retrieves a single organization by ID. Throws a translated error if not found * or if the user lacks rights to view the org. * - create(dto, currentUser, lang) * Creates a new organization with optional parent references and membership link. * We throw a translated exception if parent is invalid, or if user lacks rights. * - update(targetOrgId, dto, user, lang) * Updates an existing org's fields. If not found, we throw an error. If locked * or insufficient permission, also a translated error. * - remove(targetOrgId, user, lang) * Removes an org after deleting memberships and roles. Checks for locks and child * org references. If something is invalid, we throw a translated error. * - updateLockStatus(orgId, dto, user, lang) * Updates the locked boolean on an organization. Ensures the user can do so. If * invalid conditions, we throw a translated error. */ import { Injectable, NotFoundException, ForbiddenException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { CreateOrganizationDto } from './dto/create-organization.dto'; import { UpdateOrganizationDto } from './dto/update-organization.dto'; import { OrgType, OrgStatus } from '@prisma/client'; import { RolesService } from '../roles/roles.service'; import { I18nService } from 'nestjs-i18n'; @Injectable() export class OrganizationsService { constructor( private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, private readonly rolesService: RolesService, private readonly i18n: I18nService, ) {} /** * findAll * * Retrieves all organizations if ownOnly=false, or only those the user is a member of if ownOnly=true. * * Logs the operation, and if membership queries fail or the user is not found, we may throw an exception. * This logic can also be expanded if needed, but currently we assume it's straightforward. */ async findAll(ownOnly: boolean, requesterUserId: number) { this.logger.debug( `OrganizationsService.findAll => ownOnly=${ownOnly}, userId=${requesterUserId}`, ); if (ownOnly) { const memberships = await this.prisma.membership.findMany({ where: { userId: requesterUserId }, include: { organization: true }, }); return memberships .map((m) => m.organization) .filter((org) => org !== null); } return this.prisma.organization.findMany(); } /** * findOne * * Retrieves a single organization record by ID. * - If the organization does not exist, we throw a NotFoundException with a translated message. * - If the user does not have "read any" and also lacks membership in this org ("read own"), we throw a ForbiddenException with a translated message. */ async findOne(targetOrgId: number, user: any, lang?: string) { this.logger.debug( `OrganizationsService.findOne => org #${targetOrgId}, user #${user.userId}`, ); const organization = await this.prisma.organization.findUnique({ where: { id: targetOrgId }, }); if (!organization) { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.not_found', { lang, args: { orgId: targetOrgId }, }), ]); this.logger.warn(`Organization #${targetOrgId} not found`); throw new NotFoundException(errorMsg); } return organization; } /** * create * * Creates a new organization with optional parent references. * If parent org is invalid, locked, or the user cannot create a PLATFORM org, we throw an exception with a translated message. * Also logs creation events for debugging and auditing. */ async create(dto: CreateOrganizationDto, currentUser: any, lang?: string) { this.logger.debug( `OrganizationsService.create => user #${currentUser.userId} attempting to create organization`, ); if (dto.parentId != null) { const parentOrg = await this.prisma.organization.findUnique({ where: { id: dto.parentId }, }); if (!parentOrg) { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.parentNotFound', { lang, args: { id: dto.parentId }, }), ]); this.logger.warn(`Parent org #${dto.parentId} not found`); throw new NotFoundException(errorMsg); } if ( parentOrg.organizationType !== OrgType.PLATFORM && parentOrg.organizationType !== OrgType.AGENCY ) { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.invalidParentType', { lang, }), ]); this.logger.warn( `Parent org #${dto.parentId} is not PLATFORM or AGENCY`, ); throw new ForbiddenException(errorMsg); } } else { const platformOrg = await this.prisma.organization.findFirst({ where: { organizationType: OrgType.PLATFORM }, }); if (platformOrg) { dto.parentId = platformOrg.id; } else { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.noPlatform', { lang }), ]); this.logger.warn('No PLATFORM organization found for default parent'); throw new NotFoundException(errorMsg); } } if (dto.organizationType === OrgType.PLATFORM) { if (!currentUser.combinedPerms?.has('platform.org.create')) { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.platformCreate', { lang }), ]); this.logger.warn( `User #${currentUser.userId} lacks permission to create PLATFORM org`, ); throw new ForbiddenException(errorMsg); } } const organization = await this.prisma.organization.create({ data: { name: dto.name, organizationType: dto.organizationType ?? OrgType.CLIENT, locked: false, parentId: dto.parentId, status: OrgStatus.ACTIVE, industry: dto.industry, email: dto.email, phone: dto.phone, address: dto.address, city: dto.city, state: dto.state, country: dto.country, postalCode: dto.postalCode, timezone: dto.timezone, currency: dto.currency, logo: dto.logo, website: dto.website, socialLinks: dto.socialLinks, doingBusinessSince: new Date(dto.doingBusinessSince), organizationSize: dto.organizationSize, isDeleted: false, }, }); this.logger.info( `Created organization #${organization.id} by user #${currentUser.userId}`, ); await this.prisma.membership.create({ data: { userId: currentUser.userId, organizationId: organization.id, roleId: 3, }, }); this.logger.info( `User #${currentUser.userId} joined org #${organization.id} with role=3`, ); const [msg] = await Promise.all([ this.i18n.translate('organizations.success.created', { lang, args: { name: organization.name }, }), ]); // Return message, organization, and membership data return { message: msg, data: { organization, membership: { userId: currentUser.userId, organizationId: organization.id, roleId: 3, }, }, }; } /** * update * * Updates an existing organization record. * If not found, we throw a NotFoundException with a translated message. * If the user is not allowed, we rely on the guard logic, but can still log or handle final checks here if needed. */ async update( targetOrgId: number, dto: UpdateOrganizationDto, user: any, lang?: string, ) { this.logger.debug( `OrganizationsService.update => user #${user.userId} updating org #${targetOrgId}`, ); const organization = await this.prisma.organization.findUnique({ where: { id: targetOrgId }, }); if (!organization) { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.notFound', { lang, args: { id: targetOrgId }, }), ]); this.logger.warn(`Organization #${targetOrgId} not found for update`); throw new NotFoundException(errorMsg); } const updatedOrganization = await this.prisma.organization.update({ where: { id: targetOrgId }, data: { name: dto.name ?? organization.name, industry: dto.industry ?? organization.industry, email: dto.email ?? organization.email, phone: dto.phone ?? organization.phone, address: dto.address ?? organization.address, city: dto.city ?? organization.city, state: dto.state ?? organization.state, country: dto.country ?? organization.country, postalCode: dto.postalCode ?? organization.postalCode, timezone: dto.timezone ?? organization.timezone, currency: dto.currency ?? organization.currency, logo: dto.logo ?? organization.logo, website: dto.website ?? organization.website, socialLinks: dto.socialLinks ?? organization.socialLinks, doingBusinessSince: dto.doingBusinessSince ?? organization.doingBusinessSince, organizationSize: dto.organizationSize ?? organization.organizationSize, }, }); this.logger.success(`Org #${targetOrgId} updated by user #${user.userId}`); const [msg] = await Promise.all([ this.i18n.translate('organizations.success.updated', { lang, args: { id: targetOrgId }, }), ]); return { message: msg, data: updatedOrganization }; } /** * remove * * Deletes an organization record after removing memberships and roles. * We check if org is locked, has child orgs, or doesn't exist, * and throw a translated error accordingly. */ async remove(targetOrgId: number, user: any, lang?: string) { this.logger.debug( `OrganizationsService.remove => org #${targetOrgId}, user #${user.userId}`, ); const organization = await this.prisma.organization.findUnique({ where: { id: targetOrgId }, }); if (!organization) { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.notFound', { lang, args: { id: targetOrgId }, }), ]); this.logger.warn(`Org #${targetOrgId} not found for removal`); throw new NotFoundException(errorMsg); } if (organization.locked) { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.locked', { lang }), ]); this.logger.warn(`Org #${targetOrgId} is locked, cannot remove`); throw new ForbiddenException(errorMsg); } const children = await this.prisma.organization.findMany({ where: { parentId: targetOrgId }, }); if (children.length > 0) { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.hasChildren', { lang }), ]); this.logger.warn(`Org #${targetOrgId} has child org(s), cannot remove`); throw new ForbiddenException(errorMsg); } await this.prisma.membership.deleteMany({ where: { organizationId: targetOrgId }, }); const roles = await this.prisma.role.findMany({ where: { organizationId: targetOrgId }, }); for (const role of roles) { const membership = await this.prisma.membership.findUnique({ where: { userId_organizationId: { userId: user.userId, organizationId: targetOrgId, }, }, }); await this.rolesService.remove( targetOrgId, role.id, membership, user, lang, ); } await this.prisma.organization.delete({ where: { id: targetOrgId } }); this.logger.success( `Organization #${targetOrgId} deleted by user #${user.userId}`, ); return { message: 'Organization deleted successfully' }; } /** * updateLockStatus * * Toggles the locked boolean for an organization. If the org is missing, we throw a NotFoundException. * If locked state is unchanged, we throw a ForbiddenException. */ async updateLockStatus( orgId: number, dto: { locked: boolean }, user: any, lang?: string, ) { this.logger.debug( `OrganizationsService.updateLockStatus => org #${orgId}, locked=${dto.locked}`, ); const organization = await this.prisma.organization.findUnique({ where: { id: orgId }, }); if (!organization) { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.notFound', { lang, args: { id: orgId }, }), ]); this.logger.warn(`Organization #${orgId} not found for lock update`); throw new NotFoundException(errorMsg); } if (organization.locked === dto.locked) { const [errorMsg] = await Promise.all([ this.i18n.translate('organizations.errors.lockUnchanged', { lang, args: { id: orgId, locked: dto.locked }, }), ]); this.logger.warn( `Lock status of org #${orgId} is already = ${dto.locked}`, ); throw new ForbiddenException(errorMsg); } const updated = await this.prisma.organization.update({ where: { id: orgId }, data: { locked: dto.locked }, }); this.logger.success( `Lock status for org #${orgId} changed to ${dto.locked} by user #${user.userId}`, ); return updated; } } File: src/permissions/dto/create-permission.dto.ts import { IsOptional, IsString, IsEnum } from 'class-validator'; import { UserTypes } from '@prisma/client'; // <-- Import enum from Prisma /** * DTO for creating a new Permission record. * * We use `@IsEnum(UserTypes)` to ensure only valid enum values * can be passed from the client, e.g. "PLATFORM", "AGENCY", or "CLIENT". */ export class CreatePermissionDto { @IsString() tag: string; @IsString() name: string; @IsString() @IsOptional() description?: string; @IsString() @IsOptional() category?: string; // Validate 'scope' as an enum (PLATFORM, AGENCY, CLIENT) @IsEnum(UserTypes) scope: UserTypes = UserTypes.CLIENT; } File: src/permissions/dto/update-permission.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreatePermissionDto } from './create-permission.dto'; export class UpdatePermissionDto extends PartialType(CreatePermissionDto) {} File: src/permissions/permissions.controller.ts import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Req, UseGuards, UsePipes, ValidationPipe, UseInterceptors, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { Permissions } from 'src/auth/permissions.decorator'; import { PermissionsService } from './permissions.service'; import { CreatePermissionDto } from './dto/create-permission.dto'; import { UpdatePermissionDto } from './dto/update-permission.dto'; import { PlatformGuard } from 'src/guards/platform.guard'; import { TransformPermissionsInterceptor } from 'src/interceptors/transform-permissions.interceptor'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; @Controller('permissions') // NOT using @UseGuards(PlatformGuard) here at the class level export class PermissionsController { constructor( private readonly permissionsService: PermissionsService, private readonly i18n: I18nService, private readonly logger: NexusLoggerService, ) {} /** * POST /permissions/add * Only PLATFORM can create new permissions => we require 'permissions.create' * and also membership in PLATFORM => so we apply PlatformGuard specifically here. */ @Post('add') @UseGuards(PlatformGuard) @Permissions('permissions.create') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async create( @Body() dto: CreatePermissionDto, @Req() req: any, @I18nLang() lang: string, ) { const userScope = req.user?.userType; // e.g. "PLATFORM" | "AGENCY" | "CLIENT" this.logger.info( `Received a create permission request from user with scope ${userScope}.`, ); const [newPerm, msg] = await Promise.all([ this.permissionsService.create(dto, userScope, lang), this.i18n.translate('permissions.success.created', { lang, }), ]); this.logger.success( `Permission created successfully with ID ${newPerm.id}.`, ); return { message: msg, data: newPerm }; } /** * GET /permissions * - A user must have 'permissions.read.any' to see the list * - If PLATFORM => sees all * - If AGENCY => sees only scope=AGENCY * - If CLIENT => sees only scope=CLIENT */ @Get() @Permissions('permissions.read.any') @UseInterceptors(TransformPermissionsInterceptor) async findAll(@Req() req: any, @I18nLang() lang: string) { const userScope = req.user?.userType; this.logger.info( `Received a request to list permissions for user with scope ${userScope}.`, ); const [perms, msg] = await Promise.all([ this.permissionsService.findAll(userScope, lang), this.i18n.translate('permissions.success.list', { lang }), ]); this.logger.info( `Returning ${perms.length} permissions for user with scope ${userScope}.`, ); return { message: msg, data: perms }; } /** * GET /permissions/:id * - Must have 'permissions.read.any' * - If userScope != PLATFORM, only see if permission.scope == userScope */ @Get(':id') @Permissions('permissions.read.any') async findOne( @Param('id', ParseIntPipe) id: number, @Req() req: any, @I18nLang() lang: string, // added lang for translations ) { const userScope = req.user?.userType; this.logger.info( `Received a request for permission details of ID ${id} from user with scope ${userScope}.`, ); const [perm, msg] = await Promise.all([ this.permissionsService.findOne(id, userScope, lang), this.i18n.translate('permissions.success.getOne', { lang, args: { id }, }), ]); this.logger.info(`Permission details for ID ${id} retrieved successfully.`); return { message: msg, data: perm }; } /** * PATCH /permissions/edit/:id * - Only PLATFORM can edit => so we use PlatformGuard * - Also require 'permissions.edit.any' */ @Patch('edit/:id') @UseGuards(PlatformGuard) @Permissions('permissions.edit.any') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdatePermissionDto, @Req() req: any, @I18nLang() lang: string, // added lang for translations ) { const userScope = req.user?.userType; this.logger.info( `Received an update request for permission ID ${id} from user with scope ${userScope}.`, ); const [updated, msg] = await Promise.all([ this.permissionsService.update(id, dto, userScope, lang), this.i18n.translate('permissions.success.updated', { lang, args: { id }, }), ]); this.logger.success(`Permission with ID ${id} updated successfully.`); return { message: msg, data: updated }; } /** * DELETE /permissions/delete/:id * - Only PLATFORM can delete => so we use PlatformGuard * - Also require 'permissions.delete.any' */ @Delete('delete/:id') @UseGuards(PlatformGuard) @Permissions('permissions.delete.any') async remove( @Param('id', ParseIntPipe) id: number, @Req() req: any, @I18nLang() lang: string, // added lang for translations ) { const userScope = req.user?.userType; this.logger.info( `Received a deletion request for permission ID ${id} from user with scope ${userScope}.`, ); await this.permissionsService.remove(id, userScope, lang); const [msg] = await Promise.all([ this.i18n.translate('permissions.success.deleted', { lang, args: { id }, }), ]); this.logger.success(`Permission with ID ${id} deleted successfully.`); return { message: msg }; } } File: src/permissions/permissions.module.ts import { Module } from '@nestjs/common'; import { PermissionsService } from './permissions.service'; import { PermissionsController } from './permissions.controller'; import { PrismaService } from 'src/prisma/prisma.service'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; @Module({ imports: [], controllers: [PermissionsController], providers: [PermissionsService, PrismaService, NexusLoggerService], exports: [PermissionsService], }) export class PermissionsModule {} File: src/permissions/permissions.service.ts import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { I18nService } from 'nestjs-i18n'; import { PrismaService } from 'src/prisma/prisma.service'; import { UserTypes } from '@prisma/client'; import { CreatePermissionDto } from './dto/create-permission.dto'; import { UpdatePermissionDto } from './dto/update-permission.dto'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; /** * We define a rank mapping for userScope => numeric: * PLATFORM => 3 * AGENCY => 2 * CLIENT => 1 * * Then a permission with scope=AGENCY is rank=2, etc. * If userScope rank >= permScope rank, they can see that permission. */ const rank = { PLATFORM: 3, AGENCY: 2, CLIENT: 1, }; @Injectable() export class PermissionsService { constructor( private readonly prisma: PrismaService, private readonly i18n: I18nService, private readonly logger: NexusLoggerService, ) {} /** * CREATE: Only PLATFORM can do so. */ async create(data: CreatePermissionDto, userScope: string, lang: string) { this.logger.info( `Attempting to create a permission with tag '${data.tag}' by a user with scope '${userScope}'.`, ); if (userScope !== 'PLATFORM') { const [errorMsg] = await Promise.all([ this.i18n.translate('permissions.errors.platform_only_create', { lang, }), ]); this.logger.warn( `Permission creation denied for user with scope '${userScope}'.`, ); throw new ForbiddenException(errorMsg); } const existing = await this.prisma.permission.findUnique({ where: { tag: data.tag }, }); if (existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('permissions.errors.tag_exists', { lang, args: { tag: data.tag }, }), ]); this.logger.error(`Permission creation failed: ${errorMsg}`); throw new BadRequestException(errorMsg); } const newPermission = await this.prisma.permission.create({ data: { tag: data.tag, name: data.name, category: data.category, description: data.description, scope: data.scope, // data.scope is validated as UserTypes }, }); this.logger.success( `Permission created successfully with ID ${newPermission.id}.`, ); return newPermission; } /** * FIND ALL: * - We match the userScope rank against the permission scope rank. * - If userScope rank >= permScope rank => user can see it. * e.g. PLATFORM => rank 3 => sees all * AGENCY => rank 2 => sees AGENCY + CLIENT * CLIENT => rank 1 => sees CLIENT only */ async findAll(userScope: string, lang: string) { this.logger.info( `Fetching permissions for user with scope '${userScope}'.`, ); if (!rank[userScope]) { const [errorMsg] = await Promise.all([ this.i18n.translate('permissions.errors.invalid_scope', { lang, args: { userScope }, }), ]); this.logger.warn(`Invalid user scope encountered: '${userScope}'.`); throw new ForbiddenException(errorMsg); } const userRank = rank[userScope]; // Build an array of scopes the user can see: const allowedScopes = Object.entries(rank) .filter(([_, scopeRank]) => scopeRank <= userRank) .map(([scopeKey]) => scopeKey); const permissions = await this.prisma.permission.findMany({ where: { scope: { in: allowedScopes as UserTypes[] }, }, }); this.logger.info( `Fetched ${permissions.length} permissions for user with scope '${userScope}'.`, ); return permissions; } /** * FIND ONE: * - Must exist * - Then check if rank[permission.scope] <= rank[userScope] */ async findOne(id: number, userScope: string, lang: string) { this.logger.info( `Fetching permission details for ID ${id} requested by user with scope '${userScope}'.`, ); const permission = await this.prisma.permission.findUnique({ where: { id }, }); if (!permission) { const [errorMsg] = await Promise.all([ this.i18n.translate('permissions.errors.not_found', { lang, args: { id }, }), ]); this.logger.warn(`Permission with ID ${id} not found.`); throw new NotFoundException(errorMsg); } const userRank = rank[userScope] || 0; const permRank = rank[permission.scope] || 0; if (permRank > userRank) { const [errorMsg] = await Promise.all([ this.i18n.translate('permissions.errors.scope_forbidden', { lang, args: { userScope, permissionScope: permission.scope }, }), ]); this.logger.warn( `Access denied: User with scope '${userScope}' is not authorized to view permission ID ${id} with scope '${permission.scope}'.`, ); throw new ForbiddenException(errorMsg); } this.logger.info(`Permission details for ID ${id} retrieved successfully.`); return permission; } /** * UPDATE: Only PLATFORM => same as before */ async update( id: number, data: UpdatePermissionDto, userScope: string, lang: string, ) { this.logger.info( `Attempting to update permission ID ${id} by user with scope '${userScope}'.`, ); if (userScope !== 'PLATFORM') { const [errorMsg] = await Promise.all([ this.i18n.translate('permissions.errors.platform_only_update', { lang, }), ]); this.logger.warn( `Update denied: User with scope '${userScope}' is not authorized to update permission ID ${id}.`, ); throw new ForbiddenException(errorMsg); } const permission = await this.prisma.permission.findUnique({ where: { id }, }); if (!permission) { const [errorMsg] = await Promise.all([ this.i18n.translate('permissions.errors.not_found', { lang, args: { id }, }), ]); this.logger.warn(`Update failed: Permission with ID ${id} not found.`); throw new NotFoundException(errorMsg); } if (data.tag && data.tag !== permission.tag) { const existing = await this.prisma.permission.findUnique({ where: { tag: data.tag }, }); if (existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('permissions.errors.tag_exists', { lang, args: { tag: data.tag }, }), ]); this.logger.error(`Update failed: ${errorMsg}`); throw new BadRequestException(errorMsg); } } const updatedPermission = await this.prisma.permission.update({ where: { id }, data: { tag: data.tag ?? permission.tag, name: data.name ?? permission.name, category: data.category ?? permission.category, description: data.description ?? permission.description, scope: data.scope ?? permission.scope, }, }); this.logger.success(`Permission with ID ${id} updated successfully.`); return updatedPermission; } /** * DELETE: Only PLATFORM => same as before */ async remove(id: number, userScope: string, lang: string) { this.logger.info( `Attempting to delete permission ID ${id} by user with scope '${userScope}'.`, ); if (userScope !== 'PLATFORM') { const [errorMsg] = await Promise.all([ this.i18n.translate('permissions.errors.platform_only_delete', { lang, }), ]); this.logger.warn( `Deletion denied: User with scope '${userScope}' is not authorized to delete permission ID ${id}.`, ); throw new ForbiddenException(errorMsg); } const permission = await this.prisma.permission.findUnique({ where: { id }, }); if (!permission) { const [errorMsg] = await Promise.all([ this.i18n.translate('permissions.errors.not_found', { lang, args: { id }, }), ]); this.logger.warn(`Deletion failed: Permission with ID ${id} not found.`); throw new NotFoundException(errorMsg); } await this.prisma.permission.delete({ where: { id } }); this.logger.success(`Permission with ID ${id} deleted successfully.`); return true; } } File: src/prisma/prisma.module.ts import { Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; @Module({ providers: [PrismaService], exports: [PrismaService], }) export class PrismaModule {} File: src/prisma/prisma.service.ts import { Injectable, OnModuleInit, OnApplicationShutdown, } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { INestApplication } from '@nestjs/common'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnApplicationShutdown { async onModuleInit() { try { await this.$connect(); } catch (error) { console.error('Failed to connect to the database:', error); throw new Error('Database connection failed'); } } async enableShutdownHooks(app: INestApplication) { this.$on('beforeExit' as unknown as never, async () => { await app.close(); }); } async onApplicationShutdown() { await this.$disconnect(); } async closeConnection() { await this.$disconnect(); } } File: src/prisma/seed.ts import { AssignmentType, OrgType, PrismaClient, UserStatus, UserTypes, } from '@prisma/client'; import * as bcrypt from 'bcrypt'; /** * Initialize a PrismaClient for DB operations in the seed script. */ const prisma = new PrismaClient(); /** * A large set of permission definitions. Each object includes: * - tag: unique key, e.g. "users.read.own" * - name: friendly display name * - category: grouping, e.g. "Users" * - description: short explanation * - scope: which userType typically assigns it (CLIENT, PLATFORM, etc.) * - assignmentType: whether this permission is generally for an ORGANIZATION-level role * or a USER-level role. */ const PERMISSIONS_SEED = [ // ───────────────────────────────────────────────────────────── // PLATFORM ONLY (ORGANIZATION-based) // ───────────────────────────────────────────────────────────── { tag: 'platform.org.create', name: 'Create top-level organizations', category: 'Organizations', description: 'Add new organizations under the platform.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'platform.settings.update', name: 'Update platform settings', category: 'Settings', description: 'Change global settings for the platform.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, // ───────────────────────────────────────────────────────────── // AGENCY (ORGANIZATION-based) // ───────────────────────────────────────────────────────────── { tag: 'organizations.operations.lock', name: 'Lock organizations', category: 'Organizations', description: 'Lock or unlock organizations in the system.', scope: UserTypes.AGENCY, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'organizations.package.update', name: 'Update organization package', category: 'Organizations', description: 'Change the package assigned to an organization.', scope: UserTypes.AGENCY, assignmentType: AssignmentType.ORGANIZATION, }, // ───────────────────────────────────────────────────────────── // Some ORGANIZATION-based, some USER-based // Some CLIENT-based, some PLATFORM-based // ───────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────── // USERS // ───────────────────────────────────────────────────────────── { tag: 'users.create', name: 'Create users', category: 'Users', description: 'Add new users to the system.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'users.read.any', name: 'Read any user', category: 'Users', description: 'View details of any user in the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'users.read.own', name: 'Read own user data', category: 'Users', description: 'View own user details.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.USER, // user-level }, { tag: 'users.edit.any', name: 'Edit any user', category: 'Users', description: 'Update details of any user in the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'users.delete.any', name: 'Delete any user', category: 'Users', description: 'Remove any user from the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'users.edit.own', name: 'Edit own user info', category: 'Users', description: 'Update own user details.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.USER, // user-level }, { tag: 'users.delete.own', name: 'Delete own user', category: 'Users', description: 'Remove own user account.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.USER, // user-level }, // ───────────────────────────────────────────────────────────── // Organizations // ───────────────────────────────────────────────────────────── { tag: 'organizations.create', name: 'Create organizations', category: 'Organizations', description: 'Add new organizations to the system.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.USER, }, { tag: 'organizations.read.any', name: 'Read any organization', category: 'Organizations', description: 'View details of any organization in the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'organizations.read.own', name: 'Read own organization', category: 'Organizations', description: 'View details of own organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.USER, }, { tag: 'organizations.read.this', name: 'Read this organization', category: 'Organizations', description: 'View details of this organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'organizations.edit.any', name: 'Edit any organization', category: 'Organizations', description: 'Update details of any organization in the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'organizations.edit.this', name: 'Edit this organization', category: 'Organizations', description: 'Update details of this organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'organizations.delete.any', name: 'Delete any organization', category: 'Organizations', description: 'Remove any organization from the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'organizations.delete.this', name: 'Delete this organization', category: 'Organizations', description: 'Remove this organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, // ───────────────────────────────────────────────────────────── // ROLES (ORGANIZATION-based) // ───────────────────────────────────────────────────────────── { tag: 'roles.create', name: 'Create roles', category: 'Roles', description: 'Add new roles to the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'roles.read.any', name: 'Read any role', category: 'Roles', description: 'View details of any role in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'roles.edit.any', name: 'Edit any role', category: 'Roles', description: 'Update details of any role in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'roles.delete.any', name: 'Delete any role', category: 'Roles', description: 'Remove any role from the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'roles.permissions', name: 'Assign permissions to roles', category: 'Roles', description: 'Assign permissions to roles in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, // ───────────────────────────────────────────────────────────── // USER ROLES (ORGANIZATION-based) // ───────────────────────────────────────────────────────────── { tag: 'users.roles.read', name: 'Read user roles', category: 'User Roles', description: 'View details of any user role in the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'users.roles.create', name: 'Create user roles', category: 'User Roles', description: 'Add new user roles to the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'users.roles.edit', name: 'Edit user roles', category: 'User Roles', description: 'Update details of any user role in the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'users.roles.delete', name: 'Delete user roles', category: 'User Roles', description: 'Remove any user role from the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'users.roles.permissions', name: 'Add permissions to user roles', category: 'User Roles', description: 'Add permissions to user roles in the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'users.roles.assign', name: 'Assign roles to users', category: 'User Roles', description: 'Assign user roles to users in the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, // ───────────────────────────────────────────────────────────── // PERMISSIONS (ORGANIZATION-based) // ───────────────────────────────────────────────────────────── { tag: 'permissions.read.any', name: 'Read any permission', category: 'Permissions', description: 'View details of any permission in the system.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'permissions.create', name: 'Create permissions', category: 'Permissions', description: 'Add new permissions to the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'permissions.edit.any', name: 'Edit any permission', category: 'Permissions', description: 'Update details of any permission in the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'permissions.delete.any', name: 'Delete any permission', category: 'Permissions', description: 'Remove any permission from the system.', scope: UserTypes.PLATFORM, assignmentType: AssignmentType.ORGANIZATION, }, // ───────────────────────────────────────────────────────────── // LISTS (ORGANIZATION-based) // ───────────────────────────────────────────────────────────── { tag: 'lists.create', name: 'Create lists', category: 'Lists', description: 'Add new lists to an organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'lists.read.any', name: 'Read any list', category: 'Lists', description: 'View details of any list in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'lists.read.own', name: 'Read own lists', category: 'Lists', description: 'View details of any list in the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'lists.edit.any', name: 'Edit any list', category: 'Lists', description: 'Update details of any list in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'lists.edit.own', name: 'Edit own lists', category: 'Lists', description: 'Update details of any list in the organization that were created by user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'lists.delete.any', name: 'Delete any list', category: 'Lists', description: 'Remove any list from the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'lists.delete.own', name: 'Delete own lists', category: 'Lists', description: 'Remove any list from the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, // ───────────────────────────────────────────────────────────── // API KEYS (ORGANIZATION-based) // ───────────────────────────────────────────────────────────── { tag: 'apikeys.create', name: 'Create API keys', category: 'API Keys', description: 'Add new API keys to the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'apikeys.read.any', name: 'Read API keys', category: 'API Keys', description: 'View details of any API key in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'apikeys.read.own', name: 'Read own API keys', category: 'API Keys', description: 'View details of any API key in the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'apikeys.edit.any', name: 'Edit Any API keys', category: 'API Keys', description: 'Update details of any API key in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'apikeys.edit.own', name: 'Edit own API keys', category: 'API Keys', description: 'Update details of any API key in the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'apikeys.delete.any', name: 'Delete Any API keys', category: 'API Keys', description: 'Remove any API key from the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'apikeys.delete.own', name: 'Delete own API keys', category: 'API Keys', description: 'Remove any API key from the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'memberships.read.any', name: 'Read any membership', category: 'Memberships', description: 'View details of any membership in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'memberships.read.own', name: 'Read own membership', category: 'Memberships', description: 'View details of own membership in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'memberships.create', name: 'Create memberships', category: 'Memberships', description: 'Add new memberships to the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'memberships.edit.any', name: 'Edit any membership', category: 'Memberships', description: 'Update details of any membership in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'memberships.edit.own', name: 'Edit own membership', category: 'Memberships', description: 'Update details of own membership in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'memberships.delete.any', name: 'Delete any membership', category: 'Memberships', description: 'Remove any membership from the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'memberships.delete.own', name: 'Delete own membership', category: 'Memberships', description: 'Remove own membership from the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'memberships.assign', name: 'Assign memberships', category: 'Memberships', description: 'Assign memberships to users in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, // ───────────────────────────────────────────────────────────── // CONTACTS (ORGANIZATION-based) // ───────────────────────────────────────────────────────────── { tag: 'contacts.create', name: 'Create contacts', category: 'Contacts', description: 'Add new contacts to the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'contacts.read.any', name: 'Read any contact', category: 'Contacts', description: 'View details of any contact in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'contacts.read.own', name: 'Read own contacts', category: 'Contacts', description: 'View details of any contact in the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'contacts.edit.any', name: 'Edit any contact', category: 'Contacts', description: 'Update details of any contact in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'contacts.edit.own', name: 'Edit own contacts', category: 'Contacts', description: 'Update details of any contact in the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'contacts.delete.any', name: 'Delete any contact', category: 'Contacts', description: 'Remove any contact from the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'contacts.delete.own', name: 'Delete own contacts', category: 'Contacts', description: 'Remove any contact from the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, // ───────────────────────────────────────────────────────────── // CAMPAIGNS (ORGANIZATION-based) // ───────────────────────────────────────────────────────────── { tag: 'campaigns.create', name: 'Create campaigns', category: 'Campaigns', description: 'Add new campaigns to the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'campaigns.read.any', name: 'Read any campaign', category: 'Campaigns', description: 'View details of any campaign in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'campaigns.read.own', name: 'Read own campaigns', category: 'Campaigns', description: 'View details of any campaign in the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'campaigns.edit.any', name: 'Edit any campaign', category: 'Campaigns', description: 'Update details of any campaign in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'campaigns.edit.own', name: 'Edit own campaigns', category: 'Campaigns', description: 'Update details of any campaign in the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'campaigns.delete.any', name: 'Delete any campaign', category: 'Campaigns', description: 'Remove any campaign from the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'campaigns.delete.own', name: 'Delete own campaigns', category: 'Campaigns', description: 'Remove any campaign from the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, // ───────────────────────────────────────────────────────────── // Groups (ORGANIZATION-based) // ───────────────────────────────────────────────────────────── { tag: 'groups.create', name: 'Create groups', category: 'Groups', description: 'Add new groups to the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'groups.read.any', name: 'Read any group', category: 'Groups', description: 'View details of any group in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'groups.read.own', name: 'Read own groups', category: 'Groups', description: 'View details of any group in the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'groups.edit.any', name: 'Edit any group', category: 'Groups', description: 'Update details of any group in the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'groups.edit.own', name: 'Edit own groups', category: 'Groups', description: 'Update details of any group in the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'groups.delete.any', name: 'Delete any group', category: 'Groups', description: 'Remove any group from the organization.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, { tag: 'groups.delete.own', name: 'Delete own groups', category: 'Groups', description: 'Remove any group from the organization that were created by the user.', scope: UserTypes.CLIENT, assignmentType: AssignmentType.ORGANIZATION, }, ]; /** * The arrays for role-based permissions (org-level). * We'll define 'PLATFORM_PERMISSIONS', 'AGENCY_PERMISSIONS', 'CLIENT_PERMISSIONS' * but note that some permissions might be user-level if you prefer to do so * in a separate user-based role. For demonstration, we keep the main ones here. */ const PLATFORM_PERMISSIONS = [ // Get the permissions where UserTypes.PLATFORM is the scope 'platform.org.create', 'platform.settings.update', 'users.read.any', 'users.edit.any', 'users.delete.any', 'organizations.read.any', 'organizations.edit.any', 'organizations.delete.any', 'users.roles.read', 'users.roles.create', 'users.roles.edit', 'users.roles.delete', 'users.roles.permissions', 'users.roles.assign', 'permissions.create', 'permissions.read.any', 'permissions.edit.any', 'permissions.delete.any', ]; const AGENCY_PERMISSIONS = [ 'organizations.operations.lock', 'organizations.package.update', ]; /** * We remove 'users.read.own', 'users.edit.own', 'users.delete.own' * from the 'CLIENT_PERMISSIONS' array, so those .own perms are * purely assigned to user-level roles if we want. */ const CLIENT_PERMISSIONS = [ 'users.create', 'roles.create', 'roles.read.any', 'roles.edit.any', 'roles.delete.any', 'roles.permissions', 'permissions.read.any', 'lists.create', 'lists.read.any', 'lists.read.own', 'lists.edit.any', 'lists.edit.own', 'lists.delete.any', 'lists.delete.own', 'apikeys.create', 'apikeys.read.any', 'apikeys.read.own', 'apikeys.edit.any', 'apikeys.edit.own', 'apikeys.delete.any', 'apikeys.delete.own', 'memberships.read.any', 'memberships.read.own', 'memberships.create', 'memberships.edit.any', 'memberships.edit.own', 'memberships.delete.any', 'memberships.delete.own', 'memberships.assign', 'organizations.read.this', 'organizations.edit.this', 'organizations.delete.this', 'organizations.edit.own', 'organizations.delete.own', 'contacts.create', 'contacts.read.any', 'contacts.read.own', 'contacts.edit.any', 'contacts.edit.own', 'contacts.delete.any', 'contacts.delete.own', 'campaigns.create', 'campaigns.read.any', 'campaigns.read.own', 'campaigns.edit.any', 'campaigns.edit.own', 'campaigns.delete.any', 'campaigns.delete.own', 'groups.create', 'groups.read.any', 'groups.read.own', 'groups.edit.any', 'groups.edit.own', 'groups.delete.any', 'groups.delete.own', ]; /** * ROLE_PERMISSIONS * * We define 3 org-based roles: * 1) "Platform Owner" => ID=1 => OrgType=PLATFORM * 2) "Agency Owner" => ID=2 => OrgType=AGENCY * 3) "Client Owner" => ID=3 => OrgType=CLIENT * * Each references a set of tags from above arrays. assignmentType=ORGANIZATION */ const ROLE_PERMISSIONS = [ { id: 1, name: 'Platform Owner', description: 'Has all platform-level permissions', OrgType: OrgType.PLATFORM, tags: [ ...PLATFORM_PERMISSIONS, ...AGENCY_PERMISSIONS, ...CLIENT_PERMISSIONS, ], assignmentType: AssignmentType.ORGANIZATION, }, { id: 2, name: 'Agency Owner', description: 'Owner role for an agency org', OrgType: OrgType.AGENCY, tags: [...AGENCY_PERMISSIONS, ...CLIENT_PERMISSIONS], assignmentType: AssignmentType.ORGANIZATION, }, { id: 3, name: 'Client Owner', description: 'Owner role for a client org', OrgType: OrgType.CLIENT, tags: [...CLIENT_PERMISSIONS], assignmentType: AssignmentType.ORGANIZATION, }, ]; /** * We also create a user-based role for the .own perms => role #4. * This is purely assignmentType=USER, so no org is tied to it. */ const USER_BASED_OWN_ROLE = { id: 4, name: 'OwnUserRole', description: 'Grants .own user permissions (read/edit/delete own user).', OrgType: OrgType.CLIENT, // can be anything, but let's keep it as CLIENT tags: [ 'users.read.own', 'users.edit.own', 'users.delete.own', 'organizations.create', 'organizations.read.own', ], assignmentType: AssignmentType.USER, }; /** * We also define 3 base users for the org-based roles: * #1 => platform@mumara.com => membership => role=Platform Owner * #2 => agency@mumara.com => membership => role=Agency Owner * #3 => client@mumara.com => membership => role=Client Owner * * We'll ALSO assign user #3 => role #4 in "users_on_roles" pivot * to demonstrate user-level .own perms. */ const USERS_TO_CREATE = [ { userId: 1, email: 'platform@mumara.com', firstName: 'Platform', lastName: 'Owner', password: 'Nexus123**', userType: UserTypes.PLATFORM, orgId: 1, // membership org roleId: 1, // membership org-based role }, { userId: 2, email: 'agency@mumara.com', firstName: 'Agency', lastName: 'Owner', password: 'Nexus123**', userType: UserTypes.AGENCY, orgId: 2, roleId: 2, }, { userId: 3, email: 'client@mumara.com', firstName: 'Client', lastName: 'Owner', password: 'Nexus123**', userType: UserTypes.CLIENT, orgId: 3, roleId: 3, }, ]; /** * A helper function to forcibly create or update a Role by ID, * ensuring there's no conflict in name vs existing record. */ async function upsertRoleById( id: number, name: string, description: string, OrgType: OrgType, assignmentType: AssignmentType, ) { // Attempt to find an existing role with the same ID const existingById = await prisma.role.findUnique({ where: { id } }); if (existingById) { // If it exists, verify it has the same name; if not, conflict if (existingById.name === name) { // We update only the description & OrgType. // (If assignmentType differs, you can decide whether to override or not.) return prisma.role.update({ where: { id }, data: { description, OrgType, assignmentType: assignmentType, }, }); } else { throw new Error( `Collision: Role with id=${id} has name="${existingById.name}", not "${name}". ` + `Rename or remove it to proceed.`, ); } } // No existing => create new return prisma.role.create({ data: { id, name, description, OrgType, assignmentType: assignmentType, }, }); } /** * The main seed function. * * Steps: * 1) Upserts organizations (1=Platform,2=Agency,3=Client). * 2) Upserts all permissions from PERMISSIONS_SEED. * 3) Creates or updates the 3 org-based roles #1,#2,#3. * 4) Creates or updates the user-based role #4 for .own perms. * 5) Creates/updates 3 test users, each with membership-based role. * 6) Adds pivot in "users_on_roles" linking user #3 => role #4. */ async function main() { // ───────────────────────────────────────────────────────────── // 1) Upsert Organizations // ───────────────────────────────────────────────────────────── console.log('--- Upserting Orgs (Platform=1, Agency=2, Client=3) ---'); const orgSeeds = [ { id: 1, name: 'Mumara Platform', organizationType: OrgType.PLATFORM, locked: false, parentId: null, }, { id: 2, name: 'Mumara Agency', organizationType: OrgType.AGENCY, locked: false, parentId: 1, }, { id: 3, name: 'Mumara Client', organizationType: OrgType.CLIENT, locked: false, parentId: 2, }, ]; for (const o of orgSeeds) { await prisma.organization.upsert({ where: { id: o.id }, update: { name: o.name, organizationType: o.organizationType, locked: o.locked, parentId: o.parentId ? o.parentId : undefined, }, create: { id: o.id, name: o.name, organizationType: o.organizationType, locked: o.locked, parentId: o.parentId ? o.parentId : undefined, }, }); console.log(`Org ${o.id} upserted: ${o.name}`); } // ───────────────────────────────────────────────────────────── // 2) Upsert Permissions // ───────────────────────────────────────────────────────────── console.log('\n--- Upserting Permissions by tag ---'); for (const p of PERMISSIONS_SEED) { try { // We upsert by tag. If found, we update name, scope, etc. await prisma.permission.upsert({ where: { tag: p.tag }, update: { name: p.name, scope: p.scope, category: p.category, description: p.description ?? undefined, assignmentType: p.assignmentType, }, create: { tag: p.tag, name: p.name, scope: p.scope, category: p.category, description: p.description ?? undefined, assignmentType: p.assignmentType, }, }); console.log( `Permission upserted: ${p.tag} (scope=${p.scope}, assignmentType=${p.assignmentType})`, ); } catch (err: any) { console.error(`Error upserting permission "${p.tag}": ${err.message}`); } } // ───────────────────────────────────────────────────────────── // 3) Create/Update the 3 ORG-based roles => IDs=1,2,3 // ───────────────────────────────────────────────────────────── console.log('\n--- Upserting Org-based Roles (ID=1,2,3) ---'); for (const r of ROLE_PERMISSIONS) { let role; try { // forcibly upsert role with the given ID role = await upsertRoleById( r.id, r.name, r.description, r.OrgType, r.assignmentType, ); console.log( `Role upserted: id=${role.id}, name="${role.name}", OrgType=${role.OrgType}, assignmentType=${role.AssignmentType}`, ); } catch (error: any) { console.error( `Error upserting role id=${r.id} name="${r.name}" OrgType=${r.OrgType} assignmentType=${r.assignmentType}: ${error.message}`, ); continue; } // 3a) Find the relevant permission records by tag const foundPerms = await prisma.permission.findMany({ where: { tag: { in: r.tags } }, }); // 3b) Check if some are missing const missingTags = r.tags.filter( (tag) => !foundPerms.some((fp) => fp.tag === tag), ); if (missingTags.length > 0) { console.warn( `WARNING: Role "${r.name}" references missing tags: [${missingTags.join( ', ', )}]`, ); } // 3c) remove old pivot await prisma.roleOnPermission.deleteMany({ where: { roleId: r.id } }); // 3d) create new pivot if (foundPerms.length > 0) { const pivotData = foundPerms.map((fp) => ({ roleId: r.id, permissionId: fp.id, })); await prisma.roleOnPermission.createMany({ data: pivotData }); console.log( `Assigned ${pivotData.length} perms to org-based role "${r.name}" (id=${r.id})`, ); } } // ───────────────────────────────────────────────────────────── // 4) Create/Update the USER-based role #4 => "OwnUserRole" // ───────────────────────────────────────────────────────────── console.log( `\n--- Upserting User-based Role #${USER_BASED_OWN_ROLE.id} => ${USER_BASED_OWN_ROLE.name} ---`, ); try { const userRole = await upsertRoleById( USER_BASED_OWN_ROLE.id, USER_BASED_OWN_ROLE.name, USER_BASED_OWN_ROLE.description, USER_BASED_OWN_ROLE.OrgType, USER_BASED_OWN_ROLE.assignmentType, ); console.log( `User-based role upserted: id=${userRole.id}, name="${userRole.name}", assignmentType=${userRole.assignmentType}`, ); // find perms for the user-based role const foundPerms = await prisma.permission.findMany({ where: { tag: { in: USER_BASED_OWN_ROLE.tags } }, }); const missingTags = USER_BASED_OWN_ROLE.tags.filter( (tag) => !foundPerms.some((fp) => fp.tag === tag), ); if (missingTags.length > 0) { console.warn( `WARNING: User-based role "${USER_BASED_OWN_ROLE.name}" missing tags: [${missingTags.join( ', ', )}]`, ); } // remove old pivot await prisma.roleOnPermission.deleteMany({ where: { roleId: userRole.id }, }); // create new pivot if (foundPerms.length > 0) { const pivotData = foundPerms.map((fp) => ({ roleId: userRole.id, permissionId: fp.id, })); await prisma.roleOnPermission.createMany({ data: pivotData }); console.log( `Assigned ${pivotData.length} perms to user-based role "${userRole.name}" (id=${userRole.id})`, ); } } catch (err: any) { console.error( `Error upserting user-based role #${USER_BASED_OWN_ROLE.id}: ${err.message}`, ); } // ───────────────────────────────────────────────────────────── // 5) Create/Update 3 Users + membership // ───────────────────────────────────────────────────────────── console.log('\n--- Creating/Updating 3 Users + membership ---'); for (const u of USERS_TO_CREATE) { try { // 5a) hash password const hashedPassword = await bcrypt.hash(u.password, 10); // 5b) upsert user by ID const existingUser = await prisma.user.findUnique({ where: { id: u.userId }, }); if (existingUser) { // update existing await prisma.user.update({ where: { id: u.userId }, data: { email: u.email, hashedPassword, firstName: u.firstName, lastName: u.lastName, userType: u.userType, status: UserStatus.ACTIVE, }, }); console.log(`User updated: ${u.email} (id=${u.userId})`); } else { // create await prisma.user.create({ data: { id: u.userId, email: u.email, hashedPassword, firstName: u.firstName, lastName: u.lastName, userType: u.userType, status: UserStatus.ACTIVE, }, }); console.log(`User created: ${u.email} (id=${u.userId})`); } // 5c) upsert membership => user <-> organization => role await prisma.membership.upsert({ where: { userId_organizationId: { userId: u.userId, organizationId: u.orgId, }, }, update: { roleId: u.roleId, }, create: { userId: u.userId, organizationId: u.orgId, roleId: u.roleId, }, }); console.log( `Membership: user #${u.userId} => org #${u.orgId} => role #${u.roleId}`, ); } catch (err: any) { console.error( `Error creating/updating user #${u.userId}: ${err.message}`, ); } } // ───────────────────────────────────────────────────────────── // 6) Link user #3 => user-based role #4 in "users_on_roles" pivot // ───────────────────────────────────────────────────────────── console.log('\n--- Adding user_on_roles pivot => user #3 => role #4 ---'); try { const pivotCheck = await prisma.userOnRole.findUnique({ where: { userId_roleId: { userId: 3, roleId: 4, }, }, }); if (!pivotCheck) { // create pivot await prisma.userOnRole.create({ data: { userId: 3, roleId: 4, }, }); console.log('Pivot created: user #3 => user-based role #4'); } else { console.log('Pivot already exists: user #3 => user-based role #4'); } } catch (err: any) { console.error(`Error linking user #3 => role #4: ${err.message}`); } console.log('\nSeed script completed successfully!'); } /** * Finally, call our main() function, * and handle any fatal errors or clean up on exit. */ main() .catch((e) => { console.error('FATAL SCRIPT ERROR:', e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); }); File: src/roles/dto/assign-permissions.dto.ts import { IsArray, IsString } from 'class-validator'; export class AssignPermissionsByTagDto { @IsArray() @IsString({ each: true }) scopes: string[]; // e.g. ["users.read.any", "lists.edit.any"] } File: src/roles/dto/assign-user-role.dto.ts import { IsInt } from 'class-validator'; /** * AssignUserRoleDto * * This DTO defines the structure required to assign a user role * to a user by providing the target user's ID. */ export class AssignUserRoleDto { @IsInt() userId: number; } File: src/roles/dto/create-role.dto.ts import { IsOptional, IsString, IsInt, IsEnum } from 'class-validator'; import { AssignmentType } from '@prisma/client'; export class CreateRoleDto { @IsString() name: string; @IsString() @IsOptional() icon?: string; @IsString() @IsOptional() description?: string; @IsInt() @IsOptional() organizationId?: number | null; // If undefined or null => we consider it a "global" role for some orgType @IsString() @IsOptional() orgType?: string; // "PLATFORM" | "AGENCY" | "CLIENT" => if user is platform and wants // to create a global agency role, they'd do orgType="AGENCY" + orgId=null // Use IsEnum to validate the allowed values for assignmentType @IsEnum(AssignmentType) @IsOptional() assignmentType: AssignmentType = 'ORGANIZATION'; } File: src/roles/dto/create-user-role.dto.ts import { IsOptional, IsString } from 'class-validator'; /** * CreateUserRoleDto * * This DTO defines the required fields to create a user role. * User roles are stored in the roles table with fixed properties: * - assignmentType = USER * - OrgType = CLIENT */ export class CreateUserRoleDto { @IsString() name: string; @IsOptional() @IsString() icon?: string; @IsOptional() @IsString() description?: string; } File: src/roles/dto/update-role.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateRoleDto } from './create-role.dto'; export class UpdateRoleDto extends PartialType(CreateRoleDto) {} File: src/roles/dto/update-user-role.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateUserRoleDto } from './create-user-role.dto'; /** * UpdateUserRoleDto * * This DTO is used to update a user role. * It extends PartialType so that all fields from CreateUserRoleDto are optional. * Only mutable fields (name, icon, description) can be updated. */ export class UpdateUserRoleDto extends PartialType(CreateUserRoleDto) {} File: src/roles/roles.controller.ts import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Req, UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { RolesService } from './roles.service'; import { CreateRoleDto } from './dto/create-role.dto'; import { UpdateRoleDto } from './dto/update-role.dto'; import { AssignPermissionsByTagDto } from './dto/assign-permissions.dto'; import { Permissions } from 'src/auth/permissions.decorator'; import { OrganizationGuard } from 'src/guards/organization.guard'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; /** * RolesController: * * - Routes are prefixed with `/:orgId/roles`. * - `orgId=0` is a special "global" org for platform. * - The `OrganizationGuard` ensures the user has membership in orgId * (or is platform if 0) and checks route-level permissions (e.g. 'roles.read.any'). */ @UseGuards(OrganizationGuard) @Controller(':orgId/roles') export class RolesController { constructor( private readonly rolesService: RolesService, private readonly i18n: I18nService, private readonly logger: NexusLoggerService, ) {} /** * GET /:orgId/roles * Retrieves roles for the given orgId (or global if orgId=0). * * Example: GET /0/roles -> returns platform/global roles if user is platform. * Example: GET /5/roles -> returns roles for org #5 plus global roles that user can see. */ @Get() @Permissions('roles.read.any') async getAllRoles( @Param('orgId', ParseIntPipe) orgId: number, @Req() req: any, @I18nLang() lang: string, ) { // The OrganizationGuard attaches `req.orgMembership = { userId, permissions, orgId }` // We also have the entire user object in `req.user`. const membership = req.orgMembership; const [roles, msg] = await Promise.all([ this.rolesService.findAll(orgId, membership, req.user, lang), this.i18n.translate('roles.success.list', { lang }), ]); this.logger.info(`Returning ${roles.length} roles for orgId=${orgId}`); return { message: msg, data: roles }; } /** * GET /:orgId/roles/:id * Retrieves a single role within the specified org. * * - If orgId=0 => we expect the role to be global (organizationId=null). * - If orgId !=0 => the role must belong to that org or be within scope allowed. */ @Get(':id') @Permissions('roles.read.any') async getRoleById( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) id: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; const [role, msg] = await Promise.all([ this.rolesService.findOne(orgId, id, membership, req.user, lang), this.i18n.translate('roles.success.getOne', { lang, args: { id }, }), ]); this.logger.info(`Retrieved role with ID ${id} from orgId=${orgId}`); return { message: msg, data: role }; } /** * POST /:orgId/roles/add * Creates a new role for the specified org, or a "global" role if orgId=0 (platform only). * * Route-level permission required: 'roles.create'. * Additional logic (rank checks, etc.) is handled in the service. */ @Post('add') @Permissions('roles.create') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async createRole( @Param('orgId', ParseIntPipe) orgId: number, @Body() dto: CreateRoleDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; const [role, msg] = await Promise.all([ this.rolesService.create(orgId, dto, membership, req.user, lang), this.i18n.translate('roles.success.created', { lang }), ]); this.logger.info(`Role created successfully in orgId=${orgId}`); return { message: msg, data: role }; } /** * PATCH /:orgId/roles/edit/:id * Edits an existing role (name, icon, and description) within the org. * * - Must have 'roles.edit.any' at route-level. * - If orgId=0 => editing a global role. * - If orgId != 0 => editing an org-bound role. */ @Patch('edit/:id') @Permissions('roles.edit.any') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async updateRole( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoleDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; const [updated, msg] = await Promise.all([ this.rolesService.update(orgId, id, dto, membership, req.user, lang), this.i18n.translate('roles.success.updated', { lang, args: { id }, }), ]); this.logger.info( `Role with ID ${id} updated successfully in orgId=${orgId}`, ); return { message: msg, data: updated }; } /** * DELETE /:orgId/roles/delete/:id * Removes an existing role under the specified org. * * - Must have 'roles.delete.any'. * - If orgId=0 => removing a global role (platform only). * - Otherwise => removing org-bound role. */ @Delete('delete/:id') @Permissions('roles.delete.any') async deleteRole( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) id: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; await this.rolesService.remove(orgId, id, membership, req.user, lang); const [msg] = await Promise.all([ this.i18n.translate('roles.success.deleted', { lang, args: { id }, }), ]); this.logger.info(`Role with ID ${id} deleted from orgId=${orgId}`); return { message: msg }; } /** * POST /:orgId/roles/:id/permissions * Assigns a set of permission tags to a specific role within orgId or global if 0. * * - Must have 'roles.permissions'. * - The service ensures the user can't assign higher-rank perms than their userType. */ @Post(':id/permissions') @Permissions('roles.permissions') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async assignPermissionsByTag( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) roleId: number, @Body() dto: AssignPermissionsByTagDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; const [updated, msg] = await Promise.all([ this.rolesService.assignPermissionsByTag( orgId, roleId, dto, membership, req.user, lang, ), this.i18n.translate('roles.success.permissions_assigned', { lang }), ]); this.logger.info( `Permissions assigned to role ID ${roleId} in orgId=${orgId}`, ); return { message: msg, data: updated }; } /** * GET /:orgId/roles/:id/permissions * Retrieves all permissions assigned to a specific role within orgId or global if 0. * * - Must have 'roles.read.any'. * - The service ensures the user can't see higher-rank perms than their userType. */ @Get(':id/permissions') @Permissions('roles.read.any') async getRolePermissions( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) roleId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; const [permissions, msg] = await Promise.all([ this.rolesService.getPermissions( orgId, roleId, membership, req.user, lang, ), this.i18n.translate('roles.success.permissions_fetched', { lang, }), ]); this.logger.info( `Permissions for role ID ${roleId} retrieved from orgId=${orgId}`, ); return { message: msg, data: permissions }; } } File: src/roles/roles.module.ts import { Module } from '@nestjs/common'; import { RolesService } from './roles.service'; import { UserRolesService } from './user-roles.service'; import { RolesController } from './roles.controller'; import { UserRolesController } from './user-roles.controller'; import { PrismaService } from 'src/prisma/prisma.service'; import { ApiKeysModule } from '../api-keys/api-keys.module'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; @Module({ imports: [ApiKeysModule], controllers: [RolesController, UserRolesController], providers: [ RolesService, UserRolesService, PrismaService, NexusLoggerService, ], exports: [RolesService, UserRolesService], }) export class RolesModule {} File: src/roles/roles.service.ts import { BadRequestException, Injectable, NotFoundException, ForbiddenException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateRoleDto } from './dto/create-role.dto'; import { UpdateRoleDto } from './dto/update-role.dto'; import { AssignPermissionsByTagDto } from './dto/assign-permissions.dto'; import { AssignmentType, OrgType, UserTypes } from '@prisma/client'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; import { I18nService } from 'nestjs-i18n'; /** * We define a simple rank mapping: * PLATFORM => 3, AGENCY => 2, CLIENT => 1 * * This rank mapping is used to: * - Restrict users from modifying or viewing roles/permissions beyond their authority. * - Enforce that, for example, a CLIENT cannot modify PLATFORM-level permissions. */ const RANK = { PLATFORM: 3, AGENCY: 2, CLIENT: 1, } as const; @Injectable() export class RolesService { constructor( private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, private readonly i18n: I18nService, ) {} /** * CREATE a new role. * * @param orgId - The organizationId route parameter (0 indicates a global role). * @param dto - Data Transfer Object with role creation data (name, description, etc.). * @param membership - The organization membership object provided by the guard. * @param currentUser - The full user object (including userType, userId, etc.). * @param lang - The current language for translations. * * Global roles (orgId=0) will have their organizationId set to null. */ async create( orgId: number, dto: CreateRoleDto, membership: any, currentUser: any, lang: string, ) { this.logger.debug( `RolesService.create => orgId=${orgId}, user=${currentUser.userId}`, ); const userScope = currentUser.userType as UserTypes; // Check if the role is meant to be global (orgId === 0) if (orgId === 0) { // Only platform users are allowed to create global roles. if (userScope !== 'PLATFORM') { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.global_create_forbidden', { lang }), ]); this.logger.warn( `Non-platform user ${currentUser.userId} tried to create a global role (orgId=0).`, ); throw new ForbiddenException(errorMsg); } // Determine the final organization type for the global role. // Default to 'PLATFORM' if no orgType is provided. const finalOrgType = dto.orgType ?? 'PLATFORM'; // Validate that the provided orgType exists in our rank mapping. if (!(finalOrgType in RANK)) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.invalid_org_type', { lang, args: { orgType: finalOrgType }, }), ]); this.logger.warn( `Invalid orgType="${finalOrgType}" provided by user ${currentUser.userId}.`, ); throw new BadRequestException(errorMsg); } this.logger.success( `Creating global role (organizationId=null) with orgType=${finalOrgType}.`, ); // Create the global role with organizationId set to null. return this.prisma.role.create({ data: { name: dto.name, icon: dto.icon, description: dto.description, organizationId: null, OrgType: finalOrgType as OrgType, assignmentType: dto.assignmentType ?? AssignmentType.ORGANIZATION, }, }); } // If orgId is not 0, the role is bound to a specific organization. const targetOrg = await this.prisma.organization.findUnique({ where: { id: orgId }, }); if (!targetOrg) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.org_not_found', { lang, args: { orgId }, }), ]); this.logger.warn(`Org ${orgId} not found while creating a role.`); throw new NotFoundException(errorMsg); } this.logger.success( `Creating role in orgId=${orgId} (type=${targetOrg.organizationType}) for user ${currentUser.userId}.`, ); // Create the role with the organization's type. return this.prisma.role.create({ data: { name: dto.name, icon: dto.icon, description: dto.description, organizationId: targetOrg.id, OrgType: targetOrg.organizationType, }, }); } /** * FIND ALL roles for a given orgId (or global roles if orgId=0). * Optionally merges in global roles for non-global requests when allowed by user rank. */ async findAll( orgId: number, membership: any, currentUser: any, lang: string, ) { this.logger.debug( `RolesService.findAll => orgId=${orgId}, user=${currentUser.userId}`, ); const userScope = currentUser.userType as UserTypes; const userRank = RANK[userScope] ?? 0; // For global roles (orgId === 0), only platform users are allowed. if (orgId === 0) { if (userScope !== 'PLATFORM') { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_view_global', { lang }), ]); this.logger.warn( `User ${currentUser.userId} (scope=${userScope}) cannot list global roles (orgId=0).`, ); throw new ForbiddenException(errorMsg); } this.logger.info( `Listing all global roles for platform user ${currentUser.userId}.`, ); return this.prisma.role.findMany({ where: { organizationId: null, assignmentType: AssignmentType.ORGANIZATION, }, }); } // For organization-bound roles, fetch roles for the organization plus allowed global roles. const org = await this.prisma.organization.findUnique({ where: { id: orgId }, select: { organizationType: true }, }); if (!org) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.org_not_found', { lang, args: { orgId }, }), ]); this.logger.warn(`Org ${orgId} not found in findAll roles.`); throw new NotFoundException(errorMsg); } // Build a list of global organization types that the current user is allowed to see, // meaning the rank of the global role is less than or equal to the user's rank. const allowedGlobalTypes = Object.entries(RANK) .filter(([_, val]) => val <= userRank) .map(([key]) => key as OrgType); this.logger.debug( `User ${currentUser.userId} (scope=${userScope}) can see global orgTypes: [${allowedGlobalTypes.join(', ')}]`, ); return this.prisma.role.findMany({ where: { OR: [ { organizationId: orgId }, { organizationId: null, OrgType: { in: allowedGlobalTypes }, }, ], assignmentType: AssignmentType.ORGANIZATION, }, }); } /** * FIND ONE role by ID under the given orgId (or global if orgId=0). * * - For a global request (orgId === 0), ensures the role's organizationId is null. * - For organization-bound roles (orgId !== 0), verifies the role's organizationId matches the route parameter. */ async findOne( orgId: number, roleId: number, membership: any, currentUser: any, lang: string, ) { this.logger.debug( `RolesService.findOne => orgId=${orgId}, roleId=${roleId}, user=${currentUser.userId}`, ); // Retrieve the role record by its unique ID. const role = await this.prisma.role.findUnique({ where: { id: roleId }, }); if (!role) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.role_not_found', { lang, args: { roleId }, }), ]); this.logger.warn(`Role with ID ${roleId} not found in findOne.`); throw new NotFoundException(errorMsg); } // If assignmentType in database is not Organization, then the role is not allowed to be viewed if (role.assignmentType !== AssignmentType.ORGANIZATION) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_view_role', { lang }), ]); this.logger.warn( `User ${currentUser.userId} tried to view a role with assignmentType=${role.assignmentType}.`, ); throw new ForbiddenException(errorMsg); } // For global roles (orgId === 0), ensure that the role is indeed global (organizationId must be null). if (orgId === 0) { if (role.organizationId !== null) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_view_global', { lang }), ]); this.logger.warn( `User ${currentUser.userId} tried to access a non-global role (#${roleId}) with orgId=0.`, ); throw new ForbiddenException(errorMsg); } } else { // For organization-bound roles (orgId !== 0), ensure that: // 1. Global roles (organizationId === null) cannot be accessed via an org-bound route. // 2. The role's organizationId exactly matches the provided orgId. this.logger.debug( `Role has organizationId=${role.organizationId}, route param orgId=${orgId}`, ); if (role.organizationId === null) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_view_role', { lang }), ]); this.logger.warn( `User ${currentUser.userId} is not allowed to see a global role from an org route (#${orgId}).`, ); throw new ForbiddenException(errorMsg); } if (role.organizationId !== orgId) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.role_not_found', { lang, args: { roleId, orgId }, }), ]); this.logger.warn( `Role #${roleId} belongs to org #${role.organizationId}, not #${orgId}`, ); throw new ForbiddenException(errorMsg); } } this.logger.success( `User ${currentUser.userId} retrieved role #${roleId} from orgId=${orgId} successfully.`, ); return role; } /** * UPDATE an existing role's mutable fields (such as name, icon, and description). * This method has been extended to include extra checks when updating global roles (orgId=0). * * For global roles: * - Only platform users are allowed to perform the update. * - The role must have organizationId set to null. * * For all roles: * - Changes to immutable properties like organizationId or OrgType are disallowed. * * @param orgId - The organization ID from the route parameter (0 indicates a global role). * @param roleId - The unique identifier of the role to update. * @param dto - Data Transfer Object containing updated fields (name, icon, description). * @param membership - The organization membership information provided by the guard. * @param currentUser - The full user object (including userType, userId, etc.). * @param lang - The current language for translations. */ async update( orgId: number, roleId: number, dto: UpdateRoleDto, membership: any, currentUser: any, lang: string, ) { this.logger.debug( `RolesService.update => orgId=${orgId}, roleId=${roleId}, user=${currentUser.userId}`, ); // ----------------------------------------------------------------------------------- // EXTRA GLOBAL ROLE CHECKS: // ----------------------------------------------------------------------------------- // When the orgId is 0, we are dealing with a global role. // Global roles are only allowed to be updated by platform users. if (orgId === 0) { if (currentUser.userType !== 'PLATFORM') { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.global_create_forbidden', { lang }), ]); this.logger.warn( `User ${currentUser.userId} (type=${currentUser.userType}) attempted to update a global role (orgId=0).`, ); throw new ForbiddenException(errorMsg); } } // ----------------------------------------------------------------------------------- // RETRIEVE THE EXISTING ROLE: // ----------------------------------------------------------------------------------- // Use the findOne method to verify that the role exists and is within the correct scope. const role = await this.findOne( orgId, roleId, membership, currentUser, lang, ); // ----------------------------------------------------------------------------------- // PREVENT IMMUTABLE PROPERTY CHANGES: // ----------------------------------------------------------------------------------- // Users should not be allowed to change immutable properties such as organizationId or OrgType. if ((dto as any).organizationId !== undefined) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.immutable_change', { lang, args: { field: 'organizationId' }, }), ]); this.logger.warn( `User ${currentUser.userId} tried to change organizationId in update.`, ); throw new ForbiddenException(errorMsg); } if ((dto as any).OrgType !== undefined) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.immutable_change', { lang, args: { field: 'OrgType' }, }), ]); this.logger.warn( `User ${currentUser.userId} tried to change OrgType in update.`, ); throw new ForbiddenException(errorMsg); } // Check if the assignmentType in database is not Organization, then the role is not allowed to be updated if (role.assignmentType !== AssignmentType.ORGANIZATION) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_view_role', { lang }), ]); this.logger.warn( `User ${currentUser.userId} tried to update a role with assignmentType=${role.assignmentType}.`, ); throw new ForbiddenException(errorMsg); } // ----------------------------------------------------------------------------------- // PERFORM THE UPDATE: // ----------------------------------------------------------------------------------- // Update only the mutable fields (name, icon, description). // If a field is not provided in the update DTO, retain its current value. this.logger.info( `Updating role #${roleId} for user ${currentUser.userId}.`, ); return this.prisma.role.update({ where: { id: roleId }, data: { name: dto.name ?? role.name, icon: dto.icon ?? role.icon, description: dto.description ?? role.description, }, }); } /** * DELETE an existing role. * * This method first verifies that the role exists and that the current user is authorized to view it. * It then deletes any associated permission assignments (pivot table references) before finally deleting the role. */ async remove( orgId: number, roleId: number, membership: any, currentUser: any, lang: string, ) { this.logger.debug( `RolesService.remove => orgId=${orgId}, roleId=${roleId}, user=${currentUser.userId}`, ); // Retrieve the role to verify that it exists and belongs to the proper scope. const role = await this.findOne( orgId, roleId, membership, currentUser, lang, ); // If the assignmentType in database is not Organization, then the role is not allowed to be deleted if (role.assignmentType !== AssignmentType.ORGANIZATION) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_view_role', { lang }), ]); this.logger.warn( `User ${currentUser.userId} tried to delete a role with assignmentType=${role.assignmentType}.`, ); throw new ForbiddenException(errorMsg); } this.logger.info( `Deleting role #${roleId} (orgId=${orgId}) for user ${currentUser.userId}.`, ); // First, delete all associated permission assignments for the role. await this.prisma.roleOnPermission.deleteMany({ where: { roleId } }); // Then, delete the role record itself. await this.prisma.role.delete({ where: { id: roleId } }); return true; } /** * ASSIGN PERMISSIONS to the given role. * * - Retrieves the role to verify its existence and scope. * - Fetches the permissions based on provided permission tags (scopes). * - Ensures that the current user cannot assign permissions beyond their authority (by checking rank). * - Clears existing permission assignments and creates new pivot table entries. * * @param orgId - The organization ID from the route parameter. * @param roleId - The unique identifier of the role. * @param dto - Data Transfer Object containing an array of permission tags to assign. * @param membership - The organization membership information. * @param currentUser - The full user object. * @param lang - The current language for translations. */ async assignPermissionsByTag( orgId: number, roleId: number, dto: AssignPermissionsByTagDto, membership: any, currentUser: any, lang: string, ) { this.logger.debug( `RolesService.assignPermissionsByTag => orgId=${orgId}, roleId=${roleId}, user=${currentUser.userId}`, ); const role = await this.findOne( orgId, roleId, membership, currentUser, lang, ); const { scopes } = dto; this.logger.debug( `User ${currentUser.userId} tries to assign perms [${scopes.join(', ')}] to role #${roleId}.`, ); // Fetch the Permission rows based on the provided tags. const perms = await this.prisma.permission.findMany({ where: { tag: { in: scopes } }, }); if (perms.length !== scopes.length) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.permission_tags_not_found', { lang, args: { tags: scopes.join(', ') }, }), ]); this.logger.warn( `Some permission tags do not exist. Requested=[${scopes.join(', ')}], Found=[${perms.map((p) => p.tag).join(', ')}].`, ); throw new BadRequestException(errorMsg); } // Enforce rank logic: ensure that the user is not assigning a permission beyond their authority. const userScope = currentUser.userType as UserTypes; const userRank = RANK[userScope] ?? 0; for (const p of perms) { const pRank = RANK[p.scope] ?? 0; if (pRank > userRank) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_assign_permission', { lang, args: { tag: p.tag, requiredScope: p.scope }, }), ]); this.logger.warn( `User ${currentUser.userId} tried to assign a higher-scope perm "${p.tag}" (requires ${p.scope}).`, ); throw new ForbiddenException(errorMsg); } } // Remove any existing permission assignments for this role. await this.prisma.roleOnPermission.deleteMany({ where: { roleId } }); this.logger.debug( `Assigning ${perms.length} permissions to role #${roleId}.`, ); // Create new pivot table entries for the permission assignments. await this.prisma.roleOnPermission.createMany({ data: perms.map((p) => ({ roleId, permissionId: p.id, })), }); this.logger.success( `User ${currentUser.userId} successfully assigned [${scopes.join(', ')}] perms to role #${roleId}.`, ); // Return the updated role including its nested permission assignments. return this.prisma.role.findUnique({ where: { id: roleId }, include: { permissions: { include: { permission: true }, }, }, }); } /** * GET all permissions assigned to a role. * * - Retrieves the role to verify that the user is authorized to view its permissions. * - Fetches all associated permission assignments. * - Filters out any permissions that exceed the current user's rank. * * @param orgId - The organization ID from the route parameter. * @param roleId - The unique identifier of the role. * @param membership - The organization membership information. * @param currentUser - The full user object. * @param lang - The current language for translations. */ async getPermissions( orgId: number, roleId: number, membership: any, currentUser: any, lang: string, ) { this.logger.debug( `RolesService.getPermissions => orgId=${orgId}, roleId=${roleId}, user=${currentUser.userId}`, ); const role = await this.findOne( orgId, roleId, membership, currentUser, lang, ); this.logger.debug( `User ${currentUser.userId} tries to fetch permissions for role #${roleId}.`, ); // Fetch permission assignments along with permission details. const perms = await this.prisma.roleOnPermission.findMany({ where: { roleId }, include: { permission: true }, }); // Enforce rank logic: only allow permissions with a rank less than or equal to the user's rank. const userScope = currentUser.userType as UserTypes; const userRank = RANK[userScope] ?? 0; const filteredPerms = perms.filter((p) => { const pRank = RANK[p.permission.scope] ?? 0; return pRank <= userRank; }); this.logger.success( `User ${currentUser.userId} successfully fetched permissions for role #${roleId}.`, ); return filteredPerms; } } File: src/roles/user-roles.controller.ts import { Body, Controller, Delete, Param, ParseIntPipe, Patch, Post, Get, Req, UsePipes, UseGuards, ValidationPipe, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { UserRolesService } from './user-roles.service'; import { CreateUserRoleDto } from './dto/create-user-role.dto'; import { AssignUserRoleDto } from './dto/assign-user-role.dto'; import { UpdateUserRoleDto } from './dto/update-user-role.dto'; import { Permissions } from 'src/auth/permissions.decorator'; import { PlatformGuard } from 'src/guards/platform.guard'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; /** * UserRolesController * * This controller handles endpoints for managing user roles. * It provides endpoints to create, update, assign, remove, and delete user roles. * These endpoints are isolated from your existing organization roles. */ @Controller('user-roles') @UseGuards(PlatformGuard) export class UserRolesController { constructor( private readonly userRolesService: UserRolesService, private readonly i18n: I18nService, private readonly logger: NexusLoggerService, ) {} /** * POST /user-roles/add * * Creates a new user role. * The role is saved with: * - organizationId: null * - assignmentType: USER * - OrgType: CLIENT */ @Post('add') @Permissions('users.roles.create') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async createUserRole( @Body() dto: CreateUserRoleDto, @Req() req: any, @I18nLang() lang: string, ) { const currentUser = req.user; const [role, msg] = await Promise.all([ this.userRolesService.createUserRole(dto, currentUser, lang), this.i18n.translate('roles.success.user_role_created', { lang, }), ]); this.logger.info( `User role created with ID ${role.id} by user ${currentUser.userId}`, ); return { message: msg, data: role }; } /** * GET /user-roles * * Retrieves all user roles. * This endpoint returns all user roles with assignmentType = USER. * It does not return organization roles. * Permission required: users.roles.read */ @Get() @Permissions('users.roles.read') async getUserRoles(@I18nLang() lang: string) { const [roles, msg] = await Promise.all([ this.userRolesService.getUserRoles(lang), this.i18n.translate('roles.success.user_roles_retrieved', { lang }), ]); this.logger.info(`Retrieved ${roles.length} user roles.`); return { message: msg, data: roles }; } /** * GET /user-roles/:roleId * * Retrieves a single user role by ID. * This endpoint returns a single user role with assignmentType = USER. * It does not return organization roles. * Permission required: users.roles.read */ @Get(':roleId') @Permissions('users.roles.read') async getUserRole( @Param('roleId', ParseIntPipe) roleId: number, @I18nLang() lang: string, ) { const [role, msg] = await Promise.all([ this.userRolesService.getUserRole(roleId, lang), this.i18n.translate('roles.success.user_role_retrieved', { lang, args: { roleId }, }), ]); this.logger.info(`Retrieved user role with ID ${roleId}.`); return { message: msg, data: role }; } /** * PATCH /user-roles/edit/:roleId * * Updates an existing user role. * Only mutable fields (name, icon, description) are updated. */ @Patch('edit/:roleId') @Permissions('users.roles.edit') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async updateUserRole( @Param('roleId', ParseIntPipe) roleId: number, @Body() dto: UpdateUserRoleDto, @Req() req: any, @I18nLang() lang: string, ) { const currentUser = req.user; const [updatedRole, msg] = await Promise.all([ this.userRolesService.updateUserRole(roleId, dto, currentUser, lang), this.i18n.translate('roles.success.user_role_updated', { lang, args: { roleId }, }), ]); this.logger.info( `User role with ID ${roleId} updated by user ${currentUser.userId}.`, ); return { message: msg, data: updatedRole }; } /** * POST /user-roles/:roleId/assign * * Assigns an existing user role to a user. * Creates an entry in the pivot table (UserOnRole) linking the role to the user. */ @Post(':roleId/assign') @Permissions('users.roles.assign') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async assignRoleToUser( @Param('roleId', ParseIntPipe) roleId: number, @Body() dto: AssignUserRoleDto, @Req() req: any, @I18nLang() lang: string, ) { const currentUser = req.user; const [assignment, msg] = await Promise.all([ this.userRolesService.assignRoleToUser( roleId, dto.userId, currentUser, lang, ), this.i18n.translate('roles.success.user_role_assigned', { lang, args: { roleId }, }), ]); this.logger.info( `User role with ID ${roleId} assigned to user ${dto.userId} by user ${currentUser.userId}.`, ); return { message: msg, data: assignment }; } /** * DELETE /user-roles/:roleId/remove/:userId * * Removes an assigned user role from a user. */ @Delete(':roleId/remove/:userId') @Permissions('users.roles.assign') async removeRoleFromUser( @Param('roleId', ParseIntPipe) roleId: number, @Param('userId', ParseIntPipe) userId: number, @Req() req: any, @I18nLang() lang: string, ) { const currentUser = req.user; await this.userRolesService.removeRoleFromUser( roleId, userId, currentUser, lang, ); const [msg] = await Promise.all([ this.i18n.translate('roles.success.user_role_removed', { lang, args: { roleId, userId }, }), ]); this.logger.info( `User role with ID ${roleId} removed from user ${userId} by user ${currentUser.userId}.`, ); return { message: msg }; } /** * DELETE /user-roles/delete/:roleId * * Deletes a user role entirely. * This endpoint removes: * - All user assignments (from UserOnRole) * - All permission assignments (from RoleOnPermission) * Then it deletes the role record. */ @Delete('delete/:roleId') @Permissions('users.roles.delete') async deleteUserRole( @Param('roleId', ParseIntPipe) roleId: number, @Req() req: any, @I18nLang() lang: string, ) { const currentUser = req.user; await this.userRolesService.deleteUserRole(roleId, currentUser, lang); const [msg] = await Promise.all([ this.i18n.translate('roles.success.user_role_deleted', { lang, args: { roleId }, }), ]); this.logger.info( `User role with ID ${roleId} deleted by user ${currentUser.userId}.`, ); return { message: msg }; } } File: src/roles/user-roles.service.ts import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateUserRoleDto } from './dto/create-user-role.dto'; import { UpdateUserRoleDto } from './dto/update-user-role.dto'; import { NexusLoggerService } from 'src/logger/nexus-logger.service'; import { I18nService } from 'nestjs-i18n'; @Injectable() export class UserRolesService { constructor( private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, private readonly i18n: I18nService, ) {} /** * CREATE a new user role. * * This method creates a role in the roles table with fixed properties: * - organizationId is set to null (not tied to any organization) * - assignmentType is set to USER * - OrgType is set to CLIENT * * @param dto - Data required to create the user role. * @param currentUser - The user making the request. * @param lang - The current language for translations. * @returns The newly created user role. */ async createUserRole(dto: CreateUserRoleDto, currentUser: any, lang: string) { this.logger.debug( `UserRolesService.createUserRole invoked by user ${currentUser.userId}`, ); // Optionally, add further permission checks here. return this.prisma.role.create({ data: { name: dto.name, icon: dto.icon, description: dto.description, organizationId: null, // Not linked to any organization. assignmentType: 'USER', // Fixed for user roles. OrgType: 'CLIENT', // Fixed for user roles. }, }); } /** * UPDATE an existing user role. * * Only mutable fields (name, icon, description) can be updated. * Immutable properties (organizationId, assignmentType, OrgType) cannot be changed. * * @param roleId - The ID of the user role to update. * @param dto - Data Transfer Object containing updated fields. * @param currentUser - The user making the request. * @param lang - The current language for translations. * @returns The updated user role. */ async updateUserRole( roleId: number, dto: UpdateUserRoleDto, currentUser: any, lang: string, ) { this.logger.debug( `UserRolesService.updateUserRole invoked for role ID ${roleId} by user ${currentUser.userId}`, ); // Retrieve the existing role. const existingRole = await this.prisma.role.findUnique({ where: { id: roleId }, }); if (!existingRole) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.role_not_found', { lang, args: { roleId }, }), ]); this.logger.warn(`User role with ID ${roleId} not found.`); throw new NotFoundException(errorMsg); } // Verify that it is a user role (i.e. fixed properties). if ( existingRole.assignmentType !== 'USER' || existingRole.OrgType !== 'CLIENT' ) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_view_role', { lang }), ]); this.logger.warn(`Role with ID ${roleId} is not a valid user role.`); throw new BadRequestException(errorMsg); } // Prevent updates to immutable fields. if ( (dto as any).organizationId !== undefined || (dto as any).assignmentType !== undefined || (dto as any).OrgType !== undefined ) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.immutable_change', { lang, args: { field: 'immutable fields' }, }), ]); this.logger.warn( `User ${currentUser.userId} attempted to change immutable fields on user role ${roleId}.`, ); throw new ForbiddenException(errorMsg); } const updatedRole = await this.prisma.role.update({ where: { id: roleId }, data: { name: dto.name ?? existingRole.name, icon: dto.icon ?? existingRole.icon, description: dto.description ?? existingRole.description, }, }); this.logger.info( `User role with ID ${roleId} updated by user ${currentUser.userId}`, ); return updatedRole; } /** * ASSIGN a user role to a user. * * This method creates an entry in the pivot table (UserOnRole) linking the role to the target user. * * @param roleId - The ID of the user role. * @param targetUserId - The ID of the user to assign the role to. * @param currentUser - The user making the request. * @param lang - The current language for translations. * @returns The created pivot record linking the user and the role. */ async assignRoleToUser( roleId: number, targetUserId: number, currentUser: any, lang: string, ) { this.logger.debug( `UserRolesService.assignRoleToUser invoked: roleId=${roleId}, targetUserId=${targetUserId} by user ${currentUser.userId}`, ); // Retrieve the role record. const role = await this.prisma.role.findUnique({ where: { id: roleId } }); if (!role) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.role_not_found', { lang, args: { roleId }, }), ]); this.logger.warn(`User role with ID ${roleId} not found.`); throw new NotFoundException(errorMsg); } // Ensure the role is indeed a user role. if (role.assignmentType !== 'USER' || role.OrgType !== 'CLIENT') { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_view_role', { lang }), ]); this.logger.warn(`Role with ID ${roleId} is not a valid user role.`); throw new BadRequestException(errorMsg); } // Verify that the target user exists. const user = await this.prisma.user.findUnique({ where: { id: targetUserId }, }); if (!user) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.role_not_found', { lang, args: { roleId: targetUserId }, }), ]); this.logger.warn(`Target user with ID ${targetUserId} not found.`); throw new NotFoundException(errorMsg); } // Create the pivot record linking the user and the role. const assignment = await this.prisma.userOnRole.create({ data: { userId: targetUserId, roleId: roleId, }, }); this.logger.info( `User role with ID ${roleId} assigned to user ${targetUserId} by user ${currentUser.userId}`, ); return assignment; } /** * REMOVE a user role from a user. * * This method deletes the corresponding record from the pivot table (UserOnRole) * linking the user to the role. * * @param roleId - The ID of the user role. * @param targetUserId - The ID of the user from whom the role will be removed. * @param currentUser - The user making the request. * @param lang - The current language for translations. * @returns A boolean indicating successful removal. */ async removeRoleFromUser( roleId: number, targetUserId: number, currentUser: any, lang: string, ) { this.logger.debug( `UserRolesService.removeRoleFromUser invoked: roleId=${roleId}, targetUserId=${targetUserId} by user ${currentUser.userId}`, ); // Check if the entry exists in the pivot table. const assignment = await this.prisma.userOnRole.findUnique({ where: { userId_roleId: { userId: targetUserId, roleId: roleId, }, }, }); if (!assignment) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.role_not_found', { lang, args: { roleId, userId: targetUserId }, }), ]); this.logger.warn( `No record found for user role with ID ${roleId} assigned to user ${targetUserId}.`, ); throw new NotFoundException(errorMsg); } // Remove the pivot record linking the user and the role. await this.prisma.userOnRole.delete({ where: { userId_roleId: { userId: targetUserId, roleId: roleId, }, }, }); this.logger.info( `User role with ID ${roleId} removed from user ${targetUserId} by user ${currentUser.userId}`, ); return true; } /** * DELETE a user role. * * This method deletes a user role from the roles table. * Before deletion, it: * - Checks for and removes any user assignments (from UserOnRole). * - Removes all permission assignments (from RoleOnPermission). * Then, it deletes the role itself. * * @param roleId - The ID of the user role to delete. * @param currentUser - The user making the request. * @param lang - The current language for translations. * @returns A boolean indicating successful deletion. */ async deleteUserRole(roleId: number, currentUser: any, lang: string) { this.logger.debug( `UserRolesService.deleteUserRole invoked for roleId=${roleId} by user ${currentUser.userId}`, ); // Retrieve the role. const role = await this.prisma.role.findUnique({ where: { id: roleId } }); if (!role) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.role_not_found', { lang, args: { roleId }, }), ]); this.logger.warn(`User role with ID ${roleId} not found.`); throw new NotFoundException(errorMsg); } // Verify it is a user role. if (role.assignmentType !== 'USER' || role.OrgType !== 'CLIENT') { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_view_role', { lang }), ]); this.logger.warn(`Role with ID ${roleId} is not a valid user role.`); throw new BadRequestException(errorMsg); } // Check for assigned users. const assignedUsers = await this.prisma.userOnRole.findMany({ where: { roleId }, }); if (assignedUsers.length > 0) { this.logger.info( `Found ${assignedUsers.length} user assignments for role ${roleId}. Removing them.`, ); // Remove all user assignments. await this.prisma.userOnRole.deleteMany({ where: { roleId } }); } // Remove all permission assignments for this role. await this.prisma.roleOnPermission.deleteMany({ where: { roleId } }); // Finally, delete the role record. await this.prisma.role.delete({ where: { id: roleId } }); this.logger.info(`User role with ID ${roleId} deleted successfully.`); return true; } /** * GET all user roles. * * This method retrieves all user roles from the roles table. * Get all roles where assignmentType is USER and OrgType is CLIENT. * * @returns An array of user roles. */ async getUserRoles(lang: string) { this.logger.debug(`UserRolesService.getUserRoles invoked.`); return this.prisma.role.findMany({ where: { assignmentType: 'USER', OrgType: 'CLIENT', }, }); } /** * GET a user role by ID. * * This method retrieves a user role by its ID. * The role must have assignmentType USER and OrgType CLIENT. * * @param roleId - The ID of the user role to retrieve. * @returns The user role record. */ async getUserRole(roleId: number, lang: string) { this.logger.debug( `UserRolesService.getUserRole invoked for roleId=${roleId}.`, ); const role = await this.prisma.role.findUnique({ where: { id: roleId } }); if (!role) { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.role_not_found', { lang, args: { roleId }, }), ]); this.logger.warn(`User role with ID ${roleId} not found.`); throw new NotFoundException(errorMsg); } if (role.assignmentType !== 'USER' || role.OrgType !== 'CLIENT') { const [errorMsg] = await Promise.all([ this.i18n.translate('roles.errors.forbidden_view_role', { lang }), ]); this.logger.warn(`Role with ID ${roleId} is not a valid user role.`); throw new BadRequestException(errorMsg); } return role; } } File: src/sample/yup.controller.ts import { Controller, Get, UseInterceptors } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { YupValidationInterceptor } from '../interceptors/yupvalidation.interceptor'; @Controller('sample') export class SampleYupController { constructor(private readonly i18n: I18nService) {} /** * GET /sample/yup/form * Returns a complete form schema with various field types. * When the request includes ?format=react (or header format/react), * the YupValidationInterceptor adds translated Yup validation rules. */ @Get('yup/form') @UseInterceptors(YupValidationInterceptor) async getSampleForm(@I18nLang() lang: string) { const formSchema = { sections: [ { id: 1, label: await this.i18n.translate('sample.form.section.general', { lang, }), }, { id: 2, label: await this.i18n.translate('sample.form.section.additional', { lang, }), }, ], fields: [ // Text field for first name { sectionId: 1, name: 'firstName', label: await this.i18n.translate('sample.form.firstName', { lang }), type: 'text', required: true, initialValue: '', }, // Text field for last name { sectionId: 1, name: 'lastName', label: await this.i18n.translate('sample.form.lastName', { lang }), type: 'text', required: true, initialValue: '', }, // Email field { sectionId: 1, name: 'email', label: await this.i18n.translate('sample.form.email', { lang }), type: 'email', required: true, initialValue: '', }, // Number field for age { sectionId: 1, name: 'age', label: await this.i18n.translate('sample.form.age', { lang }), type: 'number', required: false, initialValue: '25', }, // Password field { sectionId: 1, name: 'password', label: await this.i18n.translate('sample.form.password', { lang }), type: 'password', required: true, initialValue: '', }, // Dropdown / Select field for country (using your original country variables) { sectionId: 1, name: 'country', label: await this.i18n.translate('sample.form.country', { lang }), type: 'select', options: [ { label: await this.i18n.translate( 'sample.form.countryOptions.us', { lang }, ), value: 'us', }, { label: await this.i18n.translate( 'sample.form.countryOptions.fr', { lang }, ), value: 'fr', }, { label: await this.i18n.translate( 'sample.form.countryOptions.de', { lang }, ), value: 'de', }, { label: await this.i18n.translate( 'sample.form.countryOptions.pk', { lang }, ), value: 'pk', }, { label: await this.i18n.translate( 'sample.form.countryOptions.gb', { lang }, ), value: 'gb', }, ], required: true, initialValue: 'us', }, // Multiselect field for favorite colors { sectionId: 2, name: 'favoriteColors', label: await this.i18n.translate('sample.form.favoriteColors', { lang, }), type: 'multiselect', options: [ { label: await this.i18n.translate('sample.form.colors.red', { lang, }), value: 'red', }, { label: await this.i18n.translate('sample.form.colors.green', { lang, }), value: 'green', }, { label: await this.i18n.translate('sample.form.colors.blue', { lang, }), value: 'blue', }, ], required: false, // Initial value red and blue initialValue: ['red', 'blue'], }, // Single checkbox for newsletter subscription { sectionId: 2, name: 'subscribeNewsletter', label: await this.i18n.translate('sample.form.subscribeNewsletter', { lang, }), type: 'checkbox', required: true, initialValue: false, }, // Multiple checkboxes for interests { sectionId: 2, name: 'interests', label: await this.i18n.translate('sample.form.interests', { lang }), type: 'checkboxes', options: [ { label: await this.i18n.translate( 'sample.form.interestOptions.sports', { lang }, ), value: 'sports', }, { label: await this.i18n.translate( 'sample.form.interestOptions.music', { lang }, ), value: 'music', }, { label: await this.i18n.translate( 'sample.form.interestOptions.movies', { lang }, ), value: 'movies', }, ], required: true, // Initial value sports and movies initialValue: ['sports', 'movies'], }, // Radio buttons for gender selection { sectionId: 2, name: 'gender', label: await this.i18n.translate('sample.form.gender', { lang }), type: 'radio', options: [ { label: await this.i18n.translate( 'sample.form.genderOptions.male', { lang }, ), value: 'male', }, { label: await this.i18n.translate( 'sample.form.genderOptions.female', { lang }, ), value: 'female', }, ], required: true, initialValue: 'male', }, // Date field for birth date { sectionId: 2, name: 'birthDate', label: await this.i18n.translate('sample.form.birthDate', { lang }), type: 'date', required: false, initialValue: '', }, // Textarea field for additional comments { sectionId: 2, name: 'comments', label: await this.i18n.translate('sample.form.comments', { lang }), type: 'textarea', required: false, initialValue: '', }, // Hidden field for internal use { sectionId: 2, name: 'internalId', label: await this.i18n.translate('sample.form.internalId', { lang }), type: 'hidden', required: false, initialValue: '', }, // Switch field for notifications { sectionId: 2, name: 'notifications', label: await this.i18n.translate('sample.form.notifications', { lang, }), type: 'switch', required: false, initialValue: false, }, ], }; // Return the schema as a plain object (without wrapping in a "data" property) return { ...formSchema }; } } File: src/senders/dto/create-sender.dto.ts import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; import { SenderType } from '@prisma/client'; export class CreateSenderDto { @IsString() name: string; @IsEnum(SenderType) type: SenderType; // e.g., EMAIL, SMS, WHATSAPP, PUSH @IsBoolean() @IsOptional() isActive?: boolean = true; // config is a JSON field, can store anything; in practice, you'd have more typed approach // but we do a quick example @IsOptional() config?: any; } File: src/senders/dto/update-sender.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateSenderDto } from './create-sender.dto'; export class UpdateSenderDto extends PartialType(CreateSenderDto) {} File: src/senders/senders.controller.ts import { Body, Controller, Delete, ForbiddenException, Get, NotFoundException, Param, ParseIntPipe, Patch, Post, Req, UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { SendersService } from './senders.service'; import { CreateSenderDto } from './dto/create-sender.dto'; import { UpdateSenderDto } from './dto/update-sender.dto'; import { Permissions } from 'src/auth/permissions.decorator'; import { OrganizationGuard } from 'src/guards/organization.guard'; @UseGuards(OrganizationGuard) @Controller(':orgId/senders') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) export class SendersController { constructor( private readonly sendersService: SendersService, private readonly i18n: I18nService, ) {} /** * GET /:orgId/senders * => e.g. 'senders.read' */ @Get() @Permissions('senders.read.any') async findAll( @Param('orgId', ParseIntPipe) orgId: number, @Req() req: any, @I18nLang() lang: string, ) { // membership check const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('senders.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } // Example check if membership.permissions has 'senders.read.any' const data = await this.sendersService.findAll(orgId); const [msg] = await Promise.all([ this.i18n.translate('senders.success.list', { lang }), ]); return { message: msg, data }; } /** * GET /:orgId/senders/:id * => e.g. 'senders.read.any' */ @Get(':id') @Permissions('senders.read.any') async findOne( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) senderId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('senders.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const sender = await this.sendersService.findOne(orgId, senderId); if (!sender) { const [errorMsg] = await Promise.all([ this.i18n.translate('senders.errors.not_found', { lang, args: { id: senderId }, }), ]); throw new NotFoundException(errorMsg); } const [msg] = await Promise.all([ this.i18n.translate('senders.success.getOne', { lang }), ]); return { message: msg, data: sender }; } /** * POST /:orgId/senders/add * => e.g. 'senders.create' */ @Post('add') @Permissions('senders.create') async create( @Param('orgId', ParseIntPipe) orgId: number, @Body() dto: CreateSenderDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('senders.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const [created, msg] = await Promise.all([ this.sendersService.create(orgId, dto), this.i18n.translate('senders.success.created', { lang }), ]); return { message: msg, data: created }; } /** * PATCH /:orgId/senders/edit/:id * => e.g. 'senders.edit' */ @Patch('edit/:id') @Permissions('senders.edit.any') async update( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) senderId: number, @Body() dto: UpdateSenderDto, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('senders.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } const updated = await this.sendersService.update(orgId, senderId, dto); const [msg] = await Promise.all([ this.i18n.translate('senders.success.updated', { lang, args: { id: senderId }, }), ]); return { message: msg, data: updated }; } /** * DELETE /:orgId/senders/delete/:id * => e.g. 'senders.delete.any' */ @Delete('delete/:id') @Permissions('senders.delete.any') async remove( @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseIntPipe) senderId: number, @Req() req: any, @I18nLang() lang: string, ) { const membership = req.orgMembership; if (!membership) { const [errorMsg] = await Promise.all([ this.i18n.translate('senders.errors.no_membership', { lang }), ]); throw new ForbiddenException(errorMsg); } await this.sendersService.remove(orgId, senderId); const [msg] = await Promise.all([ this.i18n.translate('senders.success.deleted', { lang, args: { id: senderId }, }), ]); return { message: msg }; } } File: src/senders/senders.module.ts import { Module } from '@nestjs/common'; import { SendersService } from './senders.service'; import { SendersController } from './senders.controller'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; @Module({ controllers: [SendersController], providers: [SendersService, PrismaService, NexusLoggerService], exports: [SendersService], }) export class SendersModule {} File: src/senders/senders.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { CreateSenderDto } from './dto/create-sender.dto'; import { UpdateSenderDto } from './dto/update-sender.dto'; @Injectable() export class SendersService { constructor( private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, ) {} async findOne(orgId: number, senderId: number) { return this.prisma.sender.findFirst({ where: { id: senderId, organizationId: orgId, }, }); } async findAll(orgId: number) { return this.prisma.sender.findMany({ where: { organizationId: orgId }, orderBy: { createdAt: 'desc' }, }); } async create(orgId: number, dto: CreateSenderDto) { const created = await this.prisma.sender.create({ data: { organizationId: orgId, name: dto.name, type: dto.type, isActive: dto.isActive ?? true, config: dto.config ?? {}, }, }); this.logger.info(`Sender #${created.id} created in org #${orgId}.`); return created; } async update(orgId: number, senderId: number, dto: UpdateSenderDto) { const existing = await this.findOne(orgId, senderId); if (!existing) { throw new NotFoundException( `Sender #${senderId} not found in org #${orgId}.`, ); } // Example of preventing changes if the sender is used in active campaigns, etc. const updated = await this.prisma.sender.update({ where: { id: senderId }, data: { name: dto.name ?? existing.name, type: dto.type ?? existing.type, isActive: dto.isActive === undefined ? existing.isActive : dto.isActive, config: dto.config ?? existing.config, }, }); this.logger.info(`Sender #${senderId} updated in org #${orgId}.`); return updated; } async remove(orgId: number, senderId: number) { const existing = await this.findOne(orgId, senderId); if (!existing) { throw new NotFoundException( `Sender #${senderId} not found in org #${orgId}.`, ); } // For safety, check if this sender is used by any ongoing campaign // If so, you might throw a BadRequestException. await this.prisma.sender.delete({ where: { id: senderId } }); this.logger.success(`Sender #${senderId} removed from org #${orgId}.`); return true; } } File: src/shared/constants/countries.ts export const COUNTRIES = [ { label: 'Afghanistan', value: 'AF' }, { label: 'Albania', value: 'AL' }, { label: 'Algeria', value: 'DZ' }, { label: 'Andorra', value: 'AD' }, { label: 'Angola', value: 'AO' }, { label: 'Antigua and Barbuda', value: 'AG' }, { label: 'Argentina', value: 'AR' }, { label: 'Armenia', value: 'AM' }, { label: 'Australia', value: 'AU' }, { label: 'Austria', value: 'AT' }, { label: 'Azerbaijan', value: 'AZ' }, { label: 'Bahamas', value: 'BS' }, { label: 'Bahrain', value: 'BH' }, { label: 'Bangladesh', value: 'BD' }, { label: 'Barbados', value: 'BB' }, { label: 'Belarus', value: 'BY' }, { label: 'Belgium', value: 'BE' }, { label: 'Belize', value: 'BZ' }, { label: 'Benin', value: 'BJ' }, { label: 'Bhutan', value: 'BT' }, { label: 'Bolivia', value: 'BO' }, { label: 'Bosnia and Herzegovina', value: 'BA' }, { label: 'Botswana', value: 'BW' }, { label: 'Brazil', value: 'BR' }, { label: 'Brunei', value: 'BN' }, { label: 'Bulgaria', value: 'BG' }, { label: 'Burkina Faso', value: 'BF' }, { label: 'Burundi', value: 'BI' }, { label: "Côte d'Ivoire", value: 'CI' }, { label: 'Cabo Verde', value: 'CV' }, { label: 'Cambodia', value: 'KH' }, { label: 'Cameroon', value: 'CM' }, { label: 'Canada', value: 'CA' }, { label: 'Central African Republic', value: 'CF' }, { label: 'Chad', value: 'TD' }, { label: 'Chile', value: 'CL' }, { label: 'China', value: 'CN' }, { label: 'Colombia', value: 'CO' }, { label: 'Comoros', value: 'KM' }, { label: 'Congo (Congo-Brazzaville)', value: 'CG' }, { label: 'Costa Rica', value: 'CR' }, { label: 'Croatia', value: 'HR' }, { label: 'Cuba', value: 'CU' }, { label: 'Cyprus', value: 'CY' }, { label: 'Czechia (Czech Republic)', value: 'CZ' }, { label: 'Democratic Republic of the Congo', value: 'CD' }, { label: 'Denmark', value: 'DK' }, { label: 'Djibouti', value: 'DJ' }, { label: 'Dominica', value: 'DM' }, { label: 'Dominican Republic', value: 'DO' }, { label: 'Ecuador', value: 'EC' }, { label: 'Egypt', value: 'EG' }, { label: 'El Salvador', value: 'SV' }, { label: 'Equatorial Guinea', value: 'GQ' }, { label: 'Eritrea', value: 'ER' }, { label: 'Estonia', value: 'EE' }, { label: 'Eswatini (Swaziland)', value: 'SZ' }, { label: 'Ethiopia', value: 'ET' }, { label: 'Fiji', value: 'FJ' }, { label: 'Finland', value: 'FI' }, { label: 'France', value: 'FR' }, { label: 'Gabon', value: 'GA' }, { label: 'Gambia', value: 'GM' }, { label: 'Georgia', value: 'GE' }, { label: 'Germany', value: 'DE' }, { label: 'Ghana', value: 'GH' }, { label: 'Greece', value: 'GR' }, { label: 'Grenada', value: 'GD' }, { label: 'Guatemala', value: 'GT' }, { label: 'Guinea', value: 'GN' }, { label: 'Guinea-Bissau', value: 'GW' }, { label: 'Guyana', value: 'GY' }, { label: 'Haiti', value: 'HT' }, { label: 'Holy See', value: 'VA' }, { label: 'Honduras', value: 'HN' }, { label: 'Hungary', value: 'HU' }, { label: 'Iceland', value: 'IS' }, { label: 'India', value: 'IN' }, { label: 'Indonesia', value: 'ID' }, { label: 'Iran', value: 'IR' }, { label: 'Iraq', value: 'IQ' }, { label: 'Ireland', value: 'IE' }, { label: 'Israel', value: 'IL' }, { label: 'Italy', value: 'IT' }, { label: 'Jamaica', value: 'JM' }, { label: 'Japan', value: 'JP' }, { label: 'Jordan', value: 'JO' }, { label: 'Kazakhstan', value: 'KZ' }, { label: 'Kenya', value: 'KE' }, { label: 'Kiribati', value: 'KI' }, { label: 'Kuwait', value: 'KW' }, { label: 'Kyrgyzstan', value: 'KG' }, { label: 'Laos', value: 'LA' }, { label: 'Latvia', value: 'LV' }, { label: 'Lebanon', value: 'LB' }, { label: 'Lesotho', value: 'LS' }, { label: 'Liberia', value: 'LR' }, { label: 'Libya', value: 'LY' }, { label: 'Liechtenstein', value: 'LI' }, { label: 'Lithuania', value: 'LT' }, { label: 'Luxembourg', value: 'LU' }, { label: 'Madagascar', value: 'MG' }, { label: 'Malawi', value: 'MW' }, { label: 'Malaysia', value: 'MY' }, { label: 'Maldives', value: 'MV' }, { label: 'Mali', value: 'ML' }, { label: 'Malta', value: 'MT' }, { label: 'Marshall Islands', value: 'MH' }, { label: 'Mauritania', value: 'MR' }, { label: 'Mauritius', value: 'MU' }, { label: 'Mexico', value: 'MX' }, { label: 'Micronesia', value: 'FM' }, { label: 'Moldova', value: 'MD' }, { label: 'Monaco', value: 'MC' }, { label: 'Mongolia', value: 'MN' }, { label: 'Montenegro', value: 'ME' }, { label: 'Morocco', value: 'MA' }, { label: 'Mozambique', value: 'MZ' }, { label: 'Myanmar (Burma)', value: 'MM' }, { label: 'Namibia', value: 'NA' }, { label: 'Nauru', value: 'NR' }, { label: 'Nepal', value: 'NP' }, { label: 'Netherlands', value: 'NL' }, { label: 'New Zealand', value: 'NZ' }, { label: 'Nicaragua', value: 'NI' }, { label: 'Niger', value: 'NE' }, { label: 'Nigeria', value: 'NG' }, { label: 'North Korea', value: 'KP' }, { label: 'North Macedonia', value: 'MK' }, { label: 'Norway', value: 'NO' }, { label: 'Oman', value: 'OM' }, { label: 'Pakistan', value: 'PK' }, { label: 'Palau', value: 'PW' }, { label: 'Palestine', value: 'PS' }, { label: 'Panama', value: 'PA' }, { label: 'Papua New Guinea', value: 'PG' }, { label: 'Paraguay', value: 'PY' }, { label: 'Peru', value: 'PE' }, { label: 'Philippines', value: 'PH' }, { label: 'Poland', value: 'PL' }, { label: 'Portugal', value: 'PT' }, { label: 'Qatar', value: 'QA' }, { label: 'Romania', value: 'RO' }, { label: 'Russia', value: 'RU' }, { label: 'Rwanda', value: 'RW' }, { label: 'Saint Kitts and Nevis', value: 'KN' }, { label: 'Saint Lucia', value: 'LC' }, { label: 'Saint Vincent and the Grenadines', value: 'VC' }, { label: 'Samoa', value: 'WS' }, { label: 'San Marino', value: 'SM' }, { label: 'Sao Tome and Principe', value: 'ST' }, { label: 'Saudi Arabia', value: 'SA' }, { label: 'Senegal', value: 'SN' }, { label: 'Serbia', value: 'RS' }, { label: 'Seychelles', value: 'SC' }, { label: 'Sierra Leone', value: 'SL' }, { label: 'Singapore', value: 'SG' }, { label: 'Slovakia', value: 'SK' }, { label: 'Slovenia', value: 'SI' }, { label: 'Solomon Islands', value: 'SB' }, { label: 'Somalia', value: 'SO' }, { label: 'South Africa', value: 'ZA' }, { label: 'South Korea', value: 'KR' }, { label: 'South Sudan', value: 'SS' }, { label: 'Spain', value: 'ES' }, { label: 'Sri Lanka', value: 'LK' }, { label: 'Sudan', value: 'SD' }, { label: 'Suriname', value: 'SR' }, { label: 'Sweden', value: 'SE' }, { label: 'Switzerland', value: 'CH' }, { label: 'Syria', value: 'SY' }, { label: 'Tajikistan', value: 'TJ' }, { label: 'Tanzania', value: 'TZ' }, { label: 'Thailand', value: 'TH' }, { label: 'Timor-Leste', value: 'TL' }, { label: 'Togo', value: 'TG' }, { label: 'Tonga', value: 'TO' }, { label: 'Trinidad and Tobago', value: 'TT' }, { label: 'Tunisia', value: 'TN' }, { label: 'Turkey', value: 'TR' }, { label: 'Turkmenistan', value: 'TM' }, { label: 'Tuvalu', value: 'TV' }, { label: 'Uganda', value: 'UG' }, { label: 'Ukraine', value: 'UA' }, { label: 'United Arab Emirates', value: 'AE' }, { label: 'United Kingdom', value: 'GB' }, { label: 'United States of America', value: 'US' }, { label: 'Uruguay', value: 'UY' }, { label: 'Uzbekistan', value: 'UZ' }, { label: 'Vanuatu', value: 'VU' }, { label: 'Venezuela', value: 'VE' }, { label: 'Vietnam', value: 'VN' }, { label: 'Yemen', value: 'YE' }, { label: 'Zambia', value: 'ZM' }, { label: 'Zimbabwe', value: 'ZW' }, ]; File: src/users/dto/create-user.dto.spec.ts import { validate } from 'class-validator'; import { CreateUserDto } from './create-user.dto'; import { UserStatus, UserTypes } from '@prisma/client'; describe('CreateUserDto', () => { // Test: Validate a correct DTO it('should validate a correct DTO', async () => { const dto = new CreateUserDto(); dto.email = 'test@mumara.com'; dto.password = 'StrongP@ssw0rd'; dto.firstName = 'John'; dto.lastName = 'Doe'; dto.status = UserStatus.ACTIVE; dto.userType = UserTypes.CLIENT; const errors = await validate(dto); expect(errors.length).toBe(0); }); // Test: Check if email is not valid it('should fail if email is not valid', async () => { const dto = new CreateUserDto(); dto.email = 'invalid-email'; dto.password = 'StrongP@ssw0rd'; const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); expect(errors[0].constraints).toHaveProperty('isEmail'); }); // Test: Check if password is empty it('should fail if password is empty', async () => { const dto = new CreateUserDto(); dto.email = 'test@mumara.com'; dto.password = ''; const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); expect(errors[0].constraints).toHaveProperty('isNotEmpty'); }); // Test: Check if status is not valid it('should fail if status is not valid', async () => { const dto = new CreateUserDto(); dto.email = 'test@mumara.com'; dto.password = 'StrongP@ssw0rd'; dto.status = 'INVALID' as UserStatus; const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); expect(errors[0].constraints).toHaveProperty('isEnum'); }); }); File: src/users/dto/create-user.dto.ts import { IsEmail, IsEnum, IsOptional, IsString, IsBoolean, IsDateString, IsNotEmpty, } from 'class-validator'; import { UserTypes, UserStatus } from '@prisma/client'; /** * CreateUserDto * * A sample that includes the fields from your Prisma `User` model. * Fields that are typically system-managed (like lastLoginAt) are optional. */ export class CreateUserDto { @IsEmail() email: string; @IsString() @IsNotEmpty() password: string; // Plain-text, will be hashed in the service. @IsString() @IsOptional() firstName?: string; @IsString() @IsOptional() lastName?: string; @IsEnum(UserStatus) @IsOptional() status?: UserStatus; // default=ACTIVE in the model @IsEnum(UserTypes) @IsOptional() userType?: UserTypes; // default=CLIENT @IsDateString() @IsOptional() lastLoginAt?: Date; @IsString() @IsOptional() regIpAddress?: string; @IsString() @IsOptional() lastLoginIpAddress?: string; @IsString() @IsOptional() timezone?: string; @IsString() @IsOptional() preferredLanguage?: string; @IsBoolean() @IsOptional() acceptsMarketing?: boolean; // default=true @IsBoolean() @IsOptional() emailVerified?: boolean; // default=false @IsString() @IsOptional() verificationToken?: string; @IsString() @IsOptional() passwordResetToken?: string; } File: src/users/dto/update-user.dto.spec.ts import { validate } from 'class-validator'; import { UpdateUserDto } from './update-user.dto'; describe('UpdateUserDto', () => { it('should allow an empty object since all fields are optional', async () => { const dto = new UpdateUserDto(); const errors = await validate(dto); expect(errors.length).toBe(0); }); it('should fail if provided email is not valid', async () => { const dto = new UpdateUserDto(); dto.email = 'invalid-email'; const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); expect(errors[0].constraints).toHaveProperty('isEmail'); }); }); File: src/users/dto/update-user.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateUserDto } from './create-user.dto'; /** * UpdateUserDto * * Inherits from CreateUserDto and makes all fields optional. * You can still omit certain fields if you want them to never be updated by the client * (e.g. "regIpAddress" might be set only once). */ export class UpdateUserDto extends PartialType(CreateUserDto) { // All fields are optional from CreateUserDto. // Example: You might remove 'email' from here if you do NOT want it to be updated. } File: src/users/user-roles.controller.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { UserRolesController } from './user-roles.controller'; import { PrismaService } from '../prisma/prisma.service'; import { I18nService } from 'nestjs-i18n'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { UserGuard } from 'src/guards/user.guard'; // Import the guard to override it import { NotFoundException } from '@nestjs/common'; import { AssignmentType } from '@prisma/client'; describe('UserRolesController', () => { let module: TestingModule; let controller: UserRolesController; let prisma: PrismaService; beforeEach(async () => { module = await Test.createTestingModule({ controllers: [UserRolesController], providers: [ { provide: PrismaService, useValue: { userOnRole: { findMany: jest.fn(), create: jest.fn(), findUnique: jest.fn(), delete: jest.fn(), }, role: { findUnique: jest.fn() }, }, }, { provide: I18nService, useValue: { translate: jest.fn().mockResolvedValue('Success') }, }, { provide: NexusLoggerService, useValue: { info: jest.fn(), warn: jest.fn(), success: jest.fn(), debug: jest.fn(), }, }, ], }) // Override the UserGuard so it always allows access .overrideGuard(UserGuard) .useValue({ canActivate: () => true }) .compile(); controller = module.get(UserRolesController); prisma = module.get(PrismaService); }); afterAll(async () => { if (module) await module.close(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); describe('listUserRoles', () => { it('should return assigned user roles', async () => { const req = { user: { userId: 3 } }; const roles = [{ role: { id: 4, name: 'OwnUserRole' } }]; (prisma.userOnRole.findMany as jest.Mock).mockResolvedValue(roles); const response = await controller.listUserRoles(3, req, 'en'); expect(response.data).toEqual(roles); }); }); describe('assignRoleToUser', () => { it('should assign a role to a user', async () => { const dto = { roleId: 4 }; const req = { user: { userId: 1 } }; // Simulate a valid role lookup (prisma.role.findUnique as jest.Mock).mockResolvedValue({ id: 4, assignmentType: AssignmentType.USER, OrgType: 'CLIENT', }); (prisma.userOnRole.findUnique as jest.Mock).mockResolvedValue(null); (prisma.userOnRole.create as jest.Mock).mockResolvedValue({ userId: 2, roleId: 4, }); const response = await controller.assignRoleToUser(2, dto, 'en'); expect(response).toEqual({ message: 'Success' }); }); it('should throw NotFoundException if role not found', async () => { const dto = { roleId: 999 }; (prisma.role.findUnique as jest.Mock).mockResolvedValue(null); await expect(controller.assignRoleToUser(2, dto, 'en')).rejects.toThrow( NotFoundException, ); }); }); describe('removeUserRole', () => { it('should remove a user role from a user', async () => { (prisma.userOnRole.findUnique as jest.Mock).mockResolvedValue({ userId: 2, roleId: 4, }); (prisma.userOnRole.delete as jest.Mock).mockResolvedValue(true); const response = await controller.removeUserRole(2, 4, 'en'); expect(response).toEqual({ message: 'Success' }); }); it('should throw NotFoundException if role not assigned', async () => { (prisma.userOnRole.findUnique as jest.Mock).mockResolvedValue(null); await expect(controller.removeUserRole(2, 4, 'en')).rejects.toThrow( NotFoundException, ); }); }); }); File: src/users/user-roles.controller.ts import { Controller, Get, Post, Delete, Param, Body, ParseIntPipe, NotFoundException, BadRequestException, Req, UseGuards, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { Permissions } from '../auth/permissions.decorator'; import { UserGuard } from '../guards/user.guard'; import { AssignmentType } from '@prisma/client'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { NexusLoggerService } from '../logger/nexus-logger.service'; /** * AssignUserRoleDto * - Simple DTO for assigning a role by ID to a user. */ class AssignUserRoleDto { roleId: number; } /** * UserRolesController * * - Route prefix: /users/:userId/roles * - Protected by UserGuard, which ensures: * - "own" access if userId == requesterUserId + has ".own" perms * - "cross-user" access if user is PLATFORM + has ".any" perms * * - We'll use some route-level permissions (like 'users.edit.any') or define new ones. */ @Controller('users/:userId/roles') @UseGuards(UserGuard) export class UserRolesController { constructor( private readonly prisma: PrismaService, private readonly i18n: I18nService, private readonly logger: NexusLoggerService, ) {} /** * GET /users/:userId/roles * * For listing roles assigned to a specific user. * Typically, you'd require 'users.read.any' to read someone else's user roles, * or 'users.read.own' if it's yourself. But we keep it simple with .any below. */ @Get() @Permissions('users.read.any', 'users.read.own') async listUserRoles( @Param('userId', ParseIntPipe) targetUserId: number, @Req() req: any, @I18nLang() lang: string, ) { // The UserGuard handles whether cross-user is allowed. const [userOnRoles, msg] = await Promise.all([ this.prisma.userOnRole.findMany({ where: { userId: targetUserId }, include: { role: { include: { permissions: { include: { permission: true } }, }, }, }, }), this.i18n.translate('users.roles.success.list', { lang, args: { userId: targetUserId }, }), ]); this.logger.info(`Retrieved roles for user #${targetUserId}`); return { message: msg, data: userOnRoles }; } /** * POST /users/:userId/roles * - Assign a new role to the user * - Typically you'd require 'users.edit.any' if it's cross-user * or 'users.edit.own' if it's themselves. */ @Post() @Permissions('users.edit.any', 'users.edit.own') async assignRoleToUser( @Param('userId', ParseIntPipe) targetUserId: number, @Body() dto: AssignUserRoleDto, @I18nLang() lang: string, ) { // 1) Check if role is valid const [role] = await Promise.all([ this.prisma.role.findUnique({ where: { id: dto.roleId }, }), ]); if (!role) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.roles.errors.role_not_found', { lang, args: { roleId: dto.roleId }, }), ]); throw new NotFoundException(errorMsg); } // 2) The role must be assignmentType=USER if you want it to be user-based if (role.assignmentType !== AssignmentType.USER) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.roles.errors.invalid_role_type', { lang, args: { roleId: dto.roleId }, }), ]); throw new BadRequestException(errorMsg); } // 3) Check if pivot already exists const existing = await this.prisma.userOnRole.findUnique({ where: { userId_roleId: { userId: targetUserId, roleId: dto.roleId } }, }); if (existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.roles.errors.role_already_assigned', { lang, args: { roleId: dto.roleId, userId: targetUserId }, }), ]); throw new BadRequestException(errorMsg); } // 4) Create pivot await this.prisma.userOnRole.create({ data: { userId: targetUserId, roleId: dto.roleId, }, }); const [msg] = await Promise.all([ this.i18n.translate('users.roles.success.assigned', { lang, args: { roleId: dto.roleId, userId: targetUserId }, }), ]); return { message: msg }; } /** * DELETE /users/:userId/roles/:roleId * - Remove a user-based role pivot * - Typically 'users.edit.any' or 'users.edit.own' */ @Delete(':roleId') @Permissions('users.edit.any', 'users.edit.own') async removeUserRole( @Param('userId', ParseIntPipe) targetUserId: number, @Param('roleId', ParseIntPipe) roleId: number, @I18nLang() lang: string, ) { // Check pivot const pivot = await this.prisma.userOnRole.findUnique({ where: { userId_roleId: { userId: targetUserId, roleId } }, }); if (!pivot) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.roles.errors.role_not_assigned', { lang, args: { roleId, userId: targetUserId }, }), ]); throw new NotFoundException(errorMsg); } // Remove pivot await this.prisma.userOnRole.delete({ where: { userId_roleId: { userId: targetUserId, roleId } }, }); const [msg] = await Promise.all([ this.i18n.translate('users.roles.success.removed', { lang, args: { roleId, userId: targetUserId }, }), ]); return { message: msg }; } } File: src/users/users.controller.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { I18nService } from 'nestjs-i18n'; import { UserGuard } from 'src/guards/user.guard'; import { ForbiddenException, NotFoundException, BadRequestException, } from '@nestjs/common'; describe('UsersController', () => { let module: TestingModule; let controller: UsersController; const mockUsersService = { findAll: jest.fn(), findOne: jest.fn(), createUser: jest.fn(), updateUser: jest.fn(), deleteUser: jest.fn(), }; const mockI18nService = { translate: jest.fn().mockResolvedValue('Translated message'), }; beforeEach(async () => { module = await Test.createTestingModule({ controllers: [UsersController], providers: [ { provide: UsersService, useValue: mockUsersService }, { provide: I18nService, useValue: mockI18nService }, ], }) .overrideGuard(UserGuard) .useValue({ canActivate: () => true }) .compile(); controller = module.get(UsersController); jest.clearAllMocks(); }); afterAll(async () => { if (module) await module.close(); }); describe('findAll', () => { it('should return all users if requester has users.read.any', async () => { const req = { user: { userId: 1, combinedPerms: new Set(['users.read.any']) }, }; const lang = 'en'; const users = [{ id: 1, email: 'test@example.com' }]; mockUsersService.findAll.mockResolvedValue(users); const result = await controller.findAll(req, lang); expect(mockUsersService.findAll).toHaveBeenCalledWith( false, req.user.userId, lang, ); expect(result).toEqual({ message: 'Translated message', data: users }); }); it('should return own user record if requester has only users.read.own', async () => { const req = { user: { userId: 1, combinedPerms: new Set(['users.read.own']) }, }; const lang = 'en'; const users = [{ id: 1, email: 'self@example.com' }]; mockUsersService.findAll.mockResolvedValue(users); const result = await controller.findAll(req, lang); expect(mockUsersService.findAll).toHaveBeenCalledWith( true, req.user.userId, lang, ); expect(result).toEqual({ message: 'Translated message', data: users }); }); it('should throw ForbiddenException if requester has no read permissions', async () => { const req = { user: { userId: 1, combinedPerms: new Set() } }; const lang = 'en'; await expect(controller.findAll(req, lang)).rejects.toThrow( ForbiddenException, ); }); }); describe('findOne', () => { it('should return a user if found and permitted', async () => { const req = { user: { userId: 1, combinedPerms: new Set(['users.read.any']) }, }; const lang = 'en'; const user = { id: 2, email: 'user2@example.com' }; mockUsersService.findOne.mockResolvedValue(user); const result = await controller.findOne(2, req, lang); expect(mockUsersService.findOne).toHaveBeenCalledWith( 2, true, false, req.user.userId, lang, ); expect(result).toEqual({ message: 'Translated message', data: user }); }); it('should throw NotFoundException if the user is not found', async () => { const req = { user: { userId: 1, combinedPerms: new Set(['users.read.any']) }, }; const lang = 'en'; mockUsersService.findOne.mockRejectedValue(new NotFoundException()); await expect(controller.findOne(999, req, lang)).rejects.toThrow( NotFoundException, ); }); }); describe('createUser', () => { it('should create a user and return it', async () => { const req = { user: { userId: 10 } }; const lang = 'en'; const dto = { email: 'new@example.com', password: 'password', firstName: 'New', lastName: 'User', }; const createdUser = { id: 100, email: dto.email }; mockUsersService.createUser.mockResolvedValue(createdUser); const result = await controller.createUser(dto, req, lang); expect(mockUsersService.createUser).toHaveBeenCalledWith( dto, req.user.userId || 0, lang, ); expect(result).toEqual({ message: 'Translated message', data: createdUser, }); }); }); describe('updateUser', () => { it('should update and return the user', async () => { const req = { user: { userId: 1, combinedPerms: new Set(['users.edit.any']) }, }; const lang = 'en'; const dto = { firstName: 'Updated' }; const updatedUser = { id: 2, firstName: 'Updated' }; mockUsersService.updateUser.mockResolvedValue(updatedUser); const result = await controller.updateUser(2, dto, req, lang); expect(mockUsersService.updateUser).toHaveBeenCalledWith( 2, dto, true, false, req.user.userId, lang, ); expect(result).toEqual({ message: 'Translated message', data: updatedUser, }); }); }); describe('removeUser', () => { it('should delete the user and return a message', async () => { const req = { user: { userId: 1, combinedPerms: new Set(['users.delete.any']) }, }; const lang = 'en'; mockUsersService.deleteUser.mockResolvedValue(true); const result = await controller.removeUser( 2, undefined, undefined, req, lang, ); expect(mockUsersService.deleteUser).toHaveBeenCalledWith( 2, true, false, req.user.userId, undefined, 0, lang, ); expect(result).toEqual({ message: 'Translated message' }); }); it('should throw BadRequestException for an invalid migrateToUserId', async () => { const req = { user: { userId: 1, combinedPerms: new Set(['users.delete.any']) }, }; const lang = 'en'; await expect( controller.removeUser(2, 'invalid', undefined, req, lang), ).rejects.toThrow(BadRequestException); }); }); }); File: src/users/users.controller.ts import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe, Req, Query, ForbiddenException, BadRequestException, UsePipes, UseGuards, ValidationPipe, } from '@nestjs/common'; import { I18nLang, I18nService } from 'nestjs-i18n'; import { UsersService } from './users.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { Permissions } from '../auth/permissions.decorator'; import { Public } from '../auth/public.decorator'; import { UserGuard } from '../guards/user.guard'; /** * UsersController: * This controller exposes endpoints for user management (create, read, update, delete). * Each endpoint is decorated with required permissions and uses UserGuard to enforce access control. */ @Controller('users') @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) export class UsersController { constructor( private readonly usersService: UsersService, private readonly i18n: I18nService, ) {} /** * GET /users * - If the requesting user has 'users.read.any', return all users. * - If they have 'users.read.own', return only their own user record. */ @Get() @UseGuards(UserGuard) @Permissions('users.read.any', 'users.read.own') async findAll(@Req() req: any, @I18nLang() lang: string) { // Retrieve the union of permissions computed by the guard. const userPerms = (req.user?.combinedPerms as Set) ?? new Set(); const canReadAny = userPerms.has('users.read.any'); const canReadOwn = userPerms.has('users.read.own'); if (canReadAny) { const [users, msg] = await Promise.all([ this.usersService.findAll(false, req.user.userId, lang), this.i18n.translate('users.success.list', { lang }), ]); return { message: msg, data: users }; } else if (canReadOwn) { const [users, msg] = await Promise.all([ this.usersService.findAll(true, req.user.userId, lang), this.i18n.translate('users.success.list', { lang }), ]); return { message: msg, data: users }; } else { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.forbidden_read', { lang }), ]); throw new ForbiddenException(errorMsg); } } /** * GET /users/:id * Retrieves details of a specific user. */ @Get(':id') @UseGuards(UserGuard) @Permissions('users.read.any', 'users.read.own') async findOne( @Param('id', ParseIntPipe) targetUserId: number, @Req() req: any, @I18nLang() lang: string, ) { const userPerms = (req.user?.combinedPerms as Set) ?? new Set(); const canReadAny = userPerms.has('users.read.any'); const canReadOwn = userPerms.has('users.read.own'); const user = await this.usersService.findOne( targetUserId, canReadAny, canReadOwn, req.user.userId, lang, ); const [msg] = await Promise.all([ this.i18n.translate('users.success.getOne', { lang, args: { id: targetUserId }, }), ]); return { message: msg, data: user }; } /** * POST /users/add * Creates a new user. * This endpoint is public for demonstration purposes (though typically you'd protect it). */ @Post('add') @Public() async createUser( @Body() dto: CreateUserDto, @Req() req: any, @I18nLang() lang: string, ) { // If available, use the authenticated user's ID as the creator. const creatorId = req.user?.userId || 0; const user = await this.usersService.createUser(dto, creatorId, lang); const [msg] = await Promise.all([ this.i18n.translate('users.success.created', { lang }), ]); return { message: msg, data: user }; } /** * PATCH /users/edit/:id * Updates an existing user's details. * Requires either 'users.edit.any' (to edit any user) or 'users.edit.own' (to edit the requester’s own record). */ @Patch('edit/:id') @UseGuards(UserGuard) @Permissions('users.edit.any', 'users.edit.own') async updateUser( @Param('id', ParseIntPipe) targetUserId: number, @Body() dto: UpdateUserDto, @Req() req: any, @I18nLang() lang: string, ) { const userPerms = (req.user?.combinedPerms as Set) ?? new Set(); const canEditAny = userPerms.has('users.edit.any'); const canEditOwn = userPerms.has('users.edit.own'); const updated = await this.usersService.updateUser( targetUserId, dto, canEditAny, canEditOwn, req.user.userId, lang, ); const [msg] = await Promise.all([ this.i18n.translate('users.success.updated', { lang, args: { id: targetUserId }, }), ]); return { message: msg, data: updated }; } /** * DELETE /users/delete/:id * Deletes a user. * * Additional behavior: * - If the query parameter force=1 is passed, the service forcibly deletes memberships, * user_on_roles entries, and other dependencies. * - Otherwise, if migrateToUserId is provided, assets are migrated. * - If neither is provided and dependencies exist, an error is thrown. */ @Delete('delete/:id') @UseGuards(UserGuard) @Permissions('users.delete.any', 'users.delete.own') async removeUser( @Param('id', ParseIntPipe) targetUserId: number, @Query('migrateToUserId') migrateToUserId: string | undefined, @Query('force') force: string | undefined, @Req() req: any, @I18nLang() lang: string, ) { const userPerms = (req.user?.combinedPerms as Set) ?? new Set(); const canDeleteAny = userPerms.has('users.delete.any'); const canDeleteOwn = userPerms.has('users.delete.own'); let migrateId: number | undefined; if (migrateToUserId) { const parsed = parseInt(migrateToUserId, 10); if (isNaN(parsed)) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.invalid_migrate_param', { lang }), ]); throw new BadRequestException(errorMsg); } migrateId = parsed; } const forceFlag = force ? parseInt(force, 10) : 0; await this.usersService.deleteUser( targetUserId, canDeleteAny, canDeleteOwn, req.user.userId, migrateId, forceFlag, lang, ); const [msg] = await Promise.all([ this.i18n.translate('users.success.deleted', { lang, args: { id: targetUserId }, }), ]); return { message: msg }; } } File: src/users/users.module.ts import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UserRolesController } from './user-roles.controller'; import { UsersService } from './users.service'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; @Module({ controllers: [UsersController, UserRolesController], providers: [UsersService, PrismaService, NexusLoggerService], exports: [UsersService], }) export class UsersModule {} File: src/users/users.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { UsersService } from './users.service'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { I18nService } from 'nestjs-i18n'; import { BadRequestException } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { UserStatus, UserTypes } from '@prisma/client'; const mockPrismaService = { user: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), }, userOnRole: { create: jest.fn(), deleteMany: jest.fn(), findUnique: jest.fn(), }, membership: { findMany: jest.fn(), deleteMany: jest.fn(), }, list: { count: jest.fn(), updateMany: jest.fn(), }, contact: { count: jest.fn(), updateMany: jest.fn(), }, group: { count: jest.fn(), updateMany: jest.fn(), }, apiKey: { count: jest.fn(), updateMany: jest.fn(), }, }; const mockLogger = { info: jest.fn(), success: jest.fn(), warn: jest.fn(), }; const mockI18nService = { translate: jest.fn().mockResolvedValue(['Translated message']), }; describe('UsersService', () => { let module: TestingModule; let service: UsersService; let prisma: PrismaService; beforeEach(async () => { module = await Test.createTestingModule({ providers: [ UsersService, { provide: PrismaService, useValue: mockPrismaService }, { provide: NexusLoggerService, useValue: mockLogger }, { provide: I18nService, useValue: mockI18nService }, ], }).compile(); service = module.get(UsersService); prisma = module.get(PrismaService); jest.clearAllMocks(); }); afterAll(async () => { if (module) await module.close(); }); describe('createUser', () => { it('should create a new user account when email is not taken', async () => { const dto = { email: 'test@example.com', password: 'Test1234!', firstName: 'John', lastName: 'Doe', status: UserStatus.ACTIVE, userType: UserTypes.CLIENT, lastLoginAt: new Date(), regIpAddress: '103.181.98.1', lastLoginIpAddress: '103.181.98.3', timezone: 'UTC', preferredLanguage: 'en', acceptsMarketing: true, emailVerified: false, verificationToken: 'verificationToken', passwordResetToken: 'passwordResetToken', }; const creatorId = 99; const lang = 'en'; (prisma.user.findUnique as jest.Mock).mockResolvedValue(null); // Correctly cast the bcrypt.hash spy const hashSpy = jest.spyOn(bcrypt, 'hash') as unknown as jest.SpyInstance< Promise, [string, number] >; hashSpy.mockResolvedValue('hashedPassword'); const fakeUser = { id: 1, email: dto.email, hashedPassword: 'hashedPassword', firstName: dto.firstName, lastName: dto.lastName, status: dto.status, userType: dto.userType, lastLoginAt: dto.lastLoginAt, regIpAddress: dto.regIpAddress, lastLoginIpAddress: dto.lastLoginIpAddress, timezone: dto.timezone, preferredLanguage: dto.preferredLanguage, acceptsMarketing: dto.acceptsMarketing, emailVerified: dto.emailVerified, verificationToken: dto.verificationToken, passwordResetToken: dto.passwordResetToken, }; (prisma.user.create as jest.Mock).mockResolvedValue(fakeUser); (prisma.userOnRole.create as jest.Mock).mockResolvedValue({}); const result = await service.createUser(dto, creatorId, lang); expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { email: dto.email }, }); expect(hashSpy).toHaveBeenCalledWith(dto.password, 10); expect(prisma.user.create).toHaveBeenCalledWith({ data: { email: dto.email, hashedPassword: 'hashedPassword', firstName: dto.firstName, lastName: dto.lastName, status: dto.status, userType: dto.userType, lastLoginAt: dto.lastLoginAt ?? null, regIpAddress: dto.regIpAddress ?? null, lastLoginIpAddress: dto.lastLoginIpAddress ?? null, timezone: dto.timezone ?? null, preferredLanguage: dto.preferredLanguage ?? null, acceptsMarketing: dto.acceptsMarketing, emailVerified: dto.emailVerified, verificationToken: dto.verificationToken ?? null, passwordResetToken: dto.passwordResetToken ?? null, }, }); expect(prisma.userOnRole.create).toHaveBeenCalledWith({ data: { userId: fakeUser.id, roleId: 4, }, }); expect(mockLogger.success).toHaveBeenCalledWith( `User #${fakeUser.id} created by #${creatorId}.`, ); expect(result).toEqual(fakeUser); hashSpy.mockRestore(); }); it('should throw a BadRequestException if the email is already taken', async () => { const dto = { email: 'taken@example.com', password: 'Test1234!', firstName: 'Jane', lastName: 'Smith', }; const creatorId = 1; const lang = 'en'; (prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: 10, email: dto.email, }); await expect(service.createUser(dto, creatorId, lang)).rejects.toThrow( BadRequestException, ); }); }); // Additional tests for updateUser, deleteUser, etc. can follow a similar pattern. }); File: src/users/users.service.ts import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { NexusLoggerService } from '../logger/nexus-logger.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import * as bcrypt from 'bcrypt'; import { I18nService } from 'nestjs-i18n'; /** * UsersService: * Provides methods for managing user records and handling associated business rules. */ @Injectable() export class UsersService { constructor( private readonly prisma: PrismaService, private readonly logger: NexusLoggerService, private readonly i18n: I18nService, ) {} /** * findAll: * Retrieves user records. * - If ownOnly is true, returns only the requesting user's record. * - Otherwise, returns all users. */ async findAll(ownOnly: boolean, requesterUserId: number, lang: string) { if (ownOnly) { const me = await this.prisma.user.findUnique({ where: { id: requesterUserId }, }); this.logger.info(`User #${requesterUserId} requested own record.`); return me ? [me] : []; } this.logger.info(`User #${requesterUserId} requested all user records.`); return this.prisma.user.findMany(); } /** * findOne: * Retrieves a single user record based on targetUserId. * - If canReadAny is true, returns any user. * - If canReadOwn is true, returns the user only if they are the requester. */ async findOne( targetUserId: number, canReadAny: boolean, canReadOwn: boolean, requesterUserId: number, lang: string, ) { const [user] = await Promise.all([ this.prisma.user.findUnique({ where: { id: targetUserId }, }), ]); if (!user) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.not_found', { lang, args: { id: targetUserId }, }), ]); throw new NotFoundException(errorMsg); } if (canReadAny) { this.logger.info(`User #${requesterUserId} read user #${targetUserId}.`); return user; } if (canReadOwn && user.id === requesterUserId) { this.logger.info(`User #${requesterUserId} read own record.`); return user; } const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.forbidden_read', { lang, }), ]); throw new ForbiddenException(errorMsg); } /** * createUser: * Creates a new user record. * - Validates that the email is not already in use. * - Hashes the password before storing. */ async createUser(dto: CreateUserDto, creatorId: number, lang: string) { // Check if the email is already taken. if (dto.email) { const existing = await this.prisma.user.findUnique({ where: { email: dto.email }, }); if (existing) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.email_taken', { lang, args: { email: dto.email }, }), ]); throw new BadRequestException(errorMsg); } } // Hash the provided password. const hashed = await bcrypt.hash(dto.password, 10); // Create and return the new user record. const user = await this.prisma.user.create({ data: { email: dto.email, hashedPassword: hashed, firstName: dto.firstName, lastName: dto.lastName, status: dto.status, userType: dto.userType, lastLoginAt: dto.lastLoginAt ?? null, regIpAddress: dto.regIpAddress ?? null, lastLoginIpAddress: dto.lastLoginIpAddress ?? null, timezone: dto.timezone ?? null, preferredLanguage: dto.preferredLanguage ?? null, acceptsMarketing: dto.acceptsMarketing ?? true, emailVerified: dto.emailVerified ?? false, verificationToken: dto.verificationToken ?? null, passwordResetToken: dto.passwordResetToken ?? null, }, }); this.logger.success(`User #${user.id} created by #${creatorId}.`); // Also add entry in user_on_roles for this user await this.prisma.userOnRole.create({ data: { userId: user.id, roleId: 4, // Default role ID for new users }, }); return user; } /** * updateUser: * Updates a user's record. * - Allows editing if the requester has either 'users.edit.any' or 'users.edit.own' (and is editing their own record). */ async updateUser( targetUserId: number, dto: UpdateUserDto, canEditAny: boolean, canEditOwn: boolean, requesterUserId: number, lang: string, ) { const user = await this.prisma.user.findUnique({ where: { id: targetUserId }, }); if (!user) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.not_found', { lang, args: { id: targetUserId }, }), ]); throw new NotFoundException(errorMsg); } // Enforce permission rules. if (!canEditAny) { if (!canEditOwn || user.id !== requesterUserId) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.forbidden_edit', { lang, args: { id: targetUserId }, }), ]); throw new ForbiddenException(errorMsg); } } let hashed: string | undefined; if (dto.password) { // Hash the new password if provided. hashed = await bcrypt.hash(dto.password, 10); } // Update the user record with new details. const updated = await this.prisma.user.update({ where: { id: user.id }, data: { email: dto.email ?? user.email, hashedPassword: hashed ?? user.hashedPassword, firstName: dto.firstName ?? user.firstName, lastName: dto.lastName ?? user.lastName, status: dto.status ?? user.status, userType: dto.userType ?? user.userType, lastLoginAt: dto.lastLoginAt ?? user.lastLoginAt, regIpAddress: dto.regIpAddress ?? user.regIpAddress, lastLoginIpAddress: dto.lastLoginIpAddress ?? user.lastLoginIpAddress, timezone: dto.timezone ?? user.timezone, preferredLanguage: dto.preferredLanguage ?? user.preferredLanguage, acceptsMarketing: dto.acceptsMarketing === undefined ? user.acceptsMarketing : dto.acceptsMarketing, emailVerified: dto.emailVerified === undefined ? user.emailVerified : dto.emailVerified, verificationToken: dto.verificationToken ?? user.verificationToken, passwordResetToken: dto.passwordResetToken ?? user.passwordResetToken, }, }); this.logger.info(`User #${updated.id} updated by #${requesterUserId}.`); return updated; } /** * deleteUser: * Deletes a user from the system. * * Extended behavior: * - If force=1 is passed, deletes all associated memberships and entries in user_on_roles * (i.e. forcibly cleans up dependencies) before deleting the user. * - Otherwise, if the user has assets and the requester is not self-deleting, then if a * migrateToUserId is provided, assets are migrated; if not, an error is thrown. * * @param targetUserId - The ID of the user to delete. * @param canDeleteAny - Whether the requester has global "delete any" permission. * @param canDeleteOwn - Whether the requester has "delete own" permission. * @param requesterUserId - The ID of the user performing the deletion. * @param migrateToUserId - (Optional) The ID of the user to migrate assets to. * @param force - (Optional) If 1, forcibly deletes memberships and user_on_roles entries. * @param lang - The current language for translations. */ async deleteUser( targetUserId: number, canDeleteAny: boolean, canDeleteOwn: boolean, requesterUserId: number, migrateToUserId: number | undefined, force: number | undefined, lang: string, ) { const user = await this.prisma.user.findUnique({ where: { id: targetUserId }, }); if (!user) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.not_found', { lang, args: { id: targetUserId }, }), ]); throw new NotFoundException(errorMsg); } // Determine if the deletion is self-initiated. const isSelfDelete = user.id === requesterUserId; if (!canDeleteAny && !(canDeleteOwn && isSelfDelete)) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.forbidden_delete', { lang, args: { id: targetUserId }, }), ]); throw new ForbiddenException(errorMsg); } // Check if the user has any memberships. const memberships = await this.prisma.membership.findMany({ where: { userId: user.id }, }); if (memberships.length > 0) { if (force === 1) { // Force deletion: delete all membership records. await this.prisma.membership.deleteMany({ where: { userId: user.id }, }); // Also delete all entries in user_on_roles for this user. await this.prisma.userOnRole.deleteMany({ where: { userId: user.id }, }); } else { if (isSelfDelete) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.memberships_exist_self', { lang, args: { count: memberships.length }, }), ]); throw new BadRequestException(errorMsg); } else { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.memberships_exist', { lang, args: { count: memberships.length, id: user.id }, }), ]); throw new BadRequestException(errorMsg); } } } // Check if the user has assets that must be migrated or removed before deletion. const assetCount = await this.countAssetsForUser(user.id); if (assetCount > 0) { if (isSelfDelete) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.assets_exist_self', { lang, args: { count: assetCount }, }), ]); throw new BadRequestException(errorMsg); } else { if (!migrateToUserId) { const [errorMsg] = await Promise.all([ this.i18n.translate('users.errors.assets_exist', { lang, args: { count: assetCount, id: user.id }, }), ]); throw new BadRequestException(errorMsg); } await this.migrateAssets(user.id, migrateToUserId); } } // Finally, delete the user record. await this.prisma.user.delete({ where: { id: user.id } }); this.logger.success(`User #${user.id} deleted by #${requesterUserId}.`); return true; } /** * countAssetsForUser: * Counts all assets (lists, contacts, groups, API keys, etc.) that reference this user. */ private async countAssetsForUser(userId: number): Promise { let total = 0; total += await this.prisma.list.count({ where: { createdByUserId: userId }, }); total += await this.prisma.contact.count({ where: { createdByUserId: userId }, }); total += await this.prisma.group.count({ where: { createdByUserId: userId }, }); total += await this.prisma.apiKey.count({ where: { createdByUserId: userId }, }); // Add more asset types as needed. return total; } /** * migrateAssets: * Reassigns all assets created by the old user to another user. * This is used when an admin deletes a user that has assets. */ private async migrateAssets(oldUserId: number, newUserId: number) { this.logger.warn( `Migrating assets from user #${oldUserId} => #${newUserId}...`, ); await this.prisma.list.updateMany({ where: { createdByUserId: oldUserId }, data: { createdByUserId: newUserId }, }); await this.prisma.contact.updateMany({ where: { createdByUserId: oldUserId }, data: { createdByUserId: newUserId }, }); await this.prisma.group.updateMany({ where: { createdByUserId: oldUserId }, data: { createdByUserId: newUserId }, }); await this.prisma.apiKey.updateMany({ where: { createdByUserId: oldUserId }, data: { createdByUserId: newUserId }, }); this.logger.success( `Assets migrated from user #${oldUserId} to #${newUserId}.`, ); } /** * findByEmail: * Retrieves a user record by email. */ async findByEmail(email: string) { return this.prisma.user.findUnique({ where: { email } }); } }