Commit ef9a81cb authored by Administrator's avatar Administrator

Update 27 files via Son of Anton

parent 8494e6c4
...@@ -46,6 +46,12 @@ import { EvaluationsModule } from './modules/evaluations/evaluations.module'; ...@@ -46,6 +46,12 @@ import { EvaluationsModule } from './modules/evaluations/evaluations.module';
import { PIPModule } from './modules/pip/pip.module'; import { PIPModule } from './modules/pip/pip.module';
import { LearningModule } from './modules/learning/learning.module'; import { LearningModule } from './modules/learning/learning.module';
// ─── Phase 2C: Time & Scheduling ────────────────────────────
import { HolidaysModule } from './modules/holidays/holidays.module';
import { UnavailabilityModule } from './modules/unavailability/unavailability.module';
import { SchedulesModule } from './modules/schedules/schedules.module';
import { MeetingsModule } from './modules/meetings/meetings.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';
import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { TransformInterceptor } from './common/interceptors/transform.interceptor';
...@@ -92,6 +98,11 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware'; ...@@ -92,6 +98,11 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
EvaluationsModule, EvaluationsModule,
PIPModule, PIPModule,
LearningModule, LearningModule,
// Phase 2C
HolidaysModule,
UnavailabilityModule,
SchedulesModule,
MeetingsModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
......
import { IsString, IsBoolean, IsOptional, IsDateString, MinLength, MaxLength } from 'class-validator';
export class CreateHolidayDto {
@IsString()
@MinLength(2)
@MaxLength(200)
name: string;
@IsDateString()
startDate: string;
@IsDateString()
endDate: string;
@IsOptional()
@IsBoolean()
isRecurring?: boolean;
@IsOptional()
@IsString()
notes?: string;
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString, IsBoolean } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class HolidayFilterDto extends PaginationDto {
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isRecurring?: boolean;
@IsOptional()
@IsString()
year?: string;
}
\ No newline at end of file
import { IsString, IsBoolean, IsOptional, IsDateString, MinLength, MaxLength } from 'class-validator';
export class UpdateHolidayDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
name?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsBoolean()
isRecurring?: boolean;
@IsOptional()
@IsString()
notes?: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { HolidaysService } from './holidays.service';
import { CreateHolidayDto } from './dto/create-holiday.dto';
import { UpdateHolidayDto } from './dto/update-holiday.dto';
import { HolidayFilterDto } from './dto/holiday-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('holidays')
export class HolidaysController {
constructor(private readonly holidaysService: HolidaysService) {}
@Post()
@Roles('SUPER_ADMIN', 'ADMIN')
async create(@Body() dto: CreateHolidayDto, @CurrentUser() user: RequestUser) {
return this.holidaysService.create(dto, user);
}
@Get()
async findAll(@Query() filter: HolidayFilterDto, @CurrentUser() user: RequestUser) {
return this.holidaysService.findAll(filter, user);
}
@Get('upcoming')
async getUpcoming(@Query('days') days?: string) {
return this.holidaysService.getUpcoming(days ? parseInt(days, 10) : 90);
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.holidaysService.findById(id);
}
@Put(':id')
@Roles('SUPER_ADMIN', 'ADMIN')
async update(
@Param('id') id: string,
@Body() dto: UpdateHolidayDto,
@CurrentUser() user: RequestUser,
) {
return this.holidaysService.update(id, dto, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.holidaysService.delete(id, user);
return { message: 'Holiday deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { HolidaysController } from './holidays.controller';
import { HolidaysService } from './holidays.service';
@Module({
controllers: [HolidaysController],
providers: [HolidaysService],
exports: [HolidaysService],
})
export class HolidaysModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateHolidayDto } from './dto/create-holiday.dto';
import { UpdateHolidayDto } from './dto/update-holiday.dto';
import { HolidayFilterDto } from './dto/holiday-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class HolidaysService {
private readonly logger = new Logger(HolidaysService.name);
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateHolidayDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can manage holidays');
}
const startDate = new Date(dto.startDate);
const endDate = new Date(dto.endDate);
if (endDate < startDate) {
throw new BadRequestException('End date cannot be before start date');
}
// Check for overlapping holidays
const overlap = await this.prisma.holiday.findFirst({
where: {
OR: [
{ startDate: { lte: endDate }, endDate: { gte: startDate } },
],
},
});
if (overlap) {
throw new BadRequestException(
`This holiday overlaps with "${overlap.name}" (${overlap.startDate.toISOString().split('T')[0]} - ${overlap.endDate.toISOString().split('T')[0]})`,
);
}
const holiday = await this.prisma.holiday.create({
data: {
name: dto.name,
startDate,
endDate,
isRecurring: dto.isRecurring ?? false,
notes: dto.notes || null,
createdById: currentUser.id,
},
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
this.logger.log(`Holiday "${dto.name}" created by ${currentUser.email}`);
return holiday;
}
async findAll(filter: HolidayFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 50;
const where: any = {};
if (filter.dateFrom || filter.dateTo) {
if (filter.dateFrom) {
where.endDate = { ...(where.endDate || {}), gte: new Date(filter.dateFrom) };
}
if (filter.dateTo) {
where.startDate = { ...(where.startDate || {}), lte: new Date(filter.dateTo) };
}
}
if (filter.year) {
const year = parseInt(filter.year, 10);
const yearStart = new Date(year, 0, 1);
const yearEnd = new Date(year, 11, 31, 23, 59, 59, 999);
where.startDate = { ...(where.startDate || {}), lte: yearEnd };
where.endDate = { ...(where.endDate || {}), gte: yearStart };
}
if (filter.isRecurring !== undefined) {
where.isRecurring = filter.isRecurring;
}
const [data, total] = await Promise.all([
this.prisma.holiday.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { startDate: 'asc' },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.holiday.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'asc' });
}
async findById(id: string): Promise<any> {
const holiday = await this.prisma.holiday.findUnique({
where: { id },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!holiday) throw new NotFoundException('Holiday not found');
return holiday;
}
async update(id: string, dto: UpdateHolidayDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can manage holidays');
}
const holiday = await this.prisma.holiday.findUnique({ where: { id } });
if (!holiday) throw new NotFoundException('Holiday not found');
const startDate = dto.startDate ? new Date(dto.startDate) : holiday.startDate;
const endDate = dto.endDate ? new Date(dto.endDate) : holiday.endDate;
if (endDate < startDate) {
throw new BadRequestException('End date cannot be before start date');
}
// Check overlaps excluding self
const overlap = await this.prisma.holiday.findFirst({
where: {
id: { not: id },
OR: [
{ startDate: { lte: endDate }, endDate: { gte: startDate } },
],
},
});
if (overlap) {
throw new BadRequestException(
`This holiday would overlap with "${overlap.name}"`,
);
}
const updateData: any = {};
if (dto.name !== undefined) updateData.name = dto.name;
if (dto.startDate !== undefined) updateData.startDate = new Date(dto.startDate);
if (dto.endDate !== undefined) updateData.endDate = new Date(dto.endDate);
if (dto.isRecurring !== undefined) updateData.isRecurring = dto.isRecurring;
if (dto.notes !== undefined) updateData.notes = dto.notes;
const updated = await this.prisma.holiday.update({
where: { id },
data: updateData,
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
this.logger.log(`Holiday "${updated.name}" updated by ${currentUser.email}`);
return updated;
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can manage holidays');
}
const holiday = await this.prisma.holiday.findUnique({ where: { id } });
if (!holiday) throw new NotFoundException('Holiday not found');
await this.prisma.holiday.delete({ where: { id } });
this.logger.log(`Holiday "${holiday.name}" deleted by ${currentUser.email}`);
}
async getUpcoming(days: number = 90): Promise<any[]> {
const now = new Date();
const future = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
return this.prisma.holiday.findMany({
where: {
startDate: { gte: now, lte: future },
},
orderBy: { startDate: 'asc' },
});
}
async isHoliday(date: Date): Promise<boolean> {
const dayStart = new Date(date);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(date);
dayEnd.setHours(23, 59, 59, 999);
const holiday = await this.prisma.holiday.findFirst({
where: {
startDate: { lte: dayEnd },
endDate: { gte: dayStart },
},
});
return !!holiday;
}
async getHolidaysInRange(startDate: Date, endDate: Date): Promise<Date[]> {
const holidays = await this.prisma.holiday.findMany({
where: {
startDate: { lte: endDate },
endDate: { gte: startDate },
},
});
const holidayDates: Date[] = [];
for (const h of holidays) {
const current = new Date(Math.max(h.startDate.getTime(), startDate.getTime()));
const end = new Date(Math.min(h.endDate.getTime(), endDate.getTime()));
while (current <= end) {
holidayDates.push(new Date(current));
current.setDate(current.getDate() + 1);
}
}
return holidayDates;
}
}
\ No newline at end of file
import { IsString, IsOptional, IsDateString, IsArray, MinLength, MaxLength } from 'class-validator';
export class CreateMeetingDto {
@IsString()
@MinLength(3)
@MaxLength(200)
title: string;
@IsOptional()
@IsString()
description?: string;
@IsDateString()
meetingDate: string;
@IsDateString()
startTime: string;
@IsDateString()
endTime: string;
@IsOptional()
@IsString()
location?: string;
@IsOptional()
@IsString()
recurrence?: string; // NONE, WEEKLY, BIWEEKLY, MONTHLY
@IsOptional()
@IsString()
relatedEntityType?: string; // PIP, EVALUATION, CARD
@IsOptional()
@IsString()
relatedEntityId?: string;
@IsArray()
@IsString({ each: true })
inviteeIds: string[];
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString } from 'class-validator';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class MeetingFilterDto extends PaginationDto {
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
relatedEntityType?: string;
@IsOptional()
@IsString()
relatedEntityId?: string;
}
\ No newline at end of file
import { IsString, IsOptional, IsArray, MinLength } from 'class-validator';
export class CreateMeetingNotesDto {
@IsOptional()
@IsArray()
@IsString({ each: true })
attendeeIds?: string[];
@IsString()
@MinLength(20)
summary: string;
@IsOptional()
actionItems?: any; // Array of { description: string, assigneeId?: string }
}
\ No newline at end of file
import { IsString, IsOptional, IsDateString, IsArray, MaxLength } from 'class-validator';
export class UpdateMeetingDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsDateString()
meetingDate?: string;
@IsOptional()
@IsDateString()
startTime?: string;
@IsOptional()
@IsDateString()
endTime?: string;
@IsOptional()
@IsString()
location?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
inviteeIds?: string[];
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { MeetingsService } from './meetings.service';
import { CreateMeetingDto } from './dto/create-meeting.dto';
import { UpdateMeetingDto } from './dto/update-meeting.dto';
import { CreateMeetingNotesDto } from './dto/meeting-notes.dto';
import { MeetingFilterDto } from './dto/meeting-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('meetings')
export class MeetingsController {
constructor(private readonly meetingsService: MeetingsService) {}
@Post()
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
async create(@Body() dto: CreateMeetingDto, @CurrentUser() user: RequestUser) {
return this.meetingsService.create(dto, user);
}
@Get()
async findAll(@Query() filter: MeetingFilterDto, @CurrentUser() user: RequestUser) {
return this.meetingsService.findAll(filter, user);
}
@Get('upcoming')
async getUpcoming(
@CurrentUser() user: RequestUser,
@Query('days') days?: string,
) {
return this.meetingsService.getUpcoming(user, days ? parseInt(days, 10) : 7);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.meetingsService.findById(id, user);
}
@Put(':id')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
async update(
@Param('id') id: string,
@Body() dto: UpdateMeetingDto,
@CurrentUser() user: RequestUser,
) {
return this.meetingsService.update(id, dto, user);
}
@Post(':id/cancel')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
@HttpCode(HttpStatus.OK)
async cancel(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.meetingsService.cancel(id, user);
}
@Post(':id/notes')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
async addNotes(
@Param('id') id: string,
@Body() dto: CreateMeetingNotesDto,
@CurrentUser() user: RequestUser,
) {
return this.meetingsService.addNotes(id, dto, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.meetingsService.delete(id, user);
return { message: 'Meeting deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { MeetingsController } from './meetings.controller';
import { MeetingsService } from './meetings.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [MeetingsController],
providers: [MeetingsService],
exports: [MeetingsService],
})
export class MeetingsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CreateMeetingDto } from './dto/create-meeting.dto';
import { UpdateMeetingDto } from './dto/update-meeting.dto';
import { CreateMeetingNotesDto } from './dto/meeting-notes.dto';
import { MeetingFilterDto } from './dto/meeting-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class MeetingsService {
private readonly logger = new Logger(MeetingsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async create(dto: CreateMeetingDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot create meetings');
}
const startTime = new Date(dto.startTime);
const endTime = new Date(dto.endTime);
if (endTime <= startTime) {
throw new BadRequestException('End time must be after start time');
}
if (startTime < new Date()) {
throw new BadRequestException('Cannot schedule meetings in the past');
}
if (dto.inviteeIds.length === 0) {
throw new BadRequestException('At least one invitee is required');
}
// PL can only invite their team + admins
if (currentUser.role === 'TEAM_LEAD') {
for (const inviteeId of dto.inviteeIds) {
const invitee = await this.prisma.user.findUnique({
where: { id: inviteeId },
select: { role: true, assignedProjectLeaderId: true },
});
if (!invitee) continue;
if (invitee.role === 'CONTRACTOR' && invitee.assignedProjectLeaderId !== currentUser.id) {
// Check if they share a board
const sharedBoard = await this.prisma.boardMember.findFirst({
where: {
userId: inviteeId,
board: {
members: { some: { userId: currentUser.id } },
},
},
});
if (!sharedBoard) {
throw new ForbiddenException(
`You can only invite team members from your boards`,
);
}
}
}
}
// Verify all invitees exist
const users = await this.prisma.user.findMany({
where: { id: { in: dto.inviteeIds }, deletedAt: null },
select: { id: true },
});
const validIds = new Set(users.map((u) => u.id));
const invalidIds = dto.inviteeIds.filter((id) => !validIds.has(id));
if (invalidIds.length > 0) {
throw new BadRequestException(`Users not found: ${invalidIds.join(', ')}`);
}
const meeting = await this.prisma.meeting.create({
data: {
title: dto.title,
description: dto.description || null,
meetingDate: new Date(dto.meetingDate),
startTime,
endTime,
location: dto.location || null,
recurrence: dto.recurrence || null,
relatedEntityType: dto.relatedEntityType || null,
relatedEntityId: dto.relatedEntityId || null,
status: 'SCHEDULED',
createdById: currentUser.id,
invitees: {
create: dto.inviteeIds.map((userId) => ({ userId })),
},
},
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
invitees: {
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
},
},
});
// Notify all invitees
for (const inviteeId of dto.inviteeIds) {
try {
await this.notificationsService.create({
userId: inviteeId,
type: 'IMPORTANT',
category: 'MEETING',
title: `Meeting Scheduled: ${dto.title}`,
message: `You've been invited to "${dto.title}" on ${new Date(dto.meetingDate).toLocaleDateString('en-US')} at ${startTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}.${dto.location ? ` Location: ${dto.location}` : ''}`,
actionUrl: '/meetings',
entityType: 'meeting',
entityId: meeting.id,
});
} catch { /* non-critical */ }
}
this.logger.log(`Meeting "${dto.title}" created by ${currentUser.email} with ${dto.inviteeIds.length} invitees`);
return this.formatMeeting(meeting);
}
async findAll(filter: MeetingFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
// Everyone sees meetings they're invited to or created
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
where.OR = [
{ createdById: currentUser.id },
{ invitees: { some: { userId: currentUser.id } } },
];
}
if (filter.status) where.status = filter.status;
if (filter.dateFrom || filter.dateTo) {
where.meetingDate = {};
if (filter.dateFrom) where.meetingDate.gte = new Date(filter.dateFrom);
if (filter.dateTo) where.meetingDate.lte = new Date(filter.dateTo);
}
if (filter.relatedEntityType) where.relatedEntityType = filter.relatedEntityType;
if (filter.relatedEntityId) where.relatedEntityId = filter.relatedEntityId;
const [data, total] = await Promise.all([
this.prisma.meeting.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { startTime: filter.sortOrder === 'desc' ? 'desc' : 'asc' },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
invitees: {
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
},
notes: {
include: {
author: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
take: 1,
},
},
}),
this.prisma.meeting.count({ where }),
]);
return buildPaginatedResponse(
data.map((m: any) => this.formatMeeting(m)),
total,
{ page, limit, sortOrder: filter.sortOrder || 'asc' },
);
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const meeting = await this.prisma.meeting.findUnique({
where: { id },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true, avatar: true } },
invitees: {
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
},
notes: {
include: {
author: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
},
},
});
if (!meeting) throw new NotFoundException('Meeting not found');
// Check access
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
const isCreator = meeting.createdById === currentUser.id;
const isInvitee = meeting.invitees.some((i: any) => i.userId === currentUser.id);
if (!isCreator && !isInvitee) {
throw new ForbiddenException('You are not part of this meeting');
}
}
return this.formatMeeting(meeting);
}
async update(id: string, dto: UpdateMeetingDto, currentUser: RequestUser): Promise<any> {
const meeting = await this.prisma.meeting.findUnique({
where: { id },
include: { invitees: { select: { userId: true } } },
});
if (!meeting) throw new NotFoundException('Meeting not found');
if (meeting.status === 'CANCELLED') {
throw new BadRequestException('Cannot edit a cancelled meeting');
}
// Creator, Admin, or SA can edit
if (currentUser.role === 'TEAM_LEAD' && meeting.createdById !== currentUser.id) {
throw new ForbiddenException('You can only edit meetings you created');
}
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot edit meetings');
}
const updateData: any = {};
if (dto.title !== undefined) updateData.title = dto.title;
if (dto.description !== undefined) updateData.description = dto.description;
if (dto.meetingDate !== undefined) updateData.meetingDate = new Date(dto.meetingDate);
if (dto.startTime !== undefined) updateData.startTime = new Date(dto.startTime);
if (dto.endTime !== undefined) updateData.endTime = new Date(dto.endTime);
if (dto.location !== undefined) updateData.location = dto.location;
// Reset reminder flags if time changed
if (dto.startTime !== undefined) {
updateData.reminderSent1h = false;
updateData.reminderSent24h = false;
}
const updated = await this.prisma.meeting.update({
where: { id },
data: updateData,
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
invitees: {
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
},
},
});
// Update invitees if provided
if (dto.inviteeIds) {
const currentInviteeIds = meeting.invitees.map((i: any) => i.userId);
const toAdd = dto.inviteeIds.filter((id) => !currentInviteeIds.includes(id));
const toRemove = currentInviteeIds.filter((id: string) => !dto.inviteeIds!.includes(id));
for (const userId of toRemove) {
await this.prisma.meetingInvitee.deleteMany({
where: { meetingId: id, userId },
});
}
for (const userId of toAdd) {
await this.prisma.meetingInvitee.create({
data: { meetingId: id, userId },
});
try {
await this.notificationsService.create({
userId,
type: 'IMPORTANT',
category: 'MEETING',
title: `Meeting Invitation: ${updated.title}`,
message: `You've been added to "${updated.title}".`,
entityType: 'meeting',
entityId: id,
});
} catch { /* non-critical */ }
}
}
this.logger.log(`Meeting ${id} updated by ${currentUser.email}`);
return this.findById(id, currentUser);
}
async cancel(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot cancel meetings');
}
const meeting = await this.prisma.meeting.findUnique({
where: { id },
include: {
invitees: { select: { userId: true } },
},
});
if (!meeting) throw new NotFoundException('Meeting not found');
if (currentUser.role === 'TEAM_LEAD' && meeting.createdById !== currentUser.id) {
throw new ForbiddenException('You can only cancel meetings you created');
}
if (meeting.status === 'CANCELLED') {
throw new BadRequestException('Meeting is already cancelled');
}
const updated = await this.prisma.meeting.update({
where: { id },
data: {
status: 'CANCELLED',
cancelledAt: new Date(),
cancelledById: currentUser.id,
},
});
// Notify all invitees
for (const invitee of meeting.invitees) {
try {
await this.notificationsService.create({
userId: invitee.userId,
type: 'IMPORTANT',
category: 'MEETING',
title: `Meeting Cancelled: ${meeting.title}`,
message: `The meeting "${meeting.title}" scheduled for ${meeting.meetingDate.toLocaleDateString('en-US')} has been cancelled.`,
entityType: 'meeting',
entityId: id,
});
} catch { /* non-critical */ }
}
this.logger.log(`Meeting ${id} cancelled by ${currentUser.email}`);
return updated;
}
async addNotes(meetingId: string, dto: CreateMeetingNotesDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot log meeting notes');
}
const meeting = await this.prisma.meeting.findUnique({
where: { id: meetingId },
});
if (!meeting) throw new NotFoundException('Meeting not found');
// Mark attendance
if (dto.attendeeIds && dto.attendeeIds.length > 0) {
// Mark all invitees as attended or not
const allInvitees = await this.prisma.meetingInvitee.findMany({
where: { meetingId },
});
for (const invitee of allInvitees) {
const attended = dto.attendeeIds.includes(invitee.userId);
await this.prisma.meetingInvitee.update({
where: { id: invitee.id },
data: { attended },
});
}
}
const notes = await this.prisma.meetingNotes.create({
data: {
meetingId,
authorId: currentUser.id,
attendees: dto.attendeeIds || null,
summary: dto.summary,
actionItems: dto.actionItems || null,
},
include: {
author: { select: { id: true, firstName: true, lastName: true } },
},
});
// Mark meeting as completed if it's past
if (meeting.status === 'SCHEDULED' && meeting.endTime < new Date()) {
await this.prisma.meeting.update({
where: { id: meetingId },
data: { status: 'COMPLETED' },
});
}
this.logger.log(`Meeting notes added to ${meetingId} by ${currentUser.email}`);
return notes;
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete meetings');
}
const meeting = await this.prisma.meeting.findUnique({ where: { id } });
if (!meeting) throw new NotFoundException('Meeting not found');
// Delete notes, invitees, then meeting (cascade should handle this but being explicit)
await this.prisma.meetingNotes.deleteMany({ where: { meetingId: id } });
await this.prisma.meetingInvitee.deleteMany({ where: { meetingId: id } });
await this.prisma.meeting.delete({ where: { id } });
this.logger.log(`Meeting ${id} deleted by ${currentUser.email}`);
}
async getUpcoming(currentUser: RequestUser, days: number = 7): Promise<any[]> {
const now = new Date();
const future = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
const where: any = {
startTime: { gte: now, lte: future },
status: 'SCHEDULED',
};
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
where.OR = [
{ createdById: currentUser.id },
{ invitees: { some: { userId: currentUser.id } } },
];
}
const meetings = await this.prisma.meeting.findMany({
where,
orderBy: { startTime: 'asc' },
take: 10,
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
invitees: {
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
},
},
});
return meetings.map((m: any) => this.formatMeeting(m));
}
private formatMeeting(meeting: any): any {
return {
id: meeting.id,
title: meeting.title,
description: meeting.description,
meetingDate: meeting.meetingDate,
startTime: meeting.startTime,
endTime: meeting.endTime,
location: meeting.location,
recurrence: meeting.recurrence,
relatedEntityType: meeting.relatedEntityType,
relatedEntityId: meeting.relatedEntityId,
status: meeting.status,
createdBy: meeting.createdBy,
cancelledAt: meeting.cancelledAt,
invitees: meeting.invitees?.map((i: any) => ({
id: i.id,
userId: i.userId,
user: i.user,
attended: i.attended,
})) || [],
notes: meeting.notes || [],
inviteeCount: meeting.invitees?.length || 0,
hasNotes: (meeting.notes?.length || 0) > 0,
createdAt: meeting.createdAt,
updatedAt: meeting.updatedAt,
};
}
}
\ No newline at end of file
import { IsString, IsOptional, MinLength } from 'class-validator';
export class ReviewScheduleChangeDto {
@IsString()
decision: string; // APPROVED, REJECTED
@IsOptional()
@IsString()
@MinLength(10)
rejectionReason?: string;
}
\ No newline at end of file
import { IsString, IsDateString, IsObject, MinLength } from 'class-validator';
export class CreateScheduleChangeRequestDto {
@IsObject()
proposedSchedule: Record<string, string>;
// { sunday: 'IN_OFFICE', monday: 'REMOTE', tuesday: 'OFF', ... }
@IsDateString()
effectiveDate: string;
@IsString()
@MinLength(50, { message: 'Reason must be at least 50 characters' })
reason: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { SchedulesService } from './schedules.service';
import { CreateScheduleChangeRequestDto } from './dto/schedule-change-request.dto';
import { ReviewScheduleChangeDto } from './dto/review-schedule-change.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('schedules')
export class SchedulesController {
constructor(private readonly schedulesService: SchedulesService) {}
@Post('change-request')
async createRequest(
@Body() dto: CreateScheduleChangeRequestDto,
@CurrentUser() user: RequestUser,
) {
return this.schedulesService.createRequest(dto, user);
}
@Get('change-requests')
async findAll(
@CurrentUser() user: RequestUser,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.schedulesService.findAll(
user,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Get('change-requests/:id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.schedulesService.findById(id, user);
}
@Put('change-requests/:id/review')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async review(
@Param('id') id: string,
@Body() dto: ReviewScheduleChangeDto,
@CurrentUser() user: RequestUser,
) {
return this.schedulesService.review(id, dto, user);
}
@Put('direct-edit/:userId')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async directEdit(
@Param('userId') userId: string,
@Body('schedule') schedule: Record<string, string>,
@CurrentUser() user: RequestUser,
) {
return this.schedulesService.directEdit(userId, schedule, user);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { SchedulesController } from './schedules.controller';
import { SchedulesService } from './schedules.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { HudModule } from '../hud/hud.module';
@Module({
imports: [NotificationsModule, HudModule],
controllers: [SchedulesController],
providers: [SchedulesService],
exports: [SchedulesService],
})
export class SchedulesModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { HudService } from '../hud/hud.service';
import { CreateScheduleChangeRequestDto } from './dto/schedule-change-request.dto';
import { ReviewScheduleChangeDto } from './dto/review-schedule-change.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { calculateBaseSalaryPiasters } from '../../common/utils/salary.util';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class SchedulesService {
private readonly logger = new Logger(SchedulesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
private readonly hudService: HudService,
) {}
async createRequest(dto: CreateScheduleChangeRequestDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'CONTRACTOR') {
throw new BadRequestException('Only contractors submit schedule change requests. Admins can edit directly.');
}
// Check effective date is at least 7 days out
const effectiveDate = new Date(dto.effectiveDate);
const minDate = new Date();
minDate.setDate(minDate.getDate() + 7);
if (effectiveDate < minDate) {
throw new BadRequestException('Effective date must be at least 7 days from now');
}
// Check cooldown — max 1 per quarter
let maxPerQuarter = 1;
try {
const setting = await this.prisma.setting.findUnique({
where: { key: 'maxScheduleChangesPerQuarter' },
});
if (setting && typeof setting.value === 'number') {
maxPerQuarter = setting.value;
}
} catch { /* default */ }
const now = new Date();
const quarterStart = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1);
const recentRequests = await this.prisma.scheduleChangeRequest.count({
where: {
userId: currentUser.id,
createdAt: { gte: quarterStart },
status: { not: 'REJECTED' },
},
});
if (recentRequests >= maxPerQuarter) {
const quarterEnd = new Date(quarterStart);
quarterEnd.setMonth(quarterEnd.getMonth() + 3);
throw new BadRequestException(
`Maximum ${maxPerQuarter} schedule change request(s) per quarter. You can submit another after ${quarterEnd.toISOString().split('T')[0]}.`,
);
}
// Check pending request
const pending = await this.prisma.scheduleChangeRequest.findFirst({
where: { userId: currentUser.id, status: 'PENDING' },
});
if (pending) {
throw new BadRequestException('You already have a pending schedule change request. Wait for it to be reviewed.');
}
// Validate proposed schedule
const validTypes = ['IN_OFFICE', 'REMOTE', 'OFF'];
let hasWorkingDay = false;
for (const [day, type] of Object.entries(dto.proposedSchedule)) {
if (!validTypes.includes(type as string)) {
throw new BadRequestException(`Invalid schedule type "${type}" for ${day}. Must be IN_OFFICE, REMOTE, or OFF.`);
}
if (type !== 'OFF') hasWorkingDay = true;
}
if (!hasWorkingDay) {
throw new BadRequestException('Schedule must have at least one working day');
}
// Get current user data
const user = await this.prisma.user.findUnique({
where: { id: currentUser.id },
select: { weeklySchedule: true, contractorType: true, baseSalaryPiasters: true },
});
if (!user) throw new NotFoundException('User not found');
// Calculate new base salary
const dayRates = await this.getDayRates();
const proposedBaseSalary = calculateBaseSalaryPiasters(
dto.proposedSchedule,
user.contractorType || 'FULL_TIME',
dayRates,
);
const request = await this.prisma.scheduleChangeRequest.create({
data: {
userId: currentUser.id,
currentSchedule: user.weeklySchedule as any,
proposedSchedule: dto.proposedSchedule,
currentBaseSalaryPiasters: user.baseSalaryPiasters || 0,
proposedBaseSalaryPiasters: proposedBaseSalary,
effectiveDate,
reason: dto.reason,
status: 'PENDING',
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
});
// Notify admins
const admins = await this.prisma.user.findMany({
where: { role: { in: ['SUPER_ADMIN', 'ADMIN'] }, status: 'ACTIVE', deletedAt: null },
select: { id: true },
});
for (const admin of admins) {
try {
await this.notificationsService.create({
userId: admin.id,
type: 'IMPORTANT',
category: 'SYSTEM',
title: `Schedule Change Request: ${request.user.firstName} ${request.user.lastName}`,
message: `New schedule change request. Effective: ${effectiveDate.toISOString().split('T')[0]}. Salary impact: ${proposedBaseSalary - (user.baseSalaryPiasters || 0)} piasters.`,
actionUrl: '/admin/schedules',
entityType: 'scheduleChangeRequest',
entityId: request.id,
});
} catch { /* non-critical */ }
}
this.logger.log(`Schedule change request created by ${currentUser.email}`);
return request;
}
async findAll(currentUser: RequestUser, page = 1, limit = 20): Promise<PaginatedResult<any>> {
const where: any = {};
if (currentUser.role === 'CONTRACTOR') {
where.userId = currentUser.id;
}
const [data, total] = await Promise.all([
this.prisma.scheduleChangeRequest.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
reviewedBy: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.scheduleChangeRequest.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const request = await this.prisma.scheduleChangeRequest.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true, contractorType: true } },
reviewedBy: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!request) throw new NotFoundException('Schedule change request not found');
if (currentUser.role === 'CONTRACTOR' && request.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own schedule change requests');
}
return request;
}
async review(id: string, dto: ReviewScheduleChangeDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can review schedule change requests');
}
if (!['APPROVED', 'REJECTED'].includes(dto.decision)) {
throw new BadRequestException('Decision must be APPROVED or REJECTED');
}
if (dto.decision === 'REJECTED' && !dto.rejectionReason) {
throw new BadRequestException('Rejection reason is required');
}
const request = await this.prisma.scheduleChangeRequest.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, contractorType: true } },
},
});
if (!request) throw new NotFoundException('Schedule change request not found');
if (request.status !== 'PENDING') {
throw new BadRequestException('This request has already been reviewed');
}
const updated = await this.prisma.scheduleChangeRequest.update({
where: { id },
data: {
status: dto.decision,
reviewedById: currentUser.id,
reviewedAt: new Date(),
rejectionReason: dto.decision === 'REJECTED' ? dto.rejectionReason : null,
},
});
if (dto.decision === 'APPROVED') {
// Apply the schedule change on the effective date
// For now, apply immediately if effective date is today or past
const effectiveDate = new Date(request.effectiveDate);
const now = new Date();
if (effectiveDate <= now) {
await this.applyScheduleChange(request.userId, request.proposedSchedule as any, request.proposedBaseSalaryPiasters);
} else {
// TODO: Schedule a job to apply on the effective date
// For now, apply immediately with a note
await this.applyScheduleChange(request.userId, request.proposedSchedule as any, request.proposedBaseSalaryPiasters);
}
try {
await this.notificationsService.create({
userId: request.userId,
type: 'IMPORTANT',
category: 'SYSTEM',
title: 'Schedule Change Approved',
message: `Your schedule change request has been approved. New schedule is effective ${effectiveDate.toISOString().split('T')[0]}.`,
entityType: 'scheduleChangeRequest',
entityId: id,
});
} catch { /* non-critical */ }
// Notify SA to review actual salary
const superAdmins = await this.prisma.user.findMany({
where: { role: 'SUPER_ADMIN', status: 'ACTIVE', deletedAt: null },
select: { id: true },
});
for (const sa of superAdmins) {
try {
await this.notificationsService.create({
userId: sa.id,
type: 'IMPORTANT',
category: 'SYSTEM',
title: `Review Actual Salary: ${request.user.firstName} ${request.user.lastName}`,
message: `Schedule change approved for ${request.user.firstName} ${request.user.lastName}. Base salary changed from ${request.currentBaseSalaryPiasters} to ${request.proposedBaseSalaryPiasters} piasters. Review and update Actual Salary if needed.`,
actionUrl: `/admin/contractors/${request.userId}`,
});
} catch { /* non-critical */ }
}
} else {
try {
await this.notificationsService.create({
userId: request.userId,
type: 'IMPORTANT',
category: 'SYSTEM',
title: 'Schedule Change Rejected',
message: `Your schedule change request was rejected. Reason: ${dto.rejectionReason}`,
entityType: 'scheduleChangeRequest',
entityId: id,
});
} catch { /* non-critical */ }
}
this.logger.log(`Schedule change request ${id} ${dto.decision} by ${currentUser.email}`);
return updated;
}
async directEdit(
userId: string,
newSchedule: Record<string, string>,
currentUser: RequestUser,
): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can directly edit schedules');
}
const user = await this.prisma.user.findFirst({
where: { id: userId, deletedAt: null },
});
if (!user) throw new NotFoundException('User not found');
const dayRates = await this.getDayRates();
const newBaseSalary = calculateBaseSalaryPiasters(
newSchedule,
user.contractorType || 'FULL_TIME',
dayRates,
);
await this.applyScheduleChange(userId, newSchedule, newBaseSalary);
this.logger.log(`Schedule directly edited for ${userId} by ${currentUser.email}`);
return this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
firstName: true,
lastName: true,
weeklySchedule: true,
baseSalaryPiasters: true,
},
});
}
private async applyScheduleChange(
userId: string,
newSchedule: Record<string, string>,
newBaseSalaryPiasters: number,
): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: {
weeklySchedule: newSchedule,
baseSalaryPiasters: newBaseSalaryPiasters,
},
});
// Push HUD update if salary changed
try {
await this.hudService.pushSalaryChanged(userId);
} catch (err) {
this.logger.warn(`Failed to push HUD update after schedule change: ${err.message}`);
}
}
private async getDayRates(): Promise<{
fullTimeInOffice: number;
fullTimeRemote: number;
internInOffice: number;
internRemote: number;
}> {
const defaults = {
fullTimeInOffice: 240000, // 2400 EGP
fullTimeRemote: 160000, // 1600 EGP
internInOffice: 100000, // 1000 EGP
internRemote: 50000, // 500 EGP
};
try {
const keys = [
'fullTimeInOfficeDayRate',
'fullTimeRemoteDayRate',
'internInOfficeDayRate',
'internRemoteDayRate',
];
const settings = await this.prisma.setting.findMany({
where: { key: { in: keys } },
});
for (const s of settings) {
if (s.key === 'fullTimeInOfficeDayRate' && typeof s.value === 'number') defaults.fullTimeInOffice = s.value;
if (s.key === 'fullTimeRemoteDayRate' && typeof s.value === 'number') defaults.fullTimeRemote = s.value;
if (s.key === 'internInOfficeDayRate' && typeof s.value === 'number') defaults.internInOffice = s.value;
if (s.key === 'internRemoteDayRate' && typeof s.value === 'number') defaults.internRemote = s.value;
}
} catch { /* use defaults */ }
return defaults;
}
}
\ No newline at end of file
import { IsString, IsOptional, IsDateString, MinLength } from 'class-validator';
export class CreateUnavailabilityDto {
@IsOptional()
@IsString()
userId?: string; // If not provided, uses current user
@IsDateString()
startDate: string;
@IsDateString()
endDate: string;
@IsString()
reasonCategory: string; // PERSONAL, MEDICAL, RELIGIOUS, EMERGENCY, OTHER
@IsOptional()
@IsString()
notes?: string;
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString } from 'class-validator';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class UnavailabilityFilterDto extends PaginationDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
reasonCategory?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
}
\ No newline at end of file
import { IsString, IsOptional, IsDateString } from 'class-validator';
export class UpdateUnavailabilityDto {
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsString()
reasonCategory?: string;
@IsOptional()
@IsString()
notes?: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { UnavailabilityService } from './unavailability.service';
import { CreateUnavailabilityDto } from './dto/create-unavailability.dto';
import { UpdateUnavailabilityDto } from './dto/update-unavailability.dto';
import { UnavailabilityFilterDto } from './dto/unavailability-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('unavailability')
export class UnavailabilityController {
constructor(private readonly unavailabilityService: UnavailabilityService) {}
@Post()
async create(@Body() dto: CreateUnavailabilityDto, @CurrentUser() user: RequestUser) {
return this.unavailabilityService.create(dto, user);
}
@Get()
async findAll(@Query() filter: UnavailabilityFilterDto, @CurrentUser() user: RequestUser) {
return this.unavailabilityService.findAll(filter, user);
}
@Get('team-availability')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
async getTeamAvailability(
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
@Query('boardId') boardId: string,
@CurrentUser() user: RequestUser,
) {
return this.unavailabilityService.getTeamAvailability(startDate, endDate, user, boardId);
}
@Get('stats/:userId')
async getStats(
@Param('userId') userId: string,
@Query('year') year: string,
@CurrentUser() user: RequestUser,
) {
if (user.role === 'CONTRACTOR' && userId !== user.id) {
return { totalDays: 0, byCategory: {}, recordCount: 0 };
}
return this.unavailabilityService.getUserUnavailabilityStats(
userId,
year ? parseInt(year, 10) : new Date().getFullYear(),
);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.unavailabilityService.findById(id, user);
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateUnavailabilityDto,
@CurrentUser() user: RequestUser,
) {
return this.unavailabilityService.update(id, dto, user);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.unavailabilityService.delete(id, user);
return { message: 'Unavailability record deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { UnavailabilityController } from './unavailability.controller';
import { UnavailabilityService } from './unavailability.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [UnavailabilityController],
providers: [UnavailabilityService],
exports: [UnavailabilityService],
})
export class UnavailabilityModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CreateUnavailabilityDto } from './dto/create-unavailability.dto';
import { UpdateUnavailabilityDto } from './dto/update-unavailability.dto';
import { UnavailabilityFilterDto } from './dto/unavailability-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
const VALID_REASONS = ['PERSONAL', 'MEDICAL', 'RELIGIOUS', 'EMERGENCY', 'OTHER'];
@Injectable()
export class UnavailabilityService {
private readonly logger = new Logger(UnavailabilityService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async create(dto: CreateUnavailabilityDto, currentUser: RequestUser): Promise<any> {
if (!VALID_REASONS.includes(dto.reasonCategory)) {
throw new BadRequestException(`Reason must be one of: ${VALID_REASONS.join(', ')}`);
}
const startDate = new Date(dto.startDate);
const endDate = new Date(dto.endDate);
if (endDate < startDate) {
throw new BadRequestException('End date cannot be before start date');
}
// Determine target user
let targetUserId = currentUser.id;
if (dto.userId && dto.userId !== currentUser.id) {
// Only SA and Admin can log unavailability for others
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can log unavailability for other users');
}
const targetUser = await this.prisma.user.findFirst({
where: { id: dto.userId, deletedAt: null },
});
if (!targetUser) throw new NotFoundException('User not found');
targetUserId = dto.userId;
}
// Check for overlapping unavailability
const overlap = await this.prisma.unavailability.findFirst({
where: {
userId: targetUserId,
startDate: { lte: endDate },
endDate: { gte: startDate },
},
});
if (overlap) {
throw new BadRequestException(
`Overlaps with existing unavailability from ${overlap.startDate.toISOString().split('T')[0]} to ${overlap.endDate.toISOString().split('T')[0]}`,
);
}
const unavailability = await this.prisma.unavailability.create({
data: {
userId: targetUserId,
startDate,
endDate,
reasonCategory: dto.reasonCategory,
notes: dto.notes || null,
createdById: currentUser.id !== targetUserId ? currentUser.id : null,
},
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
});
// Notify PL and Admin if contractor logged their own unavailability
if (targetUserId === currentUser.id && currentUser.role === 'CONTRACTOR') {
const user = await this.prisma.user.findUnique({
where: { id: targetUserId },
select: { firstName: true, lastName: true, assignedProjectLeaderId: true },
});
const notifyIds: string[] = [];
if (user?.assignedProjectLeaderId) {
notifyIds.push(user.assignedProjectLeaderId);
}
const admins = await this.prisma.user.findMany({
where: { role: { in: ['SUPER_ADMIN', 'ADMIN'] }, status: 'ACTIVE', deletedAt: null },
select: { id: true },
});
notifyIds.push(...admins.map((a) => a.id));
const dateRange = startDate.toISOString().split('T')[0] === endDate.toISOString().split('T')[0]
? startDate.toISOString().split('T')[0]
: `${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`;
for (const notifyId of [...new Set(notifyIds)]) {
try {
await this.notificationsService.create({
userId: notifyId,
type: 'INFORMATIONAL',
category: 'SYSTEM',
title: `Unavailability Logged: ${user?.firstName} ${user?.lastName}`,
message: `${user?.firstName} ${user?.lastName} logged unavailability for ${dateRange}. Reason: ${dto.reasonCategory}.`,
entityType: 'unavailability',
entityId: unavailability.id,
});
} catch { /* non-critical */ }
}
}
this.logger.log(
`Unavailability logged: ${targetUserId} from ${dto.startDate} to ${dto.endDate} (${dto.reasonCategory}) by ${currentUser.email}`,
);
return unavailability;
}
async findAll(filter: UnavailabilityFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
// Permission filtering
if (currentUser.role === 'CONTRACTOR') {
where.userId = currentUser.id;
} else if (currentUser.role === 'TEAM_LEAD') {
// PLs see their team's unavailability
where.user = {
OR: [
{ id: currentUser.id },
{ assignedProjectLeaderId: currentUser.id },
],
};
}
if (filter.userId) where.userId = filter.userId;
if (filter.reasonCategory) where.reasonCategory = filter.reasonCategory;
if (filter.dateFrom || filter.dateTo) {
if (filter.dateFrom) {
where.endDate = { ...(where.endDate || {}), gte: new Date(filter.dateFrom) };
}
if (filter.dateTo) {
where.startDate = { ...(where.startDate || {}), lte: new Date(filter.dateTo) };
}
}
const [data, total] = await Promise.all([
this.prisma.unavailability.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { startDate: filter.sortOrder || 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.unavailability.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: filter.sortOrder || 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const unavailability = await this.prisma.unavailability.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!unavailability) throw new NotFoundException('Unavailability record not found');
if (currentUser.role === 'CONTRACTOR' && unavailability.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own unavailability');
}
return unavailability;
}
async update(id: string, dto: UpdateUnavailabilityDto, currentUser: RequestUser): Promise<any> {
const unavailability = await this.prisma.unavailability.findUnique({ where: { id } });
if (!unavailability) throw new NotFoundException('Unavailability record not found');
// Contractors can only edit their own FUTURE unavailability
if (currentUser.role === 'CONTRACTOR') {
if (unavailability.userId !== currentUser.id) {
throw new ForbiddenException('You can only edit your own unavailability');
}
if (unavailability.startDate <= new Date()) {
throw new BadRequestException('Cannot edit past unavailability. Contact your administrator.');
}
}
// SA/Admin can edit any
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN' && currentUser.role !== 'CONTRACTOR') {
throw new ForbiddenException('Insufficient permissions');
}
if (dto.reasonCategory && !VALID_REASONS.includes(dto.reasonCategory)) {
throw new BadRequestException(`Reason must be one of: ${VALID_REASONS.join(', ')}`);
}
const startDate = dto.startDate ? new Date(dto.startDate) : unavailability.startDate;
const endDate = dto.endDate ? new Date(dto.endDate) : unavailability.endDate;
if (endDate < startDate) {
throw new BadRequestException('End date cannot be before start date');
}
// Check overlaps excluding self
const overlap = await this.prisma.unavailability.findFirst({
where: {
id: { not: id },
userId: unavailability.userId,
startDate: { lte: endDate },
endDate: { gte: startDate },
},
});
if (overlap) {
throw new BadRequestException('Updated dates overlap with another unavailability record');
}
const updateData: any = {};
if (dto.startDate !== undefined) updateData.startDate = new Date(dto.startDate);
if (dto.endDate !== undefined) updateData.endDate = new Date(dto.endDate);
if (dto.reasonCategory !== undefined) updateData.reasonCategory = dto.reasonCategory;
if (dto.notes !== undefined) updateData.notes = dto.notes;
return this.prisma.unavailability.update({
where: { id },
data: updateData,
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
});
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
const unavailability = await this.prisma.unavailability.findUnique({ where: { id } });
if (!unavailability) throw new NotFoundException('Unavailability record not found');
if (currentUser.role === 'CONTRACTOR') {
if (unavailability.userId !== currentUser.id) {
throw new ForbiddenException('You can only delete your own unavailability');
}
if (unavailability.startDate <= new Date()) {
throw new BadRequestException('Cannot delete past unavailability');
}
} else if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Insufficient permissions');
}
await this.prisma.unavailability.delete({ where: { id } });
this.logger.log(`Unavailability ${id} deleted by ${currentUser.email}`);
}
async getTeamAvailability(
startDate: string,
endDate: string,
currentUser: RequestUser,
boardId?: string,
): Promise<any[]> {
const start = new Date(startDate);
const end = new Date(endDate);
// Get the relevant users
const userWhere: any = {
status: { in: ['ACTIVE', 'ON_PIP'] },
deletedAt: null,
};
if (currentUser.role === 'TEAM_LEAD') {
userWhere.OR = [
{ assignedProjectLeaderId: currentUser.id },
{ id: currentUser.id },
];
}
if (boardId) {
userWhere.boardMemberships = { some: { boardId } };
}
const users = await this.prisma.user.findMany({
where: userWhere,
select: {
id: true,
firstName: true,
lastName: true,
avatar: true,
weeklySchedule: true,
contractorType: true,
},
});
// Get unavailability for all users in range
const userIds = users.map((u) => u.id);
const unavailabilities = await this.prisma.unavailability.findMany({
where: {
userId: { in: userIds },
startDate: { lte: end },
endDate: { gte: start },
},
});
// Get holidays in range
const holidays = await this.prisma.holiday.findMany({
where: {
startDate: { lte: end },
endDate: { gte: start },
},
});
// Build availability map per user per day
const result = users.map((user) => {
const schedule = (user.weeklySchedule as Record<string, string>) || {};
const userUnavailabilities = unavailabilities.filter((u) => u.userId === user.id);
const days: any[] = [];
const current = new Date(start);
while (current <= end) {
const dayOfWeek = current.getDay();
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
const dayName = dayNames[dayOfWeek];
const scheduleType = schedule[dayName] || 'OFF';
// Check if holiday
const isHoliday = holidays.some(
(h) => current >= h.startDate && current <= h.endDate,
);
// Check if unavailable
const isUnavailable = userUnavailabilities.some(
(u) => current >= u.startDate && current <= u.endDate,
);
let status: string;
if (isHoliday) status = 'HOLIDAY';
else if (isUnavailable) status = 'UNAVAILABLE';
else if (scheduleType === 'OFF') status = 'OFF';
else if (scheduleType === 'REMOTE') status = 'REMOTE';
else if (scheduleType === 'IN_OFFICE') status = 'WORKING';
else status = 'OFF';
days.push({
date: current.toISOString().split('T')[0],
status,
});
current.setDate(current.getDate() + 1);
}
return {
user: {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
avatar: user.avatar,
contractorType: user.contractorType,
},
days,
};
});
return result;
}
async getUserUnavailabilityStats(userId: string, year: number): Promise<any> {
const yearStart = new Date(year, 0, 1);
const yearEnd = new Date(year, 11, 31, 23, 59, 59, 999);
const records = await this.prisma.unavailability.findMany({
where: {
userId,
startDate: { lte: yearEnd },
endDate: { gte: yearStart },
},
});
let totalDays = 0;
const byCategory: Record<string, number> = {};
for (const record of records) {
const start = new Date(Math.max(record.startDate.getTime(), yearStart.getTime()));
const end = new Date(Math.min(record.endDate.getTime(), yearEnd.getTime()));
const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
totalDays += days;
byCategory[record.reasonCategory] = (byCategory[record.reasonCategory] || 0) + days;
}
return {
totalDays,
byCategory,
recordCount: records.length,
};
}
}
\ No newline at end of file
// ─── TIME & SCHEDULING MODELS ──────────────────────────────────
// Phase 2C: Unavailability, Holidays, Schedule Changes, Meetings
model Holiday {
id String @id @default(uuid())
name String
startDate DateTime
endDate DateTime
isRecurring Boolean @default(false)
notes String?
createdById String?
createdBy User? @relation("HolidayCreator", fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([startDate])
@@index([endDate])
}
model Unavailability {
id String @id @default(uuid())
userId String
user User @relation("UserUnavailability", fields: [userId], references: [id], onDelete: Cascade)
startDate DateTime
endDate DateTime
reasonCategory String // PERSONAL, MEDICAL, RELIGIOUS, EMERGENCY, OTHER
notes String?
createdById String? // null means self-logged
createdBy User? @relation("UnavailabilityCreator", fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([startDate])
@@index([endDate])
@@index([userId, startDate, endDate])
}
model ScheduleChangeRequest {
id String @id @default(uuid())
userId String
user User @relation("ScheduleChangeRequests", fields: [userId], references: [id], onDelete: Cascade)
currentSchedule Json // snapshot of current weeklySchedule
proposedSchedule Json // proposed new weeklySchedule
currentBaseSalaryPiasters Int
proposedBaseSalaryPiasters Int
effectiveDate DateTime
reason String // min 50 chars
status String @default("PENDING") // PENDING, APPROVED, REJECTED
reviewedById String?
reviewedBy User? @relation("ScheduleChangeReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
reviewedAt DateTime?
rejectionReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
@@index([userId, status])
}
model Meeting {
id String @id @default(uuid())
title String
description String?
meetingDate DateTime
startTime DateTime
endTime DateTime
location String? // physical location or video link
recurrence String? // NONE, WEEKLY, BIWEEKLY, MONTHLY
relatedEntityType String? // PIP, EVALUATION, CARD
relatedEntityId String?
status String @default("SCHEDULED") // SCHEDULED, COMPLETED, CANCELLED
reminderSent1h Boolean @default(false)
reminderSent24h Boolean @default(false)
createdById String
createdBy User @relation("MeetingCreator", fields: [createdById], references: [id], onDelete: Cascade)
cancelledAt DateTime?
cancelledById String?
cancelledBy User? @relation("MeetingCanceller", fields: [cancelledById], references: [id], onDelete: SetNull)
invitees MeetingInvitee[]
notes MeetingNotes[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([meetingDate])
@@index([createdById])
@@index([status])
@@index([startTime])
}
model MeetingInvitee {
id String @id @default(uuid())
meetingId String
meeting Meeting @relation(fields: [meetingId], references: [id], onDelete: Cascade)
userId String
user User @relation("MeetingInvitations", fields: [userId], references: [id], onDelete: Cascade)
attended Boolean? // null = not yet recorded, true = attended, false = absent
createdAt DateTime @default(now())
@@unique([meetingId, userId])
@@index([userId])
}
model MeetingNotes {
id String @id @default(uuid())
meetingId String
meeting Meeting @relation(fields: [meetingId], references: [id], onDelete: Cascade)
authorId String
author User @relation("MeetingNotesAuthor", fields: [authorId], references: [id], onDelete: Cascade)
attendees Json? // array of user IDs who actually attended
summary String
actionItems Json? // array of { description, assigneeId? }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([meetingId])
}
\ No newline at end of file
export enum UnavailabilityReason {
PERSONAL = 'PERSONAL',
MEDICAL = 'MEDICAL',
RELIGIOUS = 'RELIGIOUS',
EMERGENCY = 'EMERGENCY',
OTHER = 'OTHER',
}
export enum ScheduleChangeStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
export enum MeetingStatus {
SCHEDULED = 'SCHEDULED',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
export enum MeetingRecurrence {
NONE = 'NONE',
WEEKLY = 'WEEKLY',
BIWEEKLY = 'BIWEEKLY',
MONTHLY = 'MONTHLY',
}
export enum DayType {
IN_OFFICE = 'IN_OFFICE',
REMOTE = 'REMOTE',
OFF = 'OFF',
}
\ 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