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'; ...@@ -15,6 +15,8 @@ import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { SettingsModule } from './modules/settings/settings.module'; import { SettingsModule } from './modules/settings/settings.module';
import { AuditTrailModule } from './modules/audit-trail/audit-trail.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 { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard'; import { RolesGuard } from './common/guards/roles.guard';
...@@ -36,6 +38,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware'; ...@@ -36,6 +38,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
AuthModule, AuthModule,
SettingsModule, SettingsModule,
AuditTrailModule, AuditTrailModule,
UsersModule,
OnboardingModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { 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
This diff is collapsed.
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
This diff is collapsed.
// ─── 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