feat(shifts): added a master function to create/update/delete a single shift

This commit is contained in:
Matthieu Haineault 2025-09-11 15:22:57 -04:00
parent 125f443ec0
commit ef4f6340d2
4 changed files with 345 additions and 4 deletions

View File

@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { Shifts } from "@prisma/client";
import { CreateShiftDto } from "../dtos/create-shift.dto";
import { UpdateShiftsDto } from "../dtos/update-shift.dto";
@ -9,6 +9,7 @@ import { ShiftsCommandService } from "../services/shifts-command.service";
import { SearchShiftsDto } from "../dtos/search-shift.dto";
import { OverviewRow, ShiftsQueryService } from "../services/shifts-query.service";
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
@ApiTags('Shifts')
@ApiBearerAuth('access-token')
@ -17,9 +18,20 @@ import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
export class ShiftsController {
constructor(
private readonly shiftsService: ShiftsQueryService,
private readonly shiftsApprovalService: ShiftsCommandService,
private readonly shiftsCommandService: ShiftsCommandService,
private readonly shiftsValidationService: ShiftsQueryService,
){}
@Put('upsert/:email/:date')
async upsert_by_date(
@Param('email') email_param: string,
@Param('date') date_param: string,
@Body() payload: UpsertShiftDto,
) {
const email = decodeURIComponent(email_param);
const date = date_param;
return this.shiftsCommandService.upsertShfitsByDate(email, date, payload);
}
@Post()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ -70,7 +82,7 @@ export class ShiftsController {
@Patch('approval/:id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
return this.shiftsApprovalService.updateApproval(id, isApproved);
return this.shiftsCommandService.updateApproval(id, isApproved);
}
@Get('summary')

View File

@ -0,0 +1,29 @@
import { IsBoolean, IsOptional, IsString, Matches, MaxLength } from "class-validator";
export const COMMENT_MAX_LENGTH = 512;
export class ShiftPayloadDto {
@Matches(/^\d{2}:\d{2}$/, {message: 'start_time must be HH:mm' })
start_time!: string;
@Matches(/^\d{2}:\d{2}$/, {message: 'start_time must be HH:mm' })
end_time!: string;
@IsString()
type!: string;
@IsBoolean()
is_remote!: boolean;
@IsOptional()
@IsString()
@MaxLength(COMMENT_MAX_LENGTH)
comment?: string;
};
export class UpsertShiftDto {
old_shift?: ShiftPayloadDto;
new_shift?: ShiftPayloadDto;
};

View File

@ -0,0 +1,19 @@
export function timeFromHHMMUTC(hhmm: string): Date {
const [hour, min] = hhmm.split(':').map(Number);
return new Date(Date.UTC(1970,0,1,hour, min,0));
}
export function weekStartMondayUTC(date: Date): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const day = d.getUTCDay();
const diff = (day + 6) % 7;
d.setUTCDate(d.getUTCDate() - diff);
d.setUTCHours(0,0,0,0);
return d;
}
export function toDateOnlyUTC(input: string | Date): Date {
const date = new Date(input);
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}

View File

@ -1,12 +1,280 @@
import { Injectable } from "@nestjs/common";
import { BadRequestException, ConflictException, Injectable, NotFoundException, ParseUUIDPipe, UnprocessableEntityException } from "@nestjs/common";
import { Prisma, Shifts } from "@prisma/client";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.service";
import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { timeFromHHMMUTC, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers";
import { error, time } from "console";
type DayShiftResponse = {
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
comment: string | null;
}
type UpsertAction = 'created' | 'updated' | 'deleted';
@Injectable()
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
constructor(prisma: PrismaService) { super(prisma); }
//create/update/delete master method
async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto):
Promise<{ action: UpsertAction; day: DayShiftResponse[] }> {
if(!dto.old_shift && !dto.new_shift) {
throw new BadRequestException('At least one of old or new shift must be provided');
}
const date_only = toDateOnlyUTC(date_string);
//Resolve employee by email
const employee = await this.prisma.employees.findFirst({
where: { user: {email } },
select: { id: true },
});
if(!employee) throw new NotFoundException(`Employee not found for email : ${ email }`);
//making sure a timesheet exist in selected week
const start_of_week = weekStartMondayUTC(date_only);
let timesheet = await this.prisma.timesheets.findFirst({
where: {
employee_id: employee.id,
start_date: start_of_week
},
select: {
id: true
},
});
if(!timesheet) {
timesheet = await this.prisma.timesheets.create({
data: {
employee_id: employee.id,
start_date: start_of_week
},
select: {
id: true
},
});
}
//normalization of data to ensure a valid comparison between DB and payload
const old_norm = dto.old_shift
? this.normalize_shift_payload(dto.old_shift)
: undefined;
const new_norm = dto.new_shift
? this.normalize_shift_payload(dto.new_shift)
: undefined;
if (old_norm && old_norm.end_time.getTime() <= old_norm.start_time.getTime()) {
throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time');
}
if (new_norm && new_norm.end_time.getTime() <= new_norm.start_time.getTime()) {
throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time');
}
//Resolve bank_code_id with type
const old_bank_code_id = old_norm
? await this.lookup_bank_code_id_or_throw(old_norm.type)
: undefined;
const new_bank_code_id = new_norm
? await this.lookup_bank_code_id_or_throw(new_norm.type)
: undefined;
//fetch all shifts in a single day
const day_shifts = await this.prisma.shifts.findMany({
where: {
timesheet_id: timesheet.id,
date: date_only
},
include: {
bank_code: true
},
orderBy: {
start_time: 'asc'
},
});
const result = await this.prisma.$transaction(async (transaction)=> {
let action: UpsertAction;
const find_exact_old_shift = async ()=> {
if(!old_norm || old_bank_code_id === undefined) return undefined;
const old_comment = old_norm.comment ?? null;
return transaction.shifts.findFirst({
where: {
timesheet_id: timesheet.id,
date: date_only,
start_time: old_norm.start_time,
end_time: old_norm.end_time,
is_remote: old_norm.is_remote,
comment: old_comment,
bank_code_id: old_bank_code_id,
},
select: { id: true },
});
};
//checks for overlaping shifts
const assert_no_overlap = (exclude_shift_id?: number)=> {
if (!new_norm) return;
const overlap_with = day_shifts.filter((shift)=> {
if(exclude_shift_id && shift.id === exclude_shift_id) return false;
return this.overlaps(
new_norm.start_time.getTime(),
new_norm.end_time.getTime(),
shift.start_time.getTime(),
shift.end_time.getTime(),
);
});
if(overlap_with.length > 0) {
const conflicts = overlap_with.map((shift)=> ({
start_time: this.format_hhmm(shift.start_time),
end_time: this.format_hhmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN',
}));
throw new ConflictException({
error_code: 'SHIFT_OVERLAP',
message: 'New shift overlaps with existing shift(s)',
conflicts,
});
}
};
// DELETE
if ( dto.old_shift && !dto.new_shift ) {
const existing = await find_exact_old_shift();
if(!existing) {
throw new NotFoundException({
error_code: 'SHIFT_STALE',
message: 'The shift was modified or deleted by someone else',
});
}
await transaction.shifts.delete({ where: { id: existing.id } } );
action = 'deleted';
}
// CREATE
else if (!dto.old_shift && dto.new_shift) {
assert_no_overlap();
await transaction.shifts.create({
data: {
timesheet_id: timesheet.id,
date: date_only,
start_time: new_norm!.start_time,
end_time: new_norm!.end_time,
is_remote: new_norm!.is_remote,
comment: new_norm!.comment ?? null,
bank_code_id: new_bank_code_id!,
},
});
action = 'created';
}
//UPDATE
else {
const existing = await find_exact_old_shift();
if(!existing) {
throw new NotFoundException({
error_code: 'SHIFT_STALE',
message: 'The shift was modified or deleted by someone else',
});
}
assert_no_overlap(existing.id);
await transaction.shifts.update({
where: {
id: existing.id
},
data: {
start_time: new_norm!.start_time,
end_time: new_norm!.end_time,
is_remote: new_norm!.is_remote,
comment: new_norm!.comment ?? null,
bank_code_id: new_bank_code_id,
},
});
action = 'updated';
}
//Reload the day (truth source)
const fresh_day = await transaction.shifts.findMany({
where: {
timesheet_id: timesheet.id,
date: date_only,
},
include: {
bank_code: true
},
orderBy: {
start_time: 'asc'
},
});
return {
action,
day: fresh_day.map<DayShiftResponse>((shift)=> ({
start_time: this.format_hhmm(shift.start_time),
end_time: this.format_hhmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN',
is_remote: shift.is_remote,
comment: shift.comment ?? null,
})),
};
});
return result;
}
private normalize_shift_payload(payload: ShiftPayloadDto) {
//normalize shift's infos
const start_time = timeFromHHMMUTC(payload.start_time);
const end_time = timeFromHHMMUTC(payload.end_time );
const type = (payload.type || '').trim().toUpperCase();
const is_remote = payload.is_remote === true;
//normalize comment
const raw_comment = payload.comment ?? null;
const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null;
const comment = trimmed && trimmed.length > 0 ? trimmed: null;
return { start_time, end_time, type, is_remote, comment };
}
private async lookup_bank_code_id_or_throw(type: string): Promise<number> {
const bank = await this.prisma.bankCodes.findFirst({
where: { type },
select: { id: true },
});
if (!bank) {
throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` });
}
return bank.id;
}
private overlaps(
a_start_ms: number,
a_end_ms: number,
b_start_ms: number,
b_end_ms: number,
): boolean {
return a_start_ms < b_end_ms && b_start_ms < a_end_ms;
}
private format_hhmm(time: Date): string {
const hh = String(time.getUTCHours()).padStart(2,'0');
const mm = String(time.getUTCMinutes()).padStart(2,'0');
return `${hh}:${mm}`;
}
//approval methods
protected get delegate() {
return this.prisma.shifts;
}
@ -20,4 +288,17 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
this.updateApprovalWithTransaction(transaction, id, is_approved),
);
}
/*
old without new = delete
new without old = post
old with new = patch old with new
*/
async upsertShift(old_shift?: UpsertShiftDto, new_shift?: UpsertShiftDto) {
}
}