Files
siop2/apps/api/prisma/seed.ts
pr-daaif 266ffaaf1b feat(r1.1): socle backend du référentiel — modèle, contrat, API, seed, tests
- migration r1_referentiel : Category (EQUIPMENT/COMPONENT_TYPE), Location
  (site → zone, lat/lng + colonne PostGIS générée geography(Point,4326)
  + index GIST), Asset (statut d'équipement), AssetComponent (organe sans
  emplacement PAR CONSTRUCTION), Team, invitation sur User ; migration
  autosuffisante (CREATE EXTENSION IF NOT EXISTS postgis)
- contrat : 21 nouvelles opérations (26 total), générateur OpenAPI étendu
  aux paramètres de chemin ; spec + client web régénérés dans ce commit
- API : modules categories/locations/assets/teams + gestion des personnes
  (liste, rôles, invitation lien 7 j à usage unique, activation publique
  qui connecte directement, mise à jour rôle/équipes) — tout sous
  @RequirePermission ; invariants en service (profondeur 2, kinds,
  catégorie jamais supprimée)
- seed : parc de la maquette validée (5 sites + 8 zones, 8 appareils,
  organes A1/B2, 9 catégories, 2 équipes) — idempotent
- 36 tests verts (couverture 96 % stmts / 85 % branches) : recette
  site→zone→appareil→organes, matrice vivante, invitation→activation ;
  smoke test sur build de prod
- CI : postgres → postgis/postgis:18-3.6 (la migration R1 l'exige)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:27:48 +01:00

382 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Seed R0 — idempotent (upserts) : 7 rôles, matrice COMPLÈTE (une ligne par
* rôle × catégorie, invariant R0) et 7 comptes de démonstration (ADR-002).
* La matrice ci-dessous est le point de DÉPART pédagogique : elle vit en base
* et sera administrable (R1) — la modifier ici ne change pas une base déjà
* seedée (les lignes existantes ne sont pas écrasées, voir plus bas).
* Usage : pnpm seed (ou prisma db seed)
*/
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
import {
OBJECT_CATEGORIES,
ROLE_NAMES,
type ObjectCategory,
type RoleName,
} from '@siop/shared';
import * as argon2 from 'argon2';
type Grant = Partial<{
view: boolean;
viewOther: boolean;
create: boolean;
edit: boolean;
delete: boolean;
}>;
const FULL: Grant = { view: true, viewOther: true, create: true, edit: true, delete: true };
const READ: Grant = { view: true, viewOther: true };
/** Matrice de départ (rôle → catégorie → droits ; absent = tout à false). */
const MATRIX: Record<RoleName, Partial<Record<ObjectCategory, Grant>>> = {
Administrateur: Object.fromEntries(
OBJECT_CATEGORIES.map((c) => [c, FULL]),
) as Record<ObjectCategory, Grant>,
Gestionnaire: {
WORK_ORDERS: FULL,
REQUESTS: FULL,
ASSETS: FULL,
LOCATIONS: FULL,
METERS: FULL,
PARTS: FULL,
PURCHASE_ORDERS: FULL,
PEOPLE_TEAMS: { view: true, viewOther: true, create: true, edit: true },
ANALYTICS: READ,
SETTINGS: { view: true },
},
Dispatcher: {
WORK_ORDERS: { view: true, viewOther: true, create: true, edit: true },
REQUESTS: { view: true, viewOther: true, create: true, edit: true },
ASSETS: READ,
LOCATIONS: READ,
METERS: READ,
PARTS: { view: true },
PEOPLE_TEAMS: READ,
ANALYTICS: READ,
},
Technicien: {
WORK_ORDERS: { view: true, edit: true }, // ses OT uniquement (viewOther=false)
REQUESTS: { view: true },
ASSETS: READ,
LOCATIONS: READ,
METERS: { view: true, viewOther: true, create: true }, // relevés
PARTS: { view: true },
},
'Technicien limité': {
WORK_ORDERS: { view: true, edit: true }, // ses OT, sans consultation du parc
ASSETS: { view: true },
},
Demandeur: {
REQUESTS: { view: true, create: true }, // ses demandes uniquement
},
'Vue seule': {
WORK_ORDERS: READ,
REQUESTS: READ,
ASSETS: READ,
LOCATIONS: READ,
METERS: READ,
PARTS: READ,
PURCHASE_ORDERS: READ,
PEOPLE_TEAMS: READ,
ANALYTICS: READ,
},
};
/** 7 comptes démo — un par rôle. Les noms sont CEUX de la maquette validée
* (maquette-web.html) : la revue pixel compare écrans et maquettes. */
const DEMO_USERS: { email: string; displayName: string; role: RoleName }[] = [
{ email: 'admin@demo.siop.ma', displayName: 'Yasmine Alaoui', role: 'Administrateur' },
{ email: 'dispatcher@demo.siop.ma', displayName: 'Salma Idrissi', role: 'Dispatcher' },
{ email: 'technicien@demo.siop.ma', displayName: 'Ahmed Benali', role: 'Technicien' },
{ email: 'technicien-limite@demo.siop.ma', displayName: 'Youssef Tazi', role: 'Technicien limité' },
{ email: 'gestionnaire@demo.siop.ma', displayName: 'Nadia Berrada', role: 'Gestionnaire' },
{ email: 'demandeur@demo.siop.ma', displayName: 'Karim Doukkali', role: 'Demandeur' },
{ email: 'vue-seule@demo.siop.ma', displayName: 'Rachid Bennis', role: 'Vue seule' },
];
export async function seed(prisma: PrismaClient): Promise<void> {
const roleIds = new Map<RoleName, string>();
for (const name of ROLE_NAMES) {
const role = await prisma.role.upsert({
where: { name },
update: {},
create: { name },
});
roleIds.set(name, role.id);
}
// Matrice complète : une ligne par rôle × catégorie. Les lignes existantes
// ne sont PAS écrasées (la base est la source de vérité, pas ce fichier).
for (const name of ROLE_NAMES) {
const roleId = roleIds.get(name)!;
for (const category of OBJECT_CATEGORIES) {
const g = MATRIX[name][category] ?? {};
await prisma.permission.upsert({
where: { roleId_objectCategory: { roleId, objectCategory: category } },
update: {},
create: {
roleId,
objectCategory: category,
canView: g.view ?? false,
canViewOther: g.viewOther ?? false,
canCreate: g.create ?? false,
canEdit: g.edit ?? false,
canDelete: g.delete ?? false,
},
});
}
}
// Mot de passe commun des comptes démo (la connexion classique reste testable).
const passwordHash = await argon2.hash(
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
);
await seedUsers(prisma, roleIds, passwordHash);
await seedReferentiel(prisma);
}
async function seedUsers(
prisma: PrismaClient,
roleIds: Map<RoleName, string>,
passwordHash: string,
): Promise<void> {
for (const u of DEMO_USERS) {
// Les comptes démo appartiennent au seed : nom et rôle sont réalignés
// à chaque exécution (jamais le mot de passe d'un compte existant).
await prisma.user.upsert({
where: { email: u.email },
update: { displayName: u.displayName, roleId: roleIds.get(u.role)! },
create: {
email: u.email,
displayName: u.displayName,
passwordHash,
roleId: roleIds.get(u.role)!,
isDemo: true,
},
});
}
}
// ————— R1 — Référentiel (données des maquettes validées) —————
const EQUIPMENT_CATEGORIES = [
'Ascenseur électrique',
'Ascenseur hydraulique',
'Monte-charge',
'EPMR (plateforme PMR)',
];
const COMPONENT_TYPES = [
'Portes cabine / palières',
'Treuil / machinerie',
'Parachute',
'Armoire de commande',
'Boutons & signalisation',
];
/** site → zones ; positions réelles Casablanca/Mohammedia. */
const SITES: {
name: string;
address: string;
city: string;
guardianName?: string;
guardianPhone?: string;
latitude: number;
longitude: number;
zones: string[];
}[] = [
{
name: 'Tour Atlas', address: 'Bd de la Corniche, Aïn Diab', city: 'Casablanca',
guardianName: 'Karim Doukkali', guardianPhone: '06 61 23 45 67',
latitude: 33.6062, longitude: -7.6706,
zones: ['Hall principal', 'Tour bureaux (étages 1-24)', 'Parking sous-sol', 'Résidence (aile est)'],
},
{
name: 'Résidence Al Manar', address: 'Bd Hassan II', city: 'Mohammedia',
guardianName: 'Hassan Alami',
latitude: 33.6866, longitude: -7.383,
zones: ['Hall principal'],
},
{
name: 'Anfa Place', address: 'Bd de lOcéan Atlantique', city: 'Casablanca',
latitude: 33.5883, longitude: -7.6822,
zones: ['Galerie commerciale'],
},
{
name: 'Clinique Yasmine', address: 'Rue Ibn Rochd', city: 'Casablanca',
guardianName: 'Rachid Mansouri',
latitude: 33.5731, longitude: -7.6316,
zones: ['Bloc A'],
},
{
name: 'Marina Center', address: 'Av. des FAR', city: 'Mohammedia',
latitude: 33.7, longitude: -7.39,
zones: ['Hall B'],
},
];
const ASSETS: {
reference: string; brand: string; model: string; serialNumber?: string;
commissionedAt?: string; loadKg?: number; floors?: number;
category: string; site: string; zone: string;
status?: 'IN_SERVICE' | 'OUT_OF_SERVICE' | 'UNDER_MAINTENANCE';
components?: { type: string; designation?: string }[];
}[] = [
{
reference: 'A1', brand: 'Otis', model: 'Gen2 Premier', serialNumber: 'OT-2020-4521',
commissionedAt: '2020-03-15', loadKg: 630, floors: 8,
category: 'Ascenseur électrique', site: 'Résidence Al Manar', zone: 'Hall principal',
components: [
{ type: 'Portes cabine / palières', designation: 'Fermator 40/10' },
{ type: 'Treuil / machinerie', designation: 'Gen2 gearless' },
{ type: 'Parachute' },
{ type: 'Armoire de commande', designation: 'MCS 220' },
],
},
{
reference: 'A2', brand: 'Otis', model: 'Gen2', loadKg: 630, floors: 12,
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Hall principal',
},
{
reference: 'B1', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
},
{
reference: 'B2', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
status: 'OUT_OF_SERVICE',
components: [
{ type: 'Portes cabine / palières', designation: 'Sematic' },
{ type: 'Armoire de commande' },
],
},
{
reference: 'M1', brand: 'Kone', model: 'TranSys', loadKg: 2000, floors: 3,
category: 'Monte-charge', site: 'Tour Atlas', zone: 'Parking sous-sol',
status: 'UNDER_MAINTENANCE',
},
{
reference: 'C1', brand: 'Kone', model: 'MonoSpace 500', loadKg: 800, floors: 5,
category: 'Ascenseur électrique', site: 'Anfa Place', zone: 'Galerie commerciale',
},
{
reference: 'D1', brand: 'ThyssenKrupp', model: 'Evolution', loadKg: 1600, floors: 6,
category: 'Ascenseur électrique', site: 'Clinique Yasmine', zone: 'Bloc A',
},
{
reference: 'E2', brand: 'Otis', model: 'HydroFit', loadKg: 630, floors: 4,
category: 'Ascenseur hydraulique', site: 'Marina Center', zone: 'Hall B',
},
];
const TEAMS: { name: string; description: string; memberEmails: string[] }[] = [
{
name: 'Casablanca Centre',
description: 'Tour Atlas · Anfa Place · Clinique Yasmine',
memberEmails: ['technicien@demo.siop.ma', 'technicien-limite@demo.siop.ma'],
},
{
name: 'Mohammedia',
description: 'Résidence Al Manar · Marina Center',
memberEmails: [],
},
];
async function seedReferentiel(prisma: PrismaClient): Promise<void> {
const categoryIds = new Map<string, string>();
for (const [kind, names] of [
['EQUIPMENT', EQUIPMENT_CATEGORIES],
['COMPONENT_TYPE', COMPONENT_TYPES],
] as const) {
for (const name of names) {
const category = await prisma.category.upsert({
where: { kind_name: { kind, name } },
update: {},
create: { kind, name },
});
categoryIds.set(name, category.id);
}
}
// Pas d'unicité en base sur (parent, nom) : idempotence par findFirst.
const zoneIds = new Map<string, string>(); // « site / zone » → id
for (const site of SITES) {
const { zones, ...data } = site;
let root = await prisma.location.findFirst({
where: { name: site.name, parentId: null },
});
root ??= await prisma.location.create({ data });
for (const zoneName of zones) {
let zone = await prisma.location.findFirst({
where: { name: zoneName, parentId: root.id },
});
zone ??= await prisma.location.create({
data: { name: zoneName, parentId: root.id, city: site.city },
});
zoneIds.set(`${site.name} / ${zoneName}`, zone.id);
}
}
for (const asset of ASSETS) {
const created = await prisma.asset.upsert({
where: { reference: asset.reference },
update: {},
create: {
reference: asset.reference,
brand: asset.brand,
model: asset.model,
serialNumber: asset.serialNumber,
commissionedAt: asset.commissionedAt ? new Date(asset.commissionedAt) : undefined,
loadKg: asset.loadKg,
floors: asset.floors,
status: asset.status ?? 'IN_SERVICE',
categoryId: categoryIds.get(asset.category)!,
locationId: zoneIds.get(`${asset.site} / ${asset.zone}`)!,
},
include: { _count: { select: { components: true } } },
});
if (asset.components?.length && created._count.components === 0) {
await prisma.assetComponent.createMany({
data: asset.components.map((c) => ({
assetId: created.id,
typeId: categoryIds.get(c.type)!,
designation: c.designation,
})),
});
}
}
for (const team of TEAMS) {
await prisma.team.upsert({
where: { name: team.name },
update: {},
create: {
name: team.name,
description: team.description,
members: { connect: team.memberEmails.map((email) => ({ email })) },
},
});
}
}
/* c8 ignore start — wrapper CLI */
if (require.main === module) {
const prisma = new PrismaClient();
seed(prisma)
.then(async () => {
const [roles, permissions, users] = await Promise.all([
prisma.role.count(),
prisma.permission.count(),
prisma.user.count({ where: { isDemo: true } }),
]);
console.log(
`Seed OK — ${roles} rôles, ${permissions} lignes de matrice, ${users} comptes démo.`,
);
})
.catch((e) => {
console.error(e);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
/* c8 ignore stop */