Initial BizOne portal setup

This commit is contained in:
2026-05-11 11:36:33 +07:00
commit 57017dd397
249 changed files with 41305 additions and 0 deletions

View File

@ -0,0 +1,8 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class CompleteInvitationDto {
@IsString()
@IsNotEmpty()
@MinLength(8)
password!: string;
}

View File

@ -0,0 +1,14 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsEmail()
email!: string;
@IsString()
@IsOptional()
roleId?: string;
}

View File

@ -0,0 +1,20 @@
import { IsEmail, IsIn, IsOptional, IsString } from 'class-validator';
export class UpdateUserDto {
@IsString()
@IsOptional()
name?: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
roleId?: string;
@IsString()
@IsOptional()
@IsIn(['invited', 'active', 'inactive', 'suspended'])
status?: 'invited' | 'active' | 'inactive' | 'suspended';
}

View File

@ -0,0 +1,84 @@
import { Body, Controller, Get, Param, Patch, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { AuthGuard } from '../common/auth.guard';
import { RequirePermission } from '../common/permission.decorator';
import { PermissionGuard } from '../common/permission.guard';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
import { CompleteInvitationDto } from './dto/complete-invitation.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@UseGuards(AuthGuard, PermissionGuard)
@Get()
@RequirePermission('users', 'view')
findAll(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('roleId') roleId?: string,
@Query('status') status?: string,
) {
return this.usersService.findAll({
page: page ? Number(page) : 1,
limit: limit ? Number(limit) : 10,
search,
roleId,
status,
});
}
@UseGuards(AuthGuard, PermissionGuard)
@Get('export')
@RequirePermission('users', 'view')
async exportCsv(
@Res() response: Response,
@Query('search') search?: string,
@Query('roleId') roleId?: string,
@Query('status') status?: string,
) {
const csv = await this.usersService.exportCsv({ search, roleId, status });
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
response.setHeader('Content-Disposition', 'attachment; filename="users-export.csv"');
response.send(csv);
}
@UseGuards(AuthGuard, PermissionGuard)
@Post('invite')
@RequirePermission('users', 'manage')
invite(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: CreateUserDto,
) {
return this.usersService.invite(dto, request.user, request.ip);
}
@UseGuards(AuthGuard, PermissionGuard)
@Patch(':id')
@RequirePermission('users', 'manage')
update(
@Req() request: Request & { user: AuthenticatedUser },
@Param('id') id: string,
@Body() dto: UpdateUserDto,
) {
return this.usersService.update(id, dto, request.user, request.ip);
}
@Get('invitations/:token')
getInvitation(@Param('token') token: string) {
return this.usersService.getInvitation(token);
}
@Post('invitations/:token/complete')
completeInvitation(
@Req() request: Request,
@Param('token') token: string,
@Body() dto: CompleteInvitationDto,
) {
return this.usersService.completeInvitation(token, dto, request.ip);
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MailerModule } from '../mailer/mailer.module';
import { PrismaModule } from '../prisma/prisma.module';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [PrismaModule, MailerModule, AuthModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,380 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash, randomBytes } from 'node:crypto';
import { AuthenticatedUser } from '../auth/auth.types';
import { normalizeEmail, normalizeText } from '../common/normalize';
import { hasMinimumPasswordLength, hashPassword } from '../common/password';
import { getAppConfig } from '../config/env';
import { MailerService } from '../mailer/mailer.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { CompleteInvitationDto } from './dto/complete-invitation.dto';
const config = getAppConfig();
@Injectable()
export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly mailer: MailerService,
) {}
async findAll(params?: {
page?: number;
limit?: number;
search?: string;
roleId?: string;
status?: string;
}) {
const page = Math.max(1, params?.page || 1);
const limit = Math.min(50, Math.max(1, params?.limit || 10));
const where = this.buildWhere(params);
const [users, total] = await Promise.all([
this.prisma.user.findMany({
where,
include: {
role: {
select: { id: true, name: true },
},
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.user.count({ where }),
]);
return {
items: users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
status: user.status,
roleId: user.roleId,
roleName: user.role?.name || 'Unassigned',
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastLoginAt: user.lastLoginAt,
emailVerifiedAt: user.emailVerifiedAt,
})),
total,
page,
pageSize: limit,
totalPages: Math.max(1, Math.ceil(total / limit)),
};
}
async exportCsv(params?: {
search?: string;
roleId?: string;
status?: string;
}) {
const users = await this.prisma.user.findMany({
where: this.buildWhere(params),
include: {
role: {
select: { id: true, name: true },
},
},
orderBy: { createdAt: 'desc' },
});
const header = [
'Name',
'Email',
'Role',
'Status',
'Created At',
'Updated At',
'Last Login At',
'Email Verified At',
];
const rows = users.map((user) =>
[
user.name,
user.email,
user.role?.name || 'Unassigned',
user.status,
user.createdAt.toISOString(),
user.updatedAt.toISOString(),
user.lastLoginAt?.toISOString() || '',
user.emailVerifiedAt?.toISOString() || '',
]
.map((cell) => `"${String(cell).replaceAll('"', '""')}"`)
.join(','),
);
return [header.join(','), ...rows].join('\n');
}
async invite(dto: CreateUserDto, actorUser: AuthenticatedUser, ipAddress?: string) {
const actor = await this.findActor(actorUser.sub, actorUser.email);
const email = normalizeEmail(dto.email);
const name = normalizeText(dto.name);
if (!name) {
throw new BadRequestException('Name is required');
}
const role = dto.roleId
? await this.prisma.role.findUnique({ where: { id: dto.roleId } })
: await this.prisma.role.findUnique({ where: { key: 'agent' } });
if (dto.roleId && !role) {
throw new NotFoundException('Role not found');
}
const existingUser = await this.prisma.user.findUnique({
where: { email },
select: { id: true, status: true },
});
if (existingUser?.status === 'active') {
throw new BadRequestException('User already exists and is active');
}
const token = randomBytes(24).toString('hex');
const tokenHash = this.hashToken(token);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const user = await this.prisma.user.upsert({
where: { email },
update: {
name,
roleId: role?.id || null,
status: 'invited',
inviteTokenHash: tokenHash,
inviteTokenExpiresAt: expiresAt,
passwordHash: null,
emailVerifiedAt: null,
},
create: {
name,
email,
roleId: role?.id || null,
status: 'invited',
inviteTokenHash: tokenHash,
inviteTokenExpiresAt: expiresAt,
},
include: {
role: {
select: { name: true },
},
},
});
const invitationUrl = `${config.frontendOrigin}/invite/${token}`;
let mailResult: { delivered: boolean } = { delivered: false };
try {
mailResult = await this.mailer.sendInvitationEmail({
to: user.email,
name: user.name,
roleName: user.role?.name || 'User',
invitationUrl,
invitedBy: actor.name,
});
} catch {
mailResult = { delivered: false };
}
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'User Invited',
module: 'User Management',
ipAddress: ipAddress || null,
severity: 'default',
details: `Invited user ${user.email} as ${user.role?.name || 'Unassigned'}.`,
},
});
return {
id: user.id,
name: user.name,
email: user.email,
status: user.status,
roleId: user.roleId,
roleName: user.role?.name || 'Unassigned',
invitationUrl,
emailSent: mailResult.delivered,
};
}
async update(id: string, dto: UpdateUserDto, actorUser: AuthenticatedUser, ipAddress?: string) {
const actor = await this.findActor(actorUser.sub, actorUser.email);
if (dto.roleId) {
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
if (!role) {
throw new NotFoundException('Role not found');
}
}
const user = await this.prisma.user.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: normalizeText(dto.name) || '' } : {}),
...(dto.email !== undefined ? { email: normalizeEmail(dto.email) } : {}),
...(dto.roleId !== undefined ? { roleId: dto.roleId || null } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
},
include: {
role: {
select: { name: true },
},
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'User Updated',
module: 'User Management',
ipAddress: ipAddress || null,
severity: 'default',
details: `Updated user ${user.email}.`,
},
});
return {
id: user.id,
name: user.name,
email: user.email,
status: user.status,
roleId: user.roleId,
roleName: user.role?.name || 'Unassigned',
lastLoginAt: user.lastLoginAt,
emailVerifiedAt: user.emailVerifiedAt,
};
}
async getInvitation(token: string) {
const invite = await this.findInvitationByToken(token);
return {
email: invite.email,
name: invite.name,
roleName: invite.role?.name || 'User',
expiresAt: invite.inviteTokenExpiresAt,
};
}
async completeInvitation(token: string, dto: CompleteInvitationDto, ipAddress?: string) {
if (!hasMinimumPasswordLength(dto.password)) {
throw new BadRequestException('Password must be at least 8 characters');
}
const invite = await this.findInvitationByToken(token);
const passwordHash = await hashPassword(dto.password);
const user = await this.prisma.user.update({
where: { id: invite.id },
data: {
passwordHash,
status: 'active',
emailVerifiedAt: new Date(),
inviteTokenHash: null,
inviteTokenExpiresAt: null,
},
include: {
role: {
select: { name: true },
},
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: user.id,
actorName: user.name,
actorEmail: user.email,
actionType: 'User Activated',
module: 'User Management',
ipAddress: ipAddress || null,
severity: 'default',
details: `Completed password setup for ${user.email}.`,
},
});
return {
id: user.id,
email: user.email,
status: user.status,
};
}
private async findInvitationByToken(token: string) {
const invite = await this.prisma.user.findFirst({
where: {
inviteTokenHash: this.hashToken(token),
},
include: {
role: {
select: { name: true },
},
},
});
if (!invite || !invite.inviteTokenExpiresAt || invite.inviteTokenExpiresAt.getTime() < Date.now()) {
throw new UnauthorizedException('Invitation token is invalid or expired');
}
return invite;
}
private async findActor(userId: string, email: string) {
const actor = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true },
});
return {
id: actor?.id || userId,
name: actor?.name || email,
email: actor?.email || email,
};
}
private hashToken(token: string) {
return createHash('sha256').update(token).digest('hex');
}
private buildWhere(params?: {
search?: string;
roleId?: string;
status?: string;
}): Prisma.UserWhereInput {
const search = params?.search?.trim();
const roleId = params?.roleId?.trim();
const status = params?.status?.trim();
const and: Prisma.UserWhereInput[] = [];
if (search) {
and.push({
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
});
}
if (roleId) {
and.push({ roleId });
}
if (status && ['invited', 'active', 'inactive', 'suspended'].includes(status)) {
and.push({ status: status as 'invited' | 'active' | 'inactive' | 'suspended' });
}
return and.length > 0 ? { AND: and } : {};
}
}