mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
-- Migration autosuffisante : les extensions requises sont créées si absentes
|
||||
-- (la CI et tout environnement neuf n’ont pas notre init.sql).
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CategoryKind" AS ENUM ('EQUIPMENT', 'COMPONENT_TYPE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssetStatus" AS ENUM ('IN_SERVICE', 'OUT_OF_SERVICE', 'UNDER_MAINTENANCE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "activationExpiresAt" TIMESTAMP(3),
|
||||
ADD COLUMN "activationToken" TEXT,
|
||||
ADD COLUMN "phone" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" UUID NOT NULL,
|
||||
"kind" "CategoryKind" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Location" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"parentId" UUID,
|
||||
"address" TEXT,
|
||||
"city" TEXT,
|
||||
"guardianName" TEXT,
|
||||
"guardianPhone" TEXT,
|
||||
"latitude" DOUBLE PRECISION,
|
||||
"longitude" DOUBLE PRECISION,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Asset" (
|
||||
"id" UUID NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"brand" TEXT NOT NULL,
|
||||
"model" TEXT,
|
||||
"serialNumber" TEXT,
|
||||
"commissionedAt" TIMESTAMP(3),
|
||||
"loadKg" INTEGER,
|
||||
"floors" INTEGER,
|
||||
"status" "AssetStatus" NOT NULL DEFAULT 'IN_SERVICE',
|
||||
"categoryId" UUID NOT NULL,
|
||||
"locationId" UUID NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AssetComponent" (
|
||||
"id" UUID NOT NULL,
|
||||
"assetId" UUID NOT NULL,
|
||||
"typeId" UUID NOT NULL,
|
||||
"designation" TEXT,
|
||||
|
||||
CONSTRAINT "AssetComponent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_TeamToUser" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "_TeamToUser_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_kind_name_key" ON "Category"("kind", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Location_parentId_idx" ON "Location"("parentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Asset_reference_key" ON "Asset"("reference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Asset_locationId_idx" ON "Asset"("locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Asset_categoryId_idx" ON "Asset"("categoryId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AssetComponent_assetId_idx" ON "AssetComponent"("assetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_TeamToUser_B_index" ON "_TeamToUser"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_activationToken_key" ON "User"("activationToken");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Location" ADD CONSTRAINT "Location_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- ————— PostGIS (hors périmètre Prisma, décision modèle R1) —————
|
||||
-- Position générée depuis latitude/longitude : rien à synchroniser côté app,
|
||||
-- la colonne est prête pour les requêtes spatiales (affectation auto, backlog).
|
||||
ALTER TABLE "Location"
|
||||
ADD COLUMN "position" geography(Point, 4326)
|
||||
GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint("longitude", "latitude"), 4326)::geography) STORED;
|
||||
|
||||
CREATE INDEX "Location_position_gix" ON "Location" USING GIST ("position");
|
||||
@@ -39,12 +39,105 @@ model User {
|
||||
email String @unique
|
||||
displayName String
|
||||
passwordHash String? // null tant que le compte n'est pas activé (R1)
|
||||
phone String?
|
||||
roleId String @db.Uuid
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
|
||||
// Invitation (R1) : lien d'activation 7 jours, usage unique.
|
||||
// Statut dérivé : invité = passwordHash null && token présent.
|
||||
activationToken String? @unique
|
||||
activationExpiresAt DateTime?
|
||||
teams Team[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([roleId])
|
||||
}
|
||||
|
||||
// ————— R1 — Référentiel (docs/03-architecture/modele-donnees.md §R1) —————
|
||||
|
||||
enum CategoryKind {
|
||||
EQUIPMENT
|
||||
COMPONENT_TYPE
|
||||
}
|
||||
|
||||
enum AssetStatus {
|
||||
IN_SERVICE
|
||||
OUT_OF_SERVICE
|
||||
UNDER_MAINTENANCE
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
kind CategoryKind
|
||||
name String
|
||||
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
||||
assets Asset[]
|
||||
components AssetComponent[]
|
||||
|
||||
@@unique([kind, name])
|
||||
}
|
||||
|
||||
model Location {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
parentId String? @db.Uuid // site (null) → zone ; profondeur max 2 (service)
|
||||
parent Location? @relation("LocationTree", fields: [parentId], references: [id])
|
||||
children Location[] @relation("LocationTree")
|
||||
address String?
|
||||
city String?
|
||||
guardianName String?
|
||||
guardianPhone String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
// + colonne PostGIS générée (voir migration r1_referentiel) :
|
||||
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
|
||||
assets Asset[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([parentId])
|
||||
}
|
||||
|
||||
model Asset {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reference String @unique // « A1 » — imprimée sur l'étiquette QR
|
||||
brand String
|
||||
model String?
|
||||
serialNumber String?
|
||||
commissionedAt DateTime?
|
||||
loadKg Int?
|
||||
floors Int?
|
||||
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||||
categoryId String @db.Uuid
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
locationId String @db.Uuid
|
||||
location Location @relation(fields: [locationId], references: [id])
|
||||
components AssetComponent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([locationId])
|
||||
@@index([categoryId])
|
||||
}
|
||||
|
||||
// Organe : PAS de colonne emplacement — « un organe n'a pas d'emplacement
|
||||
// propre » est garanti par construction (décision maquettes R1).
|
||||
model AssetComponent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
assetId String @db.Uuid
|
||||
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||
typeId String @db.Uuid
|
||||
type Category @relation(fields: [typeId], references: [id])
|
||||
designation String?
|
||||
|
||||
@@index([assetId])
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @unique
|
||||
description String?
|
||||
members User[]
|
||||
}
|
||||
|
||||
@@ -131,6 +131,15 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
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).
|
||||
@@ -148,6 +157,207 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ————— 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 l’Océ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();
|
||||
|
||||
Reference in New Issue
Block a user