feat(schedule-presets): added a service that applies a preset to an existing timesheet. did some cleaning in utils and dtos

This commit is contained in:
Matthieu Haineault 2025-12-09 13:02:10 -05:00
parent 74e16d7960
commit b6132c8b35
18 changed files with 194 additions and 100 deletions

View File

@ -286,6 +286,30 @@
] ]
} }
}, },
"/schedule-presets/apply-preset": {
"post": {
"operationId": "SchedulePresetsController_applyPresetToTimesheet",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "number"
}
}
}
},
"responses": {
"201": {
"description": ""
}
},
"tags": [
"SchedulePresets"
]
}
},
"/expense/create": { "/expense/create": {
"post": { "post": {
"operationId": "ExpenseController_create", "operationId": "ExpenseController_create",

View File

@ -167,13 +167,12 @@ model SchedulePresetShifts {
bank_code BankCodes @relation("SchedulePresetShiftsBankCodes", fields: [bank_code_id], references: [id]) bank_code BankCodes @relation("SchedulePresetShiftsBankCodes", fields: [bank_code_id], references: [id])
bank_code_id Int bank_code_id Int
sort_order Int
start_time DateTime @db.Time(0) start_time DateTime @db.Time(0)
end_time DateTime @db.Time(0) end_time DateTime @db.Time(0)
is_remote Boolean @default(false) is_remote Boolean @default(false)
week_day Weekday week_day Weekday
@@unique([preset_id, week_day, sort_order], name: "unique_preset_shift_per_day_order") @@unique([preset_id, week_day], name: "unique_preset_shift_per_day")
@@index([preset_id, week_day]) @@index([preset_id, week_day])
@@map("schedule_preset_shifts") @@map("schedule_preset_shifts")
} }

View File

@ -1,3 +1,6 @@
import { Weekday } from "@prisma/client";
import { ANCHOR_ISO, MS_PER_DAY, PERIODS_PER_YEAR, PERIOD_DAYS } from "src/common/utils/constants.utils";
import { WEEKDAY_MAP } from "src/time-and-attendance/schedule-presets/schedule-presets.dto";
//lenght of a shift, rouded to nearest 'x' minute //lenght of a shift, rouded to nearest 'x' minute
export function computeHours(start: Date, end: Date, roundToMinutes?: number): number { export function computeHours(start: Date, end: Date, roundToMinutes?: number): number {
@ -51,9 +54,6 @@ export function hoursBetweenSameDay(day: Date, startTime: Date, endTime: Date):
return ms / 3_600_000; // decimal hours return ms / 3_600_000; // decimal hours
} }
import { BadRequestException } from "@nestjs/common";
import { ANCHOR_ISO, MS_PER_DAY, PERIODS_PER_YEAR, PERIOD_DAYS } from "src/common/utils/constants.utils";
//ensures the week starts from sunday //ensures the week starts from sunday
export function weekStartSunday(date_local: Date): Date { export function weekStartSunday(date_local: Date): Date {
const start_date = new Date(); const start_date = new Date();
@ -140,39 +140,10 @@ export function listPayYear(pay_year: number, anchorISO = ANCHOR_ISO) {
return Array.from({ length: PERIODS_PER_YEAR }, (_, i) => computePeriod(pay_year, i + 1, anchorISO)); return Array.from({ length: PERIODS_PER_YEAR }, (_, i) => computePeriod(pay_year, i + 1, anchorISO));
} }
//checks for shifts overlaping one another
export const overlaps = (a: { start: Date; end: Date, date?: Date; }, b: { start: Date; end: Date; date?: Date; }) => export const overlaps = (a: { start: Date; end: Date, date?: Date; }, b: { start: Date; end: Date; date?: Date; }) =>
((a.date?.getTime() === b.date?.getTime()) && !(a.end <= b.start || a.start >= b.end)); ((a.date?.getTime() === b.date?.getTime()) && !(a.end <= b.start || a.start >= b.end));
export const is_same_week_day = (date: Date, week_day: Weekday): boolean => {
export const hhmmFromLocal = (d: Date) => return date.getUTCDay() !== WEEKDAY_MAP[week_day];
`${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
export const toDateOnly = (s: string): Date => {
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
const y = Number(s.slice(0,4));
const m = Number(s.slice(5,7)) - 1;
const d = Number(s.slice(8,10));
return new Date(y, m, d, 0, 0, 0, 0);
} }
const dt = new Date(s);
if (Number.isNaN(dt.getTime())) throw new BadRequestException(`Invalid date: ${s}`);
return new Date(dt.getFullYear(), dt.getMonth(), dt.getDate(), 0,0,0,0);
};
// export const toStringFromDate = (d: Date) =>
// `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
export const toISOtoDateOnly = (iso: string): Date => {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException(`Invalid date: ${iso}`);
}
date.setHours(0, 0, 0, 0);
return date;
};
export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10);
export const normalizeDates = (dates: string[]): string[] =>
Array.from(new Set(dates.map((iso) => toISODateKey(toISOtoDateOnly(iso)))));

View File

@ -1,13 +0,0 @@
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Matches, Min } from "class-validator";
import { HH_MM_REGEX } from "src/common/utils/constants.utils";
import { Weekday } from "@prisma/client";
export class SchedulePresetShiftsDto {
@IsInt() preset_id!: number;
@IsEnum(Weekday) week_day!: Weekday;
@IsInt() @Min(1) sort_order!: number;
@IsString() type!: string;
@IsString() @Matches(HH_MM_REGEX) start_time!: string;
@IsString() @Matches(HH_MM_REGEX) end_time!: string;
@IsOptional() @IsBoolean() is_remote?: boolean;
}

View File

@ -1,10 +0,0 @@
import { ArrayMinSize, IsArray, IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
import { SchedulePresetShiftsDto } from "src/time-and-attendance/schedule-presets/dtos/create-schedule-preset-shifts.dto";
export class SchedulePresetsDto {
@IsInt() id!: number;
@IsString() name!: string;
@IsBoolean() @IsOptional() is_default: boolean;
@IsArray() @ArrayMinSize(1) preset_shifts: SchedulePresetShiftsDto[];
}

View File

@ -4,10 +4,12 @@ import { SchedulePresetsCreateService } from "src/time-and-attendance/schedule-p
import { SchedulePresetUpdateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-update.service"; import { SchedulePresetUpdateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-update.service";
import { SchedulePresetDeleteService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-delete.service"; import { SchedulePresetDeleteService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-delete.service";
import { SchedulePresetsGetService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-get.service"; import { SchedulePresetsGetService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-get.service";
import { SchedulePresetsDto } from "src/time-and-attendance/schedule-presets/dtos/create-schedule-presets.dto"; import { SchedulePresetsDto } from "src/time-and-attendance/schedule-presets/schedule-presets.dto";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators"; import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client"; import { Modules as ModulesEnum } from ".prisma/client";
import { Access } from "src/common/decorators/module-access.decorators";
import { SchedulePresetsApplyService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-apply.service";
@Controller('schedule-presets') @Controller('schedule-presets')
export class SchedulePresetsController { export class SchedulePresetsController {
@ -16,6 +18,7 @@ export class SchedulePresetsController {
private readonly createService: SchedulePresetsCreateService, private readonly createService: SchedulePresetsCreateService,
private readonly updateService: SchedulePresetUpdateService, private readonly updateService: SchedulePresetUpdateService,
private readonly deleteService: SchedulePresetDeleteService, private readonly deleteService: SchedulePresetDeleteService,
private readonly applyService: SchedulePresetsApplyService,
) { } ) { }
@Get('find-list') @Get('find-list')
@ -43,4 +46,12 @@ export class SchedulePresetsController {
@Param('id', ParseIntPipe) id: number) { @Param('id', ParseIntPipe) id: number) {
return await this.deleteService.deletePreset(id); return await this.deleteService.deletePreset(id);
} }
@Post('apply-preset')
@ModuleAccessAllowed(ModulesEnum.timesheets)
async applyPresetToTimesheet(
@Access('email') email: string, @Body() timesheet_id: number,
) {
return await this.applyService.ApplyPresetToTimesheet(email, timesheet_id);
}
} }

View File

@ -0,0 +1,29 @@
import { Weekday } from "@prisma/client";
import { ArrayMinSize, IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, Matches, Min } from "class-validator";
import { HH_MM_REGEX } from "src/common/utils/constants.utils";
export class SchedulePresetsDto {
@IsInt() id!: number;
@IsString() name!: string;
@IsBoolean() @IsOptional() is_default: boolean;
@IsArray() @ArrayMinSize(1) preset_shifts: SchedulePresetShiftsDto[];
}
export class SchedulePresetShiftsDto {
@IsInt() preset_id!: number;
@IsEnum(Weekday) week_day!: Weekday;
@IsString() type!: string;
@IsString() @Matches(HH_MM_REGEX) start_time!: string;
@IsString() @Matches(HH_MM_REGEX) end_time!: string;
@IsOptional() @IsBoolean() is_remote?: boolean;
}
export const WEEKDAY_MAP: Record<Weekday, number> = {
SUN: 0,
MON: 1,
TUE: 2,
WED: 3,
THU: 4,
FRI: 5,
SAT: 6,
};

View File

@ -7,6 +7,9 @@ import { SchedulePresetsGetService } from "src/time-and-attendance/schedule-pres
import { SchedulePresetsCreateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-create.service"; import { SchedulePresetsCreateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-create.service";
import { SchedulePresetUpdateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-update.service"; import { SchedulePresetUpdateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-update.service";
import { SchedulePresetDeleteService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-delete.service"; import { SchedulePresetDeleteService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-delete.service";
import { SchedulePresetsApplyService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-apply.service";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { ShiftsCreateService } from "src/time-and-attendance/shifts/services/shifts-create.service";
@ -17,6 +20,9 @@ import { SchedulePresetDeleteService } from "src/time-and-attendance/schedule-pr
SchedulePresetsCreateService, SchedulePresetsCreateService,
SchedulePresetUpdateService, SchedulePresetUpdateService,
SchedulePresetDeleteService, SchedulePresetDeleteService,
SchedulePresetsApplyService,
EmailToIdResolver,
ShiftsCreateService,
BankCodesResolver, BankCodesResolver,
], ],
exports: [ exports: [

View File

@ -0,0 +1,87 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { is_same_week_day, sevenDaysFrom, toStringFromDate } from "src/common/utils/date-utils";
import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { Result } from "src/common/errors/result-error.factory";
import { ShiftsCreateService } from "src/time-and-attendance/shifts/services/shifts-create.service";
import { timesheet_select } from "src/time-and-attendance/utils/selects.utils";
import { ShiftDto } from "src/time-and-attendance/shifts/shift.dto";
@Injectable()
export class SchedulePresetsApplyService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly shiftService: ShiftsCreateService,
private readonly typeResolver: BankCodesResolver,
) { }
async ApplyPresetToTimesheet(email: string, timesheet_id: number): Promise<Result<boolean, string>> {
const employee_id = await this.emailResolver.findIdByEmail(email);
if (!employee_id.success) return { success: false, error: 'EMPLOYEE_NOT_FOUND' };
const employee_default_schedule_preset = await this.prisma.employees.findFirst({
where: { id: employee_id.data },
select: {
schedule_preset: {
select: {
id: true,
is_default: true,
shifts: true,
},
},
},
});
if (!employee_default_schedule_preset) return { success: false, error: 'EMPLOYEE_NOT_FOUND' };
if (!employee_default_schedule_preset.schedule_preset) return { success: false, error: 'SCHEDULE_PRESET_NOT_FOUND' };
const default_preset_shifts = await this.prisma.schedulePresetShifts.findMany({
where: { preset_id: employee_default_schedule_preset.schedule_preset.id },
select: {
bank_code_id: true,
start_time: true,
end_time: true,
is_remote: true,
week_day: true,
},
});
if (default_preset_shifts.length <= 0) return { success: false, error: 'INVALID_SCHEDULE_PRESET' };
const timesheet = await this.prisma.timesheets.findUnique({
where: { id: timesheet_id },
select: timesheet_select,
});
if (!timesheet) return { success: false, error: 'TIMESHEET_NOT_FOUND' };
if (timesheet.is_approved) return { success: false, error: 'INVALID_TIMESHEET' };
if (timesheet.shift.length > 0) return { success: false, error: 'INVALID_TIMESHEET' };
const dated_map = await sevenDaysFrom(timesheet.start_date);
for (const date of dated_map) {
for (const preset_shift of default_preset_shifts) {
if (!is_same_week_day(date, preset_shift.week_day)) continue;
const type = await this.typeResolver.findTypeByBankCodeId(preset_shift.bank_code_id);
if (!type.success) return { success: false, error: 'INVALID_PRESET_SHIFT' };
const shift: ShiftDto = {
timesheet_id: timesheet.id,
type: type.data,
date: toStringFromDate(date),
start_time: toStringFromDate(preset_shift.start_time),
end_time: toStringFromDate(preset_shift.end_time),
is_approved: false,
is_remote: preset_shift.is_remote,
};
await this.shiftService.createShift(employee_id.data, shift);
}
}
return { success: true, data: true };
}
}

View File

@ -2,7 +2,7 @@ import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { SchedulePresetsDto } from "src/time-and-attendance/schedule-presets/dtos/create-schedule-presets.dto"; import { SchedulePresetsDto } from "src/time-and-attendance/schedule-presets/schedule-presets.dto";
import { overlaps, toDateFromHHmm } from "src/common/utils/date-utils"; import { overlaps, toDateFromHHmm } from "src/common/utils/date-utils";
import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper"; import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper";
@ -73,7 +73,6 @@ export class SchedulePresetsCreateService {
const result = bank_code_results[index] as { success: true, data: number }; const result = bank_code_results[index] as { success: true, data: number };
return { return {
week_day: shift.week_day, week_day: shift.week_day,
sort_order: shift.sort_order,
start_time: toDateFromHHmm(shift.start_time), start_time: toDateFromHHmm(shift.start_time),
end_time: toDateFromHHmm(shift.end_time), end_time: toDateFromHHmm(shift.end_time),
is_remote: shift.is_remote ?? false, is_remote: shift.is_remote ?? false,

View File

@ -18,7 +18,7 @@ export class SchedulePresetsGetService {
orderBy: [{ is_default: 'desc' }, { name: 'asc' }], orderBy: [{ is_default: 'desc' }, { name: 'asc' }],
include: { include: {
shifts: { shifts: {
orderBy: [{ week_day: 'asc' }, { sort_order: 'asc' }], orderBy: [{ week_day: 'asc' }],
include: { bank_code: { select: { type: true } } }, include: { bank_code: { select: { type: true } } },
}, },
}, },
@ -31,7 +31,6 @@ export class SchedulePresetsGetService {
is_default: preset.is_default, is_default: preset.is_default,
shifts: preset.shifts.map<ShiftResponse>((shift) => ({ shifts: preset.shifts.map<ShiftResponse>((shift) => ({
week_day: shift.week_day, week_day: shift.week_day,
sort_order: shift.sort_order,
start_time: hhmm(shift.start_time), start_time: hhmm(shift.start_time),
end_time: hhmm(shift.end_time), end_time: hhmm(shift.end_time),
is_remote: shift.is_remote, is_remote: shift.is_remote,

View File

@ -1,7 +1,7 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { SchedulePresetsDto } from "src/time-and-attendance/schedule-presets/dtos/create-schedule-presets.dto"; import { SchedulePresetsDto } from "src/time-and-attendance/schedule-presets/schedule-presets.dto";
import { overlaps, toDateFromHHmm } from "src/common/utils/date-utils"; import { overlaps, toDateFromHHmm } from "src/common/utils/date-utils";
import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper"; import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper";
@ -73,7 +73,6 @@ export class SchedulePresetUpdateService {
const result = bank_code_results[index] as { success: true, data: number }; const result = bank_code_results[index] as { success: true, data: number };
return { return {
week_day: shift.week_day, week_day: shift.week_day,
sort_order: shift.sort_order,
start_time: toDateFromHHmm(shift.start_time), start_time: toDateFromHHmm(shift.start_time),
end_time: toDateFromHHmm(shift.end_time), end_time: toDateFromHHmm(shift.end_time),
is_remote: shift.is_remote ?? false, is_remote: shift.is_remote ?? false,

View File

@ -6,7 +6,7 @@ import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { Result } from "src/common/errors/result-error.factory"; import { Result } from "src/common/errors/result-error.factory";
import { toStringFromHHmm, toStringFromDate, toDateFromString, overlaps, toDateFromHHmm } from "src/common/utils/date-utils"; import { toStringFromHHmm, toStringFromDate, toDateFromString, overlaps, toDateFromHHmm } from "src/common/utils/date-utils";
import { ShiftDto } from "src/time-and-attendance/shifts/shift-create.dto"; import { ShiftDto } from "src/time-and-attendance/shifts/shift.dto";
@Injectable() @Injectable()
export class ShiftsCreateService { export class ShiftsCreateService {

View File

@ -8,7 +8,7 @@ import { Result } from "src/common/errors/result-error.factory";
import { shift_select } from "src/time-and-attendance/utils/selects.utils"; import { shift_select } from "src/time-and-attendance/utils/selects.utils";
import { Normalized } from "src/time-and-attendance/utils/type.utils"; import { Normalized } from "src/time-and-attendance/utils/type.utils";
import { ShiftDto } from "src/time-and-attendance/shifts/shift-create.dto"; import { ShiftDto } from "src/time-and-attendance/shifts/shift.dto";
@Injectable() @Injectable()
export class ShiftsUpdateService { export class ShiftsUpdateService {

View File

@ -1,7 +1,7 @@
import { Body, Controller, Delete, Param, Patch, Post } from "@nestjs/common"; import { Body, Controller, Delete, Param, Patch, Post } from "@nestjs/common";
import { Modules as ModulesEnum } from ".prisma/client"; import { Modules as ModulesEnum } from ".prisma/client";
import { ShiftDto } from "src/time-and-attendance/shifts/shift-create.dto"; import { ShiftDto } from "src/time-and-attendance/shifts/shift.dto";
import { ShiftsCreateService } from "src/time-and-attendance/shifts/services/shifts-create.service"; import { ShiftsCreateService } from "src/time-and-attendance/shifts/services/shifts-create.service";
import { ShiftsUpdateService } from "src/time-and-attendance/shifts/services/shifts-update-delete.service"; import { ShiftsUpdateService } from "src/time-and-attendance/shifts/services/shifts-update-delete.service";
import { ShiftsDeleteService } from "src/time-and-attendance/shifts/services/shifts-delete.service"; import { ShiftsDeleteService } from "src/time-and-attendance/shifts/services/shifts-delete.service";

View File

@ -1,16 +1,13 @@
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator"; import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
export class ShiftDto { export class ShiftDto {
@IsInt() @IsOptional() id: number; @IsInt() @IsOptional() id?: number;
@IsInt() timesheet_id!: number; @IsInt() timesheet_id!: number;
@IsString() type!: string; @IsString() type!: string;
@IsString() date!: string; @IsString() date!: string;
@IsString() start_time!: string; @IsString() start_time!: string;
@IsString() end_time!: string; @IsString() end_time!: string;
@IsBoolean() is_approved!: boolean; @IsBoolean() is_approved!: boolean;
@IsBoolean() is_remote!: boolean; @IsBoolean() is_remote!: boolean;
@IsOptional() @IsString() @MaxLength(280) comment?: string; @IsOptional() @IsString() @MaxLength(280) comment?: string;
} }

View File

@ -36,6 +36,7 @@ import { SchedulePresetsModule } from "src/time-and-attendance/schedule-presets/
import { SchedulePresetDeleteService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-delete.service"; import { SchedulePresetDeleteService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-delete.service";
import { SchedulePresetUpdateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-update.service"; import { SchedulePresetUpdateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-update.service";
import { SchedulePresetsCreateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-create.service"; import { SchedulePresetsCreateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-create.service";
import { SchedulePresetsApplyService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-apply.service";
@Module({ @Module({
imports: [ imports: [
@ -65,6 +66,7 @@ import { SchedulePresetsCreateService } from "src/time-and-attendance/schedule-p
ExpenseUpsertService, ExpenseUpsertService,
SchedulePresetsGetService, SchedulePresetsGetService,
SchedulePresetDeleteService, SchedulePresetDeleteService,
SchedulePresetsApplyService,
SchedulePresetUpdateService, SchedulePresetUpdateService,
SchedulePresetsCreateService, SchedulePresetsCreateService,
EmailToIdResolver, EmailToIdResolver,

View File

@ -26,7 +26,6 @@ export type NormalizedLeaveRequest = {
export type ShiftResponse = { export type ShiftResponse = {
week_day: string; week_day: string;
sort_order: number;
start_time: string; start_time: string;
end_time: string; end_time: string;
is_remote: boolean; is_remote: boolean;
@ -40,8 +39,3 @@ export type PresetResponse = {
shifts: ShiftResponse[]; shifts: ShiftResponse[];
} }
export type ApplyResult = {
timesheet_id: number;
created: number;
skipped: number;
}