WP-02: locations, teams and the legal configuration register
Adds the referential models, the first database-backed settings screens, and the register the compliance matrix requires before any parameter is enforceable. The register is the point of the lot. The matrix is explicit that copying another product's configuration is not enough — each parameter must carry its value, source, effective date, population and an approver. Approval records the session's actor, never a form field: a signature you can type yourself is worth nothing. The screen names the domains that have no approved parameter yet, so the gap is visible rather than assumed closed. Two bugs of the same family, both now structurally impossible: - The Prisma scoping extension read a hand-written list of models carrying accountId. The four models added here were missing from it, so writes failed with an opaque Prisma error — and a read would have silently returned every account's rows. The list is now derived from the schema itself. - The RLS policies were likewise per-table. A new integration test fails if any table with an accountId column lacks forced RLS and both policies, which is the failure mode that hides best: nobody writes a wrong rule, someone forgets to write one. An end-to-end test signs in as a manager and confirms the settings screens refuse to render — the sidebar hiding them is a convenience, the server check is the control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
20 files changed
+1475
-17
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
authorize,
|
||||
type Actor,
|
||||
type ResourceRef,
|
||||
} from '@/domain/access/authorize';
|
||||
import type { PermissionCode } from '@/domain/access/permissions';
|
||||
import { currentSession, type SessionContext } from '@/server/auth/session';
|
||||
import { withTenant, type ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Contexte d'exécution d'une requête authentifiée.
|
||||
*
|
||||
* Toute lecture et toute écriture métier passent par ici. Le compte vient de la
|
||||
* session serveur, jamais d'un paramètre : c'est ce qui empêche un client de
|
||||
* désigner lui-même le périmètre qu'il veut lire.
|
||||
*/
|
||||
|
||||
export async function requireSession(): Promise<SessionContext> {
|
||||
const session = await currentSession();
|
||||
if (!session) redirect('/connexion');
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute une lecture dans le périmètre de la session.
|
||||
*
|
||||
* `permission` est vérifiée **avant** d'ouvrir la transaction : un refus ne
|
||||
* doit pas laisser de trace d'accès en base.
|
||||
*/
|
||||
export async function query<T>(
|
||||
permission: PermissionCode,
|
||||
fn: (db: ScopedClient, actor: Actor) => Promise<T>,
|
||||
resource?: ResourceRef,
|
||||
): Promise<T> {
|
||||
const session = await requireSession();
|
||||
authorize(session.actor, permission, resource);
|
||||
return withTenant(session.actor.accountId, (db) => fn(db, session.actor));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute une mutation dans le périmètre de la session.
|
||||
*
|
||||
* Identique à `query` par construction, mais nommée distinctement : une revue
|
||||
* de code doit pouvoir repérer d'un coup d'œil les points d'écriture, et
|
||||
* l'oubli d'un `authorize` s'y voit.
|
||||
*/
|
||||
export async function mutate<T>(
|
||||
permission: PermissionCode,
|
||||
fn: (db: ScopedClient, actor: Actor) => Promise<T>,
|
||||
resource?: ResourceRef,
|
||||
): Promise<T> {
|
||||
return query(permission, fn, resource);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { LEGAL_DOMAINS, LEGAL_DOMAIN_KEYS } from '@/domain/legal/domains';
|
||||
import { mutate, query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Registre de paramétrage juridique — PLAN.md §12.7, matrice n° 1.
|
||||
*
|
||||
* La matrice est explicite : « Il ne suffit pas de copier la configuration d'un
|
||||
* autre logiciel : il faut conserver la justification de chaque paramètre. »
|
||||
*
|
||||
* Une valeur sans source, sans date d'effet et sans approbateur n'est pas
|
||||
* opposable. Ce registre est donc la contrepartie du jeu de paramètres IDCC
|
||||
* 1517 chargé en §6.3 : les valeurs existent, ce sont les preuves qui manquent.
|
||||
*/
|
||||
|
||||
export interface LegalEntryRow {
|
||||
id: string;
|
||||
domain: string;
|
||||
key: string;
|
||||
value: string;
|
||||
source: string;
|
||||
effectiveFrom: Date;
|
||||
population: string;
|
||||
approvedBy: string | null;
|
||||
approvedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface LegalRegisterView {
|
||||
entries: LegalEntryRow[];
|
||||
/** Domaines sans aucune entrée approuvée. */
|
||||
missingDomains: string[];
|
||||
approvedCount: number;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export async function readLegalRegister(): Promise<LegalRegisterView> {
|
||||
return query('settings.access', async (db) => {
|
||||
const entries = await db.legalConfigEntry.findMany({
|
||||
orderBy: [{ domain: 'asc' }, { key: 'asc' }],
|
||||
});
|
||||
|
||||
const approved = entries.filter((entry) => entry.approvedAt !== null);
|
||||
const domainsWithApproval = new Set(approved.map((entry) => entry.domain));
|
||||
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
domain: entry.domain,
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
source: entry.source,
|
||||
effectiveFrom: entry.effectiveFrom,
|
||||
population: entry.population,
|
||||
approvedBy: entry.approvedBy,
|
||||
approvedAt: entry.approvedAt,
|
||||
})),
|
||||
missingDomains: LEGAL_DOMAINS.filter(
|
||||
(domain) => !domainsWithApproval.has(domain.key),
|
||||
).map((domain) => domain.label),
|
||||
approvedCount: approved.length,
|
||||
pendingCount: entries.length - approved.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const entryInput = z.object({
|
||||
domain: z.enum(LEGAL_DOMAIN_KEYS as unknown as [string, ...string[]]),
|
||||
key: z.string().trim().min(1, 'Paramètre requis').max(120),
|
||||
value: z.string().trim().min(1, 'Valeur requise').max(500),
|
||||
source: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Source requise — texte, article ou référence de l’accord')
|
||||
.max(500),
|
||||
effectiveFrom: z.coerce.date(),
|
||||
population: z.string().trim().min(1, 'Population requise').max(200),
|
||||
});
|
||||
|
||||
export interface ActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
export async function addLegalEntryAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = entryInput.safeParse({
|
||||
domain: formData.get('domain'),
|
||||
key: formData.get('key'),
|
||||
value: formData.get('value'),
|
||||
source: formData.get('source'),
|
||||
effectiveFrom: formData.get('effectiveFrom'),
|
||||
population: formData.get('population'),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.agreement.manage', async (db, actor) => {
|
||||
const created = await db.legalConfigEntry.create({
|
||||
data: {
|
||||
domain: parsed.data.domain,
|
||||
key: parsed.data.key,
|
||||
value: parsed.data.value,
|
||||
source: parsed.data.source,
|
||||
effectiveFrom: parsed.data.effectiveFrom,
|
||||
population: parsed.data.population,
|
||||
} as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'legal_config.create',
|
||||
entityType: 'LegalConfigEntry',
|
||||
entityId: created.id,
|
||||
after: {
|
||||
domain: created.domain,
|
||||
key: created.key,
|
||||
value: created.value,
|
||||
source: created.source,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer la convention." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/registre');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function approveLegalEntryAction(
|
||||
formData: FormData,
|
||||
): Promise<void> {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
await mutate('settings.agreement.manage', async (db, actor) => {
|
||||
const before = await db.legalConfigEntry.findUnique({ where: { id } });
|
||||
if (!before || before.approvedAt) return;
|
||||
|
||||
// L'approbateur est l'acteur de la session, jamais un champ du formulaire :
|
||||
// une signature qu'on peut saisir soi-même ne vaut rien.
|
||||
await db.legalConfigEntry.update({
|
||||
where: { id },
|
||||
data: { approvedBy: actor.membershipId, approvedAt: new Date() },
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'legal_config.approve',
|
||||
entityType: 'LegalConfigEntry',
|
||||
entityId: id,
|
||||
before: { approvedAt: null },
|
||||
after: { approvedBy: actor.membershipId },
|
||||
reason: `Approbation du paramètre ${before.domain}.${before.key}`,
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath('/reglages/registre');
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate, query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Établissements et équipes.
|
||||
*
|
||||
* Chaque mutation est autorisée puis journalisée **dans la même transaction**
|
||||
* que l'écriture : une modification sans trace, ou une trace sans modification,
|
||||
* seraient toutes deux des mensonges pour le contrôle.
|
||||
*/
|
||||
|
||||
export interface LocationRow {
|
||||
id: string;
|
||||
name: string;
|
||||
siret: string | null;
|
||||
timezone: string;
|
||||
employerContributionRate: string;
|
||||
archivedAt: Date | null;
|
||||
teams: Array<{ id: string; name: string; archivedAt: Date | null }>;
|
||||
}
|
||||
|
||||
export async function listLocations(
|
||||
includeArchived = false,
|
||||
): Promise<LocationRow[]> {
|
||||
return query('settings.access', async (db) => {
|
||||
const locations = await db.location.findMany({
|
||||
where: includeArchived ? {} : { archivedAt: null },
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
teams: {
|
||||
where: includeArchived ? {} : { archivedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return locations.map((location) => ({
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
siret: location.siret,
|
||||
timezone: location.timezone,
|
||||
employerContributionRate: location.employerContributionRate.toString(),
|
||||
archivedAt: location.archivedAt,
|
||||
teams: location.teams.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
archivedAt: team.archivedAt,
|
||||
})),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const locationInput = z.object({
|
||||
name: z.string().trim().min(1, 'Nom requis').max(120),
|
||||
siret: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{14}$/, 'Le SIRET compte 14 chiffres')
|
||||
.or(z.literal('')),
|
||||
timezone: z.string().trim().min(1),
|
||||
employerContributionRate: z.coerce
|
||||
.number()
|
||||
.min(0, 'Taux négatif impossible')
|
||||
.max(100, 'Un taux de cotisations dépasse rarement 100 %'),
|
||||
});
|
||||
|
||||
export interface ActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
export async function createLocationAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = locationInput.safeParse({
|
||||
name: formData.get('name'),
|
||||
siret: formData.get('siret') ?? '',
|
||||
timezone: formData.get('timezone') || 'Europe/Paris',
|
||||
employerContributionRate: formData.get('employerContributionRate') ?? 0,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.locations.manage', async (db, actor) => {
|
||||
const created = await db.location.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
siret: parsed.data.siret || null,
|
||||
timezone: parsed.data.timezone,
|
||||
employerContributionRate: parsed.data.employerContributionRate,
|
||||
} as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'location.create',
|
||||
entityType: 'Location',
|
||||
entityId: created.id,
|
||||
after: {
|
||||
name: created.name,
|
||||
siret: created.siret,
|
||||
timezone: created.timezone,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer les établissements." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/etablissements');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function archiveLocationAction(formData: FormData): Promise<void> {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
await mutate('settings.locations.manage', async (db, actor) => {
|
||||
const before = await db.location.findUnique({ where: { id } });
|
||||
if (!before) return;
|
||||
|
||||
// Archiver plutôt que supprimer : un établissement fermé garde des
|
||||
// plannings, des contrats et des variables de paie dont la conservation
|
||||
// court encore (PLAN.md §12.5).
|
||||
await db.location.update({
|
||||
where: { id },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'location.archive',
|
||||
entityType: 'Location',
|
||||
entityId: id,
|
||||
before: { archivedAt: before.archivedAt },
|
||||
after: { archivedAt: new Date().toISOString() },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath('/reglages/etablissements');
|
||||
}
|
||||
|
||||
const teamInput = z.object({
|
||||
locationId: z.string().min(1),
|
||||
name: z.string().trim().min(1, "Nom d'équipe requis").max(120),
|
||||
});
|
||||
|
||||
export async function createTeamAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = teamInput.safeParse({
|
||||
locationId: formData.get('locationId'),
|
||||
name: formData.get('name'),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate(
|
||||
'settings.teams.manage',
|
||||
async (db, actor) => {
|
||||
// Le périmètre est revérifié en base : l'établissement doit exister
|
||||
// *dans ce compte*. Sans cette lecture, un identifiant soumis depuis le
|
||||
// formulaire pourrait désigner celui d'un autre client.
|
||||
const location = await db.location.findUnique({
|
||||
where: { id: parsed.data.locationId },
|
||||
});
|
||||
if (!location) {
|
||||
throw new AuthorizationError('settings.teams.manage');
|
||||
}
|
||||
|
||||
const count = await db.team.count({
|
||||
where: { locationId: location.id },
|
||||
});
|
||||
|
||||
const created = await db.team.create({
|
||||
data: {
|
||||
locationId: location.id,
|
||||
name: parsed.data.name,
|
||||
position: count,
|
||||
} as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'team.create',
|
||||
entityType: 'Team',
|
||||
entityId: created.id,
|
||||
after: { name: created.name, locationId: location.id },
|
||||
});
|
||||
},
|
||||
{ locationId: parsed.data.locationId },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer les équipes." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/etablissements');
|
||||
return { ok: true };
|
||||
}
|
||||
+19
-14
@@ -1,4 +1,4 @@
|
||||
import type { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { Prisma, type PrismaClient } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@/server/db';
|
||||
|
||||
@@ -17,18 +17,24 @@ import { prisma } from '@/server/db';
|
||||
* dire pourquoi ; la seconde seule tomberait avec le premier `$queryRaw`.
|
||||
*/
|
||||
|
||||
/** Tables portant une colonne `accountId`. */
|
||||
const SCOPED_MODELS = new Set([
|
||||
'Location',
|
||||
'Team',
|
||||
'Membership',
|
||||
'MembershipScope',
|
||||
'Invitation',
|
||||
'Role',
|
||||
'AuditLog',
|
||||
'RetentionPolicy',
|
||||
'FeatureFlag',
|
||||
]);
|
||||
/**
|
||||
* Modèles portant une colonne `accountId`, **dérivés du schéma**.
|
||||
*
|
||||
* Une liste tenue à la main se périme au premier modèle ajouté, et l'oubli est
|
||||
* silencieux : le scoping ne s'applique plus, et selon les cas la requête
|
||||
* échoue avec un message obscur ou — bien pire — réussit sans filtre.
|
||||
* La dériver du DMMF supprime le mode de défaillance plutôt que de compter sur
|
||||
* la vigilance.
|
||||
*/
|
||||
const SCOPED_MODELS = new Set(
|
||||
Prisma.dmmf.datamodel.models
|
||||
.filter((model) =>
|
||||
model.fields.some(
|
||||
(field) => field.name === 'accountId' && field.kind === 'scalar',
|
||||
),
|
||||
)
|
||||
.map((model) => model.name),
|
||||
);
|
||||
|
||||
const READ_OPERATIONS = new Set([
|
||||
'findFirst',
|
||||
@@ -135,4 +141,3 @@ export function unscoped(): PrismaClient {
|
||||
return prisma;
|
||||
}
|
||||
|
||||
export type { Prisma };
|
||||
Reference in new issue
Block a user