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:
141
apps/api/src/users/users.service.ts
Normal file
141
apps/api/src/users/users.service.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
INVITATION_TTL_DAYS,
|
||||
type InvitationCreate,
|
||||
type InvitationResponse,
|
||||
type RoleName,
|
||||
type RolesResponse,
|
||||
type UserAdmin,
|
||||
type UsersResponse,
|
||||
type UserUpdate,
|
||||
} from '@siop/shared';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const userInclude = {
|
||||
role: true,
|
||||
teams: { orderBy: { name: 'asc' } },
|
||||
} satisfies Prisma.UserInclude;
|
||||
|
||||
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<UsersResponse> {
|
||||
const rows = await this.prisma.user.findMany({
|
||||
include: userInclude,
|
||||
orderBy: { displayName: 'asc' },
|
||||
});
|
||||
return { users: rows.map((u) => this.toDto(u)) };
|
||||
}
|
||||
|
||||
async roles(): Promise<RolesResponse> {
|
||||
const roles = await this.prisma.role.findMany({ orderBy: { name: 'asc' } });
|
||||
return { roles: roles.map((r) => ({ id: r.id, name: r.name as RoleName })) };
|
||||
}
|
||||
|
||||
/** Invitation : compte créé SANS mot de passe + lien d'activation 7 jours.
|
||||
* L'envoi d'email viendra plus tard — le web affiche le lien à copier. */
|
||||
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||
try {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
displayName: dto.displayName,
|
||||
phone: dto.phone,
|
||||
roleId: dto.roleId,
|
||||
teams: dto.teamIds?.length
|
||||
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
...this.freshToken(),
|
||||
},
|
||||
});
|
||||
return {
|
||||
userId: user.id,
|
||||
activationToken: user.activationToken!,
|
||||
expiresAt: user.activationExpiresAt!.toISOString(),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Cet email a déjà un compte');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async resendInvitation(userId: string): Promise<InvitationResponse> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('Personne inconnue');
|
||||
if (user.passwordHash) {
|
||||
throw new ConflictException('Ce compte est déjà activé');
|
||||
}
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: this.freshToken(),
|
||||
});
|
||||
return {
|
||||
userId: updated.id,
|
||||
activationToken: updated.activationToken!,
|
||||
expiresAt: updated.activationExpiresAt!.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||
try {
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
displayName: dto.displayName,
|
||||
phone: dto.phone,
|
||||
roleId: dto.roleId,
|
||||
isActive: dto.isActive,
|
||||
teams: dto.teamIds
|
||||
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
},
|
||||
include: userInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Personne inconnue');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private freshToken() {
|
||||
return {
|
||||
activationToken: randomBytes(32).toString('base64url'),
|
||||
activationExpiresAt: new Date(
|
||||
Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: UserRow): UserAdmin {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
displayName: row.displayName,
|
||||
phone: row.phone,
|
||||
role: { id: row.role.id, name: row.role.name as RoleName },
|
||||
teams: row.teams.map((t) => ({ id: t.id, name: t.name })),
|
||||
status: !row.isActive
|
||||
? 'disabled'
|
||||
: row.passwordHash
|
||||
? 'active'
|
||||
: 'invited',
|
||||
isDemo: row.isDemo,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user