initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:55 +08:00
commit 4a8f1e9318
157 changed files with 9198 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import { SetMetadata } from '@nestjs/common';
export interface AccessRequirement {
module: string;
action: 'canView' | 'canCreate' | 'canUpdate' | 'canArchive' | 'canApprove' | 'canExport';
}
export const ACCESS_KEY = 'access_requirement';
/**
* Require the user's tenant role to grant a specific module+action.
* Example: @RequireAccess('clients', 'canCreate')
*/
export const RequireAccess = (module: string, action: AccessRequirement['action']) =>
SetMetadata(ACCESS_KEY, { module, action } as AccessRequirement);

View File

@@ -0,0 +1,20 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserPayload {
sub: string;
tenantId: string;
roles: string[];
permissions: string[];
}
export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserPayload;
// For tenantId, prefer request.tenantId which TenantGuard may have set (e.g., super_admin x-tenant-id header)
if (data === 'tenantId') {
return request.tenantId || user?.tenantId;
}
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '@fiberops/shared';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,55 @@
import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class PaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
export interface PaginatedResult<T> {
items: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export function paginationArgs(dto: PaginationDto) {
const page = dto.page || 1;
const limit = dto.limit || 20;
return {
skip: (page - 1) * limit,
take: limit,
page,
limit,
};
}
export function paginatedResult<T>(
items: T[],
total: number,
page: number,
limit: number,
): PaginatedResult<T> {
return {
items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}

View File

@@ -0,0 +1,39 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { ApiResponse } from '@fiberops/shared';
@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);
}
const body: ApiResponse = {
success: false,
data: null,
error: Array.isArray(message) ? message.join(', ') : message,
};
response.status(status).json(body);
}
}

View File

@@ -0,0 +1,53 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ACCESS_KEY, AccessRequirement } from '../decorators/access.decorator';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AccessGuard implements CanActivate {
constructor(
private reflector: Reflector,
private prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requirement = this.reflector.getAllAndOverride<AccessRequirement>(ACCESS_KEY, [
context.getHandler(),
context.getClass(),
]);
// No @RequireAccess decorator → allow
if (!requirement) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) return false;
// Super admin bypasses all access checks
if (user.roles?.includes('super_admin')) return true;
// Resolve user's tenant role permissions from DB
const assignments = await this.prisma.userTenantRole.findMany({
where: { userId: user.sub },
include: {
tenantRole: {
include: { permissions: true },
},
},
});
// Check if any assigned role grants the required module+action
for (const a of assignments) {
if (!a.tenantRole.isActive || a.tenantRole.deletedAt) continue;
for (const p of a.tenantRole.permissions) {
if (p.module === requirement.module && p[requirement.action]) {
return true;
}
}
}
throw new ForbiddenException(
`Access denied: requires ${requirement.action} on ${requirement.module}`,
);
}
}

View File

@@ -0,0 +1,34 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) return false;
// Super admin bypasses all permission checks
if (user.roles?.includes('super_admin')) return true;
const userPerms: string[] = user.permissions || [];
const missing = required.filter((p) => !userPerms.includes(p));
if (missing.length > 0) {
throw new ForbiddenException(`Missing permissions: ${missing.join(', ')}`);
}
return true;
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role, satisfiesRole } from '@fiberops/shared';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user || !user.roles) {
return false;
}
// Hierarchy-aware check: user with 'manager' satisfies 'technician' requirement
return requiredRoles.some((role) => satisfiesRole(user.roles, role));
}
}

View File

@@ -0,0 +1,36 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('Authentication required');
}
// Super admin: can switch tenant context via x-tenant-id header
const isSuperAdmin = user.roles?.includes('super_admin');
if (isSuperAdmin) {
const headerTenantId = request.headers['x-tenant-id'];
// Use header tenant if provided, otherwise fall back to user's own tenant (if any)
request.tenantId = headerTenantId || user.tenantId || null;
return true;
}
// Regular users must have a tenantId from JWT
if (!user.tenantId) {
throw new ForbiddenException('Tenant context required');
}
request.tenantId = user.tenantId;
return true;
}
}

View File

@@ -0,0 +1,24 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { ApiResponse } from '@fiberops/shared';
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
error: null,
})),
);
}
}

View File

@@ -0,0 +1,20 @@
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(value: unknown) {
try {
return this.schema.parse(value);
} catch (error) {
if (error instanceof ZodError) {
const messages = error.errors.map(
(e) => `${e.path.join('.')}: ${e.message}`,
);
throw new BadRequestException(messages.join('; '));
}
throw new BadRequestException('Validation failed');
}
}
}