Commit f9cd0fe6 authored by Administrator's avatar Administrator

Update 22 files via Son of Anton

parent 84e833db
......@@ -15,6 +15,8 @@ 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 { UsersModule } from './modules/users/users.module';
import { OnboardingModule } from './modules/onboarding/onboarding.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
......@@ -36,6 +38,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
AuthModule,
SettingsModule,
AuditTrailModule,
UsersModule,
OnboardingModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
......
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export interface ChecklistItem {
key: string;
label: string;
completed: boolean;
completedAt: string | null;
verificationMethod: 'AUTOMATIC' | 'MANUAL';
verifiedBy: string | null;
}
const DEFAULT_CHECKLIST_ITEMS: Array<Omit<ChecklistItem, 'completed' | 'completedAt' | 'verifiedBy'>> = [
{ key: 'profile_photo', label: 'Profile photo uploaded', verificationMethod: 'AUTOMATIC' },
{ key: 'bank_details', label: 'Bank details provided', verificationMethod: 'AUTOMATIC' },
{ key: 'contract_signed', label: 'Contract signed', verificationMethod: 'AUTOMATIC' },
{ key: 'policies_acknowledged', label: 'All policies acknowledged', verificationMethod: 'AUTOMATIC' },
{ key: 'self_assessment', label: 'Competency self-assessment completed', verificationMethod: 'AUTOMATIC' },
{ key: 'device_setup', label: 'Device setup confirmed', verificationMethod: 'MANUAL' },
{ key: 'source_control', label: 'Source control access configured', verificationMethod: 'MANUAL' },
{ key: 'first_board', label: 'First board assigned', verificationMethod: 'MANUAL' },
{ key: 'intro_meeting', label: 'Introduction meeting completed', verificationMethod: 'MANUAL' },
];
@Injectable()
export class ChecklistService {
private readonly logger = new Logger(ChecklistService.name);
constructor(private readonly prisma: PrismaService) {}
async getChecklist(userId: string): Promise<{ items: ChecklistItem[]; completionPercentage: number }> {
const user = await this.prisma.user.findFirst({
where: { id: userId, deletedAt: null },
});
if (!user) {
throw new NotFoundException('User not found');
}
const storedChecklist = (user as any).onboardingChecklist as Record<string, any> | null;
const items: ChecklistItem[] = DEFAULT_CHECKLIST_ITEMS.map((item) => {
const stored = storedChecklist?.[item.key];
return {
...item,
completed: stored?.completed || false,
completedAt: stored?.completedAt || null,
verifiedBy: stored?.verifiedBy || null,
};
});
// Auto-check automatic items
await this.autoCheckItems(userId, items);
const completedCount = items.filter((i) => i.completed).length;
const completionPercentage = Math.round((completedCount / items.length) * 100);
return { items, completionPercentage };
}
async updateItem(userId: string, itemKey: string, completed: boolean, verifiedById: string): Promise<void> {
const user = await this.prisma.user.findFirst({ where: { id: userId, deletedAt: null } });
if (!user) {
throw new NotFoundException('User not found');
}
const item = DEFAULT_CHECKLIST_ITEMS.find((i) => i.key === itemKey);
if (!item) {
throw new NotFoundException('Checklist item not found');
}
const checklist = ((user as any).onboardingChecklist as Record<string, any>) || {};
checklist[itemKey] = {
completed,
completedAt: completed ? new Date().toISOString() : null,
verifiedBy: completed ? verifiedById : null,
};
await this.prisma.user.update({
where: { id: userId },
data: { onboardingChecklist: checklist } as any,
});
}
async isComplete(userId: string): Promise<boolean> {
const { completionPercentage } = await this.getChecklist(userId);
return completionPercentage === 100;
}
private async autoCheckItems(userId: string, items: ChecklistItem[]): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
avatar: true,
bankName: true,
bankAccountNumber: true,
onboardingChecklist: true,
},
});
if (!user) return;
let updated = false;
const checklist = ((user as any).onboardingChecklist as Record<string, any>) || {};
// Profile photo
const photoItem = items.find((i) => i.key === 'profile_photo');
if (photoItem && !photoItem.completed && user.avatar) {
photoItem.completed = true;
photoItem.completedAt = new Date().toISOString();
photoItem.verifiedBy = 'SYSTEM';
checklist['profile_photo'] = { completed: true, completedAt: photoItem.completedAt, verifiedBy: 'SYSTEM' };
updated = true;
}
// Bank details
const bankItem = items.find((i) => i.key === 'bank_details');
if (bankItem && !bankItem.completed && user.bankName && user.bankAccountNumber) {
bankItem.completed = true;
bankItem.completedAt = new Date().toISOString();
bankItem.verifiedBy = 'SYSTEM';
checklist['bank_details'] = { completed: true, completedAt: bankItem.completedAt, verifiedBy: 'SYSTEM' };
updated = true;
}
if (updated) {
await this.prisma.user.update({
where: { id: userId },
data: { onboardingChecklist: checklist } as any,
});
}
}
}
\ No newline at end of file
import { IsString, IsBoolean } from 'class-validator';
export class UpdateChecklistItemDto {
@IsString()
itemKey: string;
@IsBoolean()
completed: boolean;
}
\ No newline at end of file
import { IsString, IsBoolean, IsArray, MinLength } from 'class-validator';
export class ContractSignDto {
@IsString()
userId: string;
@IsArray()
@IsString({ each: true })
acknowledgedClauses: string[];
// ['deduction_policy', 'ip_assignment', 'nda', 'termination_terms', 'code_of_conduct', 'data_security', 'salary_adjustment']
@IsString()
@MinLength(4)
signedFullName: string;
@IsBoolean()
confirmedDigitalSignature: boolean;
@IsString()
ipAddress: string;
@IsString()
userAgent: string;
}
\ No newline at end of file
import { IsString, IsOptional, IsEnum, IsInt, Min, Max, IsArray } from 'class-validator';
export class CreateInviteDto {
@IsEnum(['FULL_TIME', 'PART_TIME', 'PROJECT_BASED'])
contractorType: string;
@IsOptional()
@IsString()
assignedProjectLeaderId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
assignedBoardIds?: string[];
@IsOptional()
@IsInt()
@Min(1)
@Max(30)
expirationDays?: number;
@IsOptional()
@IsString()
welcomeNote?: string;
}
\ No newline at end of file
import { IsString, MinLength, MaxLength, Matches, IsOptional, IsDateString, IsEnum } from 'class-validator';
export class RegisterDto {
@IsString()
inviteCode: string;
@IsString()
@MinLength(4)
nameArabic: string;
@IsString()
@MinLength(4)
firstName: string;
@IsString()
@MinLength(4)
lastName: string;
@IsString()
@Matches(/^\d{14}$/, { message: 'National ID must be 14 digits' })
nationalId: string;
@IsDateString()
dateOfBirth: string;
@IsString()
@Matches(/^01\d{9}$/, { message: 'Primary phone must be Egyptian format (01XXXXXXXXX)' })
phone: string;
@IsOptional()
@IsString()
@Matches(/^01\d{9}$/, { message: 'Secondary phone must be Egyptian format (01XXXXXXXXX)' })
phoneSecondary?: string;
@IsString()
@MinLength(20, { message: 'Address must be at least 20 characters' })
address: string;
@IsString()
@MinLength(4)
emergencyContactName: string;
@IsString()
@Matches(/^01\d{9}$/)
emergencyContactPhone: string;
@IsEnum(['PARENT', 'SIBLING', 'SPOUSE', 'FRIEND', 'OTHER'])
emergencyContactRelationship: string;
@IsString()
bankName: string;
@IsString()
bankAccountNumber: string;
@IsString()
@MinLength(4)
bankAccountHolderName: string;
@IsOptional()
@IsString()
taxRegistrationNumber?: string;
@IsString()
@MinLength(3)
@MaxLength(30)
@Matches(/^[a-zA-Z0-9_]+$/, { message: 'Username must be alphanumeric with underscores' })
username: string;
@IsString()
@MinLength(10)
@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',
})
password: string;
@IsString()
confirmPassword: string;
}
\ No newline at end of file
import { IsObject, IsString } from 'class-validator';
export class ScheduleConfigDto {
@IsString()
userId: string;
@IsObject()
schedule: Record<string, string>;
// e.g. { sunday: 'IN_OFFICE', monday: 'IN_OFFICE', tuesday: 'REMOTE', wednesday: 'OFF', thursday: 'OFF' }
}
\ No newline at end of file
import { IsString, IsArray, ValidateNested, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class CompetencyRatingDto {
@IsString()
competencyAreaId: string;
@IsInt()
@Min(0)
@Max(5)
level: number;
}
export class SelfAssessmentDto {
@IsString()
userId: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => CompetencyRatingDto)
ratings: CompetencyRatingDto[];
}
\ No newline at end of file
import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateInviteDto } from './dto/create-invite.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import * as crypto from 'crypto';
@Injectable()
export class InviteService {
private readonly logger = new Logger(InviteService.name);
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateInviteDto, currentUser: RequestUser): Promise<any> {
const expirationDays = dto.expirationDays || 7;
const code = this.generateInviteCode();
const token = crypto.randomUUID();
const expiresAt = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000);
const invite = await this.prisma.invite.create({
data: {
code,
token,
contractorType: dto.contractorType,
assignedProjectLeaderId: dto.assignedProjectLeaderId || null,
assignedBoardIds: dto.assignedBoardIds || [],
welcomeNote: dto.welcomeNote || null,
expiresAt,
status: 'ACTIVE',
createdById: currentUser.id,
},
});
this.logger.log(`Invite ${code} created by ${currentUser.email}, expires ${expiresAt.toISOString()}`);
return {
id: invite.id,
code: invite.code,
token: invite.token,
contractorType: invite.contractorType,
expiresAt: invite.expiresAt,
status: invite.status,
createdAt: invite.createdAt,
};
}
async findAll(currentUser: RequestUser): Promise<any[]> {
const invites = await this.prisma.invite.findMany({
orderBy: { createdAt: 'desc' },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true, username: true } },
usedBy: { select: { id: true, firstName: true, lastName: true, username: true } },
},
});
// Auto-expire stale invites
const now = new Date();
for (const invite of invites) {
if (invite.status === 'ACTIVE' && invite.expiresAt < now) {
await this.prisma.invite.update({
where: { id: invite.id },
data: { status: 'EXPIRED' },
});
invite.status = 'EXPIRED';
}
}
return invites;
}
async findByCode(code: string): Promise<any> {
const invite = await this.prisma.invite.findFirst({
where: { OR: [{ code }, { token: code }] },
});
if (!invite) {
throw new NotFoundException('Invalid invite code');
}
if (invite.status !== 'ACTIVE') {
throw new BadRequestException(`This invitation has been ${invite.status.toLowerCase()}. Contact your administrator.`);
}
if (invite.expiresAt < new Date()) {
await this.prisma.invite.update({ where: { id: invite.id }, data: { status: 'EXPIRED' } });
throw new BadRequestException('This invitation has expired. Contact your administrator.');
}
return invite;
}
async revoke(id: string, currentUser: RequestUser): Promise<void> {
const invite = await this.prisma.invite.findUnique({ where: { id } });
if (!invite) {
throw new NotFoundException('Invite not found');
}
if (invite.status !== 'ACTIVE') {
throw new BadRequestException('Only active invites can be revoked');
}
await this.prisma.invite.update({
where: { id },
data: { status: 'REVOKED' },
});
this.logger.log(`Invite ${invite.code} revoked by ${currentUser.email}`);
}
async markAsUsed(inviteId: string, userId: string): Promise<void> {
await this.prisma.invite.update({
where: { id: inviteId },
data: {
status: 'USED',
usedById: userId,
usedAt: new Date(),
},
});
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new BadRequestException('Only Super Admin can delete invite records');
}
await this.prisma.invite.delete({ where: { id } });
this.logger.log(`Invite ${id} deleted by ${currentUser.email}`);
}
private generateInviteCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code = '';
const bytes = crypto.randomBytes(8);
for (let i = 0; i < 8; i++) {
code += chars[bytes[i] % chars.length];
}
return code;
}
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { OnboardingService } from './onboarding.service';
import { InviteService } from './invite.service';
import { ChecklistService } from './checklist.service';
import { SalaryCalculatorService } from './salary-calculator.service';
import { CreateInviteDto } from './dto/create-invite.dto';
import { RegisterDto } from './dto/register.dto';
import { ScheduleConfigDto } from './dto/schedule-config.dto';
import { ContractSignDto } from './dto/contract-sign.dto';
import { SelfAssessmentDto } from './dto/self-assessment.dto';
import { UpdateChecklistItemDto } from './dto/checklist-item.dto';
import { Public } from '../../common/decorators/public.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
@Controller('onboarding')
export class OnboardingController {
constructor(
private readonly onboardingService: OnboardingService,
private readonly inviteService: InviteService,
private readonly checklistService: ChecklistService,
private readonly salaryCalculator: SalaryCalculatorService,
) {}
// ─── INVITES ─────────────────────────────────────────
@Post('invites')
@Roles('SUPER_ADMIN', 'ADMIN')
async createInvite(@Body() dto: CreateInviteDto, @CurrentUser() user: RequestUser) {
return this.inviteService.create(dto, user);
}
@Get('invites')
@Roles('SUPER_ADMIN', 'ADMIN')
async listInvites(@CurrentUser() user: RequestUser) {
return this.inviteService.findAll(user);
}
@Delete('invites/:id')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async revokeInvite(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.inviteService.revoke(id, user);
return { message: 'Invite revoked' };
}
@Delete('invites/:id/permanent')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async deleteInvite(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.inviteService.delete(id, user);
return { message: 'Invite deleted permanently' };
}
// ─── PUBLIC REGISTRATION FLOW ────────────────────────
@Public()
@Post('validate-invite')
@HttpCode(HttpStatus.OK)
async validateInvite(@Body('code') code: string) {
return this.onboardingService.validateInvite(code);
}
@Public()
@Get('check-unique')
async checkUnique(@Query('field') field: string, @Query('value') value: string) {
return this.onboardingService.checkUniqueField(field, value);
}
@Public()
@Post('register')
async register(@Body() dto: RegisterDto) {
return this.onboardingService.register(dto);
}
// ─── ONBOARDING STEPS (AUTHENTICATED) ────────────────
@Post('schedule')
@HttpCode(HttpStatus.OK)
async configureSchedule(@Body() dto: ScheduleConfigDto) {
return this.onboardingService.configureSchedule(dto);
}
@Post('schedule/preview')
@HttpCode(HttpStatus.OK)
async previewSalary(@Body() dto: ScheduleConfigDto) {
const user = await this.salaryCalculator.calculateBaseSalary(
dto.schedule,
'FULL_TIME', // will be overridden by actual user type in full flow
);
return user;
}
@Post('contract')
@HttpCode(HttpStatus.OK)
async signContract(@Body() dto: ContractSignDto) {
return this.onboardingService.signContract(dto);
}
@Post('self-assessment')
@HttpCode(HttpStatus.OK)
async submitSelfAssessment(@Body() dto: SelfAssessmentDto) {
return this.onboardingService.submitSelfAssessment(dto);
}
// ─── CHECKLIST ───────────────────────────────────────
@Get('checklist/:userId')
async getChecklist(@Param('userId') userId: string) {
return this.checklistService.getChecklist(userId);
}
@Put('checklist/:userId/items')
@HttpCode(HttpStatus.OK)
async updateChecklistItem(
@Param('userId') userId: string,
@Body() dto: UpdateChecklistItemDto,
@CurrentUser() user: RequestUser,
) {
await this.checklistService.updateItem(userId, dto.itemKey, dto.completed, user.id);
return { message: 'Checklist item updated' };
}
// ─── ACTIVATION ──────────────────────────────────────
@Post('activate/:userId')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async activate(@Param('userId') userId: string, @CurrentUser() user: RequestUser) {
return this.onboardingService.activate(userId, user);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { OnboardingController } from './onboarding.controller';
import { OnboardingService } from './onboarding.service';
import { InviteService } from './invite.service';
import { ChecklistService } from './checklist.service';
import { SalaryCalculatorService } from './salary-calculator.service';
@Module({
controllers: [OnboardingController],
providers: [OnboardingService, InviteService, ChecklistService, SalaryCalculatorService],
exports: [OnboardingService, InviteService, ChecklistService, SalaryCalculatorService],
})
export class OnboardingModule {}
\ No newline at end of file
import {
Injectable,
BadRequestException,
ConflictException,
ForbiddenException,
NotFoundException,
Logger,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../prisma/prisma.service';
import { InviteService } from './invite.service';
import { SalaryCalculatorService } from './salary-calculator.service';
import { ChecklistService } from './checklist.service';
import { RegisterDto } from './dto/register.dto';
import { ScheduleConfigDto } from './dto/schedule-config.dto';
import { ContractSignDto } from './dto/contract-sign.dto';
import { SelfAssessmentDto } from './dto/self-assessment.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class OnboardingService {
private readonly logger = new Logger(OnboardingService.name);
private readonly BCRYPT_ROUNDS = 12;
constructor(
private readonly prisma: PrismaService,
private readonly inviteService: InviteService,
private readonly salaryCalculator: SalaryCalculatorService,
private readonly checklistService: ChecklistService,
) {}
async validateInvite(code: string): Promise<any> {
const invite = await this.inviteService.findByCode(code);
return {
valid: true,
contractorType: invite.contractorType,
welcomeNote: invite.welcomeNote,
expiresAt: invite.expiresAt,
};
}
async register(dto: RegisterDto): Promise<any> {
if (dto.password !== dto.confirmPassword) {
throw new BadRequestException('Passwords do not match');
}
// Validate invite
const invite = await this.inviteService.findByCode(dto.inviteCode);
// Validate age (at least 16)
const dob = new Date(dto.dateOfBirth);
const age = Math.floor((Date.now() - dob.getTime()) / (365.25 * 24 * 60 * 60 * 1000));
if (age < 16) {
throw new BadRequestException('Must be at least 16 years old');
}
// Uniqueness checks
const existingUsername = await this.prisma.user.findUnique({ where: { username: dto.username } });
if (existingUsername) throw new ConflictException('Username already in use');
const existingNationalId = await this.prisma.user.findFirst({ where: { nationalId: dto.nationalId } });
if (existingNationalId) throw new ConflictException('National ID already in use');
const existingPhone = await this.prisma.user.findFirst({ where: { phone: dto.phone } });
if (existingPhone) throw new ConflictException('Phone number already in use');
const passwordHash = await bcrypt.hash(dto.password, this.BCRYPT_ROUNDS);
const user = await this.prisma.user.create({
data: {
email: `${dto.username}@thegrind.local`, // Internal email, no external email system
username: dto.username,
firstName: dto.firstName,
lastName: dto.lastName,
nameArabic: dto.nameArabic,
nationalId: dto.nationalId,
dateOfBirth: dob,
phone: dto.phone,
phoneSecondary: dto.phoneSecondary || null,
address: dto.address,
emergencyContactName: dto.emergencyContactName,
emergencyContactPhone: dto.emergencyContactPhone,
emergencyContactRelationship: dto.emergencyContactRelationship,
bankName: dto.bankName,
bankAccountNumber: dto.bankAccountNumber,
bankAccountHolderName: dto.bankAccountHolderName,
taxRegistrationNumber: dto.taxRegistrationNumber || null,
passwordHash,
role: 'CONTRACTOR',
status: 'ONBOARDING',
contractorType: invite.contractorType,
assignedProjectLeaderId: invite.assignedProjectLeaderId || null,
timezone: 'Africa/Cairo',
forcePasswordChange: false,
onboardingChecklist: {},
},
});
// Mark invite as used
await this.inviteService.markAsUsed(invite.id, user.id);
// Auto-assign boards if specified
if (invite.assignedBoardIds && invite.assignedBoardIds.length > 0) {
for (const boardId of invite.assignedBoardIds) {
try {
await this.prisma.boardMember.create({
data: {
boardId,
userId: user.id,
role: 'MEMBER',
},
});
} catch (err) {
this.logger.warn(`Failed to assign board ${boardId} to user ${user.id}: ${err.message}`);
}
}
}
this.logger.log(`User ${user.username} registered via invite ${invite.code}`);
return {
userId: user.id,
username: user.username,
contractorType: user.contractorType,
status: user.status,
message: 'Registration successful. Proceed to schedule configuration.',
};
}
async configureSchedule(dto: ScheduleConfigDto): Promise<any> {
const user = await this.prisma.user.findFirst({
where: { id: dto.userId, deletedAt: null },
});
if (!user) throw new NotFoundException('User not found');
if (user.status !== 'ONBOARDING') throw new BadRequestException('User is not in onboarding status');
// Validate schedule — at least 1 working day
const workingDays = Object.values(dto.schedule).filter((v) => v !== 'OFF');
if (workingDays.length === 0) {
throw new BadRequestException('Schedule must have at least 1 working day');
}
// Calculate base salary
const { baseSalaryPiasters, breakdown } = await this.salaryCalculator.calculateBaseSalary(
dto.schedule,
user.contractorType || 'FULL_TIME',
);
await this.prisma.user.update({
where: { id: dto.userId },
data: {
weeklySchedule: dto.schedule,
baseSalaryPiasters,
},
});
return {
schedule: dto.schedule,
baseSalaryPiasters,
breakdown,
message: 'Schedule configured. Proceed to contract signing.',
};
}
async signContract(dto: ContractSignDto): Promise<any> {
const user = await this.prisma.user.findFirst({
where: { id: dto.userId, deletedAt: null },
});
if (!user) throw new NotFoundException('User not found');
if (user.status !== 'ONBOARDING') throw new BadRequestException('User is not in onboarding status');
// Validate all required clauses are acknowledged
const requiredClauses = [
'deduction_policy', 'ip_assignment', 'nda', 'termination_terms',
'code_of_conduct', 'data_security', 'salary_adjustment',
];
for (const clause of requiredClauses) {
if (!dto.acknowledgedClauses.includes(clause)) {
throw new BadRequestException(`Must acknowledge clause: ${clause}`);
}
}
// Validate digital signature matches
const expectedName = `${user.firstName} ${user.lastName}`;
if (dto.signedFullName.trim().toLowerCase() !== expectedName.trim().toLowerCase()) {
throw new BadRequestException('Signed name must match your full legal name (English)');
}
if (!dto.confirmedDigitalSignature) {
throw new BadRequestException('Must confirm digital signature checkbox');
}
// Create contract record
try {
await this.prisma.contract.create({
data: {
userId: dto.userId,
contractType: user.contractorType || 'FULL_TIME',
contractText: this.generateContractText(user),
signedAt: new Date(),
signedFullName: dto.signedFullName,
acknowledgedClauses: dto.acknowledgedClauses,
signatureIpAddress: dto.ipAddress,
signatureUserAgent: dto.userAgent,
baseSalaryAtSigning: user.baseSalaryPiasters,
scheduleAtSigning: user.weeklySchedule as any,
startDate: new Date(),
status: 'ACTIVE',
},
});
} catch (err) {
this.logger.warn(`Contract table may not exist yet: ${err.message}`);
}
// Update checklist
await this.checklistService.updateItem(dto.userId, 'contract_signed', true, 'SYSTEM');
await this.checklistService.updateItem(dto.userId, 'policies_acknowledged', true, 'SYSTEM');
return { message: 'Contract signed successfully. Proceed to competency self-assessment.' };
}
async submitSelfAssessment(dto: SelfAssessmentDto): Promise<any> {
const user = await this.prisma.user.findFirst({
where: { id: dto.userId, deletedAt: null },
});
if (!user) throw new NotFoundException('User not found');
if (user.status !== 'ONBOARDING') throw new BadRequestException('User is not in onboarding status');
// Validate all competency areas are rated
const allAreas = await this.prisma.competencyArea.findMany({
where: { isActive: true },
orderBy: { order: 'asc' },
});
if (dto.ratings.length < allAreas.length) {
throw new BadRequestException(`Must rate all ${allAreas.length} competency areas`);
}
// Store self-assessment ratings
for (const rating of dto.ratings) {
try {
await this.prisma.competencyRating.upsert({
where: {
userId_competencyAreaId_type: {
userId: dto.userId,
competencyAreaId: rating.competencyAreaId,
type: 'SELF',
},
},
update: { level: rating.level },
create: {
userId: dto.userId,
competencyAreaId: rating.competencyAreaId,
type: 'SELF',
level: rating.level,
},
});
} catch (err) {
this.logger.warn(`CompetencyRating upsert failed: ${err.message}. Table may not exist.`);
}
}
// Update checklist
await this.checklistService.updateItem(dto.userId, 'self_assessment', true, 'SYSTEM');
// Identify learning gaps (level 0 or 1)
const gaps = dto.ratings.filter((r) => r.level <= 1);
return {
message: 'Self-assessment submitted.',
gapsIdentified: gaps.length,
learningGoalsWillBeCreated: gaps.length,
};
}
async activate(userId: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can activate contractors');
}
const user = await this.prisma.user.findFirst({
where: { id: userId, deletedAt: null },
});
if (!user) throw new NotFoundException('User not found');
if (user.status !== 'ONBOARDING') throw new BadRequestException('User is not in onboarding status');
// Check checklist completion
const isComplete = await this.checklistService.isComplete(userId);
if (!isComplete) {
throw new BadRequestException('Onboarding checklist is not 100% complete');
}
// Activate
const activated = await this.prisma.user.update({
where: { id: userId },
data: {
status: 'ACTIVE',
startDate: new Date(),
// If no actual salary set yet, use base salary
actualSalaryPiasters: user.actualSalaryPiasters || user.baseSalaryPiasters,
},
});
this.logger.log(`User ${user.username} activated by ${currentUser.email}`);
// TODO: When notifications module is built:
// - Send blocking notification to SA: "Set Actual Salary"
// - Send welcome notification to contractor
// - Create learning goals from self-assessment gaps
return {
id: activated.id,
username: activated.username,
status: activated.status,
actualSalaryPiasters: activated.actualSalaryPiasters,
message: `Contractor ${activated.firstName} ${activated.lastName} is now active.`,
};
}
async checkUniqueField(field: string, value: string): Promise<{ available: boolean }> {
let existing: any = null;
switch (field) {
case 'username':
existing = await this.prisma.user.findUnique({ where: { username: value } });
break;
case 'nationalId':
existing = await this.prisma.user.findFirst({ where: { nationalId: value } });
break;
case 'phone':
existing = await this.prisma.user.findFirst({ where: { phone: value } });
break;
default:
throw new BadRequestException(`Cannot check uniqueness of field: ${field}`);
}
return { available: !existing };
}
private generateContractText(user: any): string {
// This generates the frozen contract snapshot
return `
COMPREHENSIVE SERVICE AGREEMENT
This agreement is entered into between AL-Arcade ("The Company") and ${user.firstName} ${user.lastName} ("The Contractor").
Contractor Type: ${user.contractorType}
Registration Date: ${new Date().toISOString().split('T')[0]}
TERMS AND CONDITIONS:
1. DEDUCTION POLICY
The Contractor acknowledges the Company's deduction system as outlined in the operational handbook.
2. INTELLECTUAL PROPERTY ASSIGNMENT
All work produced during the engagement is the intellectual property of AL-Arcade.
3. NON-DISCLOSURE AGREEMENT
The Contractor agrees not to disclose any confidential information.
4. TERMINATION TERMS
Either party may terminate with appropriate notice as defined in the handbook.
5. CODE OF CONDUCT
The Contractor agrees to abide by the Company's code of conduct.
6. DATA & SECURITY POLICY
The Contractor agrees to follow all data handling and security policies.
7. SALARY ADJUSTMENT
The Contractor acknowledges that the Super Admin may adjust salary at any time.
This document constitutes a legally binding digital agreement.
`.trim();
}
}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { calculateBaseSalaryPiasters } from '../../common/utils/salary.util';
@Injectable()
export class SalaryCalculatorService {
private readonly logger = new Logger(SalaryCalculatorService.name);
constructor(private readonly prisma: PrismaService) {}
async calculateBaseSalary(
schedule: Record<string, string>,
contractorType: string,
): Promise<{ baseSalaryPiasters: number; breakdown: any }> {
const rates = await this.getSalaryRates();
const baseSalaryPiasters = calculateBaseSalaryPiasters(schedule, contractorType, rates);
const isFullTime = contractorType === 'FULL_TIME';
const inOfficeRate = isFullTime ? rates.fullTimeInOffice : rates.internInOffice;
const remoteRate = isFullTime ? rates.fullTimeRemote : rates.internRemote;
const breakdown: any[] = [];
for (const [day, type] of Object.entries(schedule)) {
if (type === 'IN_OFFICE') {
breakdown.push({ day, type, ratePiasters: inOfficeRate });
} else if (type === 'REMOTE') {
breakdown.push({ day, type, ratePiasters: remoteRate });
}
}
return { baseSalaryPiasters, breakdown };
}
async recalculateForUser(userId: string): Promise<number> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { weeklySchedule: true, contractorType: true },
});
if (!user || !user.weeklySchedule || !user.contractorType) {
return 0;
}
const { baseSalaryPiasters } = await this.calculateBaseSalary(
user.weeklySchedule as Record<string, string>,
user.contractorType,
);
await this.prisma.user.update({
where: { id: userId },
data: { baseSalaryPiasters },
});
return baseSalaryPiasters;
}
private async getSalaryRates(): Promise<{
fullTimeInOffice: number;
fullTimeRemote: number;
internInOffice: number;
internRemote: number;
}> {
const settings = await this.prisma.setting.findMany({
where: {
key: {
in: ['fullTimeInOfficeRate', 'fullTimeRemoteRate', 'internInOfficeRate', 'internRemoteRate'],
},
},
});
const map: Record<string, any> = {};
for (const s of settings) {
map[s.key] = s.value;
}
return {
fullTimeInOffice: (map['fullTimeInOfficeRate'] as number) || 240000,
fullTimeRemote: (map['fullTimeRemoteRate'] as number) || 160000,
internInOffice: (map['internInOfficeRate'] as number) || 100000,
internRemote: (map['internRemoteRate'] as number) || 50000,
};
}
}
\ No newline at end of file
import {
IsString,
IsEmail,
IsOptional,
MinLength,
MaxLength,
Matches,
IsEnum,
IsDateString,
} from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(3)
@MaxLength(30)
@Matches(/^[a-zA-Z0-9_]+$/, { message: 'Username must be alphanumeric with underscores only' })
username: string;
@IsString()
@MinLength(1)
firstName: string;
@IsString()
@MinLength(1)
lastName: string;
@IsOptional()
@IsString()
displayName?: string;
@IsOptional()
@IsString()
nameArabic?: string;
@IsOptional()
@IsString()
nationalId?: string;
@IsOptional()
@IsDateString()
dateOfBirth?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
phoneSecondary?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
emergencyContactName?: string;
@IsOptional()
@IsString()
emergencyContactPhone?: string;
@IsOptional()
@IsString()
emergencyContactRelationship?: string;
@IsOptional()
@IsString()
bankName?: string;
@IsOptional()
@IsString()
bankAccountNumber?: string;
@IsOptional()
@IsString()
bankAccountHolderName?: string;
@IsOptional()
@IsString()
taxRegistrationNumber?: string;
@IsEnum(['SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD', 'CONTRACTOR'])
role: string;
@IsOptional()
@IsEnum(['FULL_TIME', 'PART_TIME', 'PROJECT_BASED'])
contractorType?: string;
@IsOptional()
@IsString()
department?: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
bio?: string;
@IsOptional()
@IsString()
timezone?: 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',
})
password: string;
}
\ No newline at end of file
import { IsOptional, IsString, MinLength } from 'class-validator';
export class ResetPasswordResponseDto {
temporaryPassword: string;
message: string;
}
export class SetActualSalaryDto {
@MinLength(0)
actualSalaryPiasters: number;
@IsOptional()
@IsString()
@MinLength(1)
reason?: string;
}
export class AddPrivateNoteDto {
@IsString()
@MinLength(1)
content: string;
}
export class ChangeStatusDto {
@IsString()
status: string;
@IsOptional()
@IsString()
reason?: string;
}
\ No newline at end of file
import {
IsString,
IsOptional,
IsEmail,
MinLength,
MaxLength,
Matches,
IsEnum,
IsDateString,
IsInt,
Min,
IsObject,
} from 'class-validator';
export class UpdateUserDto {
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(30)
@Matches(/^[a-zA-Z0-9_]+$/, { message: 'Username must be alphanumeric with underscores only' })
username?: string;
@IsOptional()
@IsString()
firstName?: string;
@IsOptional()
@IsString()
lastName?: string;
@IsOptional()
@IsString()
displayName?: string;
@IsOptional()
@IsString()
nameArabic?: string;
@IsOptional()
@IsString()
nationalId?: string;
@IsOptional()
@IsDateString()
dateOfBirth?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
phoneSecondary?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
avatar?: string;
@IsOptional()
@IsString()
emergencyContactName?: string;
@IsOptional()
@IsString()
emergencyContactPhone?: string;
@IsOptional()
@IsString()
emergencyContactRelationship?: string;
@IsOptional()
@IsString()
bankName?: string;
@IsOptional()
@IsString()
bankAccountNumber?: string;
@IsOptional()
@IsString()
bankAccountHolderName?: string;
@IsOptional()
@IsString()
taxRegistrationNumber?: string;
@IsOptional()
@IsEnum(['SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD', 'CONTRACTOR'])
role?: string;
@IsOptional()
@IsEnum(['INVITED', 'ONBOARDING', 'ACTIVE', 'ON_PIP', 'SUSPENDED', 'OFFBOARDING', 'OFFBOARDED'])
status?: string;
@IsOptional()
@IsEnum(['FULL_TIME', 'PART_TIME', 'PROJECT_BASED'])
contractorType?: string;
@IsOptional()
@IsString()
department?: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
bio?: string;
@IsOptional()
@IsString()
timezone?: string;
@IsOptional()
@IsObject()
weeklySchedule?: Record<string, string>;
@IsOptional()
@IsInt()
@Min(0)
actualSalaryPiasters?: number;
@IsOptional()
@IsString()
salaryChangeReason?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
contractStartDate?: string;
@IsOptional()
@IsDateString()
contractEndDate?: string;
@IsOptional()
@IsString()
assignedProjectLeaderId?: string;
}
\ No newline at end of file
import { IsOptional, IsString, IsEnum } from 'class-validator';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class UserFilterDto extends PaginationDto {
@IsOptional()
@IsEnum(['SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD', 'CONTRACTOR'])
role?: string;
@IsOptional()
@IsEnum(['INVITED', 'ONBOARDING', 'ACTIVE', 'ON_PIP', 'SUSPENDED', 'OFFBOARDING', 'OFFBOARDED'])
status?: string;
@IsOptional()
@IsEnum(['FULL_TIME', 'PART_TIME', 'PROJECT_BASED'])
contractorType?: string;
@IsOptional()
@IsString()
department?: string;
@IsOptional()
@IsString()
boardId?: string;
}
\ No newline at end of file
export class UserResponseDto {
id: string;
email: string;
username: string;
firstName: string;
lastName: string;
displayName: string | null;
nameArabic: string | null;
avatar: string | null;
role: string;
status: string;
contractorType: string | null;
department: string | null;
title: string | null;
phone: string | null;
timezone: string | null;
bio: string | null;
startDate: string | null;
lastLoginAt: string | null;
createdAt: string;
}
export class UserProfileResponseDto extends UserResponseDto {
nationalId: string | null;
dateOfBirth: string | null;
phoneSecondary: string | null;
address: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
emergencyContactRelationship: string | null;
bankName: string | null;
bankAccountNumber: string | null;
bankAccountHolderName: string | null;
taxRegistrationNumber: string | null;
weeklySchedule: Record<string, string> | null;
baseSalaryPiasters: number;
actualSalaryPiasters: number;
contractStartDate: string | null;
contractEndDate: string | null;
assignedProjectLeaderId: string | null;
forcePasswordChange: boolean;
}
export class ContractorDirectoryEntryDto {
id: string;
firstName: string;
lastName: string;
displayName: string | null;
avatar: string | null;
role: string;
contractorType: string | null;
department: string | null;
title: string | null;
status: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserFilterDto } from './dto/user-filter.dto';
import { AddPrivateNoteDto, ChangeStatusDto, SetActualSalaryDto } from './dto/reset-password.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@Roles('SUPER_ADMIN', 'ADMIN')
async create(@Body() dto: CreateUserDto, @CurrentUser() user: RequestUser) {
return this.usersService.create(dto, user);
}
@Get()
async findAll(@Query() filter: UserFilterDto, @CurrentUser() user: RequestUser) {
return this.usersService.findAll(filter, user);
}
@Get('directory')
async getDirectory(@Query() filter: UserFilterDto, @CurrentUser() user: RequestUser) {
return this.usersService.getDirectory(filter, user);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.usersService.findById(id, user);
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateUserDto,
@CurrentUser() user: RequestUser,
) {
return this.usersService.update(id, dto, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async softDelete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.usersService.softDelete(id, user);
return { message: 'User deleted successfully' };
}
@Post(':id/reset-password')
@Roles('SUPER_ADMIN')
async resetPassword(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.usersService.resetPassword(id, user);
}
@Post(':id/force-logout')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async forceLogout(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.usersService.forceLogout(id, user);
return { message: 'User sessions revoked' };
}
@Post('force-logout-all')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async forceLogoutAll(@CurrentUser() user: RequestUser) {
const result = await this.usersService.forceLogoutAll(user);
return { message: `${result.count} sessions revoked` };
}
@Post(':id/unlock')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async unlockAccount(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.usersService.unlockAccount(id, user);
return { message: 'Account unlocked' };
}
@Get(':id/sessions')
async getUserSessions(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.usersService.getUserSessions(id, user);
}
@Delete(':id/sessions/:sessionId')
@HttpCode(HttpStatus.OK)
async revokeSession(
@Param('id') userId: string,
@Param('sessionId') sessionId: string,
@CurrentUser() user: RequestUser,
) {
await this.usersService.revokeSession(userId, sessionId, user);
return { message: 'Session revoked' };
}
@Delete(':id/sessions')
@HttpCode(HttpStatus.OK)
async revokeAllOtherSessions(@Param('id') id: string, @CurrentUser() user: RequestUser) {
const result = await this.usersService.revokeAllOtherSessions(id, user);
return { message: `${result.count} sessions revoked` };
}
@Put(':id/status')
@Roles('SUPER_ADMIN', 'ADMIN')
async changeStatus(
@Param('id') id: string,
@Body() dto: ChangeStatusDto,
@CurrentUser() user: RequestUser,
) {
return this.usersService.changeStatus(id, dto.status, dto.reason, user);
}
@Put(':id/salary')
@Roles('SUPER_ADMIN')
async setActualSalary(
@Param('id') id: string,
@Body() dto: SetActualSalaryDto,
@CurrentUser() user: RequestUser,
) {
return this.usersService.setActualSalary(id, dto.actualSalaryPiasters, dto.reason, user);
}
@Post(':id/notes')
@Roles('SUPER_ADMIN', 'ADMIN')
async addPrivateNote(
@Param('id') id: string,
@Body() dto: AddPrivateNoteDto,
@CurrentUser() user: RequestUser,
) {
return this.usersService.addPrivateNote(id, dto, user);
}
@Get(':id/notes')
@Roles('SUPER_ADMIN', 'ADMIN')
async getPrivateNotes(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.usersService.getPrivateNotes(id, user);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ConflictException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserFilterDto } from './dto/user-filter.dto';
import { AddPrivateNoteDto } from './dto/reset-password.dto';
import {
getSkip,
buildPaginatedResponse,
PaginatedResult,
} from '../../common/utils/pagination.util';
import {
calculateBaseSalaryPiasters,
} from '../../common/utils/salary.util';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
private readonly BCRYPT_ROUNDS = 12;
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateUserDto, createdBy: RequestUser): Promise<any> {
if (createdBy.role !== 'SUPER_ADMIN' && createdBy.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can create users');
}
if (createdBy.role === 'ADMIN' && (dto.role === 'SUPER_ADMIN' || dto.role === 'ADMIN')) {
throw new ForbiddenException('Admin cannot create Super Admin or Admin accounts');
}
const existingEmail = await this.prisma.user.findUnique({ where: { email: dto.email } });
if (existingEmail) {
throw new ConflictException('Email already in use');
}
const existingUsername = await this.prisma.user.findUnique({ where: { username: dto.username } });
if (existingUsername) {
throw new ConflictException('Username already in use');
}
if (dto.nationalId) {
const existingNationalId = await this.prisma.user.findFirst({
where: { nationalId: dto.nationalId },
});
if (existingNationalId) {
throw new ConflictException('National ID already in use');
}
}
if (dto.phone) {
const existingPhone = await this.prisma.user.findFirst({ where: { phone: dto.phone } });
if (existingPhone) {
throw new ConflictException('Phone number already in use');
}
}
const passwordHash = await bcrypt.hash(dto.password, this.BCRYPT_ROUNDS);
const user = await this.prisma.user.create({
data: {
email: dto.email,
username: dto.username,
firstName: dto.firstName,
lastName: dto.lastName,
displayName: dto.displayName || null,
nameArabic: dto.nameArabic || null,
nationalId: dto.nationalId || null,
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null,
phone: dto.phone || null,
phoneSecondary: dto.phoneSecondary || null,
address: dto.address || null,
emergencyContactName: dto.emergencyContactName || null,
emergencyContactPhone: dto.emergencyContactPhone || null,
emergencyContactRelationship: dto.emergencyContactRelationship || null,
bankName: dto.bankName || null,
bankAccountNumber: dto.bankAccountNumber || null,
bankAccountHolderName: dto.bankAccountHolderName || null,
taxRegistrationNumber: dto.taxRegistrationNumber || null,
passwordHash,
role: dto.role,
status: 'ACTIVE',
contractorType: dto.contractorType || null,
department: dto.department || null,
title: dto.title || null,
bio: dto.bio || null,
timezone: dto.timezone || 'Africa/Cairo',
forcePasswordChange: true,
},
});
this.logger.log(`User ${user.username} created by ${createdBy.email}`);
return this.sanitizeUser(user, createdBy.role);
}
async findAll(filter: UserFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = { deletedAt: null };
if (filter.role) where.role = filter.role;
if (filter.status) where.status = filter.status;
if (filter.contractorType) where.contractorType = filter.contractorType;
if (filter.department) where.department = filter.department;
if (filter.search) {
where.OR = [
{ firstName: { contains: filter.search, mode: 'insensitive' } },
{ lastName: { contains: filter.search, mode: 'insensitive' } },
{ username: { contains: filter.search, mode: 'insensitive' } },
{ email: { contains: filter.search, mode: 'insensitive' } },
{ displayName: { contains: filter.search, mode: 'insensitive' } },
{ nameArabic: { contains: filter.search, mode: 'insensitive' } },
];
}
if (filter.boardId) {
where.boardMemberships = { some: { boardId: filter.boardId } };
}
// TEAM_LEAD can only see their team members
if (currentUser.role === 'TEAM_LEAD') {
where.OR = [
{ assignedProjectLeaderId: currentUser.id },
{ id: currentUser.id },
];
}
const [users, total] = await Promise.all([
this.prisma.user.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { [filter.sortBy || 'createdAt']: filter.sortOrder || 'desc' },
}),
this.prisma.user.count({ where }),
]);
const sanitized = users.map((u) => this.sanitizeUser(u, currentUser.role));
return buildPaginatedResponse(sanitized, total, { page, limit, sortOrder: filter.sortOrder || 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const user = await this.prisma.user.findFirst({
where: { id, deletedAt: null },
});
if (!user) {
throw new NotFoundException('User not found');
}
// Contractors can only view their own full profile
if (currentUser.role === 'CONTRACTOR' && currentUser.id !== id) {
return this.sanitizeUserForDirectory(user);
}
// TEAM_LEAD can only view their team members' profiles
if (currentUser.role === 'TEAM_LEAD' && currentUser.id !== id) {
if (user.assignedProjectLeaderId !== currentUser.id) {
return this.sanitizeUserForDirectory(user);
}
}
return this.sanitizeUser(user, currentUser.role);
}
async update(id: string, dto: UpdateUserDto, currentUser: RequestUser): Promise<any> {
const user = await this.prisma.user.findFirst({ where: { id, deletedAt: null } });
if (!user) {
throw new NotFoundException('User not found');
}
this.enforceUpdatePermissions(user, dto, currentUser);
// Uniqueness checks
if (dto.email && dto.email !== user.email) {
const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
if (existing) throw new ConflictException('Email already in use');
}
if (dto.username && dto.username !== user.username) {
const existing = await this.prisma.user.findUnique({ where: { username: dto.username } });
if (existing) throw new ConflictException('Username already in use');
}
// Handle salary change with audit
let salaryChanged = false;
let oldSalary = user.actualSalaryPiasters;
if (dto.actualSalaryPiasters !== undefined && dto.actualSalaryPiasters !== user.actualSalaryPiasters) {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can change actual salary');
}
if (dto.actualSalaryPiasters < user.baseSalaryPiasters) {
this.logger.warn(
`Actual salary (${dto.actualSalaryPiasters}) is below base salary (${user.baseSalaryPiasters}) for user ${id}`,
);
}
salaryChanged = true;
}
// Handle schedule change → recalculate base salary
let baseSalaryUpdate: number | undefined;
if (dto.weeklySchedule && currentUser.role === 'SUPER_ADMIN') {
const settings = await this.getSalaryRates();
baseSalaryUpdate = calculateBaseSalaryPiasters(
dto.weeklySchedule,
user.contractorType || 'FULL_TIME',
settings,
);
}
const updateData: any = {};
// Map only provided fields
const directFields = [
'email', 'username', 'firstName', 'lastName', 'displayName', 'nameArabic',
'nationalId', 'phone', 'phoneSecondary', 'address', 'avatar',
'emergencyContactName', 'emergencyContactPhone', 'emergencyContactRelationship',
'bankName', 'bankAccountNumber', 'bankAccountHolderName', 'taxRegistrationNumber',
'role', 'status', 'contractorType', 'department', 'title', 'bio', 'timezone',
'assignedProjectLeaderId',
];
for (const field of directFields) {
if ((dto as any)[field] !== undefined) {
updateData[field] = (dto as any)[field];
}
}
if (dto.dateOfBirth !== undefined) updateData.dateOfBirth = new Date(dto.dateOfBirth);
if (dto.startDate !== undefined) updateData.startDate = new Date(dto.startDate);
if (dto.contractStartDate !== undefined) updateData.contractStartDate = new Date(dto.contractStartDate);
if (dto.contractEndDate !== undefined) updateData.contractEndDate = dto.contractEndDate ? new Date(dto.contractEndDate) : null;
if (dto.weeklySchedule !== undefined) updateData.weeklySchedule = dto.weeklySchedule;
if (dto.actualSalaryPiasters !== undefined) updateData.actualSalaryPiasters = dto.actualSalaryPiasters;
if (baseSalaryUpdate !== undefined) updateData.baseSalaryPiasters = baseSalaryUpdate;
const updated = await this.prisma.user.update({
where: { id },
data: updateData,
});
// Log salary change
if (salaryChanged) {
await this.prisma.salaryChangeLog.create({
data: {
userId: id,
oldSalaryPiasters: oldSalary,
newSalaryPiasters: dto.actualSalaryPiasters!,
reason: dto.salaryChangeReason || 'No reason provided',
changedById: currentUser.id,
},
}).catch((err) => {
this.logger.warn(`Failed to log salary change: ${err.message}. Table may not exist yet.`);
});
}
return this.sanitizeUser(updated, currentUser.role);
}
async softDelete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete users');
}
const user = await this.prisma.user.findFirst({ where: { id, deletedAt: null } });
if (!user) {
throw new NotFoundException('User not found');
}
if (user.id === currentUser.id) {
throw new BadRequestException('Cannot delete your own account');
}
await this.prisma.user.update({
where: { id },
data: { deletedAt: new Date(), status: 'OFFBOARDED' },
});
// Revoke all sessions
await this.prisma.session.updateMany({
where: { userId: id, revokedAt: null },
data: { revokedAt: new Date() },
});
this.logger.log(`User ${id} soft-deleted by ${currentUser.email}`);
}
async resetPassword(id: string, currentUser: RequestUser): Promise<{ temporaryPassword: string }> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can reset passwords');
}
const user = await this.prisma.user.findFirst({ where: { id, deletedAt: null } });
if (!user) {
throw new NotFoundException('User not found');
}
const temporaryPassword = this.generateTemporaryPassword();
const passwordHash = await bcrypt.hash(temporaryPassword, this.BCRYPT_ROUNDS);
await this.prisma.user.update({
where: { id },
data: {
passwordHash,
forcePasswordChange: true,
failedLoginAttempts: 0,
lockedUntil: null,
},
});
this.logger.log(`Password reset for user ${id} by ${currentUser.email}`);
return { temporaryPassword };
}
async forceLogout(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can force logout users');
}
const user = await this.prisma.user.findFirst({ where: { id, deletedAt: null } });
if (!user) {
throw new NotFoundException('User not found');
}
const result = await this.prisma.session.updateMany({
where: { userId: id, revokedAt: null },
data: { revokedAt: new Date() },
});
this.logger.log(`Force logged out user ${id} (${result.count} sessions revoked) by ${currentUser.email}`);
}
async forceLogoutAll(currentUser: RequestUser): Promise<{ count: number }> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can force logout all users');
}
const result = await this.prisma.session.updateMany({
where: {
revokedAt: null,
userId: { not: currentUser.id },
},
data: { revokedAt: new Date() },
});
this.logger.log(`Force logged out ALL users (${result.count} sessions) by ${currentUser.email}`);
return { count: result.count };
}
async getUserSessions(id: string, currentUser: RequestUser): Promise<any[]> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.id !== id) {
throw new ForbiddenException('You can only view your own sessions');
}
const sessions = await this.prisma.session.findMany({
where: { userId: id },
orderBy: { createdAt: 'desc' },
take: 50,
select: {
id: true,
ipAddress: true,
userAgent: true,
createdAt: true,
lastActiveAt: true,
expiresAt: true,
revokedAt: true,
},
});
return sessions.map((s) => ({
...s,
isActive: !s.revokedAt && s.expiresAt > new Date(),
}));
}
async revokeSession(userId: string, sessionId: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.id !== userId) {
throw new ForbiddenException('You can only manage your own sessions');
}
const session = await this.prisma.session.findFirst({
where: { id: sessionId, userId, revokedAt: null },
});
if (!session) {
throw new NotFoundException('Session not found or already revoked');
}
// Don't allow revoking your own current session via this endpoint
if (sessionId === currentUser.sessionId) {
throw new BadRequestException('Cannot revoke your current session. Use logout instead.');
}
await this.prisma.session.update({
where: { id: sessionId },
data: { revokedAt: new Date() },
});
}
async revokeAllOtherSessions(userId: string, currentUser: RequestUser): Promise<{ count: number }> {
if (currentUser.id !== userId && currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('You can only manage your own sessions');
}
const result = await this.prisma.session.updateMany({
where: {
userId,
revokedAt: null,
id: { not: currentUser.sessionId },
},
data: { revokedAt: new Date() },
});
return { count: result.count };
}
async getDirectory(filter: UserFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 50;
const where: any = {
deletedAt: null,
status: { notIn: ['OFFBOARDED', 'INVITED'] },
};
if (filter.search) {
where.OR = [
{ firstName: { contains: filter.search, mode: 'insensitive' } },
{ lastName: { contains: filter.search, mode: 'insensitive' } },
{ username: { contains: filter.search, mode: 'insensitive' } },
{ displayName: { contains: filter.search, mode: 'insensitive' } },
];
}
if (filter.role) where.role = filter.role;
if (filter.status) where.status = filter.status;
if (filter.contractorType) where.contractorType = filter.contractorType;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { firstName: 'asc' },
}),
this.prisma.user.count({ where }),
]);
const sanitized = users.map((u) => this.sanitizeUserForDirectory(u, currentUser));
return buildPaginatedResponse(sanitized, total, { page, limit, sortOrder: 'asc' });
}
async addPrivateNote(userId: string, dto: AddPrivateNoteDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can add private notes');
}
const user = await this.prisma.user.findFirst({ where: { id: userId, deletedAt: null } });
if (!user) {
throw new NotFoundException('User not found');
}
const note = await this.prisma.privateNote.create({
data: {
userId,
content: dto.content,
authorId: currentUser.id,
},
}).catch(() => {
// If PrivateNote table doesn't exist yet, store as JSON on user
this.logger.warn('PrivateNote table may not exist. Skipping.');
return null;
});
return note;
}
async getPrivateNotes(userId: string, currentUser: RequestUser): Promise<any[]> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can view private notes');
}
try {
return await this.prisma.privateNote.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
include: {
author: {
select: { id: true, firstName: true, lastName: true, username: true },
},
},
});
} catch {
return [];
}
}
async changeStatus(id: string, status: string, reason: string | undefined, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can change user status');
}
if (currentUser.role === 'ADMIN' && (status === 'OFFBOARDED')) {
throw new ForbiddenException('Only Super Admin can terminate users');
}
const user = await this.prisma.user.findFirst({ where: { id, deletedAt: null } });
if (!user) {
throw new NotFoundException('User not found');
}
const oldStatus = user.status;
const updated = await this.prisma.user.update({
where: { id },
data: { status },
});
// Log status change
try {
await this.prisma.statusChangeLog.create({
data: {
userId: id,
fromStatus: oldStatus,
toStatus: status,
reason: reason || null,
changedById: currentUser.id,
},
});
} catch {
this.logger.warn('StatusChangeLog table may not exist yet.');
}
// If offboarded, revoke all sessions
if (status === 'OFFBOARDED') {
await this.prisma.session.updateMany({
where: { userId: id, revokedAt: null },
data: { revokedAt: new Date() },
});
}
return this.sanitizeUser(updated, currentUser.role);
}
async setActualSalary(id: string, salaryPiasters: number, reason: string | undefined, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can set actual salary');
}
const user = await this.prisma.user.findFirst({ where: { id, deletedAt: null } });
if (!user) {
throw new NotFoundException('User not found');
}
const oldSalary = user.actualSalaryPiasters;
const updated = await this.prisma.user.update({
where: { id },
data: { actualSalaryPiasters: salaryPiasters },
});
// Log salary change
try {
await this.prisma.salaryChangeLog.create({
data: {
userId: id,
oldSalaryPiasters: oldSalary,
newSalaryPiasters: salaryPiasters,
reason: reason || 'No reason provided',
changedById: currentUser.id,
},
});
} catch {
this.logger.warn('SalaryChangeLog table may not exist yet.');
}
this.logger.log(
`Salary for user ${id} changed from ${oldSalary} to ${salaryPiasters} by ${currentUser.email}`,
);
return this.sanitizeUser(updated, currentUser.role);
}
async unlockAccount(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can unlock accounts');
}
await this.prisma.user.update({
where: { id },
data: { failedLoginAttempts: 0, lockedUntil: null },
});
this.logger.log(`Account ${id} unlocked by ${currentUser.email}`);
}
// ─── HELPERS ─────────────────────────────────────────────────────────────
private enforceUpdatePermissions(user: any, dto: UpdateUserDto, currentUser: RequestUser): void {
const isSelf = currentUser.id === user.id;
const isSA = currentUser.role === 'SUPER_ADMIN';
const isAdmin = currentUser.role === 'ADMIN';
if (isSA) return; // SA can edit anything
if (isAdmin) {
const forbidden = ['role', 'actualSalaryPiasters', 'nationalId', 'dateOfBirth'];
if (dto.contractorType !== undefined) {
throw new ForbiddenException('Admin cannot change contractor type');
}
for (const field of forbidden) {
if ((dto as any)[field] !== undefined) {
throw new ForbiddenException(`Admin cannot change ${field}`);
}
}
return;
}
if (isSelf) {
const allowedSelfFields = [
'phone', 'phoneSecondary', 'address', 'avatar',
'emergencyContactName', 'emergencyContactPhone', 'emergencyContactRelationship',
'bankName', 'bankAccountNumber', 'bankAccountHolderName',
'displayName', 'bio', 'timezone',
];
for (const key of Object.keys(dto)) {
if (!allowedSelfFields.includes(key) && (dto as any)[key] !== undefined) {
throw new ForbiddenException(`You cannot change the field: ${key}`);
}
}
return;
}
throw new ForbiddenException('You do not have permission to edit this user');
}
private sanitizeUser(user: any, viewerRole: string): any {
const base: any = {
id: user.id,
email: user.email,
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
displayName: user.displayName,
nameArabic: user.nameArabic,
avatar: user.avatar,
role: user.role,
status: user.status,
contractorType: user.contractorType,
department: user.department,
title: user.title,
bio: user.bio,
timezone: user.timezone,
startDate: user.startDate,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
if (viewerRole === 'SUPER_ADMIN' || viewerRole === 'ADMIN') {
base.nationalId = user.nationalId;
base.dateOfBirth = user.dateOfBirth;
base.phone = user.phone;
base.phoneSecondary = user.phoneSecondary;
base.address = user.address;
base.emergencyContactName = user.emergencyContactName;
base.emergencyContactPhone = user.emergencyContactPhone;
base.emergencyContactRelationship = user.emergencyContactRelationship;
base.bankName = user.bankName;
base.bankAccountNumber = user.bankAccountNumber;
base.bankAccountHolderName = user.bankAccountHolderName;
base.taxRegistrationNumber = user.taxRegistrationNumber;
base.weeklySchedule = user.weeklySchedule;
base.baseSalaryPiasters = user.baseSalaryPiasters;
base.actualSalaryPiasters = user.actualSalaryPiasters;
base.contractStartDate = user.contractStartDate;
base.contractEndDate = user.contractEndDate;
base.assignedProjectLeaderId = user.assignedProjectLeaderId;
base.forcePasswordChange = user.forcePasswordChange;
base.failedLoginAttempts = user.failedLoginAttempts;
base.lockedUntil = user.lockedUntil;
}
// Never expose password hash
return base;
}
private sanitizeUserForDirectory(user: any, currentUser?: RequestUser): any {
const entry: any = {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
displayName: user.displayName,
avatar: user.avatar,
role: user.role,
contractorType: user.contractorType,
department: user.department,
title: user.title,
status: user.status,
};
if (currentUser) {
const isAdminPlus = currentUser.role === 'SUPER_ADMIN' || currentUser.role === 'ADMIN';
if (isAdminPlus) {
entry.phone = user.phone;
entry.email = user.email;
entry.actualSalaryPiasters = user.actualSalaryPiasters;
}
if (currentUser.role === 'TEAM_LEAD' && user.assignedProjectLeaderId === currentUser.id) {
entry.phone = user.phone;
entry.email = user.email;
}
}
return entry;
}
private generateTemporaryPassword(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*';
let password = '';
const bytes = crypto.randomBytes(12);
for (let i = 0; i < 12; i++) {
password += chars[bytes[i] % chars.length];
}
return password;
}
private async getSalaryRates(): Promise<{
fullTimeInOffice: number;
fullTimeRemote: number;
internInOffice: number;
internRemote: number;
}> {
const settings = await this.prisma.setting.findMany({
where: {
key: {
in: [
'fullTimeInOfficeRate',
'fullTimeRemoteRate',
'internInOfficeRate',
'internRemoteRate',
],
},
},
});
const map: Record<string, any> = {};
for (const s of settings) {
map[s.key] = s.value;
}
return {
fullTimeInOffice: (map['fullTimeInOfficeRate'] as number) || 240000,
fullTimeRemote: (map['fullTimeRemoteRate'] as number) || 160000,
internInOffice: (map['internInOfficeRate'] as number) || 100000,
internRemote: (map['internRemoteRate'] as number) || 50000,
};
}
}
\ No newline at end of file
// ─── ADD TO User MODEL ──────────────────────────────────────────
// These fields must exist on the User model:
// nameArabic String?
// nationalId String? @unique
// dateOfBirth DateTime?
// phone String? @unique
// phoneSecondary String?
// address String?
// emergencyContactName String?
// emergencyContactPhone String?
// emergencyContactRelationship String?
// bankName String?
// bankAccountNumber String?
// bankAccountHolderName String?
// taxRegistrationNumber String?
// contractorType String?
// department String?
// title String?
// bio String?
// timezone String @default("Africa/Cairo")
// weeklySchedule Json?
// baseSalaryPiasters Int @default(0)
// actualSalaryPiasters Int @default(0)
// startDate DateTime?
// contractStartDate DateTime?
// contractEndDate DateTime?
// assignedProjectLeaderId String?
// onboardingChecklist Json?
// deletedAt DateTime?
// ─── NEW MODELS ─────────────────────────────────────────────────
model Invite {
id String @id @default(uuid())
code String @unique
token String @unique
contractorType String
assignedProjectLeaderId String?
assignedBoardIds String[]
welcomeNote String?
expiresAt DateTime
status String @default("ACTIVE") // ACTIVE, USED, EXPIRED, REVOKED
createdById String
createdBy User @relation("InviteCreator", fields: [createdById], references: [id])
usedById String?
usedBy User? @relation("InviteUsed", fields: [usedById], references: [id])
usedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([code])
@@index([token])
@@index([status])
}
model BoardMember {
id String @id @default(uuid())
boardId String
userId String
role String @default("MEMBER") // OWNER, ADMIN, MEMBER, VIEWER
joinedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([boardId, userId])
@@index([boardId])
@@index([userId])
}
model Contract {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contractType String
contractText String
signedAt DateTime
signedFullName String
acknowledgedClauses String[]
signatureIpAddress String
signatureUserAgent String
baseSalaryAtSigning Int
scheduleAtSigning Json?
startDate DateTime
endDate DateTime?
status String @default("ACTIVE") // DRAFT, ACTIVE, EXPIRED, TERMINATED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
}
model CompetencyRating {
id String @id @default(uuid())
userId String
competencyAreaId String
type String // SELF, PL_ASSESSMENT
level Int // 0-5
assessedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
competencyArea CompetencyArea @relation(fields: [competencyAreaId], references: [id])
@@unique([userId, competencyAreaId, type])
@@index([userId])
}
model SalaryChangeLog {
id String @id @default(uuid())
userId String
oldSalaryPiasters Int
newSalaryPiasters Int
reason String
changedById String
createdAt DateTime @default(now())
@@index([userId])
}
model StatusChangeLog {
id String @id @default(uuid())
userId String
fromStatus String
toStatus String
reason String?
changedById String
createdAt DateTime @default(now())
@@index([userId])
}
model PrivateNote {
id String @id @default(uuid())
userId String
content String
authorId String
author User @relation("NoteAuthor", fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
\ 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