feat(migration): added a split prismaModule to manage legacy DB, created scripts to fetch old data, transform to match the new structure and creates new items in the new DB

This commit is contained in:
Matthieu Haineault 2025-11-24 14:59:16 -05:00
parent ddb6fa2ada
commit 5452641f19
22 changed files with 1515 additions and 242 deletions

197
prisma-legacy/schema.prisma Normal file
View File

@ -0,0 +1,197 @@
generator client {
provider = "prisma-client-js"
output = "../node_modules/@prisma/client-legacy"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL_LEGACY")
}
model codeDesjardins {
id String @id @map("_id") @db.VarChar(50)
code String @db.VarChar(50)
label String @db.VarChar(50)
description String @db.VarChar(250)
}
model customers {
id String @id @map("_id") @db.Uuid
user_id String? @db.VarChar(50)
email String? @db.VarChar(50)
first_name String? @db.VarChar(50)
last_name String? @db.VarChar(50)
phone_number String? @db.VarChar(50)
address String? @db.VarChar(255)
created_at BigInt?
updated_at BigInt?
created_by String? @db.VarChar(50)
}
model dealers {
id String @id @map("_id") @db.Uuid
user_id String? @db.VarChar(50)
email String? @db.VarChar(50)
first_name String? @db.VarChar(50)
last_name String? @db.VarChar(50)
phone_number String? @db.VarChar(50)
created_at BigInt?
updated_at BigInt?
created_by String? @db.VarChar(50)
}
model employee_shift_template {
id String @id @map("_id") @db.Uuid
employee_id String @db.VarChar
day_of_the_week String @db.VarChar
start_time BigInt
end_time BigInt
created_at BigInt
updated_at BigInt
}
model employees {
id String @id @map("_id") @db.Uuid
user_id String? @db.VarChar(50)
employee_number String? @db.VarChar(50)
email String? @db.VarChar(50)
first_name String? @db.VarChar(50)
last_name String? @db.VarChar(50)
phone_number String? @db.VarChar(50)
job_title String? @db.VarChar(50)
company Int?
supervisor String? @db.VarChar(50)
is_supervisor Boolean?
onboarding BigInt?
offboarding BigInt?
regular_hours_day Float? @db.Real
hours_bank_max Int?
created_at BigInt?
updated_at BigInt?
created_by String? @db.VarChar
}
model expenses {
id String @id @map("_id") @db.Uuid
time_sheet_id String? @db.VarChar(50)
date String? @db.VarChar(50)
code String? @db.VarChar(50)
value Float? @db.Real
description String? @db.VarChar
evidence_id String? @db.VarChar
status Boolean?
created_at BigInt?
updated_at BigInt?
supervisor_note String? @db.VarChar(255)
}
model hours_bank {
id String @id @map("_id") @db.Uuid
employee_id String? @db.VarChar(50)
hours Float? @db.Real
created_at BigInt?
updated_at BigInt?
}
model mileage_bank {
id String @id @map("_id") @db.Uuid
employee_id String? @db.VarChar(50)
mileage Int?
year Int?
}
model shifts {
id String @id @map("_id") @db.Uuid
time_sheet_id String? @db.VarChar(50)
code String? @db.VarChar(50)
type String? @db.VarChar(50)
date DateTime? @db.Date
start_time BigInt?
end_time BigInt?
comment String? @db.VarChar(255)
status Boolean?
created_at BigInt?
updated_at BigInt?
supervisor_note String? @db.VarChar(255)
}
model shifts_of_template {
id String @id @map("_id") @db.Uuid
model_id String @db.Uuid
day_of_the_week String @db.VarChar(50)
start_time BigInt
end_time BigInt
created_at BigInt
updated_at BigInt
}
model sick_leave {
id String @id @map("_id") @db.Uuid
employee_id String? @db.VarChar(50)
accumulated Float? @db.Real
consumed Float? @db.Real
year Int?
created_at BigInt?
updated_at BigInt?
}
model time_sheet_periods {
id String @id @map("_id") @db.Uuid
start_date DateTime? @db.Date
end_date DateTime? @db.Date
payment_date DateTime? @db.Date
period_number Int?
year Int?
}
model time_sheet_template {
id String @id @map("_id") @db.Uuid
title String @db.VarChar
description String? @db.VarChar
created_at BigInt
updated_at BigInt
}
model time_sheets {
id String @id @map("_id") @db.Uuid
employee_id String? @db.VarChar
start_date DateTime? @db.Date
end_date DateTime? @db.Date
status Boolean?
banked_hours Float? @db.Real
consumed_vacation Float? @db.Real
consumed_sick Float? @db.Real
period_id String? @db.VarChar(50)
period_number Int?
created_at BigInt?
updated_at BigInt?
blocked_week Boolean? @default(false)
}
model users {
id String @id @map("_id") @db.VarChar(50)
email String @unique @db.VarChar(50)
password String @db.VarChar(255)
type String @db.VarChar(50)
role String @db.VarChar(50)
is_verified Boolean?
verification_token String?
otp_token String?
refresh_token String?
created_at BigInt?
updated_at BigInt?
created_by String @db.VarChar(255)
last_login BigInt
}
model vacation_leave {
id String @id @map("_id") @db.Uuid
employee_id String? @db.VarChar(50)
accumulated Float? @db.Real
consumed Float? @db.Real
created_at BigInt?
updated_at BigInt?
start_year Int?
end_year Int?
max_hours_per_year Float? @db.Real
}

View File

@ -0,0 +1,67 @@
/*
Warnings:
- You are about to drop the column `patch` on the `attachment_variants` table. All the data in the column will be lost.
- You are about to alter the column `amount` on the `expenses` table. The data in that column could be lost. The data in that column will be cast from `Money` to `Decimal(12,2)`.
- You are about to alter the column `amount` on the `expenses_archive` table. The data in that column could be lost. The data in that column will be cast from `Money` to `Decimal(12,2)`.
- You are about to drop the column `date` on the `leave_requests` table. All the data in the column will be lost.
- You are about to drop the `customers` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `customers_archive` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `employees_archive` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[employee_id,leave_type,dates]` on the table `leave_requests` will be added. If there are existing duplicate values, this will fail.
- Added the required column `path` to the `attachment_variants` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "public"."customers" DROP CONSTRAINT "customers_user_id_fkey";
-- DropForeignKey
ALTER TABLE "public"."customers_archive" DROP CONSTRAINT "customers_archive_customer_id_fkey";
-- DropForeignKey
ALTER TABLE "public"."customers_archive" DROP CONSTRAINT "customers_archive_user_id_fkey";
-- DropForeignKey
ALTER TABLE "public"."employees_archive" DROP CONSTRAINT "employees_archive_employee_id_fkey";
-- DropForeignKey
ALTER TABLE "public"."employees_archive" DROP CONSTRAINT "employees_archive_supervisor_id_fkey";
-- DropForeignKey
ALTER TABLE "public"."employees_archive" DROP CONSTRAINT "employees_archive_user_id_fkey";
-- DropIndex
DROP INDEX "public"."leave_requests_employee_id_date_idx";
-- DropIndex
DROP INDEX "public"."leave_requests_employee_id_leave_type_date_key";
-- AlterTable
ALTER TABLE "attachment_variants" DROP COLUMN "patch",
ADD COLUMN "path" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "expenses" ALTER COLUMN "amount" DROP NOT NULL,
ALTER COLUMN "amount" SET DATA TYPE DECIMAL(12,2);
-- AlterTable
ALTER TABLE "expenses_archive" ALTER COLUMN "amount" SET DATA TYPE DECIMAL(12,2);
-- AlterTable
ALTER TABLE "leave_requests" DROP COLUMN "date",
ADD COLUMN "dates" DATE[];
-- DropTable
DROP TABLE "public"."customers";
-- DropTable
DROP TABLE "public"."customers_archive";
-- DropTable
DROP TABLE "public"."employees_archive";
-- CreateIndex
CREATE INDEX "leave_requests_employee_id_dates_idx" ON "leave_requests"("employee_id", "dates");
-- CreateIndex
CREATE UNIQUE INDEX "leave_requests_employee_id_leave_type_dates_key" ON "leave_requests"("employee_id", "leave_type", "dates");

View File

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "public"."users_phone_number_key";

View File

@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[timesheet_id,date,amount,mileage]` on the table `expenses` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[timesheet_id,date,start_time]` on the table `shifts` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "expenses_timesheet_id_date_amount_mileage_key" ON "expenses"("timesheet_id", "date", "amount", "mileage");
-- CreateIndex
CREATE UNIQUE INDEX "shifts_timesheet_id_date_start_time_key" ON "shifts"("timesheet_id", "date", "start_time");

View File

@ -11,7 +11,7 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL_DEV") url = env("DATABASE_URL_STAGING")
} }
model Users { model Users {
@ -19,7 +19,7 @@ model Users {
first_name String first_name String
last_name String last_name String
email String @unique email String @unique
phone_number String @unique phone_number String
residence String? residence String?
role Roles @default(GUEST) role Roles @default(GUEST)
@ -180,7 +180,7 @@ model Shifts {
comment String? comment String?
archive ShiftsArchive[] @relation("ShiftsToArchive") archive ShiftsArchive[] @relation("ShiftsToArchive")
@@unique([timesheet_id, date, start_time], name: "unique_ts_id_date_start_time")
@@map("shifts") @@map("shifts")
} }
@ -232,7 +232,7 @@ model Expenses {
is_approved Boolean @default(false) is_approved Boolean @default(false)
archive ExpensesArchive[] @relation("ExpensesToArchive") archive ExpensesArchive[] @relation("ExpensesToArchive")
@@unique([timesheet_id, date, amount, mileage], name: "unique_ts_id_date_amount_mileage")
@@map("expenses") @@map("expenses")
} }

View File

@ -0,0 +1,270 @@
// src/scripts/import-employees-from-csv.ts
import { PrismaClient, Roles } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
// ⚙️ Chemin vers ton CSV employees
const CSV_PATH = path.resolve(__dirname, '../../data/export_new_employee_table.csv');
// Rôles éligibles pour la table Employees
const ELIGIBLE_ROLES: Roles[] = [
Roles.EMPLOYEE,
Roles.SUPERVISOR,
Roles.HR,
Roles.ACCOUNTING,
Roles.ADMIN,
];
// Type correspondant EXACT aux colonnes de ton CSV
type EmployeeCsvRow = {
employee_number: string;
email: string;
job_title: string;
company: string; // sera converti en number
is_supervisor: string; // "True"/"False" (ou variantes)
onboarding: string; // millis
offboarding: string; // millis ou "NULL"
};
// Représentation minimale d'un user
type UserSummary = {
id: string; // UUID
email: string;
role: Roles;
};
// ============ Helpers CSV ============
function splitCsvLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
// guillemet échappé ""
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result.map((v) => v.trim());
}
// ============ Helpers de parsing ============
function parseBoolean(value: string): boolean {
const v = value.trim().toLowerCase();
return v === 'true' || v === '1' || v === 'yes' || v === 'y' || v === 'oui';
}
function parseIntSafe(value: string, fieldName: string): number | null {
const trimmed = value.trim();
if (!trimmed || trimmed.toUpperCase() === 'NULL') return null;
const n = Number.parseInt(trimmed, 10);
if (Number.isNaN(n)) {
console.warn(`⚠️ Impossible de parser "${value}" en entier pour le champ ${fieldName}`);
return null;
}
return n;
}
function millisToDate(value: string): Date | null {
const trimmed = value.trim().toUpperCase();
if (!trimmed || trimmed === 'NULL') return null;
const ms = Number(trimmed);
if (!Number.isFinite(ms)) {
console.warn(`⚠️ Impossible de parser "${value}" en millis pour une Date`);
return null;
}
const d = new Date(ms);
// On normalise au jour (minuit UTC)
const normalized = new Date(Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
));
return normalized;
}
// ============ MAIN ============
async function main() {
// 1. Lecture du CSV
const fileContent = fs.readFileSync(CSV_PATH, 'utf-8');
const lines = fileContent
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length <= 1) {
console.error('CSV vide ou seulement un header');
return;
}
const header = splitCsvLine(lines[0]); // ["employee_number","email",...]
const dataLines = lines.slice(1);
const csvRows: EmployeeCsvRow[] = dataLines.map((line) => {
const values = splitCsvLine(line);
const row: any = {};
header.forEach((col, idx) => {
row[col] = values[idx] ?? '';
});
return row as EmployeeCsvRow;
});
console.log(`➡️ ${csvRows.length} lignes trouvées dans le CSV employees`);
// 2. Récupérer tous les emails du CSV
const emails = Array.from(
new Set(
csvRows
.map((r) => r.email.trim())
.filter((e) => e.length > 0),
),
);
console.log(`➡️ ${emails.length} emails uniques trouvés dans le CSV`);
// 3. Charger les users correspondants avec les bons rôles
const users = (await prisma.users.findMany({
where: {
email: { in: emails },
role: { in: ELIGIBLE_ROLES },
},
select: {
id: true,
email: true,
role: true,
},
})) as UserSummary[];
console.log(`➡️ ${users.length} users éligibles trouvés dans la DB`);
// Map email → user
const userByEmail = new Map<string, UserSummary>();
for (const user of users) {
const key = user.email.trim().toLowerCase();
userByEmail.set(key, user);
}
// 4. Construire les données pour employees.createMany
const employeesToCreate: {
user_id: string;
external_payroll_id: number;
company_code: number;
first_work_day: Date;
last_work_day: Date | null;
job_title: string | null;
is_supervisor: boolean;
supervisor_id?: number | null;
}[] = [];
const rowsWithoutUser: EmployeeCsvRow[] = [];
const rowsWithInvalidNumbers: EmployeeCsvRow[] = [];
for (const row of csvRows) {
const emailKey = row.email.trim().toLowerCase();
const user = userByEmail.get(emailKey);
if (!user) {
rowsWithoutUser.push(row);
continue;
}
const external_payroll_id = parseIntSafe(row.employee_number, 'external_payroll_id');
const company_code = parseIntSafe(String(row.company), 'company_code');
if (external_payroll_id === null || company_code === null) {
rowsWithInvalidNumbers.push(row);
continue;
}
const first_work_day = millisToDate(row.onboarding);
const last_work_day = millisToDate(row.offboarding);
const is_supervisor = parseBoolean(row.is_supervisor);
const job_title = row.job_title?.trim() || null;
if (!first_work_day) {
console.warn(
`⚠️ Date d'onboarding invalide pour ${row.email} (employee_number=${row.employee_number})`,
);
continue;
}
employeesToCreate.push({
user_id: user.id,
external_payroll_id,
company_code,
first_work_day,
last_work_day,
job_title,
is_supervisor,
supervisor_id: null, // on pourra gérer ça plus tard si tu as les infos
});
}
console.log(`➡️ ${employeesToCreate.length} entrées Employees prêtes à être insérées`);
if (rowsWithoutUser.length > 0) {
console.warn(`⚠️ ${rowsWithoutUser.length} lignes CSV sans user correspondant (email / rôle) :`);
for (const row of rowsWithoutUser) {
console.warn(
` - email=${row.email}, employee_number=${row.employee_number}, company=${row.company}`,
);
}
}
if (rowsWithInvalidNumbers.length > 0) {
console.warn(`⚠️ ${rowsWithInvalidNumbers.length} lignes CSV avec ids/compagnies invalides :`);
for (const row of rowsWithInvalidNumbers) {
console.warn(
` - email=${row.email}, employee_number="${row.employee_number}", company="${row.company}"`,
);
}
}
if (employeesToCreate.length === 0) {
console.warn('⚠️ Aucun Employees à créer, arrêt.');
return;
}
// 5. Insert en batch
const result = await prisma.employees.createMany({
data: employeesToCreate,
skipDuplicates: true, // évite les erreurs si tu relances le script
});
console.log(`${result.count} employees insérés dans la DB`);
}
main()
.catch((err) => {
console.error('❌ Erreur pendant limport CSV → Employees', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,106 @@
// src/scripts/import-users-from-csv.ts
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
// ⚙️ Chemin vers ton CSV (à adapter selon où tu le mets)
const CSV_PATH = path.resolve(__dirname, '../../data/export_employee_table.csv');
// Type aligné sur les colonnes du CSV
type CsvUserRow = {
email: string;
first_name: string;
last_name: string;
phone_number: string;
};
// Petit parseur de ligne CSV sans dépendance
function splitCsvLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
// guillemet échappé ""
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++; // on saute le deuxième
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result.map((v) => v.trim());
}
async function main() {
// 1. Lecture du fichier CSV
const fileContent = fs.readFileSync(CSV_PATH, 'utf-8');
const lines = fileContent
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length <= 1) {
console.error('CSV vide ou seulement un header');
return;
}
// 2. Header (noms de colonnes) -> ["email", "first_name", "last_name", "phone_number"]
const header = splitCsvLine(lines[0]);
const dataLines = lines.slice(1);
// 3. Conversion de chaque ligne en objet { email, first_name, last_name, phone_number }
const records: CsvUserRow[] = dataLines.map((line) => {
const values = splitCsvLine(line);
const row: any = {};
header.forEach((col, idx) => {
row[col] = values[idx] ?? '';
});
return row as CsvUserRow;
});
// 4. Mapping vers le format attendu par Prisma (model Users)
const data = records.map((row) => ({
email: row.email.trim(),
first_name: row.first_name.trim(),
last_name: row.last_name.trim(),
phone_number: row.phone_number.trim(),
// residence: null, // si tu veux la forcer à null
// role: 'GUEST', // sinon Prisma va appliquer la valeur par défaut
}));
console.log(`➡️ ${data.length} lignes trouvées dans le CSV`);
console.log('Exemple importé :', data[0]);
const result = await prisma.users.createMany({
data,
});
console.log(`${result.count} utilisateurs insérés dans la DB`);
}
main()
.catch((err) => {
console.error('❌ Erreur pendant limport CSV → DB', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,67 @@
// src/scripts/init-preferences.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
type UserSummary = {
id: string; // UUID
email: string;
};
async function main() {
console.log('➡️ Initialisation des préférences utilisateurs…');
// 1. Récupérer tous les users
const users = (await prisma.users.findMany({
select: {
id: true,
email: true,
},
})) as UserSummary[];
console.log(`➡️ ${users.length} users trouvés dans la DB`);
// 2. Récupérer toutes les préférences existantes
const existingPrefs = await prisma.preferences.findMany({
select: {
user_id: true,
},
});
const userIdsWithPrefs = new Set(existingPrefs.map((p) => p.user_id));
console.log(`➡️ ${existingPrefs.length} users ont déjà des préférences`);
// 3. Filtrer les users qui n'ont pas encore de preferences
const usersWithoutPrefs = users.filter((u) => !userIdsWithPrefs.has(u.id));
console.log(`➡️ ${usersWithoutPrefs.length} users n'ont pas encore de préférences`);
if (usersWithoutPrefs.length === 0) {
console.log('✅ Rien à faire, toutes les préférences sont déjà créées.');
return;
}
// 4. Préparer les entrées pour createMany
const prefsToCreate = usersWithoutPrefs.map((u) => ({
user_id: u.id,
// tous les autres champs prendront leurs valeurs par défaut (0)
}));
// 5. Insertion en batch
const result = await prisma.preferences.createMany({
data: prefsToCreate,
skipDuplicates: true, // sécurité si jamais le script est relancé
});
console.log(`${result.count} préférences créées dans la DB`);
}
main()
.catch((err) => {
console.error('❌ Erreur pendant linitialisation des préférences', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

164
scripts/migrate-expenses.ts Normal file
View File

@ -0,0 +1,164 @@
import { PrismaClient as Prisma } from "@prisma/client";
import { PrismaClient as PrismaLegacy } from "@prisma/client-legacy"
import { toDateFromString, toHHmmFromDate, toStringFromDate } from "src/common/utils/date-utils";
const prisma_legacy = new PrismaLegacy({});
const prisma = new Prisma({});
type NewEmployee = {
id: number;
company_code: number;
external_payroll_id: number;
}
type OldExpense = {
time_sheet_id: string | null;
date: string | null;
code: string | null;
description: string | null;
value: number | null;
status: boolean | null;
}
export const extractOldExpenses = async () => {
for (let id = 1; id <= 61; id++) {
console.log(`Start of Expense migration ***************************************************************`);
const new_employee = await findOneNewEmployee(id);
console.log(`Employee ${id} found in new DB`);
const new_timesheets = await findManyNewTimesheets(new_employee.id);
console.log(`New Timesheets found for employee ${id}`);
const old_employee_id = await findOneOldEmployee(new_employee);
console.log(`Employee ${new_employee.id} found in old DB`);
const old_timesheets = await findManyOldTimesheets(old_employee_id);
console.log(`Timesheets for employee ${old_employee_id}/${new_employee.id} found in old DB`);
console.log('Start of Expense creation*****************************************************************');
for (const old_timesheet of old_timesheets) {
if (!old_timesheet.start_date) continue;
const new_timesheet = new_timesheets.find((ts) => ts.start_date.getTime() === old_timesheet.start_date!.getTime());
if (!new_timesheet) {
console.warn(`No new timesheet matching legacy timesheet ${old_timesheet.id}`);
continue;
}
const old_expenses = await prisma_legacy.expenses.findMany({
where: { time_sheet_id: old_timesheet.id },
select: {
time_sheet_id: true,
date: true,
code: true,
description: true,
value: true,
status: true,
},
});
await createManyNewExpenses(new_timesheet.id, old_expenses);
}
}
await prisma_legacy.$disconnect();
await prisma.$disconnect();
}
const findOneNewEmployee = async (id: number): Promise<NewEmployee> => {
const new_employee = await prisma.employees.findUnique({
where: { id: id },
select: {
id: true,
company_code: true,
external_payroll_id: true,
},
});
if (!new_employee) throw new Error(`New Employee with id ${id} not found`)
return new_employee;
}
const findOneOldEmployee = async (new_employee: NewEmployee): Promise<string> => {
const old_employee = await prisma_legacy.employees.findFirst({
where: {
company: new_employee.company_code,
employee_number: new_employee.external_payroll_id.toString(),
},
select: {
id: true,
},
});
if (!old_employee) throw new Error(`Old Employee not found`);
return old_employee.id;
}
const findManyOldTimesheets = async (old_employee_id: string) => {
const old_timesheets = await prisma_legacy.time_sheets.findMany({
where: { employee_id: old_employee_id },
select: { id: true, start_date: true, status: true }
});
return old_timesheets;
}
const findManyNewTimesheets = async (employee_id: number) => {
const timesheets = await prisma.timesheets.findMany({
where: { employee_id: employee_id },
select: { id: true, start_date: true }
})
return timesheets;
}
const createManyNewExpenses = async (timesheet_id: number, old_expenses: OldExpense[]) => {
for (const old_expense of old_expenses) {
let mileage: number = 0;
let amount: number = old_expense.value ?? 0;
if (old_expense.code === 'G503') {
mileage = old_expense.value!;
amount = mileage * 0.72;
}
if (mileage < 0) {
console.warn(`expense of value less than '0' found`)
}
if (old_expense.date == null) {
console.warn(`Expense date invalid ${old_expense.date}`);
continue;
}
const date = toDateFromString(old_expense.date);
if (old_expense.status == null) {
console.warn(`status null for legacy expense ${old_expense}`);
continue;
}
if (old_expense.code == null) {
console.warn(`Code null for legacy expense ${old_expense.code}`);
continue;
}
const bank_code_id = await findBankCodeIdUsingOldCode(old_expense.code);
await prisma.expenses.create({
// where: { unique_ts_id_date_amount_mileage: { timesheet_id: timesheet_id, date, amount, mileage } },
// update: {
// is_approved: old_expense.status,
// },
data: {
date: date,
comment: old_expense.description ?? '',
timesheet_id: timesheet_id,
bank_code_id: bank_code_id,
amount: amount,
mileage: mileage,
}
});
}
}
const findBankCodeIdUsingOldCode = async (code: string): Promise<number> => {
const bank_code = await prisma.bankCodes.findFirst({
where: { bank_code: code },
select: { id: true },
});
if (!bank_code) throw new Error(`Bank_code_id not found for Code ${code}`)
return bank_code.id;
}

210
scripts/migrate-shifts.ts Normal file
View File

@ -0,0 +1,210 @@
import { PrismaClient as PrismaNew } from "@prisma/client";
import { PrismaClient as PrismaLegacy } from "@prisma/client-legacy"
const prisma_legacy = new PrismaLegacy({});
const prisma = new PrismaNew({});
type NewEmployee = {
id: number;
company_code: number;
external_payroll_id: number;
}
type OldShifts = {
time_sheet_id: string | null;
code: string | null;
type: string | null;
date: Date | null;
start_time: bigint | null;
end_time: bigint | null;
comment: string | null;
status: boolean | null;
}
export const extractOldShifts = async () => {
for (let id = 1; id <= 61; id++) {
console.log(`Start of shift migration ***************************************************************`);
const new_employee = await findOneNewEmployee(id);
console.log(`Employee ${id} found in new DB`);
const new_timesheets = await findManyNewTimesheets(new_employee.id);
console.log(`New Timesheets found for employee ${id}`);
for (const ts of new_timesheets) {
console.log(`start_date = ${ts.start_date} timesheet_id = ${ts.id}`)
}
console.log('***************************************************************');
const old_employee_id = await findOneOldEmployee(new_employee);
console.log(`Employee ${new_employee.id} found in old DB`);
const old_timesheets = await findManyOldTimesheets(old_employee_id);
console.log(`Timesheets for employee ${old_employee_id}/${new_employee.id} found in old DB`);
for (const old_timesheet of old_timesheets) {
if (!old_timesheet.start_date) continue;
const new_timesheet = new_timesheets.find((ts) => ts.start_date.getTime() === old_timesheet.start_date!.getTime());
if (!new_timesheet) {
console.warn(`No new timesheet ${new_timesheet} matching legacy timesheet ${old_timesheet.id}`);
continue;
}
const old_shifts = await prisma_legacy.shifts.findMany({
where: { time_sheet_id: old_timesheet.id },
select: {
time_sheet_id: true,
code: true,
type: true,
date: true,
start_time: true,
end_time: true,
comment: true,
status: true,
},
});
await createManyNewShifts(new_timesheet.id, old_shifts);
}
}
await prisma_legacy.$disconnect();
await prisma.$disconnect();
}
const findOneNewEmployee = async (id: number): Promise<NewEmployee> => {
const new_employee = await prisma.employees.findUnique({
where: { id: id },
select: {
id: true,
company_code: true,
external_payroll_id: true,
},
});
if (!new_employee) throw new Error(`New Employee with id ${id} not found`)
return new_employee;
}
const findOneOldEmployee = async (new_employee: NewEmployee): Promise<string> => {
const old_employee = await prisma_legacy.employees.findFirst({
where: {
company: new_employee.company_code,
employee_number: new_employee.external_payroll_id.toString(),
},
select: {
id: true,
},
});
if (!old_employee) throw new Error(`Old Employee not found`);
return old_employee.id;
}
const findManyOldTimesheets = async (old_employee_id: string) => {
const old_timesheets = await prisma_legacy.time_sheets.findMany({
where: { employee_id: old_employee_id },
select: { id: true, start_date: true, status: true }
});
return old_timesheets;
}
const findManyNewTimesheets = async (employee_id: number) => {
const timesheets = await prisma.timesheets.findMany({
where: { employee_id: employee_id },
select: { id: true, start_date: true }
})
return timesheets;
}
const createManyNewShifts = async (timesheet_id: number, old_shifts: OldShifts[]) => {
for (const old_shift of old_shifts) {
let is_remote = true;
const start = toHHmmfromLegacyTimestamp(old_shift.start_time);
if (old_shift.start_time == null || !start) {
console.warn(`Shift start invalid ${old_shift.start_time}`);
continue;
}
const end = toHHmmfromLegacyTimestamp(old_shift.end_time);
if (old_shift.end_time == null || !end) {
console.warn(`Shift end invalid ${old_shift.end_time}`);
continue;
}
if (old_shift.date == null) {
console.warn(`Shift date invalid ${old_shift.date}`);
continue;
}
if (old_shift.status == null) {
console.warn(`status null for legacy shift ${old_shift}`);
continue;
}
if (old_shift.type == null) {
console.warn(`type null for legacy shift ${old_shift.type}`);
continue;
}
if (old_shift.type === 'office') {
is_remote = false;
}
if (old_shift.code == null) {
console.warn(`Code null for legacy shift ${old_shift.code}`);
continue;
}
const bank_code_id = await findBankCodeIdUsingOldCode(old_shift.code);
try {
await prisma.shifts.create({
// where: { unique_ts_id_date_start_time: {
// timesheet_id,
// date: old_shift.date,
// start_time: toDateFromHHmm(start) }},
// update: {
// start_time: toDateFromHHmm(start),
// end_time: toDateFromHHmm(end),
// comment: old_shift.comment,
// is_approved: old_shift.status,
// is_remote: is_remote,
// bank_code_id: bank_code_id,
// },
data: {
date: old_shift.date,
start_time: toDateFromHHmm(start),
end_time: toDateFromHHmm(end),
comment: old_shift.comment,
is_approved: old_shift.status,
is_remote: is_remote,
timesheet_id: timesheet_id,
bank_code_id: bank_code_id,
},
});
} catch (error) {
console.log('An error occured during shifts creation');
}
}
}
const toHHmmfromLegacyTimestamp = (value: bigint | null): string | null => {
if (value == null) return null;
const date = new Date(Number(value));
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
const toDateFromHHmm = (hhmm: string): Date => {
const [hh, mm] = hhmm.split(':');
const hours = Number(hh);
const minutes = Number(mm);
return new Date(Date.UTC(1970, 0, 1, hours, minutes, 0, 0));
}
const findBankCodeIdUsingOldCode = async (code: string): Promise<number> => {
if (code === 'G700') {
code = 'G104';
} else if (code === 'G140') {
code = 'G56'
}
const bank_code = await prisma.bankCodes.findFirst({
where: { bank_code: code },
select: { id: true, bank_code: true },
});
if (!bank_code) throw new Error(`Bank_code_id not found for Code ${code}`)
return bank_code.id;
}

View File

@ -0,0 +1,118 @@
import { PrismaClient as Prisma } from "@prisma/client";
import { PrismaClient as PrismaLegacy } from "@prisma/client-legacy"
import { toStringFromDate } from "src/common/utils/date-utils";
type NewEmployee = {
id: number;
company_code: number;
external_payroll_id: number;
}
type OldTimesheets = {
id: string;
start_date: Date | null;
status: boolean | null;
}
const prisma_legacy = new PrismaLegacy({});
const prisma_new = new Prisma({});
export const extractOldTimesheets = async () => {
for (let id = 1; id <= 61; id++) {
const new_employee = await findOneNewEmployee(id);
console.log(`Employee ${id} found in new DB ${new_employee.external_payroll_id}`);
const old_employee_id = await findOneOldEmployee(new_employee);
console.log(`Employee ${new_employee.id} found in old DB`);
const old_timesheets = await findManyOldTimesheets(old_employee_id);
console.log(` ${old_timesheets.length} Timesheets for employee ${old_employee_id}/${new_employee.id} found in old DB`);
await createManyNewTimesheets(old_timesheets, new_employee);
console.log(`${old_timesheets.length} New Timesheets created in new DB for employee ${new_employee.id}`);
}
await prisma_legacy.$disconnect();
await prisma_new.$disconnect();
}
const findOneNewEmployee = async (id: number): Promise<NewEmployee> => {
const new_employee = await prisma_new.employees.findUnique({
where: { id: id },
select: {
id: true,
company_code: true,
external_payroll_id: true,
},
});
if (!new_employee) throw new Error(`New Employee with id ${id} not found`)
return new_employee;
}
const findOneOldEmployee = async (new_employee: NewEmployee): Promise<string> => {
const employee_number = new_employee.external_payroll_id.toString()
const old_employee = await prisma_legacy.employees.findFirst({
where: {
company: new_employee.company_code,
employee_number: employee_number,
},
select: {
id: true,
},
});
if (!old_employee) throw new Error(`Old Employee not found`);
return old_employee.id;
}
const findManyOldTimesheets = async (old_employee_id: string) => {
const old_timesheets = await prisma_legacy.time_sheets.findMany({
where: { employee_id: old_employee_id },
select: { id: true, start_date: true, status: true }
});
if (!old_timesheets) throw new Error(`old Timesheets not found for employee_id ${old_employee_id}`)
return old_timesheets;
}
const createManyNewTimesheets = async (old_timesheets: OldTimesheets[], new_employee: NewEmployee) => {
for (const timesheet of old_timesheets) {
if (timesheet.start_date == null) {
console.warn(`start_date invalid for legacy timesheet ${timesheet.id}`);
continue;
}
if (timesheet.status == null) {
console.warn(`status null for legacy timesheet ${timesheet.id}`);
continue;
}
try {
const new_timesheet = await prisma_new.timesheets.upsert({
where: { employee_id_start_date: { employee_id: new_employee.id, start_date: timesheet.start_date } },
update: {
is_approved: timesheet.status,
},
create: {
employee_id: new_employee.id,
start_date: timesheet.start_date,
is_approved: timesheet.status,
},
});
if (!new_timesheet) throw new Error(
`Timesheet with start_date: ${toStringFromDate(timesheet.start_date!)} for employee ${new_employee.id} not created`
);
} catch (error) {
throw new Error('An error occured during timesheets creation');
}
}
}
extractOldTimesheets()
.then(() => {
console.log("Migration completed");
})
.catch((error) => {
console.error("Migration failed:", error);
})
.finally(async () => {
await prisma_legacy.$disconnect();
await prisma_new.$disconnect();
});

View File

@ -0,0 +1,21 @@
import { extractOldTimesheets } from "scripts/migrate-timesheets";
import { extractOldExpenses } from "scripts/migrate-expenses";
import { extractOldShifts } from "scripts/migrate-shifts";
import { Injectable } from "@nestjs/common";
@Injectable()
export class MigrationService {
constructor() {}
async migrateTimesheets() {
extractOldTimesheets();
};
async migrateShifts() {
extractOldShifts();
}
async migrateExpenses() {
extractOldExpenses();
}
}

View File

@ -12,6 +12,7 @@ import { ValidationError } from 'class-validator';
import { TimeAndAttendanceModule } from 'src/time-and-attendance/time-and-attendance.module'; import { TimeAndAttendanceModule } from 'src/time-and-attendance/time-and-attendance.module';
import { AuthenticationModule } from 'src/identity-and-account/authentication/auth.module'; import { AuthenticationModule } from 'src/identity-and-account/authentication/auth.module';
import { IdentityAndAccountModule } from 'src/identity-and-account/identity-and-account.module'; import { IdentityAndAccountModule } from 'src/identity-and-account/identity-and-account.module';
import { PrismaLegacyModule } from 'src/prisma-legacy/prisma.module';
@Module({ @Module({
imports: [ imports: [
@ -20,6 +21,7 @@ import { IdentityAndAccountModule } from 'src/identity-and-account/identity-and-
ScheduleModule.forRoot(), //cronjobs ScheduleModule.forRoot(), //cronjobs
NotificationsModule, NotificationsModule,
PrismaModule, PrismaModule,
PrismaLegacyModule,
TimeAndAttendanceModule, TimeAndAttendanceModule,
IdentityAndAccountModule, IdentityAndAccountModule,
], ],

View File

@ -75,7 +75,7 @@ export const toStringFromDate = (date: Date) =>
//converts HHmm format to string //converts HHmm format to string
export const toHHmmFromString = (hhmm: string): Date => { export const toDateFromHHmm = (hhmm: string): Date => {
const [hh, mm] = hhmm.split(':').map(Number); const [hh, mm] = hhmm.split(':').map(Number);
const date = new Date('1970-01-01T00:00:00.000Z'); const date = new Date('1970-01-01T00:00:00.000Z');
date.setUTCHours(hh, mm, 0, 0); date.setUTCHours(hh, mm, 0, 0);

View File

@ -18,6 +18,10 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
import * as session from 'express-session'; import * as session from 'express-session';
import * as passport from 'passport'; import * as passport from 'passport';
// import { extractOldTimesheets } from 'scripts/migrate-timesheets';
import { extractOldShifts } from 'scripts/migrate-shifts';
import { extractOldTimesheets } from 'scripts/migrate-timesheets';
import { extractOldExpenses } from 'scripts/migrate-expenses';
const SESSION_TOKEN_DURATION_MINUTES = 180 const SESSION_TOKEN_DURATION_MINUTES = 180
@ -89,5 +93,11 @@ async function bootstrap() {
await ensureAttachmentsTmpDir(); await ensureAttachmentsTmpDir();
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
// migration function calls
// await extractOldTimesheets();
// await extractOldShifts();
// await extractOldExpenses();
} }
bootstrap(); bootstrap();

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaLegacyService } from './prisma.service';
@Global()
@Module({
providers: [PrismaLegacyService],
exports: [PrismaLegacyService],
})
export class PrismaLegacyModule {}

View File

@ -0,0 +1,18 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient as PrismaLegacyClient } from '@prisma/client';
//Gestion des connections à la DB
@Injectable()
export class PrismaLegacyService
extends PrismaLegacyClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -5,7 +5,7 @@ import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper"; import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { Result } from "src/common/errors/result-error.factory"; import { Result } from "src/common/errors/result-error.factory";
import { toStringFromHHmm, toStringFromDate, toDateFromString, overlaps, toHHmmFromString } from "src/common/utils/date-utils"; import { toStringFromHHmm, toStringFromDate, toDateFromString, overlaps, toDateFromHHmm } from "src/common/utils/date-utils";
import { ShiftDto } from "src/time-and-attendance/shifts/dtos/shift-create.dto"; import { ShiftDto } from "src/time-and-attendance/shifts/dtos/shift-create.dto";
@Injectable() @Injectable()
@ -135,8 +135,8 @@ export class ShiftsCreateService {
//TODO: validate date and time to ensure "banana" is not accepted using an if statement and a REGEX //TODO: validate date and time to ensure "banana" is not accepted using an if statement and a REGEX
const date = toDateFromString(dto.date); const date = toDateFromString(dto.date);
const start_time = toHHmmFromString(dto.start_time); const start_time = toDateFromHHmm(dto.start_time);
const end_time = toHHmmFromString(dto.end_time); const end_time = toDateFromHHmm(dto.end_time);
return { success: true, data: { date, start_time, end_time, bank_code_id: bank_code_id.data } }; return { success: true, data: { date, start_time, end_time, bank_code_id: bank_code_id.data } };
} }

View File

@ -5,7 +5,7 @@ import { Injectable } from "@nestjs/common";
import { Normalized } from "src/time-and-attendance/utils/type.utils"; import { Normalized } from "src/time-and-attendance/utils/type.utils";
import { Result } from "src/common/errors/result-error.factory"; import { Result } from "src/common/errors/result-error.factory";
import { EmployeeTimesheetResolver } from "src/common/mappers/timesheet.mapper"; import { EmployeeTimesheetResolver } from "src/common/mappers/timesheet.mapper";
import { toDateFromString, toStringFromHHmm, toStringFromDate, toHHmmFromString, overlaps } from "src/common/utils/date-utils"; import { toDateFromString, toStringFromHHmm, toStringFromDate, toDateFromHHmm, overlaps } from "src/common/utils/date-utils";
import { ShiftDto } from "src/time-and-attendance/shifts/dtos/shift-create.dto"; import { ShiftDto } from "src/time-and-attendance/shifts/dtos/shift-create.dto";
@Injectable() @Injectable()
@ -145,8 +145,8 @@ export class ShiftsUpdateDeleteService {
success: true, success: true,
data: { data: {
date: toDateFromString(dto.date), date: toDateFromString(dto.date),
start_time: toHHmmFromString(dto.start_time), start_time: toDateFromHHmm(dto.start_time),
end_time: toHHmmFromString(dto.end_time), end_time: toDateFromHHmm(dto.end_time),
bank_code_id: bank_code_id.data bank_code_id: bank_code_id.data
} }
}; };
@ -160,8 +160,8 @@ export class ShiftsUpdateDeleteService {
if (shift_a.date !== shift_b.date || shift_a.id === shift_b.id) continue; if (shift_a.date !== shift_b.date || shift_a.id === shift_b.id) continue;
const has_overlap = overlaps( const has_overlap = overlaps(
{ start: toHHmmFromString(shift_a.start_time), end: toHHmmFromString(shift_a.end_time) }, { start: toDateFromHHmm(shift_a.start_time), end: toDateFromHHmm(shift_a.end_time) },
{ start: toHHmmFromString(shift_b.start_time), end: toHHmmFromString(shift_b.end_time) }, { start: toDateFromHHmm(shift_b.start_time), end: toDateFromHHmm(shift_b.end_time) },
); );
if (has_overlap) return { success: false, error: `SHIFT_OVERLAP` }; if (has_overlap) return { success: false, error: `SHIFT_OVERLAP` };
} }

View File

@ -124,7 +124,7 @@
// // } // // }
// // async findAll(filters: SearchExpensesDto): Promise<Expenses[]> { // // async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
// // const where = buildPrismaWhere(filters); // const where = buildPrismaWhere(filters);
// // const expenses = await this.prisma.expenses.findMany({ where }) // // const expenses = await this.prisma.expenses.findMany({ where })
// // return expenses; // // return expenses;
// // } // // }

View File

@ -1,125 +1,125 @@
import * as request from 'supertest'; // import * as request from 'supertest';
import { INestApplication } from '@nestjs/common'; // import { INestApplication } from '@nestjs/common';
import { createApp } from './utils/testing-app'; // import { createApp } from './utils/testing-app';
import { PrismaService } from 'src/prisma/prisma.service'; // import { PrismaService } from 'src/prisma/prisma.service';
type CustomerPayload = { // type CustomerPayload = {
user_id?: string; // user_id?: string;
first_name: string; // first_name: string;
last_name: string; // last_name: string;
email?: string; // email?: string;
phone_number: number; // phone_number: number;
residence?: string; // residence?: string;
invoice_id: number; // invoice_id: number;
}; // };
const BASE = '/customers'; // const BASE = '/customers';
const uniqueEmail = () => // const uniqueEmail = () =>
`customer+${Date.now()}_${Math.random().toString(36).slice(2,8)}@test.local`; // `customer+${Date.now()}_${Math.random().toString(36).slice(2,8)}@test.local`;
const uniquePhone = () => // const uniquePhone = () =>
Math.floor(100_000_000 + Math.random() * 900_000_000); // Math.floor(100_000_000 + Math.random() * 900_000_000);
function makeCustomerPayload(overrides: Partial<CustomerPayload> = {}): CustomerPayload { // function makeCustomerPayload(overrides: Partial<CustomerPayload> = {}): CustomerPayload {
return { // return {
first_name: 'Gandalf', // first_name: 'Gandalf',
last_name: 'TheGray', // last_name: 'TheGray',
email: uniqueEmail(), // email: uniqueEmail(),
phone_number: uniquePhone(), // phone_number: uniquePhone(),
residence: '1 Ringbearers Way, Mount Doom, ME', // residence: '1 Ringbearers Way, Mount Doom, ME',
invoice_id: Math.floor(1_000_000 + Math.random() * 9_000_000), // invoice_id: Math.floor(1_000_000 + Math.random() * 9_000_000),
...overrides, // ...overrides,
}; // };
} // }
describe('Customers (e2e) — autonome', () => { // describe('Customers (e2e) — autonome', () => {
let app: INestApplication; // let app: INestApplication;
let prisma: PrismaService; // let prisma: PrismaService;
let createdId: number | null = null; // let createdId: number | null = null;
beforeAll(async () => { // beforeAll(async () => {
app = await createApp(); // app = await createApp();
prisma = app.get(PrismaService); // prisma = app.get(PrismaService);
}); // });
afterAll(async () => { // afterAll(async () => {
if (createdId) { // if (createdId) {
try { await prisma.customers.delete({ where: { id: createdId } }); } catch {} // try { await prisma.customers.delete({ where: { id: createdId } }); } catch {}
} // }
await app.close(); // await app.close();
await prisma.$disconnect(); // await prisma.$disconnect();
}); // });
it(`GET ${BASE} → 200 (array)`, async () => { // it(`GET ${BASE} → 200 (array)`, async () => {
const res = await request(app.getHttpServer()).get(BASE); // const res = await request(app.getHttpServer()).get(BASE);
expect(res.status).toBe(200); // expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true); // expect(Array.isArray(res.body)).toBe(true);
}); // });
it(`POST ${BASE} (valid) → 201 puis GET /:id → 200`, async () => { // it(`POST ${BASE} (valid) → 201 puis GET /:id → 200`, async () => {
const payload = makeCustomerPayload(); // const payload = makeCustomerPayload();
const createRes = await request(app.getHttpServer()).post(BASE).send(payload); // const createRes = await request(app.getHttpServer()).post(BASE).send(payload);
if (createRes.status !== 201) { // if (createRes.status !== 201) {
console.log('Create error:', createRes.body || createRes.text); // console.log('Create error:', createRes.body || createRes.text);
} // }
expect(createRes.status).toBe(201); // expect(createRes.status).toBe(201);
expect(createRes.body).toEqual( // expect(createRes.body).toEqual(
expect.objectContaining({ // expect.objectContaining({
id: expect.any(Number), // id: expect.any(Number),
user_id: expect.any(String), // user_id: expect.any(String),
invoice_id: payload.invoice_id, // invoice_id: payload.invoice_id,
}) // })
); // );
expect(createRes.body.user_id).toMatch(/^[0-9a-fA-F-]{36}$/); // expect(createRes.body.user_id).toMatch(/^[0-9a-fA-F-]{36}$/);
createdId = createRes.body.id; // createdId = createRes.body.id;
const getRes = await request(app.getHttpServer()).get(`${BASE}/${createdId}`); // const getRes = await request(app.getHttpServer()).get(`${BASE}/${createdId}`);
expect(getRes.status).toBe(200); // expect(getRes.status).toBe(200);
expect(getRes.body).toEqual(expect.objectContaining({ id: createdId })); // expect(getRes.body).toEqual(expect.objectContaining({ id: createdId }));
}); // });
it(`PATCH ${BASE}/:id → 200 (first_name mis à jour)`, async () => { // it(`PATCH ${BASE}/:id → 200 (first_name mis à jour)`, async () => {
if (!createdId) { // if (!createdId) {
const create = await request(app.getHttpServer()).post(BASE).send(makeCustomerPayload()); // const create = await request(app.getHttpServer()).post(BASE).send(makeCustomerPayload());
expect(create.status).toBe(201); // expect(create.status).toBe(201);
createdId = create.body.id; // createdId = create.body.id;
} // }
const patchRes = await request(app.getHttpServer()) // const patchRes = await request(app.getHttpServer())
.patch(`${BASE}/${createdId}`) // .patch(`${BASE}/${createdId}`)
.send({ first_name: 'Mithrandir' }); // .send({ first_name: 'Mithrandir' });
expect([200, 204]).toContain(patchRes.status); // expect([200, 204]).toContain(patchRes.status);
const getRes = await request(app.getHttpServer()).get(`${BASE}/${createdId}`); // const getRes = await request(app.getHttpServer()).get(`${BASE}/${createdId}`);
expect(getRes.status).toBe(200); // expect(getRes.status).toBe(200);
expect(getRes.body.first_name ?? 'Mithrandir').toBe('Mithrandir'); // expect(getRes.body.first_name ?? 'Mithrandir').toBe('Mithrandir');
}); // });
it(`GET ${BASE}/:id (not found) → 404/400`, async () => { // it(`GET ${BASE}/:id (not found) → 404/400`, async () => {
const res = await request(app.getHttpServer()).get(`${BASE}/999999`); // const res = await request(app.getHttpServer()).get(`${BASE}/999999`);
expect([404, 400]).toContain(res.status); // expect([404, 400]).toContain(res.status);
}); // });
it(`POST ${BASE} (invalid payload) → 400`, async () => { // it(`POST ${BASE} (invalid payload) → 400`, async () => {
const res = await request(app.getHttpServer()).post(BASE).send({}); // const res = await request(app.getHttpServer()).post(BASE).send({});
expect(res.status).toBeGreaterThanOrEqual(400); // expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500); // expect(res.status).toBeLessThan(500);
}); // });
it(`DELETE ${BASE}/:id → 200/204`, async () => { // it(`DELETE ${BASE}/:id → 200/204`, async () => {
let id = createdId; // let id = createdId;
if (!id) { // if (!id) {
const create = await request(app.getHttpServer()).post(BASE).send(makeCustomerPayload()); // const create = await request(app.getHttpServer()).post(BASE).send(makeCustomerPayload());
expect(create.status).toBe(201); // expect(create.status).toBe(201);
id = create.body.id; // id = create.body.id;
} // }
const del = await request(app.getHttpServer()).delete(`${BASE}/${id}`); // const del = await request(app.getHttpServer()).delete(`${BASE}/${id}`);
expect([200, 204]).toContain(del.status); // expect([200, 204]).toContain(del.status);
if (createdId === id) createdId = null; // if (createdId === id) createdId = null;
}); // });
}); // });

View File

@ -1,143 +1,143 @@
// test/pay-periods-approval.e2e-spec.ts // // test/pay-periods-approval.e2e-spec.ts
const supertest = require('supertest'); // const supertest = require('supertest');
import { INestApplication } from '@nestjs/common'; // import { INestApplication } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service'; // import { PrismaService } from 'src/prisma/prisma.service';
import { createApp } from './utils/testing-app'; // import { createApp } from './utils/testing-app';
import { makeEmployee } from './factories/employee.factory'; // import { makeEmployee } from './factories/employee.factory';
import { makeTimesheet } from './factories/timesheet.factory'; // import { makeTimesheet } from './factories/timesheet.factory';
describe('PayPeriods approval (e2e)', () => { // describe('PayPeriods approval (e2e)', () => {
const BASE = '/pay-periods'; // const BASE = '/pay-periods';
let app: INestApplication; // let app: INestApplication;
let prisma: PrismaService; // let prisma: PrismaService;
let periodYear: number; // let periodYear: number;
let periodNumber: number; // let periodNumber: number;
let employeeId: number; // let employeeId: number;
let timesheetId: number; // let timesheetId: number;
let shiftId: number; // let shiftId: number;
let expenseId: number; // let expenseId: number;
const isoDay = (d: Date) => // const isoDay = (d: Date) =>
new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())).toISOString(); // new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())).toISOString();
const isoTime = (h: number, m = 0) => // const isoTime = (h: number, m = 0) =>
new Date(Date.UTC(1970, 0, 1, h, m, 0)).toISOString(); // new Date(Date.UTC(1970, 0, 1, h, m, 0)).toISOString();
beforeAll(async () => { // beforeAll(async () => {
app = await createApp(); // app = await createApp();
prisma = app.get(PrismaService); // prisma = app.get(PrismaService);
// 1) Récupère un pay period existant // // 1) Récupère un pay period existant
const period = await prisma.payPeriods.findFirst({ orderBy: { period_number: 'asc' } }); // const period = await prisma.payPeriods.findFirst({ orderBy: { period_number: 'asc' } });
if (!period) throw new Error('Aucun pay period en DB (seed requis).'); // if (!period) throw new Error('Aucun pay period en DB (seed requis).');
periodYear = period.year; // periodYear = period.year;
periodNumber = period.period_number; // periodNumber = period.period_number;
// 2) Crée un employé + timesheet (non approuvé) // // 2) Crée un employé + timesheet (non approuvé)
const empRes = await supertest(app.getHttpServer()) // const empRes = await supertest(app.getHttpServer())
.post('/employees') // .post('/employees')
.send(makeEmployee()); // .send(makeEmployee());
if (empRes.status !== 201) { // if (empRes.status !== 201) {
// eslint-disable-next-line no-console // // eslint-disable-next-line no-console
console.warn('Create employee error:', empRes.body || empRes.text); // console.warn('Create employee error:', empRes.body || empRes.text);
throw new Error('Impossible de créer un employé pour le test pay-periods.'); // throw new Error('Impossible de créer un employé pour le test pay-periods.');
} // }
employeeId = empRes.body.id; // employeeId = empRes.body.id;
const tsRes = await supertest(app.getHttpServer()) // const tsRes = await supertest(app.getHttpServer())
.post('/timesheets') // .post('/timesheets')
.send(makeTimesheet(employeeId, { is_approved: false })); // .send(makeTimesheet(employeeId, { is_approved: false }));
if (tsRes.status !== 201) { // if (tsRes.status !== 201) {
// eslint-disable-next-line no-console // // eslint-disable-next-line no-console
console.warn('Create timesheet error:', tsRes.body || tsRes.text); // console.warn('Create timesheet error:', tsRes.body || tsRes.text);
throw new Error('Impossible de créer un timesheet pour le test pay-periods.'); // throw new Error('Impossible de créer un timesheet pour le test pay-periods.');
} // }
timesheetId = tsRes.body.id; // timesheetId = tsRes.body.id;
// 3) Bank codes // // 3) Bank codes
const bcShift = await prisma.bankCodes.findFirst({ // const bcShift = await prisma.bankCodes.findFirst({
where: { categorie: 'SHIFT' }, // where: { categorie: 'SHIFT' },
select: { id: true }, // select: { id: true },
}); // });
if (!bcShift) throw new Error('Aucun bank code SHIFT trouvé.'); // if (!bcShift) throw new Error('Aucun bank code SHIFT trouvé.');
const bcExpense = await prisma.bankCodes.findFirst({ // const bcExpense = await prisma.bankCodes.findFirst({
where: { categorie: 'EXPENSE' }, // where: { categorie: 'EXPENSE' },
select: { id: true }, // select: { id: true },
}); // });
if (!bcExpense) throw new Error('Aucun bank code EXPENSE trouvé.'); // if (!bcExpense) throw new Error('Aucun bank code EXPENSE trouvé.');
// 4) Crée 1 shift + 1 expense DANS la période choisie // // 4) Crée 1 shift + 1 expense DANS la période choisie
const dateISO = isoDay(period.start_date); // const dateISO = isoDay(period.start_date);
const shiftRes = await supertest(app.getHttpServer()) // const shiftRes = await supertest(app.getHttpServer())
.post('/shifts') // .post('/shifts')
.send({ // .send({
timesheet_id: timesheetId, // timesheet_id: timesheetId,
bank_code_id: bcShift.id, // bank_code_id: bcShift.id,
date: dateISO, // date: dateISO,
start_time: isoTime(9), // start_time: isoTime(9),
end_time: isoTime(17), // end_time: isoTime(17),
description: 'PP approval shift', // description: 'PP approval shift',
}); // });
if (shiftRes.status !== 201) { // if (shiftRes.status !== 201) {
// eslint-disable-next-line no-console // // eslint-disable-next-line no-console
console.warn('Create shift error:', shiftRes.body || shiftRes.text); // console.warn('Create shift error:', shiftRes.body || shiftRes.text);
throw new Error('Création shift échouée.'); // throw new Error('Création shift échouée.');
} // }
shiftId = shiftRes.body.id; // shiftId = shiftRes.body.id;
const expenseRes = await supertest(app.getHttpServer()) // const expenseRes = await supertest(app.getHttpServer())
.post('/Expenses') // <- respecte ta casse de route // .post('/Expenses') // <- respecte ta casse de route
.send({ // .send({
timesheet_id: timesheetId, // timesheet_id: timesheetId,
bank_code_id: bcExpense.id, // bank_code_id: bcExpense.id,
date: dateISO, // date: dateISO,
amount: 42, // amount: 42,
description: 'PP approval expense', // description: 'PP approval expense',
is_approved: false, // is_approved: false,
}); // });
if (expenseRes.status !== 201) { // if (expenseRes.status !== 201) {
// eslint-disable-next-line no-console // // eslint-disable-next-line no-console
console.warn('Create expense error:', expenseRes.body || expenseRes.text); // console.warn('Create expense error:', expenseRes.body || expenseRes.text);
throw new Error('Création expense échouée.'); // throw new Error('Création expense échouée.');
} // }
expenseId = expenseRes.body.id; // expenseId = expenseRes.body.id;
}); // });
afterAll(async () => { // afterAll(async () => {
await app.close(); // await app.close();
await prisma.$disconnect(); // await prisma.$disconnect();
}); // });
it(`PATCH ${BASE}/:year/:periodNumber/approval → 200 (cascade approval)`, async () => { // it(`PATCH ${BASE}/:year/:periodNumber/approval → 200 (cascade approval)`, async () => {
const res = await supertest(app.getHttpServer()) // const res = await supertest(app.getHttpServer())
.patch(`${BASE}/${periodYear}/${periodNumber}/approval`) // .patch(`${BASE}/${periodYear}/${periodNumber}/approval`)
.send(); // aucun body requis par ton contrôleur // .send(); // aucun body requis par ton contrôleur
expect([200, 204]).toContain(res.status); // expect([200, 204]).toContain(res.status);
if (res.body?.message) { // if (res.body?.message) {
expect(String(res.body.message)).toContain(`${periodYear}-${periodNumber}`); // expect(String(res.body.message)).toContain(`${periodYear}-${periodNumber}`);
} // }
// Vérifie cascade: // // Vérifie cascade:
const tsCheck = await supertest(app.getHttpServer()).get(`/timesheets/${timesheetId}`); // const tsCheck = await supertest(app.getHttpServer()).get(`/timesheets/${timesheetId}`);
expect(tsCheck.status).toBe(200); // expect(tsCheck.status).toBe(200);
expect(tsCheck.body?.is_approved).toBe(true); // expect(tsCheck.body?.is_approved).toBe(true);
const shiftCheck = await supertest(app.getHttpServer()).get(`/shifts/${shiftId}`); // const shiftCheck = await supertest(app.getHttpServer()).get(`/shifts/${shiftId}`);
expect(shiftCheck.status).toBe(200); // expect(shiftCheck.status).toBe(200);
expect(shiftCheck.body?.is_approved).toBe(true); // expect(shiftCheck.body?.is_approved).toBe(true);
const expCheck = await supertest(app.getHttpServer()).get(`/Expenses/${expenseId}`); // const expCheck = await supertest(app.getHttpServer()).get(`/Expenses/${expenseId}`);
expect(expCheck.status).toBe(200); // expect(expCheck.status).toBe(200);
expect(expCheck.body?.is_approved).toBe(true); // expect(expCheck.body?.is_approved).toBe(true);
}); // });
it(`PATCH ${BASE}/2099/999/approval → 404 (period not found)`, async () => { // it(`PATCH ${BASE}/2099/999/approval → 404 (period not found)`, async () => {
const bad = await supertest(app.getHttpServer()) // const bad = await supertest(app.getHttpServer())
.patch(`${BASE}/2099/999/approval`) // .patch(`${BASE}/2099/999/approval`)
.send(); // .send();
expect(bad.status).toBe(404); // expect(bad.status).toBe(404);
}); // });
}); // });