feat(shifts): added a master function to create/update/delete a single shift
This commit is contained in:
parent
125f443ec0
commit
ef4f6340d2
|
|
@ -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,10 +18,21 @@ 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)
|
||||
@ApiOperation({ summary: 'Create shift' })
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
29
src/modules/shifts/dtos/upsert-shift.dto.ts
Normal file
29
src/modules/shifts/dtos/upsert-shift.dto.ts
Normal 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;
|
||||
};
|
||||
19
src/modules/shifts/helpers/shifts-date-time-helpers.ts
Normal file
19
src/modules/shifts/helpers/shifts-date-time-helpers.ts
Normal 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()));
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user