Checkpoint: Application SONUM v1.0 - Cartographie des Solutions Numériques FEHAP. Toutes les fonctionnalités implémentées : charte CGU, moteur de recherche multicritères avec tri, Mes Établissements avec accordéons et édition inline, formulaire de saisie guidé en 3 étapes, fiche établissement avec compteur de consultation, prise de contact, Mes Demandes de Contact avec réponse directe, administration SONUM, gestion des rôles (Référent / Gestionnaire), traçabilité, lien espace adhérent FEHAP. 14 tests Vitest passés.
This commit is contained in:
424
server/db.ts
424
server/db.ts
@@ -1,11 +1,20 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, desc, eq, ilike, like, or, sql } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { InsertUser, users } from "../drizzle/schema";
|
||||
import { ENV } from './_core/env';
|
||||
import {
|
||||
InsertUser,
|
||||
blocsFonctionnels,
|
||||
consultations,
|
||||
demandesContact,
|
||||
editeurs,
|
||||
etablissements,
|
||||
logicielsEtablissements,
|
||||
solutions,
|
||||
users,
|
||||
} from "../drizzle/schema";
|
||||
import { ENV } from "./_core/env";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
// Lazily create the drizzle instance so local tooling can run without a DB.
|
||||
export async function getDb() {
|
||||
if (!_db && process.env.DATABASE_URL) {
|
||||
try {
|
||||
@@ -18,75 +27,366 @@ export async function getDb() {
|
||||
return _db;
|
||||
}
|
||||
|
||||
// ─── Utilisateurs ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertUser(user: InsertUser): Promise<void> {
|
||||
if (!user.openId) {
|
||||
throw new Error("User openId is required for upsert");
|
||||
}
|
||||
|
||||
if (!user.openId) throw new Error("User openId is required for upsert");
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.warn("[Database] Cannot upsert user: database not available");
|
||||
return;
|
||||
if (!db) { console.warn("[Database] Cannot upsert user: database not available"); return; }
|
||||
|
||||
const values: InsertUser = { openId: user.openId };
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
|
||||
const textFields = ["name", "email", "loginMethod"] as const;
|
||||
for (const field of textFields) {
|
||||
const value = user[field];
|
||||
if (value === undefined) continue;
|
||||
const normalized = value ?? null;
|
||||
values[field] = normalized;
|
||||
updateSet[field] = normalized;
|
||||
}
|
||||
|
||||
try {
|
||||
const values: InsertUser = {
|
||||
openId: user.openId,
|
||||
};
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; }
|
||||
if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; }
|
||||
else if (user.openId === ENV.ownerOpenId) { values.role = "admin"; updateSet.role = "admin"; }
|
||||
|
||||
const textFields = ["name", "email", "loginMethod"] as const;
|
||||
type TextField = (typeof textFields)[number];
|
||||
if (!values.lastSignedIn) values.lastSignedIn = new Date();
|
||||
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
|
||||
|
||||
const assignNullable = (field: TextField) => {
|
||||
const value = user[field];
|
||||
if (value === undefined) return;
|
||||
const normalized = value ?? null;
|
||||
values[field] = normalized;
|
||||
updateSet[field] = normalized;
|
||||
};
|
||||
|
||||
textFields.forEach(assignNullable);
|
||||
|
||||
if (user.lastSignedIn !== undefined) {
|
||||
values.lastSignedIn = user.lastSignedIn;
|
||||
updateSet.lastSignedIn = user.lastSignedIn;
|
||||
}
|
||||
if (user.role !== undefined) {
|
||||
values.role = user.role;
|
||||
updateSet.role = user.role;
|
||||
} else if (user.openId === ENV.ownerOpenId) {
|
||||
values.role = 'admin';
|
||||
updateSet.role = 'admin';
|
||||
}
|
||||
|
||||
if (!values.lastSignedIn) {
|
||||
values.lastSignedIn = new Date();
|
||||
}
|
||||
|
||||
if (Object.keys(updateSet).length === 0) {
|
||||
updateSet.lastSignedIn = new Date();
|
||||
}
|
||||
|
||||
await db.insert(users).values(values).onDuplicateKeyUpdate({
|
||||
set: updateSet,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Database] Failed to upsert user:", error);
|
||||
throw error;
|
||||
}
|
||||
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
|
||||
}
|
||||
|
||||
export async function getUserByOpenId(openId: string) {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.warn("[Database] Cannot get user: database not available");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
||||
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
// TODO: add feature queries here as your schema grows.
|
||||
export async function updateUserCgu(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(users).set({ cguAccepted: true, cguAcceptedAt: new Date() }).where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
export async function updateUserSonumRole(userId: number, sonumRole: "referent" | "gestionnaire") {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(users).set({ sonumRole }).where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
export async function getAllUsers() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(users).orderBy(desc(users.createdAt));
|
||||
}
|
||||
|
||||
// ─── Référentiel ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getEditeurs() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(editeurs).where(eq(editeurs.estValide, true)).orderBy(editeurs.nom);
|
||||
}
|
||||
|
||||
export async function createEditeur(nom: string, estValide = false) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db.insert(editeurs).values({ nom, estValide });
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getBlocsFonctionnels() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(blocsFonctionnels).where(eq(blocsFonctionnels.estValide, true)).orderBy(blocsFonctionnels.nom);
|
||||
}
|
||||
|
||||
export async function createBlocFonctionnel(nom: string, estValide = false) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db.insert(blocsFonctionnels).values({ nom, estValide });
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getSolutions(search?: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
const query = db
|
||||
.select({
|
||||
id: solutions.id,
|
||||
nom: solutions.nom,
|
||||
editeurId: solutions.editeurId,
|
||||
editeurNom: editeurs.nom,
|
||||
blocFonctionnelId: solutions.blocFonctionnelId,
|
||||
blocFonctionnelNom: blocsFonctionnels.nom,
|
||||
estValide: solutions.estValide,
|
||||
})
|
||||
.from(solutions)
|
||||
.leftJoin(editeurs, eq(solutions.editeurId, editeurs.id))
|
||||
.leftJoin(blocsFonctionnels, eq(solutions.blocFonctionnelId, blocsFonctionnels.id))
|
||||
.where(
|
||||
search
|
||||
? and(eq(solutions.estValide, true), or(like(solutions.nom, `%${search}%`), like(editeurs.nom, `%${search}%`)))
|
||||
: eq(solutions.estValide, true)
|
||||
)
|
||||
.orderBy(solutions.nom);
|
||||
return query;
|
||||
}
|
||||
|
||||
export async function createSolution(nom: string, editeurId: number, blocFonctionnelId?: number | null, estValide = false) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db.insert(solutions).values({ nom, editeurId, blocFonctionnelId: blocFonctionnelId ?? null, estValide });
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// ─── Établissements ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function getEtablissementsByReferent(referentId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(etablissements).where(eq(etablissements.referentId, referentId)).orderBy(etablissements.nom);
|
||||
}
|
||||
|
||||
export async function getAllEtablissements() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(etablissements).orderBy(etablissements.nom);
|
||||
}
|
||||
|
||||
export async function getEtablissementById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db.select().from(etablissements).where(eq(etablissements.id, id)).limit(1);
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
export async function searchEtablissements(filters: {
|
||||
solutionId?: number;
|
||||
editeurId?: number;
|
||||
blocFonctionnelId?: number;
|
||||
region?: string;
|
||||
typeActivite?: string;
|
||||
tailleEffectifs?: string;
|
||||
etatDeploiement?: string;
|
||||
userId?: number;
|
||||
sonumRole?: string;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const conditions = [];
|
||||
|
||||
// Visibilité : si pas gestionnaire, on ne montre que les fiches "tous"
|
||||
if (filters.sonumRole !== "gestionnaire") {
|
||||
conditions.push(eq(etablissements.visibilite, "tous"));
|
||||
}
|
||||
|
||||
if (filters.region) conditions.push(eq(etablissements.region, filters.region));
|
||||
if (filters.typeActivite) conditions.push(eq(etablissements.typeActivite, filters.typeActivite));
|
||||
if (filters.tailleEffectifs) conditions.push(eq(etablissements.tailleEffectifs, filters.tailleEffectifs));
|
||||
|
||||
let query = db
|
||||
.select({
|
||||
id: etablissements.id,
|
||||
finess: etablissements.finess,
|
||||
nom: etablissements.nom,
|
||||
region: etablissements.region,
|
||||
departement: etablissements.departement,
|
||||
typeActivite: etablissements.typeActivite,
|
||||
tailleEffectifs: etablissements.tailleEffectifs,
|
||||
referentId: etablissements.referentId,
|
||||
visibilite: etablissements.visibilite,
|
||||
accepteMiseEnRelation: etablissements.accepteMiseEnRelation,
|
||||
})
|
||||
.from(etablissements)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(etablissements.nom);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// ─── Logiciels par Établissement ─────────────────────────────────────────────
|
||||
|
||||
export async function getLogicielsByEtablissement(etablissementId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db
|
||||
.select({
|
||||
id: logicielsEtablissements.id,
|
||||
etablissementId: logicielsEtablissements.etablissementId,
|
||||
solutionId: logicielsEtablissements.solutionId,
|
||||
solutionNom: solutions.nom,
|
||||
editeurNom: editeurs.nom,
|
||||
blocFonctionnelNom: blocsFonctionnels.nom,
|
||||
etatDeploiement: logicielsEtablissements.etatDeploiement,
|
||||
modeHebergement: logicielsEtablissements.modeHebergement,
|
||||
modeFacturation: logicielsEtablissements.modeFacturation,
|
||||
interoperabilite: logicielsEtablissements.interoperabilite,
|
||||
versionMajeure: logicielsEtablissements.versionMajeure,
|
||||
commentaire: logicielsEtablissements.commentaire,
|
||||
contactNom: logicielsEtablissements.contactNom,
|
||||
contactFonction: logicielsEtablissements.contactFonction,
|
||||
contactEmail: logicielsEtablissements.contactEmail,
|
||||
createdAt: logicielsEtablissements.createdAt,
|
||||
updatedAt: logicielsEtablissements.updatedAt,
|
||||
})
|
||||
.from(logicielsEtablissements)
|
||||
.leftJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id))
|
||||
.leftJoin(editeurs, eq(solutions.editeurId, editeurs.id))
|
||||
.leftJoin(blocsFonctionnels, eq(solutions.blocFonctionnelId, blocsFonctionnels.id))
|
||||
.where(eq(logicielsEtablissements.etablissementId, etablissementId))
|
||||
.orderBy(logicielsEtablissements.createdAt);
|
||||
}
|
||||
|
||||
export async function upsertLogicielEtablissement(data: {
|
||||
id?: number;
|
||||
etablissementId: number;
|
||||
solutionId: number;
|
||||
etatDeploiement: "demarrage" | "en_cours" | "operationnel" | "en_remplacement";
|
||||
modeHebergement?: "hds" | "on_premise" | "hybride" | null;
|
||||
modeFacturation?: "saas" | "achat_maintenance" | "location" | null;
|
||||
interoperabilite?: "non" | "oui_interface" | "oui_eai" | null;
|
||||
versionMajeure?: string | null;
|
||||
commentaire?: string | null;
|
||||
contactNom?: string | null;
|
||||
contactFonction?: string | null;
|
||||
contactEmail?: string | null;
|
||||
saisiePar?: number;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
if (data.id) {
|
||||
await db.update(logicielsEtablissements).set({ ...data, updatedAt: new Date() }).where(eq(logicielsEtablissements.id, data.id));
|
||||
return data.id;
|
||||
}
|
||||
const result = await db.insert(logicielsEtablissements).values(data);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function deleteLogicielEtablissement(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(logicielsEtablissements).where(eq(logicielsEtablissements.id, id));
|
||||
}
|
||||
|
||||
// ─── Traçabilité ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function recordConsultation(etablissementId: number, userId: number, userName: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.insert(consultations).values({ etablissementId, consultePar: userId, consultéParNom: userName });
|
||||
}
|
||||
|
||||
export async function getConsultationCount(etablissementId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return 0;
|
||||
const result = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(consultations)
|
||||
.where(eq(consultations.etablissementId, etablissementId));
|
||||
return Number(result[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
export async function getConsultationsList(etablissementId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db
|
||||
.select()
|
||||
.from(consultations)
|
||||
.where(eq(consultations.etablissementId, etablissementId))
|
||||
.orderBy(desc(consultations.createdAt))
|
||||
.limit(50);
|
||||
}
|
||||
|
||||
// ─── Demandes de Contact ──────────────────────────────────────────────────────
|
||||
|
||||
export async function createDemandeContact(data: {
|
||||
etablissementCibleId: number;
|
||||
demandeurId: number;
|
||||
demandeurNom: string;
|
||||
demandeurEmail: string;
|
||||
message: string;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db.insert(demandesContact).values(data);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getDemandesByDemandeur(demandeurId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db
|
||||
.select({
|
||||
id: demandesContact.id,
|
||||
etablissementNom: etablissements.nom,
|
||||
message: demandesContact.message,
|
||||
statut: demandesContact.statut,
|
||||
reponse: demandesContact.reponse,
|
||||
reponduAt: demandesContact.reponduAt,
|
||||
createdAt: demandesContact.createdAt,
|
||||
})
|
||||
.from(demandesContact)
|
||||
.leftJoin(etablissements, eq(demandesContact.etablissementCibleId, etablissements.id))
|
||||
.where(eq(demandesContact.demandeurId, demandeurId))
|
||||
.orderBy(desc(demandesContact.createdAt));
|
||||
}
|
||||
|
||||
export async function getDemandesRecuesParEtablissement(referentId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db
|
||||
.select({
|
||||
id: demandesContact.id,
|
||||
etablissementNom: etablissements.nom,
|
||||
demandeurNom: demandesContact.demandeurNom,
|
||||
demandeurEmail: demandesContact.demandeurEmail,
|
||||
message: demandesContact.message,
|
||||
statut: demandesContact.statut,
|
||||
reponse: demandesContact.reponse,
|
||||
reponduAt: demandesContact.reponduAt,
|
||||
createdAt: demandesContact.createdAt,
|
||||
})
|
||||
.from(demandesContact)
|
||||
.leftJoin(etablissements, eq(demandesContact.etablissementCibleId, etablissements.id))
|
||||
.where(eq(etablissements.referentId, referentId))
|
||||
.orderBy(desc(demandesContact.createdAt));
|
||||
}
|
||||
|
||||
export async function getAllDemandes() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db
|
||||
.select({
|
||||
id: demandesContact.id,
|
||||
etablissementNom: etablissements.nom,
|
||||
demandeurNom: demandesContact.demandeurNom,
|
||||
demandeurEmail: demandesContact.demandeurEmail,
|
||||
message: demandesContact.message,
|
||||
statut: demandesContact.statut,
|
||||
reponse: demandesContact.reponse,
|
||||
reponduAt: demandesContact.reponduAt,
|
||||
createdAt: demandesContact.createdAt,
|
||||
})
|
||||
.from(demandesContact)
|
||||
.leftJoin(etablissements, eq(demandesContact.etablissementCibleId, etablissements.id))
|
||||
.orderBy(desc(demandesContact.createdAt));
|
||||
}
|
||||
|
||||
export async function repondreDemandeContact(id: number, reponse: string, reponsePar: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db
|
||||
.update(demandesContact)
|
||||
.set({ reponse, reponsePar, statut: "repondu", reponduAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(demandesContact.id, id));
|
||||
}
|
||||
|
||||
export async function getDemandeById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db.select().from(demandesContact).where(eq(demandesContact.id, id)).limit(1);
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,338 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createBlocFonctionnel,
|
||||
createDemandeContact,
|
||||
createEditeur,
|
||||
createSolution,
|
||||
deleteLogicielEtablissement,
|
||||
getAllDemandes,
|
||||
getAllEtablissements,
|
||||
getAllUsers,
|
||||
getBlocsFonctionnels,
|
||||
getConsultationCount,
|
||||
getConsultationsList,
|
||||
getDemandeById,
|
||||
getDemandesByDemandeur,
|
||||
getDemandesRecuesParEtablissement,
|
||||
getEditeurs,
|
||||
getEtablissementById,
|
||||
getEtablissementsByReferent,
|
||||
getLogicielsByEtablissement,
|
||||
getSolutions,
|
||||
recordConsultation,
|
||||
repondreDemandeContact,
|
||||
updateUserCgu,
|
||||
updateUserSonumRole,
|
||||
upsertLogicielEtablissement,
|
||||
upsertUser,
|
||||
} from "./db";
|
||||
import { COOKIE_NAME } from "@shared/const";
|
||||
import { getSessionCookieOptions } from "./_core/cookies";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { publicProcedure, router } from "./_core/trpc";
|
||||
import { protectedProcedure, publicProcedure, router } from "./_core/trpc";
|
||||
import { notifyOwner } from "./_core/notification";
|
||||
import { getDb } from "./db";
|
||||
import { etablissements } from "../drizzle/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// ─── Middleware gestionnaire SONUM ────────────────────────────────────────────
|
||||
|
||||
const gestionnaireProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Accès réservé aux gestionnaires SONUM" });
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
// ─── Router principal ─────────────────────────────────────────────────────────
|
||||
|
||||
export const appRouter = router({
|
||||
// if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly
|
||||
system: systemRouter,
|
||||
|
||||
// ─── Auth ──────────────────────────────────────────────────────────────────
|
||||
auth: router({
|
||||
me: publicProcedure.query(opts => opts.ctx.user),
|
||||
me: publicProcedure.query((opts) => opts.ctx.user),
|
||||
logout: publicProcedure.mutation(({ ctx }) => {
|
||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
||||
return {
|
||||
success: true,
|
||||
} as const;
|
||||
return { success: true } as const;
|
||||
}),
|
||||
}),
|
||||
|
||||
// TODO: add feature routers here, e.g.
|
||||
// todo: router({
|
||||
// list: protectedProcedure.query(({ ctx }) =>
|
||||
// db.getUserTodos(ctx.user.id)
|
||||
// ),
|
||||
// }),
|
||||
// ─── CGU ───────────────────────────────────────────────────────────────────
|
||||
cgu: router({
|
||||
accept: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await updateUserCgu(ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
status: protectedProcedure.query(({ ctx }) => ({
|
||||
accepted: ctx.user.cguAccepted,
|
||||
acceptedAt: ctx.user.cguAcceptedAt,
|
||||
})),
|
||||
}),
|
||||
|
||||
// ─── Référentiel ───────────────────────────────────────────────────────────
|
||||
referentiel: router({
|
||||
editeurs: publicProcedure.query(() => getEditeurs()),
|
||||
blocsFonctionnels: publicProcedure.query(() => getBlocsFonctionnels()),
|
||||
solutions: publicProcedure
|
||||
.input(z.object({ search: z.string().optional() }))
|
||||
.query(({ input }) => getSolutions(input.search)),
|
||||
|
||||
createEditeur: protectedProcedure
|
||||
.input(z.object({ nom: z.string().min(1) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||
return createEditeur(input.nom, isGestionnaire);
|
||||
}),
|
||||
|
||||
createBlocFonctionnel: protectedProcedure
|
||||
.input(z.object({ nom: z.string().min(1) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||
return createBlocFonctionnel(input.nom, isGestionnaire);
|
||||
}),
|
||||
|
||||
createSolution: protectedProcedure
|
||||
.input(z.object({
|
||||
nom: z.string().min(1),
|
||||
editeurId: z.number().int(),
|
||||
blocFonctionnelId: z.number().int().optional().nullable(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||
return createSolution(input.nom, input.editeurId, input.blocFonctionnelId, isGestionnaire);
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── Établissements ────────────────────────────────────────────────────────
|
||||
etablissements: router({
|
||||
mesEtablissements: protectedProcedure.query(({ ctx }) =>
|
||||
getEtablissementsByReferent(ctx.user.id)
|
||||
),
|
||||
|
||||
all: gestionnaireProcedure.query(() => getAllEtablissements()),
|
||||
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.number().int() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const etab = await getEtablissementById(input.id);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
// Vérifier visibilité
|
||||
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
|
||||
if (etab.referentId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
}
|
||||
return etab;
|
||||
}),
|
||||
|
||||
search: protectedProcedure
|
||||
.input(z.object({
|
||||
solutionId: z.number().int().optional(),
|
||||
editeurId: z.number().int().optional(),
|
||||
blocFonctionnelId: z.number().int().optional(),
|
||||
region: z.string().optional(),
|
||||
typeActivite: z.string().optional(),
|
||||
tailleEffectifs: z.string().optional(),
|
||||
etatDeploiement: z.string().optional(),
|
||||
}))
|
||||
.query(({ input, ctx }) => {
|
||||
return import("./db").then(({ searchEtablissements }) =>
|
||||
searchEtablissements({
|
||||
...input,
|
||||
userId: ctx.user.id,
|
||||
sonumRole: ctx.user.sonumRole,
|
||||
})
|
||||
);
|
||||
}),
|
||||
|
||||
create: gestionnaireProcedure
|
||||
.input(z.object({
|
||||
finess: z.string().optional(),
|
||||
nom: z.string().min(1),
|
||||
region: z.string().optional(),
|
||||
departement: z.string().optional(),
|
||||
typeActivite: z.string().optional(),
|
||||
tailleEffectifs: z.string().optional(),
|
||||
referentId: z.number().int().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = await getDb();
|
||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
const result = await db.insert(etablissements).values(input);
|
||||
return result[0];
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number().int(),
|
||||
visibilite: z.enum(["tous", "gestionnaires"]).optional(),
|
||||
accepteMiseEnRelation: z.boolean().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const etab = await getEtablissementById(input.id);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
const db = await getDb();
|
||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
const { id, ...updateData } = input;
|
||||
await db.update(etablissements).set(updateData).where(eq(etablissements.id, id));
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── Logiciels ─────────────────────────────────────────────────────────────
|
||||
logiciels: router({
|
||||
byEtablissement: protectedProcedure
|
||||
.input(z.object({ etablissementId: z.number().int() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const etab = await getEtablissementById(input.etablissementId);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
|
||||
if (etab.referentId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
return getLogicielsByEtablissement(input.etablissementId);
|
||||
}),
|
||||
|
||||
upsert: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number().int().optional(),
|
||||
etablissementId: z.number().int(),
|
||||
solutionId: z.number().int(),
|
||||
etatDeploiement: z.enum(["demarrage", "en_cours", "operationnel", "en_remplacement"]),
|
||||
modeHebergement: z.enum(["hds", "on_premise", "hybride"]).optional().nullable(),
|
||||
modeFacturation: z.enum(["saas", "achat_maintenance", "location"]).optional().nullable(),
|
||||
interoperabilite: z.enum(["non", "oui_interface", "oui_eai"]).optional().nullable(),
|
||||
versionMajeure: z.string().optional().nullable(),
|
||||
commentaire: z.string().optional().nullable(),
|
||||
contactNom: z.string().optional().nullable(),
|
||||
contactFonction: z.string().optional().nullable(),
|
||||
contactEmail: z.string().optional().nullable(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const etab = await getEtablissementById(input.etablissementId);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
return upsertLogicielEtablissement({ ...input, saisiePar: ctx.user.id });
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number().int(), etablissementId: z.number().int() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const etab = await getEtablissementById(input.etablissementId);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
await deleteLogicielEtablissement(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── Traçabilité ───────────────────────────────────────────────────────────
|
||||
tracabilite: router({
|
||||
enregistrerConsultation: protectedProcedure
|
||||
.input(z.object({ etablissementId: z.number().int() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await recordConsultation(input.etablissementId, ctx.user.id, ctx.user.name ?? "Inconnu");
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
compteur: protectedProcedure
|
||||
.input(z.object({ etablissementId: z.number().int() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const etab = await getEtablissementById(input.etablissementId);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const canSee = etab.referentId === ctx.user.id || ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||
if (!canSee) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
return { count: await getConsultationCount(input.etablissementId) };
|
||||
}),
|
||||
|
||||
liste: protectedProcedure
|
||||
.input(z.object({ etablissementId: z.number().int() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const etab = await getEtablissementById(input.etablissementId);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const canSee = etab.referentId === ctx.user.id || ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||
if (!canSee) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
return getConsultationsList(input.etablissementId);
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── Demandes de Contact ───────────────────────────────────────────────────
|
||||
contact: router({
|
||||
envoyer: protectedProcedure
|
||||
.input(z.object({
|
||||
etablissementCibleId: z.number().int(),
|
||||
message: z.string().min(1),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const etab = await getEtablissementById(input.etablissementCibleId);
|
||||
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
await createDemandeContact({
|
||||
etablissementCibleId: input.etablissementCibleId,
|
||||
demandeurId: ctx.user.id,
|
||||
demandeurNom: ctx.user.name ?? "Inconnu",
|
||||
demandeurEmail: ctx.user.email ?? "",
|
||||
message: input.message,
|
||||
});
|
||||
|
||||
// Notification au propriétaire (gestionnaire SONUM)
|
||||
await notifyOwner({
|
||||
title: `Nouvelle demande de contact — ${etab.nom}`,
|
||||
content: `${ctx.user.name} souhaite contacter le référent de ${etab.nom}.\n\nMessage : ${input.message}`,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
mesDemandes: protectedProcedure.query(({ ctx }) =>
|
||||
getDemandesByDemandeur(ctx.user.id)
|
||||
),
|
||||
|
||||
demandesRecues: protectedProcedure.query(({ ctx }) =>
|
||||
getDemandesRecuesParEtablissement(ctx.user.id)
|
||||
),
|
||||
|
||||
toutesLesDemandes: gestionnaireProcedure.query(() => getAllDemandes()),
|
||||
|
||||
repondre: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number().int(),
|
||||
reponse: z.string().min(1),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const demande = await getDemandeById(input.id);
|
||||
if (!demande) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const etab = await getEtablissementById(demande.etablissementCibleId);
|
||||
const canReply = (etab?.referentId === ctx.user.id) || ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||
if (!canReply) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
await repondreDemandeContact(input.id, input.reponse, ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── Administration ────────────────────────────────────────────────────────
|
||||
admin: router({
|
||||
users: gestionnaireProcedure.query(() => getAllUsers()),
|
||||
|
||||
updateRole: gestionnaireProcedure
|
||||
.input(z.object({
|
||||
userId: z.number().int(),
|
||||
sonumRole: z.enum(["referent", "gestionnaire"]),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
await updateUserSonumRole(input.userId, input.sonumRole);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
154
server/sonum.test.ts
Normal file
154
server/sonum.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { appRouter } from "./routers";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeUser(overrides: Partial<TrpcContext["user"]> = {}): NonNullable<TrpcContext["user"]> {
|
||||
return {
|
||||
id: 1,
|
||||
openId: "user-1",
|
||||
name: "Référent Test",
|
||||
email: "referent@test.fr",
|
||||
loginMethod: "manus",
|
||||
role: "user",
|
||||
sonumRole: "referent",
|
||||
cguAccepted: true,
|
||||
cguAcceptedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastSignedIn: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGestionnaire(): NonNullable<TrpcContext["user"]> {
|
||||
return makeUser({ id: 2, openId: "gestionnaire-1", sonumRole: "gestionnaire", name: "Gestionnaire SONUM" });
|
||||
}
|
||||
|
||||
function makeCtx(user: NonNullable<TrpcContext["user"]> | null = null): TrpcContext {
|
||||
return {
|
||||
user,
|
||||
req: { protocol: "https", headers: {} } as TrpcContext["req"],
|
||||
res: {
|
||||
clearCookie: vi.fn(),
|
||||
} as unknown as TrpcContext["res"],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests Auth ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("auth.logout", () => {
|
||||
it("clears the session cookie and returns success", async () => {
|
||||
const ctx = makeCtx(makeUser());
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const result = await caller.auth.logout();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("works for unauthenticated users too (public procedure)", async () => {
|
||||
const ctx = makeCtx(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const result = await caller.auth.logout();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth.me", () => {
|
||||
it("returns null for unauthenticated user", async () => {
|
||||
const ctx = makeCtx(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const result = await caller.auth.me();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns user for authenticated user", async () => {
|
||||
const user = makeUser();
|
||||
const ctx = makeCtx(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const result = await caller.auth.me();
|
||||
expect(result?.id).toBe(1);
|
||||
expect(result?.sonumRole).toBe("referent");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests CGU ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("cgu.status", () => {
|
||||
it("throws UNAUTHORIZED for unauthenticated user", async () => {
|
||||
const ctx = makeCtx(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.cgu.status()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns cgu status for authenticated user", async () => {
|
||||
const user = makeUser({ cguAccepted: true });
|
||||
const ctx = makeCtx(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const result = await caller.cgu.status();
|
||||
expect(result.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when CGU not accepted", async () => {
|
||||
const user = makeUser({ cguAccepted: false, cguAcceptedAt: undefined });
|
||||
const ctx = makeCtx(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const result = await caller.cgu.status();
|
||||
expect(result.accepted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests Gestion des rôles ──────────────────────────────────────────────────
|
||||
|
||||
describe("admin.users (gestionnaire only)", () => {
|
||||
it("throws FORBIDDEN for a referent", async () => {
|
||||
const ctx = makeCtx(makeUser());
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.admin.users()).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED for unauthenticated user", async () => {
|
||||
const ctx = makeCtx(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.admin.users()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("contact.toutesLesDemandes (gestionnaire only)", () => {
|
||||
it("throws FORBIDDEN for a referent", async () => {
|
||||
const ctx = makeCtx(makeUser());
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.contact.toutesLesDemandes()).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests Référentiel ────────────────────────────────────────────────────────
|
||||
|
||||
describe("referentiel.editeurs", () => {
|
||||
it("is accessible as a public procedure", async () => {
|
||||
const ctx = makeCtx(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
// Should not throw (may return empty array if DB not available)
|
||||
const result = await caller.referentiel.editeurs();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("referentiel.blocsFonctionnels", () => {
|
||||
it("is accessible as a public procedure", async () => {
|
||||
const ctx = makeCtx(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const result = await caller.referentiel.blocsFonctionnels();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests Traçabilité ────────────────────────────────────────────────────────
|
||||
|
||||
describe("tracabilite.compteur", () => {
|
||||
it("throws UNAUTHORIZED for unauthenticated user", async () => {
|
||||
const ctx = makeCtx(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.tracabilite.compteur({ etablissementId: 1 })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user