initial: standalone repo from monorepo split
This commit is contained in:
15
src/common/decorators/access.decorator.ts
Normal file
15
src/common/decorators/access.decorator.ts
Normal 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);
|
||||
20
src/common/decorators/current-user.decorator.ts
Normal file
20
src/common/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
5
src/common/decorators/permissions.decorator.ts
Normal file
5
src/common/decorators/permissions.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const RequirePermissions = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
5
src/common/decorators/roles.decorator.ts
Normal file
5
src/common/decorators/roles.decorator.ts
Normal 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);
|
||||
55
src/common/dto/pagination.dto.ts
Normal file
55
src/common/dto/pagination.dto.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
39
src/common/filters/http-exception.filter.ts
Normal file
39
src/common/filters/http-exception.filter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
src/common/guards/access.guard.ts
Normal file
53
src/common/guards/access.guard.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/common/guards/permissions.guard.ts
Normal file
34
src/common/guards/permissions.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/common/guards/roles.guard.ts
Normal file
28
src/common/guards/roles.guard.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
36
src/common/guards/tenant.guard.ts
Normal file
36
src/common/guards/tenant.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
src/common/interceptors/response.interceptor.ts
Normal file
24
src/common/interceptors/response.interceptor.ts
Normal 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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
src/common/pipes/zod-validation.pipe.ts
Normal file
20
src/common/pipes/zod-validation.pipe.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user