refactor(shifts): refactor main upsert function to use shared utils and helpers

This commit is contained in:
Matthieu Haineault 2025-10-08 08:54:43 -04:00
parent cc310e286d
commit f6c5b2a73c
24 changed files with 312 additions and 438 deletions

View File

@ -42,8 +42,6 @@ export class EmployeesService {
);
}
async findOneProfile(email: string): Promise<EmployeeProfileItemDto> {
const emp = await this.prisma.employees.findFirst({
where: { user: { email } },

View File

@ -3,28 +3,20 @@ import { Module } from "@nestjs/common";
import { ExpensesQueryService } from "./services/expenses-query.service";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
import { ExpensesCommandService } from "./services/expenses-command.service";
import { BankCodesRepo } from "./repos/bank-codes.repo";
import { TimesheetsRepo } from "./repos/timesheets.repo";
import { EmployeesRepo } from "./repos/employee.repo";
import { ExpensesArchivalService } from "./services/expenses-archival.service";
import { SharedModule } from "../shared/shared.module";
@Module({
imports: [BusinessLogicsModule],
imports: [BusinessLogicsModule, SharedModule],
controllers: [ExpensesController],
providers: [
ExpensesQueryService,
ExpensesArchivalService,
ExpensesCommandService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,
],
exports: [
ExpensesQueryService,
ExpensesArchivalService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,
],
})

View File

@ -2,16 +2,16 @@ import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { Expenses, Prisma } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { BankCodesRepo } from "../repos/bank-codes.repo";
import { TimesheetsRepo } from "../repos/timesheets.repo";
import { EmployeesRepo } from "../repos/employee.repo";
import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils";
import {
BadRequestException,
Injectable,
NotFoundException
} from "@nestjs/common";
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
import {
assertAndTrimComment,
computeAmountDecimal,
@ -25,9 +25,9 @@ import {
export class ExpensesCommandService extends BaseApprovalService<Expenses> {
constructor(
prisma: PrismaService,
private readonly bankCodesRepo: BankCodesRepo,
private readonly timesheetsRepo: TimesheetsRepo,
private readonly employeesRepo: EmployeesRepo,
private readonly bankCodesResolver: BankCodesResolver,
private readonly timesheetsResolver: EmployeeTimesheetResolver,
private readonly emailResolver: EmployeeIdEmailResolver,
) { super(prisma); }
//_____________________________________________________________________________________________
@ -56,27 +56,25 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
//validates if there is an existing expense, at least 1 old or new
const { old_expense, new_expense } = dto ?? {};
if(!old_expense && !new_expense) {
throw new BadRequestException('At least one expense must be provided');
}
if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided');
//validate date format
const date_only = toDateOnlyUTC(date);
if(Number.isNaN(date_only.getTime())) {
throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
}
if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
//resolve employee_id by email
const employee_id = await this.resolveEmployeeIdByEmail(email);
const employee_id = await this.emailResolver.findIdByEmail(email);
//make sure a timesheet existes
const timesheet_id = await this.ensureTimesheetForDate(employee_id, date_only);
const timesheet_id = await this.timesheetsResolver.ensureForDate(employee_id, date_only);
if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`)
const {id} = timesheet_id;
return this.prisma.$transaction(async (tx) => {
const loadDay = async (): Promise<ExpenseResponse[]> => {
const rows = await tx.expenses.findMany({
where: {
timesheet_id: timesheet_id,
timesheet_id: id,
date: date_only,
},
include: {
@ -118,7 +116,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
const comment = assertAndTrimComment(payload.comment);
const attachment = parseAttachmentId(payload.attachment);
const { id: bank_code_id, modifier } = await this.resolveBankCodeIdByType(tx, type);
const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type);
let amount = computeAmountDecimal(type, payload, modifier);
let mileage: number | null = null;
@ -139,11 +137,11 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
}
if (attachment !== null) {
const attachmentRow = await tx.attachments.findUnique({
const attachment_row = await tx.attachments.findUnique({
where: { id: attachment },
select: { status: true },
});
if (!attachmentRow || attachmentRow.status !== 'ACTIVE') {
if (!attachment_row || attachment_row.status !== 'ACTIVE') {
throw new BadRequestException('Attachment not found or inactive');
}
}
@ -167,7 +165,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
}) => {
return tx.expenses.findFirst({
where: {
timesheet_id: timesheet_id,
timesheet_id: id,
date: date_only,
bank_code_id: norm.bank_code_id,
amount: norm.amount,
@ -184,8 +182,8 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
// DELETE
//_____________________________________________________________________________________________
if(old_expense && !new_expense) {
const oldNorm = await normalizePayload(old_expense);
const existing = await findExactOld(oldNorm);
const old_norm = await normalizePayload(old_expense);
const existing = await findExactOld(old_norm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
@ -202,7 +200,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
const new_exp = await normalizePayload(new_expense);
await tx.expenses.create({
data: {
timesheet_id: timesheet_id,
timesheet_id: id,
date: date_only,
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
@ -218,8 +216,8 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
// UPDATE
//_____________________________________________________________________________________________
else if(old_expense && new_expense) {
const oldNorm = await normalizePayload(old_expense);
const existing = await findExactOld(oldNorm);
const old_norm = await normalizePayload(old_expense);
const existing = await findExactOld(old_norm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
@ -249,22 +247,4 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
return { action, day };
});
}
//_____________________________________________________________________________________________
// HELPERS
//_____________________________________________________________________________________________
private readonly resolveEmployeeIdByEmail = async (email: string): Promise<number> =>
this.employeesRepo.findIdByEmail(email);
private readonly ensureTimesheetForDate = async ( employee_id: number, date: Date
): Promise<number> => {
const { id } = await this.timesheetsRepo.ensureForDate(employee_id, date);
return id;
};
private readonly resolveBankCodeIdByType = async ( transaction: Prisma.TransactionClient, type: string
): Promise<{id: number; modifier: number}> =>
this.bankCodesRepo.findByType(type, transaction);
}

View File

@ -1,15 +1,15 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { DayExpensesDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
import { EmployeesRepo } from "../repos/employee.repo";
import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers";
import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types";
import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils";
@Injectable()
export class ExpensesQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly employeeRepo: EmployeesRepo,
private readonly employeeRepo: EmployeeIdEmailResolver,
) {}

View File

@ -7,21 +7,16 @@ import { TimesheetsModule } from "../timesheets/timesheets.module";
import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service";
import { ExpensesCommandService } from "../expenses/services/expenses-command.service";
import { ShiftsCommandService } from "../shifts/services/shifts-command.service";
import { BankCodesRepo } from "../expenses/repos/bank-codes.repo";
import { EmployeesRepo } from "../expenses/repos/employee.repo";
import { TimesheetsRepo } from "../expenses/repos/timesheets.repo";
import { SharedModule } from "../shared/shared.module";
@Module({
imports: [PrismaModule, TimesheetsModule],
imports: [PrismaModule, TimesheetsModule, SharedModule],
providers: [
PayPeriodsQueryService,
PayPeriodsCommandService,
TimesheetsCommandService,
ExpensesCommandService,
ShiftsCommandService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,
],
controllers: [PayPeriodsController],
exports: [

View File

@ -0,0 +1,22 @@
import { Module } from "@nestjs/common";
import { EmployeeIdEmailResolver } from "./utils/resolve-email-id.utils";
import { EmployeeTimesheetResolver } from "./utils/resolve-employee-timesheet.utils";
import { FullNameResolver } from "./utils/resolve-full-name.utils";
import { BankCodesResolver } from "./utils/resolve-bank-type-id.utils";
import { PrismaModule } from "src/prisma/prisma.module";
@Module({
imports: [PrismaModule],
providers: [
FullNameResolver,
EmployeeIdEmailResolver,
BankCodesResolver,
EmployeeTimesheetResolver,
],
exports: [
FullNameResolver,
EmployeeIdEmailResolver,
BankCodesResolver,
EmployeeTimesheetResolver,
],
}) export class SharedModule {}

View File

@ -2,11 +2,10 @@ import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
type Tx = Prisma.TransactionClient | PrismaClient;
@Injectable()
export class BankCodesRepo {
export class BankCodesResolver {
constructor(private readonly prisma: PrismaService) {}
//find id and modifier by type
@ -14,21 +13,11 @@ export class BankCodesRepo {
): Promise<{id:number; modifier: number }> => {
const db = client ?? this.prisma;
const bank = await db.bankCodes.findFirst({
where: {
type,
},
select: {
id: true,
modifier: true,
},
where: { type },
select: { id: true, modifier: true },
});
if(!bank) {
throw new NotFoundException(`Unknown bank code type: ${type}`);
}
return {
id: bank.id,
modifier: bank.modifier,
};
if(!bank) throw new NotFoundException(`Unknown bank code type: ${type}`);
return { id: bank.id, modifier: bank.modifier };
};
}

View File

@ -2,31 +2,22 @@ import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
type Tx = Prisma.TransactionClient | PrismaClient;
@Injectable()
export class EmployeesRepo {
export class EmployeeIdEmailResolver {
constructor(private readonly prisma: PrismaService) {}
// find employee id by email
// find employee_id using email
readonly findIdByEmail = async ( email: string, client?: Tx
): Promise<number> => {
const db = client ?? this.prisma;
const employee = await db.employees.findFirst({
where: {
user: {
email,
},
},
select: {
id: true,
},
where: { user: { email } },
select: { id: true },
});
if(!employee) {
throw new NotFoundException(`Employee with email: ${email} not found`);
}
if(!employee)throw new NotFoundException(`Employee with email: ${email} not found`);
return employee.id;
}
}

View File

@ -7,7 +7,7 @@ import { PrismaService } from "src/prisma/prisma.service";
type Tx = Prisma.TransactionClient | PrismaClient;
@Injectable()
export class TimesheetsRepo {
export class EmployeeTimesheetResolver {
constructor(private readonly prisma: PrismaService) {}
//find an existing timesheet linked to the employee

View File

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

View File

@ -6,7 +6,7 @@ import { ShiftsCommandService } from "../services/shifts-command.service";
import { ShiftsQueryService } from "../services/shifts-query.service";
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { OverviewRow } from "../types and interfaces/shifts-overview-row.interface";
import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
@ApiTags('Shifts')
@ApiBearerAuth('access-token')

View File

@ -3,13 +3,11 @@ export function timeFromHHMMUTC(hhmm: string): Date {
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()));
export function weekStartSundayUTC(d: Date): Date {
const day = d.getUTCDay();
const diff = (day + 6) % 7;
d.setUTCDate(d.getUTCDate() - diff);
d.setUTCHours(0,0,0,0);
return d;
const start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
start.setUTCDate(start.getUTCDate()- day);
return start;
}
export function toDateOnlyUTC(input: string | Date): Date {

View File

@ -1,7 +1,9 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common";
import { formatHHmm, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers";
import { normalizeShiftPayload, overlaps, resolveBankCodeByType } from "../utils/shifts.utils";
import { DayShiftResponse, UpsertAction } from "../types and interfaces/shifts-upsert.types";
import { formatHHmm, toDateOnlyUTC, weekStartSundayUTC } from "../helpers/shifts-date-time-helpers";
import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils";
import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types";
import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { Prisma, Shifts } from "@prisma/client";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
@ -9,7 +11,11 @@ import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
constructor(prisma: PrismaService) { super(prisma); }
constructor(
prisma: PrismaService,
private readonly emailResolver: EmployeeIdEmailResolver,
private readonly bankTypeResolver: BankCodesResolver,
) { super(prisma); }
//_____________________________________________________________________________________________
// APPROVAL AND DELEGATE METHODS
@ -40,88 +46,53 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
}
const date_only = toDateOnlyUTC(date_string);
const employee_id = await this.emailResolver.findIdByEmail(email);
//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 }`);
return this.prisma.$transaction(async (tx) => {
const start_of_week = weekStartSundayUTC(date_only);
//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
},
const timesheet = await tx.timesheets.upsert({
where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
update: {},
create: { 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
? normalizeShiftPayload(dto.old_shift)
: undefined;
const new_norm = dto.new_shift
? normalizeShiftPayload(dto.new_shift)
: undefined;
//validation/sanitation
//resolve bank_code_id using type
const old_norm_shift = old_shift ? await normalizeShiftPayload(old_shift) : undefined;
if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) {
throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time');
}
const old_bank_code_id: number | undefined = old_norm_shift ? (await this.bankTypeResolver.findByType(old_norm_shift.type, tx))?.id : 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 resolveBankCodeByType(old_norm.type)
: undefined;
const new_bank_code_id = new_norm
? await resolveBankCodeByType(new_norm.type)
: undefined;
const new_norm_shift = new_shift ? await normalizeShiftPayload(new_shift) : undefined;
if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) {
throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time');
}
const new_bank_code_id: number | undefined = new_norm_shift ? (await this.bankTypeResolver.findByType(new_norm_shift.type, tx))?.id : 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;
//fetch all shifts in a single day and verify possible overlaps
const day_shifts = await tx.shifts.findMany({
where: { timesheet_id: timesheet.id, date: date_only },
include: { bank_code: true },
orderBy: { start_time: 'asc'},
});
const findExactOldShift = async ()=> {
if(!old_norm || old_bank_code_id === undefined) return undefined;
const old_comment = old_norm.comment ?? null;
if(!old_norm_shift || old_bank_code_id === undefined) return undefined;
const old_comment = old_norm_shift.comment ?? null;
return transaction.shifts.findFirst({
return await tx.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,
start_time: old_norm_shift.start_time,
end_time: old_norm_shift.end_time,
is_remote: old_norm_shift.is_remote,
comment: old_comment,
bank_code_id: old_bank_code_id,
},
@ -131,12 +102,12 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
//checks for overlaping shifts
const assertNoOverlap = (exclude_shift_id?: number)=> {
if (!new_norm) return;
if (!new_norm_shift) return;
const overlap_with = day_shifts.filter((shift)=> {
if(exclude_shift_id && shift.id === exclude_shift_id) return false;
return overlaps(
new_norm.start_time.getTime(),
new_norm.end_time.getTime(),
new_norm_shift.start_time.getTime(),
new_norm_shift.end_time.getTime(),
shift.start_time.getTime(),
shift.end_time.getTime(),
);
@ -148,100 +119,93 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
end_time: formatHHmm(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,
});
throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts});
}
};
//_____________________________________________________________________________________________
// DELETE
//_____________________________________________________________________________________________
if ( old_shift && !new_shift ) {
const existing = await findExactOldShift();
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 (!old_shift && new_shift) {
assertNoOverlap();
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!,
},
};
let action: UpsertAction;
//_____________________________________________________________________________________________
// DELETE
//_____________________________________________________________________________________________
if ( old_shift && !new_shift ) {
if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`);
const existing = await findExactOldShift();
if(!existing) {
throw new NotFoundException({
error_code: 'SHIFT_STALE',
message: 'The shift was modified or deleted by someone else',
});
action = 'created';
}
//_____________________________________________________________________________________________
// UPDATE
//_____________________________________________________________________________________________
else if (old_shift && new_shift){
await tx.shifts.delete({ where: { id: existing.id } } );
action = 'deleted';
}
//_____________________________________________________________________________________________
// CREATE
//_____________________________________________________________________________________________
else if (!old_shift && new_shift) {
if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`);
assertNoOverlap();
await tx.shifts.create({
data: {
timesheet_id: timesheet.id,
date: date_only,
start_time: new_norm_shift!.start_time,
end_time: new_norm_shift!.end_time,
is_remote: new_norm_shift!.is_remote,
comment: new_norm_shift!.comment ?? null,
bank_code_id: new_bank_code_id!,
},
});
action = 'created';
}
//_____________________________________________________________________________________________
// UPDATE
//_____________________________________________________________________________________________
else if (old_shift && new_shift){
if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`);
if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`);
const existing = await findExactOldShift();
if(!existing) {
throw new NotFoundException({
error_code: 'SHIFT_STALE',
message: 'The shift was modified or deleted by someone else',
});
}
if(!existing) throw new NotFoundException({ error_code: 'SHIFT_STALE', message: 'The shift was modified or deleted by someone else'});
assertNoOverlap(existing.id);
await transaction.shifts.update({
await tx.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,
start_time: new_norm_shift!.start_time,
end_time: new_norm_shift!.end_time,
is_remote: new_norm_shift!.is_remote,
comment: new_norm_shift!.comment ?? null,
bank_code_id: new_bank_code_id,
},
});
action = 'updated';
} else {
throw new BadRequestException('At least one of old_shift or new_shift must be provided');
}
} else throw new BadRequestException('At least one of old_shift or new_shift must be provided');
//Reload the day (truth source)
const fresh_day = await transaction.shifts.findMany({
where: {
date: date_only,
timesheet_id: timesheet.id,
},
include: {
bank_code: true
},
orderBy: {
start_time: 'asc'
},
});
return {
action,
day: fresh_day.map<DayShiftResponse>((shift)=> ({
start_time: formatHHmm(shift.start_time),
end_time: formatHHmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN',
is_remote: shift.is_remote,
comment: shift.comment ?? null,
})),
};
//Reload the day (truth source)
const fresh_day = await tx.shifts.findMany({
where: {
date: date_only,
timesheet_id: timesheet.id,
},
include: {
bank_code: true
},
orderBy: {
start_time: 'asc'
},
});
return result;
}
return {
action,
day: fresh_day.map<DayShiftResponse>((shift)=> ({
start_time: formatHHmm(shift.start_time),
end_time: formatHHmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN',
is_remote: shift.is_remote,
comment: shift.comment ?? null,
})),
};
});
}
}

View File

@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { NotificationsService } from "src/modules/notifications/services/notifications.service";
import { computeHours } from "src/common/utils/date-utils";
import { OverviewRow } from "../types and interfaces/shifts-overview-row.interface";
import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
// const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);

View File

@ -1,13 +1,12 @@
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { TimesheetsQueryService } from '../services/timesheets-query.service';
import { CreateTimesheetDto, CreateWeekShiftsDto } from '../dtos/create-timesheet.dto';
import { Timesheets } from '@prisma/client';
import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto';
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { TimesheetsCommandService } from '../services/timesheets-command.service';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { TimesheetDto } from '../dtos/overview-timesheet.dto';
import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
@ApiTags('Timesheets')
@ApiBearerAuth('access-token')

View File

@ -1,28 +0,0 @@
export class TimesheetDto {
is_approved: boolean;
start_day: string;
end_day: string;
label: string;
shifts: ShiftsDto[];
expenses: ExpensesDto[]
}
export class ShiftsDto {
bank_type: string;
date: string;
start_time: string;
end_time: string;
comment: string;
is_approved: boolean;
is_remote: boolean;
}
export class ExpensesDto {
bank_type: string;
date: string;
amount: number;
mileage: number;
comment: string;
supervisor_comment: string;
is_approved: boolean;
}

View File

@ -1,3 +1,12 @@
export class TimesheetDto {
start_day: string;
end_day: string;
label: string;
shifts: ShiftDto[];
expenses: ExpenseDto[]
is_approved: boolean;
}
export class ShiftDto {
date: string;
type: string;
@ -31,7 +40,7 @@ export class DetailedShifts {
}
export class DayExpensesDto {
expenses: ExpenseDto[];
expenses: ExpenseDto[] = [];
total_mileage: number;
total_expense: number;
}

View File

@ -1,4 +0,0 @@
import { PartialType } from "@nestjs/swagger";
import { CreateTimesheetDto } from "./create-timesheet.dto";
export class UpdateTimesheetDto extends PartialType(CreateTimesheetDto) {}

View File

@ -4,15 +4,21 @@ import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.service";
import { TimesheetsQueryService } from "./timesheets-query.service";
import { CreateTimesheetDto } from "../dtos/create-timesheet.dto";
import { TimesheetDto } from "../dtos/overview-timesheet.dto";
import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils";
import { parseISODate, parseHHmm } from "../utils/timesheet.helpers";
import { TimesheetDto } from "../dtos/timesheet-period.dto";
import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable()
export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
constructor(
prisma: PrismaService,
private readonly query: TimesheetsQueryService,
private readonly emailResolver: EmployeeIdEmailResolver,
private readonly timesheetResolver: EmployeeTimesheetResolver,
private readonly bankTypeResolver: BankCodesResolver,
) {super(prisma);}
//_____________________________________________________________________________________________
// APPROVAL AND DELEGATE METHODS
@ -33,17 +39,14 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise<Timesheets> {
const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved);
await transaction.shifts.updateMany({
where: { timesheet_id: timesheetId },
data: { is_approved: isApproved },
});
await transaction.expenses.updateManyAndReturn({
where: { timesheet_id: timesheetId },
data: { is_approved: isApproved },
});
return timesheet;
}
@ -56,20 +59,9 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
shifts: CreateTimesheetDto[],
week_offset = 0,
): Promise<TimesheetDto> {
//match user's email with email
const user = await this.prisma.users.findUnique({
where: { email },
select: { id: true },
});
if(!user) throw new NotFoundException(`user with email ${email} not found`);
//fetchs employee matchint user's email
const employee = await this.prisma.employees.findFirst({
where: { user_id: user?.id },
select: { id: true },
});
if(!employee) throw new NotFoundException(`employee for ${ email } not found`);
const employee_id = await this.emailResolver.findIdByEmail(email);
if(!employee_id) throw new NotFoundException(`employee for ${ email } not found`);
//insure that the week starts on sunday and finishes on saturday
const base = new Date();
@ -77,43 +69,27 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
const start_week = getWeekStart(base, 0);
const end_week = getWeekEnd(start_week);
const timesheet = await this.prisma.timesheets.upsert({
where: {
employee_id_start_date: {
employee_id: employee.id,
start_date: start_week,
},
},
create: {
employee_id: employee.id,
start_date: start_week,
is_approved: false,
},
update: {},
select: { id: true },
});
const timesheet = await this.timesheetResolver.ensureForDate(employee_id, base)
if(!timesheet) throw new NotFoundException(`no timesheet found for employe ${employee_id}`);
//validations and insertions
for(const shift of shifts) {
const date = parseISODate(shift.date);
if (date < start_week || date > end_week) throw new BadRequestException(`date ${shift.date} not in current week`);
const bank_code = await this.prisma.bankCodes.findFirst({
where: { type: shift.type },
select: { id: true },
});
const bank_code = await this.bankTypeResolver.findByType(shift.type)
if(!bank_code) throw new BadRequestException(`Invalid bank_code type: ${shift.type}`);
await this.prisma.shifts.create({
data: {
timesheet_id: timesheet.id,
bank_code_id: bank_code.id,
date: date,
start_time: parseHHmm(shift.start_time),
end_time: parseHHmm(shift.end_time),
comment: shift.comment ?? null,
is_approved: false,
is_remote: false,
date: date,
start_time: parseHHmm(shift.start_time),
end_time: parseHHmm(shift.end_time),
comment: shift.comment ?? null,
is_approved: false,
is_remote: false,
},
});
}

View File

@ -2,42 +2,29 @@ import { endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers';
import { Injectable, NotFoundException } from '@nestjs/common';
import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
import { PrismaService } from 'src/prisma/prisma.service';
import { OvertimeService } from 'src/modules/business-logics/services/overtime.service';
import { TimesheetDto } from '../dtos/overview-timesheet.dto';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { ShiftRow, ExpenseRow } from '../types/timesheet.types';
import { buildPeriod } from '../utils/timesheet.utils';
import { EmployeeIdEmailResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils';
@Injectable()
export class TimesheetsQueryService {
constructor(
private readonly prisma: PrismaService,
// private readonly overtime: OvertimeService,
private readonly emailResolver: EmployeeIdEmailResolver,
private readonly fullNameResolver: FullNameResolver
) {}
async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
//finds the employee
const employee = await this.prisma.employees.findFirst({
where: {
user: { is: { email } }
},
select: {
id: true,
user_id: true,
},
});
if(!employee) throw new NotFoundException(`no employee with email ${email} found`);
//finds the employee using email
const employee_id = await this.emailResolver.findIdByEmail(email);
if(!employee_id) throw new NotFoundException(`employee with email : ${email} not found`);
//gets the employee's full name
const user = await this.prisma.users.findFirst({
where: { id: employee.user_id },
select: {
first_name: true,
last_name: true,
}
});
const employee_full_name: string = ( user?.first_name + " " + user?.last_name ) || " ";
//finds the employee full name using employee_id
const full_name = await this.fullNameResolver.resolveFullName(employee_id);
if(!full_name) throw new NotFoundException(`employee with id: ${employee_id} not found`)
//finds the period
const period = await this.prisma.payPeriods.findFirst({
@ -57,7 +44,7 @@ export class TimesheetsQueryService {
const raw_shifts = await this.prisma.shifts.findMany({
where: {
timesheet: { is: { employee_id: employee.id } },
timesheet: { is: { employee_id: employee_id } },
date: { gte: from, lte: to },
},
select: {
@ -74,7 +61,7 @@ export class TimesheetsQueryService {
const raw_expenses = await this.prisma.expenses.findMany({
where: {
timesheet: { is: { employee_id: employee.id } },
timesheet: { is: { employee_id: employee_id } },
date: { gte: from, lte: to },
},
select: {
@ -115,24 +102,12 @@ export class TimesheetsQueryService {
supervisor_comment: expense.supervisor_comment ?? '',
}));
return buildPeriod(period.period_start, period.period_end, shifts , expenses, employee_full_name);
return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name);
}
async getTimesheetByEmail(email: string, week_offset = 0): Promise<TimesheetDto> {
//fetch user related to email
const user = await this.prisma.users.findUnique({
where: { email },
select: { id: true },
});
if(!user) throw new NotFoundException(`user with email ${email} not found`);
//fetch employee_id matching the email
const employee = await this.prisma.employees.findFirst({
where: { user_id: user.id },
select: { id: true },
});
if(!employee) throw new NotFoundException(`Employee with email: ${email} not found`);
const employee_id = await this.emailResolver.findIdByEmail(email);
if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
//sets current week Sunday -> Saturday
const base = new Date();
@ -152,7 +127,7 @@ export class TimesheetsQueryService {
const timesheet = await this.prisma.timesheets.findUnique({
where: {
employee_id_start_date: {
employee_id: employee.id,
employee_id: employee_id,
start_date: start_date_week,
},
},
@ -182,7 +157,7 @@ export class TimesheetsQueryService {
//maps all shifts of selected timesheet
const shifts = timesheet.shift.map((shift_row) => ({
bank_type: shift_row.bank_code?.type ?? '',
type: shift_row.bank_code?.type ?? '',
date: formatDateISO(shift_row.date),
start_time: toHHmm(shift_row.start_time),
end_time: toHHmm(shift_row.end_time),
@ -193,7 +168,7 @@ export class TimesheetsQueryService {
//maps all expenses of selected timsheet
const expenses = timesheet.expense.map((exp) => ({
bank_type: exp.bank_code?.type ?? '',
type: exp.bank_code?.type ?? '',
date: formatDateISO(exp.date),
amount: Number(exp.amount) || 0,
mileage: exp.mileage != null ? Number(exp.mileage) : 0,
@ -203,12 +178,12 @@ export class TimesheetsQueryService {
}));
return {
is_approved: timesheet.is_approved,
start_day,
end_day,
label,
shifts,
expenses,
is_approved: timesheet.is_approved,
} as TimesheetDto;
}
//_____________________________________________________________________________________________

View File

@ -5,24 +5,20 @@ import { TimesheetsCommandService } from './services/timesheets-command.service'
import { ShiftsCommandService } from '../shifts/services/shifts-command.service';
import { ExpensesCommandService } from '../expenses/services/expenses-command.service';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { BankCodesRepo } from '../expenses/repos/bank-codes.repo';
import { TimesheetsRepo } from '../expenses/repos/timesheets.repo';
import { EmployeesRepo } from '../expenses/repos/employee.repo';
import { Module } from '@nestjs/common';
import { SharedModule } from '../shared/shared.module';
import { Module } from '@nestjs/common';
@Module({
imports: [BusinessLogicsModule],
imports: [BusinessLogicsModule, SharedModule],
controllers: [TimesheetsController],
providers: [
TimesheetsQueryService,
TimesheetsCommandService,
ShiftsCommandService,
ExpensesCommandService,
TimesheetArchiveService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,
],
TimesheetsQueryService,
TimesheetsCommandService,
ShiftsCommandService,
ExpensesCommandService,
TimesheetArchiveService,
],
exports: [
TimesheetsQueryService,
TimesheetArchiveService,