Merge branch 'main' of git.targo.ca:Targo/targo_backend into auth-session-fullstack

did something it didn't like, not sure whadid something it didn't like,
not sure whatt
This commit is contained in:
Nicolas Drolet 2025-10-06 16:06:38 -04:00
commit 406233b2a3
62 changed files with 2026 additions and 2447 deletions

File diff suppressed because it is too large Load Diff

78
package-lock.json generated
View File

@ -51,7 +51,7 @@
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^6.14.0",
"prisma": "^6.16.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
@ -3148,9 +3148,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz",
"integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz",
"integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==",
"devOptional": true,
"dependencies": {
"c12": "3.1.0",
@ -3160,48 +3160,48 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz",
"integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz",
"integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==",
"devOptional": true
},
"node_modules/@prisma/engines": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz",
"integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz",
"integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/debug": "6.14.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"@prisma/fetch-engine": "6.14.0",
"@prisma/get-platform": "6.14.0"
"@prisma/debug": "6.16.3",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"@prisma/fetch-engine": "6.16.3",
"@prisma/get-platform": "6.16.3"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz",
"integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==",
"version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz",
"integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==",
"devOptional": true
},
"node_modules/@prisma/fetch-engine": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz",
"integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz",
"integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==",
"devOptional": true,
"dependencies": {
"@prisma/debug": "6.14.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"@prisma/get-platform": "6.14.0"
"@prisma/debug": "6.16.3",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"@prisma/get-platform": "6.16.3"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz",
"integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz",
"integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==",
"devOptional": true,
"dependencies": {
"@prisma/debug": "6.14.0"
"@prisma/debug": "6.16.3"
}
},
"node_modules/@scarf/scarf": {
@ -9450,15 +9450,15 @@
}
},
"node_modules/nypm": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz",
"integrity": "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
"integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
"devOptional": true,
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.2",
"pathe": "^2.0.3",
"pkg-types": "^2.2.0",
"pkg-types": "^2.3.0",
"tinyexec": "^1.0.1"
},
"bin": {
@ -9967,9 +9967,9 @@
}
},
"node_modules/pkg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
"integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"devOptional": true,
"dependencies": {
"confbox": "^0.2.2",
@ -10049,14 +10049,14 @@
}
},
"node_modules/prisma": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz",
"integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz",
"integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/config": "6.14.0",
"@prisma/engines": "6.14.0"
"@prisma/config": "6.16.3",
"@prisma/engines": "6.16.3"
},
"bin": {
"prisma": "build/index.js"

View File

@ -82,7 +82,7 @@
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^6.14.0",
"prisma": "^6.16.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",

View File

@ -0,0 +1,25 @@
/*
Warnings:
- You are about to drop the column `attachement` on the `expenses` table. All the data in the column will be lost.
- You are about to drop the column `attachement` on the `expenses_archive` table. All the data in the column will be lost.
- Made the column `comment` on table `expenses` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "public"."expenses" DROP COLUMN "attachement",
ADD COLUMN "attachment" INTEGER,
ADD COLUMN "mileage" DECIMAL(65,30),
ALTER COLUMN "comment" SET NOT NULL;
-- AlterTable
ALTER TABLE "public"."expenses_archive" DROP COLUMN "attachement",
ADD COLUMN "attachment" INTEGER,
ADD COLUMN "mileage" DECIMAL(65,30),
ALTER COLUMN "amount" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "public"."expenses" ADD CONSTRAINT "expenses_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."expenses_archive" ADD CONSTRAINT "expenses_archive_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,56 @@
/*
Warnings:
- You are about to drop the column `end_date_time` on the `leave_requests` table. All the data in the column will be lost.
- You are about to drop the column `start_date_time` on the `leave_requests` table. All the data in the column will be lost.
- You are about to drop the column `end_date_time` on the `leave_requests_archive` table. All the data in the column will be lost.
- You are about to drop the column `start_date_time` on the `leave_requests_archive` table. All the data in the column will be lost.
- A unique constraint covering the columns `[employee_id,leave_type,date]` on the table `leave_requests` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[leave_request_id]` on the table `leave_requests_archive` will be added. If there are existing duplicate values, this will fail.
- Added the required column `date` to the `leave_requests` table without a default value. This is not possible if the table is not empty.
- Added the required column `date` to the `leave_requests_archive` table without a default value. This is not possible if the table is not empty.
*/
-- AlterEnum
ALTER TYPE "leave_types" ADD VALUE 'HOLIDAY';
-- AlterTable
ALTER TABLE "leave_requests" DROP COLUMN "end_date_time",
DROP COLUMN "start_date_time",
ADD COLUMN "date" DATE NOT NULL,
ADD COLUMN "payable_hours" DECIMAL(5,2),
ADD COLUMN "requested_hours" DECIMAL(5,2);
-- AlterTable
ALTER TABLE "leave_requests_archive" DROP COLUMN "end_date_time",
DROP COLUMN "start_date_time",
ADD COLUMN "date" DATE NOT NULL,
ADD COLUMN "payable_hours" DECIMAL(5,2),
ADD COLUMN "requested_hours" DECIMAL(5,2);
-- CreateTable
CREATE TABLE "preferences" (
"user_id" UUID NOT NULL,
"notifications" BOOLEAN NOT NULL DEFAULT false,
"dark_mode" BOOLEAN NOT NULL DEFAULT false,
"lang_switch" BOOLEAN NOT NULL DEFAULT false,
"lefty_mode" BOOLEAN NOT NULL DEFAULT false
);
-- CreateIndex
CREATE UNIQUE INDEX "preferences_user_id_key" ON "preferences"("user_id");
-- CreateIndex
CREATE INDEX "leave_requests_employee_id_date_idx" ON "leave_requests"("employee_id", "date");
-- CreateIndex
CREATE UNIQUE INDEX "leave_requests_employee_id_leave_type_date_key" ON "leave_requests"("employee_id", "leave_type", "date");
-- CreateIndex
CREATE INDEX "leave_requests_archive_employee_id_date_idx" ON "leave_requests_archive"("employee_id", "date");
-- CreateIndex
CREATE UNIQUE INDEX "leave_requests_archive_leave_request_id_key" ON "leave_requests_archive"("leave_request_id");
-- AddForeignKey
ALTER TABLE "preferences" ADD CONSTRAINT "preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,14 +1,14 @@
import { PrismaClient, Prisma, LeaveTypes, LeaveApprovalStatus } from '@prisma/client';
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)");
console.log('?? Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)');
process.exit(0);
}
const prisma = new PrismaClient();
function dateOn(y: number, m: number, d: number) {
// stocke une date (pour @db.Date) à minuit UTC
// stocke une date (@db.Date) à minuit UTC
return new Date(Date.UTC(y, m - 1, d, 0, 0, 0));
}
@ -19,7 +19,7 @@ async function main() {
const employees = await prisma.employees.findMany({ select: { id: true } });
const bankCodes = await prisma.bankCodes.findMany({
where: { categorie: 'LEAVE' },
select: { id: true },
select: { id: true, type: true },
});
if (!employees.length || !bankCodes.length) {
@ -44,30 +44,31 @@ async function main() {
LeaveApprovalStatus.ESCALATED,
];
const futureMonths = [8, 9, 10, 11, 12]; // Août→Déc (1-based)
const futureMonths = [8, 9, 10, 11, 12]; // Août ? Déc. (1-based)
// ✅ typer rows pour éviter never[]
const rows: Prisma.LeaveRequestsCreateManyInput[] = [];
for (let i = 0; i < 10; i++) {
const emp = employees[i % employees.length];
const m = futureMonths[i % futureMonths.length];
const start = dateOn(year, m, 5 + i); // 5..14
if (start <= today) continue; // garantir "futur"
const date = dateOn(year, m, 5 + i); // 5..14
if (date <= today) continue; // garantir « futur »
const end = Math.random() < 0.5 ? null : dateOn(year, m, 6 + i);
const type = types[i % types.length];
const status = statuses[i % statuses.length];
const bc = bankCodes[i % bankCodes.length];
const requestedHours = 4 + (i % 5); // 4 ? 8 h
const payableHours = status === LeaveApprovalStatus.APPROVED ? Math.min(requestedHours, 8) : null;
rows.push({
employee_id: emp.id,
bank_code_id: bc.id,
leave_type: type,
start_date_time: start,
end_date_time: end, // ok: Date | null
comment: `Future leave #${i + 1}`,
date,
comment: `Future leave #${i + 1} (${bc.type})`,
approval_status: status,
requested_hours: requestedHours,
payable_hours: payableHours,
});
}
@ -75,7 +76,7 @@ async function main() {
await prisma.leaveRequests.createMany({ data: rows });
}
console.log(` LeaveRequests (future): ${rows.length} rows`);
console.log(`? LeaveRequests (future): ${rows.length} rows`);
}
main().finally(() => prisma.$disconnect());

View File

@ -1,7 +1,7 @@
import { PrismaClient, LeaveApprovalStatus, LeaveRequests } from '@prisma/client';
import { PrismaClient, LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)");
console.log('?? Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)');
process.exit(0);
}
@ -15,65 +15,73 @@ function daysAgo(n: number) {
}
async function main() {
// 1) Récupère tous les employés
const employees = await prisma.employees.findMany({ select: { id: true } });
if (!employees.length) {
throw new Error('Aucun employé trouvé. Exécute le seed employees avant celui-ci.');
throw new Error('Aucun employé trouvé. Exécute le seed employees avant celui-ci.');
}
// 2) Va chercher les bank codes dont le type est SICK, VACATION ou HOLIDAY
const leaveCodes = await prisma.bankCodes.findMany({
where: { type: { in: ['SICK', 'VACATION'] } },
select: { id: true, type: true, bank_code: true },
where: { type: { in: ['SICK', 'VACATION', 'HOLIDAY'] } },
select: { id: true, type: true },
});
if (!leaveCodes.length) {
throw new Error("Aucun bank code trouvé avec type in ('SICK','VACATION','HOLIDAY'). Vérifie ta table bank_codes.");
throw new Error("Aucun bank code trouvé avec type in ('SICK','VACATION','HOLIDAY'). Vérifie ta table bank_codes.");
}
const statuses = Object.values(LeaveApprovalStatus);
const created: LeaveRequests[] = [];
const created = [] as Array<{ id: number; employee_id: number; leave_type: LeaveTypes; date: Date; comment: string; approval_status: LeaveApprovalStatus; requested_hours: number; payable_hours: number | null }>;
// 3) Crée quelques leave requests
const COUNT = 12;
for (let i = 0; i < COUNT; i++) {
const emp = employees[i % employees.length];
const leaveCode = leaveCodes[Math.floor(Math.random() * leaveCodes.length)];
const start = daysAgo(120 - i * 3);
const end = Math.random() < 0.6 ? daysAgo(119 - i * 3) : null;
const date = daysAgo(120 - i * 3);
const status = statuses[(i + 2) % statuses.length];
const requestedHours = 4 + (i % 5); // 4 ? 8 h
const payableHours = status === LeaveApprovalStatus.APPROVED ? Math.min(requestedHours, 8) : null;
const lr = await prisma.leaveRequests.create({
data: {
employee_id: emp.id,
bank_code_id: leaveCode.id,
// on stocke le "type" tel quil est défini dans bank_codes
leave_type: leaveCode.type as any,
start_date_time: start,
end_date_time: end,
leave_type: leaveCode.type as LeaveTypes,
date,
comment: `Past leave #${i + 1} (${leaveCode.type})`,
approval_status: statuses[(i + 2) % statuses.length],
approval_status: status,
requested_hours: requestedHours,
payable_hours: payableHours,
},
});
created.push(lr);
created.push({
id: lr.id,
employee_id: lr.employee_id,
leave_type: lr.leave_type,
date: lr.date,
comment: lr.comment,
approval_status: lr.approval_status,
requested_hours: requestedHours,
payable_hours: payableHours,
});
}
// 4) Archive
for (const lr of created) {
await prisma.leaveRequestsArchive.create({
data: {
leave_request_id: lr.id,
employee_id: lr.employee_id,
leave_type: lr.leave_type,
start_date_time: lr.start_date_time,
end_date_time: lr.end_date_time,
date: lr.date,
comment: lr.comment,
approval_status: lr.approval_status,
requested_hours: lr.requested_hours,
payable_hours: lr.payable_hours,
},
});
}
console.log(` LeaveRequestsArchive: ${created.length} rows`);
console.log(`? LeaveRequestsArchive: ${created.length} rows`);
}
main().finally(() => prisma.$disconnect());

View File

@ -143,7 +143,7 @@ async function main() {
bank_code_id,
date,
amount, // string "xx.yy" (2 décimales exactes)
attachement: null,
attachment: null,
comment: `Expense ${code} ${amount}$ (emp ${e.id})`,
is_approved: Math.random() < 0.65,
supervisor_comment: Math.random() < 0.25 ? 'OK' : null,

View File

@ -32,7 +32,7 @@ async function main() {
bank_code_id: bc.id,
date: daysAgo(60 + i),
amount: (20 + i * 3.5).toFixed(2), // ok: Decimal accepte string
attachement: null,
attachment: null,
comment: `Old expense #${i + 1}`,
is_approved: true,
supervisor_comment: null,
@ -50,7 +50,7 @@ async function main() {
bank_code_id: e.bank_code_id,
date: e.date,
amount: e.amount,
attachement: e.attachement,
attachment: e.attachment,
comment: e.comment,
is_approved: e.is_approved,
supervisor_comment: e.supervisor_comment,

View File

@ -28,7 +28,7 @@ model Users {
oauth_sessions OAuthSessions[] @relation("UserOAuthSessions")
employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive")
customer_archive CustomersArchive[] @relation("UserToCustomersToArchive")
preferences Preferences? @relation("UserPreferences")
@@map("users")
}
@ -105,16 +105,19 @@ model LeaveRequests {
id Int @id @default(autoincrement())
employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id])
employee_id Int
bank_code BankCodes? @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id])
bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id])
bank_code_id Int
leave_type LeaveTypes
start_date_time DateTime @db.Date
end_date_time DateTime? @db.Date
date DateTime @db.Date
payable_hours Decimal? @db.Decimal(5,2)
requested_hours Decimal? @db.Decimal(5,2)
comment String
approval_status LeaveApprovalStatus @default(PENDING)
archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive")
@@unique([employee_id, leave_type, date], name: "leave_per_employee_date")
@@index([employee_id, date])
@@map("leave_requests")
}
@ -125,11 +128,14 @@ model LeaveRequestsArchive {
archived_at DateTime @default(now())
employee_id Int
leave_type LeaveTypes
start_date_time DateTime @db.Date
end_date_time DateTime? @db.Date
date DateTime @db.Date
payable_hours Decimal? @db.Decimal(5,2)
requested_hours Decimal? @db.Decimal(5,2)
comment String
approval_status LeaveApprovalStatus
@@unique([leave_request_id])
@@index([employee_id, date])
@@map("leave_requests_archive")
}
@ -226,8 +232,10 @@ model Expenses {
bank_code_id Int
date DateTime @db.Date
amount Decimal @db.Money
attachement String?
comment String?
mileage Decimal?
attachment Int?
attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull)
comment String
is_approved Boolean @default(false)
supervisor_comment String?
@ -244,8 +252,10 @@ model ExpensesArchive {
archived_at DateTime @default(now())
bank_code_id Int
date DateTime @db.Date
amount Decimal @db.Money
attachement String?
amount Decimal? @db.Money
mileage Decimal?
attachment Int?
attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull)
comment String?
is_approved Boolean
supervisor_comment String?
@ -296,11 +306,27 @@ model Attachments {
created_by String
created_at DateTime @default(now())
expenses Expenses[] @relation("ExpenseAttachment")
expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment")
@@index([owner_type, owner_id, created_at])
@@index([sha256])
@@map("attachments")
}
model Preferences {
id Int @id @default(autoincrement())
user Users @relation("UserPreferences", fields: [user_id], references: [id])
user_id String @unique @db.Uuid
notifications Boolean @default(false)
dark_mode Boolean @default(false)
lang_switch Boolean @default(false)
lefty_mode Boolean @default(false)
@@map("preferences")
}
enum AttachmentStatus {
ACTIVE
DELETED
@ -333,6 +359,7 @@ enum LeaveTypes {
PARENTAL // maternite/paternite/adoption
LEGAL // obligations legales comme devoir de juree
WEDDING // mariage
HOLIDAY // férier
@@map("leave_types")
}

View File

@ -1,30 +1,30 @@
import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ArchivalModule } from './modules/archival/archival.module';
import { AuthenticationModule } from './modules/authentication/auth.module';
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
import { BusinessLogicsModule } from './modules/business-logics/business-logics.module';
// import { CsvExportModule } from './modules/exports/csv-exports.module';
import { CustomersModule } from './modules/customers/customers.module';
import { EmployeesModule } from './modules/employees/employees.module';
import { ExpensesModule } from './modules/expenses/expenses.module';
import { HealthModule } from './health/health.module';
import { CustomersModule } from './modules/customers/customers.module';
import { EmployeesModule } from './modules/employees/employees.module';
import { ExpensesModule } from './modules/expenses/expenses.module';
import { HealthModule } from './health/health.module';
import { HealthController } from './health/health.controller';
import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module';
import { OvertimeService } from './modules/business-logics/services/overtime.service';
import { OvertimeService } from './modules/business-logics/services/overtime.service';
import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
import { PrismaModule } from './prisma/prisma.module';
import { ScheduleModule } from '@nestjs/schedule';
import { ShiftsModule } from './modules/shifts/shifts.module';
import { PrismaModule } from './prisma/prisma.module';
import { ScheduleModule } from '@nestjs/schedule';
import { ShiftsModule } from './modules/shifts/shifts.module';
import { TimesheetsModule } from './modules/timesheets/timesheets.module';
import { UsersModule } from './modules/users-management/users.module';
import { ConfigModule } from '@nestjs/config';
import { UsersModule } from './modules/users-management/users.module';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ValidationError } from 'class-validator';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ValidationError } from 'class-validator';
@Module({
imports: [

View File

@ -28,7 +28,7 @@ import { EmployeesModule } from "../employees/employees.module";
LeaveRequestsArchiveController,
ShiftsArchiveController,
TimesheetsArchiveController,
]
],
})
export class ArchivalModule {}

View File

@ -1,33 +1,7 @@
import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { LeaveRequestsArchive, Roles as RoleEnum } from "@prisma/client";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { LeaveRequestViewDto } from "src/modules/leave-requests/dtos/leave-request.view.dto";
import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service";
import { Controller } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('LeaveRequests Archives')
// @UseGuards()
@Controller('archives/leaveRequests')
export class LeaveRequestsArchiveController {
constructor(private readonly leaveRequestsService: LeaveRequestsService) {}
@Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'List of archived leaveRequests'})
@ApiResponse({ status: 200, description: 'List of archived leaveRequests', isArray: true })
async findAllArchived(): Promise<LeaveRequestsArchive[]> {
return this.leaveRequestsService.findAllArchived();
}
@Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Fetch leaveRequest in archives with its Id'})
@ApiResponse({ status: 200, description: 'Archived leaveRequest found'})
async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<LeaveRequestViewDto> {
try{
return await this.leaveRequestsService.findOneArchived(id);
}catch {
throw new NotFoundException(`Archived leaveRequest #${id} not found`);
}
}
}
export class LeaveRequestsArchiveController {}

View File

@ -1,7 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service";
import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service";
import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service";
import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service";
@ -13,7 +12,6 @@ export class ArchivalService {
private readonly timesheetsService: TimesheetsQueryService,
private readonly expensesService: ExpensesQueryService,
private readonly shiftsService: ShiftsQueryService,
private readonly leaveRequestsService: LeaveRequestsService,
) {}
@Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00
@ -31,7 +29,7 @@ export class ArchivalService {
await this.timesheetsService.archiveOld();
await this.expensesService.archiveOld();
await this.shiftsService.archiveOld();
await this.leaveRequestsService.archiveExpired();
// await this.leaveRequestsService.archiveExpired();
this.logger.log('archivation process done');
} catch (err) {
this.logger.error('an error occured during archivation process ', err);

View File

@ -7,40 +7,43 @@ import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOperation, ApiResponse }
@Controller('bank-codes')
export class BankCodesControllers {
constructor(private readonly bankCodesService: BankCodesService) {}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
@Post()
@ApiOperation({ summary: 'Create a new bank code' })
@ApiResponse({ status: 201, description: 'Bank code successfully created.' })
@ApiBadRequestResponse({ description: 'Invalid input data.' })
create(@Body() dto: CreateBankCodeDto) {
return this.bankCodesService.create(dto);
}
// @Post()
// @ApiOperation({ summary: 'Create a new bank code' })
// @ApiResponse({ status: 201, description: 'Bank code successfully created.' })
// @ApiBadRequestResponse({ description: 'Invalid input data.' })
// create(@Body() dto: CreateBankCodeDto) {
// return this.bankCodesService.create(dto);
// }
@Get()
@ApiOperation({ summary: 'Retrieve all bank codes' })
@ApiResponse({ status: 200, description: 'List of bank codes.' })
findAll() {
return this.bankCodesService.findAll();
}
// @Get()
// @ApiOperation({ summary: 'Retrieve all bank codes' })
// @ApiResponse({ status: 200, description: 'List of bank codes.' })
// findAll() {
// return this.bankCodesService.findAll();
// }
@Get(':id')
@ApiOperation({ summary: 'Retrieve a bank code by its ID' })
@ApiNotFoundResponse({ description: 'Bank code not found.' })
findOne(@Param('id', ParseIntPipe) id: number){
return this.bankCodesService.findOne(id);
}
// @Get(':id')
// @ApiOperation({ summary: 'Retrieve a bank code by its ID' })
// @ApiNotFoundResponse({ description: 'Bank code not found.' })
// findOne(@Param('id', ParseIntPipe) id: number){
// return this.bankCodesService.findOne(id);
// }
@Patch(':id')
@ApiOperation({ summary: 'Update an existing bank code' })
@ApiNotFoundResponse({ description: 'Bank code not found.' })
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) {
return this.bankCodesService.update(id, dto)
}
// @Patch(':id')
// @ApiOperation({ summary: 'Update an existing bank code' })
// @ApiNotFoundResponse({ description: 'Bank code not found.' })
// update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) {
// return this.bankCodesService.update(id, dto)
// }
@Delete(':id')
@ApiOperation({ summary: 'Delete a bank code' })
@ApiNotFoundResponse({ description: 'Bank code not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.bankCodesService.remove(id);
}
// @Delete(':id')
// @ApiOperation({ summary: 'Delete a bank code' })
// @ApiNotFoundResponse({ description: 'Bank code not found.' })
// remove(@Param('id', ParseIntPipe) id: number) {
// return this.bankCodesService.remove(id);
// }
}

View File

@ -1,6 +1,14 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../../prisma/prisma.service";
import { computeHours, getWeekStart } from "src/common/utils/date-utils";
import { PrismaService } from "../../../prisma/prisma.service";
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
/*
le calcul est 1/20 des 4 dernières semaines, précédent la semaine incluant le férier.
Un maximum de 08h00 est allouable pour le férier
Un maximum de 40hrs par semaine est retenue pour faire le calcul.
*/
@Injectable()
export class HolidayService {
@ -22,36 +30,49 @@ export class HolidayService {
private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise<number> {
const employee_id = await this.resolveEmployeeByEmail(email);
return this.computeHoursPrevious4Weeks(employee_id, holiday_date)
return this.computeHoursPrevious4Weeks(employee_id, holiday_date);
}
private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<number> {
//sets the end of the window to 1ms before the week with the holiday
const holiday_week_start = getWeekStart(holiday_date);
const window_start = new Date(holiday_week_start.getTime() - 4 * WEEK_IN_MS);
const window_end = new Date(holiday_week_start.getTime() - 1);
//sets the start of the window to 28 days ( 4 completed weeks ) before the week with the holiday
const window_start = new Date(window_end.getTime() - 28 * 24 * 60 * 60000 + 1 )
const valid_codes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700'];
//fetches all shift of the employee in said window ( 4 previous completed weeks )
const valid_codes = ['G1', 'G43', 'G56', 'G104', 'G105', 'G700'];
const shifts = await this.prisma.shifts.findMany({
where: { timesheet: { employee_id: employee_id } ,
date: { gte: window_start, lte: window_end },
bank_code: { bank_code: { in: valid_codes } },
where: {
timesheet: { employee_id: employee_id },
date: { gte: window_start, lte: window_end },
bank_code: { bank_code: { in: valid_codes } },
},
select: { date: true, start_time: true, end_time: true },
});
const total_hours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0);
const daily_hours = total_hours / 20;
const hours_by_week = new Map<number, number>();
for(const shift of shifts) {
const hours = computeHours(shift.start_time, shift.end_time);
if(hours <= 0) continue;
const shift_week_start = getWeekStart(shift.date);
const key = shift_week_start.getTime();
hours_by_week.set(key, (hours_by_week.get(key) ?? 0) + hours);
}
return daily_hours;
let capped_total = 0;
for(let offset = 1; offset <= 4; offset++) {
const week_start = new Date(holiday_week_start.getTime() - offset * WEEK_IN_MS);
const key = week_start.getTime();
const weekly_hours = hours_by_week.get(key) ?? 0;
capped_total += Math.min(weekly_hours, 40);
}
const average_daily_hours = capped_total / 20;
return average_daily_hours;
}
async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise<number> {
const hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date);
const daily_rate = Math.min(hours, 8);
this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`);
const average_daily_hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date);
const daily_rate = Math.min(average_daily_hours, 8);
this.logger.debug(`Holiday pay calculation: cappedHoursPerDay= ${average_daily_hours.toFixed(2)}, appliedDailyRate= ${daily_rate.toFixed(2)}`);
return daily_rate * modifier;
}
}

View File

@ -1,6 +1,6 @@
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../../prisma/prisma.service";
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
@Injectable()
export class SickLeaveService {
@ -9,28 +9,38 @@ export class SickLeaveService {
private readonly logger = new Logger(SickLeaveService.name);
//switch employeeId for email
async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number):
Promise<number> {
async calculateSickLeavePay(
employee_id: number,
reference_date: Date,
days_requested: number,
hours_per_day: number,
modifier: number,
): Promise<number> {
if (days_requested <= 0 || hours_per_day <= 0 || modifier <= 0) {
return 0;
}
//sets the year to jan 1st to dec 31st
const period_start = getYearStart(reference_date);
const period_end = reference_date;
const period_end = reference_date;
//fetches all shifts of a selected employee
const shifts = await this.prisma.shifts.findMany({
where: {
timesheet: { employee_id: employee_id },
date: { gte: period_start, lte: period_end},
date: { gte: period_start, lte: period_end },
},
select: { date: true },
});
//count the amount of worked days
const worked_dates = new Set(
shifts.map(shift => shift.date.toISOString().slice(0,10))
shifts.map((shift) => shift.date.toISOString().slice(0, 10)),
);
const days_worked = worked_dates.size;
this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()}
-> ${period_end.toDateString()}`);
this.logger.debug(
`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} -> ${period_end.toDateString()}`,
);
//less than 30 worked days returns 0
if (days_worked < 30) {
@ -45,22 +55,31 @@ export class SickLeaveService {
const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day
//calculate each completed month, starting the 1st of the next month
const first_bonus_date = new Date(threshold_date.getFullYear(), threshold_date.getMonth() +1, 1);
let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
(period_end.getMonth() - first_bonus_date.getMonth()) + 1;
if(months < 0) months = 0;
const first_bonus_date = new Date(
threshold_date.getFullYear(),
threshold_date.getMonth() + 1,
1,
);
let months =
(period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
(period_end.getMonth() - first_bonus_date.getMonth()) +
1;
if (months < 0) months = 0;
acquired_days += months;
//cap of 10 days
if (acquired_days > 10) acquired_days = 10;
this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()}
, bonusMonths = ${months}, acquired Days = ${acquired_days}`);
this.logger.debug(
`Sick leave: threshold Date = ${threshold_date.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquired_days}`,
);
const payable_days = Math.min(acquired_days, days_requested);
const raw_hours = payable_days * 8 * modifier;
const rounded = roundToQuarterHour(raw_hours)
this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`);
const raw_hours = payable_days * hours_per_day * modifier;
const rounded = roundToQuarterHour(raw_hours);
this.logger.debug(
`Sick leave pay: days= ${payable_days}, hoursPerDay= ${hours_per_day}, modifier= ${modifier}, hours= ${rounded}`,
);
return rounded;
}
}

View File

@ -6,16 +6,8 @@ export class VacationService {
constructor(private readonly prisma: PrismaService) {}
private readonly logger = new Logger(VacationService.name);
/**
* Calculate the ammount allowed for vacation days.
*
* @param employee_id employee ID
* @param startDate first day of vacation
* @param daysRequested number of days requested
* @param modifier Coefficient of hours(1)
* @returns amount of payable hours
*/
//switch employeeId for email
//switch employeeId for email
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
//fetch hiring date
const employee = await this.prisma.employees.findUnique({
@ -56,7 +48,7 @@ export class VacationService {
const segment_end = boundaries[i+1];
//number of days in said segment
const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day);
const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day);
const years_since_hire = (segment_start.getFullYear() - hire_date.getFullYear()) -
(segment_start < new Date(segment_start.getFullYear(), hire_date.getMonth()) ? 1 : 0);
let alloc_days: number;

View File

@ -14,51 +14,55 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Post()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Create customer' })
@ApiResponse({ status: 201, description: 'Customer created', type: CreateCustomerDto })
@ApiResponse({ status: 400, description: 'Invalid task or invalid data' })
create(@Body() dto: CreateCustomerDto): Promise<Customers> {
return this.customersService.create(dto);
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
@Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find all customers' })
@ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true })
@ApiResponse({ status: 400, description: 'List of customers not found' })
findAll(): Promise<Customers[]> {
return this.customersService.findAll();
}
// @Post()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Create customer' })
// @ApiResponse({ status: 201, description: 'Customer created', type: CreateCustomerDto })
// @ApiResponse({ status: 400, description: 'Invalid task or invalid data' })
// create(@Body() dto: CreateCustomerDto): Promise<Customers> {
// return this.customersService.create(dto);
// }
@Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find customer' })
@ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto })
@ApiResponse({ status: 400, description: 'Customer not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<Customers> {
return this.customersService.findOne(id);
}
// @Get()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find all customers' })
// @ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true })
// @ApiResponse({ status: 400, description: 'List of customers not found' })
// findAll(): Promise<Customers[]> {
// return this.customersService.findAll();
// }
@Patch(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Update customer' })
@ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto })
@ApiResponse({ status: 400, description: 'Customer not found' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateCustomerDto,
): Promise<Customers> {
return this.customersService.update(id, dto);
}
// @Get(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find customer' })
// @ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto })
// @ApiResponse({ status: 400, description: 'Customer not found' })
// findOne(@Param('id', ParseIntPipe) id: number): Promise<Customers> {
// return this.customersService.findOne(id);
// }
@Delete(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Delete customer' })
@ApiResponse({ status: 201, description: 'Customer deleted', type: CreateCustomerDto })
@ApiResponse({ status: 400, description: 'Customer not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Customers>{
return this.customersService.remove(id);
}
// @Patch(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Update customer' })
// @ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto })
// @ApiResponse({ status: 400, description: 'Customer not found' })
// update(
// @Param('id', ParseIntPipe) id: number,
// @Body() dto: UpdateCustomerDto,
// ): Promise<Customers> {
// return this.customersService.update(id, dto);
// }
// @Delete(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Delete customer' })
// @ApiResponse({ status: 201, description: 'Customer deleted', type: CreateCustomerDto })
// @ApiResponse({ status: 400, description: 'Customer not found' })
// remove(@Param('id', ParseIntPipe) id: number): Promise<Customers>{
// return this.customersService.remove(id);
// }
}

View File

@ -24,15 +24,6 @@ export class EmployeesController {
return this.employeesService.create(dto);
}
@Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
@ApiOperation({summary: 'Find all employees' })
@ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true })
@ApiResponse({ status: 400, description: 'List of employees not found' })
findAll(): Promise<Employees[]> {
return this.employeesService.findAll();
}
@Get('employee-list')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
@ApiOperation({summary: 'Find all employees with scoped info' })
@ -42,34 +33,6 @@ export class EmployeesController {
return this.employeesService.findListEmployees();
}
@Get(':email')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING )
@ApiOperation({summary: 'Find employee' })
@ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto })
@ApiResponse({ status: 400, description: 'Employee not found' })
findOne(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
return this.employeesService.findOne(email);
}
@Get('profile/:email')
@ApiOperation({summary: 'Find employee profile' })
@ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' })
@ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto })
@ApiResponse({ status: 400, description: 'Employee profile not found' })
findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
return this.employeesService.findOneProfile(email);
}
@Delete(':email')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR )
@ApiOperation({summary: 'Delete employee' })
@ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' })
@ApiResponse({ status: 204, description: 'Employee deleted' })
@ApiResponse({ status: 404, description: 'Employee not found' })
remove(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
return this.employeesService.remove(email);
}
@Patch(':email')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiBearerAuth('access-token')
@ -88,4 +51,47 @@ export class EmployeesController {
}
return result;
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @Get()
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
// @ApiOperation({summary: 'Find all employees' })
// @ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true })
// @ApiResponse({ status: 400, description: 'List of employees not found' })
// findAll(): Promise<Employees[]> {
// return this.employeesService.findAll();
// }
// @Get(':email')
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING )
// @ApiOperation({summary: 'Find employee' })
// @ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto })
// @ApiResponse({ status: 400, description: 'Employee not found' })
// findOne(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
// return this.employeesService.findOne(email);
// }
// @Get('profile/:email')
// @ApiOperation({summary: 'Find employee profile' })
// @ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' })
// @ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto })
// @ApiResponse({ status: 400, description: 'Employee profile not found' })
// findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
// return this.employeesService.findOneProfile(email);
// }
// @Delete(':email')
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR )
// @ApiOperation({summary: 'Delete employee' })
// @ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' })
// @ApiResponse({ status: 204, description: 'Employee deleted' })
// @ApiResponse({ status: 404, description: 'Employee not found' })
// remove(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
// return this.employeesService.remove(email);
// }
}

View File

@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { ExpensesQueryService } from "../services/expenses-query.service";
import { CreateExpenseDto } from "../dtos/create-expense.dto";
import { Expenses } from "@prisma/client";
@ -8,6 +8,8 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagg
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ExpensesCommandService } from "../services/expenses-command.service";
import { SearchExpensesDto } from "../dtos/search-expense.dto";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces";
@ApiTags('Expenses')
@ApiBearerAuth('access-token')
@ -15,60 +17,73 @@ import { SearchExpensesDto } from "../dtos/search-expense.dto";
@Controller('Expenses')
export class ExpensesController {
constructor(
private readonly expensesService: ExpensesQueryService,
private readonly expensesApprovalService: ExpensesCommandService,
private readonly query: ExpensesQueryService,
private readonly command: ExpensesCommandService,
) {}
@Post()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Create expense' })
@ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
return this.expensesService.create(dto);
@Put('upsert/:email/:date')
async upsert_by_date(
@Param('email') email: string,
@Param('date') date: string,
@Body() dto: UpsertExpenseDto,
): Promise<UpsertExpenseResult> {
return this.command.upsertExpensesByDate(email, date, dto);
}
@Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find all expenses' })
@ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
@ApiResponse({ status: 400, description: 'List of expenses not found' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
return this.expensesService.findAll(filters);
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
@Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find expense' })
@ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
@ApiResponse({ status: 400, description: 'Expense not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
return this.expensesService.findOne(id);
}
// @Post()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Create expense' })
// @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
// create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
// return this.query.create(dto);
// }
@Patch(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Expense shift' })
@ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
@ApiResponse({ status: 400, description: 'Expense not found' })
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
return this.expensesService.update(id,dto);
}
// @Get()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find all expenses' })
// @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
// @ApiResponse({ status: 400, description: 'List of expenses not found' })
// @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
// findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
// return this.query.findAll(filters);
// }
@Delete(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Delete expense' })
@ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
@ApiResponse({ status: 400, description: 'Expense not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
return this.expensesService.remove(id);
}
// @Get(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find expense' })
// @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' })
// findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
// return this.query.findOne(id);
// }
@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.expensesApprovalService.updateApproval(id, isApproved);
}
// @Patch(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Expense shift' })
// @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' })
// update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
// return this.query.update(id,dto);
// }
// @Delete(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Delete expense' })
// @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' })
// remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
// return this.query.remove(id);
// }
// @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.command.updateApproval(id, isApproved);
// }
}

View File

@ -46,7 +46,7 @@ export class CreateExpenseDto {
description:'explain`s why the expense was made'
})
@IsString()
comment?: string;
comment: string;
@ApiProperty({
example: 'DENIED, APPROUVED, PENDING, etc...',

View File

@ -0,0 +1,59 @@
import { Transform, Type } from "class-transformer";
import {
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
ValidateIf,
ValidateNested
} from "class-validator";
export class ExpensePayloadDto {
@IsString()
type!: string;
@ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE')
@IsNumber()
@Min(0)
amount?: number;
@ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE')
@IsNumber()
@Min(0)
mileage?: number;
@IsString()
@MaxLength(280)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
comment!: string;
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined || value === '') return undefined;
if (typeof value === 'number') return value.toString();
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length ? trimmed : undefined;
}
return undefined;
})
@IsString()
@Matches(/^\d+$/)
@MaxLength(255)
attachment?: string;
}
export class UpsertExpenseDto {
@IsOptional()
@ValidateNested()
@Type(()=> ExpensePayloadDto)
old_expense?: ExpensePayloadDto;
@IsOptional()
@ValidateNested()
@Type(()=> ExpensePayloadDto)
new_expense?: ExpensePayloadDto;
}

View File

@ -3,12 +3,26 @@ 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";
@Module({
imports: [BusinessLogicsModule],
controllers: [ExpensesController],
providers: [ExpensesQueryService, ExpensesCommandService],
exports: [ ExpensesQueryService ],
providers: [
ExpensesQueryService,
ExpensesCommandService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,
],
exports: [
ExpensesQueryService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,
],
})
export class ExpensesModule {}

View File

@ -0,0 +1,34 @@
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 {
constructor(private readonly prisma: PrismaService) {}
//find id and modifier by type
readonly findByType = async ( type: string, client?: Tx
): Promise<{id:number; modifier: number }> => {
const db = client ?? this.prisma;
const bank = await db.bankCodes.findFirst({
where: {
type,
},
select: {
id: true,
modifier: true,
},
});
if(!bank) {
throw new NotFoundException(`Unknown bank code type: ${type}`);
}
return {
id: bank.id,
modifier: bank.modifier,
};
};
}

View File

@ -0,0 +1,32 @@
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 {
constructor(private readonly prisma: PrismaService) {}
// find employee id by 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,
},
});
if(!employee) {
throw new NotFoundException(`Employee with email: ${email} not found`);
}
return employee.id;
}
}

View File

@ -0,0 +1,42 @@
import { Injectable } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client";
import { weekStartMondayUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers";
import { PrismaService } from "src/prisma/prisma.service";
type Tx = Prisma.TransactionClient | PrismaClient;
@Injectable()
export class TimesheetsRepo {
constructor(private readonly prisma: PrismaService) {}
//find an existing timesheet linked to the employee
readonly ensureForDate = async (employee_id: number, date: Date, client?: Tx,
): Promise<{id: number; start_date: Date }> => {
const db = client ?? this.prisma;
const startOfWeek = weekStartMondayUTC(date);
const existing = await db.timesheets.findFirst({
where: {
employee_id: employee_id,
start_date: startOfWeek,
},
select: {
id: true,
start_date: true,
},
});
if(existing) return existing;
const created = await db.timesheets.create({
data: {
employee_id: employee_id,
start_date: startOfWeek,
},
select: {
id: true,
start_date: true,
},
});
return created;
}
}

View File

@ -1,13 +1,32 @@
import { Injectable } from "@nestjs/common";
import { Expenses, Prisma } from "@prisma/client";
import { Decimal } from "@prisma/client/runtime/library";
import { transcode } from "buffer";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.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 {
BadRequestException,
Injectable,
NotFoundException
} from "@nestjs/common";
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
import {
assertAndTrimComment,
computeMileageAmount,
mapDbExpenseToDayResponse,
normalizeType as normalizeTypeUtil
} from "../utils/expenses.utils";
@Injectable()
export class ExpensesCommandService extends BaseApprovalService<Expenses> {
constructor(prisma: PrismaService) { super(prisma); }
constructor(
prisma: PrismaService,
private readonly bankCodesRepo: BankCodesRepo,
private readonly timesheetsRepo: TimesheetsRepo,
private readonly employeesRepo: EmployeesRepo,
) { super(prisma); }
protected get delegate() {
return this.prisma.expenses;
@ -22,4 +41,273 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
this.updateApprovalWithTransaction(transaction, id, isApproved),
);
}
//-------------------- Master CRUD function --------------------
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
//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');
}
//validate date format
const date_only = toDateOnlyUTC(date);
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);
//make sure a timesheet existes
const timesheet_id = await this.ensureTimesheetForDate(employee_id, date_only);
return this.prisma.$transaction(async (tx) => {
const loadDay = async (): Promise<ExpenseResponse[]> => {
const rows = await tx.expenses.findMany({
where: {
timesheet_id: timesheet_id,
date: date_only,
},
include: {
bank_code: {
select: {
type: true,
},
},
},
orderBy: [{ date: 'asc' }, { id: 'asc' }],
});
return rows.map((r) =>
this.mapDbToDayResponse({
date: r.date,
amount: r.amount ?? 0,
mileage: r.mileage ?? 0,
comment: r.comment,
is_approved: r.is_approved,
bank_code: r.bank_code,
}));
};
const normalizePayload = async (payload: {
type: string;
amount?: number;
mileage?: number;
comment: string;
attachment?: string | number;
}): Promise<{
type: string;
bank_code_id: number;
amount: Prisma.Decimal;
mileage: number | null;
comment: string;
attachment: number | null;
}> => {
const type = this.normalizeType(payload.type);
const comment = this.assertAndTrimComment(payload.comment);
const attachment = this.parseAttachmentId(payload.attachment);
const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type);
let amount = this.computeAmountDecimal(type, payload, modifier);
let mileage: number | null = null;
if (type === 'MILEAGE') {
mileage = Number(payload.mileage ?? 0);
if (!(mileage > 0)) {
throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE');
}
const amountNumber = computeMileageAmount(mileage, modifier);
amount = new Prisma.Decimal(amountNumber);
} else {
if (!(typeof payload.amount === 'number' && payload.amount >= 0)) {
throw new BadRequestException('Amount required for non-MILEAGE expense');
}
amount = new Prisma.Decimal(payload.amount);
}
if (attachment !== null) {
const attachmentRow = await tx.attachments.findUnique({
where: { id: attachment },
select: { status: true },
});
if (!attachmentRow || attachmentRow.status !== 'ACTIVE') {
throw new BadRequestException('Attachment not found or inactive');
}
}
return {
type,
bank_code_id,
amount,
mileage,
comment,
attachment
};
};
const findExactOld = async (norm: {
bank_code_id: number;
amount: Prisma.Decimal;
mileage: number | null;
comment: string;
attachment: number | null;
}) => {
return tx.expenses.findFirst({
where: {
timesheet_id: timesheet_id,
date: date_only,
bank_code_id: norm.bank_code_id,
amount: norm.amount,
comment: norm.comment,
attachment: norm.attachment,
...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }),
},
select: { id: true },
});
};
let action : UpsertAction;
//-------------------- DELETE --------------------
if(old_expense && !new_expense) {
const oldNorm = await normalizePayload(old_expense);
const existing = await findExactOld(oldNorm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else',
});
}
await tx.expenses.delete({where: { id: existing.id } });
action = 'delete';
}
//-------------------- CREATE --------------------
else if (!old_expense && new_expense) {
const new_exp = await normalizePayload(new_expense);
await tx.expenses.create({
data: {
timesheet_id: timesheet_id,
date: date_only,
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
mileage: new_exp.mileage,
comment: new_exp.comment,
attachment: new_exp.attachment,
is_approved: false,
},
});
action = 'create';
}
//-------------------- UPDATE --------------------
else if(old_expense && new_expense) {
const oldNorm = await normalizePayload(old_expense);
const existing = await findExactOld(oldNorm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else',
});
}
const new_exp = await normalizePayload(new_expense);
await tx.expenses.update({
where: { id: existing.id },
data: {
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
mileage: new_exp.mileage,
comment: new_exp.comment,
attachment: new_exp.attachment,
},
});
action = 'update';
}
else {
throw new BadRequestException('Invalid upsert combination');
}
const day = await loadDay();
return { action, day };
});
}
//-------------------- helpers --------------------
private readonly normalizeType = (type: string): string =>
normalizeTypeUtil(type);
private readonly assertAndTrimComment = (comment: string): string =>
assertAndTrimComment(comment);
private readonly parseAttachmentId = (value: unknown): number | null => {
if (value == null) {
return null;
}
if (typeof value === 'number') {
if (!Number.isInteger(value) || value <= 0) {
throw new BadRequestException('Invalid attachment id');
}
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed.length) return null;
if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id');
const parsed = Number(trimmed);
if (parsed <= 0) throw new BadRequestException('Invalid attachment id');
return parsed;
}
throw new BadRequestException('Invalid attachment id');
};
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 lookupBankCodeOrThrow = async ( transaction: Prisma.TransactionClient, type: string
): Promise<{id: number; modifier: number}> =>
this.bankCodesRepo.findByType(type, transaction);
private readonly computeAmountDecimal = (
type: string,
payload: {
amount?: number;
mileage?: number;
},
modifier: number,
): Prisma.Decimal => {
if(type === 'MILEAGE') {
const km = payload.mileage ?? 0;
const amountNumber = computeMileageAmount(km, modifier);
return new Prisma.Decimal(amountNumber);
}
return new Prisma.Decimal(payload.amount!);
};
private readonly mapDbToDayResponse = (row: {
date: Date;
amount: Prisma.Decimal | number | string;
mileage: Prisma.Decimal | number | string;
comment: string;
is_approved: boolean;
bank_code: { type: string } | null;
}): ExpenseResponse => mapDbExpenseToDayResponse(row);
}

View File

@ -121,7 +121,7 @@ export class ExpensesQueryService {
bank_code_id: exp.bank_code_id,
date: exp.date,
amount: exp.amount,
attachement: exp.attachement,
attachment: exp.attachment,
comment: exp.comment,
is_approved: exp.is_approved,
supervisor_comment: exp.supervisor_comment,

View File

@ -0,0 +1,14 @@
export type UpsertAction = 'create' | 'update' | 'delete';
export interface ExpenseResponse {
date: string;
type: string;
amount: number;
comment: string;
is_approved: boolean;
};
export type UpsertExpenseResult = {
action: UpsertAction;
day: ExpenseResponse[]
};

View File

@ -0,0 +1,69 @@
import { BadRequestException } from "@nestjs/common";
import { ExpenseResponse } from "../types and interfaces/expenses.types.interfaces";
import { Prisma } from "@prisma/client";
//uppercase and trim for validation
export function normalizeType(type: string): string {
return (type ?? '').trim().toUpperCase();
};
//required comment after trim
export function assertAndTrimComment(comment: string): string {
const cmt = (comment ?? '').trim();
if(cmt.length === 0) {
throw new BadRequestException('A comment is required');
}
return cmt;
};
//rounding $ to 2 decimals
export function roundMoney2(num: number): number {
return Math.round((num + Number.EPSILON) * 100)/ 100;
};
export function computeMileageAmount(km: number, modifier: number): number {
if(km < 0) throw new BadRequestException('mileage must be positive');
if(modifier < 0) throw new BadRequestException('modifier must be positive');
return roundMoney2(km * modifier);
};
//compat. types with Prisma.Decimal. work around Prisma import in utils.
export type DecimalLike =
| number
| string
| { toNumber?: () => number }
| { toString?: () => string };
//safe conversion to number
export function toNumberSafe(value: DecimalLike): number {
if(typeof value === 'number') return value;
if(value && typeof (value as any).toNumber === 'function') return (value as any).toNumber();
return Number(
typeof (value as any)?.toString === 'function'
? (value as any).toString()
: value,
);
}
//map of a row for DayExpenseResponse
export function mapDbExpenseToDayResponse(row: {
date: Date;
amount: Prisma.Decimal | number | string | null;
mileage?: Prisma.Decimal | number | string | null;
comment: string;
is_approved: boolean;
bank_code?: { type?: string | null } | null;
}): ExpenseResponse {
const yyyyMmDd = row.date.toISOString().slice(0,10);
const toNum = (value: any)=> (value == null ? 0 : Number(value));
return {
date: yyyyMmDd,
type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'),
amount: toNum(row.amount),
comment: row.comment,
is_approved: row.is_approved,
...(row.mileage !== null ? { mileage: toNum(row.mileage) }: {}),
};
}

View File

@ -1,76 +1,30 @@
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { LeaveRequestsService } from "../services/leave-requests.service";
import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto";
import { LeaveRequests } from "@prisma/client";
import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { LeaveApprovalStatus, Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { Body, Controller, Post } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { LeaveRequestsService } from "../services/leave-request.service";
import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
import { LeaveTypes } from "@prisma/client";
@ApiTags('Leave Requests')
@ApiBearerAuth('access-token')
// @UseGuards()
@Controller('leave-requests')
export class LeaveRequestController {
constructor(private readonly leaveRequetsService: LeaveRequestsService){}
constructor(private readonly leave_service: LeaveRequestsService){}
@Post()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Create leave request' })
@ApiResponse({ status: 201, description: 'Leave request created',type: CreateLeaveRequestsDto })
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
create(@Body() dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
return this. leaveRequetsService.create(dto);
}
@Post('upsert')
async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
const { action, leave_requests } = await this.leave_service.handle(dto);
return { action, leave_requests };
}q
@Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Find all leave request' })
@ApiResponse({ status: 201, description: 'List of Leave requests found',type: LeaveRequestViewDto, isArray: true })
@ApiResponse({ status: 400, description: 'List of leave request not found' })
@UsePipes(new ValidationPipe({transform: true, whitelist: true}))
findAll(@Query() filters: SearchLeaveRequestsDto): Promise<LeaveRequestViewDto[]> {
return this.leaveRequetsService.findAll(filters);
}
//remove emp_id and use email
@Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Find leave request' })
@ApiResponse({ status: 201, description: 'Leave request found',type: LeaveRequestViewDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.findOne(id);
}
//remove emp_id and use email
@Patch(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Update leave request' })
@ApiResponse({ status: 201, description: 'Leave request updated',type: LeaveRequestViewDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.update(id, dto);
}
//TODO:
/*
@Get('archive')
findAllArchived(){...}
//remove emp_id and use email
@Delete(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Delete leave request' })
@ApiResponse({ status: 201, description: 'Leave request deleted',type: CreateLeaveRequestsDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.remove(id);
}
@Get('archive/:id')
findOneArchived(id){...}
*/
//remove emp_id and use email
@Patch('approval/:id')
updateApproval( @Param('id', ParseIntPipe) id: number,
@Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise<LeaveRequestViewDto> {
const approvalStatus = is_approved ?
LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED;
return this.leaveRequetsService.update(id, { approval_status: approvalStatus });
}
}
}

View File

@ -1,56 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { Type } from "class-transformer";
import { IsDateString, IsEmail, IsEnum, IsInt, IsISO8601, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateLeaveRequestsDto {
@IsEmail()
email: string;
@ApiProperty({
example: 7,
description: 'ID number of a leave-request code (link with bank-codes)',
})
@Type(()=> Number)
@IsInt()
bank_code_id: number;
@ApiProperty({
example: 'Sick or Vacation or Unpaid or Bereavement or Parental or Legal',
description: 'type of leave request for an accounting perception',
})
@IsEnum(LeaveTypes)
leave_type: LeaveTypes;
@ApiProperty({
example: '22/06/2463',
description: 'Leave request`s start date',
})
@IsISO8601()
start_date_time:string;
@ApiProperty({
example: '25/03/3019',
description: 'Leave request`s end date',
})
@IsOptional()
@IsISO8601()
end_date_time?: string;
@ApiProperty({
example: 'My precious',
description: 'Leave request`s comment',
})
@IsString()
@IsNotEmpty()
comment: string;
@ApiProperty({
example: 'True or False or Pending or Denied or Cancelled or Escalated',
description: 'Leave request`s approval status',
})
@IsEnum(LeaveApprovalStatus)
@IsOptional()
approval_status?: LeaveApprovalStatus;
}

View File

@ -1,13 +1,14 @@
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
export class LeaveRequestViewDto {
id!: number;
id: number;
leave_type!: LeaveTypes;
start_date_time!: string;
end_date_time!: string | null;
comment!: string | null;
date!: string;
comment!: string;
approval_status: LeaveApprovalStatus;
email!: string;
employee_full_name: string;
days_requested?: number;
employee_full_name!: string;
payable_hours?: number;
requested_hours?: number;
action?: 'create' | 'update' | 'delete';
}

View File

@ -1,30 +0,0 @@
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { Type } from "class-transformer";
import { IsOptional, IsInt, IsEnum, IsDateString, IsEmail } from "class-validator";
export class SearchLeaveRequestsDto {
@IsEmail()
email: string;
@IsOptional()
@Type(()=> Number)
@IsInt()
bank_code_id?: number;
@IsOptional()
@IsEnum(LeaveApprovalStatus)
approval_status?: LeaveApprovalStatus
@IsOptional()
@IsDateString()
start_date?: string;
@IsOptional()
@IsDateString()
end_date?: string;
@IsOptional()
@IsEnum(LeaveTypes)
leave_type?: LeaveTypes;
}

View File

@ -1,4 +0,0 @@
import { PartialType } from "@nestjs/swagger";
import { CreateLeaveRequestsDto } from "./create-leave-request.dto";
export class UpdateLeaveRequestsDto extends PartialType(CreateLeaveRequestsDto){}

View File

@ -0,0 +1,51 @@
import { IsEmail, IsArray, ArrayNotEmpty, ArrayUnique, IsISO8601, IsIn, IsOptional, IsString, IsNumber, Min, Max, IsEnum } from "class-validator";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { LeaveRequestViewDto } from "./leave-request-view.dto";
import { Type } from "class-transformer";
//sets wich function to call
export const UPSERT_ACTIONS = ['create', 'update', 'delete'] as const;
export type UpsertAction = (typeof UPSERT_ACTIONS)[number];
//sets wich types to use
export const REQUEST_TYPES = Object.values(LeaveTypes) as readonly LeaveTypes[];
export type RequestTypes = (typeof REQUEST_TYPES)[number];
//filter requests by type and action
export interface UpsertResult {
action: UpsertAction;
leave_requests: LeaveRequestViewDto[];
}
export class UpsertLeaveRequestDto {
@IsEmail()
email!: string;
@IsArray()
@ArrayNotEmpty()
@ArrayUnique()
@IsISO8601({}, { each: true })
dates!: string[];
@IsOptional()
@IsEnum(LeaveTypes)
type!: string;
@IsIn(UPSERT_ACTIONS)
action!: UpsertAction;
@IsOptional()
@IsString()
comment?: string;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(24)
requested_hours?: number;
@IsOptional()
@IsEnum(LeaveApprovalStatus)
approval_status?: LeaveApprovalStatus
}

View File

@ -1,13 +1,28 @@
import { PrismaService } from "src/prisma/prisma.service";
import { LeaveRequestController } from "./controllers/leave-requests.controller";
import { LeaveRequestsService } from "./services/leave-requests.service";
import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
import { Module } from "@nestjs/common";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service";
import { SickLeaveRequestsService } from "./services/sick-leave-requests.service";
import { LeaveRequestsService } from "./services/leave-request.service";
import { ShiftsModule } from "../shifts/shifts.module";
import { LeaveRequestsUtils } from "./utils/leave-request.util";
@Module({
imports: [BusinessLogicsModule],
imports: [BusinessLogicsModule, ShiftsModule],
controllers: [LeaveRequestController],
providers: [LeaveRequestsService],
exports: [LeaveRequestsService],
providers: [
VacationLeaveRequestsService,
SickLeaveRequestsService,
HolidayLeaveRequestsService,
LeaveRequestsService,
PrismaService,
LeaveRequestsUtils,
],
exports: [
LeaveRequestsService,
],
})
export class LeaveRequestsModule {}

View File

@ -1,14 +1,20 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { Prisma } from "@prisma/client";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { LeaveRequestArchiveRow } from "../utils/leave-requests-archive.select";
const toISO = (date: Date | null): string | null => (date ? date.toISOString().slice(0,10): null);
const toNum = (value?: Prisma.Decimal | null) => value ? Number(value) : undefined;
export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto {
const isoDate = row.date?.toISOString().slice(0, 10);
if (!isoDate) {
throw new Error(`Leave request #${row.id} has no date set.`);
}
return {
id: row.id,
leave_type: row.leave_type,
start_date_time: toISO(row.start_date_time)!,
end_date_time: toISO(row.end_date_time),
date: isoDate,
payable_hours: toNum(row.payable_hours),
requested_hours: toNum(row.requested_hours),
comment: row.comment,
approval_status: row.approval_status,
email,

View File

@ -1,19 +1,23 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { Prisma } from "@prisma/client";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { LeaveRequestRow } from "../utils/leave-requests.select";
function toISODateString(date:Date | null): string | null {
return date ? date.toISOString().slice(0,10) : null;
}
const toNum = (value?: Prisma.Decimal | null) =>
value !== null && value !== undefined ? Number(value) : undefined;
export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto {
const iso_date = row.date?.toISOString().slice(0, 10);
if (!iso_date) throw new Error(`Leave request #${row.id} has no date set.`);
return {
id: row.id,
leave_type: row.leave_type,
start_date_time: toISODateString(row.start_date_time)!,
end_date_time: toISODateString(row.end_date_time),
date: iso_date,
payable_hours: toNum(row.payable_hours),
requested_hours: toNum(row.requested_hours),
comment: row.comment,
approval_status: row.approval_status,
email: row.employee.user.email,
employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`
}
employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`,
};
}

View File

@ -0,0 +1,73 @@
import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto';
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { mapRowToView } from '../mappers/leave-requests.mapper';
import { leaveRequestsSelect } from '../utils/leave-requests.select';
import { LeaveRequestsUtils, normalizeDates, toDateOnly } from '../utils/leave-request.util';
@Injectable()
export class HolidayLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayService: HolidayService,
private readonly leaveUtils: LeaveRequestsUtils,
) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email);
const bank_code = await this.leaveUtils.resolveBankCodeByType(LeaveTypes.HOLIDAY);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
const dates = normalizeDates(dto.dates);
if (!dates.length) throw new BadRequestException('Dates array must not be empty');
const created: LeaveRequestViewDto[] = [];
for (const iso_date of dates) {
const date = toDateOnly(iso_date);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.HOLIDAY,
date,
},
},
select: { id: true },
});
if (existing) {
throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
}
const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
leave_type: LeaveTypes.HOLIDAY,
date,
comment: dto.comment ?? '',
requested_hours: dto.requested_hours ?? 8,
payable_hours: payable,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
},
select: leaveRequestsSelect,
});
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment);
}
created.push({ ...mapRowToView(row), action: 'create' });
}
return { action: 'create', leave_requests: created };
}
}

View File

@ -0,0 +1,243 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { roundToQuarterHour } from "src/common/utils/date-utils";
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service";
import { SickLeaveRequestsService } from "./sick-leave-requests.service";
import { VacationLeaveRequestsService } from "./vacation-leave-requests.service";
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { PrismaService } from "src/prisma/prisma.service";
import { LeaveRequestsUtils, normalizeDates, toDateOnly, toISODateKey } from "../utils/leave-request.util";
@Injectable()
export class LeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayLeaveService: HolidayLeaveRequestsService,
private readonly holidayService: HolidayService,
private readonly sickLogic: SickLeaveService,
private readonly sickLeaveService: SickLeaveRequestsService,
private readonly vacationLeaveService: VacationLeaveRequestsService,
private readonly vacationLogic: VacationService,
private readonly leaveUtils: LeaveRequestsUtils,
) {}
//handle distribution to the right service according to the selected type and action
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
switch (dto.type) {
case LeaveTypes.HOLIDAY:
if( dto.action === 'create'){
return this.holidayLeaveService.create(dto);
} else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.HOLIDAY);
} else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.HOLIDAY);
}
case LeaveTypes.VACATION:
if( dto.action === 'create'){
return this.vacationLeaveService.create(dto);
} else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.VACATION);
} else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.VACATION);
}
case LeaveTypes.SICK:
if( dto.action === 'create'){
return this.sickLeaveService.create(dto);
} else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.SICK);
} else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.SICK);
}
default:
throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`);
}
}
async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
const email = dto.email.trim();
const dates = normalizeDates(dto.dates);
const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email);
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const rows = await this.prisma.leaveRequests.findMany({
where: {
employee_id: employee_id,
leave_type: type,
date: { in: dates.map((d) => toDateOnly(d)) },
},
select: leaveRequestsSelect,
});
if (rows.length !== dates.length) {
const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`);
}
for (const row of rows) {
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
const iso = toISODateKey(row.date);
await this.leaveUtils.removeShift(email, employee_id, iso, type);
}
}
await this.prisma.leaveRequests.deleteMany({
where: { id: { in: rows.map((row) => row.id) } },
});
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const }));
return { action: "delete", leave_requests: deleted };
}
async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email);
const bank_code = await this.leaveUtils.resolveBankCodeByType(type);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = Number(bank_code.modifier ?? 1);
const dates = normalizeDates(dto.dates);
if (!dates.length) {
throw new BadRequestException("Dates array must not be empty");
}
const entries = await Promise.all(
dates.map(async (iso_date) => {
const date = toDateOnly(iso_date);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: type,
date,
},
},
select: leaveRequestsSelect,
});
if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`);
return { iso_date, date, existing };
}),
);
const updated: LeaveRequestViewDto[] = [];
if (type === LeaveTypes.SICK) {
const firstExisting = entries[0].existing;
const fallbackRequested =
firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined
? Number(firstExisting.requested_hours)
: 8;
const requested_hours_per_day = dto.requested_hours ?? fallbackRequested;
const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date,
);
const total_payable_hours = await this.sickLogic.calculateSickLeavePay(
employee_id,
reference_date,
entries.length,
requested_hours_per_day,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
for (const { iso_date, existing } of entries) {
const previous_status = existing.approval_status;
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
const row = await this.prisma.leaveRequests.update({
where: { id: existing.id },
data: {
comment: dto.comment ?? existing.comment,
requested_hours: requested_hours_per_day,
payable_hours: payable_rounded,
bank_code_id: bank_code.id,
approval_status: dto.approval_status ?? existing.approval_status,
},
select: leaveRequestsSelect,
});
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} else if (was_approved && !is_approved) {
await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
} else if (was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
}
updated.push({ ...mapRowToView(row), action: "update" });
}
return { action: "update", leave_requests: updated };
}
for (const { iso_date, date, existing } of entries) {
const previous_status = existing.approval_status;
const fallbackRequested =
existing.requested_hours !== null && existing.requested_hours !== undefined
? Number(existing.requested_hours)
: 8;
const requested_hours = dto.requested_hours ?? fallbackRequested;
let payable: number;
switch (type) {
case LeaveTypes.HOLIDAY:
payable = await this.holidayService.calculateHolidayPay(email, date, modifier);
break;
case LeaveTypes.VACATION: {
const days_requested = requested_hours / 8;
payable = await this.vacationLogic.calculateVacationPay(
employee_id,
date,
Math.max(0, days_requested),
modifier,
);
break;
}
default:
payable = existing.payable_hours !== null && existing.payable_hours !== undefined
? Number(existing.payable_hours)
: requested_hours;
}
const row = await this.prisma.leaveRequests.update({
where: { id: existing.id },
data: {
requested_hours,
comment: dto.comment ?? existing.comment,
payable_hours: payable,
bank_code_id: bank_code.id,
approval_status: dto.approval_status ?? existing.approval_status,
},
select: leaveRequestsSelect,
});
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} else if (was_approved && !is_approved) {
await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
} else if (was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
}
updated.push({ ...mapRowToView(row), action: "update" });
}
return { action: "update", leave_requests: updated };
}
}

View File

@ -1,175 +0,0 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto";
import { LeaveRequests, LeaveRequestsArchive } from "@prisma/client";
import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto";
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto";
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { LeaveRequestRow, leaveRequestsSelect } from "../utils/leave-requests.select";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { LeaveRequestsArchiveController } from "src/modules/archival/controllers/leave-requests-archive.controller";
import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select";
import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper";
import { mapArchiveRowToViewWithDays, mapRowToViewWithDays } from "../utils/leave-request.transform";
@Injectable()
export class LeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayService: HolidayService,
private readonly vacationService: VacationService,
private readonly sickLeaveService: SickLeaveService
) {}
//function to avoid using employee_id as identifier in the frontend.
private async resolveEmployeeIdByEmail(email: string): Promise<number> {
const employee = await this.prisma.employees.findFirst({
where: { user: { email} },
select: { id:true },
});
if(!employee) throw new NotFoundException(`Employee with email ${email} not found`);
return employee.id;
}
//create a leave-request without the use of employee_id
async create(dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
const employee_id = await this.resolveEmployeeIdByEmail(dto.email);
const row: LeaveRequestRow = await this.prisma.leaveRequests.create({
data: {
employee_id,
bank_code_id: dto.bank_code_id,
leave_type: dto.leave_type,
start_date_time: new Date(dto.start_date_time),
end_date_time: dto.end_date_time ? new Date(dto.end_date_time) : null,
comment: dto.comment,
approval_status: dto.approval_status ?? undefined,
},
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
}
//fetches all leave-requests using email
async findAll(filters: SearchLeaveRequestsDto): Promise<LeaveRequestViewDto[]> {
const {start_date, end_date,email, leave_type, approval_status, bank_code_id } = filters;
const where: any = {};
if (start_date) where.start_date_time = { ...(where.start_date_time ?? {}), gte: new Date(start_date) };
if (end_date) where.end_date_time = { ...(where.end_date_time ?? {}), lte: new Date(end_date) };
if (email) where.employee = { user: { email } };
if (leave_type) where.leave_type = leave_type;
if (approval_status) where.approval_status = approval_status;
if (typeof bank_code_id === 'number') where.bank_code_id = bank_code_id;
const rows= await this.prisma.leaveRequests.findMany({
where,
select: leaveRequestsSelect,
orderBy: { start_date_time: 'desc' },
});
return rows.map(mapRowToViewWithDays);
}
//fetch 1 leave-request using email
async findOne(id:number): Promise<LeaveRequestViewDto> {
const row: LeaveRequestRow | null = await this.prisma.leaveRequests.findUnique({
where: { id },
select: leaveRequestsSelect,
});
if(!row) throw new NotFoundException(`Leave Request #${id} not found`);
return mapRowToViewWithDays(row);
}
//updates 1 leave-request using email
async update(id: number, dto: UpdateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
await this.findOne(id);
const data: Record<string, any> = {};
if(dto.email !== undefined) data.employee_id = await this.resolveEmployeeIdByEmail(dto.email);
if(dto.leave_type !== undefined) data.bank_code_id = dto.bank_code_id;
if(dto.start_date_time !== undefined) data.start_date_time = new Date(dto.start_date_time);
if(dto.end_date_time !== undefined) data.end_date_time = new Date(dto.end_date_time);
if(dto.comment !== undefined) data.comment = dto.comment;
if(dto.approval_status !== undefined) data.approval_status = dto.approval_status;
const row: LeaveRequestRow = await this.prisma.leaveRequests.update({
where: { id },
data,
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
}
//removes 1 leave-request using email
async remove(id:number): Promise<LeaveRequestViewDto> {
await this.findOne(id);
const row: LeaveRequestRow = await this.prisma.leaveRequests.delete({
where: { id },
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
}
//archivation functions ******************************************************
async archiveExpired(): Promise<void> {
const now = new Date();
await this.prisma.$transaction(async transaction => {
//fetches expired leave requests
const expired = await transaction.leaveRequests.findMany({
where: { end_date_time: { lt: now } },
});
if(expired.length === 0) {
return;
}
//copy unto archive table
await transaction.leaveRequestsArchive.createMany({
data: expired.map(request => ({
leave_request_id: request.id,
employee_id: request.employee_id,
leave_type: request.leave_type,
start_date_time: request.start_date_time,
end_date_time: request.end_date_time,
comment: request.comment,
approval_status: request.approval_status,
})),
});
//delete from leave_requests table
await transaction.leaveRequests.deleteMany({
where: { id: { in: expired.map(request => request.id ) } },
});
});
}
//fetches all archived leave-requests
async findAllArchived(): Promise<LeaveRequestsArchive[]> {
return this.prisma.leaveRequestsArchive.findMany();
}
//remove emp_id and use email
//fetches an archived employee
async findOneArchived(id: number): Promise<LeaveRequestViewDto> {
const row: LeaveRequestArchiveRow | null = await this.prisma.leaveRequestsArchive.findUnique({
where: { id },
select: leaveRequestsArchiveSelect,
});
if(!row) throw new NotFoundException(`Archived Leave Request #${id} not found`);
const emp = await this.prisma.employees.findUnique({
where: { id: row.employee_id },
select: { user: {select: { email:true,
first_name: true,
last_name: true,
}}},
});
const email = emp?.user.email ?? "";
const full_name = emp ? `${emp.user.first_name} ${emp.user.last_name}` : "";
return mapArchiveRowToViewWithDays(row, email, full_name);
}
}

View File

@ -0,0 +1,93 @@
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { PrismaService } from "src/prisma/prisma.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { roundToQuarterHour } from "src/common/utils/date-utils";
import { LeaveRequestsUtils, normalizeDates, toDateOnly } from "../utils/leave-request.util";
@Injectable()
export class SickLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly sickService: SickLeaveService,
private readonly leaveUtils: LeaveRequestsUtils,
) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email);
const bank_code = await this.leaveUtils.resolveBankCodeByType(LeaveTypes.SICK);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates);
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const requested_hours_per_day = dto.requested_hours ?? 8;
const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date,
);
const total_payable_hours = await this.sickService.calculateSickLeavePay(
employee_id,
reference_date,
entries.length,
requested_hours_per_day,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.SICK,
date,
},
},
select: { id: true },
});
if (existing) {
throw new BadRequestException(`Sick request already exists for ${iso}`);
}
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
leave_type: LeaveTypes.SICK,
comment: dto.comment ?? "",
requested_hours: requested_hours_per_day,
payable_hours: payable_rounded,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date,
},
select: leaveRequestsSelect,
});
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment);
}
created.push({ ...mapRowToView(row), action: "create" });
}
return { action: "create", leave_requests: created };
}
}

View File

@ -0,0 +1,88 @@

import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { PrismaService } from "src/prisma/prisma.service";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { roundToQuarterHour } from "src/common/utils/date-utils";
import { LeaveRequestsUtils, normalizeDates, toDateOnly } from "../utils/leave-request.util";
@Injectable()
export class VacationLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly vacationService: VacationService,
private readonly leaveUtils: LeaveRequestsUtils,
) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email);
const bank_code = await this.leaveUtils.resolveBankCodeByType(LeaveTypes.VACATION);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates);
const requested_hours_per_day = dto.requested_hours ?? 8;
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const entries = dates
.map((iso) => ({ iso, date: toDateOnly(iso) }))
.sort((a, b) => a.date.getTime() - b.date.getTime());
const start_date = entries[0].date;
const total_payable_hours = await this.vacationService.calculateVacationPay(
employee_id,
start_date,
entries.length,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.VACATION,
date,
},
},
select: { id: true },
});
if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
payable_hours: payable_rounded,
requested_hours: requested_hours_per_day,
leave_type: LeaveTypes.VACATION,
comment: dto.comment ?? "",
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date,
},
select: leaveRequestsSelect,
});
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment);
}
created.push({ ...mapRowToView(row), action: "create" });
}
return { action: "create", leave_requests: created };
}
}

View File

@ -1,32 +1,19 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { LeaveRequestArchiveRow } from "./leave-requests-archive.select";
import { LeaveRequestRow } from "./leave-requests.select";
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
import { mapArchiveRowToView } from '../mappers/leave-requests-archive.mapper';
import { mapRowToView } from '../mappers/leave-requests.mapper';
import { LeaveRequestArchiveRow } from './leave-requests-archive.select';
import { LeaveRequestRow } from './leave-requests.select';
function toUTCDateOnly(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
const MS_PER_DAY = 86_400_000;
function computeDaysRequested(start_date: Date, end_date?: Date | null): number {
const start = toUTCDateOnly(start_date);
const end = toUTCDateOnly(end_date ?? start_date);
const diff = Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1;
return Math.max(1, diff);
}
/** Active (table leave_requests) : map + days_requested */
/** Active (table leave_requests) : proxy to base mapper */
export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto {
const view = mapRowToView(row);
view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time);
return view;
return mapRowToView(row);
}
/** Archive (table leave_requests_archive) : map + days_requested */
export function mapArchiveRowToViewWithDays(row: LeaveRequestArchiveRow, email: string, employee_full_name?: string):
LeaveRequestViewDto {
const view = mapArchiveRowToView(row, email, employee_full_name!);
view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time);
return view;
/** Archive (table leave_requests_archive) : proxy to base mapper */
export function mapArchiveRowToViewWithDays(
row: LeaveRequestArchiveRow,
email: string,
employee_full_name?: string,
): LeaveRequestViewDto {
return mapArchiveRowToView(row, email, employee_full_name!);
}

View File

@ -0,0 +1,124 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveTypes } from "@prisma/client";
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class LeaveRequestsUtils {
constructor(
private readonly prisma: PrismaService,
private readonly shiftsCommand: ShiftsCommandService,
){}
async resolveEmployeeIdByEmail(email: string): Promise<number> {
const employee = await this.prisma.employees.findFirst({
where: { user: { email } },
select: { id: true },
});
if (!employee) {
throw new NotFoundException(`Employee with email ${email} not found`);
}
return employee.id;
}
async resolveBankCodeByType(type: LeaveTypes) {
const bankCode = await this.prisma.bankCodes.findFirst({
where: { type },
select: { id: true, bank_code: true, modifier: true },
});
if (!bankCode) throw new BadRequestException(`Bank code type "${type}" not found`);
return bankCode;
}
async syncShift(
email: string,
employee_id: number,
iso_date: string,
hours: number,
type: LeaveTypes,
comment?: string,
) {
if (hours <= 0) return;
const duration_minutes = Math.round(hours * 60);
if (duration_minutes > 8 * 60) {
throw new BadRequestException("Amount of hours cannot exceed 8 hours per day.");
}
const start_minutes = 8 * 60;
const end_minutes = start_minutes + duration_minutes;
const toHHmm = (total: number) =>
`${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
const existing = await this.prisma.shifts.findFirst({
where: {
date: new Date(iso_date),
bank_code: { type },
timesheet: { employee_id: employee_id },
},
include: { bank_code: true },
});
await this.shiftsCommand.upsertShiftsByDate(email, iso_date, {
old_shift: existing
? {
start_time: existing.start_time.toISOString().slice(11, 16),
end_time: existing.end_time.toISOString().slice(11, 16),
type: existing.bank_code?.type ?? type,
is_remote: existing.is_remote,
comment: existing.comment ?? undefined,
}
: undefined,
new_shift: {
start_time: toHHmm(start_minutes),
end_time: toHHmm(end_minutes),
is_remote: existing?.is_remote ?? false,
comment: comment ?? existing?.comment ?? "",
type: type,
},
});
}
async removeShift(
email: string,
employee_id: number,
iso_date: string,
type: LeaveTypes,
) {
const existing = await this.prisma.shifts.findFirst({
where: {
date: new Date(iso_date),
bank_code: { type },
timesheet: { employee_id: employee_id },
},
include: { bank_code: true },
});
if (!existing) return;
await this.shiftsCommand.upsertShiftsByDate(email, iso_date, {
old_shift: {
start_time: existing.start_time.toISOString().slice(11, 16),
end_time: existing.end_time.toISOString().slice(11, 16),
type: existing.bank_code?.type ?? type,
is_remote: existing.is_remote,
comment: existing.comment ?? undefined,
},
});
}
}
export const toDateOnly = (iso: string): Date => {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException(`Invalid date: ${iso}`);
}
date.setHours(0, 0, 0, 0);
return date;
};
export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10);
export const normalizeDates = (dates: string[]): string[] =>
Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso)))));

View File

@ -6,11 +6,11 @@ export const leaveRequestsArchiveSelect = {
archived_at: true,
employee_id: true,
leave_type: true,
start_date_time: true,
end_date_time: true,
date: true,
payable_hours: true,
requested_hours: true,
comment: true,
approval_status: true,
} satisfies Prisma.LeaveRequestsArchiveSelect;
export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>;

View File

@ -5,8 +5,9 @@ export const leaveRequestsSelect = {
id: true,
bank_code_id: true,
leave_type: true,
start_date_time: true,
end_date_time: true,
date: true,
payable_hours: true,
requested_hours: true,
comment: true,
approval_status: true,
employee: { select: {

View File

@ -18,13 +18,6 @@ export class PayPeriodsController {
private readonly commandService: PayPeriodsCommandService,
) {}
@Get()
@ApiOperation({ summary: 'Find all pay period' })
@ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true })
async findAll(): Promise<PayPeriodDto[]> {
return this.queryService.findAll();
}
@Get('current-and-all')
@ApiOperation({summary: 'Return current pay period and the full list'})
@ApiQuery({name: 'date', required:false, example: '2025-08-11', description:'Override for resolving the current period'})
@ -95,4 +88,16 @@ export class PayPeriodsController {
): Promise<PayPeriodOverviewDto> {
return this.queryService.getOverviewByYearPeriod(year, period_no);
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @Get()
// @ApiOperation({ summary: 'Find all pay period' })
// @ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true })
// async findAll(): Promise<PayPeriodDto[]> {
// return this.queryService.findAll();
// }
}

View File

@ -7,6 +7,9 @@ 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";
@Module({
imports: [PrismaModule, TimesheetsModule],
@ -16,12 +19,14 @@ import { ShiftsCommandService } from "../shifts/services/shifts-command.service"
TimesheetsCommandService,
ExpensesCommandService,
ShiftsCommandService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,
],
controllers: [PayPeriodsController],
exports: [
PayPeriodsQueryService,
PayPeriodsCommandService,
PayPeriodsQueryService,
]
})

View File

@ -0,0 +1,14 @@
import { Body, Controller, Param, Patch } from "@nestjs/common";
import { PreferencesService } from "../services/preferences.service";
import { PreferencesDto } from "../dtos/preferences.dto";
@Controller('preferences')
export class PreferencesController {
constructor(private readonly service: PreferencesService){}
@Patch(':email')
async updatePreferences(@Param('email') email: string, @Body()payload: PreferencesDto) {
return this.service.updatePreferences(email, payload);
}
}

View File

@ -0,0 +1,16 @@
import { IsBoolean, IsEmail, IsString } from "class-validator";
export class PreferencesDto {
@IsBoolean()
notifications: boolean;
@IsBoolean()
dark_mode: boolean;
@IsBoolean()
lang_switch: boolean;
@IsBoolean()
lefty_mode: boolean;
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { PreferencesController } from "./controllers/preferences.controller";
import { PreferencesService } from "./services/preferences.service";
@Module({
controllers: [ PreferencesController ],
providers: [ PreferencesService ],
exports: [ PreferencesService ],
})
export class PreferencesModule {}

View File

@ -0,0 +1,32 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Preferences } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { PreferencesDto } from "../dtos/preferences.dto";
@Injectable()
export class PreferencesService {
constructor(private readonly prisma: PrismaService){}
async resolveUserIdWithEmail(email: string): Promise<string> {
const user = await this.prisma.users.findFirst({
where: { email },
select: { id: true },
});
if(!user) throw new NotFoundException(`User with email ${ email } not found`);
return user.id;
}
async updatePreferences(email: string, dto: PreferencesDto ): Promise<Preferences> {
const user_id = await this.resolveUserIdWithEmail(email);
return this.prisma.preferences.update({
where: { user_id },
data: {
notifications: dto.notifications,
dark_mode: dto.dark_mode,
lang_switch: dto.lang_switch,
lefty_mode: dto.lefty_mode,
},
include: { user: true },
});
}
}

View File

@ -19,7 +19,6 @@ export class ShiftsController {
constructor(
private readonly shiftsService: ShiftsQueryService,
private readonly shiftsCommandService: ShiftsCommandService,
private readonly shiftsValidationService: ShiftsQueryService,
){}
@Put('upsert/:email/:date')
@ -28,53 +27,7 @@ export class ShiftsController {
@Param('date') date_param: string,
@Body() payload: UpsertShiftDto,
) {
return this.shiftsCommandService.upsertShfitsByDate(email_param, date_param, payload);
}
@Post()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Create shift' })
@ApiResponse({ status: 201, description: 'Shift created',type: CreateShiftDto })
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
create(@Body() dto: CreateShiftDto): Promise<Shifts> {
return this.shiftsService.create(dto);
}
@Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find all shifts' })
@ApiResponse({ status: 201, description: 'List of shifts found',type: CreateShiftDto, isArray: true })
@ApiResponse({ status: 400, description: 'List of shifts not found' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
findAll(@Query() filters: SearchShiftsDto) {
return this.shiftsService.findAll(filters);
}
@Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find shift' })
@ApiResponse({ status: 201, description: 'Shift found',type: CreateShiftDto })
@ApiResponse({ status: 400, description: 'Shift not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<Shifts> {
return this.shiftsService.findOne(id);
}
@Patch(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Update shift' })
@ApiResponse({ status: 201, description: 'Shift updated',type: CreateShiftDto })
@ApiResponse({ status: 400, description: 'Shift not found' })
update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise<Shifts> {
return this.shiftsService.update(id, dto);
}
@Delete(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Delete shift' })
@ApiResponse({ status: 201, description: 'Shift deleted',type: CreateShiftDto })
@ApiResponse({ status: 400, description: 'Shift not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Shifts> {
return this.shiftsService.remove(id);
return this.shiftsCommandService.upsertShiftsByDate(email_param, date_param, payload);
}
@Patch('approval/:id')
@ -85,15 +38,14 @@ export class ShiftsController {
@Get('summary')
async getSummary( @Query() query: GetShiftsOverviewDto): Promise<OverviewRow[]> {
return this.shiftsValidationService.getSummary(query.period_id);
return this.shiftsService.getSummary(query.period_id);
}
@Get('export.csv')
@Header('Content-Type', 'text/csv; charset=utf-8')
@Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"')
async exportCsv(@Query() query: GetShiftsOverviewDto): Promise<Buffer>{
const rows = await this.shiftsValidationService.getSummary(query.period_id);
const rows = await this.shiftsService.getSummary(query.period_id);
//CSV Headers
const header = [
'full_name',
@ -125,4 +77,54 @@ export class ShiftsController {
return Buffer.from('\uFEFF' + header + body, 'utf8');
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @Post()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Create shift' })
// @ApiResponse({ status: 201, description: 'Shift created',type: CreateShiftDto })
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
// create(@Body() dto: CreateShiftDto): Promise<Shifts> {
// return this.shiftsService.create(dto);
// }
// @Get()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find all shifts' })
// @ApiResponse({ status: 201, description: 'List of shifts found',type: CreateShiftDto, isArray: true })
// @ApiResponse({ status: 400, description: 'List of shifts not found' })
// @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
// findAll(@Query() filters: SearchShiftsDto) {
// return this.shiftsService.findAll(filters);
// }
// @Get(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find shift' })
// @ApiResponse({ status: 201, description: 'Shift found',type: CreateShiftDto })
// @ApiResponse({ status: 400, description: 'Shift not found' })
// findOne(@Param('id', ParseIntPipe) id: number): Promise<Shifts> {
// return this.shiftsService.findOne(id);
// }
// @Patch(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Update shift' })
// @ApiResponse({ status: 201, description: 'Shift updated',type: CreateShiftDto })
// @ApiResponse({ status: 400, description: 'Shift not found' })
// update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise<Shifts> {
// return this.shiftsService.update(id, dto);
// }
// @Delete(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Delete shift' })
// @ApiResponse({ status: 201, description: 'Shift deleted',type: CreateShiftDto })
// @ApiResponse({ status: 400, description: 'Shift not found' })
// remove(@Param('id', ParseIntPipe) id: number): Promise<Shifts> {
// return this.shiftsService.remove(id);
// }
}

View File

@ -1,7 +1,7 @@
import { Type } from "class-transformer";
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
export const COMMENT_MAX_LENGTH = 512;
export const COMMENT_MAX_LENGTH = 280;
export class ShiftPayloadDto {

View File

@ -20,7 +20,7 @@ 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):
async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto):
Promise<{ action: UpsertAction; day: DayShiftResponse[] }> {
const { old_shift, new_shift } = dto;

View File

@ -99,10 +99,10 @@ export class ShiftsQueryService {
data: {
...(timesheet_id !== undefined && { timesheet_id }),
...(bank_code_id !== undefined && { bank_code_id }),
...(date !== undefined && { date }),
...(start_time !== undefined && { start_time }),
...(end_time !== undefined && { end_time }),
...(comment !== undefined && { comment }),
...(date !== undefined && { date }),
...(start_time !== undefined && { start_time }),
...(end_time !== undefined && { end_time }),
...(comment !== undefined && { comment }),
},
include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true,
@ -115,7 +115,7 @@ export class ShiftsQueryService {
return this.prisma.shifts.delete({ where: { id } });
}
async getSummary(period_id: number): Promise<OverviewRow[]> {
async getSummary(period_id: number): Promise<OverviewRow[]> {
//fetch pay-period to display
const period = await this.prisma.payPeriods.findFirst({
where: { pay_period_no: period_id },

View File

@ -50,27 +50,34 @@ export class TimesheetsController {
}
@Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find timesheet' })
@ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto })
@ApiResponse({ status: 400, description: 'Timesheet not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
return this.timesheetsQuery.findOne(id);
}
@Delete(':id')
// @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Delete timesheet' })
@ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto })
@ApiResponse({ status: 400, description: 'Timesheet not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
return this.timesheetsQuery.remove(id);
}
@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.timesheetsCommand.updateApproval(id, isApproved);
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @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.timesheetsCommand.updateApproval(id, isApproved);
// }
// @Get(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find timesheet' })
// @ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto })
// @ApiResponse({ status: 400, description: 'Timesheet not found' })
// findOne(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
// return this.timesheetsQuery.findOne(id);
// }
// @Delete(':id')
// // @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Delete timesheet' })
// @ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto })
// @ApiResponse({ status: 400, description: 'Timesheet not found' })
// remove(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
// return this.timesheetsQuery.remove(id);
// }
}

View File

@ -1,12 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { Timesheets, TimesheetsArchive } from '@prisma/client';
import { OvertimeService } from 'src/modules/business-logics/services/overtime.service';
import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { Injectable, NotFoundException } from '@nestjs/common';
import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers';
import { computeHours, 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 type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers';
import { TimesheetDto } from '../dtos/overview-timesheet.dto';
@Injectable()
@ -174,14 +174,14 @@ export class TimesheetsQueryService {
const to_HH_mm = (date: Date) => date.toISOString().slice(11, 16);
//maps all shifts of selected timesheet
const shifts = timesheet.shift.map((sft) => ({
bank_type: sft.bank_code?.type ?? '',
date: formatDateISO(sft.date),
start_time: to_HH_mm(sft.start_time),
end_time: to_HH_mm(sft.end_time),
comment: sft.comment ?? '',
is_approved: sft.is_approved ?? false,
is_remote: sft.is_remote ?? false,
const shifts = timesheet.shift.map((shift_row) => ({
bank_type: shift_row.bank_code?.type ?? '',
date: formatDateISO(shift_row.date),
start_time: to_HH_mm(shift_row.start_time),
end_time: to_HH_mm(shift_row.end_time),
comment: shift_row.comment ?? '',
is_approved: shift_row.is_approved ?? false,
is_remote: shift_row.is_remote ?? false,
}));
//maps all expenses of selected timsheet

View File

@ -5,6 +5,9 @@ import { BusinessLogicsModule } from 'src/modules/business-logics/business-logic
import { TimesheetsCommandService } from './services/timesheets-command.service';
import { ShiftsCommandService } from '../shifts/services/shifts-command.service';
import { ExpensesCommandService } from '../expenses/services/expenses-command.service';
import { BankCodesRepo } from '../expenses/repos/bank-codes.repo';
import { TimesheetsRepo } from '../expenses/repos/timesheets.repo';
import { EmployeesRepo } from '../expenses/repos/employee.repo';
@Module({
imports: [BusinessLogicsModule],
@ -13,7 +16,10 @@ import { ExpensesCommandService } from '../expenses/services/expenses-command.se
TimesheetsQueryService,
TimesheetsCommandService,
ShiftsCommandService,
ExpensesCommandService
ExpensesCommandService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,
],
exports: [TimesheetsQueryService],
})