Commit 84e833db authored by Administrator's avatar Administrator

Update 56 files via Son of Anton

parent 9dc41150
# ============================
# THE GRIND — Backend Environment Variables
# ============================
# App
NODE_ENV=development
PORT=3001
FRONTEND_URL=http://localhost:3000
API_PREFIX=api
CORS_ORIGINS=http://localhost:3000
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/thegrind?schema=public
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT
JWT_SECRET=CHANGE_THIS_TO_A_REAL_SECRET_IN_PRODUCTION
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
JWT_REFRESH_EXPIRY_DAYS=7
# MinIO
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=hr-files
# Upload Limits
MAX_FILE_SIZE_BYTES=26214400
MAX_PROFILE_PHOTO_SIZE_BYTES=5242880
# Rate Limiting
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30
SESSION_TIMEOUT_HOURS=8
\ No newline at end of file
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
\ No newline at end of file
{
"name": "@the-grind/backend",
"version": "1.0.0",
"description": "The Grind HR Platform — Backend API",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node src/prisma/seed.ts",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.3.0",
"@nestjs/swagger": "^7.2.0",
"@nestjs/websockets": "^10.3.0",
"@prisma/client": "^5.8.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"helmet": "^7.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"socket.io": "^4.7.4",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21",
"@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.0",
"@types/uuid": "^9.0.7",
"prisma": "^5.8.0",
"rimraf": "^5.0.5",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}
\ No newline at end of file
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER, APP_PIPE } from '@nestjs/core';
import {
appConfig,
databaseConfig,
redisConfig,
jwtConfig,
minioConfig,
uploadConfig,
} from './config';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module';
import { SettingsModule } from './modules/settings/settings.module';
import { AuditTrailModule } from './modules/audit-trail/audit-trail.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { AuditTrailInterceptor } from './common/interceptors/audit-trail.interceptor';
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ValidationPipe } from './common/pipes/validation.pipe';
import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, redisConfig, jwtConfig, minioConfig, uploadConfig],
envFilePath: ['.env.local', '.env'],
}),
PrismaModule,
AuthModule,
SettingsModule,
AuditTrailModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_INTERCEPTOR, useClass: AuditTrailInterceptor },
{ provide: APP_INTERCEPTOR, useFactory: () => new TimeoutInterceptor(30000) },
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
{ provide: APP_PIPE, useClass: ValidationPipe },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RateLimitMiddleware).forRoutes('*');
}
}
\ No newline at end of file
import { SetMetadata } from '@nestjs/common';
export const API_KEY_SCOPE_KEY = 'apiKeyScope';
export const ApiKeyScope = (...scopes: string[]) => SetMetadata(API_KEY_SCOPE_KEY, scopes);
\ No newline at end of file
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface RequestUser {
id: string;
email: string;
role: string;
sessionId: string;
}
export const CurrentUser = createParamDecorator(
(data: keyof RequestUser | undefined, ctx: ExecutionContext): RequestUser | string => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as RequestUser;
if (data) {
return user[data];
}
return user;
},
);
\ No newline at end of file
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
\ No newline at end of file
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
\ No newline at end of file
export class ApiResponseDto<T> {
success: boolean;
data: T;
message?: string;
meta?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
static success<T>(data: T, message?: string): ApiResponseDto<T> {
const response = new ApiResponseDto<T>();
response.success = true;
response.data = data;
if (message) response.message = message;
return response;
}
static error<T = null>(message: string): ApiResponseDto<T> {
const response = new ApiResponseDto<T>();
response.success = false;
response.data = null as any;
response.message = message;
return response;
}
}
\ No newline at end of file
import { IsOptional, IsInt, Min, Max, IsString, IsIn } 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;
@IsOptional()
@IsString()
sortBy?: string;
@IsOptional()
@IsIn(['asc', 'desc'])
sortOrder?: 'asc' | 'desc' = 'desc';
@IsOptional()
@IsString()
search?: string;
}
\ No newline at end of file
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response, Request } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status: number;
let message: string;
let errors: any = undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
const resp = exceptionResponse as any;
message = resp.message || exception.message;
if (Array.isArray(resp.message)) {
message = 'Validation failed';
errors = resp.message;
}
} else {
message = exception.message;
}
} else if (exception instanceof Error) {
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = 'Internal server error';
this.logger.error(
`Unhandled error on ${request.method} ${request.url}: ${exception.message}`,
exception.stack,
);
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = 'Internal server error';
this.logger.error(`Unknown exception: ${JSON.stringify(exception)}`);
}
const body: Record<string, any> = {
success: false,
message,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
};
if (errors) {
body.errors = errors;
}
response.status(status).json(body);
}
}
\ No newline at end of file
import { Catch, ArgumentsHost, Logger } from '@nestjs/common';
import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets';
@Catch(WsException)
export class WsExceptionFilter extends BaseWsExceptionFilter {
private readonly logger = new Logger(WsExceptionFilter.name);
catch(exception: WsException, host: ArgumentsHost): void {
const client = host.switchToWs().getClient();
const error = exception.getError();
const message = typeof error === 'string' ? error : (error as any)?.message || 'Unknown error';
this.logger.warn(`WS Exception: ${message}`);
client.emit('error', { success: false, message });
}
}
\ No newline at end of file
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PrismaService } from '../../prisma/prisma.service';
import { API_KEY_SCOPE_KEY } from '../decorators/api-key-scope.decorator';
import * as crypto from 'crypto';
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const apiKey = request.headers['x-api-key'] as string;
if (!apiKey) {
return false;
}
const hashedKey = crypto.createHash('sha256').update(apiKey).digest('hex');
const key = await this.prisma.apiKey.findFirst({
where: {
keyHash: hashedKey,
isActive: true,
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
});
if (!key) {
throw new UnauthorizedException('Invalid or expired API key');
}
const requiredScopes = this.reflector.getAllAndOverride<string[]>(API_KEY_SCOPE_KEY, [
context.getHandler(),
context.getClass(),
]);
if (requiredScopes && requiredScopes.length > 0) {
const hasScope = requiredScopes.some((scope) => key.scope === scope || key.scope === 'ADMIN');
if (!hasScope) {
throw new UnauthorizedException(`API key lacks required scope: ${requiredScopes.join(', ')}`);
}
}
await this.prisma.apiKey.update({
where: { id: key.id },
data: { lastUsedAt: new Date() },
});
request.apiKey = key;
request.user = {
id: key.createdById,
email: 'api-key',
role: 'SUPER_ADMIN',
sessionId: `apikey-${key.id}`,
};
return true;
}
}
\ No newline at end of file
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest<TUser = any>(err: any, user: TUser, info: any): TUser {
if (err || !user) {
throw err || new UnauthorizedException('Invalid or expired access token');
}
return user;
}
}
\ No newline at end of file
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { RequestUser } from '../decorators/current-user.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user as RequestUser;
if (!user) {
throw new ForbiddenException('No user found in request');
}
const hasRole = requiredRoles.includes(user.role);
if (!hasRole) {
throw new ForbiddenException(
`Access denied. Required roles: ${requiredRoles.join(', ')}. Your role: ${user.role}`,
);
}
return true;
}
}
\ No newline at end of file
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class WsAuthGuard implements CanActivate {
private readonly logger = new Logger(WsAuthGuard.name);
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
try {
const client: Socket = context.switchToWs().getClient();
const token =
client.handshake.auth?.token ||
client.handshake.headers?.authorization?.replace('Bearer ', '');
if (!token) {
throw new WsException('No authentication token provided');
}
const payload = this.jwtService.verify(token, {
secret: this.configService.get<string>('jwt.secret'),
});
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, role: true, status: true },
});
if (!user) {
throw new WsException('User not found');
}
if (user.status === 'OFFBOARDED') {
throw new WsException('Account is deactivated');
}
(client as any).user = {
id: user.id,
email: user.email,
role: user.role,
sessionId: payload.sessionId,
};
return true;
} catch (error) {
this.logger.warn(`WebSocket auth failed: ${error.message}`);
throw new WsException('Unauthorized');
}
}
}
\ No newline at end of file
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { PrismaService } from '../../prisma/prisma.service';
import { RequestUser } from '../decorators/current-user.decorator';
@Injectable()
export class AuditTrailInterceptor implements NestInterceptor {
private readonly logger = new Logger(AuditTrailInterceptor.name);
constructor(private readonly prisma: PrismaService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
if (!request || request.method === 'GET' || request.method === 'OPTIONS') {
return next.handle();
}
const user = request.user as RequestUser | undefined;
const startTime = Date.now();
return next.handle().pipe(
tap({
next: (responseData) => {
this.logAudit(request, user, responseData, startTime).catch((err) =>
this.logger.error(`Failed to write audit log: ${err.message}`),
);
},
error: (error) => {
this.logAudit(request, user, null, startTime, error.message).catch((err) =>
this.logger.error(`Failed to write audit log: ${err.message}`),
);
},
}),
);
}
private async logAudit(
request: any,
user: RequestUser | undefined,
responseData: any,
startTime: number,
errorMessage?: string,
): Promise<void> {
const durationMs = Date.now() - startTime;
const method = request.method;
const url = request.originalUrl || request.url;
const action = this.resolveAction(method, url);
const { entityType, entityId } = this.resolveEntity(url, request.params);
try {
await this.prisma.auditTrail.create({
data: {
userId: user?.id || null,
action,
entityType,
entityId,
method,
url,
requestBody: method !== 'GET' ? this.sanitizeBody(request.body) : undefined,
responseStatus: errorMessage ? 500 : 200,
errorMessage: errorMessage || null,
ipAddress: request.ip || request.connection?.remoteAddress || 'unknown',
userAgent: request.headers?.['user-agent'] || 'unknown',
durationMs,
},
});
} catch (err) {
this.logger.error(`Audit trail write error: ${err.message}`);
}
}
private resolveAction(method: string, url: string): string {
const urlLower = url.toLowerCase();
if (urlLower.includes('/login')) return 'LOGIN';
if (urlLower.includes('/logout')) return 'LOGOUT';
if (urlLower.includes('/acknowledge')) return 'ACKNOWLEDGE';
if (urlLower.includes('/approve')) return 'APPROVE';
if (urlLower.includes('/reject')) return 'REJECT';
if (urlLower.includes('/move')) return 'MOVE';
if (urlLower.includes('/assign')) return 'ASSIGN';
if (urlLower.includes('/review')) return 'SUBMIT';
switch (method) {
case 'POST': return 'CREATE';
case 'PUT':
case 'PATCH': return 'UPDATE';
case 'DELETE': return 'DELETE';
default: return 'UNKNOWN';
}
}
private resolveEntity(url: string, params: Record<string, string>): { entityType: string; entityId: string | null } {
const segments = url.replace(/^\/api\//, '').split('/').filter(Boolean);
const entityType = segments[0] || 'unknown';
const entityId = params?.id || params?.boardId || params?.cardId || null;
return { entityType, entityId };
}
private sanitizeBody(body: any): any {
if (!body) return undefined;
const sanitized = { ...body };
const sensitiveFields = ['password', 'currentPassword', 'newPassword', 'confirmPassword', 'refreshToken', 'token'];
for (const field of sensitiveFields) {
if (sanitized[field]) {
sanitized[field] = '[REDACTED]';
}
}
return sanitized;
}
}
\ No newline at end of file
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
RequestTimeoutException,
} from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
private readonly timeoutMs: number;
constructor(timeoutMs = 30000) {
this.timeoutMs = timeoutMs;
}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(this.timeoutMs),
catchError((err) => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException('Request timed out'));
}
return throwError(() => err);
}),
);
}
}
\ No newline at end of file
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface ApiResponseShape<T> {
success: boolean;
data: T;
message?: string;
meta?: Record<string, any>;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponseShape<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponseShape<T>> {
return next.handle().pipe(
map((responseData) => {
if (responseData && typeof responseData === 'object' && 'success' in responseData) {
return responseData;
}
if (responseData && typeof responseData === 'object' && 'data' in responseData && 'meta' in responseData) {
return {
success: true,
data: responseData.data,
meta: responseData.meta,
};
}
return {
success: true,
data: responseData,
};
}),
);
}
}
\ No newline at end of file
import { Injectable, NestMiddleware, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
interface RateLimitEntry {
count: number;
resetAt: number;
}
@Injectable()
export class RateLimitMiddleware implements NestMiddleware {
private readonly store = new Map<string, RateLimitEntry>();
private readonly windowMs = 60 * 1000; // 1 minute
private readonly maxRequests = 100;
use(req: Request, _res: Response, next: NextFunction): void {
const key = req.ip || 'unknown';
const now = Date.now();
const entry = this.store.get(key);
if (!entry || now > entry.resetAt) {
this.store.set(key, { count: 1, resetAt: now + this.windowMs });
return next();
}
entry.count++;
if (entry.count > this.maxRequests) {
throw new HttpException('Too many requests. Please slow down.', HttpStatus.TOO_MANY_REQUESTS);
}
next();
}
}
\ No newline at end of file
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
@Injectable()
export class ValidationPipe implements PipeTransform<any> {
async transform(value: any, { metatype }: ArgumentMetadata): Promise<any> {
if (!metatype || !this.toValidate(metatype)) {
return value;
}
const object = plainToInstance(metatype, value);
const errors = await validate(object, {
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
});
if (errors.length > 0) {
const messages = errors.flatMap((err) => {
if (err.constraints) {
return Object.values(err.constraints);
}
return [`${err.property} has invalid value`];
});
throw new BadRequestException(messages);
}
return object;
}
private toValidate(metatype: Function): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
}
\ No newline at end of file
export function getWorkingDaysInMonth(
year: number,
month: number,
scheduledDays: number[],
holidays: Date[] = [],
): number {
const daysInMonth = new Date(year, month, 0).getDate();
let count = 0;
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month - 1, day);
const dayOfWeek = date.getDay();
if (!scheduledDays.includes(dayOfWeek)) {
continue;
}
const isHoliday = holidays.some(
(h) => h.getFullYear() === date.getFullYear() &&
h.getMonth() === date.getMonth() &&
h.getDate() === date.getDate(),
);
if (!isHoliday) {
count++;
}
}
return count;
}
export function getScheduledDaysOfWeek(schedule: Record<string, string>): number[] {
const dayMap: Record<string, number> = {
sunday: 0, monday: 1, tuesday: 2, wednesday: 3,
thursday: 4, friday: 5, saturday: 6,
};
const days: number[] = [];
for (const [dayName, dayType] of Object.entries(schedule)) {
if (dayType !== 'OFF') {
const dayNum = dayMap[dayName.toLowerCase()];
if (dayNum !== undefined) {
days.push(dayNum);
}
}
}
return days;
}
export function isToday(date: Date): boolean {
const today = new Date();
return (
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate()
);
}
export function startOfDay(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
export function endOfDay(date: Date): Date {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
}
export function addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
export function addHours(date: Date, hours: number): Date {
const d = new Date(date);
d.setTime(d.getTime() + hours * 60 * 60 * 1000);
return d;
}
\ No newline at end of file
export function isAllowedMimeType(mimeType: string, allowedTypes: string[]): boolean {
return allowedTypes.includes(mimeType);
}
export function isWithinSizeLimit(sizeBytes: number, maxSizeBytes: number): boolean {
return sizeBytes <= maxSizeBytes;
}
export function generateStoragePath(entityType: string, entityId: string, fileName: string): string {
const uuid = generateUuid();
const sanitizedName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
return `${entityType}/${entityId}/${uuid}-${sanitizedName}`;
}
function generateUuid(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export function getFileExtension(fileName: string): string {
const parts = fileName.split('.');
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
}
export function isImageMimeType(mimeType: string): boolean {
return mimeType.startsWith('image/');
}
\ No newline at end of file
export interface PaginationParams {
page: number;
limit: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface PaginatedResult<T> {
data: T[];
meta: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
export function parsePagination(query: {
page?: number | string;
limit?: number | string;
sortBy?: string;
sortOrder?: string;
}): PaginationParams {
const page = Math.max(1, parseInt(String(query.page || '1'), 10));
const limit = Math.min(100, Math.max(1, parseInt(String(query.limit || '20'), 10)));
const sortOrder = query.sortOrder === 'asc' ? 'asc' : 'desc';
return { page, limit, sortBy: query.sortBy, sortOrder };
}
export function buildPaginatedResponse<T>(
data: T[],
total: number,
params: PaginationParams,
): PaginatedResult<T> {
return {
data,
meta: {
page: params.page,
limit: params.limit,
total,
totalPages: Math.ceil(total / params.limit),
},
};
}
export function getSkip(page: number, limit: number): number {
return (page - 1) * limit;
}
\ No newline at end of file
export function calculateBaseSalaryPiasters(
schedule: Record<string, string>,
contractorType: string,
dayRates: {
fullTimeInOffice: number;
fullTimeRemote: number;
internInOffice: number;
internRemote: number;
},
): number {
let inOfficeCount = 0;
let remoteCount = 0;
for (const dayType of Object.values(schedule)) {
if (dayType === 'IN_OFFICE') inOfficeCount++;
else if (dayType === 'REMOTE') remoteCount++;
}
const isFullTime = contractorType === 'FULL_TIME';
const inOfficeRate = isFullTime ? dayRates.fullTimeInOffice : dayRates.internInOffice;
const remoteRate = isFullTime ? dayRates.fullTimeRemote : dayRates.internRemote;
return (inOfficeCount * inOfficeRate) + (remoteCount * remoteRate);
}
export function calculateDailyRatePiasters(
actualSalaryPiasters: number,
expectedWorkingDays: number,
): number {
if (expectedWorkingDays <= 0) return 0;
return Math.round(actualSalaryPiasters / expectedWorkingDays);
}
export function piasterToEgp(piasters: number): number {
return piasters / 100;
}
export function egpToPiasters(egp: number): number {
return Math.round(egp * 100);
}
export function formatEgp(piasters: number): string {
const egp = piasters / 100;
return new Intl.NumberFormat('en-EG', {
style: 'currency',
currency: 'EGP',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(egp);
}
\ No newline at end of file
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3001', 10),
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
apiPrefix: process.env.API_PREFIX || 'api',
corsOrigins: process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
: ['http://localhost:3000'],
sessionTimeout: parseInt(process.env.SESSION_TIMEOUT_HOURS || '8', 10),
maxLoginAttempts: parseInt(process.env.MAX_LOGIN_ATTEMPTS || '5', 10),
lockoutDurationMinutes: parseInt(process.env.LOCKOUT_DURATION_MINUTES || '30', 10),
maxDailyLoginAttempts: parseInt(process.env.MAX_DAILY_LOGIN_ATTEMPTS || '15', 10),
}));
\ No newline at end of file
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
url: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/thegrind?schema=public',
}));
\ No newline at end of file
export { default as appConfig } from './app.config';
export { default as databaseConfig } from './database.config';
export { default as redisConfig } from './redis.config';
export { default as jwtConfig } from './jwt.config';
export { default as minioConfig } from './minio.config';
export { default as uploadConfig } from './upload.config';
\ No newline at end of file
import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => ({
secret: process.env.JWT_SECRET || 'CHANGE_ME_IN_PRODUCTION_OR_GET_HACKED',
accessTokenExpiry: process.env.JWT_ACCESS_EXPIRY || '15m',
refreshTokenExpiry: process.env.JWT_REFRESH_EXPIRY || '7d',
refreshTokenExpiryDays: parseInt(process.env.JWT_REFRESH_EXPIRY_DAYS || '7', 10),
}));
\ No newline at end of file
import { registerAs } from '@nestjs/config';
export default registerAs('minio', () => ({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT || '9000', 10),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
bucket: process.env.MINIO_BUCKET || 'hr-files',
}));
\ No newline at end of file
import { registerAs } from '@nestjs/config';
export default registerAs('redis', () => ({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0', 10),
}));
\ No newline at end of file
import { registerAs } from '@nestjs/config';
export default registerAs('upload', () => ({
maxFileSizeBytes: parseInt(process.env.MAX_FILE_SIZE_BYTES || '26214400', 10), // 25MB
maxProfilePhotoSizeBytes: parseInt(process.env.MAX_PROFILE_PHOTO_SIZE_BYTES || '5242880', 10), // 5MB
allowedImageMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
allowedFileMimeTypes: [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain', 'text/csv',
'application/zip', 'application/x-rar-compressed',
'video/mp4', 'audio/mpeg',
],
maxAttachmentsPerCard: 20,
}));
\ No newline at end of file
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as cookieParser from 'cookie-parser';
import * as compression from 'compression';
import helmet from 'helmet';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug'],
});
const configService = app.get(ConfigService);
// Security
app.use(helmet());
app.use(cookieParser());
app.use(compression());
// CORS
const corsOrigins = configService.get<string[]>('app.corsOrigins') || ['http://localhost:3000'];
app.enableCors({
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
});
// Global prefix
const apiPrefix = configService.get<string>('app.apiPrefix') || 'api';
app.setGlobalPrefix(apiPrefix);
// Swagger
if (configService.get<string>('app.nodeEnv') !== 'production') {
const swaggerConfig = new DocumentBuilder()
.setTitle('The Grind — HR Platform API')
.setDescription('AL-Arcade HR Platform v3.0 API Documentation')
.setVersion('3.0')
.addBearerAuth()
.addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'api-key')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, document);
logger.log('Swagger docs available at /docs');
}
const port = configService.get<number>('app.port') || 3001;
await app.listen(port);
logger.log(`🔥 The Grind backend is running on port ${port}`);
logger.log(`📝 API prefix: /${apiPrefix}`);
logger.log(`🌍 Environment: ${configService.get<string>('app.nodeEnv')}`);
}
bootstrap();
\ No newline at end of file
import { Controller, Get, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { AuditTrailService } from './audit-trail.service';
import { AuditTrailFilterDto } from './dto/audit-trail-filter.dto';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('audit-trail')
export class AuditTrailController {
constructor(private readonly auditTrailService: AuditTrailService) {}
@Get()
@Roles('SUPER_ADMIN', 'ADMIN')
async findAll(@Query() filter: AuditTrailFilterDto) {
return this.auditTrailService.findAll(filter);
}
@Get('export')
@Roles('SUPER_ADMIN')
async exportAuditTrail(@Query() filter: AuditTrailFilterDto, @Res() res: Response) {
const data = await this.auditTrailService.exportAll(filter);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename=audit-trail-${Date.now()}.json`);
res.send(JSON.stringify(data, null, 2));
}
}
\ No newline at end of file
import { Module, Global } from '@nestjs/common';
import { AuditTrailController } from './audit-trail.controller';
import { AuditTrailService } from './audit-trail.service';
@Global()
@Module({
controllers: [AuditTrailController],
providers: [AuditTrailService],
exports: [AuditTrailService],
})
export class AuditTrailModule {}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditTrailFilterDto } from './dto/audit-trail-filter.dto';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class AuditTrailService {
private readonly logger = new Logger(AuditTrailService.name);
constructor(private readonly prisma: PrismaService) {}
async findAll(filter: AuditTrailFilterDto): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
if (filter.userId) where.userId = filter.userId;
if (filter.action) where.action = filter.action;
if (filter.entityType) where.entityType = filter.entityType;
if (filter.entityId) where.entityId = filter.entityId;
if (filter.dateFrom || filter.dateTo) {
where.createdAt = {};
if (filter.dateFrom) where.createdAt.gte = new Date(filter.dateFrom);
if (filter.dateTo) where.createdAt.lte = new Date(filter.dateTo);
}
const [data, total] = await Promise.all([
this.prisma.auditTrail.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: filter.sortOrder || 'desc' },
include: {
user: {
select: { id: true, username: true, firstName: true, lastName: true, role: true },
},
},
}),
this.prisma.auditTrail.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: filter.sortOrder || 'desc' });
}
async exportAll(filter: AuditTrailFilterDto): Promise<any[]> {
const where: any = {};
if (filter.userId) where.userId = filter.userId;
if (filter.action) where.action = filter.action;
if (filter.entityType) where.entityType = filter.entityType;
if (filter.dateFrom || filter.dateTo) {
where.createdAt = {};
if (filter.dateFrom) where.createdAt.gte = new Date(filter.dateFrom);
if (filter.dateTo) where.createdAt.lte = new Date(filter.dateTo);
}
return this.prisma.auditTrail.findMany({
where,
orderBy: { createdAt: 'desc' },
take: 50000,
include: {
user: {
select: { id: true, username: true, firstName: true, lastName: true, role: true },
},
},
});
}
async log(data: {
userId?: string | null;
action: string;
entityType: string;
entityId?: string | null;
before?: any;
after?: any;
ipAddress?: string;
userAgent?: string;
}): Promise<void> {
try {
await this.prisma.auditTrail.create({
data: {
userId: data.userId || null,
action: data.action,
entityType: data.entityType,
entityId: data.entityId || null,
requestBody: data.before ? { before: data.before, after: data.after } : data.after,
ipAddress: data.ipAddress || 'system',
userAgent: data.userAgent || 'system',
},
});
} catch (err) {
this.logger.error(`Failed to log audit trail: ${err.message}`);
}
}
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString } from 'class-validator';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class AuditTrailFilterDto extends PaginationDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
action?: string;
@IsOptional()
@IsString()
entityType?: string;
@IsOptional()
@IsString()
entityId?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
}
\ No newline at end of file
export class AuditTrailResponseDto {
id: string;
userId: string | null;
action: string;
entityType: string;
entityId: string | null;
method: string;
url: string;
requestBody: any;
responseStatus: number | null;
errorMessage: string | null;
ipAddress: string;
userAgent: string;
durationMs: number | null;
createdAt: string;
user?: {
id: string;
username: string;
firstName: string;
lastName: string;
role: string;
} | null;
}
\ No newline at end of file
import {
Controller,
Post,
Body,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto, @Req() req: Request) {
const ipAddress = req.ip || req.connection?.remoteAddress || 'unknown';
const userAgent = req.headers['user-agent'] || 'unknown';
return this.authService.login(dto, ipAddress, userAgent);
}
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() dto: RefreshTokenDto, @Req() req: Request) {
const ipAddress = req.ip || req.connection?.remoteAddress || 'unknown';
const userAgent = req.headers['user-agent'] || 'unknown';
return this.authService.refresh(dto.refreshToken, ipAddress, userAgent);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(@CurrentUser() user: RequestUser) {
await this.authService.logout(user.sessionId);
return { message: 'Logged out successfully' };
}
@Post('change-password')
@HttpCode(HttpStatus.OK)
async changePassword(
@CurrentUser() user: RequestUser,
@Body() dto: ChangePasswordDto,
) {
await this.authService.changePassword(user.id, dto);
return { message: 'Password changed successfully' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('jwt.secret'),
signOptions: {
expiresIn: configService.get<string>('jwt.accessTokenExpiry') || '15m',
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
\ No newline at end of file
import {
Injectable,
UnauthorizedException,
BadRequestException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { AuthResponseDto } from './dto/auth-response.dto';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly BCRYPT_ROUNDS = 12;
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async login(dto: LoginDto, ipAddress: string, userAgent: string): Promise<AuthResponseDto> {
const user = await this.prisma.user.findFirst({
where: {
OR: [
{ email: dto.login },
{ username: dto.login },
],
},
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
if (user.status === 'OFFBOARDED') {
throw new ForbiddenException('Account has been deactivated. Contact your administrator.');
}
if (user.lockedUntil && user.lockedUntil > new Date()) {
const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000);
throw new ForbiddenException(
`Account is temporarily locked. Try again in ${minutesLeft} minute(s).`,
);
}
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!isPasswordValid) {
await this.handleFailedLogin(user.id);
throw new UnauthorizedException('Invalid credentials');
}
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: new Date(),
},
});
const sessionId = uuidv4();
const refreshTokenExpiryDays = this.configService.get<number>('jwt.refreshTokenExpiryDays') || 7;
await this.prisma.session.create({
data: {
id: sessionId,
userId: user.id,
ipAddress,
userAgent,
expiresAt: new Date(Date.now() + refreshTokenExpiryDays * 24 * 60 * 60 * 1000),
},
});
const payload = {
sub: user.id,
email: user.email,
role: user.role,
sessionId,
};
const accessToken = this.jwtService.sign(payload, {
expiresIn: this.configService.get<string>('jwt.accessTokenExpiry') || '15m',
});
const refreshToken = this.jwtService.sign(
{ sub: user.id, sessionId, type: 'refresh' },
{ expiresIn: this.configService.get<string>('jwt.refreshTokenExpiry') || '7d' },
);
return {
accessToken,
refreshToken,
expiresIn: 900,
user: {
id: user.id,
email: user.email,
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
displayName: user.displayName,
avatar: user.avatar,
role: user.role,
status: user.status,
forcePasswordChange: user.forcePasswordChange,
},
};
}
async refresh(refreshToken: string, ipAddress: string, userAgent: string): Promise<AuthResponseDto> {
let payload: any;
try {
payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get<string>('jwt.secret'),
});
} catch {
throw new UnauthorizedException('Invalid or expired refresh token');
}
if (payload.type !== 'refresh') {
throw new UnauthorizedException('Invalid token type');
}
const session = await this.prisma.session.findUnique({
where: { id: payload.sessionId },
include: { user: true },
});
if (!session || session.revokedAt || session.expiresAt < new Date()) {
throw new UnauthorizedException('Session is invalid or expired');
}
const user = session.user;
if (user.status === 'OFFBOARDED') {
throw new ForbiddenException('Account has been deactivated');
}
await this.prisma.session.update({
where: { id: session.id },
data: { lastActiveAt: new Date(), ipAddress, userAgent },
});
const newPayload = {
sub: user.id,
email: user.email,
role: user.role,
sessionId: session.id,
};
const accessToken = this.jwtService.sign(newPayload, {
expiresIn: this.configService.get<string>('jwt.accessTokenExpiry') || '15m',
});
const newRefreshToken = this.jwtService.sign(
{ sub: user.id, sessionId: session.id, type: 'refresh' },
{ expiresIn: this.configService.get<string>('jwt.refreshTokenExpiry') || '7d' },
);
return {
accessToken,
refreshToken: newRefreshToken,
expiresIn: 900,
user: {
id: user.id,
email: user.email,
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
displayName: user.displayName,
avatar: user.avatar,
role: user.role,
status: user.status,
forcePasswordChange: user.forcePasswordChange,
},
};
}
async logout(sessionId: string): Promise<void> {
await this.prisma.session.update({
where: { id: sessionId },
data: { revokedAt: new Date() },
});
}
async changePassword(userId: string, dto: ChangePasswordDto): Promise<void> {
if (dto.newPassword !== dto.confirmPassword) {
throw new BadRequestException('Passwords do not match');
}
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException('User not found');
}
const isCurrentValid = await bcrypt.compare(dto.currentPassword, user.passwordHash);
if (!isCurrentValid) {
throw new BadRequestException('Current password is incorrect');
}
if (dto.currentPassword === dto.newPassword) {
throw new BadRequestException('New password must be different from current password');
}
const newHash = await bcrypt.hash(dto.newPassword, this.BCRYPT_ROUNDS);
await this.prisma.user.update({
where: { id: userId },
data: {
passwordHash: newHash,
forcePasswordChange: false,
},
});
}
private async handleFailedLogin(userId: string): Promise<void> {
const maxAttempts = this.configService.get<number>('app.maxLoginAttempts') || 5;
const lockoutMinutes = this.configService.get<number>('app.lockoutDurationMinutes') || 30;
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { failedLoginAttempts: true },
});
const newAttempts = (user?.failedLoginAttempts || 0) + 1;
const updateData: any = { failedLoginAttempts: newAttempts };
if (newAttempts >= maxAttempts) {
updateData.lockedUntil = new Date(Date.now() + lockoutMinutes * 60 * 1000);
this.logger.warn(`User ${userId} locked out after ${newAttempts} failed attempts`);
}
await this.prisma.user.update({
where: { id: userId },
data: updateData,
});
}
}
\ No newline at end of file
export class AuthResponseDto {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: {
id: string;
email: string;
username: string;
firstName: string;
lastName: string;
displayName: string | null;
avatar: string | null;
role: string;
status: string;
forcePasswordChange: boolean;
};
}
\ No newline at end of file
import { IsString, MinLength, Matches } from 'class-validator';
export class ChangePasswordDto {
@IsString()
@MinLength(1)
currentPassword: string;
@IsString()
@MinLength(10, { message: 'Password must be at least 10 characters' })
@Matches(/(?=.*[a-z])/, { message: 'Password must contain at least one lowercase letter' })
@Matches(/(?=.*[A-Z])/, { message: 'Password must contain at least one uppercase letter' })
@Matches(/(?=.*\d)/, { message: 'Password must contain at least one number' })
@Matches(/(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/, {
message: 'Password must contain at least one special character',
})
newPassword: string;
@IsString()
confirmPassword: string;
}
\ No newline at end of file
import { IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
@MinLength(1, { message: 'Username or email is required' })
login: string;
@IsString()
@MinLength(1, { message: 'Password is required' })
password: string;
}
\ No newline at end of file
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken: string;
}
\ No newline at end of file
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../../prisma/prisma.service';
import { Request } from 'express';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req: Request) => {
return req?.cookies?.accessToken || null;
},
]),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.secret'),
});
}
async validate(payload: { sub: string; email: string; role: string; sessionId: string }) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, role: true, status: true },
});
if (!user) {
throw new UnauthorizedException('User no longer exists');
}
if (user.status === 'OFFBOARDED') {
throw new UnauthorizedException('Account has been deactivated');
}
const session = await this.prisma.session.findUnique({
where: { id: payload.sessionId },
});
if (!session || session.revokedAt) {
throw new UnauthorizedException('Session has been revoked');
}
if (session.expiresAt < new Date()) {
throw new UnauthorizedException('Session has expired');
}
return {
id: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
}
}
\ No newline at end of file
export class SettingResponseDto {
id: string;
key: string;
value: any;
description: string | null;
updatedAt: string;
}
\ No newline at end of file
import { IsString, IsNotEmpty } from 'class-validator';
export class UpdateSettingDto {
@IsString()
@IsNotEmpty()
key: string;
value: any;
}
export class UpdateSettingsBulkDto {
settings: UpdateSettingDto[];
}
\ No newline at end of file
import { Controller, Get, Put, Body } from '@nestjs/common';
import { SettingsService } from './settings.service';
import { UpdateSettingsBulkDto } from './dto/update-settings.dto';
import { Roles } from '../../common/decorators/roles.decorator';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
@Controller('settings')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get()
@Roles('SUPER_ADMIN', 'ADMIN')
async getAll() {
return this.settingsService.getAll();
}
@Put()
@Roles('SUPER_ADMIN')
async update(@Body() dto: UpdateSettingsBulkDto, @CurrentUser() user: RequestUser) {
await this.settingsService.setBulk(dto.settings, user.id);
return { message: 'Settings updated successfully' };
}
}
\ No newline at end of file
import { Module, Global } from '@nestjs/common';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
@Global()
@Module({
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}
\ No newline at end of file
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export interface SystemSettings {
// Schedule & Time
workingDays: string[];
reportSubmissionDeadline: string;
lateReportGracePeriodHours: number;
unreportedDayDetectionTime: string;
sessionTimeoutHours: number;
maxLoginAttempts: number;
lockoutDurationMinutes: number;
// Salary
fullTimeInOfficeRate: number;
fullTimeRemoteRate: number;
internInOfficeRate: number;
internRemoteRate: number;
// Payroll
payrollCalculationDay: number;
// Evaluation
technicalEvaluationDeadlineDays: number;
professionalEvaluationDeadlineDays: number;
contractorResponseWindowDays: number;
// Deduction
deductionResponseWindowHours: number;
pipThresholdPercent: number;
autoApplyOnNoResponse: boolean;
// Bounty
minimumBountyPiasters: number;
adminBountyCapPiasters: number;
adminMonthlyBountyBudgetPiasters: number;
// Board
autoArchiveDoneCardsDays: number;
// Schedule Changes
maxScheduleChangesPerQuarter: number;
scheduleChangeMinNoticeDays: number;
// Display
showRankOnHud: boolean;
darkModeAvailable: boolean;
defaultTheme: string;
companyName: string;
}
@Injectable()
export class SettingsService {
private readonly logger = new Logger(SettingsService.name);
private cache = new Map<string, any>();
constructor(private readonly prisma: PrismaService) {}
async getAll(): Promise<Record<string, any>> {
const settings = await this.prisma.setting.findMany();
const result: Record<string, any> = {};
for (const s of settings) {
result[s.key] = s.value;
this.cache.set(s.key, s.value);
}
return result;
}
async get<T = any>(key: string): Promise<T> {
if (this.cache.has(key)) {
return this.cache.get(key) as T;
}
const setting = await this.prisma.setting.findUnique({ where: { key } });
if (!setting) {
throw new NotFoundException(`Setting "${key}" not found`);
}
this.cache.set(key, setting.value);
return setting.value as T;
}
async getOrDefault<T = any>(key: string, defaultValue: T): Promise<T> {
try {
return await this.get<T>(key);
} catch {
return defaultValue;
}
}
async set(key: string, value: any, updatedById: string): Promise<void> {
await this.prisma.setting.upsert({
where: { key },
update: { value, updatedById },
create: { key, value, updatedById },
});
this.cache.set(key, value);
this.logger.log(`Setting "${key}" updated by user ${updatedById}`);
}
async setBulk(updates: Array<{ key: string; value: any }>, updatedById: string): Promise<void> {
for (const update of updates) {
await this.set(update.key, update.value, updatedById);
}
}
clearCache(): void {
this.cache.clear();
}
}
\ No newline at end of file
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
\ No newline at end of file
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'info' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
],
});
}
async onModuleInit(): Promise<void> {
this.logger.log('Connecting to database...');
await this.$connect();
this.logger.log('Database connected successfully.');
}
async onModuleDestroy(): Promise<void> {
this.logger.log('Disconnecting from database...');
await this.$disconnect();
this.logger.log('Database disconnected.');
}
async cleanDatabase(): Promise<void> {
if (process.env.NODE_ENV === 'production') {
throw new Error('cleanDatabase is not allowed in production');
}
const models = Reflect.ownKeys(this).filter(
(key) => typeof key === 'string' && !key.startsWith('_') && !key.startsWith('$'),
);
for (const model of models) {
const m = this[model as string];
if (m && typeof m.deleteMany === 'function') {
await m.deleteMany();
}
}
}
}
\ No newline at end of file
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding database...');
// 1. Create Super Admin
const passwordHash = await bcrypt.hash('ChangeMe@2025!', 12);
const superAdmin = await prisma.user.upsert({
where: { email: 'admin@thegrind.local' },
update: {},
create: {
email: 'admin@thegrind.local',
username: 'admin',
firstName: 'Super',
lastName: 'Admin',
passwordHash,
role: 'SUPER_ADMIN',
status: 'ACTIVE',
forcePasswordChange: true,
},
});
console.log(`✅ Super Admin created: ${superAdmin.username} (${superAdmin.email})`);
// 2. Default Settings
const defaultSettings: Array<{ key: string; value: any; description?: string }> = [
{ key: 'workingDays', value: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday'], description: 'Potential working days of the week' },
{ key: 'reportSubmissionDeadline', value: '23:59', description: 'Daily report submission deadline (HH:mm)' },
{ key: 'lateReportGracePeriodHours', value: 24, description: 'Hours after deadline before report becomes unreported' },
{ key: 'unreportedDayDetectionTime', value: '01:00', description: 'Time the unreported day detection job runs (HH:mm)' },
{ key: 'sessionTimeoutHours', value: 8, description: 'Auto-logout after N hours of inactivity' },
{ key: 'maxLoginAttempts', value: 5, description: 'Failed login attempts before lockout' },
{ key: 'lockoutDurationMinutes', value: 30, description: 'Temporary lockout duration in minutes' },
{ key: 'fullTimeInOfficeRate', value: 240000, description: 'Full-timer in-office day rate in piasters (2400 EGP)' },
{ key: 'fullTimeRemoteRate', value: 160000, description: 'Full-timer remote day rate in piasters (1600 EGP)' },
{ key: 'internInOfficeRate', value: 100000, description: 'Intern in-office day rate in piasters (1000 EGP)' },
{ key: 'internRemoteRate', value: 50000, description: 'Intern remote day rate in piasters (500 EGP)' },
{ key: 'payrollCalculationDay', value: 25, description: 'Day of month payroll auto-calculates' },
{ key: 'technicalEvaluationDeadlineDays', value: 5, description: 'Business days for PL to submit technical eval' },
{ key: 'professionalEvaluationDeadlineDays', value: 7, description: 'Business days for Admin to submit professional eval' },
{ key: 'contractorResponseWindowDays', value: 5, description: 'Business days for contractor to respond to eval' },
{ key: 'deductionResponseWindowHours', value: 48, description: 'Hours contractor has to respond to a deduction' },
{ key: 'pipThresholdPercent', value: 40, description: 'Deduction % of salary that triggers PIP' },
{ key: 'autoApplyOnNoResponse', value: true, description: 'Auto-apply deductions after response window expires' },
{ key: 'minimumBountyPiasters', value: 5000, description: 'Minimum bounty amount in piasters (50 EGP)' },
{ key: 'adminBountyCapPiasters', value: 500000, description: 'Max single bounty for Admin in piasters (5000 EGP)' },
{ key: 'adminMonthlyBountyBudgetPiasters', value: 5000000, description: 'Monthly admin bounty budget in piasters (50000 EGP)' },
{ key: 'autoArchiveDoneCardsDays', value: 30, description: 'Days after Done before auto-archiving cards' },
{ key: 'maxScheduleChangesPerQuarter', value: 1, description: 'Max schedule change requests per quarter' },
{ key: 'scheduleChangeMinNoticeDays', value: 7, description: 'Min days in advance for schedule change effective date' },
{ key: 'showRankOnHud', value: true, description: 'Show anonymous rank on contractor HUD' },
{ key: 'darkModeAvailable', value: true, description: 'Allow dark mode toggle' },
{ key: 'defaultTheme', value: 'light', description: 'Default theme for new users' },
{ key: 'companyName', value: 'AL-Arcade', description: 'Company name displayed throughout the system' },
{ key: 'reportAutoApprovalEnabled', value: false, description: 'Auto-approve reports that meet all criteria' },
];
for (const setting of defaultSettings) {
await prisma.setting.upsert({
where: { key: setting.key },
update: {},
create: {
key: setting.key,
value: setting.value,
description: setting.description || null,
updatedById: superAdmin.id,
},
});
}
console.log(`✅ ${defaultSettings.length} default settings created`);
// 3. Default Organization Labels
const defaultLabels = [
{ name: 'Bug', color: '#EF4444', textColor: '#FFFFFF' },
{ name: 'Feature', color: '#22C55E', textColor: '#FFFFFF' },
{ name: 'Enhancement', color: '#3B82F6', textColor: '#FFFFFF' },
{ name: 'UI/UX', color: '#EAB308', textColor: '#000000' },
{ name: 'Backend', color: '#8B5CF6', textColor: '#FFFFFF' },
{ name: 'Urgent', color: '#F97316', textColor: '#FFFFFF' },
{ name: 'DevOps', color: '#1F2937', textColor: '#FFFFFF' },
{ name: 'Design', color: '#EC4899', textColor: '#FFFFFF' },
{ name: 'Research', color: '#06B6D4', textColor: '#000000' },
{ name: 'Documentation', color: '#6B7280', textColor: '#FFFFFF' },
];
for (const label of defaultLabels) {
await prisma.label.upsert({
where: {
name_boardId: { name: label.name, boardId: 'org-level' },
},
update: {},
create: {
name: label.name,
color: label.color,
textColor: label.textColor,
scope: 'ORGANIZATION',
createdById: superAdmin.id,
},
});
}
console.log(`✅ ${defaultLabels.length} default labels created`);
// 4. Default Competency Areas
const competencyAreas = [
{ name: 'Device maintenance, debugging, and OS troubleshooting', order: 1 },
{ name: 'Collaborative work and source control (Git)', order: 2 },
{ name: 'C# mastery: data structures, algorithms, OOP', order: 3 },
{ name: 'Design patterns, architecture, parallel/concurrent programming', order: 4 },
{ name: 'Legacy code: maintenance, debugging, upgrading', order: 5 },
{ name: 'Unity Game Development and render pipelines', order: 6 },
{ name: 'Deployment: PC, Android, Web', order: 7 },
{ name: 'Unity Netcode for GameObjects + Unity multiplayer services', order: 8 },
{ name: 'Industry-standard Unity assets (DOTween, Feel, TextMeshPro, etc.)', order: 9 },
{ name: 'Unity + MySQL: basic CRUD', order: 10 },
{ name: 'Unity + Firebase services', order: 11 },
];
for (const area of competencyAreas) {
await prisma.competencyArea.upsert({
where: { name: area.name },
update: {},
create: {
name: area.name,
order: area.order,
isActive: true,
},
});
}
console.log(`✅ ${competencyAreas.length} competency areas created`);
console.log('🌱 Seeding complete!');
}
main()
.catch((e) => {
console.error('❌ Seed error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
\ No newline at end of file
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
\ No newline at end of file
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"esModuleInterop": true
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment