feat(schedule_presets): module schedule_presets setup. Ajustments to seeders to match new realities

This commit is contained in:
Matthieu Haineault 2025-10-08 16:45:37 -04:00
parent 9b169d43c8
commit 83792e596a
16 changed files with 884 additions and 142 deletions

View File

@ -1072,6 +1072,68 @@
"pay-periods" "pay-periods"
] ]
} }
},
"/schedule-presets/{email}": {
"put": {
"operationId": "SchedulePresetsController_upsert",
"parameters": [
{
"name": "email",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "action",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SchedulePresetsDto"
}
}
}
},
"responses": {
"200": {
"description": ""
}
},
"tags": [
"SchedulePresets"
]
},
"get": {
"operationId": "SchedulePresetsController_findListByEmail",
"parameters": [
{
"name": "email",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"SchedulePresets"
]
}
} }
}, },
"info": { "info": {
@ -1584,6 +1646,10 @@
"label", "label",
"employees_overview" "employees_overview"
] ]
},
"SchedulePresetsDto": {
"type": "object",
"properties": {}
} }
} }
} }

View File

@ -0,0 +1,48 @@
-- CreateEnum
CREATE TYPE "Weekday" AS ENUM ('SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT');
-- AlterTable
ALTER TABLE "preferences" ADD COLUMN "id" SERIAL NOT NULL,
ADD CONSTRAINT "preferences_pkey" PRIMARY KEY ("id");
-- CreateTable
CREATE TABLE "schedule_presets" (
"id" SERIAL NOT NULL,
"employee_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "schedule_presets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "schedule_preset_shifts" (
"id" SERIAL NOT NULL,
"preset_id" INTEGER NOT NULL,
"bank_code_id" INTEGER NOT NULL,
"sort_order" INTEGER NOT NULL,
"start_time" TIME(0) NOT NULL,
"end_time" TIME(0) NOT NULL,
"is_remote" BOOLEAN NOT NULL DEFAULT false,
"week_day" "Weekday" NOT NULL,
CONSTRAINT "schedule_preset_shifts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "schedule_presets_employee_id_name_key" ON "schedule_presets"("employee_id", "name");
-- CreateIndex
CREATE INDEX "schedule_preset_shifts_preset_id_week_day_idx" ON "schedule_preset_shifts"("preset_id", "week_day");
-- CreateIndex
CREATE UNIQUE INDEX "schedule_preset_shifts_preset_id_week_day_sort_order_key" ON "schedule_preset_shifts"("preset_id", "week_day", "sort_order");
-- AddForeignKey
ALTER TABLE "schedule_presets" ADD CONSTRAINT "schedule_presets_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "schedule_preset_shifts" ADD CONSTRAINT "schedule_preset_shifts_preset_id_fkey" FOREIGN KEY ("preset_id") REFERENCES "schedule_presets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "schedule_preset_shifts" ADD CONSTRAINT "schedule_preset_shifts_bank_code_id_fkey" FOREIGN KEY ("bank_code_id") REFERENCES "bank_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -3,41 +3,46 @@ import { PrismaClient, Roles } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// base sans underscore, en string // base sans underscore, en string
const BASE_PHONE = "1100000000"; const BASE_PHONE = '1100000000';
function emailFor(i: number) { function emailFor(i: number) {
return `user${i + 1}@example.test`; return `user${i + 1}@example.test`;
} }
async function main() { async function main() {
const usersData: { type UserSeed = {
first_name: string; first_name: string;
last_name: string; last_name: string;
email: string; email: string;
phone_number: string; phone_number: string;
residence?: string | null; residence?: string | null;
role: Roles; role: Roles;
}[] = []; };
const firstNames = ['Alex','Sam','Chris','Jordan','Taylor','Morgan','Jamie','Robin','Avery','Casey']; const usersData: UserSeed[] = [];
const lastNames = ['Smith','Johnson','Williams','Brown','Jones','Miller','Davis','Wilson','Taylor','Clark'];
const pick = <T>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; const firstNames = ['Alex', 'Sam', 'Chris', 'Jordan', 'Taylor', 'Morgan', 'Jamie', 'Robin', 'Avery', 'Casey'];
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Miller', 'Davis', 'Wilson', 'Taylor', 'Clark'];
// 40 employees, avec une distribution initiale const pick = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
const rolesForEmployees: Roles[] = [
Roles.ADMIN, /**
...Array(4).fill(Roles.SUPERVISOR), // 4 superviseurs * Objectif total: 50 users
Roles.HR, * - 39 employés génériques (dont ADMIN=1, SUPERVISOR=3, HR=1, ACCOUNTING=1, EMPLOYEE=33)
Roles.ACCOUNTING, * - +1 superviseur spécial "User Test" (=> 40 employés)
...Array(33).fill(Roles.EMPLOYEE), * - 10 customers
*/
const rolesForEmployees39: Roles[] = [
Roles.ADMIN, // 1
...Array(3).fill(Roles.SUPERVISOR), // 3 supervisors (le 4e sera "User Test")
Roles.HR, // 1
Roles.ACCOUNTING, // 1
...Array(33).fill(Roles.EMPLOYEE), // 33
// total = 39
]; ];
// --- Normalisation : forcer user5@example.test à SUPERVISOR --- // --- 39 employés génériques: user1..user39@example.test
// user5 => index 4 (i = 4) for (let i = 0; i < 39; i++) {
rolesForEmployees[4] = Roles.SUPERVISOR;
for (let i = 0; i < 40; i++) {
const fn = pick(firstNames); const fn = pick(firstNames);
const ln = pick(lastNames); const ln = pick(lastNames);
usersData.push({ usersData.push({
@ -46,12 +51,12 @@ async function main() {
email: emailFor(i), email: emailFor(i),
phone_number: BASE_PHONE + i.toString(), phone_number: BASE_PHONE + i.toString(),
residence: Math.random() < 0.5 ? 'QC' : 'ON', residence: Math.random() < 0.5 ? 'QC' : 'ON',
role: rolesForEmployees[i], role: rolesForEmployees39[i],
}); });
} }
// 10 customers // --- 10 customers: user40..user49@example.test
for (let i = 40; i < 50; i++) { for (let i = 39; i < 49; i++) {
const fn = pick(firstNames); const fn = pick(firstNames);
const ln = pick(lastNames); const ln = pick(lastNames);
usersData.push({ usersData.push({
@ -64,29 +69,132 @@ async function main() {
}); });
} }
// 1) Insert (sans doublons) // 1) Insert des 49 génériques (skipDuplicates pour rejouer le seed sans erreurs)
await prisma.users.createMany({ data: usersData, skipDuplicates: true }); await prisma.users.createMany({ data: usersData, skipDuplicates: true });
// 2) Validation/Correction post-insert : // 2) Upsert du superviseur spécial "User Test"
// - garantir que user5@example.test est SUPERVISOR const specialEmail = 'user@targointernet.com';
// - si jamais le projet avait un user avec la typo, on tente aussi de le corriger (fallback) const specialUser = await prisma.users.upsert({
const targetEmails = ['user5@example.test', 'user5@examplte.tset']; where: { email: specialEmail },
for (const email of targetEmails) { update: {
try { first_name: 'User',
await prisma.users.update({ last_name: 'Test',
where: { email }, role: Roles.SUPERVISOR,
data: { role: Roles.SUPERVISOR }, residence: 'QC',
phone_number: BASE_PHONE + '999',
},
create: {
first_name: 'User',
last_name: 'Test',
email: specialEmail,
role: Roles.SUPERVISOR,
residence: 'QC',
phone_number: BASE_PHONE + '999',
},
});
// 3) Créer/mettre à jour les entrées Employees pour tous les rôles employés
const employeeUsers = await prisma.users.findMany({
where: { role: { in: [Roles.ADMIN, Roles.SUPERVISOR, Roles.HR, Roles.ACCOUNTING, Roles.EMPLOYEE] } },
orderBy: { email: 'asc' },
});
const firstWorkDay = new Date('2025-01-06'); // à adapter à ton contexte
for (let i = 0; i < employeeUsers.length; i++) {
const u = employeeUsers[i];
await prisma.employees.upsert({
where: { user_id: u.id },
update: {
is_supervisor: u.role === Roles.SUPERVISOR,
job_title: u.role,
},
create: {
user_id: u.id,
is_supervisor: u.role === Roles.SUPERVISOR,
external_payroll_id: 1000 + i, // à adapter
company_code: 1, // à adapter
first_work_day: firstWorkDay,
job_title: u.role,
},
});
}
// 4) Répartition des 33 EMPLOYEE sur 4 superviseurs: 8/8/8/9 (9 pour User Test)
const supervisors = await prisma.employees.findMany({
where: { is_supervisor: true, user: { role: Roles.SUPERVISOR } },
include: { user: true },
orderBy: { id: 'asc' },
});
const userTestSupervisor = supervisors.find((s) => s.user.email === specialEmail);
if (!userTestSupervisor) {
throw new Error('Employee(User Test) introuvable — vérifie le upsert Users/Employees.');
}
const plainEmployees = await prisma.employees.findMany({
where: { is_supervisor: false, user: { role: Roles.EMPLOYEE } },
orderBy: { id: 'asc' },
});
// Si la configuration est bien 4 superviseurs + 33 employés, on force 8/8/8/9 avec 9 pour User Test.
if (supervisors.length === 4 && plainEmployees.length === 33) {
const others = supervisors.filter((s) => s.id !== userTestSupervisor.id);
// ordre: autres (3) puis User Test en dernier (reçoit 9)
const ordered = [...others, userTestSupervisor];
const chunks = [
plainEmployees.slice(0, 8), // -> sup 0
plainEmployees.slice(8, 16), // -> sup 1
plainEmployees.slice(16, 24), // -> sup 2
plainEmployees.slice(24, 33), // -> sup 3 (User Test) = 9
];
for (let b = 0; b < chunks.length; b++) {
const sup = ordered[b];
for (const emp of chunks[b]) {
await prisma.employees.update({
where: { id: emp.id },
data: { supervisor_id: sup.id },
});
}
}
} else {
// fallback: distribution round-robin si la config diffère
console.warn(
`Répartition fallback (round-robin). Supervisors=${supervisors.length}, Employees=${plainEmployees.length}`
);
const others = supervisors.filter((s) => s.id !== userTestSupervisor.id);
const ordered = [...others, userTestSupervisor];
for (let i = 0; i < plainEmployees.length; i++) {
const sup = ordered[i % ordered.length];
await prisma.employees.update({
where: { id: plainEmployees[i].id },
data: { supervisor_id: sup.id },
}); });
console.log(`✓ Validation: ${email} est SUPERVISOR`);
break; // on s'arrête dès qu'on a corrigé l'un des deux
} catch {
// ignore si non trouvé, on tente l'autre
} }
} }
// 3) Petite vérif : compter les superviseurs pour sanity check // 5) Sanity checks
const totalUsers = await prisma.users.count();
const supCount = await prisma.users.count({ where: { role: Roles.SUPERVISOR } }); const supCount = await prisma.users.count({ where: { role: Roles.SUPERVISOR } });
console.log(`✓ Users: 50 rows (40 employees, 10 customers) — SUPERVISORS: ${supCount}`); const empCount = await prisma.users.count({ where: { role: Roles.EMPLOYEE } });
const countForUserTest = await prisma.employees.count({
where: { supervisor_id: userTestSupervisor.id, is_supervisor: false },
});
console.log(`✓ Users total: ${totalUsers} (attendu 50)`);
console.log(`✓ Supervisors: ${supCount} (attendu 4)`);
console.log(`✓ Employees : ${empCount} (attendu 33)`);
console.log(`✓ Employés sous User Test: ${countForUserTest} (attendu 9)`);
} }
main().finally(() => prisma.$disconnect()); main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -42,6 +42,7 @@ function centsToAmountString(cents: number): string {
const c = abs % 100; const c = abs % 100;
return `${sign}${dollars}.${c.toString().padStart(2, '0')}`; return `${sign}${dollars}.${c.toString().padStart(2, '0')}`;
} }
// Tire un multiple de STEP_CENTS entre minCents et maxCents (inclus) // Tire un multiple de STEP_CENTS entre minCents et maxCents (inclus)
function rndQuantizedCents(minCents: number, maxCents: number, step = STEP_CENTS): number { function rndQuantizedCents(minCents: number, maxCents: number, step = STEP_CENTS): number {
const qmin = Math.ceil(minCents / step); const qmin = Math.ceil(minCents / step);
@ -65,7 +66,7 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) {
async function main() { async function main() {
// Codes d'EXPENSES (exemples) // Codes d'EXPENSES (exemples)
const BANKS = ['G517', 'G503', 'G502', 'G202', 'G234'] as const; const BANKS = ['G517', 'G503', 'G502', 'G202'] as const;
// Précharger les bank codes // Précharger les bank codes
const bcRows = await prisma.bankCodes.findMany({ const bcRows = await prisma.bankCodes.findMany({
@ -119,19 +120,16 @@ async function main() {
// Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard // Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard
let amount: string; let amount: string;
switch (code) { switch (code) {
case 'G503': // petites fournitures case 'G503': // kilométrage
amount = rndAmount(1000, 7500); // 10.00 à 75.00 amount = rndAmount(1000, 7500); // 10.00 à 75.00
break; break;
case 'G502': // repas case 'G502': // per_diem
amount = rndAmount(1500, 3000); // 15.00 à 30.00 amount = rndAmount(1500, 3000); // 15.00 à 30.00
break; break;
case 'G202': // essence case 'G202': // allowance /prime de garde
amount = rndAmount(2000, 15000); // 20.00 à 150.00 amount = rndAmount(2000, 15000); // 20.00 à 150.00
break; break;
case 'G234': // hébergement case 'G517': // expenses
amount = rndAmount(6000, 25000); // 60.00 à 250.00
break;
case 'G517': // péages / divers
default: default:
amount = rndAmount(500, 5000); // 5.00 à 50.00 amount = rndAmount(500, 5000); // 5.00 à 50.00
break; break;

View File

@ -29,6 +29,7 @@ model Users {
employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive")
customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") customer_archive CustomersArchive[] @relation("UserToCustomersToArchive")
preferences Preferences? @relation("UserPreferences") preferences Preferences? @relation("UserPreferences")
@@map("users") @@map("users")
} }
@ -36,6 +37,9 @@ model Employees {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user Users @relation("UserEmployee", fields: [user_id], references: [id]) user Users @relation("UserEmployee", fields: [user_id], references: [id])
user_id String @unique @db.Uuid user_id String @unique @db.Uuid
supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id])
supervisor_id Int?
external_payroll_id Int external_payroll_id Int
company_code Int company_code Int
first_work_day DateTime @db.Date first_work_day DateTime @db.Date
@ -43,14 +47,13 @@ model Employees {
job_title String? job_title String?
is_supervisor Boolean @default(false) is_supervisor Boolean @default(false)
supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id])
supervisor_id Int?
crew Employees[] @relation("EmployeeSupervisor")
crew Employees[] @relation("EmployeeSupervisor")
archive EmployeesArchive[] @relation("EmployeeToArchive") archive EmployeesArchive[] @relation("EmployeeToArchive")
timesheet Timesheets[] @relation("TimesheetEmployee") timesheet Timesheets[] @relation("TimesheetEmployee")
leave_request LeaveRequests[] @relation("LeaveRequestEmployee") leave_request LeaveRequests[] @relation("LeaveRequestEmployee")
supervisor_archive EmployeesArchive[] @relation("EmployeeSupervisorToArchive") supervisor_archive EmployeesArchive[] @relation("EmployeeSupervisorToArchive")
schedule_presets SchedulePresets[] @relation("SchedulePreset")
@@map("employees") @@map("employees")
} }
@ -59,10 +62,12 @@ model EmployeesArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id]) employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id])
employee_id Int employee_id Int
archived_at DateTime @default(now())
user_id String @db.Uuid user_id String @db.Uuid
user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id]) user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id])
supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id])
supervisor_id Int?
archived_at DateTime @default(now())
first_name String first_name String
last_name String last_name String
job_title String? job_title String?
@ -71,8 +76,6 @@ model EmployeesArchive {
company_code Int company_code Int
first_work_day DateTime @db.Date first_work_day DateTime @db.Date
last_work_day DateTime @db.Date last_work_day DateTime @db.Date
supervisor_id Int?
supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id])
@@map("employees_archive") @@map("employees_archive")
} }
@ -92,10 +95,10 @@ model CustomersArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
customer Customers @relation("CustomerToArchive", fields: [customer_id], references: [id]) customer Customers @relation("CustomerToArchive", fields: [customer_id], references: [id])
customer_id Int customer_id Int
archived_at DateTime @default(now())
user_id String @db.Uuid
user Users @relation("UserToCustomersToArchive", fields: [user_id], references: [id]) user Users @relation("UserToCustomersToArchive", fields: [user_id], references: [id])
user_id String @db.Uuid
archived_at DateTime @default(now())
invoice_id Int? @unique invoice_id Int? @unique
@@map("customers_archive") @@map("customers_archive")
@ -107,12 +110,13 @@ model LeaveRequests {
employee_id Int 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 bank_code_id Int
leave_type LeaveTypes
date DateTime @db.Date
payable_hours Decimal? @db.Decimal(5,2)
requested_hours Decimal? @db.Decimal(5,2)
comment String comment String
date DateTime @db.Date
payable_hours Decimal? @db.Decimal(5, 2)
requested_hours Decimal? @db.Decimal(5, 2)
approval_status LeaveApprovalStatus @default(PENDING) approval_status LeaveApprovalStatus @default(PENDING)
leave_type LeaveTypes
archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive") archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive")
@ -125,13 +129,14 @@ model LeaveRequestsArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id]) leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id])
leave_request_id Int leave_request_id Int
archived_at DateTime @default(now()) archived_at DateTime @default(now())
employee_id Int employee_id Int
leave_type LeaveTypes
date DateTime @db.Date date DateTime @db.Date
payable_hours Decimal? @db.Decimal(5,2) payable_hours Decimal? @db.Decimal(5, 2)
requested_hours Decimal? @db.Decimal(5,2) requested_hours Decimal? @db.Decimal(5, 2)
comment String comment String
leave_type LeaveTypes
approval_status LeaveApprovalStatus approval_status LeaveApprovalStatus
@@unique([leave_request_id]) @@unique([leave_request_id])
@ -143,10 +148,10 @@ model LeaveRequestsArchive {
view PayPeriods { view PayPeriods {
pay_year Int pay_year Int
pay_period_no Int pay_period_no Int
label String
payday DateTime @db.Date payday DateTime @db.Date
period_start DateTime @db.Date period_start DateTime @db.Date
period_end DateTime @db.Date period_end DateTime @db.Date
label String
@@map("pay_period") @@map("pay_period")
} }
@ -155,6 +160,7 @@ model Timesheets {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id]) employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id])
employee_id Int employee_id Int
start_date DateTime @db.Date start_date DateTime @db.Date
is_approved Boolean @default(false) is_approved Boolean @default(false)
@ -170,25 +176,71 @@ model TimesheetsArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
timesheet Timesheets @relation("TimesheetsToArchive", fields: [timesheet_id], references: [id]) timesheet Timesheets @relation("TimesheetsToArchive", fields: [timesheet_id], references: [id])
timesheet_id Int timesheet_id Int
archive_at DateTime @default(now())
employee_id Int employee_id Int
is_approved Boolean is_approved Boolean
archive_at DateTime @default(now())
@@map("timesheets_archive") @@map("timesheets_archive")
} }
model SchedulePresets {
id Int @id @default(autoincrement())
employee Employees @relation("SchedulePreset", fields: [employee_id], references: [id])
employee_id Int
name String
is_default Boolean @default(false)
shifts SchedulePresetShifts[] @relation("SchedulePresetShiftsSchedulePreset")
@@unique([employee_id, name], name: "unique_preset_name_per_employee")
@@map("schedule_presets")
}
model SchedulePresetShifts {
id Int @id @default(autoincrement())
preset SchedulePresets @relation("SchedulePresetShiftsSchedulePreset",fields: [preset_id], references: [id])
preset_id Int
bank_code BankCodes @relation("SchedulePresetShiftsBankCodes",fields: [bank_code_id], references: [id])
bank_code_id Int
sort_order Int
start_time DateTime @db.Time(0)
end_time DateTime @db.Time(0)
is_remote Boolean @default(false)
week_day Weekday
@@unique([preset_id, week_day, sort_order], name: "unique_preset_shift_per_day_order")
@@index([preset_id, week_day])
@@map("schedule_preset_shifts")
}
model Shifts { model Shifts {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
timesheet Timesheets @relation("ShiftTimesheet", fields: [timesheet_id], references: [id]) timesheet Timesheets @relation("ShiftTimesheet", fields: [timesheet_id], references: [id])
timesheet_id Int timesheet_id Int
bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id]) bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id])
bank_code_id Int bank_code_id Int
comment String?
date DateTime @db.Date date DateTime @db.Date
start_time DateTime @db.Time(0) start_time DateTime @db.Time(0)
end_time DateTime @db.Time(0) end_time DateTime @db.Time(0)
is_approved Boolean @default(false) is_approved Boolean @default(false)
is_remote Boolean @default(false) is_remote Boolean @default(false)
comment String?
archive ShiftsArchive[] @relation("ShiftsToArchive") archive ShiftsArchive[] @relation("ShiftsToArchive")
@ -199,13 +251,14 @@ model ShiftsArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id]) shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id])
shift_id Int shift_id Int
archive_at DateTime @default(now())
timesheet_id Int
bank_code_id Int
comment String?
date DateTime @db.Date date DateTime @db.Date
start_time DateTime @db.Time(0) start_time DateTime @db.Time(0)
end_time DateTime @db.Time(0) end_time DateTime @db.Time(0)
timesheet_id Int
bank_code_id Int
comment String?
archive_at DateTime @default(now())
@@map("shifts_archive") @@map("shifts_archive")
} }
@ -220,6 +273,7 @@ model BankCodes {
shifts Shifts[] @relation("ShiftBankCodes") shifts Shifts[] @relation("ShiftBankCodes")
expenses Expenses[] @relation("ExpenseBankCodes") expenses Expenses[] @relation("ExpenseBankCodes")
leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes") leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes")
SchedulePresetShifts SchedulePresetShifts[] @relation("SchedulePresetShiftsBankCodes")
@@map("bank_codes") @@map("bank_codes")
} }
@ -230,14 +284,15 @@ model Expenses {
timesheet_id Int timesheet_id Int
bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id])
bank_code_id Int bank_code_id Int
attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull)
attachment Int?
date DateTime @db.Date date DateTime @db.Date
amount Decimal @db.Money amount Decimal @db.Money
mileage Decimal? mileage Decimal?
attachment Int?
attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull)
comment String comment String
is_approved Boolean @default(false)
supervisor_comment String? supervisor_comment String?
is_approved Boolean @default(false)
archive ExpensesArchive[] @relation("ExpensesToArchive") archive ExpensesArchive[] @relation("ExpensesToArchive")
@ -248,14 +303,15 @@ model ExpensesArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id]) expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id])
expense_id Int expense_id Int
attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull)
attachment Int?
timesheet_id Int timesheet_id Int
archived_at DateTime @default(now()) archived_at DateTime @default(now())
bank_code_id Int bank_code_id Int
date DateTime @db.Date date DateTime @db.Date
amount Decimal? @db.Money amount Decimal? @db.Money
mileage Decimal? mileage Decimal?
attachment Int?
attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull)
comment String? comment String?
is_approved Boolean is_approved Boolean
supervisor_comment String? supervisor_comment String?
@ -296,8 +352,9 @@ model Blobs {
model Attachments { model Attachments {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
sha256 String @db.Char(64)
blob Blobs @relation(fields: [sha256], references: [sha256], onUpdate: Cascade) blob Blobs @relation(fields: [sha256], references: [sha256], onUpdate: Cascade)
sha256 String @db.Char(64)
owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc
owner_id String //expense_id, employee_id, etc owner_id String //expense_id, employee_id, etc
original_name String original_name String
@ -318,6 +375,7 @@ model Preferences {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user Users @relation("UserPreferences", fields: [user_id], references: [id]) user Users @relation("UserPreferences", fields: [user_id], references: [id])
user_id String @unique @db.Uuid user_id String @unique @db.Uuid
notifications Boolean @default(false) notifications Boolean @default(false)
dark_mode Boolean @default(false) dark_mode Boolean @default(false)
lang_switch Boolean @default(false) lang_switch Boolean @default(false)
@ -326,7 +384,6 @@ model Preferences {
@@map("preferences") @@map("preferences")
} }
enum AttachmentStatus { enum AttachmentStatus {
ACTIVE ACTIVE
DELETED DELETED
@ -373,3 +430,13 @@ enum LeaveApprovalStatus {
@@map("leave_approval_status") @@map("leave_approval_status")
} }
enum Weekday {
SUN
MON
TUE
WED
THU
FRI
SAT
}

View File

@ -25,6 +25,7 @@ import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ValidationError } from 'class-validator'; import { ValidationError } from 'class-validator';
import { SchedulePresetsModule } from './modules/schedule-presets/schedule-presets.module';
@Module({ @Module({
imports: [ imports: [
@ -43,7 +44,8 @@ import { ValidationError } from 'class-validator';
OauthSessionsModule, OauthSessionsModule,
PayperiodsModule, PayperiodsModule,
PrismaModule, PrismaModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(), //cronjobs
SchedulePresetsModule,
ShiftsModule, ShiftsModule,
TimesheetsModule, TimesheetsModule,
UsersModule, UsersModule,

View File

@ -0,0 +1,31 @@
import { Body, Controller, Get, NotFoundException, Param, Put, Query } from "@nestjs/common";
import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto";
import { SchedulePresetsCommandService } from "../services/schedule-presets-command.service";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
import { SchedulePresetsQueryService } from "../services/schedule-presets-query.service";
@Controller('schedule-presets')
export class SchedulePresetsController {
constructor(
private readonly commandService: SchedulePresetsCommandService,
private readonly queryService: SchedulePresetsQueryService,
){}
@Put(':email')
async upsert(
@Param('email') email: string,
@Query('action') action: UpsertAction,
@Body() dto: SchedulePresetsDto,
) {
const actions: UpsertAction[] = ['create','update','delete'];
if(!actions) throw new NotFoundException(`No action found for ${actions}`)
return this.commandService.upsertSchedulePreset(email, action, dto);
}
@Get(':email')
async findListByEmail(
@Param('email') email: string,
) {
return this.queryService.findSchedulePresetsByEmail(email);
}
}

View File

@ -0,0 +1,26 @@
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Matches, Min } from "class-validator";
import { Weekday } from "@prisma/client";
export class SchedulePresetShiftsDto {
@IsEnum(Weekday)
week_day!: Weekday;
@IsInt()
@Min(1)
sort_order!: number;
@IsString()
type!: string;
@IsString()
@Matches(HH_MM_REGEX)
start_time!: string;
@IsString()
@Matches(HH_MM_REGEX)
end_time!: string;
@IsOptional()
@IsBoolean()
is_remote?: boolean;
}

View File

@ -0,0 +1,15 @@
import { ArrayMinSize, IsArray, IsBoolean, IsEmail, IsOptional, IsString } from "class-validator";
import { SchedulePresetShiftsDto } from "./create-schedule-preset-shifts.dto";
export class SchedulePresetsDto {
@IsString()
name!: string;
@IsBoolean()
@IsOptional()
is_default: boolean;
@IsArray()
@ArrayMinSize(1)
preset_shifts: SchedulePresetShiftsDto[];
}

View File

@ -0,0 +1,23 @@
import { Module } from "@nestjs/common";
import { SchedulePresetsCommandService } from "./services/schedule-presets-command.service";
import { SchedulePresetsQueryService } from "./services/schedule-presets-query.service";
import { SchedulePresetsController } from "./controller/schedule-presets.controller";
import { EmployeeIdEmailResolver } from "../shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "../shared/utils/resolve-bank-type-id.utils";
import { PrismaService } from "src/prisma/prisma.service";
@Module({
imports: [],
controllers: [SchedulePresetsController],
providers: [
PrismaService,
SchedulePresetsCommandService,
SchedulePresetsQueryService,
EmployeeIdEmailResolver,
BankCodesResolver,
],
exports:[
SchedulePresetsCommandService,
SchedulePresetsQueryService
],
}) export class SchedulePresetsModule {}

View File

@ -0,0 +1,45 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { PrismaService } from "src/prisma/prisma.service";
import { ApplyResult } from "../types/schedule-presets.types";
// @Injectable()
// export class SchedulePresetsApplyService {
// constructor(
// private readonly prisma: PrismaService,
// private readonly emailResolver: EmployeeIdEmailResolver,
// ) {}
// async applyToTimesheet(
// email: string,
// preset_name: string,
// start_date_iso: string,
// ): Promise<ApplyResult> {
// if(!preset_name?.trim()) throw new BadRequestException('A preset_name is required');
// if(!DATE_ISO_FORMAT.test(start_date_iso)) throw new BadRequestException('start_date must be of format :YYYY-MM-DD');
// const employee_id = await this.emailResolver.findIdByEmail(email);
// if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
// const preset = await this.prisma.schedulePresets.findFirst({
// where: { employee_id, name: preset_name },
// include: {
// shifts: {
// orderBy: [{ week_day: 'asc'}, { sort_order: 'asc'}],
// select: {
// week_day: true,
// sort_order: true,
// start_time: true,
// end_time: true,
// is_remote: true,
// bank_code_id: true,
// },
// },
// },
// });
// if(!preset) throw new NotFoundException(`Preset ${preset} not found`);
// const start_date = new Date(`${start_date_iso}T00:00:00.000Z`)
// }
// }

View File

@ -0,0 +1,238 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
import { PrismaService } from "src/prisma/prisma.service";
import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto";
import { Prisma, Weekday } from "@prisma/client";
@Injectable()
export class SchedulePresetsCommandService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmployeeIdEmailResolver,
private readonly typeResolver : BankCodesResolver,
){}
//_________________________________________________________________
// MASTER CRUD FUNCTION
//_________________________________________________________________
async upsertSchedulePreset(
email: string,
action: UpsertAction,
dto: SchedulePresetsDto,
): Promise<{
action: UpsertAction;
preset_id?: number;
total_items?: number;
}>{
if(!dto.name?.trim()) throw new BadRequestException(`A Name is required`);
//resolve employee_id using email
const employee_id = await this.emailResolver.findIdByEmail(email);
if(!employee_id) throw new NotFoundException(`employee with email: ${email} not found`);
//DELETE
if(action === 'delete') {
return this.deletePreset(employee_id, dto.name);
}
if(!Array.isArray(dto.preset_shifts) || dto.preset_shifts.length === 0) {
throw new BadRequestException(`Empty array, no detected shifts`);
}
const shifts_data = await this.resolveAndBuildPresetShifts(dto);
//CREATE AND UPDATE
if(action === 'create') {
return this.createPreset(employee_id, dto, shifts_data);
} else if (action === 'update') {
return this.updatePreset(employee_id, dto, shifts_data);
}
throw new BadRequestException(`Unknown action: ${ action }`);
}
//_________________________________________________________________
// CREATE
//_________________________________________________________________
private async createPreset(
employee_id: number,
dto: SchedulePresetsDto,
shifts_data: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[],
): Promise<{
action: UpsertAction;
preset_id: number;
total_items: number;
}> {
try {
const result = await this.prisma.$transaction(async (tx)=> {
if(dto.is_default) {
await tx.schedulePresets.updateMany({
where: { employee_id, is_default: true },
data: { is_default: false },
});
}
const created = await tx.schedulePresets.create({
data: {
employee_id,
name: dto.name,
is_default: !!dto.is_default,
shifts: { create: shifts_data},
},
include: { shifts: true },
});
return created;
});
return { action: 'create', preset_id: result.id, total_items: result.shifts.length };
} catch (error: unknown) {
if(error instanceof Prisma.PrismaClientKnownRequestError){
if(error?.code === 'P2002') {
throw new ConflictException(`The name ${dto.name} is already used for another schedule preset`);
}
if (error.code === 'P2003' || error.code === 'P2011') {
throw new ConflictException('Invalid constraint on preset shifts');
}
}
throw error;
}
}
//_________________________________________________________________
// UPDATE
//_________________________________________________________________
private async updatePreset(
employee_id: number,
dto: SchedulePresetsDto,
shifts_data: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[],
): Promise<{
action: UpsertAction;
preset_id?: number;
total_items?: number;
}> {
const existing = await this.prisma.schedulePresets.findFirst({
where: { employee_id, name: dto.name },
select: { id:true, is_default: true },
});
if(!existing) throw new NotFoundException(`Preset "${dto.name}" not found`);
try {
const result = await this.prisma.$transaction(async (tx) => {
if(typeof dto.is_default === 'boolean'){
if(dto.is_default) {
await tx.schedulePresets.updateMany({
where: { employee_id, is_default: true, NOT: { id: existing.id } },
data: { is_default: false },
});
}
await tx.schedulePresets.update({
where: { id: existing.id },
data: { is_default: dto.is_default },
});
}
await tx.schedulePresetShifts.deleteMany({ where: { preset_id: existing.id } });
const create_many_data: Prisma.SchedulePresetShiftsCreateManyInput[] =
shifts_data.map((shift)=> {
if(!shift.bank_code || !('connect' in shift.bank_code) || typeof shift.bank_code.connect?.id !=='number'){
throw new NotFoundException(`Bank code is required for updates( ${shift.week_day}, ${shift.sort_order})`);
}
const bank_code_id = shift.bank_code.connect.id;
return {
preset_id: existing.id,
week_day: shift.week_day,
sort_order: shift.sort_order,
start_time: shift.start_time,
end_time: shift.end_time,
is_remote: shift.is_remote ?? false,
bank_code_id: bank_code_id,
};
});
await tx.schedulePresetShifts.createMany({data: create_many_data});
const count = await tx.schedulePresetShifts.count({ where: { preset_id: existing.id } });
return { id: existing.id, total: count };
});
return { action: 'update', preset_id: result.id, total_items: result.total };
} catch (error: unknown){
if(error instanceof Prisma.PrismaClientKnownRequestError){
if(error?.code === 'P2003' || error?.code === 'P2011') {
throw new ConflictException(`Invalid constraint on preset shifts`);
}
}
throw error;
}
}
//_________________________________________________________________
// DELETE
//_________________________________________________________________
private async deletePreset(
employee_id: number,
name: string,
): Promise<{
action: UpsertAction;
preset_id?: number;
total_items?: number;
}> {
const existing = await this.prisma.schedulePresets.findFirst({
where: { employee_id, name },
select: { id: true },
});
if(!existing) throw new NotFoundException(`Preset "${name}" not found`);
await this.prisma.$transaction(async (tx) => {
await tx.schedulePresetShifts.deleteMany({ where: { preset_id: existing.id } });
await tx.schedulePresets.delete({where: { id: existing.id } });
});
return { action: 'delete', preset_id: existing.id, total_items: 0 };
}
//PRIVATE HELPER
//resolve bank_code_id using type and convert hours to TIME and valid shifts end/start
private async resolveAndBuildPresetShifts(
dto: SchedulePresetsDto
): Promise<Prisma.SchedulePresetShiftsCreateWithoutPresetInput[]>{
if(!dto.preset_shifts?.length) throw new NotFoundException(`Empty or preset shifts not found`);
const types = Array.from(new Set(dto.preset_shifts.map((shift)=> shift.type)));
const bank_code_set = new Map<string, number>();
for (const type of types) {
const { id } = await this.typeResolver.findByType(type);
bank_code_set.set(type, id)
}
const toTime = (hhmm: string) => new Date(`1970-01-01T${hhmm}:00.000Z`);
const pair_set = new Set<string>();
for (const shift of dto.preset_shifts) {
const key = `${shift.week_day}:${shift.sort_order}`;
if (pair_set.has(key)) {
throw new ConflictException(`Duplicate shift for day/order (${shift.week_day}, ${shift.sort_order})`);
}
pair_set.add(key);
}
const items: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[] = dto.preset_shifts.map((shift)=> {
const bank_code_id = bank_code_set.get(shift.type);
if(!bank_code_id) throw new NotFoundException(`Bank code not found for type ${shift.type}`);
if (!shift.start_time || !shift.end_time) {
throw new BadRequestException(`start_time and end_time are required for (${shift.week_day}, ${shift.sort_order})`);
}
const start = toTime(shift.start_time);
const end = toTime(shift.end_time);
if(end.getTime() <= start.getTime()) {
throw new ConflictException(`end_time must be > start_time ( day: ${shift.week_day}, order: ${shift.sort_order})`);
}
return {
week_day: shift.week_day as Weekday,
sort_order: shift.sort_order,
bank_code: { connect: { id: bank_code_id} },
start_time: start,
end_time: end,
is_remote: !!shift.is_remote,
};
});
return items;
}
}

View File

@ -0,0 +1,51 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { PrismaService } from "src/prisma/prisma.service";
import { PresetResponse, ShiftResponse } from "../types/schedule-presets.types";
import { Prisma } from "@prisma/client";
@Injectable()
export class SchedulePresetsQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmployeeIdEmailResolver,
){}
async findSchedulePresetsByEmail(email:string): Promise<PresetResponse[]> {
const employee_id = await this.emailResolver.findIdByEmail(email);
if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
try {
const presets = await this.prisma.schedulePresets.findMany({
where: { employee_id },
orderBy: [{is_default: 'desc' }, { name: 'asc' }],
include: {
shifts: {
orderBy: [{week_day:'asc'}, { sort_order: 'asc'}],
include: { bank_code: { select: { type: true } } },
},
},
});
const hhmm = (date: Date) => date.toISOString().slice(11,16);
const response: PresetResponse[] = presets.map((preset) => ({
id: preset.id,
name: preset.name,
is_default: preset.is_default,
shifts: preset.shifts.map<ShiftResponse>((shift)=> ({
week_day: shift.week_day,
sort_order: shift.sort_order,
start_time: hhmm(shift.start_time),
end_time: hhmm(shift.end_time),
is_remote: shift.is_remote,
type: shift.bank_code?.type,
})),
}));
return response;
} catch ( error: unknown) {
if(error instanceof Prisma.PrismaClientKnownRequestError) {}
throw error;
}
}
}

View File

@ -0,0 +1,21 @@
export type ShiftResponse = {
week_day: string;
sort_order: number;
start_time: string;
end_time: string;
is_remote: boolean;
type: string;
};
export type PresetResponse = {
id: number;
name: string;
is_default: boolean;
shifts: ShiftResponse[];
}
export type ApplyResult = {
timesheet_id: number;
created: number;
skipped: number;
}

View File

@ -0,0 +1,2 @@
const HH_MM_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
const DATE_ISO_FORMAT = /^\d{4}-\d{2}-\d{2}$/;

View File

@ -0,0 +1 @@
export type UpsertAction = 'create' | 'update' | 'delete';