Files
planflow/src/server/install/install.ts
T
MichaelandClaude Opus 5 791dd523eb Donner une équipe à tout établissement neuf
Un établissement naissait sans équipe. Le planning s'ordonnant par équipe, il
n'avait alors aucune ligne où poser un créneau — même avec des salariés
rattachés. L'écran annonçait « cet établissement n'a pas encore d'équipe », ce
qui se lit « il n'y a personne », et rien ne disait où en créer une.

Deux corrections. La création d'un établissement pose une première équipe, à
l'installation comme depuis les réglages ; elle se renomme, et rien n'empêche
d'en ajouter d'autres. Et le message du planning dit désormais où aller : créer
l'équipe dans les réglages, puis rattacher les salariés depuis l'onglet
« Planification et accès » de leur fiche.

Les établissements déjà créés gardent leur situation : le correctif empêche d'en
produire de nouveaux sans équipe, il ne rattrape pas l'existant. Une équipe
s'ajoute en deux clics depuis Réglages · Établissements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:02:15 +02:00

207 lines
7.0 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import {
DEFAULT_ROLE_PERMISSIONS,
PERMISSION_DEFINITIONS,
SYSTEM_ROLES,
} from '@/domain/access/permissions';
import {
FIRST_EMPLOYEE_NUMBER,
normaliseInstallation,
type InstallationForm,
} from '@/domain/install/rules';
import { hashPassword } from '@/server/auth/session';
import { installReferentials } from '@/server/install/referentials';
/**
* Contenu d'une instance neuve — PLAN.md §5.
*
* Le seed, lui, installe une démonstration : des salariés fictifs, des
* plannings, des absences. Il refuse de tourner en production, et c'est bien
* ainsi. Il restait donc un trou : après les migrations, une instance de
* production a le schéma et rien d'autre — pas de compte, pas d'utilisateur,
* personne pour se connecter.
*
* Ce module comble ce trou : catalogue des capacités, rôles fournis, compte,
* établissement, propriétaire, puis les **référentiels** sans lesquels rien ne
* s'accroche (voir `referentials.ts`). Aucune donnée d'exemple : pas un
* salarié, pas un créneau, pas une absence.
*/
export interface InstalledInstance {
accountId: string;
membershipId: string;
userId: string;
}
/**
* Pose le contenu minimal d'un compte dans une transaction déjà ouverte.
*
* Séparé de l'action pour deux raisons. La transaction appartient à l'appelant,
* qui seul peut y écrire le marqueur d'installation dans le même souffle — un
* compte à demi installé, sans marqueur, laisserait l'écran ouvert sur une base
* déjà peuplée. Et cette forme se laisse éprouver contre une vraie base dans
* une transaction que le test annule, ce qu'une fonction qui scelle
* l'installation ne permettrait pas : le marqueur, lui, ne se supprime pas.
*/
export async function installAccount(
tx: Prisma.TransactionClient,
form: InstallationForm,
passwordHash: string,
): Promise<InstalledInstance> {
const data = normaliseInstallation(form);
// L'identifiant est tiré ici, et non laissé à la base, parce qu'il faut
// l'annoncer **avant** de créer le compte.
//
// La politique d'insertion d'`Account` accepte tout — il faut bien pouvoir
// créer le premier. Mais sa politique de lecture, elle, n'ouvre que le compte
// courant, et un `INSERT ... RETURNING` relit la ligne qu'il vient d'écrire :
// sans compte courant, l'insertion réussit et la relecture est refusée.
// Poser le compte d'abord est la même règle que partout ailleurs — une
// transaction porte son compte — appliquée à celle qui le crée.
const accountId = randomUUID();
await tx.$executeRaw`SELECT set_config('app.account_id', ${accountId}, true)`;
const account = await tx.account.create({
data: { id: accountId, name: data.companyName },
});
// Référentiel global, sans compte : les capacités sont définies par le
// produit. L'upsert vaut pour une instance dont le catalogue a déjà été posé
// par un seed ou une migration de données.
for (const permission of PERMISSION_DEFINITIONS) {
await tx.permission.upsert({
where: { code: permission.code },
update: { category: permission.category, label: permission.label },
create: permission,
});
}
const permissionIds = new Map(
(await tx.permission.findMany({ select: { id: true, code: true } })).map(
(permission) => [permission.code, permission.id],
),
);
const roleIds = new Map<string, string>();
for (const role of SYSTEM_ROLES) {
const created = await tx.role.create({
data: {
accountId: account.id,
key: role.key,
name: role.name,
isSystem: true,
},
});
roleIds.set(role.key, created.id);
await tx.rolePermission.createMany({
data: DEFAULT_ROLE_PERMISSIONS[role.key]
.map((code) => permissionIds.get(code))
.filter((id): id is string => Boolean(id))
.map((permissionId) => ({ roleId: created.id, permissionId })),
skipDuplicates: true,
});
}
const location = await tx.location.create({
data: {
accountId: account.id,
name: data.locationName,
timezone: data.timezone,
},
});
// Une première équipe. Le planning s'ordonne par équipe : sans elle,
// l'établissement neuf n'a aucune ligne où poser un créneau, et l'écran de
// planning annonce « pas encore d'équipe » à qui vient de tout installer.
await tx.team.create({
data: { accountId: account.id, locationId: location.id, name: 'Équipe' },
});
// Étiquettes, types d'absence, convention d'amorce, conservation et jours
// fériés. Ce ne sont pas des exemples : sans eux, l'instance a le schéma et
// rien à quoi l'accrocher — pas de demande d'absence saisissable, pas de
// créneau nommé, et un moteur de règles muet.
await installReferentials(tx, account.id, location.id);
const user = await tx.user.create({
data: {
email: data.email,
firstName: data.firstName,
lastName: data.lastName,
passwordHash,
},
});
const membership = await tx.membership.create({
data: {
accountId: account.id,
userId: user.id,
roleId: roleIds.get('owner') as string,
employeeNumber: FIRST_EMPLOYEE_NUMBER,
status: 'ACTIVE',
},
});
// Le registre du personnel exige le nom, et il le lit sur le dossier et non
// sur le compte : la plupart des salariés n'ont pas de compte.
await tx.employeeProfile.create({
data: {
membershipId: membership.id,
accountId: account.id,
firstName: data.firstName,
lastName: data.lastName,
},
});
// Tous les établissements, y compris ceux qui n'existent pas encore : la
// personne qui installe l'instance ne doit pas perdre la vue sur le deuxième
// établissement le jour de son ouverture.
await tx.membershipScope.create({
data: {
accountId: account.id,
membershipId: membership.id,
allLocations: true,
},
});
// Première entrée du journal, et elle compte : elle date la création du
// compte propriétaire, seul accès à l'instance jusqu'à la première
// invitation. Le journal est append-only.
await tx.auditLog.create({
data: {
accountId: account.id,
actorMembershipId: membership.id,
action: 'install.complete',
entityType: 'Account',
entityId: account.id,
after: {
companyName: data.companyName,
locationName: data.locationName,
timezone: data.timezone,
ownerEmail: data.email,
},
},
});
return {
accountId: account.id,
membershipId: membership.id,
userId: user.id,
};
}
/** Empreinte du mot de passe du propriétaire. */
export function hashOwnerPassword(password: string): Promise<string> {
// Hors transaction, à dessein : argon2 est délibérément lent, et le faire
// tourner une connexion ouverte immobiliserait celle-ci pour rien.
return hashPassword(password);
}
/** Identifiant unique de la ligne d'installation, contraint en base. */
export const INSTALLATION_ID = 'singleton';