Merge branch 'dev/matthieu/error-handling' of git.targo.ca:Targo/targo_backend

This commit is contained in:
Matthieu Haineault 2025-11-12 09:21:42 -05:00
commit d88f8727ad
34 changed files with 1200 additions and 1325 deletions

0
npm
View File

View File

@ -0,0 +1,12 @@
export type Result<T, E> =
| { success: true; data: T }
| { success: false; error: E };
// const success = <T>(data: T): Result<T, never> => {
// return { success: true, data };
// }
// const failure = <E>(error: E): Result<never, E> => {
// return { success: false, error };
// }

View File

@ -1,8 +1,9 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { computeHours, getWeekStart } from "src/common/utils/date-utils"; import { computeHours, getWeekStart } from "src/common/utils/date-utils";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { MS_PER_WEEK } from "src/time-and-attendance/utils/constants.utils"; import { MS_PER_WEEK } from "src/time-and-attendance/utils/constants.utils";
import { Result } from "src/common/errors/result-error.factory";
/* /*
le calcul est 1/20 des 4 dernières semaines, précédent la semaine incluant le férier. le calcul est 1/20 des 4 dernières semaines, précédent la semaine incluant le férier.
Un maximum de 08h00 est allouable pour le férier Un maximum de 08h00 est allouable pour le férier
@ -12,19 +13,19 @@ import { MS_PER_WEEK } from "src/time-and-attendance/utils/constants.utils";
@Injectable() @Injectable()
export class HolidayService { export class HolidayService {
private readonly logger = new Logger(HolidayService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver, private readonly emailResolver: EmailToIdResolver,
) { } ) { }
private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise<number> { private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise<Result<number, string>> {
const employee_id = await this.emailResolver.findIdByEmail(email); const employee_id = await this.emailResolver.findIdByEmail(email);
return this.computeHoursPrevious4Weeks(employee_id, holiday_date); if (!employee_id.success) return { success: false, error: employee_id.error };
return this.computeHoursPrevious4Weeks(employee_id.data, holiday_date);
} }
private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<number> { private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<Result<number, string>> {
try {
const holiday_week_start = getWeekStart(holiday_date); const holiday_week_start = getWeekStart(holiday_date);
const window_start = new Date(holiday_week_start.getTime() - 4 * MS_PER_WEEK); const window_start = new Date(holiday_week_start.getTime() - 4 * MS_PER_WEEK);
const window_end = new Date(holiday_week_start.getTime() - 1); const window_end = new Date(holiday_week_start.getTime() - 1);
@ -57,13 +58,17 @@ export class HolidayService {
} }
const average_daily_hours = capped_total / 20; const average_daily_hours = capped_total / 20;
return average_daily_hours; return { success: true, data: average_daily_hours };
} catch (error) {
return { success: false, error: `an error occureded during holiday calculation` }
}
} }
async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise<number> { async calculateHolidayPay(email: string, holiday_date: Date, modifier: number): Promise<Result<number, string>> {
const average_daily_hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date); const average_daily_hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date);
const daily_rate = Math.min(average_daily_hours, 8); if (!average_daily_hours.success) return { success: false, error: average_daily_hours.error };
this.logger.debug(`Holiday pay calculation: cappedHoursPerDay= ${average_daily_hours.toFixed(2)}, appliedDailyRate= ${daily_rate.toFixed(2)}`);
return daily_rate * modifier; const daily_rate = (Math.min(average_daily_hours.data, 8)) * modifier;
return { success: true, data: daily_rate };
} }
} }

View File

@ -1,10 +1,28 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils'; import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { DAILY_LIMIT_HOURS, WEEKLY_LIMIT_HOURS } from 'src/time-and-attendance/utils/constants.utils'; import { DAILY_LIMIT_HOURS, WEEKLY_LIMIT_HOURS } from 'src/time-and-attendance/utils/constants.utils';
import { Tx, WeekOvertimeSummary } from 'src/time-and-attendance/utils/type.utils';
type Tx = Prisma.TransactionClient | PrismaClient;
type WeekOvertimeSummary = {
week_start:string;
week_end: string;
week_total_hours: number;
weekly_overtime: number;
daily_overtime_kept: number;
total_overtime: number;
breakdown: Array<{
date:string;
day_hours: number;
day_overtime: number;
daily_kept: number;
running_total_before: number;
}>;
};
@Injectable() @Injectable()
export class OvertimeService { export class OvertimeService {

View File

@ -1,9 +1,10 @@
import { Controller, Post, Param, Body, Patch, Delete, Req, UnauthorizedException } from "@nestjs/common"; import { Controller, Post, Param, Body, Patch, Delete, Req, UnauthorizedException } from "@nestjs/common";
import { CreateExpenseResult } from "src/time-and-attendance/utils/type.utils";
import { ExpenseUpsertService } from "src/time-and-attendance/expenses/services/expense-upsert.service"; import { ExpenseUpsertService } from "src/time-and-attendance/expenses/services/expense-upsert.service";
import { ExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-create.dto"; import { ExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-create.dto";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { GLOBAL_CONTROLLER_ROLES } from "src/common/shared/role-groupes"; import { GLOBAL_CONTROLLER_ROLES } from "src/common/shared/role-groupes";
import { Result } from "src/common/errors/result-error.factory";
import { GetExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-get.dto";
@Controller('expense') @Controller('expense')
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES) @RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
@ -11,19 +12,19 @@ export class ExpenseController {
constructor(private readonly upsert_service: ExpenseUpsertService) { } constructor(private readonly upsert_service: ExpenseUpsertService) { }
@Post('create') @Post('create')
create( @Req() req, @Body() dto: ExpenseDto): Promise<CreateExpenseResult>{ create(@Req() req, @Body() dto: ExpenseDto): Promise<Result<GetExpenseDto, string>> {
const email = req.user?.email; const email = req.user?.email;
if (!email) throw new UnauthorizedException('Unauthorized User'); if (!email) throw new UnauthorizedException('Unauthorized User');
return this.upsert_service.createExpense(dto, email); return this.upsert_service.createExpense(dto, email);
} }
@Patch('update') @Patch('update')
update(@Body() dto: ExpenseDto): Promise<ExpenseDto>{ update(@Body() dto: ExpenseDto): Promise<Result<ExpenseDto, string>> {
return this.upsert_service.updateExpense(dto); return this.upsert_service.updateExpense(dto);
} }
@Delete('delete/:expense_id') @Delete('delete/:expense_id')
remove(@Param('expense_id') expense_id: number) { remove(@Param('expense_id') expense_id: number): Promise<Result<number, string>> {
return this.upsert_service.deleteExpense(expense_id); return this.upsert_service.deleteExpense(expense_id);
} }
} }

View File

@ -1,12 +1,14 @@
import { CreateExpenseResult, DeleteExpenseResult, NormalizedExpense } from "src/time-and-attendance/utils/type.utils";
import { toDateFromString, toStringFromDate, weekStartSunday } from "src/time-and-attendance/utils/date-time.utils"; import { toDateFromString, toStringFromDate, weekStartSunday } from "src/time-and-attendance/utils/date-time.utils";
import { Injectable, NotFoundException } from "@nestjs/common"; import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
// import { NormalizedExpense } from "src/time-and-attendance/utils/type.utils";
import { expense_select } from "src/time-and-attendance/utils/selects.utils"; import { expense_select } from "src/time-and-attendance/utils/selects.utils";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-create.dto";
import { GetExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-get.dto"; import { GetExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-get.dto";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { ExpenseEntity } from "src/time-and-attendance/expenses/dtos/expense-entity.dto"; import { ExpenseEntity } from "src/time-and-attendance/expenses/dtos/expense-entity.dto";
import { ExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-create.dto";
import { Injectable } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { NormalizedExpense } from "src/time-and-attendance/utils/type.utils";
@Injectable() @Injectable()
@ -19,10 +21,11 @@ export class ExpenseUpsertService {
//_________________________________________________________________ //_________________________________________________________________
// CREATE // CREATE
//_________________________________________________________________ //_________________________________________________________________
async createExpense(dto: ExpenseDto, email: string): Promise<CreateExpenseResult> { async createExpense(dto: ExpenseDto, email: string): Promise<Result<GetExpenseDto, string>> {
try { try {
//fetch employee_id using req.user.email //fetch employee_id using req.user.email
const employee_id = await this.emailResolver.findIdByEmail(email); const employee_id = await this.emailResolver.findIdByEmail(email);
if (!employee_id.success) return { success: false, error: employee_id.error };
//normalize strings and dates and Parse numbers //normalize strings and dates and Parse numbers
const normed_expense = this.normalizeAndParseExpenseDto(dto); const normed_expense = this.normalizeAndParseExpenseDto(dto);
@ -30,10 +33,10 @@ export class ExpenseUpsertService {
//finds the timesheet using expense.date by finding the sunday //finds the timesheet using expense.date by finding the sunday
const start_date = weekStartSunday(normed_expense.date); const start_date = weekStartSunday(normed_expense.date);
const timesheet = await this.prisma.timesheets.findFirst({ const timesheet = await this.prisma.timesheets.findFirst({
where: { start_date, employee_id }, where: { start_date, employee_id: employee_id.data },
select: { id: true, employee_id: true }, select: { id: true, employee_id: true },
}); });
if (!timesheet) throw new NotFoundException(`Timesheet with id ${dto.timesheet_id} not found`); if (!timesheet) return { success: false, error: `Timesheet with id : ${dto.timesheet_id} not found` };
//create a new expense //create a new expense
const expense = await this.prisma.expenses.create({ const expense = await this.prisma.expenses.create({
@ -46,6 +49,7 @@ export class ExpenseUpsertService {
//return the newly created expense with id //return the newly created expense with id
select: expense_select, select: expense_select,
}); });
if (!expense) return { success: false, error: `An error occured during creation. Expense is invalid` };
//build an object to return to the frontend to display //build an object to return to the frontend to display
const created: GetExpenseDto = { const created: GetExpenseDto = {
@ -56,17 +60,17 @@ export class ExpenseUpsertService {
attachment: expense.attachment ?? undefined, attachment: expense.attachment ?? undefined,
supervisor_comment: expense.supervisor_comment ?? undefined, supervisor_comment: expense.supervisor_comment ?? undefined,
}; };
return { ok: true, data: created } return { success: true, data: created };
} catch (error) { } catch (error) {
return { ok: false, error: error } return { success: false, error: `An error occured during creation. Expense not created : ` + error };
} }
} }
//_________________________________________________________________ //_________________________________________________________________
// UPDATE // UPDATE
//_________________________________________________________________ //_________________________________________________________________
async updateExpense(dto: ExpenseDto): Promise<ExpenseDto> { async updateExpense(dto: ExpenseDto): Promise<Result<ExpenseDto, string>> {
try { try {
//normalize string , date format and parse numbers //normalize string , date format and parse numbers
const normed_expense = this.normalizeAndParseExpenseDto(dto); const normed_expense = this.normalizeAndParseExpenseDto(dto);
@ -79,6 +83,7 @@ export class ExpenseUpsertService {
bank_code_id: dto.bank_code_id, bank_code_id: dto.bank_code_id,
is_approved: dto.is_approved, is_approved: dto.is_approved,
}; };
if (!data) return { success: false, error: `An error occured during normalization. Expense with id: ${dto.id} is invalid` }
//push updates and get updated datas //push updates and get updated datas
const expense = await this.prisma.expenses.update({ const expense = await this.prisma.expenses.update({
@ -86,6 +91,7 @@ export class ExpenseUpsertService {
data, data,
select: expense_select, select: expense_select,
}); });
if (!expense) return { success: false, error: `An error occured during update. Expense with id: ${data.id} was not updated` }
//build an object to return to the frontend //build an object to return to the frontend
const updated: GetExpenseDto = { const updated: GetExpenseDto = {
@ -96,29 +102,29 @@ export class ExpenseUpsertService {
attachment: expense.attachment ?? undefined, attachment: expense.attachment ?? undefined,
supervisor_comment: expense.supervisor_comment ?? undefined, supervisor_comment: expense.supervisor_comment ?? undefined,
}; };
return updated; return { success: true, data: updated };
} catch (error) { } catch (error) {
return error; return { success: false, error: (`Expense with id: ${dto.id} generated an error:` + error) };
} }
} }
//_________________________________________________________________ //_________________________________________________________________
// DELETE // DELETE
//_________________________________________________________________ //_________________________________________________________________
async deleteExpense(expense_id: number): Promise<DeleteExpenseResult> { async deleteExpense(expense_id: number): Promise<Result<number, string>> {
try { try {
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
const expense = await tx.expenses.findUnique({ const expense = await tx.expenses.findUnique({
where: { id: expense_id }, where: { id: expense_id },
select: { id: true }, select: { id: true },
}); });
if (!expense) throw new NotFoundException(`Expense with id: ${expense_id} not found`); if (!expense) return { success: false, error: `An error occured during removal. Expense with id :${expense_id} was not found ` };
await tx.expenses.delete({ where: { id: expense_id } }); await tx.expenses.delete({ where: { id: expense_id } });
return { success: true }; return { success: true };
}); });
return { ok: true, id: expense_id }; return { success: true, data: expense_id };
} catch (error) { } catch (error) {
return { ok: false, id: expense_id, error }; return { success: false, error: `An error occured during removal. Expense with id :${expense_id} generated an error: ` + error };
} }
} }

View File

@ -1,20 +1,20 @@
import { Body, Controller, Post } from "@nestjs/common"; // import { Body, Controller, Post } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; // import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto"; // import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestsService } from "../services/leave-request.service"; // import { LeaveRequestsService } from "../services/leave-request.service";
@ApiTags('Leave Requests') // @ApiTags('Leave Requests')
@ApiBearerAuth('access-token') // @ApiBearerAuth('access-token')
// @UseGuards() // // @UseGuards()
@Controller('leave-requests') // @Controller('leave-requests')
export class LeaveRequestController { // export class LeaveRequestController {
constructor(private readonly leave_service: LeaveRequestsService){} // constructor(private readonly leave_service: LeaveRequestsService){}
@Post('upsert') // @Post('upsert')
async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) { // async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
const { action, leave_requests } = await this.leave_service.handle(dto); // const { action, leave_requests } = await this.leave_service.handle(dto);
return { action, leave_requests }; // return { action, leave_requests };
} // }
} // }

View File

@ -1,16 +1,16 @@
import { LeaveRequestController } from "src/time-and-attendance/leave-requests/controllers/leave-requests.controller"; // import { LeaveRequestController } from "src/time-and-attendance/leave-requests/controllers/leave-requests.controller";
import { LeaveRequestsService } from "src/time-and-attendance/leave-requests/services/leave-request.service"; // import { LeaveRequestsService } from "src/time-and-attendance/leave-requests/services/leave-request.service";
import { BusinessLogicsModule } from "src/time-and-attendance/domains/business-logics.module"; // import { BusinessLogicsModule } from "src/time-and-attendance/domains/business-logics.module";
import { ShiftsModule } from "src/time-and-attendance/time-tracker/shifts/shifts.module"; // import { ShiftsModule } from "src/time-and-attendance/time-tracker/shifts/shifts.module";
import { Module } from "@nestjs/common"; // import { Module } from "@nestjs/common";
@Module({ // @Module({
imports: [ // imports: [
BusinessLogicsModule, // BusinessLogicsModule,
ShiftsModule, // ShiftsModule,
], // ],
controllers: [LeaveRequestController], // controllers: [LeaveRequestController],
providers: [LeaveRequestsService], // providers: [LeaveRequestsService],
}) // })
export class LeaveRequestsModule {} // export class LeaveRequestsModule {}

View File

@ -1,6 +1,8 @@
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { LeaveRequestRow } from "src/time-and-attendance/utils/type.utils";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils";
type LeaveRequestRow = Prisma.LeaveRequestsGetPayload<{ select: typeof leaveRequestsSelect}>;
const toNum = (value?: Prisma.Decimal | null) => const toNum = (value?: Prisma.Decimal | null) =>
value !== null && value !== undefined ? Number(value) : undefined; value !== null && value !== undefined ? Number(value) : undefined;

View File

@ -1,79 +1,84 @@
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common"; // import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { UpsertLeaveRequestDto, UpsertResult } from "src/time-and-attendance/leave-requests/dtos/upsert-leave-request.dto"; // import { UpsertLeaveRequestDto, UpsertResult } from "src/time-and-attendance/leave-requests/dtos/upsert-leave-request.dto";
import { LeaveTypes, LeaveApprovalStatus } from "@prisma/client"; // import { LeaveTypes, LeaveApprovalStatus } from "@prisma/client";
import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils"; // import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils";
import { LeaveRequestViewDto } from "src/time-and-attendance/leave-requests/dtos/leave-request-view.dto"; // import { LeaveRequestViewDto } from "src/time-and-attendance/leave-requests/dtos/leave-request-view.dto";
import { LeaveRequestsUtils } from "src/time-and-attendance/leave-requests/utils/leave-request.util"; // import { LeaveRequestsUtils } from "src/time-and-attendance/leave-requests/utils/leave-request.util";
import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils"; // import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; // import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { HolidayService } from "src/time-and-attendance/domains/services/holiday.service"; // import { HolidayService } from "src/time-and-attendance/domains/services/holiday.service";
import { PrismaService } from "src/prisma/prisma.service"; // import { PrismaService } from "src/prisma/prisma.service";
import { mapRowToView } from "src/time-and-attendance/leave-requests/mappers/leave-requests.mapper"; // import { mapRowToView } from "src/time-and-attendance/leave-requests/mappers/leave-requests.mapper";
import { normalizeDates, toDateOnly } from "src/time-and-attendance/utils/date-time.utils"; // import { normalizeDates, toDateOnly } from "src/time-and-attendance/utils/date-time.utils";
// import { Result } from "src/common/errors/result-error.factory";
@Injectable() // @Injectable()
export class HolidayLeaveRequestsService { // export class HolidayLeaveRequestsService {
constructor( // constructor(
private readonly prisma: PrismaService, // private readonly prisma: PrismaService,
private readonly holidayService: HolidayService, // private readonly holidayService: HolidayService,
private readonly leaveUtils: LeaveRequestsUtils, // private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver, // private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver, // private readonly typeResolver: BankCodesResolver,
) {} // ) { }
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> { // async create(dto: UpsertLeaveRequestDto): Promise<Result<UpsertResult, string>> {
const email = dto.email.trim(); // const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email); // const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findIdAndModifierByType(LeaveTypes.HOLIDAY); // if (!employee_id.success) return { success: false, error: employee_id.error }
const dates = normalizeDates(dto.dates);
if (!bank_code) throw new NotFoundException(`bank_code not found`);
if (!dates.length) throw new BadRequestException('Dates array must not be empty');
const created: LeaveRequestViewDto[] = []; // const bank_code = await this.typeResolver.findIdAndModifierByType(LeaveTypes.HOLIDAY);
// const dates = normalizeDates(dto.dates);
// if (!bank_code) throw new NotFoundException(`bank_code not found`);
// if (!dates.length) throw new BadRequestException('Dates array must not be empty');
for (const iso_date of dates) { // const created: LeaveRequestViewDto[] = [];
const date = toDateOnly(iso_date);
const existing = await this.prisma.leaveRequests.findUnique({ // for (const iso_date of dates) {
where: { // const date = toDateOnly(iso_date);
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.HOLIDAY,
date,
},
},
select: { id: true },
});
if (existing) {
throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
}
const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier); // const existing = await this.prisma.leaveRequests.findUnique({
const row = await this.prisma.leaveRequests.create({ // where: {
data: { // leave_per_employee_date: {
employee_id: employee_id, // employee_id: employee_id.data,
bank_code_id: bank_code.id, // leave_type: LeaveTypes.HOLIDAY,
leave_type: LeaveTypes.HOLIDAY, // date,
date, // },
comment: dto.comment ?? '', // },
requested_hours: dto.requested_hours ?? 8, // select: { id: true },
payable_hours: payable, // });
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, // if (existing) {
}, // throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
select: leaveRequestsSelect, // }
});
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); // const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier);
if (row.approval_status === LeaveApprovalStatus.APPROVED) { // if (!payable) return { success: false, error: `An error occured during calculation` };
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment);
}
created.push({ ...mapRowToView(row), action: 'create' }); // const row = await this.prisma.leaveRequests.create({
} // data: {
// employee_id: employee_id.data,
// bank_code_id: bank_code.id,
// leave_type: LeaveTypes.HOLIDAY,
// date,
// comment: dto.comment ?? '',
// requested_hours: dto.requested_hours ?? 8,
// payable_hours: payable,
// approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
// },
// select: leaveRequestsSelect,
// });
return { action: 'create', leave_requests: created }; // const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
} // if (row.approval_status === LeaveApprovalStatus.APPROVED) {
} // await this.leaveUtils.syncShift(email, employee_id.data, iso_date, hours, LeaveTypes.HOLIDAY, row.comment);
// }
// created.push({ ...mapRowToView(row), action: 'create' });
// }
// return { success: true, data: { action: 'create', leave_requests: created } };
// }
// }

View File

@ -1,241 +1,241 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; // import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; // import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; // import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils"; // import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; // import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { roundToQuarterHour } from "src/common/utils/date-utils"; // import { roundToQuarterHour } from "src/common/utils/date-utils";
import { LeaveRequestsUtils } from "src/time-and-attendance/leave-requests/utils/leave-request.util"; // import { LeaveRequestsUtils } from "src/time-and-attendance/leave-requests/utils/leave-request.util";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; // import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils"; // import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils";
import { SickLeaveService } from "src/time-and-attendance/domains/services/sick-leave.service"; // import { SickLeaveService } from "src/time-and-attendance/domains/services/sick-leave.service";
import { VacationService } from "src/time-and-attendance/domains/services/vacation.service"; // import { VacationService } from "src/time-and-attendance/domains/services/vacation.service";
import { HolidayService } from "src/time-and-attendance/domains/services/holiday.service"; // import { HolidayService } from "src/time-and-attendance/domains/services/holiday.service";
import { PrismaService } from "src/prisma/prisma.service"; // import { PrismaService } from "src/prisma/prisma.service";
import { mapRowToView } from "../mappers/leave-requests.mapper"; // import { mapRowToView } from "../mappers/leave-requests.mapper";
import { normalizeDates, toDateOnly, toISODateKey } from "src/time-and-attendance/utils/date-time.utils"; // import { normalizeDates, toDateOnly, toISODateKey } from "src/time-and-attendance/utils/date-time.utils";
@Injectable() // @Injectable()
export class LeaveRequestsService { // export class LeaveRequestsService {
constructor( // constructor(
private readonly prisma: PrismaService, // private readonly prisma: PrismaService,
private readonly holidayService: HolidayService, // private readonly holidayService: HolidayService,
private readonly sickLogic: SickLeaveService, // private readonly sickLogic: SickLeaveService,
private readonly vacationLogic: VacationService, // private readonly vacationLogic: VacationService,
private readonly leaveUtils: LeaveRequestsUtils, // private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver, // private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver, // private readonly typeResolver: BankCodesResolver,
) {} // ) {}
// handle distribution to the right service according to the selected type and action // // handle distribution to the right service according to the selected type and action
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> { // async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
switch (dto.type) { // switch (dto.type) {
case LeaveTypes.HOLIDAY: // case LeaveTypes.HOLIDAY:
if( dto.action === 'create'){ // if( dto.action === 'create'){
// return this.holidayService.create(dto); // // return this.holidayService.create(dto);
} else if (dto.action === 'update') { // } else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.HOLIDAY); // return this.update(dto, LeaveTypes.HOLIDAY);
} else if (dto.action === 'delete'){ // } else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.HOLIDAY); // return this.delete(dto, LeaveTypes.HOLIDAY);
} // }
case LeaveTypes.VACATION: // case LeaveTypes.VACATION:
if( dto.action === 'create'){ // if( dto.action === 'create'){
// return this.vacationService.create(dto); // // return this.vacationService.create(dto);
} else if (dto.action === 'update') { // } else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.VACATION); // return this.update(dto, LeaveTypes.VACATION);
} else if (dto.action === 'delete'){ // } else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.VACATION); // return this.delete(dto, LeaveTypes.VACATION);
} // }
case LeaveTypes.SICK: // case LeaveTypes.SICK:
if( dto.action === 'create'){ // if( dto.action === 'create'){
// return this.sickLeaveService.create(dto); // // return this.sickLeaveService.create(dto);
} else if (dto.action === 'update') { // } else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.SICK); // return this.update(dto, LeaveTypes.SICK);
} else if (dto.action === 'delete'){ // } else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.SICK); // return this.delete(dto, LeaveTypes.SICK);
} // }
default: // default:
throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`); // throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`);
} // }
} // }
async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> { // async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
const email = dto.email.trim(); // const email = dto.email.trim();
const dates = normalizeDates(dto.dates); // const dates = normalizeDates(dto.dates);
const employee_id = await this.emailResolver.findIdByEmail(email); // const employee_id = await this.emailResolver.findIdByEmail(email);
if (!dates.length) throw new BadRequestException("Dates array must not be empty"); // if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const rows = await this.prisma.leaveRequests.findMany({ // const rows = await this.prisma.leaveRequests.findMany({
where: { // where: {
employee_id: employee_id, // employee_id: employee_id,
leave_type: type, // leave_type: type,
date: { in: dates.map((d) => toDateOnly(d)) }, // date: { in: dates.map((d) => toDateOnly(d)) },
}, // },
select: leaveRequestsSelect, // select: leaveRequestsSelect,
}); // });
if (rows.length !== dates.length) { // if (rows.length !== dates.length) {
const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate)); // const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`); // throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`);
} // }
for (const row of rows) { // for (const row of rows) {
if (row.approval_status === LeaveApprovalStatus.APPROVED) { // if (row.approval_status === LeaveApprovalStatus.APPROVED) {
const iso = toISODateKey(row.date); // const iso = toISODateKey(row.date);
await this.leaveUtils.removeShift(email, employee_id, iso, type); // await this.leaveUtils.removeShift(email, employee_id, iso, type);
} // }
} // }
await this.prisma.leaveRequests.deleteMany({ // await this.prisma.leaveRequests.deleteMany({
where: { id: { in: rows.map((row) => row.id) } }, // where: { id: { in: rows.map((row) => row.id) } },
}); // });
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const })); // const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const }));
return { action: "delete", leave_requests: deleted }; // return { action: "delete", leave_requests: deleted };
} // }
async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> { // async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
const email = dto.email.trim(); // const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email); // const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findIdAndModifierByType(type); // const bank_code = await this.typeResolver.findIdAndModifierByType(type);
if(!bank_code) throw new NotFoundException(`bank_code not found`); // if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = Number(bank_code.modifier ?? 1); // const modifier = Number(bank_code.modifier ?? 1);
const dates = normalizeDates(dto.dates); // const dates = normalizeDates(dto.dates);
if (!dates.length) { // if (!dates.length) {
throw new BadRequestException("Dates array must not be empty"); // throw new BadRequestException("Dates array must not be empty");
} // }
const entries = await Promise.all( // const entries = await Promise.all(
dates.map(async (iso_date) => { // dates.map(async (iso_date) => {
const date = toDateOnly(iso_date); // const date = toDateOnly(iso_date);
const existing = await this.prisma.leaveRequests.findUnique({ // const existing = await this.prisma.leaveRequests.findUnique({
where: { // where: {
leave_per_employee_date: { // leave_per_employee_date: {
employee_id: employee_id, // employee_id: employee_id,
leave_type: type, // leave_type: type,
date, // date,
}, // },
}, // },
select: leaveRequestsSelect, // select: leaveRequestsSelect,
}); // });
if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`); // if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`);
return { iso_date, date, existing }; // return { iso_date, date, existing };
}), // }),
); // );
const updated: LeaveRequestViewDto[] = []; // const updated: LeaveRequestViewDto[] = [];
if (type === LeaveTypes.SICK) { // if (type === LeaveTypes.SICK) {
const firstExisting = entries[0].existing; // const firstExisting = entries[0].existing;
const fallbackRequested = // const fallbackRequested =
firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined // firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined
? Number(firstExisting.requested_hours) // ? Number(firstExisting.requested_hours)
: 8; // : 8;
const requested_hours_per_day = dto.requested_hours ?? fallbackRequested; // const requested_hours_per_day = dto.requested_hours ?? fallbackRequested;
const reference_date = entries.reduce( // const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest), // (latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date, // entries[0].date,
); // );
const total_payable_hours = await this.sickLogic.calculateSickLeavePay( // const total_payable_hours = await this.sickLogic.calculateSickLeavePay(
employee_id, // employee_id,
reference_date, // reference_date,
entries.length, // entries.length,
requested_hours_per_day, // requested_hours_per_day,
modifier, // modifier,
); // );
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); // let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); // const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
for (const { iso_date, existing } of entries) { // for (const { iso_date, existing } of entries) {
const previous_status = existing.approval_status; // const previous_status = existing.approval_status;
const payable = Math.min(remaining_payable_hours, daily_payable_cap); // const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable)); // const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour( // remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded), // Math.max(0, remaining_payable_hours - payable_rounded),
); // );
const row = await this.prisma.leaveRequests.update({ // const row = await this.prisma.leaveRequests.update({
where: { id: existing.id }, // where: { id: existing.id },
data: { // data: {
comment: dto.comment ?? existing.comment, // comment: dto.comment ?? existing.comment,
requested_hours: requested_hours_per_day, // requested_hours: requested_hours_per_day,
payable_hours: payable_rounded, // payable_hours: payable_rounded,
bank_code_id: bank_code.id, // bank_code_id: bank_code.id,
approval_status: dto.approval_status ?? existing.approval_status, // approval_status: dto.approval_status ?? existing.approval_status,
}, // },
select: leaveRequestsSelect, // select: leaveRequestsSelect,
}); // });
const was_approved = previous_status === LeaveApprovalStatus.APPROVED; // const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; // const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); // const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!was_approved && is_approved) { // if (!was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); // await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} else if (was_approved && !is_approved) { // } else if (was_approved && !is_approved) {
await this.leaveUtils.removeShift(email, employee_id, iso_date, type); // await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
} else if (was_approved && is_approved) { // } else if (was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); // await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} // }
updated.push({ ...mapRowToView(row), action: "update" }); // updated.push({ ...mapRowToView(row), action: "update" });
} // }
return { action: "update", leave_requests: updated }; // return { action: "update", leave_requests: updated };
} // }
for (const { iso_date, date, existing } of entries) { // for (const { iso_date, date, existing } of entries) {
const previous_status = existing.approval_status; // const previous_status = existing.approval_status;
const fallbackRequested = // const fallbackRequested =
existing.requested_hours !== null && existing.requested_hours !== undefined // existing.requested_hours !== null && existing.requested_hours !== undefined
? Number(existing.requested_hours) // ? Number(existing.requested_hours)
: 8; // : 8;
const requested_hours = dto.requested_hours ?? fallbackRequested; // const requested_hours = dto.requested_hours ?? fallbackRequested;
let payable: number; // let payable: number;
switch (type) { // switch (type) {
case LeaveTypes.HOLIDAY: // case LeaveTypes.HOLIDAY:
payable = await this.holidayService.calculateHolidayPay(email, date, modifier); // payable = await this.holidayService.calculateHolidayPay(email, date, modifier);
break; // break;
case LeaveTypes.VACATION: { // case LeaveTypes.VACATION: {
const days_requested = requested_hours / 8; // const days_requested = requested_hours / 8;
payable = await this.vacationLogic.calculateVacationPay( // payable = await this.vacationLogic.calculateVacationPay(
employee_id, // employee_id,
date, // date,
Math.max(0, days_requested), // Math.max(0, days_requested),
modifier, // modifier,
); // );
break; // break;
} // }
default: // default:
payable = existing.payable_hours !== null && existing.payable_hours !== undefined // payable = existing.payable_hours !== null && existing.payable_hours !== undefined
? Number(existing.payable_hours) // ? Number(existing.payable_hours)
: requested_hours; // : requested_hours;
} // }
const row = await this.prisma.leaveRequests.update({ // const row = await this.prisma.leaveRequests.update({
where: { id: existing.id }, // where: { id: existing.id },
data: { // data: {
requested_hours, // requested_hours,
comment: dto.comment ?? existing.comment, // comment: dto.comment ?? existing.comment,
payable_hours: payable, // payable_hours: payable,
bank_code_id: bank_code.id, // bank_code_id: bank_code.id,
approval_status: dto.approval_status ?? existing.approval_status, // approval_status: dto.approval_status ?? existing.approval_status,
}, // },
select: leaveRequestsSelect, // select: leaveRequestsSelect,
}); // });
const was_approved = previous_status === LeaveApprovalStatus.APPROVED; // const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; // const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); // const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!was_approved && is_approved) { // if (!was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); // await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} else if (was_approved && !is_approved) { // } else if (was_approved && !is_approved) {
await this.leaveUtils.removeShift(email, employee_id, iso_date, type); // await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
} else if (was_approved && is_approved) { // } else if (was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); // await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} // }
updated.push({ ...mapRowToView(row), action: "update" }); // updated.push({ ...mapRowToView(row), action: "update" });
} // }
return { action: "update", leave_requests: updated }; // return { action: "update", leave_requests: updated };
} // }
} // }

View File

@ -1,98 +1,98 @@
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common"; // import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { UpsertLeaveRequestDto, UpsertResult } from "src/time-and-attendance/leave-requests/dtos/upsert-leave-request.dto"; // import { UpsertLeaveRequestDto, UpsertResult } from "src/time-and-attendance/leave-requests/dtos/upsert-leave-request.dto";
import { LeaveTypes, LeaveApprovalStatus } from "@prisma/client"; // import { LeaveTypes, LeaveApprovalStatus } from "@prisma/client";
import { LeaveRequestViewDto } from "src/time-and-attendance/leave-requests/dtos/leave-request-view.dto"; // import { LeaveRequestViewDto } from "src/time-and-attendance/leave-requests/dtos/leave-request-view.dto";
import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils"; // import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils";
import { roundToQuarterHour } from "src/common/utils/date-utils"; // import { roundToQuarterHour } from "src/common/utils/date-utils";
import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils"; // import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; // import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { SickLeaveService } from "src/time-and-attendance/domains/services/sick-leave.service"; // import { SickLeaveService } from "src/time-and-attendance/domains/services/sick-leave.service";
import { PrismaService } from "src/prisma/prisma.service"; // import { PrismaService } from "src/prisma/prisma.service";
import { mapRowToView } from "src/time-and-attendance/leave-requests/mappers/leave-requests.mapper"; // import { mapRowToView } from "src/time-and-attendance/leave-requests/mappers/leave-requests.mapper";
import { normalizeDates, toDateOnly } from "src/time-and-attendance/utils/date-time.utils"; // import { normalizeDates, toDateOnly } from "src/time-and-attendance/utils/date-time.utils";
@Injectable() // @Injectable()
export class SickLeaveRequestsService { // export class SickLeaveRequestsService {
constructor( // constructor(
private readonly prisma: PrismaService, // private readonly prisma: PrismaService,
private readonly sickService: SickLeaveService, // private readonly sickService: SickLeaveService,
private readonly emailResolver: EmailToIdResolver, // private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver, // private readonly typeResolver: BankCodesResolver,
) {} // ) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> { // async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim(); // const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email); // const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findIdAndModifierByType(LeaveTypes.SICK); // const bank_code = await this.typeResolver.findIdAndModifierByType(LeaveTypes.SICK);
if(!bank_code) throw new NotFoundException(`bank_code not found`); // if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = bank_code.modifier ?? 1; // const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates); // const dates = normalizeDates(dto.dates);
if (!dates.length) throw new BadRequestException("Dates array must not be empty"); // if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const requested_hours_per_day = dto.requested_hours ?? 8; // const requested_hours_per_day = dto.requested_hours ?? 8;
const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) })); // const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
const reference_date = entries.reduce( // const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest), // (latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date, // entries[0].date,
); // );
const total_payable_hours = await this.sickService.calculateSickLeavePay( // const total_payable_hours = await this.sickService.calculateSickLeavePay(
employee_id, // employee_id,
reference_date, // reference_date,
entries.length, // entries.length,
requested_hours_per_day, // requested_hours_per_day,
modifier, // modifier,
); // );
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); // let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); // const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = []; // const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) { // for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({ // const existing = await this.prisma.leaveRequests.findUnique({
where: { // where: {
leave_per_employee_date: { // leave_per_employee_date: {
employee_id: employee_id, // employee_id: employee_id,
leave_type: LeaveTypes.SICK, // leave_type: LeaveTypes.SICK,
date, // date,
}, // },
}, // },
select: { id: true }, // select: { id: true },
}); // });
if (existing) { // if (existing) {
throw new BadRequestException(`Sick request already exists for ${iso}`); // throw new BadRequestException(`Sick request already exists for ${iso}`);
} // }
const payable = Math.min(remaining_payable_hours, daily_payable_cap); // const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable)); // const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour( // remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded), // Math.max(0, remaining_payable_hours - payable_rounded),
); // );
const row = await this.prisma.leaveRequests.create({ // const row = await this.prisma.leaveRequests.create({
data: { // data: {
employee_id: employee_id, // employee_id: employee_id,
bank_code_id: bank_code.id, // bank_code_id: bank_code.id,
leave_type: LeaveTypes.SICK, // leave_type: LeaveTypes.SICK,
comment: dto.comment ?? "", // comment: dto.comment ?? "",
requested_hours: requested_hours_per_day, // requested_hours: requested_hours_per_day,
payable_hours: payable_rounded, // payable_hours: payable_rounded,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, // approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date, // date,
}, // },
select: leaveRequestsSelect, // select: leaveRequestsSelect,
}); // });
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); // const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) { // if (row.approval_status === LeaveApprovalStatus.APPROVED) {
// await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment); // // await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment);
} // }
created.push({ ...mapRowToView(row), action: "create" }); // created.push({ ...mapRowToView(row), action: "create" });
} // }
return { action: "create", leave_requests: created }; // return { action: "create", leave_requests: created };
} // }
} // }

View File

@ -1,91 +1,91 @@
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common"; // import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { UpsertLeaveRequestDto, UpsertResult } from "src/time-and-attendance/leave-requests/dtos/upsert-leave-request.dto"; // import { UpsertLeaveRequestDto, UpsertResult } from "src/time-and-attendance/leave-requests/dtos/upsert-leave-request.dto";
import { LeaveTypes, LeaveApprovalStatus } from "@prisma/client"; // import { LeaveTypes, LeaveApprovalStatus } from "@prisma/client";
import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils"; // import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils";
import { LeaveRequestViewDto } from "src/time-and-attendance/leave-requests/dtos/leave-request-view.dto"; // import { LeaveRequestViewDto } from "src/time-and-attendance/leave-requests/dtos/leave-request-view.dto";
import { roundToQuarterHour } from "src/common/utils/date-utils"; // import { roundToQuarterHour } from "src/common/utils/date-utils";
import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils"; // import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; // import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { VacationService } from "src/time-and-attendance/domains/services/vacation.service"; // import { VacationService } from "src/time-and-attendance/domains/services/vacation.service";
import { PrismaService } from "src/prisma/prisma.service"; // import { PrismaService } from "src/prisma/prisma.service";
import { mapRowToView } from "src/time-and-attendance/leave-requests/mappers/leave-requests.mapper"; // import { mapRowToView } from "src/time-and-attendance/leave-requests/mappers/leave-requests.mapper";
import { normalizeDates, toDateOnly } from "src/time-and-attendance/utils/date-time.utils"; // import { normalizeDates, toDateOnly } from "src/time-and-attendance/utils/date-time.utils";
@Injectable() // @Injectable()
export class VacationLeaveRequestsService { // export class VacationLeaveRequestsService {
constructor( // constructor(
private readonly prisma: PrismaService, // private readonly prisma: PrismaService,
private readonly vacationService: VacationService, // private readonly vacationService: VacationService,
private readonly emailResolver: EmailToIdResolver, // private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver, // private readonly typeResolver: BankCodesResolver,
) {} // ) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> { // async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim(); // const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email); // const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findIdAndModifierByType(LeaveTypes.VACATION); // const bank_code = await this.typeResolver.findIdAndModifierByType(LeaveTypes.VACATION);
if(!bank_code) throw new NotFoundException(`bank_code not found`); // if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = bank_code.modifier ?? 1; // const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates); // const dates = normalizeDates(dto.dates);
const requested_hours_per_day = dto.requested_hours ?? 8; // const requested_hours_per_day = dto.requested_hours ?? 8;
if (!dates.length) throw new BadRequestException("Dates array must not be empty"); // if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const entries = dates // const entries = dates
.map((iso) => ({ iso, date: toDateOnly(iso) })) // .map((iso) => ({ iso, date: toDateOnly(iso) }))
.sort((a, b) => a.date.getTime() - b.date.getTime()); // .sort((a, b) => a.date.getTime() - b.date.getTime());
const start_date = entries[0].date; // const start_date = entries[0].date;
const total_payable_hours = await this.vacationService.calculateVacationPay( // const total_payable_hours = await this.vacationService.calculateVacationPay(
employee_id, // employee_id,
start_date, // start_date,
entries.length, // entries.length,
modifier, // modifier,
); // );
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); // let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); // const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = []; // const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) { // for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({ // const existing = await this.prisma.leaveRequests.findUnique({
where: { // where: {
leave_per_employee_date: { // leave_per_employee_date: {
employee_id: employee_id, // employee_id: employee_id,
leave_type: LeaveTypes.VACATION, // leave_type: LeaveTypes.VACATION,
date, // date,
}, // },
}, // },
select: { id: true }, // select: { id: true },
}); // });
if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`); // if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
const payable = Math.min(remaining_payable_hours, daily_payable_cap); // const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable)); // const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour( // remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded), // Math.max(0, remaining_payable_hours - payable_rounded),
); // );
const row = await this.prisma.leaveRequests.create({ // const row = await this.prisma.leaveRequests.create({
data: { // data: {
employee_id: employee_id, // employee_id: employee_id,
bank_code_id: bank_code.id, // bank_code_id: bank_code.id,
payable_hours: payable_rounded, // payable_hours: payable_rounded,
requested_hours: requested_hours_per_day, // requested_hours: requested_hours_per_day,
leave_type: LeaveTypes.VACATION, // leave_type: LeaveTypes.VACATION,
comment: dto.comment ?? "", // comment: dto.comment ?? "",
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, // approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date, // date,
}, // },
select: leaveRequestsSelect, // select: leaveRequestsSelect,
}); // });
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); // const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) { // if (row.approval_status === LeaveApprovalStatus.APPROVED) {
// await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment); // // await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment);
} // }
created.push({ ...mapRowToView(row), action: "create" }); // created.push({ ...mapRowToView(row), action: "create" });
} // }
return { action: "create", leave_requests: created }; // return { action: "create", leave_requests: created };
} // }
} // }

View File

@ -1,9 +1,11 @@
import { Prisma } from "@prisma/client";
import { LeaveRequestViewDto } from "src/time-and-attendance/leave-requests/dtos/leave-request-view.dto"; import { LeaveRequestViewDto } from "src/time-and-attendance/leave-requests/dtos/leave-request-view.dto";
import { mapArchiveRowToView } from "src/time-and-attendance/leave-requests/mappers/leave-requests-archive.mapper"; import { mapArchiveRowToView } from "src/time-and-attendance/leave-requests/mappers/leave-requests-archive.mapper";
import { mapRowToView } from "src/time-and-attendance/leave-requests/mappers/leave-requests.mapper"; import { mapRowToView } from "src/time-and-attendance/leave-requests/mappers/leave-requests.mapper";
import { LeaveRequestArchiveRow } from "src/time-and-attendance/leave-requests/utils/leave-requests-archive.select"; import { LeaveRequestArchiveRow } from "src/time-and-attendance/leave-requests/utils/leave-requests-archive.select";
import { LeaveRequestRow } from "src/time-and-attendance/utils/type.utils"; import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils";
export type LeaveRequestRow = Prisma.LeaveRequestsGetPayload<{ select: typeof leaveRequestsSelect}>;
/** Active (table leave_requests) : proxy to base mapper */ /** Active (table leave_requests) : proxy to base mapper */
export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto { export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto {

View File

@ -9,8 +9,9 @@ import { SchedulePresetsApplyService } from "src/time-and-attendance/time-tracke
import { SchedulePresetsGetService } from "src/time-and-attendance/time-tracker/schedule-presets/services/schedule-presets-get.service"; import { SchedulePresetsGetService } from "src/time-and-attendance/time-tracker/schedule-presets/services/schedule-presets-get.service";
import { SchedulePresetsUpsertService } from "src/time-and-attendance/time-tracker/schedule-presets/services/schedule-presets-upsert.service"; import { SchedulePresetsUpsertService } from "src/time-and-attendance/time-tracker/schedule-presets/services/schedule-presets-upsert.service";
import { ShiftController } from "src/time-and-attendance/time-tracker/shifts/controllers/shift.controller"; import { ShiftController } from "src/time-and-attendance/time-tracker/shifts/controllers/shift.controller";
import { ShiftsCreateService } from "src/time-and-attendance/time-tracker/shifts/services/shifts-create.service";
import { ShiftsGetService } from "src/time-and-attendance/time-tracker/shifts/services/shifts-get.service"; import { ShiftsGetService } from "src/time-and-attendance/time-tracker/shifts/services/shifts-get.service";
import { ShiftsUpsertService } from "src/time-and-attendance/time-tracker/shifts/services/shifts-upsert.service"; import { ShiftsUpdateDeleteService } from "src/time-and-attendance/time-tracker/shifts/services/shifts-update-delete.service";
import { TimesheetController } from "src/time-and-attendance/time-tracker/timesheets/controllers/timesheet.controller"; import { TimesheetController } from "src/time-and-attendance/time-tracker/timesheets/controllers/timesheet.controller";
import { TimesheetApprovalService } from "src/time-and-attendance/time-tracker/timesheets/services/timesheet-approval.service"; import { TimesheetApprovalService } from "src/time-and-attendance/time-tracker/timesheets/services/timesheet-approval.service";
import { GetTimesheetsOverviewService } from "src/time-and-attendance/time-tracker/timesheets/services/timesheet-get-overview.service"; import { GetTimesheetsOverviewService } from "src/time-and-attendance/time-tracker/timesheets/services/timesheet-get-overview.service";
@ -33,7 +34,8 @@ import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-i
providers: [ providers: [
GetTimesheetsOverviewService, GetTimesheetsOverviewService,
ShiftsGetService, ShiftsGetService,
ShiftsUpsertService, ShiftsCreateService,
ShiftsUpdateDeleteService,
ExpenseUpsertService, ExpenseUpsertService,
SchedulePresetsUpsertService, SchedulePresetsUpsertService,
SchedulePresetsGetService, SchedulePresetsGetService,

View File

@ -5,20 +5,21 @@ import { PrismaService } from "src/prisma/prisma.service";
import { ApplyResult } from "src/time-and-attendance/utils/type.utils"; import { ApplyResult } from "src/time-and-attendance/utils/type.utils";
import { WEEKDAY } from "src/time-and-attendance/utils/mappers.utils"; import { WEEKDAY } from "src/time-and-attendance/utils/mappers.utils";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { Result } from "src/common/errors/result-error.factory";
@Injectable() @Injectable()
export class SchedulePresetsApplyService { export class SchedulePresetsApplyService {
constructor(private readonly prisma: PrismaService, private readonly emailResolver: EmailToIdResolver) { } constructor(private readonly prisma: PrismaService, private readonly emailResolver: EmailToIdResolver) { }
async applyToTimesheet( email: string, id: number, start_date_iso: string ): Promise<ApplyResult> { async applyToTimesheet(email: string, id: number, start_date_iso: string): Promise<Result<ApplyResult, string>> {
if(!id) throw new BadRequestException(`Schedule preset with id: ${id} not found`); if (!DATE_ISO_FORMAT.test(start_date_iso)) return { success: false, error: 'start_date must be of format :YYYY-MM-DD' };
if(!DATE_ISO_FORMAT.test(start_date_iso)) throw new BadRequestException('start_date must be of format :YYYY-MM-DD');
const employee_id = await this.emailResolver.findIdByEmail(email); const employee_id = await this.emailResolver.findIdByEmail(email);
if (!employee_id.success) return { success: false, error: employee_id.error }
const preset = await this.prisma.schedulePresets.findFirst({ const preset = await this.prisma.schedulePresets.findFirst({
where: { employee_id, id }, where: { employee_id: employee_id.data, id },
include: { include: {
shifts: { shifts: {
orderBy: [{ week_day: 'asc' }, { sort_order: 'asc' }], orderBy: [{ week_day: 'asc' }, { sort_order: 'asc' }],
@ -34,13 +35,14 @@ export class SchedulePresetsApplyService {
}, },
}, },
}); });
if(!preset) throw new NotFoundException(`Preset ${preset} not found`); if (!preset) return { success: false, error: `Schedule preset with id: ${id} not found` };
const start_date = new Date(`${start_date_iso}T00:00:00.000Z`); const start_date = new Date(`${start_date_iso}T00:00:00.000Z`);
const timesheet = await this.prisma.timesheets.upsert({ const timesheet = await this.prisma.timesheets.upsert({
where: { employee_id_start_date: { employee_id, start_date: start_date} }, where: { employee_id_start_date: { employee_id: employee_id.data, start_date: start_date } },
update: {}, update: {},
create: { employee_id, start_date: start_date }, create: { employee_id: employee_id.data, start_date: start_date },
select: { id: true }, select: { id: true },
}); });
@ -85,22 +87,21 @@ export class SchedulePresetsApplyService {
for (const shift of shifts) { for (const shift of shifts) {
if (shift.end_time.getTime() <= shift.start_time.getTime()) { if (shift.end_time.getTime() <= shift.start_time.getTime()) {
throw new ConflictException(`Invalid time range in preset day: ${week_day}, order: ${shift.sort_order}`); return {
success: false,
error: `Invalid time range in preset day: ${week_day}, order: ${shift.sort_order}`
};
} }
const conflict = existing.find((existe) => overlaps( const conflict = existing.find((existe) => overlaps(
shift.start_time, shift.end_time, shift.start_time, shift.end_time,
existe.start_time, existe.end_time, existe.start_time, existe.end_time,
)); ));
if(conflict) { if (conflict)
throw new ConflictException({ return {
error_code: 'SHIFT_OVERLAP_WITH_EXISTING', success: false,
mesage: `Preset shift overlaps existing shift on ${start_date} + ${i}(week day ${week_day})`, error: `[SHIFT_OVERLAP] :Preset shift overlaps existing shift on ${start_date} + ${i}(week day ${week_day}) `
conflict: { };
existing_start: conflict.start_time.toISOString().slice(11,16),
existing_end: conflict.end_time.toISOString().slice(11,16),
},
});
}
payload.push({ payload.push({
timesheet_id: timesheet.id, timesheet_id: timesheet.id,
date: date, date: date,
@ -118,6 +119,6 @@ export class SchedulePresetsApplyService {
} }
} }
}); });
return { timesheet_id: timesheet.id, created, skipped }; return { success: true, data: { timesheet_id: timesheet.id, created, skipped } };
} }
} }

View File

@ -1,8 +1,8 @@
import { PresetResponse, ShiftResponse } from "src/time-and-attendance/utils/type.utils"; import { PresetResponse, ShiftResponse } from "src/time-and-attendance/utils/type.utils";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { Result } from "src/common/errors/result-error.factory";
@Injectable() @Injectable()
export class SchedulePresetsGetService { export class SchedulePresetsGetService {
@ -11,11 +11,12 @@ export class SchedulePresetsGetService {
private readonly emailResolver: EmailToIdResolver, private readonly emailResolver: EmailToIdResolver,
){} ){}
async getSchedulePresets(email: string): Promise<PresetResponse[]> { async getSchedulePresets(email: string): Promise<Result<PresetResponse[], string>> {
try { try {
const employee_id = await this.emailResolver.findIdByEmail(email); const employee_id = await this.emailResolver.findIdByEmail(email);
if(!employee_id.success) return { success: false, error: employee_id.error }
const presets = await this.prisma.schedulePresets.findMany({ const presets = await this.prisma.schedulePresets.findMany({
where: { employee_id }, where: { employee_id: employee_id.data },
orderBy: [{is_default: 'desc' }, { name: 'asc' }], orderBy: [{is_default: 'desc' }, { name: 'asc' }],
include: { include: {
shifts: { shifts: {
@ -39,10 +40,9 @@ export class SchedulePresetsGetService {
type: shift.bank_code?.type, type: shift.bank_code?.type,
})), })),
})); }));
return response; return { success: true, data:response};
} catch ( error: unknown) { } catch ( error) {
if(error instanceof Prisma.PrismaClientKnownRequestError) {} return { success: false, error: `Schedule presets for employee with email ${email} not found`};
throw error;
} }
} }

View File

@ -1,11 +1,11 @@
import { Injectable, BadRequestException, NotFoundException, ConflictException } from "@nestjs/common"; import { Injectable, BadRequestException, NotFoundException, ConflictException } from "@nestjs/common";
import { CreatePresetResult, DeletePresetResult, UpdatePresetResult } from "src/time-and-attendance/utils/type.utils";
import { Prisma, Weekday } from "@prisma/client"; import { Prisma, Weekday } from "@prisma/client";
import { toHHmmFromDate } from "src/time-and-attendance/utils/date-time.utils"; import { toDateFromString, toHHmmFromDate } from "src/time-and-attendance/utils/date-time.utils";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils"; import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils";
import { SchedulePresetsDto } from "src/time-and-attendance/time-tracker/schedule-presets/dtos/create-schedule-presets.dto"; import { SchedulePresetsDto } from "src/time-and-attendance/time-tracker/schedule-presets/dtos/create-schedule-presets.dto";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { Result } from "src/common/errors/result-error.factory";
@Injectable() @Injectable()
export class SchedulePresetsUpsertService { export class SchedulePresetsUpsertService {
@ -17,41 +17,42 @@ export class SchedulePresetsUpsertService {
//_________________________________________________________________ //_________________________________________________________________
// CREATE // CREATE
//_________________________________________________________________ //_________________________________________________________________
async createPreset(email: string, dto: SchedulePresetsDto): Promise<CreatePresetResult> { async createPreset(email: string, dto: SchedulePresetsDto): Promise<Result<SchedulePresetsDto, string>> {
try { try {
const shifts_data = await this.resolveAndBuildPresetShifts(dto); const shifts_data = await this.normalizePresetShifts(dto);
const employee_id = await this.emailResolver.findIdByEmail(email); if (!shifts_data.success) return { success: false, error: `Employee with email: ${email} or dto not found` };
if (!shifts_data) throw new BadRequestException(`Employee with email: ${email} or dto not found`);
await this.prisma.$transaction(async (tx) => { const employee_id = await this.emailResolver.findIdByEmail(email);
if (!employee_id.success) return { success: false, error: employee_id.error };
const created = await this.prisma.$transaction(async (tx) => {
if (dto.is_default) { if (dto.is_default) {
await tx.schedulePresets.updateMany({ await tx.schedulePresets.updateMany({
where: { is_default: true, employee_id }, where: { is_default: true, employee_id: employee_id.data },
data: { is_default: false }, data: { is_default: false },
}); });
} await tx.schedulePresets.create({
const created = await tx.schedulePresets.create({
data: { data: {
id: dto.id, id: dto.id,
employee_id, employee_id: employee_id.data,
name: dto.name, name: dto.name,
is_default: !!dto.is_default, is_default: !!dto.is_default,
shifts: { create: shifts_data }, shifts: { create: shifts_data.data },
}, },
}); });
return created; return { success: true, data: created }
}
}); });
return { ok: true }; return { success: true, data: created }
} catch (error) {
} catch (error: unknown) { return { success: false, error: ' An error occured during create. Invalid Schedule data' };
return { ok: false, error };
} }
} }
//_________________________________________________________________ //_________________________________________________________________
// UPDATE // UPDATE
//_________________________________________________________________ //_________________________________________________________________
async updatePreset(preset_id: number, dto: SchedulePresetsDto): Promise<UpdatePresetResult> { async updatePreset(preset_id: number, dto: SchedulePresetsDto): Promise<Result<SchedulePresetsDto, string>> {
try { try {
const existing = await this.prisma.schedulePresets.findFirst({ const existing = await this.prisma.schedulePresets.findFirst({
where: { id: preset_id }, where: { id: preset_id },
@ -61,9 +62,11 @@ export class SchedulePresetsUpsertService {
employee_id: true, employee_id: true,
}, },
}); });
if (!existing) throw new NotFoundException(`Preset "${dto.name}" not found`); if (!existing) return { success: false, error: `Preset "${dto.name}" not found` };
const shifts_data = await this.normalizePresetShifts(dto);
if(!shifts_data.success) return { success: false, error: 'An error occured during normalization'}
const shifts_data = await this.resolveAndBuildPresetShifts(dto);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
if (typeof dto.is_default === 'boolean') { if (typeof dto.is_default === 'boolean') {
if (dto.is_default) { if (dto.is_default) {
@ -84,27 +87,34 @@ export class SchedulePresetsUpsertService {
}, },
}); });
} }
if (shifts_data.length <= 0) throw new BadRequestException('Preset shifts to update not found'); if (shifts_data.data.length <= 0) return { success: false, error: 'Preset shifts to update not found' };
await tx.schedulePresetShifts.deleteMany({ where: { preset_id: existing.id } }); await tx.schedulePresetShifts.deleteMany({ where: { preset_id: existing.id } });
const create_many_data: Prisma.SchedulePresetShiftsCreateManyInput[] = // try {
shifts_data.map((shift) => { // const create_many_data: Result<Prisma.SchedulePresetShiftsCreateManyInput[], string> =
if (!shift.bank_code || !('connect' in shift.bank_code) || typeof shift.bank_code.connect?.id !== 'number') { // shifts_data.data.map((shift) => {
throw new NotFoundException(`Bank code is required for updates( ${shift.week_day}, ${shift.sort_order})`); // if (!shift.bank_code || !('connect' in shift.bank_code) || typeof shift.bank_code.connect?.id !== 'number') {
} // return { success: false, error: `Bank code is required for updates( ${shift.week_day}, ${shift.sort_order})`}
const bank_code_id = shift.bank_code.connect.id; // }
return { // const bank_code_id = shift.bank_code.connect.id;
preset_id: existing.id, // return {
week_day: shift.week_day, // preset_id: existing.id,
sort_order: shift.sort_order, // week_day: shift.week_day,
start_time: shift.start_time, // sort_order: shift.sort_order,
end_time: shift.end_time, // start_time: shift.start_time,
is_remote: shift.is_remote ?? false, // end_time: shift.end_time,
bank_code_id: bank_code_id, // is_remote: shift.is_remote ?? false,
}; // bank_code_id: bank_code_id,
}); // };
await tx.schedulePresetShifts.createMany({ data: create_many_data }); // });
// if(!create_many_data.success) return { success: false, error: 'Invalid data'}
// await tx.schedulePresetShifts.createMany({ data: create_many_data.data });
// return { success: true, data: create_many_data }
// } catch (error) {
// return { success: false, error: 'An error occured. Invalid data detected. ' };
// }
}); });
const saved = await this.prisma.schedulePresets.findUnique({ const saved = await this.prisma.schedulePresets.findUnique({
@ -116,7 +126,7 @@ export class SchedulePresetsUpsertService {
} }
}, },
}); });
if (!saved) throw new NotFoundException(`Preset with id: ${existing.id} not found`); if (!saved) return { success: false, error: `Preset with id: ${existing.id} not found` };
const response_dto: SchedulePresetsDto = { const response_dto: SchedulePresetsDto = {
id: saved.id, id: saved.id,
@ -133,52 +143,50 @@ export class SchedulePresetsUpsertService {
})), })),
}; };
return { ok: true, id: existing.id, data: response_dto }; return { success: true, data: response_dto };
} catch (error) { } catch (error) {
return { ok: false, id: preset_id, error } return { success: false, error: 'An error occured during update. Invalid data' }
} }
} }
//_________________________________________________________________ //_________________________________________________________________
// DELETE // DELETE
//_________________________________________________________________ //_________________________________________________________________
async deletePreset(preset_id: number): Promise<DeletePresetResult> { async deletePreset(preset_id: number): Promise<Result<number, string>> {
try { try {
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
const preset = await tx.schedulePresets.findFirst({ const preset = await tx.schedulePresets.findFirst({
where: { id: preset_id }, where: { id: preset_id },
select: { id: true }, select: { id: true },
}); });
if (!preset) throw new NotFoundException(`Preset with id ${preset_id} not found`); if (!preset) return { success: false, error: `Preset with id ${preset_id} not found` };
await tx.schedulePresets.delete({ where: { id: preset_id } }); await tx.schedulePresets.delete({ where: { id: preset_id } });
return { success: true }; return { success: true };
}); });
return { ok: true, id: preset_id }; return { success: true, data: preset_id };
} catch (error) { } catch (error) {
if (error) throw new NotFoundException(`Preset schedule with id ${preset_id} not found`); return { success: false, error: `Preset schedule with id ${preset_id} not found` };
return { ok: false, id: preset_id, error };
} }
} }
//PRIVATE HELPERS //PRIVATE HELPERS
//resolve bank_code_id using type and convert hours to TIME and valid shifts end/start //resolve bank_code_id using type and convert hours to TIME and valid shifts end/start
private async resolveAndBuildPresetShifts( private async normalizePresetShifts(
dto: SchedulePresetsDto dto: SchedulePresetsDto
): Promise<Prisma.SchedulePresetShiftsCreateWithoutPresetInput[]> { ): Promise<Result<Prisma.SchedulePresetShiftsCreateWithoutPresetInput[], string>> {
if (!dto.preset_shifts?.length) throw new NotFoundException(`Empty or preset shifts not found`); if (!dto.preset_shifts?.length) throw new NotFoundException(`Empty or preset shifts not found`);
const types = Array.from(new Set(dto.preset_shifts.map((shift) => shift.type))); const types = Array.from(new Set(dto.preset_shifts.map((shift) => shift.type)));
const bank_code_set = new Map<string, number>(); const bank_code_set = new Map<string, number>();
for (const type of types) { for (const type of types) {
const { id } = await this.typeResolver.findIdAndModifierByType(type); const bank_code = await this.typeResolver.findIdAndModifierByType(type);
bank_code_set.set(type, id) if (!bank_code.success) return { success: false, error: 'Bank_code not found' }
bank_code_set.set(type, bank_code.data.id);
} }
const toTime = (hhmm: string) => new Date(`1970-01-01T${hhmm}:00.000Z`);
const pair_set = new Set<string>(); const pair_set = new Set<string>();
for (const shift of dto.preset_shifts) { for (const shift of dto.preset_shifts) {
@ -195,8 +203,8 @@ export class SchedulePresetsUpsertService {
if (!shift.start_time || !shift.end_time) { if (!shift.start_time || !shift.end_time) {
throw new BadRequestException(`start_time and end_time are required for (${shift.week_day}, ${shift.sort_order})`); throw new BadRequestException(`start_time and end_time are required for (${shift.week_day}, ${shift.sort_order})`);
} }
const start = toTime(shift.start_time); const start = toDateFromString(shift.start_time);
const end = toTime(shift.end_time); const end = toDateFromString(shift.end_time);
if (end.getTime() <= start.getTime()) { if (end.getTime() <= start.getTime()) {
throw new ConflictException(`end_time must be > start_time ( day: ${shift.week_day}, order: ${shift.sort_order})`); throw new ConflictException(`end_time must be > start_time ( day: ${shift.week_day}, order: ${shift.sort_order})`);
} }
@ -210,6 +218,6 @@ export class SchedulePresetsUpsertService {
is_remote: !!shift.is_remote, is_remote: !!shift.is_remote,
}; };
}); });
return items; return { success: true, data: items };
} }
} }

View File

@ -1,36 +1,34 @@
import { BadRequestException, Body, Controller, Delete, Param, Patch, Post, Req } from "@nestjs/common"; import { Body, Controller, Delete, Param, Patch, Post, Req } from "@nestjs/common";
import { ShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-create.dto"; import { ShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-create.dto";
import { ShiftsUpsertService } from "src/time-and-attendance/time-tracker/shifts/services/shifts-upsert.service";
import { CreateShiftResult, UpdateShiftResult } from "src/time-and-attendance/utils/type.utils";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { GLOBAL_CONTROLLER_ROLES } from "src/common/shared/role-groupes"; import { GLOBAL_CONTROLLER_ROLES } from "src/common/shared/role-groupes";
import { Result } from "src/common/errors/result-error.factory";
import { ShiftsCreateService } from "src/time-and-attendance/time-tracker/shifts/services/shifts-create.service";
import { ShiftsUpdateDeleteService } from "src/time-and-attendance/time-tracker/shifts/services/shifts-update-delete.service";
@Controller('shift') @Controller('shift')
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES) @RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
export class ShiftController { export class ShiftController {
constructor( constructor(
private readonly upsert_service: ShiftsUpsertService, private readonly create_service: ShiftsCreateService,
private readonly update_delete_service: ShiftsUpdateDeleteService,
){} ){}
@Post('create') @Post('create')
createBatch( @Req() req, @Body()dtos: ShiftDto[]): Promise<CreateShiftResult[]> { createBatch( @Req() req, @Body()dtos: ShiftDto[]): Promise<Result<ShiftDto[], string>> {
const email = req.user?.email; const email = req.user?.email;
const list = Array.isArray(dtos) ? dtos : []; return this.create_service.createOneOrManyShifts(email, dtos)
if(list.length === 0) throw new BadRequestException('Body is missing or invalid (create shifts)');
return this.upsert_service.createShifts(email, dtos)
} }
@Patch('update') @Patch('update')
updateBatch( @Body() dtos: ShiftDto[]): Promise<UpdateShiftResult[]>{ updateBatch( @Body() dtos: ShiftDto[]): Promise<Result<ShiftDto[], string>>{
const list = Array.isArray(dtos) ? dtos: []; return this.update_delete_service.updateOneOrManyShifts(dtos);
if(list.length === 0) throw new BadRequestException('Body is missing or invalid (update shifts)');
return this.upsert_service.updateShifts(dtos);
} }
@Delete(':shift_id') @Delete(':shift_id')
remove(@Param('shift_id') shift_id: number ) { remove(@Param('shift_id') shift_id: number ): Promise<Result<number, string>> {
return this.upsert_service.deleteShift(shift_id); return this.update_delete_service.deleteShift(shift_id);
} }
} }

View File

@ -0,0 +1,160 @@
import { Normalized } from "src/time-and-attendance/utils/type.utils";
import { overlaps, toStringFromHHmm, toStringFromDate, toDateFromString, toHHmmFromString } from "src/time-and-attendance/utils/date-time.utils";
import { Injectable } from "@nestjs/common";
import { timesheet_select } from "src/time-and-attendance/utils/selects.utils";
import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { PrismaService } from "src/prisma/prisma.service";
import { ShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-create.dto";
import { Result } from "src/common/errors/result-error.factory";
@Injectable()
export class ShiftsCreateService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) { }
//_________________________________________________________________
// CREATE WRAPPER FUNCTION FOR ONE OR MANY INPUT
//_________________________________________________________________
async createOneOrManyShifts(email: string, shifts: ShiftDto[]): Promise<Result<ShiftDto[], string>> {
try {
//verify if array is empty or not
if (!Array.isArray(shifts) || shifts.length === 0) return { success: false, error: 'No data received' };
//verify if email is valid or not
const employee_id = await this.emailResolver.findIdByEmail(email);
if (!employee_id.success) return { success: false, error: employee_id.error };
//calls the create functions and await the return of successfull result or not
const results = await Promise.allSettled(shifts.map(shift => this.createShift(employee_id.data, shift)));
//return arrays of created shifts or errors
const created_shifts: ShiftDto[] = [];
const errors: string[] = [];
//filters results into created_shifts or errors arrays depending on the return from "allSettled" Promise
for (const result of results) {
if (result.status === 'fulfilled') {
if (result.value.success) {
created_shifts.push(result.value.data);
} else {
errors.push(result.value.error);
}
} else {
errors.push(result.reason instanceof Error ? result.reason.message : String(result.reason));
}
}
//verify if shifts were created and returns an array of errors if needed
if (created_shifts.length === 0) return { success: false, error: errors.join(' | ') || 'No shift created' };
// returns array of created shifts
return { success: true, data: created_shifts }
} catch (error) {
return { success: false, error }
}
}
//_________________________________________________________________
// CREATE
//_________________________________________________________________
async createShift(employee_id: number, dto: ShiftDto): Promise<Result<ShiftDto, string>> {
try {
//transform string format to date and HHmm
const normed_shift = await this.normalizeShiftDto(dto);
if(!normed_shift.success) return { success: false, error: 'An error occured during normalization' }
if (normed_shift.data.end_time <= normed_shift.data.start_time) return {
success: false,
error: `INVALID_SHIFT - `
+ `start_time: ${toStringFromHHmm(normed_shift.data.start_time)},`
+ `end_time: ${toStringFromHHmm(normed_shift.data.end_time)},`
+ `date: ${toStringFromDate(normed_shift.data.date)}.`
}
//fetch the right timesheet
const timesheet = await this.prisma.timesheets.findUnique({
where: { id: dto.timesheet_id, employee_id },
select: timesheet_select,
});
if (!timesheet) return {
success: false,
error: `INVALID_TIMESHEET -`
+ `start_time: ${toStringFromHHmm(normed_shift.data.start_time)},`
+ `end_time: ${toStringFromHHmm(normed_shift.data.end_time)},`
+ `date: ${toStringFromDate(normed_shift.data.date)}.`
}
//finds bank_code_id using the type
const bank_code_id = await this.typeResolver.findBankCodeIDByType(dto.type);
if (!bank_code_id.success) return { success: false, error: bank_code_id.error };
//fetchs existing shifts from DB to check for overlaps
const existing_shifts = await this.prisma.shifts.findMany({
where: { timesheet_id: timesheet.id, date: normed_shift.data.date },
select: { id: true, date: true, start_time: true, end_time: true },
});
for (const existing of existing_shifts) {
const existing_start = await toDateFromString(existing.start_time);
const existing_end = await toDateFromString(existing.end_time);
const existing_date = await toDateFromString(existing.date);
const has_overlap = overlaps(
{ start: normed_shift.data.start_time, end: normed_shift.data.end_time, date: normed_shift.data.date },
{ start: existing_start, end: existing_end, date: existing_date },
);
if (has_overlap) {
return {
success: false,
error: `SHIFT_OVERLAP`
+ `new shift: ${toStringFromHHmm(normed_shift.data.start_time)}${toStringFromHHmm(normed_shift.data.end_time)} `
+ `existing shift: ${toStringFromHHmm(existing.start_time)}${toStringFromHHmm(existing.end_time)} `
+ `date: ${toStringFromDate(normed_shift.data.date)})`,
}
}
}
//sends data for creation of a shift in db
const created_shift = await this.prisma.shifts.create({
data: {
timesheet_id: timesheet.id,
bank_code_id: bank_code_id.data,
date: normed_shift.data.date,
start_time: normed_shift.data.start_time,
end_time: normed_shift.data.end_time,
is_approved: dto.is_approved,
is_remote: dto.is_remote,
comment: dto.comment ?? '',
},
});
//builds an object to return for display in the frontend
const shift: ShiftDto = {
id: created_shift.id,
timesheet_id: timesheet.id,
type: dto.type,
date: toStringFromDate(created_shift.date),
start_time: toStringFromHHmm(created_shift.start_time),
end_time: toStringFromHHmm(created_shift.end_time),
is_approved: created_shift.is_approved,
is_remote: created_shift.is_remote,
comment: created_shift.comment ?? '',
}
return { success: true, data: shift };
} catch (error) {
return { success: false, error: `An error occured during creation, invalid data` };
}
}
//_________________________________________________________________
// LOCAL HELPERS
//_________________________________________________________________
//converts all string hours and date to Date and HHmm formats
private normalizeShiftDto = async (dto: ShiftDto): Promise<Result<Normalized, string>> => {
const bank_code_id = await this.typeResolver.findBankCodeIDByType(dto.type);
if(!bank_code_id.success) return { success: false, error: 'Bank_code not found'}
const date = toDateFromString(dto.date);
const start_time = toHHmmFromString(dto.start_time);
const end_time = toHHmmFromString(dto.end_time);
return { success: true, data: {date, start_time, end_time, bank_code_id: bank_code_id.data} };
}
}

View File

@ -0,0 +1,175 @@
import { Result } from "src/common/errors/result-error.factory";
import { PrismaService } from "src/prisma/prisma.service";
import { ShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-create.dto";
import { toDateFromString, toHHmmFromString, toStringFromHHmm, toStringFromDate, overlaps } from "src/time-and-attendance/utils/date-time.utils";
import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils";
import { shift_select } from "src/time-and-attendance/utils/selects.utils";
import { Normalized } from "src/time-and-attendance/utils/type.utils";
export class ShiftsUpdateDeleteService {
constructor(
private readonly prisma: PrismaService,
private readonly typeResolver: BankCodesResolver,
) { }
async updateOneOrManyShifts(shifts: ShiftDto[]): Promise<Result<ShiftDto[], string>> {
try {
//verify if array is empty or not
if (!Array.isArray(shifts) || shifts.length === 0) return { success: false, error: 'No data received' };
//calls the update functions and await the return of successfull result or not
const results = await Promise.allSettled(shifts.map(shift => this.updateShift(shift)));
//return arrays of updated shifts or errors
const updated_shifts: ShiftDto[] = [];
const errors: string[] = [];
//filters results into updated_shifts or errors arrays depending on the return from "allSettled" Promise
for (const result of results) {
if (result.status === 'fulfilled') {
if (result.value.success) {
updated_shifts.push(result.value.data);
} else {
errors.push(result.value.error);
}
} else {
errors.push(result.reason instanceof Error ? result.reason.message : String(result.reason));
}
}
//verify if shifts were updated and returns an array of errors if needed
if (updated_shifts.length === 0) return { success: false, error: errors.join(' | ') || 'No shift updated' };
// returns array of updated shifts
return { success: true, data: updated_shifts }
} catch (error) {
return { success: false, error }
}
}
//_________________________________________________________________
// UPDATE
//_________________________________________________________________
async updateShift(dto: ShiftDto): Promise<Result<ShiftDto, string>> {
try {
//finds original shift
const original = await this.prisma.shifts.findFirst({
where: { id: dto.id },
select: shift_select,
});
if (!original) return { success: false, error: `Shift with id: ${dto.id} not found` };
//transform string format to date and HHmm
const normed_shift = await this.normalizeShiftDto(dto);
if (!normed_shift.success) return { success: false, error: 'An error occured during normalization' }
if (normed_shift.data.end_time <= normed_shift.data.start_time) return {
success: false,
error: `INVALID_SHIFT - `
+ `start_time: ${toStringFromHHmm(normed_shift.data.start_time)},`
+ `end_time: ${toStringFromHHmm(normed_shift.data.end_time)},`
+ `date: ${toStringFromDate(normed_shift.data.date)}.`
};
const overlap_check = await this.overlapChecker(normed_shift.data);
if(!overlap_check.success) return { success: false, error: 'Invalid shift, overlaps with existing shifts'}
//finds bank_code_id using the type
const bank_code = await this.typeResolver.findBankCodeIDByType(dto.type);
if (!bank_code.success) return { success: false, error: 'No bank_code_id found' };
//updates sent to DB
const updated = await this.prisma.shifts.update({
where: { id: original.id },
data: {
date: normed_shift.data.date,
start_time: normed_shift.data.start_time,
end_time: normed_shift.data.end_time,
bank_code_id: bank_code.data,
comment: dto.comment,
is_approved: dto.is_approved,
is_remote: dto.is_remote,
},
select: shift_select,
});
if (!updated) return { success: false, error: ' An error occured during update, Invalid Datas' };
// builds an object to return for display in the frontend
const shift: ShiftDto = {
id: updated.id,
timesheet_id: updated.timesheet_id,
type: dto.type,
date: toStringFromDate(updated.date),
start_time: toStringFromHHmm(updated.start_time),
end_time: toStringFromHHmm(updated.end_time),
is_approved: updated.is_approved,
is_remote: updated.is_remote,
comment: updated.comment ?? '',
}
return { success: true, data: shift };
} catch (error) {
return { success: false, error: `An error occured during update. Invalid Data` };
}
}
//_________________________________________________________________
// DELETE
//_________________________________________________________________
//finds shifts using shit_ids
//blocs deletion if approved
async deleteShift(shift_id: number): Promise<Result<number, string>> {
try {
return await this.prisma.$transaction(async (tx) => {
const shift = await tx.shifts.findUnique({
where: { id: shift_id },
select: { id: true, date: true, timesheet_id: true },
});
if (!shift) return { success: false, error: `shift with id ${shift_id} not found ` };
await tx.shifts.delete({ where: { id: shift_id } });
return { success: true, data: shift.id };
});
} catch (error) {
return { success: false, error: `INVALID_SHIFT, shift with id ${shift_id} not found` }
}
}
//_________________________________________________________________
// helpers
//_________________________________________________________________
private normalizeShiftDto = async (dto: ShiftDto): Promise<Result<Normalized, string>> => {
const bank_code_id = await this.typeResolver.findBankCodeIDByType(dto.type);
if (!bank_code_id.success) return { success: false, error: 'Bank_code not found' }
const date = toDateFromString(dto.date);
const start_time = toHHmmFromString(dto.start_time);
const end_time = toHHmmFromString(dto.end_time);
return { success: true, data: { date, start_time, end_time, bank_code_id: bank_code_id.data } };
}
private overlapChecker = async (dto: Normalized): Promise<Result<void, string>> => {
const existing_shifts = await this.prisma.shifts.findMany({
where: { date: dto.date },
select: { id: true, date: true, start_time: true, end_time: true },
});
for (const existing of existing_shifts) {
const existing_start = toDateFromString(existing.start_time);
const existing_end = toDateFromString(existing.end_time);
const existing_date = toDateFromString(existing.date);
const has_overlap = overlaps(
{ start: dto.start_time, end: dto.end_time, date: dto.date },
{ start: existing_start, end: existing_end, date: existing_date },
);
if (has_overlap) {
return {
success: false,
error: `SHIFT_OVERLAP`
+ `new shift: ${toStringFromHHmm(dto.start_time)}${toStringFromHHmm(dto.end_time)} `
+ `existing shift: ${toStringFromHHmm(existing.start_time)}${toStringFromHHmm(existing.end_time)} `
+ `date: ${toStringFromDate(dto.date)})`,
}
}
}
return { success: true, data: undefined }
}
}

View File

@ -1,463 +0,0 @@
import { CreateShiftResult, NormedOk, UpdateShiftResult, Normalized } from "src/time-and-attendance/utils/type.utils";
import { overlaps, toStringFromHHmm, toStringFromDate, toDateFromString, toHHmmFromString } from "src/time-and-attendance/utils/date-time.utils";
import { Injectable, BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
import { shift_select, timesheet_select } from "src/time-and-attendance/utils/selects.utils";
import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { PrismaService } from "src/prisma/prisma.service";
import { GetShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-get.dto";
import { ShiftEntity } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-entity.dto";
import { ShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-create.dto";
@Injectable()
export class ShiftsUpsertService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) { }
//_________________________________________________________________
// CREATE
//_________________________________________________________________
//normalized frontend data to match DB
//loads all shifts from a selected day to check for overlaping shifts
//checks for overlaping shifts
//create new shifts
async createShifts(email: string, dtos: ShiftDto[]): Promise<CreateShiftResult[]> {
if (!Array.isArray(dtos) || dtos.length === 0) return [];
const employee_id = await this.emailResolver.findIdByEmail(email);
const results: CreateShiftResult[] = [];
const normed_shifts: (NormedOk | undefined)[] = await Promise.all(dtos.map(async (dto, index) => {
try {
const normed = await this.normalizeShiftDto(dto);
if (normed.end_time <= normed.start_time) {
const error = {
error_code: 'SHIFT_OVERLAP',
conflicts: {
start_time: toStringFromHHmm(normed.start_time),
end_time: toStringFromHHmm(normed.end_time),
date: toStringFromDate(normed.date),
},
};
results.push({ ok: false, error });
}
const timesheet = await this.prisma.timesheets.findUnique({
where: { id: dto.timesheet_id, employee_id },
select: timesheet_select,
});
if (!timesheet) {
const error = {
error_code: 'INVALID_TIMESHEET',
conflicts: {
start_time: toStringFromHHmm(normed.start_time),
end_time: toStringFromHHmm(normed.end_time),
date: toStringFromDate(normed.date),
},
};
results.push({ ok: false, error });
return;
}
const bank_code = await this.typeResolver.findBankCodeIDByType(dto.type);
const date = await toDateFromString(dto.date);
const start_time = await toHHmmFromString(dto.start_time);
const end_time = await toHHmmFromString(dto.end_time);
const entity: ShiftEntity = {
timesheet_id: timesheet.id,
bank_code_id: bank_code.id,
date,
start_time,
end_time,
id: dto.id,
is_approved: dto.is_approved,
is_remote: dto.is_remote,
};
return {
index,
dto: entity,
normed,
timesheet_id: timesheet.id,
};
} catch (error) {
results.push({ ok: false, error });
return;
}
}));
const ok_items = normed_shifts.filter((item) => item !== undefined);
const regroup_by_date = new Map<string, number[]>();
ok_items.forEach(({ index, normed, timesheet_id }) => {
const day = new Date(normed.date.getUTCFullYear(), normed.date.getUTCMonth(), normed.date.getUTCDate()).getTime();
const key = `${timesheet_id}|${day}`;
if (!regroup_by_date.has(key)) regroup_by_date.set(key, []);
regroup_by_date.get(key)!.push(index);
});
const timesheet_keys = Array.from(regroup_by_date.keys()).map((raw) => {
const [timesheet, day] = raw.split('|');
return {
timesheet_id: Number(timesheet),
day: Number(day),
key: raw,
};
});
for (const indices of regroup_by_date.values()) {
const ordered = indices
.map(index => {
const item = normed_shifts[index] as NormedOk & { timesheet_id: number };
return {
index: index,
start: item.normed.start_time,
end: item.normed.end_time,
date: item.normed.date,
};
})
.sort((a, b) => a.start.getTime() - b.start.getTime());
for (let j = 1; j < ordered.length; j++) {
if (
overlaps(
{ start: ordered[j - 1].start, end: ordered[j - 1].end, date: ordered[j - 1].date },
{ start: ordered[j].start, end: ordered[j].end, date: ordered[j].date },
)
) {
const error = new ConflictException({
error_code: 'SHIFT_OVERLAP',
conflicts: {
start_time: toStringFromHHmm(ordered[j].start),
end_time: toStringFromHHmm(ordered[j].end),
date: toStringFromDate(ordered[j].date),
},
});
return dtos.map((_dto, key) =>
indices.includes(key)
? ({ ok: false, error } as CreateShiftResult)
: ({ ok: false, error }),
);
}
}
}
return this.prisma.$transaction(async (tx) => {
const results: CreateShiftResult[] = Array.from(
{ length: dtos.length },
() => ({ ok: false, error: new Error('uninitialized') }));
const existing_map = new Map<string, { start_time: Date; end_time: Date, date: Date }[]>();
for (const { timesheet_id, day, key } of timesheet_keys) {
const day_date = new Date(day);
const rows = await tx.shifts.findMany({
where: { timesheet_id, date: day_date },
select: { start_time: true, end_time: true, id: true, date: true },
});
existing_map.set(key, rows.map((row) => ({ start_time: row.start_time, end_time: row.end_time, date: row.date })));
}
ok_items.forEach((x, i) => {
if ("error" in x) results[i] = { ok: false, error: x.error };
});
for (const item of ok_items) {
const { index, dto, normed, timesheet_id } = item;
const day_key = new Date(normed.date.getUTCFullYear(), normed.date.getUTCMonth(), normed.date.getUTCDate()).getTime();
const map_key = `${timesheet_id}|${day_key}`;
let existing = existing_map.get(map_key);
if (!existing) {
existing = [];
existing_map.set(map_key, existing);
}
const hit = existing.find(exist => overlaps({ start: exist.start_time, end: exist.end_time, date: exist.date },
{ start: normed.start_time, end: normed.end_time, date: normed.date }));
if (hit) {
results[index] = {
ok: false,
error: {
error_code: 'SHIFT_OVERLAP',
conflicts: {
start_time: toStringFromHHmm(hit.start_time),
end_time: toStringFromHHmm(hit.end_time),
date: toStringFromDate(hit.date),
},
},
};
continue;
}
const row = await tx.shifts.create({
data: {
timesheet_id: timesheet_id,
bank_code_id: normed.bank_code_id,
date: normed.date,
start_time: normed.start_time,
end_time: normed.end_time,
is_remote: dto.is_remote,
comment: dto.comment ?? undefined,
},
select: shift_select,
});
const normalizeHHmm = (value: Date) => toHHmmFromString(toStringFromHHmm(value));
const normalized_row = {
start_time: normalizeHHmm(row.start_time),
end_time: normalizeHHmm(row.end_time),
date: toDateFromString(row.date),
};
existing.push(normalized_row);
existing_map.set(map_key, existing);
const { type: bank_type } = await this.typeResolver.findTypeByBankCodeId(row.bank_code_id);
const shift: GetShiftDto = {
shift_id: row.id,
timesheet_id: timesheet_id,
type: bank_type,
date: toStringFromDate(row.date),
start_time: toStringFromHHmm(row.start_time),
end_time: toStringFromHHmm(row.end_time),
is_remote: row.is_remote,
is_approved: false,
comment: row.comment ?? undefined,
};
results[index] = { ok: true, data: shift };
}
return results;
});
}
//_________________________________________________________________
// UPDATE
//_________________________________________________________________
// finds existing shifts in DB
// verify if shifts are already approved
// normalized Date and Time format to string
// check for valid start and end times
// check for overlaping possibility
// buil a set of data to manipulate modified data only
// update shifts in DB
// return an updated version to display
async updateShifts(dtos: ShiftDto[]): Promise<UpdateShiftResult[]> {
if (!Array.isArray(dtos) || dtos.length === 0) throw new BadRequestException({ error_code: 'SHIFT_MISSING' });
const updates: ShiftEntity[] = await Promise.all(dtos.map(async (item) => {
try {
const date = await toDateFromString(item.date);
const start_time = await toHHmmFromString(item.start_time);
const end_time = await toHHmmFromString(item.end_time);
const bank_code = await this.typeResolver.findBankCodeIDByType(item.type);
return {
id: item.id,
timesheet_id: item.timesheet_id,
bank_code_id: bank_code.id,
date,
start_time,
end_time,
is_remote: item.is_remote,
is_approved: item.is_approved,
}
} catch (error) {
throw new BadRequestException('INVALID_SHIFT');
}
}));
return this.prisma.$transaction(async (tx) => {
const shift_ids = updates.map(update_shift => update_shift.id);
const rows = await tx.shifts.findMany({
where: { id: { in: shift_ids } },
select: shift_select,
});
const regroup_id = new Map(rows.map(r => [r.id, r]));
for (const update of updates) {
const existing = regroup_id.get(update.id);
if (!existing) {
return updates.map(exist => exist.id === update.id
? ({ ok: false, id: update.id, error: new NotFoundException({ error_code: 'SHIFT_MISSING' }) } as UpdateShiftResult)
: ({ ok: false, id: exist.id, error: new BadRequestException({ error_code: 'SHIFT_INVALID' }) })
);
}
if (existing.is_approved) {
return updates.map(exist => exist.id === update.id
? ({ ok: false, id: update.id, error: new BadRequestException({ error_code: 'SHIFT_INVALID' }) } as UpdateShiftResult)
: ({ ok: false, id: exist.id, error: new BadRequestException({ error_code: 'SHIFT_INVALID' }) })
);
}
}
const planned_updates = updates.map(update => {
const exist_shift = regroup_id.get(update.id)!;
const normed: Normalized = {
date: update.date,
start_time: update.start_time,
end_time: update.end_time,
bank_code_id: update.bank_code_id,
};
return { update, exist_shift, normed };
});
const groups = new Map<string, { existing: { start: Date; end: Date; id: number; date: Date; }[], incoming: typeof planned_updates }>();
function key(timesheet: number, d: Date) {
const day_date = new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
return `${timesheet}|${day_date.getTime()}`;
}
const unique_pairs = new Map<string, { timesheet_id: number; date: Date }>();
for (const { exist_shift, normed } of planned_updates) {
unique_pairs.set(key(exist_shift.timesheet_id, exist_shift.date), { timesheet_id: exist_shift.timesheet_id, date: exist_shift.date });
unique_pairs.set(key(exist_shift.timesheet_id, normed.date), { timesheet_id: exist_shift.timesheet_id, date: normed.date });
}
for (const group of unique_pairs.values()) {
const day_date = new Date(group.date.getUTCFullYear(), group.date.getUTCMonth(), group.date.getUTCDate());
const existing = await tx.shifts.findMany({
where: { timesheet_id: group.timesheet_id, date: day_date },
select: { id: true, start_time: true, end_time: true, date: true },
});
groups.set(key(group.timesheet_id, day_date), {
existing: existing.map(row => ({
id: row.id,
start: row.start_time,
end: row.end_time,
date: row.date,
})), incoming: planned_updates
});
}
for (const planned of planned_updates) {
const keys = key(planned.exist_shift.timesheet_id, planned.normed.date);
const group = groups.get(keys)!;
const conflict = group.existing.find(row =>
row.id !== planned.exist_shift.id && overlaps({ start: row.start, end: row.end, date: row.date },
{ start: planned.normed.start_time, end: planned.normed.end_time, date: planned.normed.date })
);
if (conflict) {
return updates.map(exist => exist.id === planned.exist_shift.id
? ({
ok: false, id: exist.id, error: {
error_code: 'SHIFT_OVERLAP',
conflicts: {
start_time: toStringFromHHmm(conflict.start),
end_time: toStringFromHHmm(conflict.end),
date: toStringFromDate(conflict.date),
},
}
} as UpdateShiftResult)
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to overlap in another update') })
);
}
}
const regoup_by_day = new Map<string, { id: number; start: Date; end: Date; date: Date }[]>();
for (const planned of planned_updates) {
const keys = key(planned.exist_shift.timesheet_id, planned.normed.date);
if (!regoup_by_day.has(keys)) regoup_by_day.set(keys, []);
regoup_by_day.get(keys)!.push({
id: planned.exist_shift.id,
start: planned.normed.start_time,
end: planned.normed.end_time,
date: planned.normed.date
});
}
for (const arr of regoup_by_day.values()) {
arr.sort((a, b) => a.start.getTime() - b.start.getTime());
for (let i = 1; i < arr.length; i++) {
if (overlaps(
{ start: arr[i - 1].start, end: arr[i - 1].end, date: arr[i - 1].date },
{ start: arr[i].start, end: arr[i].end, date: arr[i].date })
) {
const error = {
error_code: 'SHIFT_OVERLAP',
conflicts: {
start_time: toStringFromHHmm(arr[i].start),
end_time: toStringFromHHmm(arr[i].end),
date: toStringFromDate(arr[i].date),
},
};
return updates.map(exist => ({ ok: false, id: exist.id, error: error }));
}
}
}
const results: UpdateShiftResult[] = [];
for (const planned of planned_updates) {
try {
const data: Partial<ShiftEntity> = {
bank_code_id: planned.normed.bank_code_id,
date: planned.normed.date,
start_time: planned.normed.start_time,
end_time: planned.normed.end_time,
is_remote: planned.update.is_remote,
is_approved: planned.exist_shift.is_approved,
comment: planned.update.comment,
};
const row = await tx.shifts.update({
where: { id: planned.exist_shift.id },
data,
select: shift_select,
});
const type = await this.typeResolver.findTypeByBankCodeId(row.bank_code_id);
const dto: GetShiftDto = {
shift_id: row.id,
timesheet_id: row.timesheet_id,
type: type.type,
date: toStringFromDate(row.date),
start_time: toStringFromHHmm(row.start_time),
end_time: toStringFromHHmm(row.end_time),
is_approved: row.is_approved,
is_remote: row.is_remote,
comment: row.comment ?? undefined,
};
results.push({ ok: true, id: planned.exist_shift.id, data: dto });
} catch (error) {
throw new BadRequestException('INVALID_SHIFT');
}
}
return results;
});
}
//_________________________________________________________________
// DELETE
//_________________________________________________________________
//finds shifts using shit_ids
//blocs deletion if approved
async deleteShift(shift_id: number) {
return await this.prisma.$transaction(async (tx) => {
const shift = await tx.shifts.findUnique({
where: { id: shift_id },
select: { id: true, date: true, timesheet_id: true },
});
if (!shift) throw new ConflictException({ error_code: 'SHIFT_INVALID' });
await tx.shifts.delete({ where: { id: shift_id } });
return { success: true };
});
}
//_________________________________________________________________
// LOCAL HELPERS
//_________________________________________________________________
//converts all string hours and date to Date and HHmm formats
private normalizeShiftDto = async (dto: ShiftDto): Promise<Normalized> => {
const { id: bank_code_id } = await this.typeResolver.findBankCodeIDByType(dto.type);
const date = toDateFromString(dto.date);
const start_time = toHHmmFromString(dto.start_time);
const end_time = toHHmmFromString(dto.end_time);
return { date, start_time, end_time, bank_code_id: bank_code_id };
}
}

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ShiftController } from 'src/time-and-attendance/time-tracker/shifts/controllers/shift.controller'; import { ShiftController } from 'src/time-and-attendance/time-tracker/shifts/controllers/shift.controller';
import { ShiftsUpsertService } from 'src/time-and-attendance/time-tracker/shifts/services/shifts-upsert.service'; import { ShiftsCreateService } from 'src/time-and-attendance/time-tracker/shifts/services/shifts-create.service';
import { ShiftsUpdateDeleteService } from 'src/time-and-attendance/time-tracker/shifts/services/shifts-update-delete.service';
@Module({ @Module({
controllers: [ShiftController], controllers: [ShiftController],
providers: [ ShiftsUpsertService ], providers: [ ShiftsCreateService, ShiftsUpdateDeleteService ],
exports: [ ShiftsUpsertService ], exports: [ ShiftsCreateService, ShiftsUpdateDeleteService ],
}) })
export class ShiftsModule {} export class ShiftsModule {}

View File

@ -1,9 +1,27 @@
import { sevenDaysFrom, toStringFromDate, toHHmmFromDate, toDateFromString } from "src/time-and-attendance/utils/date-time.utils"; import { sevenDaysFrom, toStringFromDate, toHHmmFromDate, toDateFromString } from "src/time-and-attendance/utils/date-time.utils";
import { NUMBER_OF_TIMESHEETS_TO_RETURN } from "src/time-and-attendance/utils/constants.utils"; import { NUMBER_OF_TIMESHEETS_TO_RETURN } from "src/time-and-attendance/utils/constants.utils";
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { TotalExpenses, TotalHours } from "src/time-and-attendance/utils/type.utils";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { Timesheets } from "src/time-and-attendance/time-tracker/timesheets/dtos/timesheet.dto";
import { Result } from "src/common/errors/result-error.factory";
export type TotalHours = {
regular: number;
evening: number;
emergency: number;
overtime: number;
vacation: number;
holiday: number;
sick: number;
};
export type TotalExpenses = {
expenses: number;
per_diem: number;
on_call: number;
mileage: number;
};
@Injectable() @Injectable()
export class GetTimesheetsOverviewService { export class GetTimesheetsOverviewService {
@ -15,13 +33,15 @@ export class GetTimesheetsOverviewService {
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
// GET TIMESHEETS FOR A SELECTED EMPLOYEE // GET TIMESHEETS FOR A SELECTED EMPLOYEE
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
async getTimesheetsForEmployeeByPeriod(email: string, pay_year: number, pay_period_no: number) { async getTimesheetsForEmployeeByPeriod(email: string, pay_year: number, pay_period_no: number): Promise<Result<Timesheets, string>> {
//find period using year and period_no try { //find period using year and period_no
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no } }); const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no } });
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${pay_period_no} not found`); if (!period) return { success: false, error: `Pay period ${pay_year}-${pay_period_no} not found`};
//fetch the employee_id using the email //fetch the employee_id using the email
const employee_id = await this.emailResolver.findIdByEmail(email); const employee_id = await this.emailResolver.findIdByEmail(email);
if (!employee_id.success) return { success: false, error: employee_id.error }
//loads the timesheets related to the fetched pay-period //loads the timesheets related to the fetched pay-period
const timesheet_range = { employee_id, start_date: { gte: period.period_start, lte: period.period_end } }; const timesheet_range = { employee_id, start_date: { gte: period.period_start, lte: period.period_end } };
let rows = await this.loadTimesheets(timesheet_range); let rows = await this.loadTimesheets(timesheet_range);
@ -40,27 +60,30 @@ export class GetTimesheetsOverviewService {
const exists = rows.some( const exists = rows.some(
(row) => toDateFromString(row.start_date).getTime() === week_start.getTime() (row) => toDateFromString(row.start_date).getTime() === week_start.getTime()
); );
if (!exists) await this.ensureTimesheet(employee_id, week_start); if (!exists) await this.ensureTimesheet(employee_id.data, week_start);
} }
rows = await this.loadTimesheets(timesheet_range); rows = await this.loadTimesheets(timesheet_range);
//find user infos using the employee_id //find user infos using the employee_id
const employee = await this.prisma.employees.findUnique({ const employee = await this.prisma.employees.findUnique({
where: { id: employee_id }, where: { id: employee_id.data },
include: { user: true }, include: { user: true },
}); });
if (!employee) throw new NotFoundException(`Employee #${employee_id} not found`); if (!employee) return { success: false, error:`Employee #${employee_id} not found`}
//builds employee full name //builds employee full name
const user = employee.user; const user = employee.user;
const employee_fullname = `${user.first_name} ${user.last_name}`.trim(); const employee_fullname = `${user.first_name} ${user.last_name}`.trim();
//maps all timesheet's infos //maps all timesheet's infos
const timesheets = rows.map((timesheet) => this.mapOneTimesheet(timesheet)); const timesheets = await Promise.all(rows.map((timesheet) => this.mapOneTimesheet(timesheet)));
return { employee_fullname, timesheets };
}
return { success: true, data:{ employee_fullname, timesheets} };
} catch (error) {
return { success: false, error}
}
}
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
// MAPPERS & HELPERS // MAPPERS & HELPERS
@ -104,11 +127,14 @@ export class GetTimesheetsOverviewService {
const weekly_hours: TotalHours[] = [emptyHours()]; const weekly_hours: TotalHours[] = [emptyHours()];
const weekly_expenses: TotalExpenses[] = [emptyExpenses()]; const weekly_expenses: TotalExpenses[] = [emptyExpenses()];
//map of days //map of days
const days = day_dates.map((date) => { const days = day_dates.map((date) => {
const date_iso = toStringFromDate(date); const date_iso = toStringFromDate(date);
const shifts_source = shifts_by_date.get(date_iso) ?? []; const shifts_source = shifts_by_date.get(date_iso) ?? [];
const expenses_source = expenses_by_date.get(date_iso) ?? []; const expenses_source = expenses_by_date.get(date_iso) ?? [];
//inner map of shifts //inner map of shifts
const shifts = shifts_source.map((shift) => ({ const shifts = shifts_source.map((shift) => ({
timesheet_id: shift.timesheet_id, timesheet_id: shift.timesheet_id,
@ -132,6 +158,7 @@ export class GetTimesheetsOverviewService {
is_approved: expense.is_approved ?? false, is_approved: expense.is_approved ?? false,
comment: expense.comment ?? '', comment: expense.comment ?? '',
supervisor_comment: expense.supervisor_comment, supervisor_comment: expense.supervisor_comment,
type: expense.type,
})); }));
//daily totals //daily totals

View File

@ -39,7 +39,7 @@ export const toHHmmFromDate = (input: Date | string): string => {
return `${hh}:${mm}`; return `${hh}:${mm}`;
} }
//converts Date format to string //converts to Date format from string
export const toDateFromString = (ymd: string | Date): Date => { export const toDateFromString = (ymd: string | Date): Date => {
return new Date(ymd); return new Date(ymd);
} }

View File

@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client"; import { Prisma, PrismaClient } from "@prisma/client";
import { Result } from "src/common/errors/result-error.factory";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
type Tx = Prisma.TransactionClient | PrismaClient; type Tx = Prisma.TransactionClient | PrismaClient;
@ -10,35 +11,37 @@ export class BankCodesResolver {
//find id and modifier by type //find id and modifier by type
readonly findIdAndModifierByType = async (type: string, client?: Tx readonly findIdAndModifierByType = async (type: string, client?: Tx
): Promise<{id:number; modifier: number }> => { ): Promise<Result<{ id: number; modifier: number }, string>> => {
const db = client ?? this.prisma; const db = client ?? this.prisma;
const bank = await db.bankCodes.findFirst({ const bank = await db.bankCodes.findFirst({
where: { type }, where: { type },
select: { id: true, modifier: true }, select: { id: true, modifier: true },
}); });
if (!bank) return { success: false, error: `Unknown bank code type: ${type}` };
if(!bank) throw new NotFoundException(`Unknown bank code type: ${type}`); return { success: true, data: { id: bank.id, modifier: bank.modifier } };
return { id: bank.id, modifier: bank.modifier };
}; };
//finds only id by type //finds only id by type
readonly findBankCodeIDByType = async (type: string, client?: Tx) => { readonly findBankCodeIDByType = async (type: string, client?: Tx): Promise<Result<number, string>> => {
const db = client ?? this.prisma; const db = client ?? this.prisma;
const bank_code_id = await db.bankCodes.findFirst({ const bank_code = await db.bankCodes.findFirst({
where: { type }, where: { type },
select: { id: true }, select: { id: true },
}); });
if(!bank_code_id) throw new NotFoundException(`Unkown bank type: ${type}`); if (!bank_code) return { success: false, error:`Unkown bank type: ${type}`};
return bank_code_id;
return { success: true, data: bank_code.id};
} }
readonly findTypeByBankCodeId = async (bank_code_id: number, client?: Tx) => { readonly findTypeByBankCodeId = async (bank_code_id: number, client?: Tx): Promise<Result<string, string>> => {
const db = client ?? this.prisma; const db = client ?? this.prisma;
const type = await db.bankCodes.findFirst({ const bank_code = await db.bankCodes.findFirst({
where: { id: bank_code_id }, where: { id: bank_code_id },
select: { type: true }, select: { type: true },
}); });
if(!type) throw new NotFoundException(`Type with id : ${bank_code_id} not found`); if (!bank_code) return {success: false, error: `Type with id : ${bank_code_id} not found` }
return type;
return {success: true, data: bank_code.type};
} }
} }

View File

@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client"; import { Prisma, PrismaClient } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { Injectable } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
type Tx = Prisma.TransactionClient | PrismaClient; type Tx = Prisma.TransactionClient | PrismaClient;
@ -10,26 +11,25 @@ export class EmailToIdResolver {
constructor(private readonly prisma: PrismaService) { } constructor(private readonly prisma: PrismaService) { }
// find employee_id using email // find employee_id using email
readonly findIdByEmail = async ( email: string, client?: Tx readonly findIdByEmail = async (email: string, client?: Tx): Promise<Result<number, string>> => {
): Promise<number> => {
const db = client ?? this.prisma; const db = client ?? this.prisma;
const employee = await db.employees.findFirst({ const employee = await db.employees.findFirst({
where: { user: { email } }, where: { user: { email } },
select: { id: true }, select: { id: true },
}); });
if(!employee)throw new NotFoundException(`Employee with email: ${email} not found`); if (!employee) return { success: false, error: `Employee with email:${email} not found` };
return employee.id; return { data: employee.id, success: true };
} }
// find user_id using email // find user_id using email
readonly resolveUserIdWithEmail = async (email: string, client?: Tx readonly resolveUserIdWithEmail = async (email: string, client?: Tx
): Promise<string> => { ): Promise<Result<string, string>> => {
const db = client ?? this.prisma; const db = client ?? this.prisma;
const user = await db.users.findFirst({ const user = await db.users.findFirst({
where: { email }, where: { email },
select: { id: true }, select: { id: true },
}); });
if(!user) throw new NotFoundException(`User with email ${ email } not found`); if (!user) return { success: false, error: `User with email:${email} not found` };
return user.id; return { success: true, data: user.id };
} }
} }

View File

@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client"; import { Prisma, PrismaClient } from "@prisma/client";
import { Result } from "src/common/errors/result-error.factory";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
type Tx = Prisma.TransactionClient | PrismaClient; type Tx = Prisma.TransactionClient | PrismaClient;
@ -8,15 +9,15 @@ type Tx = Prisma.TransactionClient | PrismaClient;
export class FullNameResolver { export class FullNameResolver {
constructor(private readonly prisma: PrismaService){} constructor(private readonly prisma: PrismaService){}
readonly resolveFullName = async (employee_id: number, client?: Tx): Promise<string> =>{ readonly resolveFullName = async (employee_id: number, client?: Tx): Promise<Result<string, string>> =>{
const db = client ?? this.prisma; const db = client ?? this.prisma;
const employee = await db.employees.findUnique({ const employee = await db.employees.findUnique({
where: { id: employee_id }, where: { id: employee_id },
select: { user: { select: {first_name: true, last_name: true} } }, select: { user: { select: {first_name: true, last_name: true} } },
}); });
if(!employee) throw new NotFoundException(`Unknown user with name: ${employee_id}`) if(!employee) return { success: false, error: `Unknown user with id ${employee_id}`}
const full_name = ( employee.user.first_name + " " + employee.user.last_name ) || " "; const full_name = ( employee.user.first_name + " " + employee.user.last_name ) || " ";
return full_name; return {success: true, data: full_name };
} }
} }

View File

@ -1,14 +1,23 @@
import { Prisma, PrismaClient } from "@prisma/client"; import { Prisma, PrismaClient } from "@prisma/client";
import { NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ShiftKey } from "src/time-and-attendance/utils/type.utils"; import { Result } from "src/common/errors/result-error.factory";
type Tx = Prisma.TransactionClient | PrismaClient; type Tx = Prisma.TransactionClient | PrismaClient;
interface ShiftKey {
timesheet_id: number;
date: Date;
start_time: Date;
end_time: Date;
bank_code_id: number;
is_remote: boolean;
comment?: string | null;
}
export class ShiftIdResolver { export class ShiftIdResolver {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
readonly findShiftIdByData = async ( key: ShiftKey, client?: Tx ): Promise<{id:number}> => { readonly findShiftIdByData = async ( key: ShiftKey, client?: Tx ): Promise<Result<number, string>> => {
const db = client ?? this.prisma; const db = client ?? this.prisma;
const shift = await db.shifts.findFirst({ const shift = await db.shifts.findFirst({
where: { where: {
@ -23,7 +32,8 @@ export class ShiftIdResolver {
select: { id: true }, select: { id: true },
}); });
if(!shift) throw new NotFoundException(`shift not found`); if(!shift) return { success: false, error: `shift not found`}
return { id: shift.id };
return { success: true, data: shift.id };
}; };
} }

View File

@ -3,6 +3,7 @@ import { Prisma, PrismaClient } from "@prisma/client";
import { weekStartSunday } from "src/time-and-attendance/utils/date-time.utils"; import { weekStartSunday } from "src/time-and-attendance/utils/date-time.utils";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { EmailToIdResolver } from "./resolve-email-id.utils"; import { EmailToIdResolver } from "./resolve-email-id.utils";
import { Result } from "src/common/errors/result-error.factory";
type Tx = Prisma.TransactionClient | PrismaClient; type Tx = Prisma.TransactionClient | PrismaClient;
@ -14,15 +15,16 @@ export class EmployeeTimesheetResolver {
private readonly emailResolver: EmailToIdResolver, private readonly emailResolver: EmailToIdResolver,
) {} ) {}
readonly findTimesheetIdByEmail = async (email: string, date: Date, client?: Tx): Promise<{id: number}> => { readonly findTimesheetIdByEmail = async (email: string, date: Date, client?: Tx): Promise<Result<{id: number}, string>> => {
const db = client ?? this.prisma; const db = client ?? this.prisma;
const employee_id = await this.emailResolver.findIdByEmail(email); const employee_id = await this.emailResolver.findIdByEmail(email);
if(!employee_id.success) return { success: false, error: employee_id.error}
const start_date = weekStartSunday(date); const start_date = weekStartSunday(date);
const timesheet = await db.timesheets.findFirst({ const timesheet = await db.timesheets.findFirst({
where: { employee_id : employee_id, start_date: start_date }, where: { employee_id : employee_id.data, start_date: start_date },
select: { id: true }, select: { id: true },
}); });
if(!timesheet) throw new NotFoundException(`timesheet not found`); if(!timesheet) throw new NotFoundException(`timesheet not found`);
return { id: timesheet.id }; return { success: true, data: {id: timesheet.id} };
} }
} }

View File

@ -51,36 +51,6 @@ export const leaveRequestsSelect = {
}, },
} satisfies Prisma.LeaveRequestsSelect; } satisfies Prisma.LeaveRequestsSelect;
export const EXPENSE_SELECT = {
date: true,
amount: true,
mileage: true,
comment: true,
is_approved: true,
supervisor_comment: true,
bank_code: { select: { type: true } },
} as const;
export const EXPENSE_ASC_ORDER = { date: 'asc' as const };
export const PAY_PERIOD_SELECT = {
period_start: true,
period_end: true,
} as const;
export const SHIFT_SELECT = {
date: true,
start_time: true,
end_time: true,
comment: true,
is_approved: true,
is_remote: true,
bank_code: {select: { type: true } },
} as const;
export const SHIFT_ASC_ORDER = [{date: 'asc' as const}, {start_time: 'asc' as const}];
export const timesheet_select = { export const timesheet_select = {
id: true, id: true,
employee_id: true, employee_id: true,

View File

@ -1,47 +1,9 @@
import { Prisma, PrismaClient } from "@prisma/client";
import { GetExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-get.dto";
import { SchedulePresetsDto } from "src/time-and-attendance/time-tracker/schedule-presets/dtos/create-schedule-presets.dto";
import { GetShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-get.dto";
import { ShiftEntity } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-entity.dto";
import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils";
export type TotalHours = {
regular: number;
evening: number;
emergency: number;
overtime: number;
vacation: number;
holiday: number;
sick: number;
};
export type TotalExpenses = {
expenses: number;
per_diem: number;
on_call: number;
mileage: number;
};
export type Normalized = { export type Normalized = {
date: Date; date: Date;
start_time: Date; start_time: Date;
end_time: Date; end_time: Date;
bank_code_id: number; bank_code_id: number;
}; };
export type CreateShiftResult = { ok: true; data: GetShiftDto } | { ok: false; error: any };
export type UpdateShiftResult = { ok: true; id: number; data: GetShiftDto } | { ok: false; id: number; error: any };
export type NormedOk = {
index: number;
dto: ShiftEntity;
normed: Normalized;
timesheet_id: number;
};
export type DeletePresetResult = { ok: true; id: number; } | { ok: false; id: number; error: any };
export type CreatePresetResult = { ok: true; } | { ok: false; error: any };
export type UpdatePresetResult = { ok: true; id: number; data: SchedulePresetsDto } | { ok: false; id: number; error: any };
export type NormalizedExpense = { export type NormalizedExpense = {
date: Date; date: Date;
@ -51,9 +13,6 @@ export type NormalizedExpense = {
parsed_mileage?: number; parsed_mileage?: number;
parsed_attachment?: number; parsed_attachment?: number;
}; };
export type CreateExpenseResult = { ok: true; data: GetExpenseDto } | { ok: false; error: any };
export type DeleteExpenseResult = { ok: true; id: number; } | { ok: false; id: number; error: any };
export type ShiftResponse = { export type ShiftResponse = {
week_day: string; week_day: string;
@ -76,33 +35,3 @@ export type ApplyResult = {
created: number; created: number;
skipped: number; skipped: number;
} }
export type LeaveRequestRow = Prisma.LeaveRequestsGetPayload<{ select: typeof leaveRequestsSelect}>;
export type Tx = Prisma.TransactionClient | PrismaClient;
export type WeekOvertimeSummary = {
week_start:string;
week_end: string;
week_total_hours: number;
weekly_overtime: number;
daily_overtime_kept: number;
total_overtime: number;
breakdown: Array<{
date:string;
day_hours: number;
day_overtime: number;
daily_kept: number;
running_total_before: number;
}>;
};
export interface ShiftKey {
timesheet_id: number;
date: Date;
start_time: Date;
end_time: Date;
bank_code_id: number;
is_remote: boolean;
comment?: string | null;
}

View File

0
tsx
View File