initial: standalone repo from monorepo split
This commit is contained in:
43
src/app.module.ts
Normal file
43
src/app.module.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { AdminPrismaModule } from './prisma/admin-prisma.module';
|
||||
import { TenantPrismaModule } from './prisma/tenant-prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { TenantsModule } from './tenants/tenants.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { SupportModule } from './support/support.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { ImpersonateModule } from './impersonate/impersonate.module';
|
||||
import { PublicSupportModule } from './public-support/public-support.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
import { AdminAuthGuard } from './common/guards/admin-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '../../.env',
|
||||
}),
|
||||
AdminPrismaModule,
|
||||
TenantPrismaModule,
|
||||
AuthModule,
|
||||
DashboardModule,
|
||||
TenantsModule,
|
||||
UsersModule,
|
||||
SupportModule,
|
||||
AuditModule,
|
||||
ImpersonateModule,
|
||||
PublicSupportModule,
|
||||
HealthModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
{ provide: APP_GUARD, useClass: AdminAuthGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
29
src/audit/audit.controller.ts
Normal file
29
src/audit/audit.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Controller('audit-logs')
|
||||
export class AuditController {
|
||||
constructor(private readonly service: AuditService) {}
|
||||
|
||||
@Get()
|
||||
getTenantAuditLogs(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('userId') userId?: string,
|
||||
@Query('entity') entity?: string,
|
||||
@Query('action') action?: string,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.service.getTenantAuditLogs({ tenantId, userId, entity, action, page, limit });
|
||||
}
|
||||
|
||||
@Get('platform')
|
||||
getPlatformAuditLogs(
|
||||
@Query('adminId') adminId?: string,
|
||||
@Query('action') action?: string,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.service.getPlatformAuditLogs({ adminId, action, page, limit });
|
||||
}
|
||||
}
|
||||
11
src/audit/audit.module.ts
Normal file
11
src/audit/audit.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuditController } from './audit.controller';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [AuditController],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
80
src/audit/audit.service.ts
Normal file
80
src/audit/audit.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||
import { AdminPrismaService } from '../prisma/admin-prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
private readonly tenantDb: TenantPrismaService,
|
||||
private readonly adminDb: AdminPrismaService,
|
||||
) {}
|
||||
|
||||
async getTenantAuditLogs(query: {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
entity?: string;
|
||||
action?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { tenantId, userId, entity, action, page = 1, limit = 20 } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (tenantId) where.tenantId = tenantId;
|
||||
if (userId) where.userId = userId;
|
||||
if (entity) where.entity = entity;
|
||||
if (action) where.action = action;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.tenantDb.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.tenantDb.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
}
|
||||
|
||||
async getPlatformAuditLogs(query: {
|
||||
adminId?: string;
|
||||
action?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { adminId, action, page = 1, limit = 20 } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (adminId) where.adminId = adminId;
|
||||
if (action) where.action = action;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.adminDb.platformAuditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: { admin: { select: { id: true, firstName: true, lastName: true, email: true } } },
|
||||
}),
|
||||
this.adminDb.platformAuditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
}
|
||||
|
||||
async log(adminId: string, action: string, target?: string, details?: any, ipAddress?: string) {
|
||||
return this.adminDb.platformAuditLog.create({
|
||||
data: {
|
||||
adminId,
|
||||
action,
|
||||
target: target || null,
|
||||
details: details || {},
|
||||
ipAddress: ipAddress || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
62
src/auth/auth.controller.ts
Normal file
62
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
|
||||
class RefreshDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto.email, dto.password);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(@Body() dto: RefreshDto) {
|
||||
return this.authService.refreshToken(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async logout(@Req() req: any) {
|
||||
await this.authService.logout(req.admin.sub);
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
async profile(@Req() req: any) {
|
||||
return this.authService.getProfile(req.admin.sub);
|
||||
}
|
||||
}
|
||||
26
src/auth/auth.module.ts
Normal file
26
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AdminJwtStrategy } from './strategies/admin-jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'admin-jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('ADMIN_JWT_SECRET', 'admin-jwt-secret'),
|
||||
signOptions: {
|
||||
expiresIn: config.get<string>('ADMIN_JWT_EXPIRES_IN', '15m') as any,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AdminJwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
155
src/auth/auth.service.ts
Normal file
155
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AdminPrismaService } from '../prisma/admin-prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly adminDb: AdminPrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const admin = await this.adminDb.superAdmin.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (!admin || !admin.isActive) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, admin.password);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const tokens = await this.generateTokens(admin.id, admin.email);
|
||||
|
||||
await this.adminDb.platformAuditLog.create({
|
||||
data: {
|
||||
adminId: admin.id,
|
||||
action: 'auth.login',
|
||||
details: {},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
admin: {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
firstName: admin.firstName,
|
||||
lastName: admin.lastName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string) {
|
||||
const stored = await this.adminDb.adminRefreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
});
|
||||
|
||||
if (!stored || stored.expiresAt < new Date()) {
|
||||
if (stored) {
|
||||
await this.adminDb.adminRefreshToken.delete({
|
||||
where: { id: stored.id },
|
||||
});
|
||||
}
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
|
||||
await this.adminDb.adminRefreshToken.delete({
|
||||
where: { id: stored.id },
|
||||
});
|
||||
|
||||
const admin = await this.adminDb.superAdmin.findUnique({
|
||||
where: { id: stored.adminId },
|
||||
});
|
||||
|
||||
if (!admin || !admin.isActive) {
|
||||
throw new UnauthorizedException('Account is inactive');
|
||||
}
|
||||
|
||||
return this.generateTokens(admin.id, admin.email);
|
||||
}
|
||||
|
||||
async getProfile(adminId: string) {
|
||||
const admin = await this.adminDb.superAdmin.findUnique({
|
||||
where: { id: adminId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
throw new UnauthorizedException('Admin not found');
|
||||
}
|
||||
|
||||
return admin;
|
||||
}
|
||||
|
||||
async logout(adminId: string) {
|
||||
await this.adminDb.adminRefreshToken.deleteMany({
|
||||
where: { adminId },
|
||||
});
|
||||
|
||||
await this.adminDb.platformAuditLog.create({
|
||||
data: {
|
||||
adminId,
|
||||
action: 'auth.logout',
|
||||
details: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async generateTokens(adminId: string, email: string) {
|
||||
const accessToken = this.jwt.sign({
|
||||
sub: adminId,
|
||||
email,
|
||||
type: 'super_admin',
|
||||
});
|
||||
|
||||
const refreshSecret = this.config.get<string>(
|
||||
'ADMIN_JWT_REFRESH_SECRET',
|
||||
'admin-refresh-secret',
|
||||
);
|
||||
const refreshExpiresIn = this.config.get<string>(
|
||||
'ADMIN_JWT_REFRESH_EXPIRES_IN',
|
||||
'7d',
|
||||
);
|
||||
|
||||
const refreshToken = this.jwt.sign(
|
||||
{ sub: adminId, email, type: 'super_admin' },
|
||||
{
|
||||
secret: refreshSecret,
|
||||
expiresIn: refreshExpiresIn as any,
|
||||
},
|
||||
);
|
||||
|
||||
const days = parseInt(refreshExpiresIn) || 7;
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + days);
|
||||
|
||||
await this.adminDb.adminRefreshToken.create({
|
||||
data: {
|
||||
token: refreshToken,
|
||||
adminId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
}
|
||||
9
src/auth/dto/login.dto.ts
Normal file
9
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
password!: string;
|
||||
}
|
||||
6
src/auth/dto/refresh-token.dto.ts
Normal file
6
src/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
28
src/auth/strategies/admin-jwt.strategy.ts
Normal file
28
src/auth/strategies/admin-jwt.strategy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
export class AdminJwtStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
'admin-jwt',
|
||||
) {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey:
|
||||
config.get<string>('ADMIN_JWT_SECRET', 'admin-jwt-secret'),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: any) {
|
||||
if (payload.type !== 'super_admin') return null;
|
||||
return {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
type: payload.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/common/decorators/current-admin.decorator.ts
Normal file
9
src/common/decorators/current-admin.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentAdmin = createParamDecorator(
|
||||
(data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const admin = request['admin'];
|
||||
return data ? admin?.[data] : admin;
|
||||
},
|
||||
);
|
||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
36
src/common/filters/http-exception.filter.ts
Normal file
36
src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as { message?: string }).message || message;
|
||||
} else {
|
||||
console.error('[GlobalExceptionFilter] Unhandled exception:', exception);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: Array.isArray(message) ? message.join(', ') : message,
|
||||
});
|
||||
}
|
||||
}
|
||||
60
src/common/guards/admin-auth.guard.ts
Normal file
60
src/common/guards/admin-auth.guard.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthGuard implements CanActivate {
|
||||
private jwtSecret: string;
|
||||
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
) {
|
||||
this.jwtSecret = this.config.get<string>('ADMIN_JWT_SECRET', 'admin-jwt-secret')!;
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const url = request.url || '';
|
||||
|
||||
// Skip auth for public support endpoints
|
||||
if (url.includes('/public/support')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const token = this.extractToken(request);
|
||||
if (!token) throw new UnauthorizedException();
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.jwtSecret,
|
||||
});
|
||||
(request as any)['admin'] = payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractToken(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
20
src/common/interceptors/response.interceptor.ts
Normal file
20
src/common/interceptors/response.interceptor.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T> implements NestInterceptor<T> {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
success: true,
|
||||
data,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/dashboard/dashboard.controller.ts
Normal file
17
src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(private readonly service: DashboardService) {}
|
||||
|
||||
@Get('stats')
|
||||
getStats() {
|
||||
return this.service.getStats();
|
||||
}
|
||||
|
||||
@Get('recent-activity')
|
||||
getRecentActivity() {
|
||||
return this.service.getRecentActivity();
|
||||
}
|
||||
}
|
||||
9
src/dashboard/dashboard.module.ts
Normal file
9
src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
94
src/dashboard/dashboard.service.ts
Normal file
94
src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||
import { AdminPrismaService } from '../prisma/admin-prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(
|
||||
private readonly tenantDb: TenantPrismaService,
|
||||
private readonly adminDb: AdminPrismaService,
|
||||
) {}
|
||||
|
||||
async getStats() {
|
||||
const [
|
||||
totalTenants,
|
||||
activeTenants,
|
||||
totalUsers,
|
||||
totalClients,
|
||||
totalSubscriptions,
|
||||
activeSubscriptions,
|
||||
totalInvoices,
|
||||
totalPayments,
|
||||
paymentAgg,
|
||||
openTickets,
|
||||
] = await Promise.all([
|
||||
this.tenantDb.tenant.count({ where: { deletedAt: null } }),
|
||||
this.tenantDb.tenant.count({ where: { deletedAt: null, isActive: true } }),
|
||||
this.tenantDb.user.count({ where: { deletedAt: null } }),
|
||||
this.tenantDb.client.count({ where: { deletedAt: null } }),
|
||||
this.tenantDb.subscription.count({ where: { deletedAt: null } }),
|
||||
this.tenantDb.subscription.count({ where: { deletedAt: null, status: 'active' } }),
|
||||
this.tenantDb.invoice.count({ where: { deletedAt: null } }),
|
||||
this.tenantDb.payment.count({ where: { deletedAt: null } }),
|
||||
this.tenantDb.payment.aggregate({
|
||||
_sum: { amount: true },
|
||||
_count: true,
|
||||
where: { deletedAt: null },
|
||||
}),
|
||||
this.adminDb.supportTicket.count({ where: { status: { in: ['open', 'in_progress'] } } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalTenants,
|
||||
activeTenants,
|
||||
inactiveTenants: totalTenants - activeTenants,
|
||||
totalUsers,
|
||||
totalClients,
|
||||
totalSubscriptions,
|
||||
activeSubscriptions,
|
||||
totalInvoices,
|
||||
totalPayments,
|
||||
totalRevenue: paymentAgg._sum.amount || 0,
|
||||
openSupportTickets: openTickets,
|
||||
};
|
||||
}
|
||||
|
||||
async getRecentActivity() {
|
||||
const [recentTenants, recentAudit, recentTickets] = await Promise.all([
|
||||
this.tenantDb.tenant.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: { id: true, name: true, slug: true, isActive: true, createdAt: true },
|
||||
}),
|
||||
this.tenantDb.auditLog.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
userId: true,
|
||||
action: true,
|
||||
entity: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
this.adminDb.supportTicket.findMany({
|
||||
where: { status: { in: ['open', 'in_progress'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
tenantName: true,
|
||||
category: true,
|
||||
priority: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { recentTenants, recentAudit, recentTickets };
|
||||
}
|
||||
}
|
||||
15
src/health/health.controller.ts
Normal file
15
src/health/health.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@Public()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'admin-api',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/health/health.module.ts
Normal file
7
src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
23
src/impersonate/impersonate.controller.ts
Normal file
23
src/impersonate/impersonate.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller, Post, Delete, Param, Req } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { ImpersonateService } from './impersonate.service';
|
||||
|
||||
@Controller('impersonate')
|
||||
export class ImpersonateController {
|
||||
constructor(private readonly service: ImpersonateService) {}
|
||||
|
||||
@Post(':tenantId')
|
||||
start(@Param('tenantId') tenantId: string, @Req() req: Request & { admin: any }) {
|
||||
const admin = req.admin;
|
||||
return this.service.startImpersonation(
|
||||
tenantId,
|
||||
admin.sub,
|
||||
`${admin.firstName || ''} ${admin.lastName || ''}`.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
end(@Req() req: Request & { admin: any }) {
|
||||
return this.service.endImpersonation(req.admin.sub);
|
||||
}
|
||||
}
|
||||
12
src/impersonate/impersonate.module.ts
Normal file
12
src/impersonate/impersonate.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImpersonateController } from './impersonate.controller';
|
||||
import { ImpersonateService } from './impersonate.service';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, AuthModule],
|
||||
controllers: [ImpersonateController],
|
||||
providers: [ImpersonateService],
|
||||
})
|
||||
export class ImpersonateModule {}
|
||||
80
src/impersonate/impersonate.service.ts
Normal file
80
src/impersonate/impersonate.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class ImpersonateService {
|
||||
private readonly mainJwtSecret: string;
|
||||
|
||||
constructor(
|
||||
private readonly tenantDb: TenantPrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly audit: AuditService,
|
||||
) {
|
||||
// Use the MAIN API's JWT secret so the token works with the tenant API
|
||||
this.mainJwtSecret = this.config.get<string>('JWT_SECRET', 'dev-jwt-secret-not-for-production');
|
||||
}
|
||||
|
||||
async startImpersonation(tenantId: string, adminId: string, adminName: string) {
|
||||
const tenant = await this.tenantDb.tenant.findUnique({
|
||||
where: { id: tenantId, deletedAt: null, isActive: true },
|
||||
});
|
||||
if (!tenant) throw new NotFoundException('Tenant not found or inactive');
|
||||
|
||||
// Find the tenant's first admin user
|
||||
const adminUser = await this.tenantDb.user.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
deletedAt: null,
|
||||
isActive: true,
|
||||
roles: { some: { role: 'tenant_admin' } },
|
||||
},
|
||||
});
|
||||
if (!adminUser) throw new NotFoundException('No tenant admin found for this tenant');
|
||||
|
||||
// Generate a JWT compatible with the main API
|
||||
const token = this.jwt.sign(
|
||||
{
|
||||
sub: adminUser.id,
|
||||
tenantId,
|
||||
roles: ['tenant_admin'],
|
||||
permissions: 'all',
|
||||
impersonatedBy: adminId,
|
||||
},
|
||||
{
|
||||
secret: this.mainJwtSecret,
|
||||
expiresIn: '1h',
|
||||
},
|
||||
);
|
||||
|
||||
// Audit the impersonation
|
||||
await this.audit.log(adminId, 'impersonate.start', tenantId, {
|
||||
tenantName: tenant.name,
|
||||
impersonatedUserId: adminUser.id,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: token,
|
||||
impersonatedUser: {
|
||||
id: adminUser.id,
|
||||
email: adminUser.email,
|
||||
firstName: adminUser.firstName,
|
||||
lastName: adminUser.lastName,
|
||||
},
|
||||
tenant: {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async endImpersonation(adminId: string) {
|
||||
await this.audit.log(adminId, 'impersonate.end');
|
||||
return { message: 'Impersonation ended' };
|
||||
}
|
||||
}
|
||||
45
src/main.ts
Normal file
45
src/main.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const allowedOrigins = (
|
||||
process.env.CORS_ORIGIN ||
|
||||
'http://localhost:3000,http://localhost:3002,http://localhost:3003'
|
||||
).split(',');
|
||||
app.enableCors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'x-tenant-id'],
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const port = process.env.ADMIN_API_PORT || 3004;
|
||||
await app.listen(port, '0.0.0.0');
|
||||
console.log(`Admin API running on http://localhost:${port}/api`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
9
src/prisma/admin-prisma.module.ts
Normal file
9
src/prisma/admin-prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AdminPrismaService } from './admin-prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AdminPrismaService],
|
||||
exports: [AdminPrismaService],
|
||||
})
|
||||
export class AdminPrismaModule {}
|
||||
16
src/prisma/admin-prisma.service.ts
Normal file
16
src/prisma/admin-prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@fiberops/admin-db';
|
||||
|
||||
@Injectable()
|
||||
export class AdminPrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
9
src/prisma/tenant-prisma.module.ts
Normal file
9
src/prisma/tenant-prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TenantPrismaService } from './tenant-prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [TenantPrismaService],
|
||||
exports: [TenantPrismaService],
|
||||
})
|
||||
export class TenantPrismaModule {}
|
||||
16
src/prisma/tenant-prisma.service.ts
Normal file
16
src/prisma/tenant-prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class TenantPrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
159
src/public-support/public-support.controller.ts
Normal file
159
src/public-support/public-support.controller.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Headers,
|
||||
Res,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { SupportService } from '../support/support.service';
|
||||
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||
import { CreateTicketDto } from '../support/dto/create-ticket.dto';
|
||||
import { CommentDto } from '../support/dto/comment.dto';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { supportUploadOptions } from '../support/multer-options';
|
||||
import { createReadStream } from 'fs';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
|
||||
/**
|
||||
* Public support endpoints for tenant users.
|
||||
* These require a valid tenant JWT from the main API.
|
||||
*/
|
||||
@Controller('public/support')
|
||||
export class PublicSupportController {
|
||||
private readonly mainJwtSecret: string;
|
||||
|
||||
constructor(
|
||||
private readonly support: SupportService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly tenantDb: TenantPrismaService,
|
||||
) {
|
||||
this.mainJwtSecret = this.config.get<string>('JWT_SECRET', 'dev-jwt-secret-not-for-production');
|
||||
}
|
||||
|
||||
private async resolveTenantUser(authorization?: string) {
|
||||
if (!authorization) throw new UnauthorizedException();
|
||||
const token = authorization.replace('Bearer ', '');
|
||||
try {
|
||||
const payload = await this.jwt.verifyAsync(token, { secret: this.mainJwtSecret });
|
||||
if (!payload.tenantId || !payload.sub) throw new UnauthorizedException();
|
||||
return {
|
||||
userId: payload.sub,
|
||||
tenantId: payload.tenantId,
|
||||
name: `${payload.firstName || ''} ${payload.lastName || ''}`.trim(),
|
||||
email: payload.email,
|
||||
};
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('tickets')
|
||||
async listTickets(
|
||||
@Headers('authorization') authorization?: string,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
const user = await this.resolveTenantUser(authorization);
|
||||
return this.support.getTenantTickets(user.tenantId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('tickets/:id')
|
||||
async getTicket(
|
||||
@Param('id') id: string,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
const user = await this.resolveTenantUser(authorization);
|
||||
return this.support.getTenantTicketDetail(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('tickets')
|
||||
async createTicket(
|
||||
@Body() dto: CreateTicketDto,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
const user = await this.resolveTenantUser(authorization);
|
||||
const tenant = await this.tenantDb.tenant.findUnique({
|
||||
where: { id: user.tenantId },
|
||||
select: { name: true, slug: true },
|
||||
});
|
||||
return this.support.createFromTenant(
|
||||
user.tenantId,
|
||||
tenant?.name || '',
|
||||
tenant?.slug || '',
|
||||
user.userId,
|
||||
user.name,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('tickets/:id/comments')
|
||||
async addComment(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CommentDto,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
const user = await this.resolveTenantUser(authorization);
|
||||
return this.support.addComment(id, user.userId, user.name, 'tenant_user', dto);
|
||||
}
|
||||
|
||||
// ─── Attachments (Tenant-facing) ────────────────────
|
||||
|
||||
@Public()
|
||||
@Post('tickets/:id/attachments')
|
||||
@UseInterceptors(FilesInterceptor('files', 5, supportUploadOptions))
|
||||
async uploadAttachments(
|
||||
@Param('id') id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Body() body: { commentId?: string },
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
const user = await this.resolveTenantUser(authorization);
|
||||
await this.support.getTenantTicketDetail(user.tenantId, id);
|
||||
const results = await Promise.all(
|
||||
files.map((f) => this.support.addAttachment(id, f, user.userId, body.commentId)),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('tickets/:id/attachments')
|
||||
async getAttachments(
|
||||
@Param('id') id: string,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
const user = await this.resolveTenantUser(authorization);
|
||||
await this.support.getTenantTicketDetail(user.tenantId, id);
|
||||
return this.support.getAttachments(id);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('uploads/:fileName')
|
||||
async downloadFile(
|
||||
@Param('fileName') fileName: string,
|
||||
@Headers('authorization') authorization?: string,
|
||||
@Res() res?: Response,
|
||||
) {
|
||||
await this.resolveTenantUser(authorization);
|
||||
const { filePath, attachment } = await this.support.getAttachment(fileName);
|
||||
res!.setHeader('Content-Type', attachment.mimeType);
|
||||
res!.setHeader(
|
||||
'Content-Disposition',
|
||||
`inline; filename="${attachment.originalName}"`,
|
||||
);
|
||||
createReadStream(filePath).pipe(res!);
|
||||
}
|
||||
}
|
||||
13
src/public-support/public-support.module.ts
Normal file
13
src/public-support/public-support.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PublicSupportController } from './public-support.controller';
|
||||
import { SupportService } from '../support/support.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { AdminPrismaModule } from '../prisma/admin-prisma.module';
|
||||
import { TenantPrismaModule } from '../prisma/tenant-prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, AdminPrismaModule, TenantPrismaModule],
|
||||
controllers: [PublicSupportController],
|
||||
providers: [SupportService],
|
||||
})
|
||||
export class PublicSupportModule {}
|
||||
6
src/support/dto/comment.dto.ts
Normal file
6
src/support/dto/comment.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class CommentDto {
|
||||
@IsString()
|
||||
content!: string;
|
||||
}
|
||||
17
src/support/dto/create-ticket.dto.ts
Normal file
17
src/support/dto/create-ticket.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IsString, IsOptional, IsIn } from 'class-validator';
|
||||
|
||||
export class CreateTicketDto {
|
||||
@IsString()
|
||||
subject!: string;
|
||||
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['billing', 'technical', 'account', 'general', 'feature_request'])
|
||||
category?: string = 'general';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['low', 'normal', 'high', 'urgent'])
|
||||
priority?: string = 'normal';
|
||||
}
|
||||
40
src/support/dto/list-query.dto.ts
Normal file
40
src/support/dto/list-query.dto.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { IsOptional, IsString, IsInt, Min, IsIn } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class TicketListQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['open', 'in_progress', 'waiting_tenant', 'resolved', 'closed'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['billing', 'technical', 'account', 'general', 'feature_request'])
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['low', 'normal', 'high', 'urgent'])
|
||||
priority?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tenantId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number = 20;
|
||||
}
|
||||
19
src/support/dto/update-ticket.dto.ts
Normal file
19
src/support/dto/update-ticket.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IsOptional, IsIn, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateTicketDto {
|
||||
@IsOptional()
|
||||
@IsIn(['low', 'normal', 'high', 'urgent'])
|
||||
priority?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['open', 'in_progress', 'waiting_tenant', 'resolved', 'closed'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['billing', 'technical', 'account', 'general', 'feature_request'])
|
||||
category?: string;
|
||||
}
|
||||
40
src/support/multer-options.ts
Normal file
40
src/support/multer-options.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads/support';
|
||||
|
||||
export const supportUploadOptions: MulterOptions = {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueName = `${randomUUID()}${extname(file.originalname)}`;
|
||||
cb(null, uniqueName);
|
||||
},
|
||||
}),
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10 MB per file
|
||||
files: 5, // max 5 files per request
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`Unsupported file type: ${file.mimetype}`), false);
|
||||
}
|
||||
},
|
||||
};
|
||||
119
src/support/support.controller.ts
Normal file
119
src/support/support.controller.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Req,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { SupportService } from './support.service';
|
||||
import { UpdateTicketDto } from './dto/update-ticket.dto';
|
||||
import { CommentDto } from './dto/comment.dto';
|
||||
import { TicketListQueryDto } from './dto/list-query.dto';
|
||||
import { supportUploadOptions } from './multer-options';
|
||||
import { createReadStream } from 'fs';
|
||||
|
||||
@Controller('support/tickets')
|
||||
export class SupportController {
|
||||
constructor(private readonly service: SupportService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() query: TicketListQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get('uploads/:fileName')
|
||||
async downloadFile(
|
||||
@Param('fileName') fileName: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filePath, attachment } = await this.service.getAttachment(fileName);
|
||||
res.setHeader('Content-Type', attachment.mimeType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`inline; filename="${attachment.originalName}"`,
|
||||
);
|
||||
createReadStream(filePath).pipe(res);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateTicketDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/assign')
|
||||
assign(@Param('id') id: string, @Body() body: { adminId: string }) {
|
||||
return this.service.assign(id, body.adminId);
|
||||
}
|
||||
|
||||
@Patch(':id/resolve')
|
||||
resolve(@Param('id') id: string) {
|
||||
return this.service.resolve(id);
|
||||
}
|
||||
|
||||
@Patch(':id/close')
|
||||
close(@Param('id') id: string) {
|
||||
return this.service.close(id);
|
||||
}
|
||||
|
||||
@Post(':id/comments')
|
||||
addComment(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CommentDto,
|
||||
@Req() req: Request & { admin: any },
|
||||
) {
|
||||
const admin = req.admin;
|
||||
return this.service.addComment(
|
||||
id,
|
||||
admin.sub,
|
||||
`${admin.firstName || ''} ${admin.lastName || ''}`.trim(),
|
||||
'super_admin',
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Attachments ────────────────────────────────
|
||||
|
||||
@Post(':id/attachments')
|
||||
@UseInterceptors(FilesInterceptor('files', 5, supportUploadOptions))
|
||||
async uploadAttachments(
|
||||
@Param('id') id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Body() body: { commentId?: string },
|
||||
@Req() req: Request & { admin: any },
|
||||
) {
|
||||
const admin = req.admin;
|
||||
const results = await Promise.all(
|
||||
files.map((f) =>
|
||||
this.service.addAttachment(id, f, admin.sub, body.commentId),
|
||||
),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
@Get(':id/attachments')
|
||||
getAttachments(@Param('id') id: string) {
|
||||
return this.service.getAttachments(id);
|
||||
}
|
||||
|
||||
@Delete(':id/attachments/:attachmentId')
|
||||
deleteAttachment(
|
||||
@Param('id') id: string,
|
||||
@Param('attachmentId') attachmentId: string,
|
||||
) {
|
||||
return this.service.deleteAttachment(id, attachmentId);
|
||||
}
|
||||
}
|
||||
12
src/support/support.module.ts
Normal file
12
src/support/support.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SupportController } from './support.controller';
|
||||
import { SupportService } from './support.service';
|
||||
import { AdminPrismaModule } from '../prisma/admin-prisma.module';
|
||||
import { TenantPrismaModule } from '../prisma/tenant-prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [AdminPrismaModule, TenantPrismaModule],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService],
|
||||
})
|
||||
export class SupportModule {}
|
||||
240
src/support/support.service.ts
Normal file
240
src/support/support.service.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AdminPrismaService } from '../prisma/admin-prisma.service';
|
||||
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||
import { CreateTicketDto } from './dto/create-ticket.dto';
|
||||
import { UpdateTicketDto } from './dto/update-ticket.dto';
|
||||
import { CommentDto } from './dto/comment.dto';
|
||||
import { TicketListQueryDto } from './dto/list-query.dto';
|
||||
import { UPLOAD_DIR } from './multer-options';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class SupportService {
|
||||
constructor(
|
||||
private readonly db: AdminPrismaService,
|
||||
private readonly tenantDb: TenantPrismaService,
|
||||
) {}
|
||||
|
||||
async findAll(query: TicketListQueryDto) {
|
||||
const { search, status, category, priority, tenantId, assignedToId, page = 1, limit = 20 } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ subject: { contains: search, mode: 'insensitive' } },
|
||||
{ tenantName: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
if (status) where.status = status;
|
||||
if (category) where.category = category;
|
||||
if (priority) where.priority = priority;
|
||||
if (tenantId) where.tenantId = tenantId;
|
||||
if (assignedToId) where.assignedToId = assignedToId;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.db.supportTicket.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
assignee: { select: { id: true, firstName: true, lastName: true } },
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
}),
|
||||
this.db.supportTicket.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const ticket = await this.db.supportTicket.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
assignee: { select: { id: true, firstName: true, lastName: true } },
|
||||
comments: { orderBy: { createdAt: 'asc' }, include: { attachments: true } },
|
||||
attachments: { where: { commentId: null }, orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async createAsAdmin(tenantId: string, adminId: string, adminName: string, data: { subject: string; description: string; category?: string; priority?: string }) {
|
||||
const tenant = await this.tenantDb.tenant.findUnique({ where: { id: tenantId }, select: { id: true, name: true, slug: true } });
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
|
||||
return this.db.supportTicket.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
createdById: adminId,
|
||||
createdByName: adminName,
|
||||
subject: data.subject,
|
||||
description: data.description,
|
||||
category: data.category || 'general',
|
||||
priority: data.priority || 'normal',
|
||||
status: 'open',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createFromTenant(tenantId: string, tenantName: string, tenantSlug: string, userId: string, userName: string, dto: CreateTicketDto) {
|
||||
return this.db.supportTicket.create({
|
||||
data: {
|
||||
tenantId,
|
||||
tenantName,
|
||||
tenantSlug,
|
||||
createdById: userId,
|
||||
createdByName: userName,
|
||||
subject: dto.subject,
|
||||
description: dto.description,
|
||||
category: dto.category || 'general',
|
||||
priority: dto.priority || 'normal',
|
||||
status: 'open',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTicketDto) {
|
||||
const ticket = await this.db.supportTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
const data: any = {};
|
||||
if (dto.priority) data.priority = dto.priority;
|
||||
if (dto.status) data.status = dto.status;
|
||||
if (dto.assignedToId !== undefined) data.assignedToId = dto.assignedToId || null;
|
||||
if (dto.category) data.category = dto.category;
|
||||
|
||||
if (dto.status === 'resolved') data.resolvedAt = new Date();
|
||||
if (dto.status === 'closed') data.closedAt = new Date();
|
||||
|
||||
return this.db.supportTicket.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async assign(id: string, adminId: string) {
|
||||
const ticket = await this.db.supportTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
return this.db.supportTicket.update({
|
||||
where: { id },
|
||||
data: { assignedToId: adminId, status: 'in_progress' },
|
||||
});
|
||||
}
|
||||
|
||||
async resolve(id: string) {
|
||||
const ticket = await this.db.supportTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
return this.db.supportTicket.update({
|
||||
where: { id },
|
||||
data: { status: 'resolved', resolvedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async close(id: string) {
|
||||
const ticket = await this.db.supportTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
return this.db.supportTicket.update({
|
||||
where: { id },
|
||||
data: { status: 'closed', closedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async addComment(id: string, authorId: string, authorName: string, authorType: string, dto: CommentDto) {
|
||||
const ticket = await this.db.supportTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
return this.db.supportTicketComment.create({
|
||||
data: {
|
||||
ticketId: id,
|
||||
authorId,
|
||||
authorName,
|
||||
authorType,
|
||||
content: dto.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Attachments ──────────────────────────────────────
|
||||
|
||||
async addAttachment(ticketId: string, file: Express.Multer.File, uploadedBy: string, commentId?: string) {
|
||||
const ticket = await this.db.supportTicket.findUnique({ where: { id: ticketId } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
if (commentId) {
|
||||
const comment = await this.db.supportTicketComment.findFirst({ where: { id: commentId, ticketId } });
|
||||
if (!comment) throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
return this.db.supportTicketAttachment.create({
|
||||
data: {
|
||||
ticketId,
|
||||
commentId: commentId || null,
|
||||
fileName: file.filename,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
sizeBytes: file.size,
|
||||
uploadedBy,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAttachment(fileName: string) {
|
||||
const attachment = await this.db.supportTicketAttachment.findFirst({ where: { fileName } });
|
||||
if (!attachment) throw new NotFoundException('Attachment not found');
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, fileName);
|
||||
if (!fs.existsSync(filePath)) throw new NotFoundException('File not found on disk');
|
||||
|
||||
return { filePath, attachment };
|
||||
}
|
||||
|
||||
async getAttachments(ticketId: string) {
|
||||
return this.db.supportTicketAttachment.findMany({
|
||||
where: { ticketId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAttachment(ticketId: string, attachmentId: string) {
|
||||
const attachment = await this.db.supportTicketAttachment.findFirst({
|
||||
where: { id: attachmentId, ticketId },
|
||||
});
|
||||
if (!attachment) throw new NotFoundException('Attachment not found');
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, attachment.fileName);
|
||||
try { fs.unlinkSync(filePath); } catch { /* already gone */ }
|
||||
|
||||
return this.db.supportTicketAttachment.delete({ where: { id: attachmentId } });
|
||||
}
|
||||
|
||||
async getTenantTickets(tenantId: string) {
|
||||
return this.db.supportTicket.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
assignee: { select: { id: true, firstName: true, lastName: true } },
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTenantTicketDetail(tenantId: string, ticketId: string) {
|
||||
const ticket = await this.db.supportTicket.findFirst({
|
||||
where: { id: ticketId, tenantId },
|
||||
include: {
|
||||
assignee: { select: { id: true, firstName: true, lastName: true } },
|
||||
comments: { orderBy: { createdAt: 'asc' }, include: { attachments: true } },
|
||||
attachments: { where: { commentId: null }, orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
return ticket;
|
||||
}
|
||||
}
|
||||
25
src/tenants/dto/create-tenant.dto.ts
Normal file
25
src/tenants/dto/create-tenant.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsEmail, IsObject } from 'class-validator';
|
||||
|
||||
export class CreateTenantDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
slug!: string;
|
||||
|
||||
@IsEmail()
|
||||
adminEmail!: string;
|
||||
|
||||
@IsString()
|
||||
adminPassword!: string;
|
||||
|
||||
@IsString()
|
||||
adminFirstName!: string;
|
||||
|
||||
@IsString()
|
||||
adminLastName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
24
src/tenants/dto/list-query.dto.ts
Normal file
24
src/tenants/dto/list-query.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class TenantListQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: 'active' | 'inactive' | 'all';
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number = 20;
|
||||
}
|
||||
19
src/tenants/dto/update-tenant.dto.ts
Normal file
19
src/tenants/dto/update-tenant.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsObject } from 'class-validator';
|
||||
|
||||
export class UpdateTenantDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
59
src/tenants/tenants.controller.ts
Normal file
59
src/tenants/tenants.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { TenantsService } from './tenants.service';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
||||
import { TenantListQueryDto } from './dto/list-query.dto';
|
||||
|
||||
@Controller('tenants')
|
||||
export class TenantsController {
|
||||
constructor(private readonly service: TenantsService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() query: TenantListQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateTenantDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateTenantDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
activate(@Param('id') id: string) {
|
||||
return this.service.activate(id);
|
||||
}
|
||||
|
||||
@Patch(':id/deactivate')
|
||||
deactivate(@Param('id') id: string) {
|
||||
return this.service.deactivate(id);
|
||||
}
|
||||
|
||||
@Get(':id/users')
|
||||
getTenantUsers(@Param('id') tenantId: string) {
|
||||
return this.service.getTenantUsers(tenantId);
|
||||
}
|
||||
}
|
||||
9
src/tenants/tenants.module.ts
Normal file
9
src/tenants/tenants.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TenantsController } from './tenants.controller';
|
||||
import { TenantsService } from './tenants.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService],
|
||||
})
|
||||
export class TenantsModule {}
|
||||
323
src/tenants/tenants.service.ts
Normal file
323
src/tenants/tenants.service.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||
import { AdminPrismaService } from '../prisma/admin-prisma.service';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
||||
import { TenantListQueryDto } from './dto/list-query.dto';
|
||||
import { DEFAULT_ROLE_PERMISSIONS } from '@fiberops/shared';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class TenantsService {
|
||||
constructor(
|
||||
private readonly tenantDb: TenantPrismaService,
|
||||
private readonly adminDb: AdminPrismaService,
|
||||
) {}
|
||||
|
||||
async findAll(query: TenantListQueryDto) {
|
||||
const { search, status, page = 1, limit = 20 } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = { deletedAt: null };
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ slug: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
if (status === 'active') where.isActive = true;
|
||||
if (status === 'inactive') where.isActive = false;
|
||||
|
||||
const [tenants, total] = await Promise.all([
|
||||
this.tenantDb.tenant.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
_count: { select: { users: { where: { deletedAt: null } }, clients: { where: { deletedAt: null } }, subscriptions: { where: { deletedAt: null } } } },
|
||||
},
|
||||
}),
|
||||
this.tenantDb.tenant.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: tenants.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
isActive: t.isActive,
|
||||
settings: t.settings,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
_count: t._count,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const tenant = await this.tenantDb.tenant.findUnique({
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
users: {
|
||||
where: { deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isActive: true,
|
||||
roles: { select: { role: true } },
|
||||
tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } },
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
clients: { where: { deletedAt: null } },
|
||||
subscriptions: { where: { deletedAt: null } },
|
||||
invoices: { where: { deletedAt: null } },
|
||||
payments: { where: { deletedAt: null } },
|
||||
tickets: { where: { deletedAt: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
|
||||
// Revenue aggregation
|
||||
const revenueAgg = await this.tenantDb.payment.aggregate({
|
||||
_sum: { amount: true },
|
||||
where: { tenantId: id, deletedAt: null },
|
||||
});
|
||||
|
||||
return {
|
||||
...tenant,
|
||||
totalRevenue: revenueAgg._sum.amount || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: CreateTenantDto) {
|
||||
const existing = await this.tenantDb.tenant.findUnique({
|
||||
where: { slug: dto.slug },
|
||||
});
|
||||
if (existing) throw new ConflictException('Tenant slug already taken');
|
||||
|
||||
const existingEmail = await this.tenantDb.user.findFirst({
|
||||
where: { email: dto.adminEmail },
|
||||
});
|
||||
if (existingEmail) throw new ConflictException('Admin email already in use');
|
||||
|
||||
const hashedPassword = await bcrypt.hash(dto.adminPassword, 12);
|
||||
|
||||
const tenant = await this.tenantDb.tenant.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
settings: dto.settings || {
|
||||
companyName: dto.name,
|
||||
currency: 'PHP',
|
||||
timezone: 'Asia/Manila',
|
||||
},
|
||||
users: {
|
||||
create: {
|
||||
email: dto.adminEmail,
|
||||
password: hashedPassword,
|
||||
firstName: dto.adminFirstName,
|
||||
lastName: dto.adminLastName,
|
||||
mustChangePassword: true,
|
||||
roles: { create: { role: 'tenant_admin' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
users: { select: { id: true, email: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const adminUser = tenant.users[0];
|
||||
await this.seedTenantDefaults(tenant.id, adminUser.id);
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTenantDto) {
|
||||
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
|
||||
if (dto.slug && dto.slug !== tenant.slug) {
|
||||
const existing = await this.tenantDb.tenant.findUnique({ where: { slug: dto.slug } });
|
||||
if (existing) throw new ConflictException('Slug already taken');
|
||||
}
|
||||
|
||||
return this.tenantDb.tenant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name && { name: dto.name }),
|
||||
...(dto.slug && { slug: dto.slug }),
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
...(dto.settings && { settings: dto.settings }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
return this.tenantDb.tenant.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async activate(id: string) {
|
||||
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
return this.tenantDb.tenant.update({
|
||||
where: { id },
|
||||
data: { isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deactivate(id: string) {
|
||||
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
return this.tenantDb.tenant.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
async getTenantUsers(tenantId: string) {
|
||||
const tenant = await this.tenantDb.tenant.findUnique({ where: { id: tenantId, deletedAt: null } });
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
|
||||
return this.tenantDb.user.findMany({
|
||||
where: { tenantId, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
roles: { select: { role: true } },
|
||||
tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
private async seedTenantDefaults(tenantId: string, adminUserId: string) {
|
||||
const ROLE_DEFS: { name: string; slug: string; description: string }[] = [
|
||||
{ name: 'Tenant Admin', slug: 'tenant_admin', description: 'Full access to all modules' },
|
||||
{ name: 'Manager', slug: 'manager', description: 'Operational management with approval rights' },
|
||||
{ name: 'Technician', slug: 'technician', description: 'Field operations: clients, tickets, payments' },
|
||||
{ name: 'Collector', slug: 'collector', description: 'Payment collection and client viewing' },
|
||||
];
|
||||
|
||||
// Create roles with permissions
|
||||
const roleMap: Record<string, string> = {};
|
||||
for (const def of ROLE_DEFS) {
|
||||
const perms = DEFAULT_ROLE_PERMISSIONS[def.slug] || [];
|
||||
const role = await this.tenantDb.tenantRole.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: def.name,
|
||||
slug: def.slug,
|
||||
description: def.description,
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
create: perms.map((p) => ({
|
||||
module: p.module,
|
||||
canView: p.canView,
|
||||
canCreate: p.canCreate,
|
||||
canUpdate: p.canUpdate,
|
||||
canArchive: p.canArchive,
|
||||
canApprove: p.canApprove,
|
||||
canExport: p.canExport,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
roleMap[def.slug] = role.id;
|
||||
}
|
||||
|
||||
// Assign admin user to tenant_admin role
|
||||
await this.tenantDb.userTenantRole.create({
|
||||
data: { userId: adminUserId, tenantRoleId: roleMap['tenant_admin'] },
|
||||
});
|
||||
|
||||
// Areas
|
||||
await Promise.all([
|
||||
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 1 - Centro', description: 'Town center, commercial area' } }),
|
||||
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 2 - Poblacion', description: 'Residential zone near market' } }),
|
||||
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 3 - San Isidro', description: 'Agricultural and residential' } }),
|
||||
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 4 - Riverside', description: 'River-side residential' } }),
|
||||
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 5 - Hilltop', description: 'Elevated residential subdivision' } }),
|
||||
]);
|
||||
|
||||
// Plans
|
||||
await Promise.all([
|
||||
this.tenantDb.plan.create({ data: { tenantId, name: 'Lite 15', description: 'Entry-level 15 Mbps', speedDown: 15, speedUp: 15, price: 699, billingCycle: 30 } }),
|
||||
this.tenantDb.plan.create({ data: { tenantId, name: 'Basic 25', description: '25 Mbps residential', speedDown: 25, speedUp: 25, price: 999, billingCycle: 30 } }),
|
||||
this.tenantDb.plan.create({ data: { tenantId, name: 'Standard 50', description: '50 Mbps residential', speedDown: 50, speedUp: 50, price: 1499, billingCycle: 30 } }),
|
||||
this.tenantDb.plan.create({ data: { tenantId, name: 'Premium 100', description: '100 Mbps business', speedDown: 100, speedUp: 100, price: 2499, billingCycle: 30 } }),
|
||||
this.tenantDb.plan.create({ data: { tenantId, name: 'Enterprise 200', description: '200 Mbps dedicated', speedDown: 200, speedUp: 200, price: 4999, billingCycle: 30 } }),
|
||||
]);
|
||||
|
||||
// Chart of Accounts
|
||||
const coaDefs = [
|
||||
{ code: '1000', name: 'Assets', type: 'asset' },
|
||||
{ code: '1010', name: 'Cash on Hand', type: 'asset' },
|
||||
{ code: '1020', name: 'GCash Business', type: 'asset' },
|
||||
{ code: '1030', name: 'Maya Business', type: 'asset' },
|
||||
{ code: '1040', name: 'Bank Account', type: 'asset' },
|
||||
{ code: '1100', name: 'Accounts Receivable', type: 'asset' },
|
||||
{ code: '1200', name: 'Equipment', type: 'asset' },
|
||||
{ code: '2000', name: 'Liabilities', type: 'liability' },
|
||||
{ code: '2010', name: 'Accounts Payable', type: 'liability' },
|
||||
{ code: '3000', name: 'Equity', type: 'equity' },
|
||||
{ code: '3010', name: "Owner's Equity", type: 'equity' },
|
||||
{ code: '3020', name: 'Retained Earnings', type: 'equity' },
|
||||
{ code: '4000', name: 'Revenue', type: 'revenue' },
|
||||
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue' },
|
||||
{ code: '4020', name: 'Installation Fees', type: 'revenue' },
|
||||
{ code: '5000', name: 'Expenses', type: 'expense' },
|
||||
{ code: '5010', name: 'Utilities Expense', type: 'expense' },
|
||||
{ code: '5020', name: 'Salaries Expense', type: 'expense' },
|
||||
{ code: '5030', name: 'Maintenance Expense', type: 'expense' },
|
||||
{ code: '5040', name: 'Transport Expense', type: 'expense' },
|
||||
{ code: '5050', name: 'Supplies Expense', type: 'expense' },
|
||||
{ code: '5060', name: 'Equipment Expense', type: 'expense' },
|
||||
];
|
||||
|
||||
for (const a of coaDefs) {
|
||||
await this.tenantDb.chartOfAccount.create({
|
||||
data: { tenantId, code: a.code, name: a.name, type: a.type as any, isSystem: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Company Accounts (linked to CoA)
|
||||
const coa1010 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1010' } });
|
||||
const coa1020 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1020' } });
|
||||
const coa1030 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1030' } });
|
||||
const coa1040 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1040' } });
|
||||
|
||||
await Promise.all([
|
||||
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'Cash on Hand', type: 'cash', balance: 0, isSystem: true, chartOfAccountId: coa1010?.id } }),
|
||||
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'GCash Business', type: 'e_wallet', balance: 0, chartOfAccountId: coa1020?.id } }),
|
||||
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'Maya Business', type: 'e_wallet', balance: 0, chartOfAccountId: coa1030?.id } }),
|
||||
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'BDO Savings', type: 'bank', balance: 0, chartOfAccountId: coa1040?.id } }),
|
||||
]);
|
||||
|
||||
// Billing Settings
|
||||
await this.tenantDb.billingSetting.create({
|
||||
data: { tenantId, autoGenerate: true, gracePeriodDays: 7, dueDateOffsetDays: 15, invoicePrefix: 'INV' },
|
||||
});
|
||||
}
|
||||
}
|
||||
35
src/users/users.controller.ts
Normal file
35
src/users/users.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, Patch, Param, Body, Query } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly service: UsersService) {}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.service.findAll({ search, tenantId, page, limit });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { isActive?: boolean; firstName?: string; lastName?: string },
|
||||
) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/reset-password')
|
||||
resetPassword(@Param('id') id: string, @Body() dto: { password: string }) {
|
||||
return this.service.resetPassword(id, dto.password);
|
||||
}
|
||||
}
|
||||
9
src/users/users.module.ts
Normal file
9
src/users/users.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
91
src/users/users.service.ts
Normal file
91
src/users/users.service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly tenantDb: TenantPrismaService) {}
|
||||
|
||||
async findAll(query: { search?: string; tenantId?: string; page?: number; limit?: number }) {
|
||||
const { search, tenantId, page = 1, limit = 20 } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = { deletedAt: null };
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: search, mode: 'insensitive' } },
|
||||
{ lastName: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
if (tenantId) where.tenantId = tenantId;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.tenantDb.user.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
tenant: { select: { id: true, name: true, slug: true } },
|
||||
roles: { select: { role: true } },
|
||||
tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } },
|
||||
},
|
||||
}),
|
||||
this.tenantDb.user.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const user = await this.tenantDb.user.findUnique({
|
||||
where: { id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
tenant: { select: { id: true, name: true, slug: true } },
|
||||
roles: { select: { role: true } },
|
||||
tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } },
|
||||
},
|
||||
});
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(id: string, dto: { isActive?: boolean; firstName?: string; lastName?: string }) {
|
||||
const user = await this.tenantDb.user.findUnique({ where: { id, deletedAt: null } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
return this.tenantDb.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
...(dto.firstName && { firstName: dto.firstName }),
|
||||
...(dto.lastName && { lastName: dto.lastName }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async resetPassword(id: string, newPassword: string) {
|
||||
const user = await this.tenantDb.user.findUnique({ where: { id, deletedAt: null } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
const hashed = await bcrypt.hash(newPassword, 12);
|
||||
return this.tenantDb.user.update({
|
||||
where: { id },
|
||||
data: { password: hashed },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user