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:
158
packages/shared/src/schemas/referentiel.ts
Normal file
158
packages/shared/src/schemas/referentiel.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { z } from 'zod';
|
||||
import { ASSET_STATUSES, CATEGORY_KINDS } from '../referentiel';
|
||||
|
||||
// ————— Catégories (référentiels administrables) —————
|
||||
|
||||
export const CategorySchema = z.object({
|
||||
id: z.uuid(),
|
||||
kind: z.enum(CATEGORY_KINDS),
|
||||
name: z.string(),
|
||||
isActive: z.boolean(),
|
||||
usageCount: z.number().int(), // appareils ou organes qui l'utilisent
|
||||
});
|
||||
export type Category = z.infer<typeof CategorySchema>;
|
||||
|
||||
export const CategoriesResponseSchema = z.object({
|
||||
categories: z.array(CategorySchema),
|
||||
});
|
||||
export type CategoriesResponse = z.infer<typeof CategoriesResponseSchema>;
|
||||
|
||||
export const CategoryCreateSchema = z.object({
|
||||
kind: z.enum(CATEGORY_KINDS),
|
||||
name: z.string().min(1).max(80),
|
||||
});
|
||||
export type CategoryCreate = z.infer<typeof CategoryCreateSchema>;
|
||||
|
||||
export const CategoryUpdateSchema = z.object({
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
isActive: z.boolean().optional(), // désactivation — jamais de suppression si utilisé
|
||||
});
|
||||
export type CategoryUpdate = z.infer<typeof CategoryUpdateSchema>;
|
||||
|
||||
// ————— Emplacements (site → zone) —————
|
||||
|
||||
export const LocationSchema = z.object({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
parentId: z.uuid().nullable(), // null = site
|
||||
address: z.string().nullable(),
|
||||
city: z.string().nullable(),
|
||||
guardianName: z.string().nullable(),
|
||||
guardianPhone: z.string().nullable(),
|
||||
latitude: z.number().nullable(),
|
||||
longitude: z.number().nullable(),
|
||||
assetCount: z.number().int(), // appareils rattachés (zones incluses pour un site)
|
||||
});
|
||||
export type LocationDto = z.infer<typeof LocationSchema>;
|
||||
|
||||
export const LocationsResponseSchema = z.object({
|
||||
locations: z.array(LocationSchema), // liste plate — le web construit l'arbre
|
||||
});
|
||||
export type LocationsResponse = z.infer<typeof LocationsResponseSchema>;
|
||||
|
||||
export const LocationCreateSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
parentId: z.uuid().optional(),
|
||||
address: z.string().max(200).optional(),
|
||||
city: z.string().max(80).optional(),
|
||||
guardianName: z.string().max(120).optional(),
|
||||
guardianPhone: z.string().max(40).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
});
|
||||
export type LocationCreate = z.infer<typeof LocationCreateSchema>;
|
||||
|
||||
export const LocationUpdateSchema = LocationCreateSchema.partial();
|
||||
export type LocationUpdate = z.infer<typeof LocationUpdateSchema>;
|
||||
|
||||
// ————— Appareils & organes —————
|
||||
|
||||
export const AssetComponentSchema = z.object({
|
||||
id: z.uuid(),
|
||||
typeId: z.uuid(),
|
||||
typeName: z.string(),
|
||||
designation: z.string().nullable(),
|
||||
});
|
||||
export type AssetComponentDto = z.infer<typeof AssetComponentSchema>;
|
||||
|
||||
export const AssetSchema = z.object({
|
||||
id: z.uuid(),
|
||||
reference: z.string(),
|
||||
brand: z.string(),
|
||||
model: z.string().nullable(),
|
||||
serialNumber: z.string().nullable(),
|
||||
commissionedAt: z.iso.datetime().nullable(),
|
||||
loadKg: z.number().int().nullable(),
|
||||
floors: z.number().int().nullable(),
|
||||
status: z.enum(ASSET_STATUSES),
|
||||
categoryId: z.uuid(),
|
||||
categoryName: z.string(),
|
||||
locationId: z.uuid(),
|
||||
locationName: z.string(),
|
||||
siteName: z.string(), // le site racine (= locationName si rattaché au site)
|
||||
componentCount: z.number().int(),
|
||||
});
|
||||
export type AssetDto = z.infer<typeof AssetSchema>;
|
||||
|
||||
export const AssetsResponseSchema = z.object({ assets: z.array(AssetSchema) });
|
||||
export type AssetsResponse = z.infer<typeof AssetsResponseSchema>;
|
||||
|
||||
export const AssetDetailSchema = AssetSchema.extend({
|
||||
components: z.array(AssetComponentSchema),
|
||||
});
|
||||
export type AssetDetail = z.infer<typeof AssetDetailSchema>;
|
||||
|
||||
export const AssetComponentCreateSchema = z.object({
|
||||
typeId: z.uuid(), // Category(kind=COMPONENT_TYPE) — vérifié service
|
||||
designation: z.string().max(120).optional(),
|
||||
});
|
||||
export type AssetComponentCreate = z.infer<typeof AssetComponentCreateSchema>;
|
||||
|
||||
export const AssetCreateSchema = z.object({
|
||||
reference: z.string().min(1).max(30),
|
||||
brand: z.string().min(1).max(80),
|
||||
model: z.string().max(80).optional(),
|
||||
serialNumber: z.string().max(80).optional(),
|
||||
commissionedAt: z.iso.datetime().optional(),
|
||||
loadKg: z.number().int().positive().optional(),
|
||||
floors: z.number().int().positive().optional(),
|
||||
categoryId: z.uuid(), // Category(kind=EQUIPMENT) — vérifié service
|
||||
locationId: z.uuid(),
|
||||
components: z.array(AssetComponentCreateSchema).optional(),
|
||||
});
|
||||
export type AssetCreate = z.infer<typeof AssetCreateSchema>;
|
||||
|
||||
export const AssetUpdateSchema = AssetCreateSchema.omit({ components: true })
|
||||
.partial()
|
||||
.extend({ status: z.enum(ASSET_STATUSES).optional() });
|
||||
export type AssetUpdate = z.infer<typeof AssetUpdateSchema>;
|
||||
|
||||
// ————— Équipes —————
|
||||
|
||||
export const TeamMemberSchema = z.object({
|
||||
id: z.uuid(),
|
||||
displayName: z.string(),
|
||||
roleName: z.string(),
|
||||
initials: z.string(),
|
||||
});
|
||||
|
||||
export const TeamSchema = z.object({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
members: z.array(TeamMemberSchema),
|
||||
});
|
||||
export type Team = z.infer<typeof TeamSchema>;
|
||||
|
||||
export const TeamsResponseSchema = z.object({ teams: z.array(TeamSchema) });
|
||||
export type TeamsResponse = z.infer<typeof TeamsResponseSchema>;
|
||||
|
||||
export const TeamCreateSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
description: z.string().max(200).optional(),
|
||||
memberIds: z.array(z.uuid()).optional(),
|
||||
});
|
||||
export type TeamCreate = z.infer<typeof TeamCreateSchema>;
|
||||
|
||||
export const TeamUpdateSchema = TeamCreateSchema.partial();
|
||||
export type TeamUpdate = z.infer<typeof TeamUpdateSchema>;
|
||||
65
packages/shared/src/schemas/users-admin.ts
Normal file
65
packages/shared/src/schemas/users-admin.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod';
|
||||
import { ROLE_NAMES } from '../permissions';
|
||||
import { AuthResponseSchema } from './auth';
|
||||
|
||||
/** Statut dérivé : invité = pas de mot de passe + lien émis ; désactivé prime. */
|
||||
export const USER_STATUSES = ['active', 'invited', 'disabled'] as const;
|
||||
|
||||
export const UserAdminSchema = z.object({
|
||||
id: z.uuid(),
|
||||
email: z.email(),
|
||||
displayName: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
role: z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) }),
|
||||
teams: z.array(z.object({ id: z.uuid(), name: z.string() })),
|
||||
status: z.enum(USER_STATUSES),
|
||||
isDemo: z.boolean(),
|
||||
});
|
||||
export type UserAdmin = z.infer<typeof UserAdminSchema>;
|
||||
|
||||
export const UsersResponseSchema = z.object({ users: z.array(UserAdminSchema) });
|
||||
export type UsersResponse = z.infer<typeof UsersResponseSchema>;
|
||||
|
||||
export const RolesResponseSchema = z.object({
|
||||
roles: z.array(z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) })),
|
||||
});
|
||||
export type RolesResponse = z.infer<typeof RolesResponseSchema>;
|
||||
|
||||
export const UserUpdateSchema = z.object({
|
||||
displayName: z.string().min(1).max(120).optional(),
|
||||
phone: z.string().max(40).optional(),
|
||||
roleId: z.uuid().optional(),
|
||||
teamIds: z.array(z.uuid()).optional(), // remplace l'affectation
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
export type UserUpdate = z.infer<typeof UserUpdateSchema>;
|
||||
|
||||
// ————— Invitation (maquettes R1 : jamais de mot de passe créé pour autrui) —————
|
||||
|
||||
export const InvitationCreateSchema = z.object({
|
||||
email: z.email(),
|
||||
displayName: z.string().min(1).max(120),
|
||||
roleId: z.uuid(),
|
||||
teamIds: z.array(z.uuid()).optional(),
|
||||
phone: z.string().max(40).optional(),
|
||||
});
|
||||
export type InvitationCreate = z.infer<typeof InvitationCreateSchema>;
|
||||
|
||||
/** Le lien est construit par le web (`/activation?token=…`) — l'API ne
|
||||
* connaît pas son origine publique. L'envoi d'email viendra plus tard ;
|
||||
* en R1 l'admin copie le lien depuis l'interface. */
|
||||
export const InvitationResponseSchema = z.object({
|
||||
userId: z.uuid(),
|
||||
activationToken: z.string(),
|
||||
expiresAt: z.iso.datetime(),
|
||||
});
|
||||
export type InvitationResponse = z.infer<typeof InvitationResponseSchema>;
|
||||
|
||||
export const ActivateRequestSchema = z.object({
|
||||
token: z.string().min(10),
|
||||
password: z.string().min(8, '8 caractères minimum'),
|
||||
});
|
||||
export type ActivateRequest = z.infer<typeof ActivateRequestSchema>;
|
||||
|
||||
/** L'activation connecte directement la personne (AuthResponse). */
|
||||
export const ActivateResponseSchema = AuthResponseSchema;
|
||||
Reference in New Issue
Block a user