diff --git a/src/modules/employees/services/employees.service.ts b/src/modules/employees/services/employees.service.ts index 2833bff..3627476 100644 --- a/src/modules/employees/services/employees.service.ts +++ b/src/modules/employees/services/employees.service.ts @@ -42,8 +42,6 @@ export class EmployeesService { ); } - - async findOneProfile(email: string): Promise { const emp = await this.prisma.employees.findFirst({ where: { user: { email } }, diff --git a/src/modules/expenses/expenses.module.ts b/src/modules/expenses/expenses.module.ts index 39f1357..6201b91 100644 --- a/src/modules/expenses/expenses.module.ts +++ b/src/modules/expenses/expenses.module.ts @@ -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, ], }) diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 9ec2604..7c80eca 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -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 { 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 { //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 => { 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 { 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 { } 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 { }) => { 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 { // 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 { 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 { // 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 { return { action, day }; }); } - - //_____________________________________________________________________________________________ - // HELPERS - //_____________________________________________________________________________________________ - - private readonly resolveEmployeeIdByEmail = async (email: string): Promise => - this.employeesRepo.findIdByEmail(email); - - private readonly ensureTimesheetForDate = async ( employee_id: number, date: Date - ): Promise => { - 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); - } \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index 9bfdca6..c2fa4a8 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -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, ) {} diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index fd9106b..80dd614 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -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: [ diff --git a/src/modules/shared/shared.module.ts b/src/modules/shared/shared.module.ts new file mode 100644 index 0000000..4e71c92 --- /dev/null +++ b/src/modules/shared/shared.module.ts @@ -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 {} \ No newline at end of file diff --git a/src/modules/expenses/repos/bank-codes.repo.ts b/src/modules/shared/utils/resolve-bank-type-id.utils.ts similarity index 59% rename from src/modules/expenses/repos/bank-codes.repo.ts rename to src/modules/shared/utils/resolve-bank-type-id.utils.ts index 1de277d..039543f 100644 --- a/src/modules/expenses/repos/bank-codes.repo.ts +++ b/src/modules/shared/utils/resolve-bank-type-id.utils.ts @@ -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 }; }; } \ No newline at end of file diff --git a/src/modules/expenses/repos/employee.repo.ts b/src/modules/shared/utils/resolve-email-id.utils.ts similarity index 60% rename from src/modules/expenses/repos/employee.repo.ts rename to src/modules/shared/utils/resolve-email-id.utils.ts index aeefe53..c232fbe 100644 --- a/src/modules/expenses/repos/employee.repo.ts +++ b/src/modules/shared/utils/resolve-email-id.utils.ts @@ -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 => { 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; } } \ No newline at end of file diff --git a/src/modules/expenses/repos/timesheets.repo.ts b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts similarity index 96% rename from src/modules/expenses/repos/timesheets.repo.ts rename to src/modules/shared/utils/resolve-employee-timesheet.utils.ts index e140402..5fb7877 100644 --- a/src/modules/expenses/repos/timesheets.repo.ts +++ b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts @@ -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 diff --git a/src/modules/shared/utils/resolve-full-name.utils.ts b/src/modules/shared/utils/resolve-full-name.utils.ts new file mode 100644 index 0000000..ef6669b --- /dev/null +++ b/src/modules/shared/utils/resolve-full-name.utils.ts @@ -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 =>{ + 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; + } +} \ No newline at end of file diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index f0bd218..f12d9ad 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -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') diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts index 3e9e7f6..b00cf7b 100644 --- a/src/modules/shifts/helpers/shifts-date-time-helpers.ts +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -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 { diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 023196d..f7b59f3 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -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 { - constructor(prisma: PrismaService) { super(prisma); } + constructor( + prisma: PrismaService, + private readonly emailResolver: EmployeeIdEmailResolver, + private readonly bankTypeResolver: BankCodesResolver, + ) { super(prisma); } //_____________________________________________________________________________________________ // APPROVAL AND DELEGATE METHODS @@ -40,65 +46,147 @@ export class ShiftsCommandService extends BaseApprovalService { } 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 }, + }); + + //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; + + + 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 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_shift || old_bank_code_id === undefined) return undefined; + const old_comment = old_norm_shift.comment ?? null; + + return await tx.shifts.findFirst({ + where: { + timesheet_id: timesheet.id, + date: date_only, + 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, + }, + select: { id: true }, + }); + }; + + //checks for overlaping shifts + const assertNoOverlap = (exclude_shift_id?: number)=> { + 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_shift.start_time.getTime(), + new_norm_shift.end_time.getTime(), + shift.start_time.getTime(), + shift.end_time.getTime(), + ); + }); + + if(overlap_with.length > 0) { + const conflicts = overlap_with.map((shift)=> ({ + start_time: formatHHmm(shift.start_time), + 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}); + } + }; + 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', + }); + } + 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'}); + assertNoOverlap(existing.id); - //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; + await tx.shifts.update({ + where: { + id: existing.id + }, + data: { + 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'); - 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; - - //fetch all shifts in a single day - const day_shifts = await this.prisma.shifts.findMany({ - where: { - timesheet_id: timesheet.id, - date: date_only + //Reload the day (truth source) + const fresh_day = await tx.shifts.findMany({ + where: { + date: date_only, + timesheet_id: timesheet.id, }, include: { bank_code: true @@ -108,140 +196,16 @@ export class ShiftsCommandService extends BaseApprovalService { }, }); - const result = await this.prisma.$transaction(async (transaction)=> { - let action: UpsertAction; - - const findExactOldShift = 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 assertNoOverlap = (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 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: formatHHmm(shift.start_time), - 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, - }); - } - }; - - //_____________________________________________________________________________________________ - // 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!, - }, - }); - action = 'created'; - } - //_____________________________________________________________________________________________ - // UPDATE - //_____________________________________________________________________________________________ - else 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', - }); - } - assertNoOverlap(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'; - } 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((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, - })), - }; - }); - return result; - } + return { + action, + day: fresh_day.map((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, + })), + }; + }); + } } \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index 0d6bc6f..bfe3fe8 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -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); diff --git a/src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts b/src/modules/shifts/types-and-interfaces/shifts-overview-row.interface.ts similarity index 100% rename from src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts rename to src/modules/shifts/types-and-interfaces/shifts-overview-row.interface.ts diff --git a/src/modules/shifts/types and interfaces/shifts-upsert.types.ts b/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts similarity index 100% rename from src/modules/shifts/types and interfaces/shifts-upsert.types.ts rename to src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index c0ff293..98350ab 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -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') diff --git a/src/modules/timesheets/dtos/create-timesheet.dto.ts b/src/modules/timesheets/dtos/create-timesheet.dto.ts index 1a53a08..a6fd0b2 100644 --- a/src/modules/timesheets/dtos/create-timesheet.dto.ts +++ b/src/modules/timesheets/dtos/create-timesheet.dto.ts @@ -22,7 +22,7 @@ export class CreateTimesheetDto { @IsOptional() @IsString() @Length(0,512) - comment?: string; + comment?: string; } export class CreateWeekShiftsDto { diff --git a/src/modules/timesheets/dtos/overview-timesheet.dto.ts b/src/modules/timesheets/dtos/overview-timesheet.dto.ts deleted file mode 100644 index 417f913..0000000 --- a/src/modules/timesheets/dtos/overview-timesheet.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index c4ef385..333716b 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -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; } diff --git a/src/modules/timesheets/dtos/update-timesheet.dto.ts b/src/modules/timesheets/dtos/update-timesheet.dto.ts deleted file mode 100644 index d621e6a..0000000 --- a/src/modules/timesheets/dtos/update-timesheet.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from "@nestjs/swagger"; -import { CreateTimesheetDto } from "./create-timesheet.dto"; - -export class UpdateTimesheetDto extends PartialType(CreateTimesheetDto) {} diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 5564f0d..59652e5 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -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{ 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{ async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise { 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{ shifts: CreateTimesheetDto[], week_offset = 0, ): Promise { - - //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{ 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, }, }); } diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 9355728..c14387f 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -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 { - //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`); - - //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 using email + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`employee with email : ${email} not found`); + + //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 { - - //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; } //_____________________________________________________________________________________________ diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index 450c7b3..c7636ba 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -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,