Initial local backup snapshot

This commit is contained in:
Selecta Keke 2026-06-24 00:02:55 -03:00
commit acd9e14ba5
367 changed files with 118038 additions and 0 deletions

962
server/_core/auth.ts Normal file
View file

@ -0,0 +1,962 @@
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { ForbiddenError } from "@shared/_core/errors";
import { SignJWT, jwtVerify } from "jose";
import * as crypto from "node:crypto";
import { promisify } from "node:util";
import { parse as parseCookieHeader } from "cookie";
import type { Request, Response } from "express";
import type { User } from "../../drizzle/schema";
import * as db from "../db";
import { ENV } from "./env";
import { getSessionCookieOptions } from "./cookies";
import { canSendOperationalEmails, sendOperationalEmail } from "../mailer";
const scryptAsync = promisify(crypto.scrypt);
const argon2Async =
typeof (crypto as any).argon2 === "function"
? promisify((crypto as any).argon2)
: null;
const KEY_LENGTH = 64;
const LOGIN_FAILURE_LIMIT = 5;
const LOGIN_LOCK_DURATION_MS = 15 * 60 * 1000;
const MFA_CODE_TTL_MS = 10 * 60 * 1000;
const MFA_CODE_ATTEMPT_LIMIT = 5;
const RETENTION_REPORT_SETTING_KEY = "retention.lastReport";
const SYSTEM_ACTOR_USER_ID = 0;
const INTERNAL_MFA_POLICY = {
super_admin: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: "authenticator_app",
emailFallbackAllowed: false,
phaseLabel: "Phase 2 cible",
},
admin: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: "authenticator_app",
emailFallbackAllowed: false,
phaseLabel: "Phase 2 active",
},
directrice: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: "authenticator_app",
emailFallbackAllowed: false,
phaseLabel: "Phase 2 active",
},
accueil: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: null,
emailFallbackAllowed: true,
phaseLabel: "Phase 1 active",
},
service_terrain: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: null,
emailFallbackAllowed: true,
phaseLabel: "Phase 1 active",
},
logistique_controle: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: null,
emailFallbackAllowed: true,
phaseLabel: "Phase 1 active",
},
} as const;
class AccountLockedError extends Error {
constructor(public lockedUntil: Date) {
super(`Compte temporairement verrouillé jusqu'au ${lockedUntil.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}.`);
}
}
class MfaChallengeRequiredError extends Error {
constructor(
public challengeType: "email" | "totp",
public challengeToken: string,
public expiresAt: Date,
public maskedEmail?: string,
) {
super("Code de verification requis");
}
}
type JwtPayload = {
userId: number;
openId: string;
};
type BootstrapUserConfig = {
email: string;
password: string;
name?: string;
role?: User["role"];
};
function createBootstrapUser(config: BootstrapUserConfig, index: number): User {
const email = normalizeEmail(config.email);
const now = new Date();
return {
id: index + 1,
openId: email,
name: config.name ?? email.split("@")[0] ?? "Utilisateur",
email,
passwordHash: null,
loginMethod: "bootstrap_jwt",
role: config.role ?? "admin",
canManageLogistics: config.role === "super_admin",
delegatedSalleSignerUserId: null,
privacyConsentVersion: null,
privacyConsentAcceptedAt: null,
privacyConsentContext: null,
failedLoginAttempts: 0,
lockedUntil: null,
mfaEnabled: false,
mfaMethod: null,
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
mfaTotpSecretEncrypted: null,
mfaTotpPendingSecretEncrypted: null,
deletionRequestedAt: null,
purgeScheduledAt: null,
purgedAt: null,
legalHold: false,
legalHoldReason: null,
isActive: true,
createdAt: now,
updatedAt: now,
lastSignedIn: now,
};
}
function getBootstrapUserConfigs(): BootstrapUserConfig[] {
const users: BootstrapUserConfig[] = [];
if (ENV.bootstrapUsers) {
try {
const parsed = JSON.parse(ENV.bootstrapUsers) as BootstrapUserConfig[];
if (Array.isArray(parsed)) {
users.push(...parsed);
}
} catch (error) {
console.error("[Auth] BOOTSTRAP_USERS is not valid JSON", error);
}
}
if (ENV.bootstrapAdminEmail && ENV.bootstrapAdminPassword) {
users.push({
email: ENV.bootstrapAdminEmail,
password: ENV.bootstrapAdminPassword,
name: ENV.bootstrapAdminName,
role: "super_admin",
});
}
return users;
}
function getBootstrapUsers(): User[] {
return getBootstrapUserConfigs().map((config, index) =>
createBootstrapUser(config, index)
);
}
function findBootstrapUserByCredentials(input: { email: string; password: string }) {
const email = normalizeEmail(input.email);
const configs = getBootstrapUserConfigs();
const index = configs.findIndex(
config => normalizeEmail(config.email) === email && config.password === input.password
);
if (index === -1) return null;
return createBootstrapUser(configs[index], index);
}
function findBootstrapUserByOpenId(openId: string) {
return getBootstrapUsers().find(user => user.openId === openId) ?? null;
}
function getJwtSecret() {
const secret = ENV.cookieSecret;
if (!secret && ENV.isProduction) {
throw new Error("JWT_SECRET is required in production");
}
return new TextEncoder().encode(secret || "local-dev-secret-change-me");
}
function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
async function ensureConfiguredOwnerPrivileges(user: User): Promise<User> {
if (!user.email || !ENV.adminEmail) return user;
const isConfiguredOwner = normalizeEmail(user.email) === normalizeEmail(ENV.adminEmail);
if (!isConfiguredOwner) return user;
if (user.role === "super_admin" && user.canManageLogistics) {
return user;
}
await db.updateUser(user.id, {
role: "super_admin",
canManageLogistics: true,
});
return {
...user,
role: "super_admin",
canManageLogistics: true,
};
}
function parseCookies(cookieHeader: string | undefined) {
if (!cookieHeader) return new Map<string, string>();
return new Map(Object.entries(parseCookieHeader(cookieHeader)));
}
function hashOtpCode(code: string) {
return crypto.createHash("sha256").update(code).digest("hex");
}
function buildSecurityCipherKey() {
return crypto.createHash("sha256")
.update(ENV.cookieSecret || "local-dev-auth-security")
.digest();
}
function encryptSecuritySecret(value: string) {
const iv = crypto.randomBytes(12);
const key = buildSecurityCipherKey();
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString("base64")}.${tag.toString("base64")}.${encrypted.toString("base64")}`;
}
function decryptSecuritySecret(value?: string | null) {
if (!value) return "";
const [ivBase64, tagBase64, encryptedBase64] = value.split(".");
if (!ivBase64 || !tagBase64 || !encryptedBase64) return "";
try {
const key = buildSecurityCipherKey();
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(ivBase64, "base64"));
decipher.setAuthTag(Buffer.from(tagBase64, "base64"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedBase64, "base64")),
decipher.final(),
]);
return decrypted.toString("utf8");
} catch {
return "";
}
}
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
function encodeBase32(buffer: Buffer) {
let bits = 0;
let value = 0;
let output = "";
for (let index = 0; index < buffer.length; index += 1) {
const byte = buffer[index]!;
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
function decodeBase32(input: string) {
const normalized = input.toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, "");
let bits = 0;
let value = 0;
const output: number[] = [];
for (const char of normalized) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) continue;
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(output);
}
function generateTotpSecret() {
return encodeBase32(crypto.randomBytes(20));
}
function buildOtpAuthUrl(email: string, secret: string) {
const label = encodeURIComponent(`Portail Associations:${email}`);
const issuer = encodeURIComponent("Portail Associations");
return `otpauth://totp/${label}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`;
}
function generateTotpCode(secret: string, timestamp: number) {
const key = decodeBase32(secret);
const counter = Math.floor(timestamp / 30_000);
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const hmac = crypto.createHmac("sha1", key).update(counterBuffer).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const binary = ((hmac[offset] & 0x7f) << 24)
| ((hmac[offset + 1] & 0xff) << 16)
| ((hmac[offset + 2] & 0xff) << 8)
| (hmac[offset + 3] & 0xff);
return String(binary % 1_000_000).padStart(6, "0");
}
function verifyTotpCode(secret: string, code: string) {
const trimmedCode = code.trim();
const now = Date.now();
for (const drift of [-30_000, 0, 30_000]) {
if (generateTotpCode(secret, now + drift) === trimmedCode) {
return true;
}
}
return false;
}
function maskEmail(email: string) {
const [localPart, domain = ""] = email.split("@");
if (!localPart) return email;
const visibleStart = localPart.slice(0, 2);
const visibleEnd = localPart.length > 4 ? localPart.slice(-1) : "";
const maskedCore = "*".repeat(Math.max(localPart.length - visibleStart.length - visibleEnd.length, 1));
return `${visibleStart}${maskedCore}${visibleEnd}@${domain}`;
}
function generateOtpCode() {
return String(Math.floor(100000 + Math.random() * 900000));
}
function getPasswordHashAlgorithm(passwordHash: string | null | undefined) {
if (!passwordHash) return null;
const [algorithm] = passwordHash.split(":");
return algorithm || null;
}
export function getInternalMfaPolicy(role: User["role"]) {
return INTERNAL_MFA_POLICY[role as keyof typeof INTERNAL_MFA_POLICY] ?? null;
}
export function isInternalRole(role: User["role"]) {
return Boolean(getInternalMfaPolicy(role));
}
function requiresMfaByRole(user: User) {
return Boolean(getInternalMfaPolicy(user.role)?.required);
}
function requiresTotpByRole(user: User) {
return getInternalMfaPolicy(user.role)?.enforcedMethod === "authenticator_app";
}
function allowsEmailMfaByRole(user: User) {
return getInternalMfaPolicy(user.role)?.emailFallbackAllowed !== false;
}
function getEffectiveMfaMethod(user: User): "email" | "totp" | null {
const hasTotp = Boolean(user.mfaMethod === "authenticator_app" && user.mfaTotpSecretEncrypted);
if (hasTotp) return "totp";
if ((user.mfaEnabled || requiresMfaByRole(user)) && user.email && allowsEmailMfaByRole(user)) return "email";
return null;
}
async function hashPassword(password: string) {
if (!argon2Async) {
const salt = crypto.randomBytes(16).toString("hex");
const derivedKey = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer;
return `scrypt:${salt}:${derivedKey.toString("hex")}`;
}
const nonce = crypto.randomBytes(16);
const derivedKey = (await argon2Async("argon2id", {
message: Buffer.from(password),
nonce,
parallelism: 1,
tagLength: 32,
memory: 65536,
passes: 3,
})) as Buffer;
return `argon2id:${nonce.toString("base64url")}:${derivedKey.toString("base64url")}`;
}
export async function hashLocalPassword(password: string) {
return hashPassword(password);
}
async function verifyPassword(password: string, passwordHash: string | null | undefined) {
if (!passwordHash) return false;
const [algorithm, salt, key] = passwordHash.split(":");
if (algorithm === "argon2id" && salt && key && argon2Async) {
const nonce = Buffer.from(salt, "base64url");
const expected = Buffer.from(key, "base64url");
const derivedKey = (await argon2Async("argon2id", {
message: Buffer.from(password),
nonce,
parallelism: 1,
tagLength: expected.length,
memory: 65536,
passes: 3,
})) as Buffer;
return derivedKey.length === expected.length && crypto.timingSafeEqual(derivedKey, expected);
}
if (algorithm !== "scrypt" || !salt || !key) return false;
const storedKey = Buffer.from(key, "hex");
const derivedKey = (await scryptAsync(password, salt, storedKey.length)) as Buffer;
if (storedKey.length !== derivedKey.length) return false;
return crypto.timingSafeEqual(storedKey, derivedKey);
}
async function resetLoginSecurityState(userId: number) {
await db.updateUser(userId, {
failedLoginAttempts: 0,
lockedUntil: null,
});
}
async function registerLoginFailure(user: User) {
const nextAttempts = (user.failedLoginAttempts || 0) + 1;
const lockNow = nextAttempts >= LOGIN_FAILURE_LIMIT;
await db.updateUser(user.id, {
failedLoginAttempts: lockNow ? 0 : nextAttempts,
lockedUntil: lockNow ? new Date(Date.now() + LOGIN_LOCK_DURATION_MS) : null,
});
}
function assertUserNotLocked(user: User) {
if (user.lockedUntil && new Date(user.lockedUntil) > new Date()) {
throw new AccountLockedError(new Date(user.lockedUntil));
}
}
async function sendLoginMfaChallenge(user: User) {
if (requiresTotpByRole(user) && !user.mfaTotpSecretEncrypted) {
throw new Error("Ce rôle interne doit utiliser l'application Authenticator. Faites initialiser le compte avant la prochaine connexion.");
}
const effectiveMethod = getEffectiveMfaMethod(user);
if (effectiveMethod === "totp") {
const challengeToken = crypto.randomBytes(24).toString("base64url");
const expiresAt = new Date(Date.now() + MFA_CODE_TTL_MS);
await db.updateUser(user.id, {
mfaChallengeToken: challengeToken,
mfaCodeHash: null,
mfaCodeExpiresAt: expiresAt,
mfaCodeAttempts: 0,
});
return new MfaChallengeRequiredError("totp", challengeToken, expiresAt);
}
if (!user.email) {
throw new Error("Ce compte ne dispose pas d'une adresse email exploitable pour le MFA.");
}
const mailReady = await canSendOperationalEmails();
if (!mailReady) {
throw new Error("Le MFA par email ne peut pas etre active tant que le SMTP n'est pas configure.");
}
const code = generateOtpCode();
const expiresAt = new Date(Date.now() + MFA_CODE_TTL_MS);
const challengeToken = crypto.randomBytes(24).toString("base64url");
await db.updateUser(user.id, {
mfaChallengeToken: challengeToken,
mfaCodeHash: hashOtpCode(code),
mfaCodeExpiresAt: expiresAt,
mfaCodeAttempts: 0,
});
await sendOperationalEmail({
to: [user.email],
subject: "Code de verification - Portail Associations",
text: `Votre code de verification est ${code}. Il expire dans 10 minutes.`,
html: `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #161616;">
<h2>Code de verification</h2>
<p>Utilisez ce code pour finaliser votre connexion au Portail Associations :</p>
<p style="font-size: 28px; font-weight: 700; letter-spacing: 6px;">${code}</p>
<p>Ce code expire dans 10 minutes.</p>
</div>
`,
});
return new MfaChallengeRequiredError("email", challengeToken, expiresAt, maskEmail(user.email));
}
async function signSession(user: User) {
const expiresAt = Math.floor((Date.now() + ONE_YEAR_MS) / 1000);
return new SignJWT({
userId: user.id,
openId: user.openId,
} satisfies JwtPayload)
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(String(user.id))
.setExpirationTime(expiresAt)
.sign(getJwtSecret());
}
async function verifySession(token: string | undefined | null) {
if (!token) return null;
try {
const { payload } = await jwtVerify(token, getJwtSecret(), {
algorithms: ["HS256"],
});
const userId = Number(payload.userId ?? payload.sub);
const openId = typeof payload.openId === "string" ? payload.openId : "";
if (!Number.isInteger(userId) || userId <= 0 || !openId) return null;
return { userId, openId };
} catch {
return null;
}
}
export function setSessionCookie(req: Request, res: Response, token: string) {
res.cookie(COOKIE_NAME, token, {
...getSessionCookieOptions(req),
maxAge: ONE_YEAR_MS,
});
}
export function clearSessionCookie(req: Request, res: Response) {
res.clearCookie(COOKIE_NAME, {
...getSessionCookieOptions(req),
maxAge: -1,
});
}
export async function registerLocalUser(input: {
name: string;
email: string;
password: string;
}) {
const email = normalizeEmail(input.email);
const existing = await db.getUserByEmail(email);
if (existing) {
throw new Error("Un compte existe deja avec cette adresse email");
}
const bootstrapUser = findBootstrapUserByCredentials(input);
if (bootstrapUser) {
return bootstrapUser;
}
const role =
ENV.adminEmail && normalizeEmail(ENV.adminEmail) === email ? "super_admin" : "user";
await db.upsertUser({
openId: email,
name: input.name.trim(),
email,
passwordHash: await hashPassword(input.password),
loginMethod: "local_jwt",
role,
lastSignedIn: new Date(),
});
const user = await db.getUserByEmail(email);
if (!user) throw new Error("Impossible de creer le compte utilisateur");
return user;
}
export async function loginLocalUser(input: { email: string; password: string }) {
const email = normalizeEmail(input.email);
const user = await db.getUserByEmail(email);
if (user) {
assertUserNotLocked(user);
if (!(await verifyPassword(input.password, user.passwordHash))) {
await registerLoginFailure(user);
throw ForbiddenError("Identifiants invalides");
}
await resetLoginSecurityState(user.id);
if (argon2Async && getPasswordHashAlgorithm(user.passwordHash) === "scrypt") {
await db.updateUser(user.id, {
passwordHash: await hashPassword(input.password),
});
}
const refreshedUser = (await db.getUserById(user.id)) ?? user;
if (getEffectiveMfaMethod(refreshedUser)) {
throw await sendLoginMfaChallenge(refreshedUser);
}
await db.upsertUser({
openId: user.openId,
lastSignedIn: new Date(),
});
return ensureConfiguredOwnerPrivileges((await db.getUserById(user.id)) ?? refreshedUser);
}
const bootstrapUser = findBootstrapUserByCredentials(input);
if (bootstrapUser) {
return bootstrapUser;
}
throw ForbiddenError("Identifiants invalides");
}
export async function verifyLocalUserMfa(input: { challengeToken: string; code: string }) {
const user = await db.getUserByMfaChallengeToken(input.challengeToken);
if (!user) {
throw ForbiddenError("Verification invalide ou expirée");
}
const effectiveMethod = getEffectiveMfaMethod(user);
if (!effectiveMethod) {
throw ForbiddenError("Verification invalide ou expirée");
}
if (user.mfaCodeExpiresAt && new Date(user.mfaCodeExpiresAt) < new Date()) {
await db.updateUser(user.id, {
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
});
throw ForbiddenError("Le code de verification a expiré");
}
if ((user.mfaCodeAttempts || 0) >= MFA_CODE_ATTEMPT_LIMIT) {
await db.updateUser(user.id, {
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
lockedUntil: new Date(Date.now() + LOGIN_LOCK_DURATION_MS),
});
throw new AccountLockedError(new Date(Date.now() + LOGIN_LOCK_DURATION_MS));
}
const isValidCode = effectiveMethod === "totp"
? verifyTotpCode(decryptSecuritySecret(user.mfaTotpSecretEncrypted), input.code)
: Boolean(user.mfaCodeHash) && hashOtpCode(input.code.trim()) === user.mfaCodeHash;
if (!isValidCode) {
await db.updateUser(user.id, {
mfaCodeAttempts: (user.mfaCodeAttempts || 0) + 1,
});
throw ForbiddenError("Code de verification incorrect");
}
await db.updateUser(user.id, {
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
lastSignedIn: new Date(),
});
return ensureConfiguredOwnerPrivileges((await db.getUserById(user.id)) ?? user);
}
export async function resendLocalUserMfaChallenge(challengeToken: string) {
const user = await db.getUserByMfaChallengeToken(challengeToken);
if (!user || getEffectiveMfaMethod(user) !== "email") {
throw ForbiddenError("Verification invalide ou expirée");
}
if (!user.email) {
throw ForbiddenError("Adresse email indisponible pour ce compte");
}
const code = generateOtpCode();
const expiresAt = new Date(Date.now() + MFA_CODE_TTL_MS);
await db.updateUser(user.id, {
mfaCodeHash: hashOtpCode(code),
mfaCodeExpiresAt: expiresAt,
mfaCodeAttempts: 0,
});
await sendOperationalEmail({
to: [user.email],
subject: "Nouveau code de verification - Portail Associations",
text: `Votre nouveau code de verification est ${code}. Il expire dans 10 minutes.`,
html: `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #161616;">
<h2>Nouveau code de verification</h2>
<p>Utilisez ce code pour finaliser votre connexion au Portail Associations :</p>
<p style="font-size: 28px; font-weight: 700; letter-spacing: 6px;">${code}</p>
<p>Ce code expire dans 10 minutes.</p>
</div>
`,
});
return {
challengeToken,
expiresAt,
maskedEmail: maskEmail(user.email),
};
}
export async function startAuthenticatorSetup(user: User) {
if (user.loginMethod !== "local_jwt" || !user.email) {
throw new Error("Authenticator est disponible uniquement pour les comptes locaux avec email.");
}
const secret = generateTotpSecret();
await db.updateUser(user.id, {
mfaTotpPendingSecretEncrypted: encryptSecuritySecret(secret),
});
return {
manualEntryKey: secret,
otpauthUrl: buildOtpAuthUrl(user.email, secret),
};
}
export async function confirmAuthenticatorSetup(user: User, code: string) {
const freshUser = await db.getUserById(user.id);
if (!freshUser) {
throw new Error("Compte introuvable");
}
const pendingSecret = decryptSecuritySecret(freshUser.mfaTotpPendingSecretEncrypted);
if (!pendingSecret) {
throw new Error("Aucune configuration Authenticator en attente.");
}
if (!verifyTotpCode(pendingSecret, code)) {
throw new Error("Code Authenticator invalide.");
}
await db.updateUser(user.id, {
mfaEnabled: true,
mfaMethod: "authenticator_app",
mfaTotpSecretEncrypted: encryptSecuritySecret(pendingSecret),
mfaTotpPendingSecretEncrypted: null,
});
}
export async function cancelAuthenticatorSetup(user: User) {
await db.updateUser(user.id, {
mfaTotpPendingSecretEncrypted: null,
});
}
export async function scheduleAccountDeletion(user: User) {
if (user.legalHold) {
throw new Error("Ce compte est actuellement place en conservation legale et ne peut pas etre supprime.");
}
const now = new Date();
const purgeAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
await db.updateUser(user.id, {
deletionRequestedAt: now,
purgeScheduledAt: purgeAt,
isActive: false,
});
return { deletionRequestedAt: now, purgeScheduledAt: purgeAt };
}
export async function cancelScheduledAccountDeletion(user: User) {
await db.updateUser(user.id, {
deletionRequestedAt: null,
purgeScheduledAt: null,
isActive: true,
});
}
export async function runUserPurgeScheduler() {
const startedAt = new Date();
const dueUsers = await db.getUsersScheduledForPurge();
let purgedUsers = 0;
for (const user of dueUsers) {
const association = await db.getAssociationByUserId(user.id);
if (association) {
await db.updateAssociation(association.id, {
sourceDirectoryEntryId: null,
nomAssociation: `Association supprimée #${association.id}`,
siret: null,
rna: null,
thematique: null,
adresse: null,
codePostal: null,
ville: null,
telephone: null,
emailContact: null,
siteWeb: null,
facebookUrl: null,
instagramUrl: null,
objetAssociation: null,
nomRepresentant: null,
fonctionRepresentant: null,
gouvernance: null,
profileComplete: false,
isActive: false,
});
}
await db.updateUser(user.id, {
openId: `purged:${user.id}:${Date.now()}`,
name: `Compte supprimé #${user.id}`,
email: null,
passwordHash: null,
isActive: false,
mfaEnabled: false,
mfaMethod: null,
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
mfaTotpSecretEncrypted: null,
mfaTotpPendingSecretEncrypted: null,
purgedAt: new Date(),
legalHold: false,
legalHoldReason: null,
});
await db.createAuditLog({
userId: SYSTEM_ACTOR_USER_ID,
action: "data_purge",
entityType: "user",
entityId: user.id,
details: JSON.stringify({
event: "DATA_PURGE",
executedAt: new Date().toISOString(),
status: "SUCCESS",
purgeScheduledAt: user.purgeScheduledAt,
}),
ipAddress: "system",
});
purgedUsers += 1;
}
const allUsers = await db.getAllUsers();
const pendingDeletionCount = allUsers.filter((item) => item.deletionRequestedAt && !item.purgedAt).length;
const purgedTotalCount = allUsers.filter((item) => item.purgedAt).length;
await db.setPortalSetting(
RETENTION_REPORT_SETTING_KEY,
JSON.stringify({
generatedAt: new Date().toISOString(),
startedAt: startedAt.toISOString(),
reportType: "daily-retention-job",
purgedUsers,
pendingDeletionCount,
purgedTotalCount,
}),
"Dernier rapport automatique de retention et purge"
);
return {
generatedAt: new Date().toISOString(),
purgedUsers,
pendingDeletionCount,
purgedTotalCount,
};
}
export function isAccountLockedError(error: unknown): error is AccountLockedError {
return error instanceof AccountLockedError;
}
export function isMfaCodeRequiredError(error: unknown): error is MfaChallengeRequiredError {
return error instanceof MfaChallengeRequiredError;
}
export async function loginOAuthUser(input: {
provider: "google" | "facebook";
providerUserId: string;
email: string;
name?: string | null;
}) {
const email = normalizeEmail(input.email);
const existingByEmail = await db.getUserByEmail(email);
const openId = existingByEmail?.openId || email || `${input.provider}:${input.providerUserId}`;
const role = existingByEmail?.role
?? (ENV.adminEmail && normalizeEmail(ENV.adminEmail) === email ? "super_admin" : "user");
await db.upsertUser({
openId,
name: input.name?.trim() || existingByEmail?.name || email.split("@")[0] || "Utilisateur",
email,
loginMethod: `${input.provider}_oauth`,
role,
lastSignedIn: new Date(),
});
if (existingByEmail) {
return ensureConfiguredOwnerPrivileges((await db.getUserById(existingByEmail.id)) ?? existingByEmail);
}
const user = await db.getUserByOpenId(openId);
if (!user) {
throw new Error("Impossible de finaliser la connexion sociale");
}
return ensureConfiguredOwnerPrivileges(user);
}
export async function createSessionToken(user: User) {
return signSession(user);
}
export async function authenticateRequest(req: Request): Promise<User> {
const cookies = parseCookies(req.headers.cookie);
const session = await verifySession(cookies.get(COOKIE_NAME));
if (!session) {
throw ForbiddenError("Invalid session cookie");
}
const user = await db.getUserById(session.userId);
const userByOpenId = user ? null : await db.getUserByOpenId(session.openId);
const bootstrapUser = findBootstrapUserByOpenId(session.openId);
if (!user && userByOpenId && userByOpenId.isActive) {
return ensureConfiguredOwnerPrivileges(userByOpenId);
}
if (!user && bootstrapUser) {
return bootstrapUser;
}
if (!user || user.openId !== session.openId || !user.isActive) {
throw ForbiddenError("User not found");
}
return ensureConfiguredOwnerPrivileges(user);
}

28
server/_core/context.ts Normal file
View file

@ -0,0 +1,28 @@
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import { authenticateRequest } from "./auth";
import { withEffectiveInternalAccess, type AuthenticatedPortalUser } from "../internalAccess";
export type TrpcContext = {
req: CreateExpressContextOptions["req"];
res: CreateExpressContextOptions["res"];
user: AuthenticatedPortalUser | null;
};
export async function createContext(
opts: CreateExpressContextOptions
): Promise<TrpcContext> {
let user: AuthenticatedPortalUser | null = null;
try {
user = await withEffectiveInternalAccess(await authenticateRequest(opts.req));
} catch (error) {
// Authentication is optional for public procedures.
user = null;
}
return {
req: opts.req,
res: opts.res,
user,
};
}

50
server/_core/cookies.ts Normal file
View file

@ -0,0 +1,50 @@
import type { CookieOptions, Request } from "express";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isIpAddress(host: string) {
// Basic IPv4 check and IPv6 presence detection.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
return host.includes(":");
}
function isSecureRequest(req: Request) {
if (req.protocol === "https") return true;
const forwardedProto = req.headers["x-forwarded-proto"];
if (!forwardedProto) return false;
const protoList = Array.isArray(forwardedProto)
? forwardedProto
: forwardedProto.split(",");
return protoList.some(proto => proto.trim().toLowerCase() === "https");
}
export function getSessionCookieOptions(
req: Request
): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
// const hostname = req.hostname;
// const shouldSetDomain =
// hostname &&
// !LOCAL_HOSTS.has(hostname) &&
// !isIpAddress(hostname) &&
// hostname !== "127.0.0.1" &&
// hostname !== "::1";
// const domain =
// shouldSetDomain && !hostname.startsWith(".")
// ? `.${hostname}`
// : shouldSetDomain
// ? hostname
// : undefined;
const secure = isSecureRequest(req);
return {
httpOnly: true,
path: "/",
sameSite: secure ? "none" : "lax",
secure,
};
}

64
server/_core/dataApi.ts Normal file
View file

@ -0,0 +1,64 @@
/**
* Quick example (matches curl usage):
* await callDataApi("Youtube/search", {
* query: { gl: "US", hl: "en", q: "manus" },
* })
*/
import { ENV } from "./env";
export type DataApiCallOptions = {
query?: Record<string, unknown>;
body?: Record<string, unknown>;
pathParams?: Record<string, unknown>;
formData?: Record<string, unknown>;
};
export async function callDataApi(
apiId: string,
options: DataApiCallOptions = {}
): Promise<unknown> {
if (!ENV.forgeApiUrl) {
throw new Error("BUILT_IN_FORGE_API_URL is not configured");
}
if (!ENV.forgeApiKey) {
throw new Error("BUILT_IN_FORGE_API_KEY is not configured");
}
// Build the full URL by appending the service path to the base URL
const baseUrl = ENV.forgeApiUrl.endsWith("/") ? ENV.forgeApiUrl : `${ENV.forgeApiUrl}/`;
const fullUrl = new URL("webdevtoken.v1.WebDevService/CallApi", baseUrl).toString();
const response = await fetch(fullUrl, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"connect-protocol-version": "1",
authorization: `Bearer ${ENV.forgeApiKey}`,
},
body: JSON.stringify({
apiId,
query: options.query,
body: options.body,
path_params: options.pathParams,
multipart_form_data: options.formData,
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`Data API request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`
);
}
const payload = await response.json().catch(() => ({}));
if (payload && typeof payload === "object" && "jsonData" in payload) {
try {
return JSON.parse((payload as Record<string, string>).jsonData ?? "{}");
} catch {
return (payload as Record<string, unknown>).jsonData;
}
}
return payload;
}

30
server/_core/env.ts Normal file
View file

@ -0,0 +1,30 @@
export const ENV = {
cookieSecret: process.env.JWT_SECRET ?? "",
databaseUrl: process.env.DATABASE_URL ?? "",
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
adminEmail: process.env.ADMIN_EMAIL ?? "",
bootstrapAdminEmail: process.env.BOOTSTRAP_ADMIN_EMAIL ?? "",
bootstrapAdminPassword: process.env.BOOTSTRAP_ADMIN_PASSWORD ?? "",
bootstrapAdminName: process.env.BOOTSTRAP_ADMIN_NAME ?? "Administrateur",
bootstrapUsers: process.env.BOOTSTRAP_USERS ?? "",
isProduction: process.env.NODE_ENV === "production",
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
smtpProvider: process.env.SMTP_PROVIDER ?? "",
smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: process.env.SMTP_PORT ?? "",
smtpUser: process.env.SMTP_USER ?? "",
smtpPass: process.env.SMTP_PASS ?? "",
smtpFrom: process.env.SMTP_FROM ?? "",
smtpSecure: process.env.SMTP_SECURE ?? "",
smtpRequireTls: process.env.SMTP_REQUIRE_TLS ?? "",
appBaseUrl: process.env.APP_BASE_URL ?? "",
entrepriseApiToken: process.env.ENTREPRISE_API_TOKEN ?? "",
googleClientId: process.env.GOOGLE_CLIENT_ID ?? "",
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
facebookAppId: process.env.FACEBOOK_APP_ID ?? "",
facebookAppSecret: process.env.FACEBOOK_APP_SECRET ?? "",
appReadyDelayMs: Number.parseInt(process.env.APP_READY_DELAY_MS ?? "8000", 10) || 8000,
maintenanceFlagPath: process.env.MAINTENANCE_FLAG_PATH ?? "",
gracefulShutdownTimeoutMs: Number.parseInt(process.env.GRACEFUL_SHUTDOWN_TIMEOUT_MS ?? "20000", 10) || 20000,
};

View file

@ -0,0 +1,92 @@
/**
* Image generation helper using internal ImageService
*
* Example usage:
* const { url: imageUrl } = await generateImage({
* prompt: "A serene landscape with mountains"
* });
*
* For editing:
* const { url: imageUrl } = await generateImage({
* prompt: "Add a rainbow to this landscape",
* originalImages: [{
* url: "https://example.com/original.jpg",
* mimeType: "image/jpeg"
* }]
* });
*/
import { storagePut } from "server/storage";
import { ENV } from "./env";
export type GenerateImageOptions = {
prompt: string;
originalImages?: Array<{
url?: string;
b64Json?: string;
mimeType?: string;
}>;
};
export type GenerateImageResponse = {
url?: string;
};
export async function generateImage(
options: GenerateImageOptions
): Promise<GenerateImageResponse> {
if (!ENV.forgeApiUrl) {
throw new Error("BUILT_IN_FORGE_API_URL is not configured");
}
if (!ENV.forgeApiKey) {
throw new Error("BUILT_IN_FORGE_API_KEY is not configured");
}
// Build the full URL by appending the service path to the base URL
const baseUrl = ENV.forgeApiUrl.endsWith("/")
? ENV.forgeApiUrl
: `${ENV.forgeApiUrl}/`;
const fullUrl = new URL(
"images.v1.ImageService/GenerateImage",
baseUrl
).toString();
const response = await fetch(fullUrl, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"connect-protocol-version": "1",
authorization: `Bearer ${ENV.forgeApiKey}`,
},
body: JSON.stringify({
prompt: options.prompt,
original_images: options.originalImages || [],
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`Image generation request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`
);
}
const result = (await response.json()) as {
image: {
b64Json: string;
mimeType: string;
};
};
const base64Data = result.image.b64Json;
const buffer = Buffer.from(base64Data, "base64");
// Save to S3
const { url } = await storagePut(
`generated/${Date.now()}.png`,
buffer,
result.image.mimeType
);
return {
url,
};
}

217
server/_core/index.ts Normal file
View file

@ -0,0 +1,217 @@
import "dotenv/config";
import express from "express";
import { createServer } from "http";
import net from "net";
import path from "node:path";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { appRouter, computeReservationAnalytics, runStatsReportScheduler } from "../routers";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
import { registerEmailActionRoutes } from "../emailActions";
import { registerSocialAuthRoutes } from "../socialAuth";
import { ENV } from "./env";
import { runMaterialReturnScheduler } from "../materialReturnWorkflow";
import { authenticateRequest, runUserPurgeScheduler } from "./auth";
import { withEffectiveInternalAccess } from "../internalAccess";
import { buildRuntimeHealth, ensureMaintenanceFlagDirectory, isMaintenanceModeEnabled, markRuntimeReady, markRuntimeShuttingDown } from "./runtime";
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
const server = net.createServer();
server.listen(port, () => {
server.close(() => resolve(true));
});
server.on("error", () => resolve(false));
});
}
async function findAvailablePort(startPort: number = 3000): Promise<number> {
for (let port = startPort; port < startPort + 20; port++) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`No available port found starting from ${startPort}`);
}
async function startServer() {
const app = express();
const server = createServer(app);
const schedulerTimers: NodeJS.Timeout[] = [];
const openSockets = new Set<net.Socket>();
let shutdownInFlight = false;
await ensureMaintenanceFlagDirectory();
server.on("connection", (socket) => {
openSockets.add(socket);
socket.on("close", () => {
openSockets.delete(socket);
});
});
async function sendHealth(res: express.Response, mode: "live" | "ready") {
const health = await buildRuntimeHealth();
const status = mode === "live"
? (health.checks.shuttingDown ? 503 : 200)
: (health.ok ? 200 : 503);
res.status(status).json(health);
}
function renderMaintenancePage() {
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Maintenance en cours</title>
<style>
body{margin:0;font-family:Arial,sans-serif;background:#f6f9fc;color:#162133;display:grid;place-items:center;min-height:100vh;padding:24px}
.panel{max-width:560px;background:#fff;border:1px solid #d5e2f3;border-radius:20px;padding:32px;box-shadow:0 12px 30px rgba(15,23,42,.08)}
h1{margin:0 0 12px;font-size:32px;line-height:1.1}
p{margin:0 0 10px;font-size:16px;line-height:1.6;color:#52627a}
.badge{display:inline-flex;padding:6px 12px;border-radius:999px;background:#e8f2ff;color:#2c63c9;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-bottom:18px}
</style>
</head>
<body>
<main class="panel">
<div class="badge">Maintenance</div>
<h1>Le portail revient dans un instant</h1>
<p>Nous appliquons une mise à jour de service pour garder une expérience stable, propre et fiable.</p>
<p>Tu peux recharger la page dans quelques instants.</p>
</main>
</body>
</html>`;
}
async function gracefulShutdown(signal: string) {
if (shutdownInFlight) return;
shutdownInFlight = true;
console.log(`[Runtime] graceful shutdown triggered by ${signal}`);
markRuntimeShuttingDown();
schedulerTimers.forEach(clearTimeout);
schedulerTimers.splice(0, schedulerTimers.length);
const forceCloseTimer = setTimeout(() => {
for (const socket of Array.from(openSockets)) {
socket.destroy();
}
}, ENV.gracefulShutdownTimeoutMs);
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
clearTimeout(forceCloseTimer);
process.exit(0);
}
process.on("SIGTERM", () => {
void gracefulShutdown("SIGTERM");
});
process.on("SIGINT", () => {
void gracefulShutdown("SIGINT");
});
// Configure body parser with larger size limit for file uploads
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
app.use("/uploads", express.static(path.resolve(process.cwd(), "uploads")));
app.get("/health/live", async (_req, res) => {
await sendHealth(res, "live");
});
app.get("/health/ready", async (_req, res) => {
await sendHealth(res, "ready");
});
app.get("/health", async (_req, res) => {
await sendHealth(res, "ready");
});
// Email action routes for validate/refuse from email
registerEmailActionRoutes(app);
registerSocialAuthRoutes(app);
app.get("/api/internal/stats/reservations", async (req, res) => {
try {
let authenticatedUser;
try {
authenticatedUser = await withEffectiveInternalAccess(await authenticateRequest(req));
} catch {
return res.status(401).json({ error: "Authentification requise" });
}
if (!authenticatedUser) {
return res.status(401).json({ error: "Authentification requise" });
}
const allowedRoles = new Set(["accueil", "admin", "super_admin"]);
if (!allowedRoles.has(authenticatedUser.role)) {
return res.status(403).json({ error: "Accès réservé à laccueil et aux administrateurs" });
}
const rawPeriod = typeof req.query.period === "string" ? req.query.period : "month";
const period = ["7d", "month", "quarter", "semester", "year"].includes(rawPeriod) ? rawPeriod as "7d" | "month" | "quarter" | "semester" | "year" : "month";
const payload = await computeReservationAnalytics(period);
return res.json(payload);
} catch {
return res.status(500).json({ error: "Impossible de récupérer les statistiques" });
}
});
// tRPC API
app.use(
"/api/trpc",
createExpressMiddleware({
router: appRouter,
createContext,
})
);
app.use(async (req, res, next) => {
if (!["GET", "HEAD"].includes(req.method)) {
next();
return;
}
if (req.path.startsWith("/api") || req.path.startsWith("/health") || req.path.startsWith("/uploads")) {
next();
return;
}
if (!(await isMaintenanceModeEnabled())) {
next();
return;
}
res.status(503).type("html").send(renderMaintenancePage());
});
// development mode uses Vite, production mode uses static files
if (process.env.NODE_ENV === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
const preferredPort = parseInt(process.env.PORT || "3000");
const port = await findAvailablePort(preferredPort);
if (port !== preferredPort) {
console.log(`Port ${preferredPort} is busy, using port ${port} instead`);
}
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`);
const baseUrl = (ENV.appBaseUrl || `http://localhost:${port}`).replace(/\/$/, "");
const runScheduler = () => {
runMaterialReturnScheduler(baseUrl).catch((error) => {
console.error("[MaterialReturnScheduler] execution error:", error);
});
runStatsReportScheduler(baseUrl).catch((error) => {
console.error("[StatsReportScheduler] execution error:", error);
});
runUserPurgeScheduler().catch((error) => {
console.error("[UserPurgeScheduler] execution error:", error);
});
};
markRuntimeReady();
schedulerTimers.push(setTimeout(runScheduler, 5_000));
schedulerTimers.push(setInterval(runScheduler, 60 * 60 * 1000));
});
}
startServer().catch(console.error);

332
server/_core/llm.ts Normal file
View file

@ -0,0 +1,332 @@
import { ENV } from "./env";
export type Role = "system" | "user" | "assistant" | "tool" | "function";
export type TextContent = {
type: "text";
text: string;
};
export type ImageContent = {
type: "image_url";
image_url: {
url: string;
detail?: "auto" | "low" | "high";
};
};
export type FileContent = {
type: "file_url";
file_url: {
url: string;
mime_type?: "audio/mpeg" | "audio/wav" | "application/pdf" | "audio/mp4" | "video/mp4" ;
};
};
export type MessageContent = string | TextContent | ImageContent | FileContent;
export type Message = {
role: Role;
content: MessageContent | MessageContent[];
name?: string;
tool_call_id?: string;
};
export type Tool = {
type: "function";
function: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
};
export type ToolChoicePrimitive = "none" | "auto" | "required";
export type ToolChoiceByName = { name: string };
export type ToolChoiceExplicit = {
type: "function";
function: {
name: string;
};
};
export type ToolChoice =
| ToolChoicePrimitive
| ToolChoiceByName
| ToolChoiceExplicit;
export type InvokeParams = {
messages: Message[];
tools?: Tool[];
toolChoice?: ToolChoice;
tool_choice?: ToolChoice;
maxTokens?: number;
max_tokens?: number;
outputSchema?: OutputSchema;
output_schema?: OutputSchema;
responseFormat?: ResponseFormat;
response_format?: ResponseFormat;
};
export type ToolCall = {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
};
export type InvokeResult = {
id: string;
created: number;
model: string;
choices: Array<{
index: number;
message: {
role: Role;
content: string | Array<TextContent | ImageContent | FileContent>;
tool_calls?: ToolCall[];
};
finish_reason: string | null;
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
};
export type JsonSchema = {
name: string;
schema: Record<string, unknown>;
strict?: boolean;
};
export type OutputSchema = JsonSchema;
export type ResponseFormat =
| { type: "text" }
| { type: "json_object" }
| { type: "json_schema"; json_schema: JsonSchema };
const ensureArray = (
value: MessageContent | MessageContent[]
): MessageContent[] => (Array.isArray(value) ? value : [value]);
const normalizeContentPart = (
part: MessageContent
): TextContent | ImageContent | FileContent => {
if (typeof part === "string") {
return { type: "text", text: part };
}
if (part.type === "text") {
return part;
}
if (part.type === "image_url") {
return part;
}
if (part.type === "file_url") {
return part;
}
throw new Error("Unsupported message content part");
};
const normalizeMessage = (message: Message) => {
const { role, name, tool_call_id } = message;
if (role === "tool" || role === "function") {
const content = ensureArray(message.content)
.map(part => (typeof part === "string" ? part : JSON.stringify(part)))
.join("\n");
return {
role,
name,
tool_call_id,
content,
};
}
const contentParts = ensureArray(message.content).map(normalizeContentPart);
// If there's only text content, collapse to a single string for compatibility
if (contentParts.length === 1 && contentParts[0].type === "text") {
return {
role,
name,
content: contentParts[0].text,
};
}
return {
role,
name,
content: contentParts,
};
};
const normalizeToolChoice = (
toolChoice: ToolChoice | undefined,
tools: Tool[] | undefined
): "none" | "auto" | ToolChoiceExplicit | undefined => {
if (!toolChoice) return undefined;
if (toolChoice === "none" || toolChoice === "auto") {
return toolChoice;
}
if (toolChoice === "required") {
if (!tools || tools.length === 0) {
throw new Error(
"tool_choice 'required' was provided but no tools were configured"
);
}
if (tools.length > 1) {
throw new Error(
"tool_choice 'required' needs a single tool or specify the tool name explicitly"
);
}
return {
type: "function",
function: { name: tools[0].function.name },
};
}
if ("name" in toolChoice) {
return {
type: "function",
function: { name: toolChoice.name },
};
}
return toolChoice;
};
const resolveApiUrl = () =>
ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0
? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions`
: "https://forge.manus.im/v1/chat/completions";
const assertApiKey = () => {
if (!ENV.forgeApiKey) {
throw new Error("OPENAI_API_KEY is not configured");
}
};
const normalizeResponseFormat = ({
responseFormat,
response_format,
outputSchema,
output_schema,
}: {
responseFormat?: ResponseFormat;
response_format?: ResponseFormat;
outputSchema?: OutputSchema;
output_schema?: OutputSchema;
}):
| { type: "json_schema"; json_schema: JsonSchema }
| { type: "text" }
| { type: "json_object" }
| undefined => {
const explicitFormat = responseFormat || response_format;
if (explicitFormat) {
if (
explicitFormat.type === "json_schema" &&
!explicitFormat.json_schema?.schema
) {
throw new Error(
"responseFormat json_schema requires a defined schema object"
);
}
return explicitFormat;
}
const schema = outputSchema || output_schema;
if (!schema) return undefined;
if (!schema.name || !schema.schema) {
throw new Error("outputSchema requires both name and schema");
}
return {
type: "json_schema",
json_schema: {
name: schema.name,
schema: schema.schema,
...(typeof schema.strict === "boolean" ? { strict: schema.strict } : {}),
},
};
};
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
assertApiKey();
const {
messages,
tools,
toolChoice,
tool_choice,
outputSchema,
output_schema,
responseFormat,
response_format,
} = params;
const payload: Record<string, unknown> = {
model: "gemini-2.5-flash",
messages: messages.map(normalizeMessage),
};
if (tools && tools.length > 0) {
payload.tools = tools;
}
const normalizedToolChoice = normalizeToolChoice(
toolChoice || tool_choice,
tools
);
if (normalizedToolChoice) {
payload.tool_choice = normalizedToolChoice;
}
payload.max_tokens = 32768
payload.thinking = {
"budget_tokens": 128
}
const normalizedResponseFormat = normalizeResponseFormat({
responseFormat,
response_format,
outputSchema,
output_schema,
});
if (normalizedResponseFormat) {
payload.response_format = normalizedResponseFormat;
}
const response = await fetch(resolveApiUrl(), {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`LLM invoke failed: ${response.status} ${response.statusText} ${errorText}`
);
}
return (await response.json()) as InvokeResult;
}

319
server/_core/map.ts Normal file
View file

@ -0,0 +1,319 @@
/**
* Google Maps API Integration for Manus WebDev Templates
*
* Main function: makeRequest<T>(endpoint, params) - Makes authenticated requests to Google Maps APIs
* All credentials are automatically injected. Array parameters use | as separator.
*
* See API examples below the type definitions for usage patterns.
*/
import { ENV } from "./env";
// ============================================================================
// Configuration
// ============================================================================
type MapsConfig = {
baseUrl: string;
apiKey: string;
};
function getMapsConfig(): MapsConfig {
const baseUrl = ENV.forgeApiUrl;
const apiKey = ENV.forgeApiKey;
if (!baseUrl || !apiKey) {
throw new Error(
"Google Maps proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY"
);
}
return {
baseUrl: baseUrl.replace(/\/+$/, ""),
apiKey,
};
}
// ============================================================================
// Core Request Handler
// ============================================================================
interface RequestOptions {
method?: "GET" | "POST";
body?: Record<string, unknown>;
}
/**
* Make authenticated requests to Google Maps APIs
*
* @param endpoint - The API endpoint (e.g., "/maps/api/geocode/json")
* @param params - Query parameters for the request
* @param options - Additional request options
* @returns The API response
*/
export async function makeRequest<T = unknown>(
endpoint: string,
params: Record<string, unknown> = {},
options: RequestOptions = {}
): Promise<T> {
const { baseUrl, apiKey } = getMapsConfig();
// Construct full URL: baseUrl + /v1/maps/proxy + endpoint
const url = new URL(`${baseUrl}/v1/maps/proxy${endpoint}`);
// Add API key as query parameter (standard Google Maps API authentication)
url.searchParams.append("key", apiKey);
// Add other query parameters
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
const response = await fetch(url.toString(), {
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Google Maps API request failed (${response.status} ${response.statusText}): ${errorText}`
);
}
return (await response.json()) as T;
}
// ============================================================================
// Type Definitions
// ============================================================================
export type TravelMode = "driving" | "walking" | "bicycling" | "transit";
export type MapType = "roadmap" | "satellite" | "terrain" | "hybrid";
export type SpeedUnit = "KPH" | "MPH";
export type LatLng = {
lat: number;
lng: number;
};
export type DirectionsResult = {
routes: Array<{
legs: Array<{
distance: { text: string; value: number };
duration: { text: string; value: number };
start_address: string;
end_address: string;
start_location: LatLng;
end_location: LatLng;
steps: Array<{
distance: { text: string; value: number };
duration: { text: string; value: number };
html_instructions: string;
travel_mode: string;
start_location: LatLng;
end_location: LatLng;
}>;
}>;
overview_polyline: { points: string };
summary: string;
warnings: string[];
waypoint_order: number[];
}>;
status: string;
};
export type DistanceMatrixResult = {
rows: Array<{
elements: Array<{
distance: { text: string; value: number };
duration: { text: string; value: number };
status: string;
}>;
}>;
origin_addresses: string[];
destination_addresses: string[];
status: string;
};
export type GeocodingResult = {
results: Array<{
address_components: Array<{
long_name: string;
short_name: string;
types: string[];
}>;
formatted_address: string;
geometry: {
location: LatLng;
location_type: string;
viewport: {
northeast: LatLng;
southwest: LatLng;
};
};
place_id: string;
types: string[];
}>;
status: string;
};
export type PlacesSearchResult = {
results: Array<{
place_id: string;
name: string;
formatted_address: string;
geometry: {
location: LatLng;
};
rating?: number;
user_ratings_total?: number;
business_status?: string;
types: string[];
}>;
status: string;
};
export type PlaceDetailsResult = {
result: {
place_id: string;
name: string;
formatted_address: string;
formatted_phone_number?: string;
international_phone_number?: string;
website?: string;
rating?: number;
user_ratings_total?: number;
reviews?: Array<{
author_name: string;
rating: number;
text: string;
time: number;
}>;
opening_hours?: {
open_now: boolean;
weekday_text: string[];
};
geometry: {
location: LatLng;
};
};
status: string;
};
export type ElevationResult = {
results: Array<{
elevation: number;
location: LatLng;
resolution: number;
}>;
status: string;
};
export type TimeZoneResult = {
dstOffset: number;
rawOffset: number;
status: string;
timeZoneId: string;
timeZoneName: string;
};
export type RoadsResult = {
snappedPoints: Array<{
location: LatLng;
originalIndex?: number;
placeId: string;
}>;
};
// ============================================================================
// Google Maps API Reference
// ============================================================================
/**
* GEOCODING - Convert between addresses and coordinates
* Endpoint: /maps/api/geocode/json
* Input: { address: string } OR { latlng: string } // latlng: "37.42,-122.08"
* Output: GeocodingResult // results[0].geometry.location, results[0].formatted_address
*/
/**
* DIRECTIONS - Get navigation routes between locations
* Endpoint: /maps/api/directions/json
* Input: { origin: string, destination: string, mode?: TravelMode, waypoints?: string, alternatives?: boolean }
* Output: DirectionsResult // routes[0].legs[0].distance, duration, steps
*/
/**
* DISTANCE MATRIX - Calculate travel times/distances for multiple origin-destination pairs
* Endpoint: /maps/api/distancematrix/json
* Input: { origins: string, destinations: string, mode?: TravelMode, units?: "metric"|"imperial" } // origins: "NYC|Boston"
* Output: DistanceMatrixResult // rows[0].elements[1] = first origin to second destination
*/
/**
* PLACE SEARCH - Find businesses/POIs by text query
* Endpoint: /maps/api/place/textsearch/json
* Input: { query: string, location?: string, radius?: number, type?: string } // location: "40.7,-74.0"
* Output: PlacesSearchResult // results[].name, rating, geometry.location, place_id
*/
/**
* NEARBY SEARCH - Find places near a specific location
* Endpoint: /maps/api/place/nearbysearch/json
* Input: { location: string, radius: number, type?: string, keyword?: string } // location: "40.7,-74.0"
* Output: PlacesSearchResult
*/
/**
* PLACE DETAILS - Get comprehensive information about a specific place
* Endpoint: /maps/api/place/details/json
* Input: { place_id: string, fields?: string } // fields: "name,rating,opening_hours,website"
* Output: PlaceDetailsResult // result.name, rating, opening_hours, etc.
*/
/**
* ELEVATION - Get altitude data for geographic points
* Endpoint: /maps/api/elevation/json
* Input: { locations?: string, path?: string, samples?: number } // locations: "39.73,-104.98|36.45,-116.86"
* Output: ElevationResult // results[].elevation (meters)
*/
/**
* TIME ZONE - Get timezone information for a location
* Endpoint: /maps/api/timezone/json
* Input: { location: string, timestamp: number } // timestamp: Math.floor(Date.now()/1000)
* Output: TimeZoneResult // timeZoneId, timeZoneName
*/
/**
* ROADS - Snap GPS traces to roads, find nearest roads, get speed limits
* - /v1/snapToRoads: Input: { path: string, interpolate?: boolean } // path: "lat,lng|lat,lng"
* - /v1/nearestRoads: Input: { points: string } // points: "lat,lng|lat,lng"
* - /v1/speedLimits: Input: { path: string, units?: SpeedUnit }
* Output: RoadsResult
*/
/**
* PLACE AUTOCOMPLETE - Real-time place suggestions as user types
* Endpoint: /maps/api/place/autocomplete/json
* Input: { input: string, location?: string, radius?: number }
* Output: { predictions: Array<{ description: string, place_id: string }> }
*/
/**
* STATIC MAPS - Generate map images as URLs (for emails, reports, <img> tags)
* Endpoint: /maps/api/staticmap
* Input: URL params - center: string, zoom: number, size: string, markers?: string, maptype?: MapType
* Output: Image URL (not JSON) - use directly in <img src={url} />
* Note: Construct URL manually with getMapsConfig() for auth
*/

View file

@ -0,0 +1,110 @@
import { TRPCError } from "@trpc/server";
import { ENV } from "./env";
export type NotificationPayload = {
title: string;
content: string;
};
const TITLE_MAX_LENGTH = 1200;
const CONTENT_MAX_LENGTH = 20000;
const trimValue = (value: string): string => value.trim();
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0;
const buildEndpointUrl = (baseUrl: string): string => {
const normalizedBase = baseUrl.endsWith("/")
? baseUrl
: `${baseUrl}/`;
return new URL(
"webdevtoken.v1.WebDevService/SendNotification",
normalizedBase
).toString();
};
const validatePayload = (input: NotificationPayload): NotificationPayload => {
if (!isNonEmptyString(input.title)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Notification title is required.",
});
}
if (!isNonEmptyString(input.content)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Notification content is required.",
});
}
const title = trimValue(input.title);
const content = trimValue(input.content);
if (title.length > TITLE_MAX_LENGTH) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Notification title must be at most ${TITLE_MAX_LENGTH} characters.`,
});
}
if (content.length > CONTENT_MAX_LENGTH) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Notification content must be at most ${CONTENT_MAX_LENGTH} characters.`,
});
}
return { title, content };
};
/**
* Dispatches a project-owner notification through the Manus Notification Service.
* Returns `true` if the request was accepted, `false` when the upstream service
* cannot be reached (callers can fall back to email/slack). Validation errors
* bubble up as TRPC errors so callers can fix the payload.
*/
export async function notifyOwner(
payload: NotificationPayload
): Promise<boolean> {
const { title, content } = validatePayload(payload);
if (!ENV.forgeApiUrl) {
console.warn("[Notification] forgeApiUrl is not configured. Skipping notification.");
return false;
}
if (!ENV.forgeApiKey) {
console.warn("[Notification] forgeApiKey is not configured. Skipping notification.");
return false;
}
const endpoint = buildEndpointUrl(ENV.forgeApiUrl);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
accept: "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`,
"content-type": "application/json",
"connect-protocol-version": "1",
},
body: JSON.stringify({ title, content }),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
console.warn(
`[Notification] Failed to notify owner (${response.status} ${response.statusText})${
detail ? `: ${detail}` : ""
}`
);
return false;
}
return true;
} catch (error) {
console.warn("[Notification] Error calling notification service:", error);
return false;
}
}

78
server/_core/runtime.ts Normal file
View file

@ -0,0 +1,78 @@
import fs from "node:fs/promises";
import path from "node:path";
import { getDb, isDatabaseConfigured } from "../db";
import { ENV } from "./env";
type RuntimeHealthState = "starting" | "ready" | "maintenance" | "shutting_down";
const startedAt = Date.now();
let ready = false;
let shuttingDown = false;
function resolveMaintenanceFlagPath() {
return path.resolve(ENV.maintenanceFlagPath || path.join(process.cwd(), "uploads/system/maintenance.flag"));
}
async function fileExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export function markRuntimeReady() {
ready = true;
}
export function markRuntimeShuttingDown() {
shuttingDown = true;
ready = false;
}
export async function isMaintenanceModeEnabled() {
return fileExists(resolveMaintenanceFlagPath());
}
export async function buildRuntimeHealth() {
const uptimeMs = Date.now() - startedAt;
const db = await getDb();
const databaseOk = !isDatabaseConfigured() || Boolean(db);
const startupDelaySatisfied = uptimeMs >= ENV.appReadyDelayMs;
const maintenance = await isMaintenanceModeEnabled();
let state: RuntimeHealthState = "starting";
if (shuttingDown) {
state = "shutting_down";
} else if (maintenance) {
state = "maintenance";
} else if (ready && startupDelaySatisfied && databaseOk) {
state = "ready";
}
const ok = state === "ready" || state === "maintenance";
return {
ok,
state,
service: "portail-associations",
environment: process.env.NODE_ENV || "development",
timestamp: new Date().toISOString(),
uptimeMs,
maintenance,
checks: {
startupDelaySatisfied,
databaseConfigured: isDatabaseConfigured(),
databaseOk,
ready,
shuttingDown,
},
};
}
export async function ensureMaintenanceFlagDirectory() {
const filePath = resolveMaintenanceFlagPath();
await fs.mkdir(path.dirname(filePath), { recursive: true });
return filePath;
}

View file

@ -0,0 +1,29 @@
import { z } from "zod";
import { notifyOwner } from "./notification";
import { adminProcedure, publicProcedure, router } from "./trpc";
export const systemRouter = router({
health: publicProcedure
.input(
z.object({
timestamp: z.number().min(0, "timestamp cannot be negative"),
})
)
.query(() => ({
ok: true,
})),
notifyOwner: adminProcedure
.input(
z.object({
title: z.string().min(1, "title is required"),
content: z.string().min(1, "content is required"),
})
)
.mutation(async ({ input }) => {
const delivered = await notifyOwner(input);
return {
success: delivered,
} as const;
}),
});

45
server/_core/trpc.ts Normal file
View file

@ -0,0 +1,45 @@
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { TrpcContext } from "./context";
const t = initTRPC.context<TrpcContext>().create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(requireUser);
export const adminProcedure = t.procedure.use(
t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user || ctx.user.role !== 'admin') {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
});
}),
);

6
server/_core/types/cookie.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
declare module "cookie" {
export function parse(
str: string,
options?: Record<string, unknown>
): Record<string, string>;
}

88
server/_core/vite.ts Normal file
View file

@ -0,0 +1,88 @@
import express, { type Express } from "express";
import fs from "fs";
import { type Server } from "http";
import { nanoid } from "nanoid";
import path from "path";
import { createServer as createViteServer } from "vite";
import viteConfig from "../../vite.config";
export async function setupVite(app: Express, server: Server) {
const serverOptions = {
middlewareMode: true,
hmr: { server },
allowedHosts: true as const,
};
const vite = await createViteServer({
...viteConfig,
configFile: false,
server: serverOptions,
appType: "custom",
});
app.use(vite.middlewares);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
try {
const clientTemplate = path.resolve(
import.meta.dirname,
"../..",
"client",
"index.html"
);
// always reload the index.html file from disk incase it changes
let template = await fs.promises.readFile(clientTemplate, "utf-8");
template = template.replace(
`src="/src/main.tsx"`,
`src="/src/main.tsx?v=${nanoid()}"`
);
const page = await vite.transformIndexHtml(url, template);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
}
export function serveStatic(app: Express) {
const distPath =
process.env.NODE_ENV === "development"
? path.resolve(import.meta.dirname, "../..", "dist", "public")
: path.resolve(import.meta.dirname, "public");
if (!fs.existsSync(distPath)) {
console.error(
`Could not find the build directory: ${distPath}, make sure to build the client first`
);
}
app.use(express.static(distPath, {
index: false,
setHeaders(res, filePath) {
if (filePath.endsWith("index.html")) {
res.setHeader("Cache-Control", "no-store");
return;
}
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
}
},
}));
// fall through to index.html if the file doesn't exist
app.use("*", (req, res) => {
const requestPath = req.path;
const hasFileExtension = path.extname(requestPath).length > 0;
if (requestPath.startsWith("/assets/") || hasFileExtension) {
res.status(404).type("text/plain").send("Not found");
return;
}
res.setHeader("Cache-Control", "no-store");
res.sendFile(path.resolve(distPath, "index.html"));
});
}

View file

@ -0,0 +1,284 @@
/**
* Voice transcription helper using internal Speech-to-Text service
*
* Frontend implementation guide:
* 1. Capture audio using MediaRecorder API
* 2. Upload audio to storage (e.g., S3) to get URL
* 3. Call transcription with the URL
*
* Example usage:
* ```tsx
* // Frontend component
* const transcribeMutation = trpc.voice.transcribe.useMutation({
* onSuccess: (data) => {
* console.log(data.text); // Full transcription
* console.log(data.language); // Detected language
* console.log(data.segments); // Timestamped segments
* }
* });
*
* // After uploading audio to storage
* transcribeMutation.mutate({
* audioUrl: uploadedAudioUrl,
* language: 'en', // optional
* prompt: 'Transcribe the meeting' // optional
* });
* ```
*/
import { ENV } from "./env";
export type TranscribeOptions = {
audioUrl: string; // URL to the audio file (e.g., S3 URL)
language?: string; // Optional: specify language code (e.g., "en", "es", "zh")
prompt?: string; // Optional: custom prompt for the transcription
};
// Native Whisper API segment format
export type WhisperSegment = {
id: number;
seek: number;
start: number;
end: number;
text: string;
tokens: number[];
temperature: number;
avg_logprob: number;
compression_ratio: number;
no_speech_prob: number;
};
// Native Whisper API response format
export type WhisperResponse = {
task: "transcribe";
language: string;
duration: number;
text: string;
segments: WhisperSegment[];
};
export type TranscriptionResponse = WhisperResponse; // Return native Whisper API response directly
export type TranscriptionError = {
error: string;
code: "FILE_TOO_LARGE" | "INVALID_FORMAT" | "TRANSCRIPTION_FAILED" | "UPLOAD_FAILED" | "SERVICE_ERROR";
details?: string;
};
/**
* Transcribe audio to text using the internal Speech-to-Text service
*
* @param options - Audio data and metadata
* @returns Transcription result or error
*/
export async function transcribeAudio(
options: TranscribeOptions
): Promise<TranscriptionResponse | TranscriptionError> {
try {
// Step 1: Validate environment configuration
if (!ENV.forgeApiUrl) {
return {
error: "Voice transcription service is not configured",
code: "SERVICE_ERROR",
details: "BUILT_IN_FORGE_API_URL is not set"
};
}
if (!ENV.forgeApiKey) {
return {
error: "Voice transcription service authentication is missing",
code: "SERVICE_ERROR",
details: "BUILT_IN_FORGE_API_KEY is not set"
};
}
// Step 2: Download audio from URL
let audioBuffer: Buffer;
let mimeType: string;
try {
const response = await fetch(options.audioUrl);
if (!response.ok) {
return {
error: "Failed to download audio file",
code: "INVALID_FORMAT",
details: `HTTP ${response.status}: ${response.statusText}`
};
}
audioBuffer = Buffer.from(await response.arrayBuffer());
mimeType = response.headers.get('content-type') || 'audio/mpeg';
// Check file size (16MB limit)
const sizeMB = audioBuffer.length / (1024 * 1024);
if (sizeMB > 16) {
return {
error: "Audio file exceeds maximum size limit",
code: "FILE_TOO_LARGE",
details: `File size is ${sizeMB.toFixed(2)}MB, maximum allowed is 16MB`
};
}
} catch (error) {
return {
error: "Failed to fetch audio file",
code: "SERVICE_ERROR",
details: error instanceof Error ? error.message : "Unknown error"
};
}
// Step 3: Create FormData for multipart upload to Whisper API
const formData = new FormData();
// Create a Blob from the buffer and append to form
const filename = `audio.${getFileExtension(mimeType)}`;
const audioBlob = new Blob([new Uint8Array(audioBuffer)], { type: mimeType });
formData.append("file", audioBlob, filename);
formData.append("model", "whisper-1");
formData.append("response_format", "verbose_json");
// Add prompt - use custom prompt if provided, otherwise generate based on language
const prompt = options.prompt || (
options.language
? `Transcribe the user's voice to text, the user's working language is ${getLanguageName(options.language)}`
: "Transcribe the user's voice to text"
);
formData.append("prompt", prompt);
// Step 4: Call the transcription service
const baseUrl = ENV.forgeApiUrl.endsWith("/")
? ENV.forgeApiUrl
: `${ENV.forgeApiUrl}/`;
const fullUrl = new URL(
"v1/audio/transcriptions",
baseUrl
).toString();
const response = await fetch(fullUrl, {
method: "POST",
headers: {
authorization: `Bearer ${ENV.forgeApiKey}`,
"Accept-Encoding": "identity",
},
body: formData,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
return {
error: "Transcription service request failed",
code: "TRANSCRIPTION_FAILED",
details: `${response.status} ${response.statusText}${errorText ? `: ${errorText}` : ""}`
};
}
// Step 5: Parse and return the transcription result
const whisperResponse = await response.json() as WhisperResponse;
// Validate response structure
if (!whisperResponse.text || typeof whisperResponse.text !== 'string') {
return {
error: "Invalid transcription response",
code: "SERVICE_ERROR",
details: "Transcription service returned an invalid response format"
};
}
return whisperResponse; // Return native Whisper API response directly
} catch (error) {
// Handle unexpected errors
return {
error: "Voice transcription failed",
code: "SERVICE_ERROR",
details: error instanceof Error ? error.message : "An unexpected error occurred"
};
}
}
/**
* Helper function to get file extension from MIME type
*/
function getFileExtension(mimeType: string): string {
const mimeToExt: Record<string, string> = {
'audio/webm': 'webm',
'audio/mp3': 'mp3',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/ogg': 'ogg',
'audio/m4a': 'm4a',
'audio/mp4': 'm4a',
};
return mimeToExt[mimeType] || 'audio';
}
/**
* Helper function to get full language name from ISO code
*/
function getLanguageName(langCode: string): string {
const langMap: Record<string, string> = {
'en': 'English',
'es': 'Spanish',
'fr': 'French',
'de': 'German',
'it': 'Italian',
'pt': 'Portuguese',
'ru': 'Russian',
'ja': 'Japanese',
'ko': 'Korean',
'zh': 'Chinese',
'ar': 'Arabic',
'hi': 'Hindi',
'nl': 'Dutch',
'pl': 'Polish',
'tr': 'Turkish',
'sv': 'Swedish',
'da': 'Danish',
'no': 'Norwegian',
'fi': 'Finnish',
};
return langMap[langCode] || langCode;
}
/**
* Example tRPC procedure implementation:
*
* ```ts
* // In server/routers.ts
* import { transcribeAudio } from "./_core/voiceTranscription";
*
* export const voiceRouter = router({
* transcribe: protectedProcedure
* .input(z.object({
* audioUrl: z.string(),
* language: z.string().optional(),
* prompt: z.string().optional(),
* }))
* .mutation(async ({ input, ctx }) => {
* const result = await transcribeAudio(input);
*
* // Check if it's an error
* if ('error' in result) {
* throw new TRPCError({
* code: 'BAD_REQUEST',
* message: result.error,
* cause: result,
* });
* }
*
* // Optionally save transcription to database
* await db.insert(transcriptions).values({
* userId: ctx.user.id,
* text: result.text,
* duration: result.duration,
* language: result.language,
* audioUrl: input.audioUrl,
* createdAt: new Date(),
* });
*
* return result;
* }),
* });
* ```
*/

452
server/admin.test.ts Normal file
View file

@ -0,0 +1,452 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { appRouter } from "./routers";
import type { TrpcContext } from "./_core/context";
import * as db from "./db";
// Mock the database module
vi.mock("./db", () => ({
getDashboardStats: vi.fn().mockResolvedValue({
totalAssociations: 10,
activeAssociations: 8,
totalRequests: 25,
pendingRequests: 5,
validatedRequests: 15,
rejectedRequests: 3,
newAssociationsThisMonth: 2,
newRequestsThisMonth: 8,
requestsThisWeek: 3,
overdueRequests: 1,
acceptanceRate: 83,
}),
getRequestsPerMonth: vi.fn().mockResolvedValue([
{ month: '2026-01', total: 10, validated: 5, rejected: 2, pending: 3 },
]),
getAssociationsPerMonth: vi.fn().mockResolvedValue([
{ month: '2026-01', total: 3 },
]),
getRequestsByTypeStats: vi.fn().mockResolvedValue([
{ type: 'subvention_fonctionnement', total: 10, validated: 5, totalMontantAccorde: 50000 },
]),
getAverageProcessingTime: vi.fn().mockResolvedValue(7.5),
getPendingRequests: vi.fn().mockResolvedValue([]),
getOverdueRequests: vi.fn().mockResolvedValue([]),
getAdminUsers: vi.fn().mockResolvedValue([
{ id: 1, name: 'Admin', email: 'admin@test.com', role: 'admin', lastSignedIn: new Date() },
]),
getAuditLogs: vi.fn().mockResolvedValue({ data: [], total: 0 }),
getAdminNotifications: vi.fn().mockResolvedValue([]),
getUnreadNotificationCount: vi.fn().mockResolvedValue(0),
markNotificationAsRead: vi.fn().mockResolvedValue(undefined),
markAllNotificationsAsRead: vi.fn().mockResolvedValue(undefined),
searchAssociations: vi.fn().mockResolvedValue({ data: [], total: 0 }),
getAssociationCommuneCounts: vi.fn().mockResolvedValue({
all: 5,
kourou: 2,
sinnamary: 1,
iracoubo: 1,
saint_elie: 1,
}),
searchRequests: vi.fn().mockResolvedValue({ data: [], total: 0 }),
getAllPortalSettings: vi.fn().mockResolvedValue([]),
getPortalSetting: vi.fn().mockResolvedValue(null),
getPortalSettingRecord: vi.fn().mockResolvedValue(null),
setPortalSetting: vi.fn().mockResolvedValue(undefined),
getAssociationDirectoryEntriesSummary: vi.fn().mockResolvedValue({ total: 5, withEmail: 4, withoutEmail: 1, registered: 2, unregistered: 3, lastImportAt: null }),
listAssociationDirectoryEntriesWithStatus: vi.fn().mockResolvedValue({ data: [], total: 0 }),
listAssociationDirectoryMapEntries: vi.fn().mockResolvedValue({ data: [], total: 0 }),
getAssociationInvitationSummariesForDirectoryEntryIds: vi.fn().mockResolvedValue({}),
listAssociationDirectoryEntries: vi.fn().mockResolvedValue([]),
listAssociationDirectoryReviews: vi.fn().mockResolvedValue([]),
findAssociationDirectoryMatch: vi.fn().mockResolvedValue({ status: "none", candidates: [], reason: "no match" }),
upsertAssociationDirectoryEntry: vi.fn().mockResolvedValue("created"),
createAssociationDirectoryEntry: vi.fn().mockResolvedValue(1),
updateAssociationDirectoryEntry: vi.fn().mockResolvedValue(undefined),
getAssociationDirectoryEntryById: vi.fn().mockResolvedValue(null),
getAssociationDirectoryEntryDetails: vi.fn().mockResolvedValue(null),
createAssociationDirectoryReview: vi.fn().mockResolvedValue(1),
getAssociationDirectoryReviewById: vi.fn().mockResolvedValue(null),
getPendingAssociationDirectoryReviewByUserId: vi.fn().mockResolvedValue(null),
updateAssociationDirectoryReview: vi.fn().mockResolvedValue(undefined),
getAssociationBySourceDirectoryEntryId: vi.fn().mockResolvedValue(null),
createAssociationInvitation: vi.fn().mockResolvedValue(1),
getAssociationInvitationByToken: vi.fn().mockResolvedValue(null),
getActiveAssociationInvitationByDirectoryEntryId: vi.fn().mockResolvedValue(null),
getLatestAssociationInvitationByDirectoryEntryId: vi.fn().mockResolvedValue(null),
revokeAssociationInvitationsByDirectoryEntryId: vi.fn().mockResolvedValue(undefined),
markAssociationInvitationUsed: vi.fn().mockResolvedValue(undefined),
createAuditLog: vi.fn().mockResolvedValue(1),
getMaterialReturnFollowupByRequestId: vi.fn().mockResolvedValue(null),
}));
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createAdminContext(): { ctx: TrpcContext } {
const user: AuthenticatedUser = {
id: 1,
openId: "admin-user",
email: "admin@example.com",
name: "Admin User",
loginMethod: "manus",
role: "admin",
canManageLogistics: false,
canSignSalle: false,
delegatedSalleSignerUserId: null,
salleSignatureDelegatedByUserIds: [],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
};
const ctx: TrpcContext = {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: vi.fn(),
} as unknown as TrpcContext["res"],
};
return { ctx };
}
function createSuperAdminContext(): { ctx: TrpcContext } {
const user: AuthenticatedUser = {
id: 3,
openId: "super-admin-user",
email: "superadmin@example.com",
name: "Super Admin User",
loginMethod: "manus",
role: "super_admin",
canManageLogistics: true,
canSignSalle: true,
delegatedSalleSignerUserId: null,
salleSignatureDelegatedByUserIds: [],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
};
const ctx: TrpcContext = {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: vi.fn(),
} as unknown as TrpcContext["res"],
};
return { ctx };
}
function createUserContext(): { ctx: TrpcContext } {
const user: AuthenticatedUser = {
id: 2,
openId: "regular-user",
email: "user@example.com",
name: "Regular User",
loginMethod: "manus",
role: "user",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
};
const ctx: TrpcContext = {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: vi.fn(),
} as unknown as TrpcContext["res"],
};
return { ctx };
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("Admin Dashboard Stats", () => {
it("returns dashboard statistics for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.stats.getDashboard();
expect(result).toBeDefined();
expect(result.totalAssociations).toBe(10);
expect(result.activeAssociations).toBe(8);
expect(result.totalRequests).toBe(25);
expect(result.pendingRequests).toBe(5);
expect(result.acceptanceRate).toBe(83);
});
it("denies access to non-admin users", async () => {
const { ctx } = createUserContext();
const caller = appRouter.createCaller(ctx);
await expect(caller.stats.getDashboard()).rejects.toThrow(
"Accès réservé à laccueil, aux administrateurs et aux super administrateurs",
);
});
});
describe("Admin Request Statistics", () => {
it("returns requests per month for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.stats.getRequestsPerMonth({ months: 12 });
expect(Array.isArray(result)).toBe(true);
});
it("returns requests by type for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.stats.getRequestsByType();
expect(Array.isArray(result)).toBe(true);
});
it("returns average processing time for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.stats.getAverageProcessingTime();
expect(result).toBe(7.5);
});
});
describe("Admin Notifications", () => {
it("returns notifications for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.notifications.getAll({ unreadOnly: false });
expect(Array.isArray(result)).toBe(true);
});
it("returns unread count for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.notifications.getUnreadCount();
expect(typeof result).toBe("number");
});
it("marks notification as read", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.notifications.markAsRead({ id: 1 });
expect(result.success).toBe(true);
});
it("marks all notifications as read", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.notifications.markAllAsRead();
expect(result.success).toBe(true);
});
});
describe("Admin User Management", () => {
it("lists admin users for super admin", async () => {
const { ctx } = createSuperAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.adminUsers.listAll();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
expect(result[0].role).toBe("admin");
});
it("denies admin user listing to regular admins", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
await expect(caller.adminUsers.listAll()).rejects.toThrow("Accès réservé aux super administrateurs");
});
});
describe("Audit Log", () => {
it("returns audit logs for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.auditLog.list({ limit: 10 });
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(Array.isArray(result.data)).toBe(true);
});
});
describe("Association Directory Admin", () => {
it("returns directory summary for admin", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.associationDirectory.getSummary();
expect(result.total).toBe(5);
expect(result.withEmail).toBe(4);
expect(result.unregistered).toBe(3);
});
it("returns directory entries with registration filters for admin", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.associationDirectory.listLatest({
registrationStatus: "unregistered",
commune: "kourou",
limit: 25,
});
expect(result).toEqual({ data: [], total: 0 });
});
it("denies directory summary to regular users", async () => {
const { ctx } = createUserContext();
const caller = appRouter.createCaller(ctx);
await expect(caller.associationDirectory.getSummary()).rejects.toThrow("Accès réservé aux administrateurs");
});
});
describe("Association Search", () => {
it("searches associations for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.association.search({
search: "test",
limit: 10,
});
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(typeof result.total).toBe("number");
});
it("returns commune counts for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.association.getCommuneCounts();
expect(result.kourou).toBe(2);
expect(result.all).toBe(5);
});
});
describe("Request Search", () => {
it("searches requests for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.request.search({
search: "test",
status: "soumise",
limit: 10,
});
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(typeof result.total).toBe("number");
});
it("returns pending requests for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.request.getPending();
expect(Array.isArray(result)).toBe(true);
});
it("returns overdue requests for admin users", async () => {
const { ctx } = createAdminContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.request.getOverdue();
expect(Array.isArray(result)).toBe(true);
});
});
describe("Material Availability", () => {
it("counts submitted and in-progress material requests before validation", async () => {
vi.mocked(db.searchRequests).mockResolvedValue({
data: [
{
id: 101,
type: "demande_materiel_evenementiel",
status: "soumise",
formData: JSON.stringify({
dateDebutManifestation: "2026-06-10",
dateRestitution: "2026-06-12",
materielsDemandes: { tente3x3: true },
quantitesDemandees: { tente3x3: "4" },
}),
},
{
id: 102,
type: "demande_materiel_evenementiel",
status: "en_cours_traitement",
formData: JSON.stringify({
dateDebutManifestation: "2026-06-10",
dateRestitution: "2026-06-12",
materielsDemandes: { tente3x3: true },
quantitesDemandees: { tente3x3: "3" },
}),
},
{
id: 103,
type: "demande_materiel_evenementiel",
status: "brouillon",
formData: JSON.stringify({
dateDebutManifestation: "2026-06-10",
dateRestitution: "2026-06-12",
materielsDemandes: { tente3x3: true },
quantitesDemandees: { tente3x3: "2" },
}),
},
] as any,
total: 3,
});
vi.mocked(db.getMaterialReturnFollowupByRequestId).mockResolvedValue(null as any);
const { ctx } = createUserContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.request.getMaterialAvailability({
dateDebut: "2026-06-10",
dateFin: "2026-06-12",
});
const tente = result.items.find((item) => item.key === "tente3x3");
expect(tente?.reserved).toBe(7);
expect(tente?.available).toBe(5);
});
});

510
server/association.test.ts Normal file
View file

@ -0,0 +1,510 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { appRouter } from "./routers";
import { buildDataPrivacyConsent } from "../shared/privacyCompliance";
import type { TrpcContext } from "./_core/context";
// Mock the database functions with all required exports
vi.mock("./db", () => ({
getDb: vi.fn(() => Promise.resolve({})),
upsertUser: vi.fn(),
getUserByEmail: vi.fn(() => Promise.resolve(undefined)),
getUserByOpenId: vi.fn(),
getUserById: vi.fn(),
getAllUsers: vi.fn(() => Promise.resolve([])),
getAdminUsers: vi.fn(() => Promise.resolve([])),
updateUser: vi.fn(),
// Association functions
getAssociationByUserId: vi.fn(() => Promise.resolve(null)),
getAssociationById: vi.fn(() => Promise.resolve(null)),
getAssociationBySourceDirectoryEntryId: vi.fn(() => Promise.resolve(null)),
createAssociation: vi.fn(() => Promise.resolve(1)),
updateAssociation: vi.fn(() => Promise.resolve()),
getAllAssociations: vi.fn(() => Promise.resolve([])),
searchAssociations: vi.fn(() => Promise.resolve({ data: [], total: 0 })),
getAssociationCommuneCounts: vi.fn(() => Promise.resolve({
all: 0,
kourou: 0,
sinnamary: 0,
iracoubo: 0,
saint_elie: 0,
})),
toggleAssociationStatus: vi.fn(() => Promise.resolve()),
getAssociationDirectoryEntryByNormalizedEmail: vi.fn(() => Promise.resolve(null)),
getAssociationDirectoryEntryById: vi.fn(() => Promise.resolve(null)),
getAssociationDirectoryEntriesSummary: vi.fn(() => Promise.resolve({ total: 0, withEmail: 0, withoutEmail: 0, lastImportAt: null })),
listAssociationDirectoryEntriesWithStatus: vi.fn(() => Promise.resolve({ data: [], total: 0 })),
listAssociationDirectoryMapEntries: vi.fn(() => Promise.resolve({ data: [], total: 0 })),
getAssociationInvitationSummariesForDirectoryEntryIds: vi.fn(() => Promise.resolve({})),
listAssociationDirectoryEntries: vi.fn(() => Promise.resolve([])),
listAssociationDirectoryReviews: vi.fn(() => Promise.resolve([])),
findAssociationDirectoryMatch: vi.fn(() => Promise.resolve({ status: "none", candidates: [], reason: "no match" })),
upsertAssociationDirectoryEntry: vi.fn(() => Promise.resolve("created")),
createAssociationDirectoryEntry: vi.fn(() => Promise.resolve(1)),
updateAssociationDirectoryEntry: vi.fn(() => Promise.resolve()),
getAssociationDirectoryEntryDetails: vi.fn(() => Promise.resolve(null)),
createAssociationDirectoryReview: vi.fn(() => Promise.resolve(1)),
getAssociationDirectoryReviewById: vi.fn(() => Promise.resolve(null)),
getPendingAssociationDirectoryReviewByUserId: vi.fn(() => Promise.resolve(null)),
updateAssociationDirectoryReview: vi.fn(() => Promise.resolve()),
createAssociationInvitation: vi.fn(() => Promise.resolve(1)),
getAssociationInvitationByToken: vi.fn(() => Promise.resolve(null)),
getActiveAssociationInvitationByDirectoryEntryId: vi.fn(() => Promise.resolve(null)),
getLatestAssociationInvitationByDirectoryEntryId: vi.fn(() => Promise.resolve(null)),
revokeAssociationInvitationsByDirectoryEntryId: vi.fn(() => Promise.resolve()),
markAssociationInvitationUsed: vi.fn(() => Promise.resolve()),
// Document functions
getDocumentsByAssociationId: vi.fn(() => Promise.resolve([])),
getDocumentById: vi.fn(() => Promise.resolve(null)),
createDocument: vi.fn(() => Promise.resolve(1)),
deleteDocument: vi.fn(() => Promise.resolve()),
// Request functions
getRequestsByAssociationId: vi.fn(() => Promise.resolve([])),
getRequestById: vi.fn(() => Promise.resolve(null)),
createRequest: vi.fn(() => Promise.resolve(1)),
updateRequest: vi.fn(() => Promise.resolve()),
getAllRequests: vi.fn(() => Promise.resolve([])),
getRequestsByStatus: vi.fn(() => Promise.resolve([])),
searchRequests: vi.fn(() => Promise.resolve({ data: [], total: 0 })),
getPendingRequests: vi.fn(() => Promise.resolve([])),
getOverdueRequests: vi.fn(() => Promise.resolve([])),
assignRequest: vi.fn(() => Promise.resolve()),
// Request history
createRequestHistory: vi.fn(() => Promise.resolve(1)),
getRequestHistoryByRequestId: vi.fn(() => Promise.resolve([])),
// Templates
getAllRequestTemplates: vi.fn(() => Promise.resolve([])),
getRequestTemplateById: vi.fn(() => Promise.resolve(null)),
getRequestTemplateByType: vi.fn(() => Promise.resolve(null)),
createRequestTemplate: vi.fn(() => Promise.resolve(1)),
updateRequestTemplate: vi.fn(() => Promise.resolve()),
getAllResponseTemplates: vi.fn(() => Promise.resolve([])),
getResponseTemplateById: vi.fn(() => Promise.resolve(null)),
createResponseTemplate: vi.fn(() => Promise.resolve(1)),
updateResponseTemplate: vi.fn(() => Promise.resolve()),
deleteResponseTemplate: vi.fn(() => Promise.resolve()),
// Audit & Notifications
createAuditLog: vi.fn(() => Promise.resolve(1)),
getAuditLogs: vi.fn(() => Promise.resolve({ data: [], total: 0 })),
createAdminNotification: vi.fn(() => Promise.resolve(1)),
getAdminNotifications: vi.fn(() => Promise.resolve([])),
markNotificationAsRead: vi.fn(() => Promise.resolve()),
markAllNotificationsAsRead: vi.fn(() => Promise.resolve()),
getUnreadNotificationCount: vi.fn(() => Promise.resolve(0)),
// Settings
getPortalSetting: vi.fn(() => Promise.resolve(null)),
setPortalSetting: vi.fn(() => Promise.resolve()),
getAllPortalSettings: vi.fn(() => Promise.resolve([])),
// Stats
getDashboardStats: vi.fn(() => Promise.resolve({
totalAssociations: 0,
activeAssociations: 0,
totalRequests: 0,
pendingRequests: 0,
validatedRequests: 0,
rejectedRequests: 0,
newAssociationsThisMonth: 0,
newRequestsThisMonth: 0,
requestsThisWeek: 0,
overdueRequests: 0,
acceptanceRate: 0,
})),
getRequestsPerMonth: vi.fn(() => Promise.resolve([])),
getAssociationsPerMonth: vi.fn(() => Promise.resolve([])),
getRequestsByTypeStats: vi.fn(() => Promise.resolve([])),
getAverageProcessingTime: vi.fn(() => Promise.resolve(null)),
}));
// Mock storage
vi.mock("./storage", () => ({
storagePut: vi.fn(() => Promise.resolve({ url: "https://example.com/file.pdf", key: "test-key" })),
storageGet: vi.fn(() => Promise.resolve({ url: "https://example.com/file.pdf", key: "test-key" })),
}));
// Mock notification
vi.mock("./_core/notification", () => ({
notifyOwner: vi.fn(() => Promise.resolve(true)),
}));
vi.mock("./mailer", () => ({
canSendOperationalEmails: vi.fn(() => true),
sendOperationalEmail: vi.fn(() => Promise.resolve({ sent: true })),
}));
vi.mock("./_core/auth", () => ({
clearSessionCookie: vi.fn(),
createSessionToken: vi.fn(() => Promise.resolve("session-token")),
loginLocalUser: vi.fn(),
registerLocalUser: vi.fn((input) => Promise.resolve({
id: 99,
openId: input.email,
email: input.email,
name: input.name,
loginMethod: "local_jwt",
role: "user",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
})),
setSessionCookie: vi.fn(),
}));
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createAuthContext(role: "user" | "admin" | "directrice" | "super_admin" = "user"): TrpcContext {
const user: AuthenticatedUser = {
id: 1,
openId: "test-user-123",
email: "test@example.com",
name: "Test User",
loginMethod: "manus",
role,
canManageLogistics: false,
canSignSalle: role === "directrice" || role === "super_admin",
delegatedSalleSignerUserId: null,
salleSignatureDelegatedByUserIds: [],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
};
return {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: vi.fn(),
} as unknown as TrpcContext["res"],
};
}
function createUnauthContext(): TrpcContext {
return {
user: null,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: vi.fn(),
} as unknown as TrpcContext["res"],
};
}
describe("association router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("getMyProfile returns null when no association exists", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.association.getMyProfile();
expect(result).toBeNull();
});
it("exposes a protected portal directory listing", async () => {
const db = await import("./db");
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
(db.listAssociationDirectoryEntriesWithStatus as any).mockResolvedValue({
total: 1,
data: [
{
id: 12,
nomAssociation: "Association des Savanes",
emailOfficiel: "contact@example.com",
ville: "Kourou",
importedAt: new Date(),
registered: false,
registeredAt: null,
invitationStatus: undefined,
},
],
});
const result = await caller.associationDirectory.listPortal({
commune: "kourou",
registrationStatus: "unregistered",
limit: 20,
});
expect(result.total).toBe(1);
expect(result.data[0]?.nomAssociation).toBe("Association des Savanes");
});
it("returns a public secure invitation when the token is still valid", async () => {
const db = await import("./db");
const caller = appRouter.createCaller(createUnauthContext());
(db.getAssociationInvitationByToken as any).mockResolvedValue({
token: "invite-token",
directoryEntryId: 42,
emailOfficiel: "contact@example.com",
emailOfficielNormalise: "contact@example.com",
sentByUserId: 1,
deliveryMode: "email",
emailSent: true,
sentAt: new Date(),
expiresAt: new Date(Date.now() + 60_000),
usedAt: null,
revokedAt: null,
acceptedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
});
(db.getAssociationDirectoryEntryById as any).mockResolvedValue({
id: 42,
nomAssociation: "Association des Savanes",
emailOfficiel: "contact@example.com",
nomRepresentant: "Mme Test",
ville: "Kourou",
});
const result = await caller.associationInvitation.getPublic({ token: "invite-token" });
expect(result.valid).toBe(true);
expect(result.association.nomAssociation).toBe("Association des Savanes");
});
it("registers a user from a valid invitation and marks it as used", async () => {
const db = await import("./db");
const ctx = createUnauthContext();
const caller = appRouter.createCaller(ctx);
(db.getAssociationInvitationByToken as any).mockResolvedValue({
token: "invite-token",
directoryEntryId: 42,
emailOfficiel: "contact@example.com",
emailOfficielNormalise: "contact@example.com",
sentByUserId: 1,
deliveryMode: "email",
emailSent: true,
sentAt: new Date(),
expiresAt: new Date(Date.now() + 60_000),
usedAt: null,
revokedAt: null,
acceptedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
});
(db.getAssociationDirectoryEntryById as any).mockResolvedValue({
id: 42,
nomAssociation: "Association Importée",
emailOfficiel: "contact@example.com",
emailOfficielNormalise: "contact@example.com",
siret: "12345678901234",
rna: "W123456789",
adresse: "1 rue des Savanes",
codePostal: "97310",
ville: "Kourou",
telephone: "0594000000",
siteWeb: null,
dateCreation: null,
objetAssociation: "Culture",
statutJuridique: "association_loi_1901",
nomRepresentant: "Mme Test",
fonctionRepresentant: "Présidente",
sourceFileName: "bordereau.xlsx",
sourceRowNumber: 2,
sourceFingerprint: "abc",
isActive: true,
importedAt: new Date(),
updatedAt: new Date(),
});
(db.getAssociationBySourceDirectoryEntryId as any).mockResolvedValue(null);
(db.getAssociationByUserId as any).mockResolvedValue(null);
const result = await caller.auth.register({
name: "Mme Test",
email: "contact@example.com",
password: "motdepasse",
thematique: "culture_loisirs",
invitationToken: "invite-token",
privacyConsent: buildDataPrivacyConsent("register_account"),
});
expect(result.email).toBe("contact@example.com");
expect(db.createAssociation).toHaveBeenCalled();
expect(db.markAssociationInvitationUsed).toHaveBeenCalledWith("invite-token", 99);
});
it("getMyProfile auto-creates profile from imported directory when email matches", async () => {
const db = await import("./db");
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
(db.getAssociationDirectoryEntryByNormalizedEmail as any).mockResolvedValue({
id: 42,
nomAssociation: "Association Importée",
emailOfficiel: "test@example.com",
emailOfficielNormalise: "test@example.com",
siret: "12345678901234",
rna: "W123456789",
adresse: "1 rue des Savanes",
codePostal: "97310",
ville: "Kourou",
telephone: "0594000000",
siteWeb: null,
dateCreation: null,
objetAssociation: "Culture",
statutJuridique: "association_loi_1901",
nomRepresentant: "Mme Test",
fonctionRepresentant: "Présidente",
sourceFileName: "bordereau.xlsx",
sourceRowNumber: 2,
sourceFingerprint: "abc",
isActive: true,
importedAt: new Date(),
updatedAt: new Date(),
});
(db.getAssociationById as any).mockResolvedValue({
id: 1,
userId: 1,
sourceDirectoryEntryId: 42,
nomAssociation: "Association Importée",
siret: "12345678901234",
rna: "W123456789",
adresse: "1 rue des Savanes",
codePostal: "97310",
ville: "Kourou",
telephone: "0594000000",
emailContact: "test@example.com",
siteWeb: null,
dateCreation: null,
objetAssociation: "Culture",
statutJuridique: "association_loi_1901",
nomRepresentant: "Mme Test",
fonctionRepresentant: "Présidente",
profileComplete: true,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await caller.association.getMyProfile();
expect(db.createAssociation).toHaveBeenCalled();
expect(result?.sourceDirectoryEntryId).toBe(42);
expect(result?.nomAssociation).toBe("Association Importée");
});
it("upsertProfile requires authentication", async () => {
const ctx = createUnauthContext();
const caller = appRouter.createCaller(ctx);
await expect(
caller.association.upsertProfile({
nomAssociation: "Test Association",
})
).rejects.toThrow();
});
it("upsertProfile creates association for authenticated user", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.association.upsertProfile({
nomAssociation: "Test Association",
siret: "12345678901234",
adresse: "123 rue Test",
codePostal: "75001",
ville: "Paris",
gouvernance: {
representantLegal: {
prenom: "Jean",
nom: "Dupont",
fonction: "president",
email: "",
telephone: "",
},
membres: [],
},
privacyConsent: buildDataPrivacyConsent("save_profile"),
});
expect(result).toBeDefined();
expect(result).toHaveProperty('id');
expect(result.updated).toBe(false);
});
});
describe("document router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("getMyDocuments requires authentication", async () => {
const ctx = createUnauthContext();
const caller = appRouter.createCaller(ctx);
await expect(caller.document.getMyDocuments()).rejects.toThrow();
});
it("getMyDocuments returns empty array when no documents exist", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.document.getMyDocuments();
expect(result).toEqual([]);
});
});
describe("request router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("getMyRequests requires authentication", async () => {
const ctx = createUnauthContext();
const caller = appRouter.createCaller(ctx);
await expect(caller.request.getMyRequests()).rejects.toThrow();
});
it("getMyRequests returns empty array when no requests exist", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.request.getMyRequests();
expect(result).toEqual([]);
});
});
describe("stats router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("getDashboard requires admin role", async () => {
const ctx = createAuthContext("user");
const caller = appRouter.createCaller(ctx);
await expect(caller.stats.getDashboard()).rejects.toThrow();
});
it("getDashboard returns stats for admin", async () => {
const ctx = createAuthContext("admin");
const caller = appRouter.createCaller(ctx);
const result = await caller.stats.getDashboard();
expect(result).toBeDefined();
expect(result.totalAssociations).toBe(0);
expect(result.totalRequests).toBe(0);
});
});

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { getAssociationCommuneLabel, getAssociationCommuneVariants, normalizeAssociationCommune } from "@shared/associationCommunes";
describe("association communes helpers", () => {
it("returns expected variants for saint-elie", () => {
const variants = getAssociationCommuneVariants("saint_elie");
expect(variants).toContain("Saint-Élie");
expect(variants).toContain("Saint Elie");
expect(variants).toContain("ST ELIE");
});
it("returns no variants for all", () => {
expect(getAssociationCommuneVariants("all")).toEqual([]);
});
it("returns labels for filters", () => {
expect(getAssociationCommuneLabel("kourou")).toBe("Kourou");
expect(getAssociationCommuneLabel("saint_elie")).toBe("Saint-Élie");
});
it("normalizes commune variants", () => {
expect(normalizeAssociationCommune("ST ELIE")).toBe("saint_elie");
expect(normalizeAssociationCommune("Saint-Elie")).toBe("saint_elie");
expect(normalizeAssociationCommune("Kourou")).toBe("kourou");
});
});

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import * as XLSX from "xlsx";
import { parseAssociationDirectoryWorkbook, resolveAssociationDirectoryImportRows } from "./associationDirectory";
function createWorkbookBuffer(rows: unknown[][]) {
const worksheet = XLSX.utils.aoa_to_sheet(rows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Associations");
return XLSX.write(workbook, { type: "buffer", bookType: "xlsx" });
}
describe("association directory import parser", () => {
it("parses valid workbook rows", () => {
const buffer = createWorkbookBuffer([
["Nom association", "Email officiel", "Ville"],
["Association A", "asso-a@example.com", "Kourou"],
["Association B", "asso-b@example.com", "Sinnamary"],
]);
const result = parseAssociationDirectoryWorkbook(buffer, "bordereau.xlsx");
expect(result.totalRows).toBe(2);
expect(result.validRows).toBe(2);
expect(result.missingEmailRows).toBe(0);
expect(result.duplicateEmailRows).toBe(0);
});
it("flags rows without email", () => {
const buffer = createWorkbookBuffer([
["Nom association", "Email officiel", "Ville"],
["Association A", "", "Kourou"],
]);
const result = parseAssociationDirectoryWorkbook(buffer, "bordereau.xlsx");
expect(result.totalRows).toBe(1);
expect(result.validRows).toBe(0);
expect(result.missingEmailRows).toBe(1);
});
it("flags duplicate emails in the same workbook", () => {
const buffer = createWorkbookBuffer([
["Nom association", "Email officiel", "Ville"],
["Association A", "same@example.com", "Kourou"],
["Association B", "same@example.com", "Macouria"],
]);
const result = parseAssociationDirectoryWorkbook(buffer, "bordereau.xlsx");
expect(result.validRows).toBe(0);
expect(result.duplicateEmailRows).toBe(2);
expect(result.duplicateGroups).toHaveLength(1);
expect(result.duplicateGroups[0]?.options).toHaveLength(2);
});
it("can resolve one row per duplicate email group", () => {
const buffer = createWorkbookBuffer([
["Nom association", "Email officiel", "Ville"],
["Association A", "same@example.com", "Kourou"],
["Association B", "same@example.com", "Macouria"],
["Association C", "", "Sinnamary"],
]);
const result = parseAssociationDirectoryWorkbook(buffer, "bordereau.xlsx");
const resolvedRows = resolveAssociationDirectoryImportRows(result, {
"same@example.com": 2,
});
expect(resolvedRows).toHaveLength(2);
expect(resolvedRows.some(row => row.nomAssociation === "Association A")).toBe(true);
expect(resolvedRows.some(row => row.nomAssociation === "Association C")).toBe(true);
});
});

View file

@ -0,0 +1,419 @@
import { createHash } from "node:crypto";
import * as XLSX from "xlsx";
import type { AssociationDirectoryEntry, InsertAssociation } from "../drizzle/schema";
import { TRPCError } from "@trpc/server";
import { normalizeLegalRepresentativeRole, serializeAssociationGovernance } from "@shared/associationGovernance";
type ParsedDirectoryRow = {
sheetName: string;
rowNumber: number;
nomAssociation: string;
emailOfficiel: string | null;
emailOfficielNormalise: string | null;
siret: string | null;
rna: string | null;
adresse: string | null;
codePostal: string | null;
ville: string | null;
telephone: string | null;
siteWeb: string | null;
facebookUrl: string | null;
instagramUrl: string | null;
dateCreation: Date | null;
objetAssociation: string | null;
statutJuridique: "association_loi_1901" | "association_reconnue_utilite_publique" | "fondation" | "autre";
nomRepresentant: string | null;
fonctionRepresentant: string | null;
sourceFingerprint: string;
};
type PreviewRow = {
sheetName: string;
rowNumber: number;
nomAssociation: string;
emailOfficiel: string | null;
ville: string | null;
status: "valid" | "missing_email" | "duplicate_email";
message: string;
};
export type DuplicateEmailGroup = {
emailOfficielNormalise: string;
emailOfficiel: string;
rowNumbers: number[];
options: Array<{
sheetName: string;
rowNumber: number;
nomAssociation: string;
ville: string | null;
}>;
};
export type DirectoryPreviewResult = {
fileName: string;
totalRows: number;
validRows: number;
missingEmailRows: number;
duplicateEmailRows: number;
previewRows: PreviewRow[];
duplicateGroups: DuplicateEmailGroup[];
allRows: ParsedDirectoryRow[];
importableRows: ParsedDirectoryRow[];
};
const headerAliases: Record<string, string[]> = {
nomAssociation: ["nom association", "association", "nom", "raison sociale", "nom de la structure"],
emailOfficiel: ["email officiel", "email", "mail", "courriel", "adresse email"],
siret: ["siret", "numéro siret", "numero siret"],
rna: ["rna", "numéro rna", "numero rna"],
adresse: ["adresse", "adresse siège", "adresse siege", "adresse du siège"],
codePostal: ["code postal", "cp"],
ville: ["ville", "commune"],
telephone: ["telephone", "téléphone", "tel", "tél", "tel.", "port.", "port"],
siteWeb: ["site web", "site", "website", "url site"],
facebookUrl: ["facebook", "facebook url", "facebook link", "lien facebook", "url facebook"],
instagramUrl: ["instagram", "instagram url", "instagram link", "lien instagram", "url instagram"],
dateCreation: ["date création", "date creation", "creation", "date de création"],
objetAssociation: ["objet", "objet association", "activité", "activités", "activite"],
statutJuridique: ["statut", "statut juridique"],
nomRepresentant: ["nom représentant", "nom representant", "président", "president", "responsable", "president"],
fonctionRepresentant: ["fonction représentant", "fonction representant", "fonction", "qualité", "qualite", "secretaire", "secrétaire"],
};
function normalizeHeader(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
.toLowerCase();
}
function cleanString(value: unknown) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text ? text : null;
}
function extractFirstEmail(value: unknown) {
const text = cleanString(value);
if (!text) return null;
const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
return match?.[0] ?? null;
}
function normalizeEmail(value: string | null) {
return value ? value.trim().toLowerCase() : null;
}
function normalizeSiret(value: string | null) {
return value ? value.replace(/\D/g, "") || null : null;
}
function normalizeRna(value: string | null) {
return value ? value.replace(/\s+/g, "").toUpperCase() : null;
}
function normalizeSiteWeb(value: string | null) {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
return `https://${value}`;
}
function normalizeTelephone(value: string | null) {
if (!value) return null;
const first = value
.split(/[\/;,]/)
.map((part) => part.trim())
.find(Boolean);
return first ? first.slice(0, 20) : null;
}
function normalizeDate(value: unknown) {
if (value === null || value === undefined || value === "") return null;
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value;
}
if (typeof value === "number") {
const parsed = XLSX.SSF.parse_date_code(value);
if (parsed) {
return new Date(Date.UTC(parsed.y, parsed.m - 1, parsed.d));
}
}
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function normalizeStatut(value: string | null): ParsedDirectoryRow["statutJuridique"] {
if (!value) return "association_loi_1901";
const normalized = normalizeHeader(value);
if (normalized.includes("utilite publique")) return "association_reconnue_utilite_publique";
if (normalized.includes("fondation")) return "fondation";
if (normalized.includes("1901") || normalized.includes("association")) return "association_loi_1901";
return "autre";
}
function computeFingerprint(row: Omit<ParsedDirectoryRow, "sourceFingerprint">) {
return createHash("sha256").update(JSON.stringify(row)).digest("hex");
}
function getCommuneFromSheetName(sheetName: string) {
const normalized = normalizeHeader(sheetName).replace(/[-_]/g, " ");
if (normalized.includes("kourou")) return "Kourou";
if (normalized.includes("sinnamary")) return "Sinnamary";
if (normalized.includes("iracoubo")) return "Iracoubo";
if (normalized.includes("st elie") || normalized.includes("saint elie")) return "Saint-Élie";
return null;
}
function resolveColumnIndex(headers: string[], field: keyof typeof headerAliases) {
const aliases = headerAliases[field];
return headers.findIndex(header => aliases.includes(normalizeHeader(header)));
}
function readCell(row: unknown[], headers: string[], field: keyof typeof headerAliases) {
const index = resolveColumnIndex(headers, field);
if (index === -1) return null;
return row[index];
}
function resolveEmailValue(row: unknown[], headers: string[]) {
const direct = extractFirstEmail(readCell(row, headers, "emailOfficiel"));
if (direct) return direct;
for (const cell of row) {
const extracted = extractFirstEmail(cell);
if (extracted) return extracted;
}
return null;
}
export function parseAssociationDirectoryWorkbook(fileBuffer: Buffer, fileName: string): DirectoryPreviewResult {
const workbook = XLSX.read(fileBuffer, { type: "buffer", cellDates: true });
if (workbook.SheetNames.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier Excel ne contient aucune feuille exploitable" });
}
const importableRows: ParsedDirectoryRow[] = [];
const previewRows: PreviewRow[] = [];
const emailRowMap = new Map<string, number[]>();
let hasAnyUsableSheet = false;
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
if (!sheet) return;
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, defval: null });
if (rows.length < 2) return;
const headers = (rows[0] || []).map(value => String(value ?? ""));
if (resolveColumnIndex(headers, "nomAssociation") === -1) return;
hasAnyUsableSheet = true;
const communeFromSheet = getCommuneFromSheetName(sheetName);
rows.slice(1).forEach((rawRow, index) => {
const rowNumber = index + 2;
const nomAssociation = cleanString(readCell(rawRow, headers, "nomAssociation"));
if (!nomAssociation) {
return;
}
const emailOfficiel = resolveEmailValue(rawRow, headers);
const emailOfficielNormalise = normalizeEmail(emailOfficiel);
const rawAdresse = cleanString(readCell(rawRow, headers, "adresse"));
const parsedRowBase = {
sheetName,
rowNumber,
nomAssociation,
emailOfficiel,
emailOfficielNormalise,
siret: normalizeSiret(cleanString(readCell(rawRow, headers, "siret"))),
rna: normalizeRna(cleanString(readCell(rawRow, headers, "rna"))),
adresse: rawAdresse && extractFirstEmail(rawAdresse) ? null : rawAdresse,
codePostal: cleanString(readCell(rawRow, headers, "codePostal")),
ville: communeFromSheet || cleanString(readCell(rawRow, headers, "ville")),
telephone: normalizeTelephone(cleanString(readCell(rawRow, headers, "telephone"))),
siteWeb: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "siteWeb"))),
facebookUrl: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "facebookUrl"))),
instagramUrl: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "instagramUrl"))),
dateCreation: normalizeDate(readCell(rawRow, headers, "dateCreation")),
objetAssociation: cleanString(readCell(rawRow, headers, "objetAssociation")),
statutJuridique: normalizeStatut(cleanString(readCell(rawRow, headers, "statutJuridique"))),
nomRepresentant: cleanString(readCell(rawRow, headers, "nomRepresentant")),
fonctionRepresentant: cleanString(readCell(rawRow, headers, "fonctionRepresentant")),
};
const parsedRow: ParsedDirectoryRow = {
...parsedRowBase,
sourceFingerprint: computeFingerprint(parsedRowBase),
};
importableRows.push(parsedRow);
if (emailOfficielNormalise) {
const refs = emailRowMap.get(emailOfficielNormalise) || [];
refs.push(rowNumber);
emailRowMap.set(emailOfficielNormalise, refs);
}
});
});
if (!hasAnyUsableSheet || importableRows.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le fichier Excel ne contient pas de feuille exploitable avec une colonne de nom d'association",
});
}
importableRows.forEach(row => {
const duplicateRows = row.emailOfficielNormalise ? emailRowMap.get(row.emailOfficielNormalise) || [] : [];
if (!row.emailOfficielNormalise) {
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "missing_email",
message: `Feuille ${row.sheetName} : email officiel manquant, la ligne ne pourra pas être rattachée automatiquement`,
});
return;
}
if (duplicateRows.length > 1) {
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "duplicate_email",
message: `Feuille ${row.sheetName} : email dupliqué dans le fichier (lignes ${duplicateRows.join(", ")})`,
});
return;
}
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "valid",
message: `Feuille ${row.sheetName} : ligne prête à être importée`,
});
});
const importableRowKeys = new Set(
previewRows
.filter(row => row.status !== "duplicate_email")
.map(row => `${row.rowNumber}::${row.nomAssociation}`)
);
const duplicateGroups: DuplicateEmailGroup[] = Array.from(emailRowMap.entries())
.filter(([, rowNumbers]) => rowNumbers.length > 1)
.map(([emailOfficielNormalise, rowNumbers]) => {
const options = importableRows
.filter(row => row.emailOfficielNormalise === emailOfficielNormalise)
.map(row => ({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
ville: row.ville,
}));
return {
emailOfficielNormalise,
emailOfficiel: options.length > 0 ? importableRows.find(row => row.emailOfficielNormalise === emailOfficielNormalise)?.emailOfficiel || emailOfficielNormalise : emailOfficielNormalise,
rowNumbers,
options,
};
});
return {
fileName,
totalRows: importableRows.length,
validRows: previewRows.filter(row => row.status === "valid").length,
missingEmailRows: previewRows.filter(row => row.status === "missing_email").length,
duplicateEmailRows: previewRows.filter(row => row.status === "duplicate_email").length,
previewRows,
duplicateGroups,
allRows: importableRows,
importableRows: importableRows.filter(row => importableRowKeys.has(`${row.rowNumber}::${row.nomAssociation}`)),
};
}
export function resolveAssociationDirectoryImportRows(
preview: DirectoryPreviewResult,
duplicateSelections?: Record<string, number>,
) {
const selectedDuplicateKeys = new Set<string>();
Object.entries(duplicateSelections || {}).forEach(([email, rowNumber]) => {
const numericRow = Number(rowNumber);
if (Number.isFinite(numericRow) && numericRow > 0) {
selectedDuplicateKeys.add(`${email}::${numericRow}`);
}
});
return preview.allRows.filter((row) => {
if (!row.emailOfficielNormalise) {
return true;
}
const isDuplicate = preview.duplicateGroups.some(group => group.emailOfficielNormalise === row.emailOfficielNormalise);
if (!isDuplicate) {
return true;
}
return selectedDuplicateKeys.has(`${row.emailOfficielNormalise}::${row.rowNumber}`);
});
}
export function createAssociationProfileFromDirectoryEntry(userId: number, entry: AssociationDirectoryEntry): InsertAssociation {
const governance = entry.nomRepresentant || entry.fonctionRepresentant
? serializeAssociationGovernance({
representantLegal: {
nom: entry.nomRepresentant || "",
prenom: "",
email: "",
telephone: "",
fonction: normalizeLegalRepresentativeRole(entry.fonctionRepresentant),
},
membres: [],
})
: null;
return {
userId,
sourceDirectoryEntryId: entry.id,
nomAssociation: entry.nomAssociation,
siret: entry.siret ?? null,
rna: entry.rna ?? null,
thematique: entry.thematique ?? null,
adresse: entry.adresse ?? null,
codePostal: entry.codePostal ?? null,
ville: entry.ville ?? null,
telephone: entry.telephone ?? null,
emailContact: entry.emailOfficiel ?? null,
siteWeb: entry.siteWeb ?? null,
facebookUrl: entry.facebookUrl ?? null,
instagramUrl: entry.instagramUrl ?? null,
dateCreation: entry.dateCreation ?? null,
objetAssociation: entry.objetAssociation ?? null,
statutJuridique: entry.statutJuridique ?? "association_loi_1901",
nomRepresentant: entry.nomRepresentant ?? null,
fonctionRepresentant: entry.fonctionRepresentant ?? null,
gouvernance: governance,
profileComplete: Boolean(entry.nomAssociation && entry.adresse && entry.ville),
isActive: true,
};
}

View file

@ -0,0 +1,254 @@
import type { AssociationDirectoryEntry } from "../drizzle/schema";
export type AssociationDirectoryMatchInput = {
nomAssociation?: string | null;
email?: string | null;
siret?: string | null;
rna?: string | null;
ville?: string | null;
telephone?: string | null;
nomRepresentant?: string | null;
};
export type AssociationDirectoryMatchCandidate = Pick<
AssociationDirectoryEntry,
"id" | "nomAssociation" | "emailOfficiel" | "siret" | "rna" | "ville" | "telephone" | "nomRepresentant"
>;
export type AssociationDirectoryMatchResult = {
status: "matched" | "ambiguous" | "none";
matchedEntry?: AssociationDirectoryMatchCandidate;
candidates: AssociationDirectoryMatchCandidate[];
reason: string;
};
function normalizeText(value?: string | null) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.toLowerCase();
}
export function normalizeDirectoryEmail(value?: string | null) {
const trimmed = String(value || "").trim().toLowerCase();
return trimmed || null;
}
export function normalizeDirectorySiret(value?: string | null) {
const digits = String(value || "").replace(/\D/g, "");
return digits || null;
}
export function normalizeDirectoryRna(value?: string | null) {
const normalized = String(value || "").replace(/\s+/g, "").trim().toUpperCase();
return normalized || null;
}
export function normalizeDirectoryPhone(value?: string | null) {
const digits = String(value || "").replace(/\D/g, "");
if (!digits) return null;
return digits.length > 9 ? digits.slice(-9) : digits;
}
function candidateScore(candidate: AssociationDirectoryMatchCandidate, input: AssociationDirectoryMatchInput) {
const localName = normalizeText(input.nomAssociation);
const localCity = normalizeText(input.ville);
const localEmail = normalizeDirectoryEmail(input.email);
const localSiret = normalizeDirectorySiret(input.siret);
const localRna = normalizeDirectoryRna(input.rna);
const localPhone = normalizeDirectoryPhone(input.telephone);
const localRepresentative = normalizeText(input.nomRepresentant);
const candidateName = normalizeText(candidate.nomAssociation);
const candidateCity = normalizeText(candidate.ville);
const candidateEmail = normalizeDirectoryEmail(candidate.emailOfficiel);
const candidateSiret = normalizeDirectorySiret(candidate.siret);
const candidateRna = normalizeDirectoryRna(candidate.rna);
const candidatePhone = normalizeDirectoryPhone(candidate.telephone);
const candidateRepresentative = normalizeText(candidate.nomRepresentant);
let score = 0;
const exactSiret = Boolean(localSiret && candidateSiret && localSiret === candidateSiret);
const exactRna = Boolean(localRna && candidateRna && localRna === candidateRna);
const exactEmail = Boolean(localEmail && candidateEmail && localEmail === candidateEmail);
const exactName = Boolean(localName && candidateName && localName === candidateName);
const exactCity = Boolean(localCity && candidateCity && localCity === candidateCity);
const exactPhone = Boolean(localPhone && candidatePhone && localPhone === candidatePhone);
const exactRepresentative = Boolean(
localRepresentative && candidateRepresentative && localRepresentative === candidateRepresentative
);
if (exactSiret) score += 300;
if (exactRna) score += 260;
if (exactEmail) score += 220;
if (exactPhone) score += 180;
if (exactRepresentative) score += 140;
if (exactName) score += 120;
if (exactCity) score += 25;
if (!exactName && localName && candidateName) {
if (candidateName.includes(localName) || localName.includes(candidateName)) {
score += 40;
}
}
if (!exactRepresentative && localRepresentative && candidateRepresentative) {
if (
candidateRepresentative.includes(localRepresentative) ||
localRepresentative.includes(candidateRepresentative)
) {
score += 45;
}
}
if (exactName && exactPhone) {
score += 80;
}
if (exactName && exactRepresentative) {
score += 60;
}
return { score, exactSiret, exactRna, exactEmail, exactName, exactCity, exactPhone, exactRepresentative };
}
export function matchAssociationDirectoryEntry(
entries: AssociationDirectoryMatchCandidate[],
input: AssociationDirectoryMatchInput
): AssociationDirectoryMatchResult {
const normalizedName = normalizeText(input.nomAssociation);
const normalizedEmail = normalizeDirectoryEmail(input.email);
const normalizedSiret = normalizeDirectorySiret(input.siret);
const normalizedRna = normalizeDirectoryRna(input.rna);
const normalizedPhone = normalizeDirectoryPhone(input.telephone);
const normalizedRepresentative = normalizeText(input.nomRepresentant);
const normalizedCity = normalizeText(input.ville);
if (!normalizedName && !normalizedEmail && !normalizedSiret && !normalizedRna && !normalizedPhone && !normalizedRepresentative) {
return {
status: "none",
candidates: [],
reason: "Aucun identifiant exploitable n'a été fourni pour rechercher une fiche du bordereau.",
};
}
const exactSiret = entries.filter((entry) => normalizeDirectorySiret(entry.siret) === normalizedSiret && normalizedSiret);
if (exactSiret.length === 1) {
return { status: "matched", matchedEntry: exactSiret[0], candidates: exactSiret, reason: "Correspondance validée par le SIRET." };
}
if (exactSiret.length > 1) {
return { status: "ambiguous", candidates: exactSiret, reason: "Plusieurs fiches du bordereau portent le même SIRET." };
}
const exactRna = entries.filter((entry) => normalizeDirectoryRna(entry.rna) === normalizedRna && normalizedRna);
if (exactRna.length === 1) {
return { status: "matched", matchedEntry: exactRna[0], candidates: exactRna, reason: "Correspondance validée par le RNA." };
}
if (exactRna.length > 1) {
return { status: "ambiguous", candidates: exactRna, reason: "Plusieurs fiches du bordereau portent le même RNA." };
}
const exactEmail = entries.filter((entry) => normalizeDirectoryEmail(entry.emailOfficiel) === normalizedEmail && normalizedEmail);
if (exactEmail.length === 1) {
return { status: "matched", matchedEntry: exactEmail[0], candidates: exactEmail, reason: "Correspondance validée par l'email officiel." };
}
if (exactEmail.length > 1) {
return { status: "ambiguous", candidates: exactEmail, reason: "Plusieurs fiches du bordereau utilisent le même email officiel." };
}
const exactPhone = entries.filter((entry) => normalizeDirectoryPhone(entry.telephone) === normalizedPhone && normalizedPhone);
if (exactPhone.length === 1) {
return {
status: "matched",
matchedEntry: exactPhone[0],
candidates: exactPhone,
reason: "Correspondance validée par le numéro de téléphone.",
};
}
if (exactPhone.length > 1) {
return {
status: "ambiguous",
candidates: exactPhone,
reason: "Plusieurs fiches du bordereau utilisent le même numéro de téléphone.",
};
}
const scored = entries
.map((entry) => ({ entry, ...candidateScore(entry, input) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score);
const exactNameAndRepresentative = scored.filter((entry) => entry.exactName && entry.exactRepresentative);
if (exactNameAndRepresentative.length === 1) {
return {
status: "matched",
matchedEntry: exactNameAndRepresentative[0].entry,
candidates: exactNameAndRepresentative.map((entry) => entry.entry),
reason: "Correspondance validée par le nom de l'association et du représentant.",
};
}
if (exactNameAndRepresentative.length > 1) {
return {
status: "ambiguous",
candidates: exactNameAndRepresentative.map((entry) => entry.entry),
reason: "Plusieurs fiches du bordereau correspondent au même nom d'association et représentant.",
};
}
const exactNameAndCity = scored.filter((entry) => entry.exactName && entry.exactCity);
if (exactNameAndCity.length === 1) {
return {
status: "matched",
matchedEntry: exactNameAndCity[0].entry,
candidates: exactNameAndCity.map((entry) => entry.entry),
reason: "Correspondance validée par le nom et la commune.",
};
}
if (exactNameAndCity.length > 1) {
return {
status: "ambiguous",
candidates: exactNameAndCity.map((entry) => entry.entry),
reason: "Plusieurs fiches du bordereau correspondent au même nom dans cette commune.",
};
}
const exactNameOnly = scored.filter((entry) => entry.exactName);
if (exactNameOnly.length === 1) {
return {
status: "matched",
matchedEntry: exactNameOnly[0].entry,
candidates: exactNameOnly.map((entry) => entry.entry),
reason: "Correspondance validée par le nom de l'association.",
};
}
if (exactNameOnly.length > 1) {
return {
status: "ambiguous",
candidates: exactNameOnly.map((entry) => entry.entry),
reason: "Plusieurs fiches du bordereau portent le même nom.",
};
}
const closeCandidates = scored
.filter((entry) => entry.score >= 40)
.map((entry) => entry.entry)
.slice(0, 5);
if (closeCandidates.length > 0) {
return {
status: "ambiguous",
candidates: closeCandidates,
reason: normalizedCity
? "Des rapprochements partiels ont été trouvés, mais aucun n'est assez fiable pour lier automatiquement cette association."
: "Des rapprochements potentiels ont été trouvés, mais une validation humaine reste nécessaire.",
};
}
return {
status: "none",
candidates: [],
reason: "Aucune fiche du bordereau ne correspond de façon fiable à cette association.",
};
}

View file

@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { computeAssociationDirectoryGeoUpdate, computeAssociationDirectoryGeoUpdateForPrecision, getPublicMapCoordinates } from "./associationGeo";
describe("associationGeo", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns public coordinates for visible entries and keeps hidden precision visible by policy", () => {
const visible = getPublicMapCoordinates({
latitude: "5.123456",
longitude: "-52.123456",
geoPrecision: "commune_center",
} as any);
expect(visible).toEqual({
latitude: 5.123456,
longitude: -52.123456,
precision: "commune_center",
});
const hidden = getPublicMapCoordinates({
latitude: "5.123456",
longitude: "-52.123456",
geoPrecision: "hidden",
} as any);
expect(hidden).toEqual({
latitude: 5.123456,
longitude: -52.123456,
precision: "exact_address",
});
});
it("geocodes the address when exact precision is requested", async () => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({
features: [
{
geometry: { coordinates: [-52.61, 5.08] },
properties: { label: "Adresse test" },
},
],
}),
} as Response);
const result = await computeAssociationDirectoryGeoUpdateForPrecision({
id: 1,
nomAssociation: "Association Test",
adresse: "1 rue Test",
codePostal: "97310",
ville: "Kourou",
} as any, null, "exact_address");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.geoSource).toBe("adresse_gouv");
expect(result.latitude).toBe("5.080000");
expect(result.longitude).toBe("-52.610000");
});
it("falls back to commune center by default", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ([
{ nom: "Kourou", centre: { coordinates: [-52.7767, 4.9085] } },
]),
} as Response);
const result = await computeAssociationDirectoryGeoUpdate({
id: 1,
nomAssociation: "Association Test",
adresse: "Adresse introuvable",
codePostal: "97310",
ville: "Kourou",
} as any);
expect(result.geoSource).toBe("commune_center");
expect(result.latitude).toBe("4.908500");
expect(result.longitude).toBe("-52.776700");
});
});

180
server/associationGeo.ts Normal file
View file

@ -0,0 +1,180 @@
import type { Association, AssociationDirectoryEntry, InsertAssociationDirectoryEntry } from "../drizzle/schema";
import type { AssociationGeoPrecision, AssociationGeoSource } from "@shared/associationGeo";
type CoordinateSet = {
latitude: string | null;
longitude: string | null;
geoSource: AssociationGeoSource | null;
externalSourceStatus: string;
externalSourceLabel: string | null;
};
function formatCoordinate(value: number | null) {
if (value === null || Number.isNaN(value)) return null;
return value.toFixed(6);
}
function buildAddressQuery(entry: AssociationDirectoryEntry, association?: Association | null) {
const adresse = association?.adresse || entry.adresse;
const codePostal = association?.codePostal || entry.codePostal;
const ville = association?.ville || entry.ville;
return [adresse, codePostal, ville].filter(Boolean).join(" ").trim();
}
async function geocodeAddress(query: string) {
const url = new URL("https://api-adresse.data.gouv.fr/search/");
url.searchParams.set("q", query);
url.searchParams.set("limit", "1");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"User-Agent": "portail-associations/1.0",
},
});
if (!response.ok) {
throw new Error(`Adresse API returned ${response.status}`);
}
const payload = await response.json() as {
features?: Array<{
geometry?: { coordinates?: [number, number] };
properties?: { label?: string };
}>;
};
const first = payload.features?.[0];
const coordinates = first?.geometry?.coordinates;
if (!coordinates || coordinates.length < 2) {
return null;
}
return {
latitude: coordinates[1],
longitude: coordinates[0],
label: first?.properties?.label || null,
};
}
async function fetchCommuneCenter(ville: string, codePostal?: string | null) {
const url = new URL("https://geo.api.gouv.fr/communes");
url.searchParams.set("nom", ville);
url.searchParams.set("fields", "nom,centre,code,codesPostaux");
url.searchParams.set("format", "json");
url.searchParams.set("geometry", "centre");
if (codePostal) {
url.searchParams.set("codePostal", codePostal);
}
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"User-Agent": "portail-associations/1.0",
},
});
if (!response.ok) {
throw new Error(`Geo API returned ${response.status}`);
}
const payload = await response.json() as Array<{
nom?: string;
centre?: { coordinates?: [number, number] };
}>;
const first = payload[0];
const coordinates = first?.centre?.coordinates;
if (!coordinates || coordinates.length < 2) {
return null;
}
return {
latitude: coordinates[1],
longitude: coordinates[0],
label: first?.nom || ville,
};
}
export async function computeAssociationDirectoryGeoUpdate(entry: AssociationDirectoryEntry, association?: Association | null) {
return computeAssociationDirectoryGeoUpdateForPrecision(
entry,
association,
(entry.geoPrecision as AssociationGeoPrecision | null) || "commune_center"
);
}
export async function computeAssociationDirectoryGeoUpdateForPrecision(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
preferredPrecision: AssociationGeoPrecision
) {
const updates: Partial<InsertAssociationDirectoryEntry> = {
geoLastSyncedAt: new Date(),
geoPrecision: preferredPrecision,
};
const addressQuery = buildAddressQuery(entry, association);
if (preferredPrecision === "exact_address" && addressQuery) {
try {
const geocoded = await geocodeAddress(addressQuery);
if (geocoded) {
updates.latitude = formatCoordinate(geocoded.latitude);
updates.longitude = formatCoordinate(geocoded.longitude);
updates.geoSource = "adresse_gouv";
updates.externalSourceStatus = "geocoded_from_address";
updates.externalSourceLabel = geocoded.label || "Adresse.data.gouv.fr";
return updates;
}
} catch {
// Fall back to commune center below.
}
}
const ville = association?.ville || entry.ville;
const codePostal = association?.codePostal || entry.codePostal;
if (ville) {
try {
const communeCenter = await fetchCommuneCenter(ville, codePostal);
if (communeCenter) {
updates.latitude = formatCoordinate(communeCenter.latitude);
updates.longitude = formatCoordinate(communeCenter.longitude);
updates.geoSource = "commune_center";
updates.externalSourceStatus = "commune_center_fallback";
updates.externalSourceLabel = communeCenter.label || ville;
return updates;
}
} catch {
// Fall through to unavailable state.
}
}
updates.latitude = null;
updates.longitude = null;
updates.geoSource = null;
updates.externalSourceStatus = "unresolved";
updates.externalSourceLabel = null;
return updates;
}
export function getPublicMapCoordinates(entry: AssociationDirectoryEntry) {
if (!entry.latitude || !entry.longitude) {
return null;
}
const latitude = Number(entry.latitude);
const longitude = Number(entry.longitude);
if (Number.isNaN(latitude) || Number.isNaN(longitude)) {
return null;
}
return {
latitude,
longitude,
precision: (
entry.geoPrecision === "hidden"
? (entry.geoSource === "commune_center" ? "commune_center" : "exact_address")
: entry.geoPrecision
) as AssociationGeoPrecision,
};
}

View file

@ -0,0 +1,442 @@
import type { Association, AssociationDirectoryEntry, InsertAssociationDirectoryEntry } from "../drizzle/schema";
export const HELLOASSO_SETTINGS_KEY = "system.associationDirectory.helloasso";
export type HelloAssoSettings = {
enabled: boolean;
clientId: string;
clientSecret: string;
};
export type HelloAssoSettingsPublic = {
enabled: boolean;
clientId: string;
clientSecretConfigured: boolean;
};
type HelloAssoTokenResponse = {
access_token?: string;
token_type?: string;
expires_in?: number;
};
type HelloAssoDirectoryItem = {
action?: string | null;
record?: {
url?: string | null;
organizationSlug?: string | null;
} | null;
};
type HelloAssoDirectoryResponse = {
data?: HelloAssoDirectoryItem[] | null;
pagination?: {
continuationToken?: string | null;
} | null;
};
type HelloAssoOrganizationPublic = {
facebookPage?: string | null;
longDescription?: string | null;
webSite?: string | null;
address?: string | null;
rnaNumber?: string | null;
name?: string | null;
city?: string | null;
zipCode?: string | null;
description?: string | null;
updateDate?: string | null;
url?: string | null;
organizationSlug?: string | null;
};
type CandidateScore = {
slug: string;
detail: HelloAssoOrganizationPublic;
score: number;
exactRna: boolean;
exactName: boolean;
exactCity: boolean;
exactZipCode: boolean;
};
export type HelloAssoSyncResult = {
matched: boolean;
reason: string;
slug?: string;
candidateCount: number;
updates: Partial<InsertAssociationDirectoryEntry>;
};
export class HelloAssoSyncClient {
private accessTokenPromise: Promise<string> | null = null;
constructor(private readonly settings: HelloAssoSettings) {}
async getAccessToken() {
if (!this.accessTokenPromise) {
this.accessTokenPromise = fetchHelloAssoAccessToken(this.settings);
}
return this.accessTokenPromise;
}
}
export function sanitizeHelloAssoSettings(rawValue?: unknown): HelloAssoSettings {
const source = rawValue && typeof rawValue === "object" ? rawValue as Record<string, unknown> : {};
return {
enabled: source.enabled !== false,
clientId: typeof source.clientId === "string" ? source.clientId.trim() : "",
clientSecret: typeof source.clientSecret === "string" ? source.clientSecret.trim() : "",
};
}
export function serializeHelloAssoSettingsPublic(settings: HelloAssoSettings): HelloAssoSettingsPublic {
return {
enabled: settings.enabled,
clientId: settings.clientId,
clientSecretConfigured: settings.clientSecret.length > 0,
};
}
function normalizeText(value: string | null | undefined) {
return (value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.toLowerCase();
}
function cleanDigits(value: string | null | undefined) {
return (value || "").replace(/\D/g, "");
}
function normalizeRna(value: string | null | undefined) {
const trimmed = (value || "").trim().toUpperCase();
return /^W\d{9}$/.test(trimmed) ? trimmed : null;
}
function normalizeZipCode(value: string | null | undefined) {
const digits = cleanDigits(value);
return digits.length >= 5 ? digits.slice(0, 5) : null;
}
function parseDate(value: string | null | undefined) {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function normalizeOptionalUrl(value: string | null | undefined) {
const trimmed = String(value || "").trim();
if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
function normalizeOptionalText(value: string | null | undefined) {
const trimmed = String(value || "").trim();
return trimmed || null;
}
function buildSearchBodies(entry: AssociationDirectoryEntry, association?: Association | null) {
const name = normalizeOptionalText(association?.nomAssociation || entry.nomAssociation);
const city = normalizeOptionalText(association?.ville || entry.ville);
const zipCode = normalizeZipCode(association?.codePostal || entry.codePostal);
const variants = [
{
name,
...(city ? { cities: [city] } : {}),
...(zipCode ? { zipCodes: [zipCode] } : {}),
},
{
name,
...(city ? { cities: [city] } : {}),
},
{
name,
...(zipCode ? { zipCodes: [zipCode] } : {}),
},
{
name,
},
];
const seen = new Set<string>();
return variants.filter((variant) => {
if (!variant.name) return false;
const key = JSON.stringify(variant);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function fetchHelloAssoJson<T>(path: string, token: string, init?: RequestInit) {
const response = await fetch(`https://api.helloasso.com/v5${path}`, {
...init,
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...(init?.headers || {}),
},
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`HelloAsso HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
}
return await response.json() as T;
}
async function fetchHelloAssoAccessToken(settings: HelloAssoSettings) {
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: settings.clientId,
client_secret: settings.clientSecret,
});
const response = await fetch("https://api.helloasso.com/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!response.ok) {
const raw = await response.text().catch(() => "");
throw new Error(
response.status === 401 || response.status === 403
? "Identifiants HelloAsso invalides ou non autorisés"
: `Impossible d'obtenir un jeton HelloAsso (${response.status})${raw ? `: ${raw.slice(0, 160)}` : ""}`
);
}
const payload = await response.json() as HelloAssoTokenResponse;
if (!payload.access_token) {
throw new Error("HelloAsso n'a pas renvoyé de jeton d'accès exploitable");
}
return payload.access_token;
}
async function searchHelloAssoDirectory(
token: string,
entry: AssociationDirectoryEntry,
association?: Association | null
) {
const slugs = new Set<string>();
for (const body of buildSearchBodies(entry, association)) {
try {
const response = await fetchHelloAssoJson<HelloAssoDirectoryResponse>("/directory/organizations?pageSize=8", token, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
for (const item of response.data || []) {
const slug = item.record?.organizationSlug?.trim();
if (!slug) continue;
if ((item.action || "").toLowerCase() === "delete") continue;
slugs.add(slug);
}
if (slugs.size > 0) {
break;
}
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
if (message.includes("403")) {
throw new Error("Le client HelloAsso doit disposer du privilège OrganizationOpenDirectory pour interroger le répertoire.");
}
throw error;
}
}
return Array.from(slugs);
}
function scoreHelloAssoCandidate(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
detail: HelloAssoOrganizationPublic
): CandidateScore {
const localName = normalizeText(association?.nomAssociation || entry.nomAssociation);
const localCity = normalizeText(association?.ville || entry.ville);
const localZipCode = normalizeZipCode(association?.codePostal || entry.codePostal);
const localRna = normalizeRna(association?.rna || entry.rna);
const remoteName = normalizeText(detail.name);
const remoteCity = normalizeText(detail.city);
const remoteZipCode = normalizeZipCode(detail.zipCode);
const remoteRna = normalizeRna(detail.rnaNumber);
const exactRna = Boolean(localRna && remoteRna && localRna === remoteRna);
const exactName = Boolean(localName && remoteName && localName === remoteName);
const exactCity = Boolean(localCity && remoteCity && localCity === remoteCity);
const exactZipCode = Boolean(localZipCode && remoteZipCode && localZipCode === remoteZipCode);
let score = 0;
if (exactRna) score += 200;
if (exactName) score += 90;
else if (remoteName && (remoteName.includes(localName) || localName.includes(remoteName))) score += 35;
if (exactCity) score += 20;
if (exactZipCode) score += 20;
if (normalizeOptionalUrl(detail.webSite) && normalizeOptionalUrl(detail.webSite) === normalizeOptionalUrl(association?.siteWeb || entry.siteWeb)) {
score += 30;
}
return {
slug: detail.organizationSlug || "",
detail,
score,
exactRna,
exactName,
exactCity,
exactZipCode,
};
}
function pickHelloAssoCandidate(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
details: HelloAssoOrganizationPublic[]
) {
const scored = details
.filter((detail) => Boolean(detail.organizationSlug))
.map((detail) => scoreHelloAssoCandidate(entry, association, detail))
.sort((a, b) => b.score - a.score);
if (scored.length === 0) return null;
const exactRna = scored.filter((candidate) => candidate.exactRna);
if (exactRna.length === 1) return exactRna[0];
const exactNameAndLocation = scored.filter((candidate) => candidate.exactName && (candidate.exactCity || candidate.exactZipCode));
if (exactNameAndLocation.length === 1) return exactNameAndLocation[0];
const exactNameOnly = scored.filter((candidate) => candidate.exactName);
if (exactNameOnly.length === 1) return exactNameOnly[0];
const [best, second] = scored;
if (best && best.score >= 120 && (!second || best.score - second.score >= 20)) {
return best;
}
return null;
}
function buildHelloAssoUpdates(detail: HelloAssoOrganizationPublic): Partial<InsertAssociationDirectoryEntry> {
const description = normalizeOptionalText(detail.longDescription) || normalizeOptionalText(detail.description);
const address = normalizeOptionalText(detail.address);
const city = normalizeOptionalText(detail.city);
const zipCode = normalizeZipCode(detail.zipCode);
const webSite = normalizeOptionalUrl(detail.webSite);
const facebookPage = normalizeOptionalUrl(detail.facebookPage);
const rna = normalizeRna(detail.rnaNumber);
const updateDate = parseDate(detail.updateDate);
const slug = normalizeOptionalText(detail.organizationSlug);
return {
...(rna ? { rna } : {}),
...(address ? { adresse: address } : {}),
...(city ? { ville: city } : {}),
...(zipCode ? { codePostal: zipCode } : {}),
...(webSite ? { siteWeb: webSite } : {}),
...(facebookPage ? { facebookUrl: facebookPage } : {}),
...(description ? { objetAssociation: description } : {}),
externalSourceStatus: "helloasso_synced",
externalSourceLabel: slug ? `HelloAsso · ${slug}` : "HelloAsso",
...(updateDate ? { registryLastUpdatedAt: updateDate } : {}),
};
}
export async function computeAssociationDirectoryHelloAssoUpdate(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
settings: HelloAssoSettings,
client?: HelloAssoSyncClient
): Promise<HelloAssoSyncResult> {
if (!settings.enabled) {
return {
matched: false,
reason: "La synchronisation HelloAsso est désactivée.",
candidateCount: 0,
updates: {
externalSourceStatus: "helloasso_disabled",
externalSourceLabel: "HelloAsso",
},
};
}
if (!settings.clientId || !settings.clientSecret) {
return {
matched: false,
reason: "Les identifiants HelloAsso ne sont pas configurés.",
candidateCount: 0,
updates: {
externalSourceStatus: "helloasso_not_configured",
externalSourceLabel: "HelloAsso",
},
};
}
const syncClient = client || new HelloAssoSyncClient(settings);
const token = await syncClient.getAccessToken();
const slugs = await searchHelloAssoDirectory(token, entry, association);
if (slugs.length === 0) {
return {
matched: false,
reason: "Aucun organisme HelloAsso compatible n'a été trouvé pour cette association.",
candidateCount: 0,
updates: {
externalSourceStatus: "helloasso_no_match",
externalSourceLabel: "HelloAsso",
},
};
}
const details = await Promise.all(
slugs.map(async (slug) => {
try {
return await fetchHelloAssoJson<HelloAssoOrganizationPublic>(`/organizations/${encodeURIComponent(slug)}`, token);
} catch {
return null;
}
})
);
const matched = pickHelloAssoCandidate(entry, association, details.filter(Boolean) as HelloAssoOrganizationPublic[]);
if (!matched) {
return {
matched: false,
reason: "Des résultats HelloAsso ont été trouvés, mais aucun rapprochement n'est assez fiable pour mettre à jour la fiche automatiquement.",
candidateCount: details.filter(Boolean).length,
updates: {
externalSourceStatus: "helloasso_ambiguous_match",
externalSourceLabel: "HelloAsso",
},
};
}
return {
matched: true,
reason: matched.exactRna
? "Correspondance HelloAsso validée par le RNA."
: matched.exactName && (matched.exactCity || matched.exactZipCode)
? "Correspondance HelloAsso validée par le nom et la localisation."
: "Correspondance HelloAsso validée par le nom de l'association.",
slug: matched.slug,
candidateCount: details.filter(Boolean).length,
updates: buildHelloAssoUpdates(matched.detail),
};
}

View file

@ -0,0 +1,68 @@
type InvitationEmailInput = {
associationName: string;
invitationLink: string;
expiresAt: Date;
};
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
export function generateAssociationInvitationEmail(input: InvitationEmailInput) {
const expiryLabel = formatDate(input.expiresAt);
const associationName = escapeHtml(input.associationName);
const invitationLink = escapeHtml(input.invitationLink);
const subject = `Invitation au Portail Associations - ${input.associationName}`;
const text = [
`Bonjour,`,
``,
`L'association "${input.associationName}" est invitée à activer son espace sur le Portail Associations.`,
`Ce lien est personnel, remplace les précédents liens d'invitation et reste valide jusqu'au ${expiryLabel}.`,
``,
`${input.invitationLink}`,
``,
`Si vous n'êtes pas la bonne personne, merci de transmettre ce message au référent de l'association.`,
].join("\n");
const html = `
<div style="font-family: Arial, sans-serif; color: #161616; line-height: 1.5;">
<h2 style="margin: 0 0 16px;">Invitation au Portail Associations</h2>
<p style="margin: 0 0 12px;">
L'association <strong>${associationName}</strong> est invitée à activer son espace sur le portail.
</p>
<p style="margin: 0 0 20px;">
Ce lien est personnel, remplace les précédents liens d'invitation et reste valide jusqu'au
<strong>${expiryLabel}</strong>.
</p>
<p style="margin: 0 0 20px;">
<a href="${invitationLink}" style="display: inline-block; background: #000091; color: white; text-decoration: none; padding: 12px 18px; border-radius: 6px; font-weight: 600;">
Activer l'espace association
</a>
</p>
<p style="margin: 0 0 8px; font-size: 14px; color: #666;">
Si le bouton ne fonctionne pas, utilisez ce lien :
</p>
<p style="margin: 0; font-size: 14px; word-break: break-all;">
<a href="${invitationLink}">${invitationLink}</a>
</p>
</div>
`;
return { subject, text, html };
}

View file

@ -0,0 +1,402 @@
import type { Association, AssociationDirectoryEntry, InsertAssociationDirectoryEntry } from "../drizzle/schema";
import { ENV } from "./_core/env";
export type ReferenceProposalChange = {
field: string;
label: string;
currentValue: string;
proposedValue: string;
};
export type ReferenceProposalResult = {
hasChanges: boolean;
updates: Partial<InsertAssociationDirectoryEntry>;
changes: ReferenceProposalChange[];
sourceLabel: string | null;
summary: string;
};
type DjepvaAssociationResponse = {
data?: {
association?: {
rna?: string | null;
siret_siege?: string | null;
active?: boolean | null;
date_creation?: string | null;
objet?: string | null;
adresse_siege?: {
code_postal?: string | null;
commune?: string | null;
numero_voie?: string | null;
type_voie?: string | null;
libelle_voie?: string | null;
} | null;
forme_juridique?: {
libelle?: string | null;
} | null;
reconnue_utilite_publique?: boolean | null;
} | null;
meta?: {
date_derniere_mise_a_jour_sirene?: string | null;
date_derniere_mise_a_jour_rna?: string | null;
} | null;
} | null;
};
type SearchApiResult = {
siren?: string | null;
nom_complet?: string | null;
nom_raison_sociale?: string | null;
date_creation?: string | null;
date_mise_a_jour?: string | null;
date_mise_a_jour_insee?: string | null;
etat_administratif?: string | null;
nature_juridique?: string | null;
siege?: {
siret?: string | null;
adresse?: string | null;
code_postal?: string | null;
libelle_commune?: string | null;
} | null;
complements?: {
identifiant_association?: string | null;
est_association?: boolean | null;
} | null;
};
type SearchApiResponse = {
results?: SearchApiResult[];
};
function normalizeText(value: string | null | undefined) {
return (value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.toLowerCase();
}
function cleanDigits(value: string | null | undefined) {
return (value || "").replace(/\D/g, "");
}
function normalizeRna(value: string | null | undefined) {
const trimmed = (value || "").trim().toUpperCase();
return /^W\d{9}$/.test(trimmed) ? trimmed : null;
}
function normalizeSiret(value: string | null | undefined) {
const digits = cleanDigits(value);
return digits.length === 14 ? digits : null;
}
function normalizeSiren(value: string | null | undefined) {
const digits = cleanDigits(value);
return digits.length === 9 ? digits : null;
}
function parseDate(value: string | null | undefined) {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function maxDate(...values: Array<Date | null>) {
const dates = values.filter((value): value is Date => Boolean(value));
if (dates.length === 0) return null;
return new Date(Math.max(...dates.map((date) => date.getTime())));
}
function buildAddressLabel(parts: Array<string | null | undefined>) {
const cleaned = parts.map((part) => (part || "").trim()).filter(Boolean);
return cleaned.length > 0 ? cleaned.join(" ") : null;
}
function mapAssociationStatus(active: boolean | null | undefined, etatAdministratif?: string | null) {
if (typeof active === "boolean") {
return active ? "active" : "inactive";
}
if (etatAdministratif === "A") return "active";
if (etatAdministratif === "C" || etatAdministratif === "F") return "inactive";
return null;
}
function mapStatutJuridique(libelle?: string | null, reconnueUtilitePublique?: boolean | null) {
const normalized = normalizeText(libelle);
if (reconnueUtilitePublique) {
return "association_reconnue_utilite_publique" as const;
}
if (normalized.includes("fondation")) {
return "fondation" as const;
}
if (normalized.includes("association")) {
return "association_loi_1901" as const;
}
return null;
}
async function fetchJson<T>(url: URL | string, init?: RequestInit) {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json() as T;
}
async function fetchDjepvaAssociation(identifier: string) {
if (!ENV.entrepriseApiToken) {
return null;
}
const url = `https://entreprise.api.gouv.fr/v4/djepva/api-association/associations/${encodeURIComponent(identifier)}`;
try {
return await fetchJson<DjepvaAssociationResponse>(url, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${ENV.entrepriseApiToken}`,
"User-Agent": "portail-associations/1.0",
},
});
} catch {
return null;
}
}
function scoreSearchResult(result: SearchApiResult, targetName: string, targetCity?: string | null) {
let score = 0;
const resultName = normalizeText(result.nom_raison_sociale || result.nom_complet);
const normalizedTargetCity = normalizeText(targetCity);
const resultCity = normalizeText(result.siege?.libelle_commune);
if (result.complements?.est_association) score += 10;
if (resultName === targetName) score += 100;
else if (resultName.includes(targetName) || targetName.includes(resultName)) score += 40;
if (normalizedTargetCity && resultCity && normalizedTargetCity === resultCity) score += 20;
if (result.siege?.siret) score += 5;
return score;
}
async function searchAssociationInSirene(entry: AssociationDirectoryEntry, association?: Association | null) {
const knownSiret = normalizeSiret(association?.siret || entry.siret);
const knownSiren = normalizeSiren(knownSiret ? knownSiret.slice(0, 9) : association?.siret || entry.siret);
const query = knownSiret || knownSiren || association?.nomAssociation || entry.nomAssociation;
if (!query) return null;
const url = new URL("https://recherche-entreprises.api.gouv.fr/search");
url.searchParams.set("q", query);
url.searchParams.set("page", "1");
url.searchParams.set("per_page", "10");
url.searchParams.set("est_association", "true");
if (!knownSiret && !knownSiren) {
const codePostal = association?.codePostal || entry.codePostal;
if (codePostal) {
url.searchParams.set("code_postal", codePostal);
}
}
try {
const payload = await fetchJson<SearchApiResponse>(url, {
headers: {
Accept: "application/json",
"User-Agent": "portail-associations/1.0",
},
});
const results = payload.results || [];
if (results.length === 0) return null;
if (knownSiret) {
return results.find((result) => normalizeSiret(result.siege?.siret) === knownSiret) || results[0];
}
if (knownSiren) {
return results.find((result) => normalizeSiren(result.siren) === knownSiren) || results[0];
}
const targetName = normalizeText(association?.nomAssociation || entry.nomAssociation);
const targetCity = association?.ville || entry.ville;
return [...results].sort((a, b) => scoreSearchResult(b, targetName, targetCity) - scoreSearchResult(a, targetName, targetCity))[0];
} catch {
return null;
}
}
export async function computeAssociationDirectoryReferenceUpdate(
entry: AssociationDirectoryEntry,
association?: Association | null
) {
const knownRna = normalizeRna(association?.rna || entry.rna);
const knownSiret = normalizeSiret(association?.siret || entry.siret);
const knownSiren = normalizeSiren(knownSiret ? knownSiret.slice(0, 9) : association?.siret || entry.siret);
const djepvaIdentifier = knownRna || knownSiren;
const [djepva, searchResult] = await Promise.all([
djepvaIdentifier ? fetchDjepvaAssociation(djepvaIdentifier) : Promise.resolve(null),
searchAssociationInSirene(entry, association),
]);
const djepvaAssociation = djepva?.data?.association || null;
const djepvaMeta = djepva?.data?.meta || null;
const resolvedRna =
normalizeRna(djepvaAssociation?.rna) ||
normalizeRna(searchResult?.complements?.identifiant_association) ||
knownRna;
const resolvedSiret =
normalizeSiret(djepvaAssociation?.siret_siege) ||
normalizeSiret(searchResult?.siege?.siret) ||
knownSiret;
const resolvedStatus =
mapAssociationStatus(djepvaAssociation?.active, searchResult?.etat_administratif) ||
entry.associationStatus ||
null;
const registryLastUpdatedAt = maxDate(
parseDate(djepvaMeta?.date_derniere_mise_a_jour_rna),
parseDate(djepvaMeta?.date_derniere_mise_a_jour_sirene),
parseDate(searchResult?.date_mise_a_jour_insee),
parseDate(searchResult?.date_mise_a_jour)
);
const inferredDateCreation =
parseDate(djepvaAssociation?.date_creation) ||
parseDate(searchResult?.date_creation) ||
entry.dateCreation ||
null;
const inferredAddress =
buildAddressLabel([
djepvaAssociation?.adresse_siege?.numero_voie,
djepvaAssociation?.adresse_siege?.type_voie,
djepvaAssociation?.adresse_siege?.libelle_voie,
]) ||
searchResult?.siege?.adresse ||
entry.adresse ||
null;
const inferredCodePostal =
djepvaAssociation?.adresse_siege?.code_postal ||
searchResult?.siege?.code_postal ||
entry.codePostal ||
null;
const inferredVille =
djepvaAssociation?.adresse_siege?.commune ||
searchResult?.siege?.libelle_commune ||
entry.ville ||
null;
const resolvedStatutJuridique =
mapStatutJuridique(djepvaAssociation?.forme_juridique?.libelle, djepvaAssociation?.reconnue_utilite_publique) ||
entry.statutJuridique ||
null;
const sourceLabels = [
djepvaAssociation ? "API RNA (DJEPVA)" : null,
searchResult ? "API SIRENE / Recherche dentreprises" : null,
].filter(Boolean);
const hasAnyReference = Boolean(resolvedRna || resolvedSiret || resolvedStatus || registryLastUpdatedAt);
const updates: Partial<InsertAssociationDirectoryEntry> = {
rna: resolvedRna,
siret: resolvedSiret,
associationStatus: resolvedStatus,
registryLastUpdatedAt,
referenceLastCheckedAt: new Date(),
referenceStatus: hasAnyReference ? "reference_data_synced" : "reference_data_unresolved",
referenceSourceLabel: sourceLabels.length > 0 ? sourceLabels.join(" + ") : null,
dateCreation: inferredDateCreation,
adresse: inferredAddress,
codePostal: inferredCodePostal,
ville: inferredVille,
};
if (resolvedStatutJuridique) {
updates.statutJuridique = resolvedStatutJuridique;
}
if (djepvaAssociation?.objet && !entry.objetAssociation) {
updates.objetAssociation = djepvaAssociation.objet;
}
return updates;
}
const proposalFieldLabels: Record<string, string> = {
rna: "RNA",
siret: "SIRET",
associationStatus: "Statut",
dateCreation: "Date de création",
adresse: "Adresse",
codePostal: "Code postal",
ville: "Ville",
statutJuridique: "Statut juridique",
objetAssociation: "Objet de l'association",
};
function normalizeComparableDate(value: unknown) {
if (!value) return "";
const date = value instanceof Date ? value : new Date(String(value));
if (Number.isNaN(date.getTime())) return "";
return date.toISOString().slice(0, 10);
}
function normalizeComparableValue(field: string, value: unknown) {
if (value == null) return "";
if (field.toLowerCase().includes("date")) {
return normalizeComparableDate(value);
}
if (typeof value === "string") {
return value.trim();
}
return String(value);
}
function formatProposalValue(field: string, value: unknown) {
if (value == null || value === "") return "Non renseigné";
if (field.toLowerCase().includes("date")) {
const normalized = normalizeComparableDate(value);
return normalized || "Non renseigné";
}
return String(value);
}
export async function computeAssociationDirectoryReferenceProposal(
entry: AssociationDirectoryEntry,
association?: Association | null
): Promise<ReferenceProposalResult> {
const updates = await computeAssociationDirectoryReferenceUpdate(entry, association);
const proposalFields = Object.keys(proposalFieldLabels);
const changes: ReferenceProposalChange[] = proposalFields
.filter((field) => field in updates)
.map((field) => {
const currentValue = normalizeComparableValue(field, (entry as Record<string, unknown>)[field]);
const proposedValue = normalizeComparableValue(field, (updates as Record<string, unknown>)[field]);
if (!proposedValue || currentValue === proposedValue) {
return null;
}
return {
field,
label: proposalFieldLabels[field],
currentValue: formatProposalValue(field, (entry as Record<string, unknown>)[field]),
proposedValue: formatProposalValue(field, (updates as Record<string, unknown>)[field]),
};
})
.filter(Boolean) as ReferenceProposalChange[];
const sourceLabel = updates.referenceSourceLabel || null;
const summary = changes.length
? `${changes.length} champ(s) à revoir : ${changes.slice(0, 4).map((change) => change.label).join(", ")}${changes.length > 4 ? "…" : ""}`
: sourceLabel
? `Aucune différence utile détectée malgré une lecture via ${sourceLabel}.`
: "Aucune donnée de référence exploitable n'a été trouvée.";
return {
hasChanges: changes.length > 0,
updates,
changes,
sourceLabel,
summary,
};
}

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { appRouter } from "./routers";
import { COOKIE_NAME } from "../shared/const";
import type { TrpcContext } from "./_core/context";
type CookieCall = {
name: string;
options: Record<string, unknown>;
};
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] } {
const clearedCookies: CookieCall[] = [];
const user: AuthenticatedUser = {
id: 1,
openId: "sample-user",
email: "sample@example.com",
name: "Sample User",
loginMethod: "manus",
role: "user",
canManageLogistics: false,
canSignSalle: false,
delegatedSalleSignerUserId: null,
salleSignatureDelegatedByUserIds: [],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
};
const ctx: TrpcContext = {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: (name: string, options: Record<string, unknown>) => {
clearedCookies.push({ name, options });
},
} as TrpcContext["res"],
};
return { ctx, clearedCookies };
}
describe("auth.logout", () => {
it("clears the session cookie and reports success", async () => {
const { ctx, clearedCookies } = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.logout();
expect(result).toEqual({ success: true });
expect(clearedCookies).toHaveLength(1);
expect(clearedCookies[0]?.name).toBe(COOKIE_NAME);
expect(clearedCookies[0]?.options).toMatchObject({
maxAge: -1,
secure: true,
sameSite: "none",
httpOnly: true,
path: "/",
});
});
});

231
server/calendar.test.ts Normal file
View file

@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock db module
vi.mock('./db', () => ({
getRequestById: vi.fn(),
updateRequestSalles: vi.fn(),
createRequestHistory: vi.fn(),
getAssociationById: vi.fn(),
getApprovedReservations: vi.fn(),
}));
// Mock notification
vi.mock('./_core/notification', () => ({
notifyOwner: vi.fn().mockResolvedValue(true),
}));
import * as db from './db';
import { notifyOwner } from './_core/notification';
describe('Calendrier des réservations - Backend', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getApprovedReservations', () => {
it('should return approved reservations with association info', async () => {
const mockReservations = [
{
id: 1,
associationId: 10,
type: 'demande_salle',
titre: 'Réservation Salle TOUCAN',
status: 'validee',
formData: JSON.stringify({
sallesSelectionnees: ['Salle TOUCAN'],
dateReservation: '2026-04-15',
heureDebut: '09:00',
heureFin: '17:00',
motifReservation: 'AG annuelle',
}),
associationName: 'Association Test',
associationEmail: 'test@asso.fr',
createdAt: new Date('2026-03-01'),
},
{
id: 2,
associationId: 11,
type: 'demande_salle',
titre: 'Réservation DOJO',
status: 'validee',
formData: JSON.stringify({
sallesSelectionnees: ['DOJO'],
dateReservation: '2026-04-20',
heureDebut: '18:00',
heureFin: '20:00',
motifReservation: 'Entraînement',
}),
associationName: 'Club Judo',
associationEmail: 'judo@club.fr',
createdAt: new Date('2026-03-05'),
},
];
vi.mocked(db.getApprovedReservations).mockResolvedValue(mockReservations as any);
const result = await db.getApprovedReservations();
expect(result).toHaveLength(2);
expect(result[0].associationName).toBe('Association Test');
expect(result[1].associationName).toBe('Club Judo');
});
it('should return empty array when no approved reservations', async () => {
vi.mocked(db.getApprovedReservations).mockResolvedValue([]);
const result = await db.getApprovedReservations();
expect(result).toHaveLength(0);
});
});
describe('changeSalles - logic simulation', () => {
it('should update salles in formData correctly', async () => {
const existingRequest = {
id: 1,
type: 'demande_salle',
associationId: 10,
status: 'validee',
formData: JSON.stringify({
sallesSelectionnees: ['Salle TOUCAN'],
sallesIds: ['salle_toucan'],
dateReservation: '2026-04-15',
heureDebut: '09:00',
heureFin: '17:00',
}),
};
vi.mocked(db.getRequestById).mockResolvedValue(existingRequest as any);
vi.mocked(db.updateRequestSalles).mockResolvedValue(undefined);
vi.mocked(db.createRequestHistory).mockResolvedValue(undefined as any);
vi.mocked(db.getAssociationById).mockResolvedValue({
id: 10,
nomAssociation: 'Association Test',
emailContact: 'test@asso.fr',
} as any);
// Simulate the changeSalles logic
const request = await db.getRequestById(1);
expect(request).toBeDefined();
expect(request!.type).toBe('demande_salle');
const formData = JSON.parse(request!.formData!);
const anciennesSalles = formData.sallesSelectionnees;
// Update salles
formData.sallesSelectionnees = ['Salle IBIS', 'Salle PELICAN'];
formData.sallesIds = ['salle_ibis', 'salle_pelican'];
// Add change history
formData.historiqueChangementsSalles = [{
date: new Date().toISOString(),
anciennesSalles,
nouvellesSalles: ['Salle IBIS', 'Salle PELICAN'],
raison: 'Besoin d\'une salle plus grande',
parAdmin: 1,
}];
await db.updateRequestSalles(1, JSON.stringify(formData));
expect(db.updateRequestSalles).toHaveBeenCalledWith(
1,
expect.stringContaining('Salle IBIS')
);
// Verify the updated formData
const updatedFormData = JSON.parse(
vi.mocked(db.updateRequestSalles).mock.calls[0][1]
);
expect(updatedFormData.sallesSelectionnees).toEqual(['Salle IBIS', 'Salle PELICAN']);
expect(updatedFormData.sallesIds).toEqual(['salle_ibis', 'salle_pelican']);
expect(updatedFormData.historiqueChangementsSalles).toHaveLength(1);
expect(updatedFormData.historiqueChangementsSalles[0].anciennesSalles).toEqual(['Salle TOUCAN']);
expect(updatedFormData.historiqueChangementsSalles[0].raison).toBe('Besoin d\'une salle plus grande');
});
it('should create request history entry for salle change', async () => {
vi.mocked(db.createRequestHistory).mockResolvedValue(undefined as any);
await db.createRequestHistory({
requestId: 1,
action: 'changement_salle',
ancienStatut: 'validee',
nouveauStatut: 'validee',
userId: 1,
commentaire: 'Changement de salle(s) : Salle TOUCAN → Salle IBIS. Raison : Besoin d\'une salle plus grande',
});
expect(db.createRequestHistory).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 1,
action: 'changement_salle',
commentaire: expect.stringContaining('Salle TOUCAN'),
})
);
});
it('should reject change for non-salle requests', async () => {
const nonSalleRequest = {
id: 2,
type: 'subvention_fonctionnement',
associationId: 10,
status: 'validee',
formData: '{}',
};
vi.mocked(db.getRequestById).mockResolvedValue(nonSalleRequest as any);
const request = await db.getRequestById(2);
expect(request!.type).not.toBe('demande_salle');
// In the actual procedure, this would throw a TRPCError
});
it('should send notification to association on salle change', async () => {
vi.mocked(db.getAssociationById).mockResolvedValue({
id: 10,
nomAssociation: 'Association Test',
emailContact: 'test@asso.fr',
} as any);
const association = await db.getAssociationById(10);
expect(association).toBeDefined();
await notifyOwner({
title: 'Changement de salle pour votre réservation',
content: `Bonjour ${association!.nomAssociation}, votre salle a été modifiée.`,
});
expect(notifyOwner).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Changement de salle pour votre réservation',
content: expect.stringContaining('Association Test'),
})
);
});
it('should preserve change history across multiple changes', () => {
const formData = {
sallesSelectionnees: ['Salle PELICAN'],
sallesIds: ['salle_pelican'],
historiqueChangementsSalles: [
{
date: '2026-03-10T10:00:00Z',
anciennesSalles: ['Salle TOUCAN'],
nouvellesSalles: ['Salle IBIS'],
raison: 'Premier changement',
parAdmin: 1,
},
{
date: '2026-03-11T14:00:00Z',
anciennesSalles: ['Salle IBIS'],
nouvellesSalles: ['Salle PELICAN'],
raison: 'Deuxième changement',
parAdmin: 1,
},
],
};
expect(formData.historiqueChangementsSalles).toHaveLength(2);
expect(formData.historiqueChangementsSalles[0].anciennesSalles).toEqual(['Salle TOUCAN']);
expect(formData.historiqueChangementsSalles[1].nouvellesSalles).toEqual(['Salle PELICAN']);
expect(formData.sallesSelectionnees).toEqual(['Salle PELICAN']);
});
});
});

View file

@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
describe("Cancel request feature", () => {
describe("Cancel procedure validation", () => {
it("should define the cancel procedure in the request router", async () => {
const { appRouter } = await import("./routers");
// Verify the cancel procedure exists
expect(appRouter._def.procedures).toHaveProperty("request.cancel");
}, 15000);
it("should have cancel as a callable procedure", async () => {
const { appRouter } = await import("./routers");
const procedure = (appRouter._def.procedures as any)["request.cancel"];
expect(procedure).toBeDefined();
expect(procedure._def).toBeDefined();
}, 15000);
});
describe("Schema validation", () => {
it("should include 'annulee' in request statuses", async () => {
const { requestStatuses } = await import("../drizzle/schema");
expect(requestStatuses).toContain("annulee");
});
it("should include 'annulation' in request history actions", async () => {
const { requestHistory } = await import("../drizzle/schema");
const actionColumn = requestHistory.action;
// Verify the column exists and has the right enum values
expect(actionColumn).toBeDefined();
expect(actionColumn.enumValues).toContain("annulation");
});
it("should include 'annulation_demande' in admin notification types", async () => {
const { adminNotifications } = await import("../drizzle/schema");
const typeColumn = adminNotifications.type;
expect(typeColumn).toBeDefined();
expect(typeColumn.enumValues).toContain("annulation_demande");
});
it("should include 'modification_demande' in admin notification types", async () => {
const { adminNotifications } = await import("../drizzle/schema");
const typeColumn = adminNotifications.type;
expect(typeColumn).toBeDefined();
expect(typeColumn.enumValues).toContain("modification_demande");
});
});
describe("Update procedure with notification", () => {
it("should define the update procedure in the request router", async () => {
const { appRouter } = await import("./routers");
expect(appRouter._def.procedures).toHaveProperty("request.update");
}, 15000);
it("should have update as a callable procedure", async () => {
const { appRouter } = await import("./routers");
const procedure = (appRouter._def.procedures as any)["request.update"];
expect(procedure).toBeDefined();
expect(procedure._def).toBeDefined();
}, 15000);
});
describe("Calendar integration", () => {
it("getApprovedReservations should only return validated requests (not annulee)", async () => {
// The getApprovedReservations function filters by status = 'validee'
// so cancelled requests are automatically excluded from the calendar
const dbModule = await import("./db");
expect(typeof dbModule.getApprovedReservations).toBe("function");
});
});
});

1649
server/db.ts Normal file

File diff suppressed because it is too large Load diff

280
server/dsu.test.ts Normal file
View file

@ -0,0 +1,280 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the database module
vi.mock('./db', () => ({
getRequestById: vi.fn(),
updateRequest: vi.fn(),
createRequestHistory: vi.fn(),
createAuditLog: vi.fn(),
}));
import * as db from './db';
describe('DSU (Direction des Services aux Usagers) - Cadre réservé', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Merging DSU data into formData', () => {
it('should merge DSU data into existing formData for demande_salle', () => {
const existingFormData = {
nomAssociation: 'Association Test',
sallesSelectionnees: ['Salle TOUCAN'],
dateReservation: '2026-04-15',
motifReservation: 'Réunion annuelle',
formulaireType: 'reservation_salle_mjs',
};
const dsuData = {
dateReception: '2026-03-10',
avisDSU: 'favorable' as const,
conditionsParticulieres: 'Nettoyage obligatoire',
cautionRequise: true,
montantCaution: '500',
assuranceRequise: true,
horairesImposes: '8h00 - 22h00',
responsableDSU: 'Jean Dupont',
dateDecision: '2026-03-11',
observationsDSU: 'RAS',
};
// Simulate the merge logic from routers.ts
const merged = { ...existingFormData };
(merged as any).cadreDSU = {
...dsuData,
rempliPar: 1,
dateRemplissage: new Date().toISOString(),
};
const result = JSON.parse(JSON.stringify(merged));
// Verify original data is preserved
expect(result.nomAssociation).toBe('Association Test');
expect(result.sallesSelectionnees).toEqual(['Salle TOUCAN']);
expect(result.dateReservation).toBe('2026-04-15');
expect(result.motifReservation).toBe('Réunion annuelle');
// Verify DSU data is added
expect(result.cadreDSU).toBeDefined();
expect(result.cadreDSU.avisDSU).toBe('favorable');
expect(result.cadreDSU.dateReception).toBe('2026-03-10');
expect(result.cadreDSU.conditionsParticulieres).toBe('Nettoyage obligatoire');
expect(result.cadreDSU.cautionRequise).toBe(true);
expect(result.cadreDSU.montantCaution).toBe('500');
expect(result.cadreDSU.assuranceRequise).toBe(true);
expect(result.cadreDSU.horairesImposes).toBe('8h00 - 22h00');
expect(result.cadreDSU.responsableDSU).toBe('Jean Dupont');
expect(result.cadreDSU.dateDecision).toBe('2026-03-11');
expect(result.cadreDSU.observationsDSU).toBe('RAS');
expect(result.cadreDSU.rempliPar).toBe(1);
expect(result.cadreDSU.dateRemplissage).toBeDefined();
});
it('should not add DSU data for non-salle request types', () => {
const requestType = 'subvention_fonctionnement';
const dsuData = {
avisDSU: 'favorable' as const,
responsableDSU: 'Jean Dupont',
};
// Simulate the condition check from routers.ts
const shouldAddDsu = requestType === 'demande_salle';
expect(shouldAddDsu).toBe(false);
});
it('should handle empty existing formData gracefully', () => {
const existingFormData = null;
const dsuData = {
avisDSU: 'defavorable' as const,
responsableDSU: 'Marie Martin',
observationsDSU: 'Salle non disponible',
};
// Simulate the merge logic
const parsed = existingFormData ? JSON.parse(existingFormData) : {};
parsed.cadreDSU = {
...dsuData,
rempliPar: 2,
dateRemplissage: new Date().toISOString(),
};
const result = JSON.parse(JSON.stringify(parsed));
expect(result.cadreDSU).toBeDefined();
expect(result.cadreDSU.avisDSU).toBe('defavorable');
expect(result.cadreDSU.responsableDSU).toBe('Marie Martin');
});
it('should handle favorable_avec_reserves avis correctly', () => {
const existingFormData = { nomAssociation: 'Test' };
const dsuData = {
avisDSU: 'favorable_avec_reserves' as const,
conditionsParticulieres: 'Sous réserve de présentation de l\'attestation d\'assurance',
cautionRequise: true,
montantCaution: '1000',
};
const merged = { ...existingFormData, cadreDSU: { ...dsuData, rempliPar: 1, dateRemplissage: new Date().toISOString() } };
const result = JSON.parse(JSON.stringify(merged));
expect(result.cadreDSU.avisDSU).toBe('favorable_avec_reserves');
expect(result.cadreDSU.conditionsParticulieres).toContain('attestation d\'assurance');
expect(result.cadreDSU.cautionRequise).toBe(true);
expect(result.cadreDSU.montantCaution).toBe('1000');
});
it('should merge financial decision into an existing salle request dossier', () => {
const existingFormData = {
nomAssociation: 'Association Test',
sallesSelectionnees: ['Salle TOUCAN'],
dateReservation: '2026-06-20',
cadreDSU: {
responsableDSU: 'Agent DSU',
},
};
const financialDecision = {
financialMode: 'location_payante' as const,
depositRequired: true,
depositAmountCents: 45000,
rentalAmountCents: 12500,
pricingNotes: 'Facturation exceptionnelle pour occupation prolongée',
contractStatus: 'a_generer' as const,
};
const merged = structuredClone(existingFormData) as any;
merged.cadreDSU = {
...merged.cadreDSU,
cautionRequise: financialDecision.depositRequired,
montantCaution: String(financialDecision.depositAmountCents / 100),
materielEvent: {
financialDecision,
},
};
expect(merged.cadreDSU.cautionRequise).toBe(true);
expect(merged.cadreDSU.montantCaution).toBe('450');
expect(merged.cadreDSU.materielEvent.financialDecision.financialMode).toBe('location_payante');
expect(merged.cadreDSU.materielEvent.financialDecision.rentalAmountCents).toBe(12500);
});
it('should merge material financial decision into the existing material request data', () => {
const existingFormData = {
commune: 'Kourou',
service: 'Vie associative',
quantitesDemandees: { tente3x3: '2' },
cadreDSU: {
dateReception: '2026-05-10',
materielEvent: {
itemsAccordes: { tente3x3: true },
quantitesAccordees: { tente3x3: '2' },
},
},
};
const financialDecision = {
financialMode: 'location_payante' as const,
depositRequired: true,
depositAmountCents: 25000,
rentalAmountCents: 9500,
pricingNotes: 'Tarif exceptionnel pour événement intercommunal',
contractStatus: 'a_generer' as const,
};
const merged = structuredClone(existingFormData) as any;
merged.cadreDSU = {
...merged.cadreDSU,
responsableDSU: 'Admin Logistique',
materielEvent: {
...merged.cadreDSU.materielEvent,
financialDecision,
},
};
expect(merged.cadreDSU.materielEvent.itemsAccordes.tente3x3).toBe(true);
expect(merged.cadreDSU.materielEvent.quantitesAccordees.tente3x3).toBe('2');
expect(merged.cadreDSU.materielEvent.financialDecision.financialMode).toBe('location_payante');
expect(merged.cadreDSU.materielEvent.financialDecision.depositRequired).toBe(true);
expect(merged.cadreDSU.materielEvent.financialDecision.depositAmountCents).toBe(25000);
expect(merged.cadreDSU.materielEvent.financialDecision.rentalAmountCents).toBe(9500);
expect(merged.cadreDSU.materielEvent.financialDecision.pricingNotes).toContain('Tarif exceptionnel');
});
});
describe('DSU avis labels mapping', () => {
const avisDSULabels: Record<string, { label: string; className: string }> = {
favorable: { label: 'Favorable', className: 'bg-green-100 text-green-800 border-green-200' },
defavorable: { label: 'Défavorable', className: 'bg-red-100 text-red-800 border-red-200' },
favorable_avec_reserves: { label: 'Favorable avec réserves', className: 'bg-amber-100 text-amber-800 border-amber-200' },
};
it('should have correct labels for all avis types', () => {
expect(avisDSULabels.favorable.label).toBe('Favorable');
expect(avisDSULabels.defavorable.label).toBe('Défavorable');
expect(avisDSULabels.favorable_avec_reserves.label).toBe('Favorable avec réserves');
});
it('should have appropriate CSS classes for visual distinction', () => {
expect(avisDSULabels.favorable.className).toContain('green');
expect(avisDSULabels.defavorable.className).toContain('red');
expect(avisDSULabels.favorable_avec_reserves.className).toContain('amber');
});
});
describe('Process mutation with DSU data', () => {
it('should call updateRequest with merged formData when DSU data is provided', async () => {
const mockRequest = {
id: 1,
type: 'demande_salle',
status: 'soumise',
formData: JSON.stringify({
nomAssociation: 'Test Asso',
sallesSelectionnees: ['Salle TOUCAN'],
}),
};
(db.getRequestById as any).mockResolvedValue(mockRequest);
(db.updateRequest as any).mockResolvedValue(undefined);
(db.createRequestHistory as any).mockResolvedValue(undefined);
(db.createAuditLog as any).mockResolvedValue(undefined);
const dsuData = {
avisDSU: 'favorable' as const,
responsableDSU: 'Admin Test',
dateReception: '2026-03-10',
dateDecision: '2026-03-11',
};
// Simulate the process logic
const existingFormData = JSON.parse(mockRequest.formData);
existingFormData.cadreDSU = {
...dsuData,
rempliPar: 1,
dateRemplissage: new Date().toISOString(),
};
const updatedFormData = JSON.stringify(existingFormData);
await db.updateRequest(mockRequest.id, {
status: 'validee' as any,
commentaireAdmin: 'Approuvé',
traitePar: 1,
dateTraitement: new Date(),
formData: updatedFormData,
});
expect(db.updateRequest).toHaveBeenCalledWith(
1,
expect.objectContaining({
status: 'validee',
formData: expect.stringContaining('cadreDSU'),
})
);
// Verify the formData contains the DSU data
const callArgs = (db.updateRequest as any).mock.calls[0][1];
const parsedFormData = JSON.parse(callArgs.formData);
expect(parsedFormData.cadreDSU.avisDSU).toBe('favorable');
expect(parsedFormData.cadreDSU.responsableDSU).toBe('Admin Test');
expect(parsedFormData.nomAssociation).toBe('Test Asso');
});
});
});

159
server/emailActions.test.ts Normal file
View file

@ -0,0 +1,159 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// Mock db module
vi.mock("./db", () => ({
getEmailActionToken: vi.fn(),
getRequestById: vi.fn(),
updateRequest: vi.fn(),
createRequestHistory: vi.fn(),
markTokenAsUsed: vi.fn(),
createEmailActionToken: vi.fn(),
}));
import * as db from "./db";
describe("Email Action Token Logic", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should reject an invalid token", async () => {
(db.getEmailActionToken as any).mockResolvedValue(null);
const token = await db.getEmailActionToken("invalid-token");
expect(token).toBeNull();
});
it("should reject an already used token", async () => {
(db.getEmailActionToken as any).mockResolvedValue({
id: 1,
token: "test-token",
requestId: 1,
action: "validee",
used: true,
expiresAt: new Date(Date.now() + 86400000),
usedAt: new Date(),
usedBy: 1,
createdAt: new Date(),
});
const token = await db.getEmailActionToken("test-token");
expect(token).not.toBeNull();
expect(token!.used).toBe(true);
});
it("should reject an expired token", async () => {
const expiredDate = new Date(Date.now() - 86400000); // 1 day ago
(db.getEmailActionToken as any).mockResolvedValue({
id: 1,
token: "expired-token",
requestId: 1,
action: "validee",
used: false,
expiresAt: expiredDate,
usedAt: null,
usedBy: null,
createdAt: new Date(),
});
const token = await db.getEmailActionToken("expired-token");
expect(token).not.toBeNull();
expect(new Date() > token!.expiresAt).toBe(true);
});
it("should accept a valid token for validation", async () => {
const futureDate = new Date(Date.now() + 7 * 86400000); // 7 days from now
(db.getEmailActionToken as any).mockResolvedValue({
id: 1,
token: "valid-token",
requestId: 5,
action: "validee",
used: false,
expiresAt: futureDate,
usedAt: null,
usedBy: null,
createdAt: new Date(),
});
(db.getRequestById as any).mockResolvedValue({
id: 5,
titre: "Subvention test",
type: "subvention_projet",
status: "soumise",
description: "Test description",
montantDemande: 100000,
associationId: 1,
});
const token = await db.getEmailActionToken("valid-token");
expect(token).not.toBeNull();
expect(token!.used).toBe(false);
expect(new Date() < token!.expiresAt).toBe(true);
expect(token!.action).toBe("validee");
const request = await db.getRequestById(token!.requestId);
expect(request).not.toBeNull();
expect(request!.status).not.toBe("validee");
expect(request!.status).not.toBe("refusee");
});
it("should accept a valid token for refusal", async () => {
const futureDate = new Date(Date.now() + 7 * 86400000);
(db.getEmailActionToken as any).mockResolvedValue({
id: 2,
token: "refuse-token",
requestId: 5,
action: "refusee",
used: false,
expiresAt: futureDate,
usedAt: null,
usedBy: null,
createdAt: new Date(),
});
const token = await db.getEmailActionToken("refuse-token");
expect(token).not.toBeNull();
expect(token!.action).toBe("refusee");
});
it("should reject processing an already validated request", async () => {
(db.getRequestById as any).mockResolvedValue({
id: 5,
titre: "Subvention test",
type: "subvention_projet",
status: "validee",
associationId: 1,
});
const request = await db.getRequestById(5);
expect(request!.status === "validee" || request!.status === "refusee").toBe(true);
});
it("should create email action tokens with correct expiry", async () => {
(db.createEmailActionToken as any).mockResolvedValue("new-token");
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await db.createEmailActionToken({
token: "validate-token-123",
requestId: 10,
action: "validee",
expiresAt,
});
expect(db.createEmailActionToken).toHaveBeenCalledWith({
token: "validate-token-123",
requestId: 10,
action: "validee",
expiresAt,
});
});
it("should mark token as used after processing", async () => {
(db.markTokenAsUsed as any).mockResolvedValue(undefined);
await db.markTokenAsUsed("used-token", 1);
expect(db.markTokenAsUsed).toHaveBeenCalledWith("used-token", 1);
});
});

369
server/emailActions.ts Normal file
View file

@ -0,0 +1,369 @@
import type { Express } from "express";
import * as db from "./db";
import {
getSalleWorkflowData,
updateSalleWorkflowQuoteStatus,
} from "./salleReservationWorkflow";
/**
* Register Express routes for email-based actions (validate/refuse requests)
* These routes handle one-time token links sent in notification emails
*/
export function registerEmailActionRoutes(app: Express) {
// Handle email action token - renders a confirmation page
app.get("/api/email-action/:token", async (req, res) => {
try {
const { token } = req.params;
const actionToken = await db.getEmailActionToken(token);
if (!actionToken) {
return res.status(404).send(renderPage("Lien invalide", "Ce lien d'action n'existe pas ou a été supprimé.", "error"));
}
if (actionToken.used) {
return res.status(410).send(renderPage("Action déjà effectuée", "Ce lien a déjà été utilisé. La demande a été traitée.", "warning"));
}
if (new Date() > actionToken.expiresAt) {
return res.status(410).send(renderPage("Lien expiré", "Ce lien d'action a expiré. Veuillez traiter la demande depuis l'interface d'administration.", "warning"));
}
// Get request details
const request = await db.getRequestById(actionToken.requestId);
if (!request) {
return res.status(404).send(renderPage("Demande introuvable", "La demande associée à ce lien n'existe plus.", "error"));
}
const isQuoteAction = actionToken.action === "acceptation_devis_salle" || actionToken.action === "refus_devis_salle";
if (isQuoteAction) {
const workflow = getSalleWorkflowData(request.formData);
if (workflow.quoteStatus !== "en_attente_association") {
return res.status(410).send(
renderPage(
"Réponse déjà enregistrée",
"L'association a déjà répondu à ce devis. Retournez au portail pour consulter l'état actuel du dossier.",
"warning"
)
);
}
}
// Check if request is still in a processable state
if (request.status === 'validee' || request.status === 'refusee') {
return res.status(410).send(renderPage("Demande déjà traitée", `Cette demande a déjà été ${request.status === 'validee' ? 'validée' : 'refusée'}.`, "warning"));
}
const actionLabel = actionToken.action === 'validee'
? 'Valider'
: actionToken.action === 'acceptation_devis_salle'
? "Accepter le devis"
: 'Refuser';
const actionColor = actionToken.action === 'validee' || actionToken.action === "acceptation_devis_salle" ? '#18753C' : '#CE0500';
const actionEmoji = actionToken.action === 'validee' || actionToken.action === "acceptation_devis_salle" ? '✅' : '❌';
// Render confirmation page
return res.send(renderConfirmationPage(request, actionToken, actionLabel, actionColor, actionEmoji, isQuoteAction));
} catch (error) {
console.error("[EmailAction] Error:", error);
return res.status(500).send(renderPage("Erreur serveur", "Une erreur inattendue s'est produite. Veuillez réessayer.", "error"));
}
});
// Handle confirmation POST
app.post("/api/email-action/:token/confirm", async (req, res) => {
try {
const { token } = req.params;
const actionToken = await db.getEmailActionToken(token);
if (!actionToken) {
return res.status(404).send(renderPage("Lien invalide", "Ce lien d'action n'existe pas.", "error"));
}
if (actionToken.used) {
return res.status(410).send(renderPage("Action déjà effectuée", "Ce lien a déjà été utilisé.", "warning"));
}
if (new Date() > actionToken.expiresAt) {
return res.status(410).send(renderPage("Lien expiré", "Ce lien a expiré.", "warning"));
}
const request = await db.getRequestById(actionToken.requestId);
if (!request) {
return res.status(404).send(renderPage("Demande introuvable", "La demande n'existe plus.", "error"));
}
if (actionToken.action === "acceptation_devis_salle" || actionToken.action === "refus_devis_salle") {
const workflow = getSalleWorkflowData(request.formData);
if (workflow.quoteStatus !== "en_attente_association") {
return res.status(410).send(
renderPage(
"Réponse déjà enregistrée",
"L'association a déjà répondu à ce devis. Aucun nouvel enregistrement n'est nécessaire.",
"warning"
)
);
}
}
if (request.status === 'validee' || request.status === 'refusee') {
return res.status(410).send(renderPage("Demande déjà traitée", `Cette demande a déjà été ${request.status === 'validee' ? 'validée' : 'refusée'}.`, "warning"));
}
let successTitle = "";
let successMessage = "";
if (actionToken.action === "acceptation_devis_salle" || actionToken.action === "refus_devis_salle") {
const nextWorkflowFormData = updateSalleWorkflowQuoteStatus(
request.formData,
actionToken.action === "acceptation_devis_salle" ? "accepte" : "refuse"
);
const workflow = getSalleWorkflowData(nextWorkflowFormData);
const isAccepted = actionToken.action === "acceptation_devis_salle";
const nextStatus = isAccepted ? request.status : "refusee";
await db.updateRequest(actionToken.requestId, {
formData: nextWorkflowFormData,
status: nextStatus,
dateTraitement: isAccepted ? request.dateTraitement : new Date(),
commentaireAdmin: isAccepted
? request.commentaireAdmin
: ((request.commentaireAdmin ? `${request.commentaireAdmin}\n\n` : "") + "Devis refusé par l'association via email"),
});
await db.createRequestHistory({
requestId: actionToken.requestId,
action: isAccepted ? "modification" : "refus",
ancienStatut: request.status,
nouveauStatut: nextStatus,
userId: 0,
commentaire: isAccepted
? "Devis accepté par l'association via email"
: "Devis refusé par l'association via email",
});
await db.createAdminNotification({
type: "systeme",
titre: isAccepted ? `Devis accepté : ${request.titre}` : `Devis refusé : ${request.titre}`,
message: isAccepted
? `L'association a accepté le devis pour la demande "${request.titre}".`
: `L'association a refusé le devis pour la demande "${request.titre}".`,
lien: `/dashboard/requests/${request.id}`,
});
successTitle = isAccepted ? "✅ Devis accepté" : "❌ Devis refusé";
successMessage = isAccepted
? `Votre accord a bien été enregistré pour "${request.titre}". Le dossier repart vers l'hôtesse pour transmission à la Directrice.`
: `Votre refus a bien été enregistré pour "${request.titre}".`;
} else {
// Process the admin validation/refusal action
const newStatus = actionToken.action;
const commentaire = req.body?.commentaire || (newStatus === 'validee' ? 'Demande validée via email' : 'Demande refusée via email');
await db.updateRequest(actionToken.requestId, {
status: newStatus,
commentaireAdmin: commentaire,
dateTraitement: new Date(),
});
await db.createRequestHistory({
requestId: actionToken.requestId,
action: newStatus === 'validee' ? 'validation' : 'refus',
ancienStatut: request.status,
nouveauStatut: newStatus,
userId: actionToken.usedBy || 0,
commentaire: commentaire,
});
successTitle = newStatus === 'validee' ? '✅ Demande validée' : '❌ Demande refusée';
successMessage = newStatus === 'validee'
? `La demande "${request.titre}" a été validée avec succès.`
: `La demande "${request.titre}" a été refusée.`;
}
// Mark token as used
await db.markTokenAsUsed(token, 0);
return res.send(renderPage(successTitle, successMessage, "success"));
} catch (error) {
console.error("[EmailAction] Confirm error:", error);
return res.status(500).send(renderPage("Erreur serveur", "Une erreur s'est produite lors du traitement.", "error"));
}
});
}
function renderConfirmationPage(
request: any,
actionToken: any,
actionLabel: string,
actionColor: string,
actionEmoji: string,
isQuoteAction: boolean
): string {
const statusLabels: Record<string, string> = {
brouillon: 'Brouillon',
soumise: 'Soumise',
en_cours_traitement: 'En cours',
information_complementaire: 'Info. complémentaire',
validee: 'Validée',
refusee: 'Refusée',
};
const typeLabels: Record<string, string> = {
subvention_fonctionnement: 'Subvention de fonctionnement',
subvention_projet: 'Subvention de projet',
agrement_jeunesse_education: 'Agrément Jeunesse et Éducation',
agrement_sport: 'Agrément Sport',
autorisation_occupation: 'Autorisation d\'occupation',
demande_salle: 'Demande de salle',
demande_materiel_evenementiel: 'Demande du matériel événementiel',
autre: 'Autre',
};
const montant = request.montantDemande ? `${(request.montantDemande / 100).toLocaleString('fr-FR')}` : 'Non spécifié';
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${actionEmoji} ${actionLabel} la demande - Portail Associations</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Marianne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f6f6f6; color: #161616; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; }
.container { max-width: 600px; width: 100%; background: white; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden; }
.header { background: #000091; color: white; padding: 24px 32px; }
.header h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.header p { font-size: 14px; opacity: 0.8; }
.content { padding: 32px; }
.badge { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-bottom: 16px; }
.badge-action { background: ${actionColor}15; color: ${actionColor}; border: 1px solid ${actionColor}30; }
.info-grid { display: grid; gap: 12px; margin-bottom: 24px; }
.info-row { display: flex; border-bottom: 1px solid #eee; padding-bottom: 8px; }
.info-label { font-weight: 600; color: #666; min-width: 140px; font-size: 14px; }
.info-value { font-size: 14px; color: #161616; }
.warning-box { background: #FEF7DA; border-left: 4px solid #D64D00; padding: 16px; border-radius: 0 4px 4px 0; margin-bottom: 24px; }
.warning-box p { font-size: 14px; color: #3A3A3A; }
.comment-box { margin-bottom: 24px; }
.comment-box label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 8px; }
.comment-box textarea { width: 100%; min-height: 100px; padding: 12px; border: 1px solid #ddd; border-radius: 4px; font-family: inherit; font-size: 14px; resize: vertical; }
.comment-box textarea:focus { outline: none; border-color: #000091; box-shadow: 0 0 0 2px #000091 20; }
.actions { display: flex; gap: 12px; justify-content: flex-end; }
.btn { padding: 12px 24px; border-radius: 4px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; transition: opacity 0.2s; }
.btn:hover { opacity: 0.85; }
.btn-primary { background: ${actionColor}; color: white; }
.btn-secondary { background: white; color: #161616; border: 1px solid #ddd; }
.footer { padding: 16px 32px; background: #f6f6f6; border-top: 1px solid #eee; text-align: center; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Portail Associations</h1>
<p>Traitement de demande par email</p>
</div>
<div class="content">
<span class="badge badge-action">${actionEmoji} ${actionLabel}</span>
<div class="info-grid">
<div class="info-row">
<span class="info-label">Demande</span>
<span class="info-value">${escapeHtml(request.titre)}</span>
</div>
<div class="info-row">
<span class="info-label">Type</span>
<span class="info-value">${typeLabels[request.type] || request.type}</span>
</div>
<div class="info-row">
<span class="info-label">Statut actuel</span>
<span class="info-value">${statusLabels[request.status] || request.status}</span>
</div>
<div class="info-row">
<span class="info-label">Montant demandé</span>
<span class="info-value">${montant}</span>
</div>
${request.description ? `<div class="info-row">
<span class="info-label">Description</span>
<span class="info-value">${escapeHtml(request.description).substring(0, 200)}${request.description.length > 200 ? '...' : ''}</span>
</div>` : ''}
</div>
<div class="warning-box">
<p><strong>Attention :</strong> Vous êtes sur le point de <strong>${actionLabel.toLowerCase()}</strong> ${isQuoteAction ? 'ce devis / cette mise à disposition' : 'cette demande'}. Cette action est irréversible depuis cet email.</p>
</div>
<form method="POST" action="/api/email-action/${actionToken.token}/confirm">
<div class="comment-box">
<label for="commentaire">Commentaire (optionnel) :</label>
<textarea id="commentaire" name="commentaire" placeholder="${isQuoteAction ? "Ajoutez un commentaire à destination de l'administration..." : "Ajoutez un commentaire pour l'association..."}"></textarea>
</div>
<div class="actions">
<a href="javascript:window.close()" class="btn btn-secondary">Annuler</a>
<button type="submit" class="btn btn-primary">${actionEmoji} Confirmer : ${actionLabel}</button>
</div>
</form>
</div>
<div class="footer">
<p>Ce lien expire le ${actionToken.expiresAt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })} Portail Associations</p>
</div>
</div>
</body>
</html>`;
}
function renderPage(title: string, message: string, type: "success" | "error" | "warning"): string {
const colors = {
success: { bg: '#B8FEC9', border: '#18753C', icon: '✅' },
error: { bg: '#FFE9E6', border: '#CE0500', icon: '❌' },
warning: { bg: '#FEF7DA', border: '#D64D00', icon: '⚠️' },
};
const c = colors[type];
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} - Portail Associations</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Marianne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f6f6f6; color: #161616; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; }
.container { max-width: 500px; width: 100%; background: white; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden; }
.header { background: #000091; color: white; padding: 24px 32px; }
.header h1 { font-size: 20px; font-weight: 700; }
.content { padding: 32px; text-align: center; }
.icon { font-size: 48px; margin-bottom: 16px; }
.alert { background: ${c.bg}; border-left: 4px solid ${c.border}; padding: 16px; border-radius: 0 4px 4px 0; margin-bottom: 24px; text-align: left; }
.alert h2 { font-size: 16px; margin-bottom: 8px; color: ${c.border}; }
.alert p { font-size: 14px; color: #3A3A3A; }
.btn { display: inline-block; padding: 12px 24px; background: #000091; color: white; border-radius: 4px; text-decoration: none; font-weight: 600; font-size: 14px; margin-top: 16px; }
.btn:hover { opacity: 0.85; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Portail Associations</h1>
</div>
<div class="content">
<div class="icon">${c.icon}</div>
<div class="alert">
<h2>${escapeHtml(title)}</h2>
<p>${escapeHtml(message)}</p>
</div>
<a href="/" class="btn">Retour au portail</a>
</div>
</div>
</body>
</html>`;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

343
server/emailRecap.ts Normal file
View file

@ -0,0 +1,343 @@
/**
* Génère un récapitulatif complet d'une demande traitée pour diffusion interne.
*/
const requestTypeLabels: Record<string, string> = {
subvention_fonctionnement: "Subvention de fonctionnement",
subvention_projet: "Subvention de projet",
agrement_jeunesse_education: "Agrément Jeunesse et Éducation Populaire",
agrement_sport: "Agrément Sport",
autorisation_occupation: "Autorisation d'occupation",
demande_salle: "Demande de salle / local",
demande_materiel_evenementiel: "Demande de matériel événementiel",
autre: "Autre demande",
};
const statusLabels: Record<string, string> = {
validee: "Validée",
refusee: "Refusée",
en_cours_traitement: "En cours de traitement",
information_complementaire: "Information complémentaire demandée",
};
const statusBadgeStyles: Record<string, { label: string; bg: string; border: string; text: string }> = {
validee: { label: "VALIDÉE", bg: "#ecfdf3", border: "#a7f3d0", text: "#166534" },
refusee: { label: "REFUSÉE", bg: "#fef2f2", border: "#fecaca", text: "#b91c1c" },
en_cours_traitement: { label: "EN COURS", bg: "#eff6ff", border: "#bfdbfe", text: "#1d4ed8" },
information_complementaire: { label: "INFO REQUISE", bg: "#fff7ed", border: "#fed7aa", text: "#c2410c" },
};
interface RecapData {
request: {
id: number;
titre: string;
type: string;
status: string;
description: string | null;
montantDemande: number | null;
montantAccorde: number | null;
commentaireAdmin: string | null;
formData: string | null;
dateSubmission: Date | null;
dateTraitement: Date | null;
createdAt: Date | null;
};
association: {
nomAssociation: string;
siret: string | null;
adresse: string | null;
codePostal: string | null;
ville: string | null;
telephone: string | null;
emailContact: string | null;
nomRepresentant: string | null;
} | null;
traitePar: string;
documents?: { name: string; type: string }[];
serviceLabel?: string | null;
}
function formatDateTimeFr(date: Date | string | null | undefined) {
if (!date) return "Non renseignée";
return new Date(date).toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatDateFr(date: string | null | undefined) {
if (!date) return "Non renseignée";
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatAmount(amount: number | null | undefined) {
if (amount == null) return "Non renseigné";
return `${(amount / 100).toLocaleString("fr-FR")}`;
}
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function parseRequestHighlights(requestType: string, formData: any): Array<{ label: string; value: string }> {
if (!formData || typeof formData !== "object") return [];
if (requestType === "demande_salle") {
const salles = Array.isArray(formData.sallesNoms)
? formData.sallesNoms.join(", ")
: Array.isArray(formData.sallesSelectionnees)
? formData.sallesSelectionnees.join(", ")
: "";
const highlights = [
salles ? { label: "Salle(s)", value: salles } : null,
formData.dateReservation ? { label: "Date de début", value: formatDateFr(formData.dateReservation) } : null,
formData.dateFinReservation ? { label: "Date de fin", value: formatDateFr(formData.dateFinReservation) } : null,
formData.heureDebut || formData.heureFin
? { label: "Créneau", value: `${formData.heureDebut || "?"} - ${formData.heureFin || "?"}` }
: null,
formData.nombreParticipants ? { label: "Participants", value: String(formData.nombreParticipants) } : null,
formData.motifReservation ? { label: "Motif", value: String(formData.motifReservation) } : null,
];
return highlights.filter(Boolean) as Array<{ label: string; value: string }>;
}
if (requestType === "demande_materiel_evenementiel") {
const highlights = [
formData.commune ? { label: "Commune", value: String(formData.commune) } : null,
formData.dateDebutManifestation || formData.dateManifestation
? { label: "Début manifestation", value: formatDateFr(formData.dateDebutManifestation || formData.dateManifestation) }
: null,
formData.dateFinManifestation
? { label: "Fin manifestation", value: formatDateFr(formData.dateFinManifestation) }
: null,
formData.dateRestitution ? { label: "Restitution", value: formatDateFr(formData.dateRestitution) } : null,
formData.motifDemande ? { label: "Motif", value: String(formData.motifDemande) } : null,
];
return highlights.filter(Boolean) as Array<{ label: string; value: string }>;
}
return [
formData.dateDebut ? { label: "Date de début", value: formatDateFr(formData.dateDebut) } : null,
formData.dateFin ? { label: "Date de fin", value: formatDateFr(formData.dateFin) } : null,
formData.objetProjet ? { label: "Objet", value: String(formData.objetProjet) } : null,
].filter(Boolean) as Array<{ label: string; value: string }>;
}
export function generateRequestRecapEmail(data: RecapData): { title: string; content: string; html: string } {
const { request, association, traitePar, documents, serviceLabel } = data;
const typeLabel = requestTypeLabels[request.type] || request.type;
const statusLabel = statusLabels[request.status] || request.status;
const badge = statusBadgeStyles[request.status] || {
label: statusLabel.toUpperCase(),
bg: "#f3f4f6",
border: "#d1d5db",
text: "#374151",
};
let formData: any = {};
try {
formData = request.formData ? JSON.parse(request.formData) : {};
} catch {
formData = {};
}
const highlights = parseRequestHighlights(request.type, formData);
const subject = `Demande traitée #${request.id} - ${statusLabel} - ${request.titre}`;
const textSections: string[] = [
"Récapitulatif de demande traitée",
"",
`Demande n°${request.id} - ${statusLabel}`,
`Type : ${typeLabel}`,
`Association : ${association?.nomAssociation || "Non renseignée"}`,
`Traitée par : ${traitePar}`,
`Date de traitement : ${formatDateTimeFr(request.dateTraitement)}`,
serviceLabel ? `Service concerné : ${serviceLabel}` : "",
request.commentaireAdmin ? `Commentaire : ${request.commentaireAdmin}` : "",
request.montantAccorde != null ? `Montant accordé : ${formatAmount(request.montantAccorde)}` : "",
"",
"Informations de l'association",
`- Nom : ${association?.nomAssociation || "Non renseigné"}`,
association?.siret ? `- SIRET : ${association.siret}` : "",
association?.adresse ? `- Adresse : ${association.adresse}${association.codePostal ? `, ${association.codePostal}` : ""}${association.ville ? ` ${association.ville}` : ""}` : "",
association?.nomRepresentant ? `- Représentant : ${association.nomRepresentant}` : "",
association?.telephone ? `- Téléphone : ${association.telephone}` : "",
association?.emailContact ? `- Email : ${association.emailContact}` : "",
"",
"Points clés du dossier",
...highlights.map((item) => `- ${item.label} : ${item.value}`),
request.description ? `- Description : ${request.description}` : "",
request.montantDemande != null ? `- Montant demandé : ${formatAmount(request.montantDemande)}` : "",
];
if (documents?.length) {
textSections.push("", "Documents joints au dossier");
textSections.push(...documents.map((doc, index) => `${index + 1}. ${doc.name} (${doc.type})`));
}
textSections.push(
"",
"Le PDF récapitulatif est joint à cet email lorsqu'il est disponible pour ce type de demande.",
"",
"Ce message a été généré automatiquement par le Portail Associations."
);
const htmlHighlights = highlights
.map(
(item) => `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;width:180px;vertical-align:top;">${escapeHtml(item.label)}</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(item.value)}</td>
</tr>`
)
.join("");
const htmlDocuments = documents?.length
? `
<div style="margin-top:24px;padding:20px;border:1px solid #e5e7eb;border-radius:14px;background:#ffffff;">
<h3 style="margin:0 0 12px 0;font-size:16px;color:#111827;">Documents du dossier</h3>
<ul style="margin:0;padding-left:18px;color:#374151;font-size:14px;line-height:1.6;">
${documents.map((doc) => `<li>${escapeHtml(doc.name)} <span style="color:#6b7280;">(${escapeHtml(doc.type)})</span></li>`).join("")}
</ul>
</div>`
: "";
const html = `
<div style="margin:0;padding:32px 0;background:#f5f7fb;font-family:Arial,Helvetica,sans-serif;color:#111827;">
<div style="max-width:720px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:20px;overflow:hidden;box-shadow:0 10px 30px rgba(15,23,42,0.08);">
<div style="padding:28px 32px;background:linear-gradient(135deg,#000091 0%,#163d8f 100%);color:#ffffff;">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;opacity:0.8;">Portail Associations</div>
<h1 style="margin:10px 0 0 0;font-size:24px;line-height:1.25;">Transmission d'une demande traitée</h1>
<p style="margin:10px 0 0 0;font-size:14px;line-height:1.6;opacity:0.92;">
Ce message reprend les informations essentielles du dossier et le PDF récapitulatif est joint lorsqu'il est disponible.
</p>
</div>
<div style="padding:28px 32px;">
<div style="display:inline-block;padding:8px 14px;border-radius:999px;background:${badge.bg};border:1px solid ${badge.border};color:${badge.text};font-size:12px;font-weight:700;letter-spacing:0.04em;">
${escapeHtml(badge.label)}
</div>
<h2 style="margin:18px 0 6px 0;font-size:22px;line-height:1.3;">${escapeHtml(request.titre)}</h2>
<p style="margin:0;color:#4b5563;font-size:15px;line-height:1.7;">
Demande <strong>#${request.id}</strong> ${escapeHtml(typeLabel)}
</p>
<div style="margin-top:24px;padding:20px;border:1px solid #dbeafe;border-radius:16px;background:#f8fbff;">
<h3 style="margin:0 0 14px 0;font-size:16px;color:#0f172a;">Décision et transmission</h3>
<table style="width:100%;border-collapse:collapse;">
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;width:180px;">Traitée par</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(traitePar)}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Date de traitement</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(formatDateTimeFr(request.dateTraitement))}</td>
</tr>
${serviceLabel ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Service concerné</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(serviceLabel)}</td>
</tr>` : ""}
${request.montantAccorde != null ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Montant accordé</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(formatAmount(request.montantAccorde))}</td>
</tr>` : ""}
</table>
${request.commentaireAdmin ? `
<div style="margin-top:14px;padding:14px 16px;border-radius:12px;background:#ffffff;border:1px solid #e5e7eb;">
<div style="font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">Commentaire administratif</div>
<div style="font-size:14px;line-height:1.7;color:#111827;">${escapeHtml(request.commentaireAdmin).replace(/\n/g, "<br />")}</div>
</div>` : ""}
</div>
<div style="margin-top:24px;padding:20px;border:1px solid #e5e7eb;border-radius:16px;background:#ffffff;">
<h3 style="margin:0 0 14px 0;font-size:16px;color:#111827;">Association</h3>
<table style="width:100%;border-collapse:collapse;">
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;width:180px;">Nom</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(association?.nomAssociation || "Non renseignée")}</td>
</tr>
${association?.siret ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">SIRET</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(association.siret)}</td>
</tr>` : ""}
${(association?.adresse || association?.ville || association?.codePostal) ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Adresse</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(
[association?.adresse, association?.codePostal, association?.ville].filter(Boolean).join(" ")
)}</td>
</tr>` : ""}
${association?.nomRepresentant ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Représentant</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(association.nomRepresentant)}</td>
</tr>` : ""}
${association?.telephone ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Téléphone</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(association.telephone)}</td>
</tr>` : ""}
${association?.emailContact ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Email</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(association.emailContact)}</td>
</tr>` : ""}
</table>
</div>
<div style="margin-top:24px;padding:20px;border:1px solid #e5e7eb;border-radius:16px;background:#ffffff;">
<h3 style="margin:0 0 14px 0;font-size:16px;color:#111827;">Points clés du dossier</h3>
<table style="width:100%;border-collapse:collapse;">
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;width:180px;">Date de soumission</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(formatDateTimeFr(request.dateSubmission))}</td>
</tr>
${request.montantDemande != null ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Montant demandé</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(formatAmount(request.montantDemande))}</td>
</tr>` : ""}
${htmlHighlights}
${request.description ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;vertical-align:top;">Description</td>
<td style="padding:8px 0;color:#111827;font-size:14px;line-height:1.7;">${escapeHtml(request.description).replace(/\n/g, "<br />")}</td>
</tr>` : ""}
</table>
</div>
${htmlDocuments}
<div style="margin-top:24px;padding:18px 20px;border-radius:14px;background:#f9fafb;border:1px dashed #d1d5db;">
<p style="margin:0;color:#374151;font-size:14px;line-height:1.7;">
Le PDF récapitulatif est joint à cet email lorsquil est disponible pour ce type de demande. Tu peux lutiliser pour la transmission interne, larchivage ou limpression.
</p>
</div>
</div>
</div>
</div>
`;
return {
title: subject,
content: textSections.filter(Boolean).join("\n"),
html,
};
}

108
server/internalAccess.ts Normal file
View file

@ -0,0 +1,108 @@
import type { User } from "../drizzle/schema";
import * as db from "./db";
import { userHasLogisticsAccess } from "./logisticsGroup";
import {
getDelegatedSignerIdsForDirectrice,
getDirectriceIdsDelegatingToUser,
} from "./salleSignatureDelegation";
export type AuthenticatedPortalUser = User & {
canManageLogistics: boolean;
canSignSalle: boolean;
salleSignatureDelegatedByUserIds: number[];
};
type MinimalUser = Pick<User, "id" | "role" | "canManageLogistics">;
export async function userHasSalleSignatureAccess(user: Pick<User, "id" | "role">) {
if (user.role === "super_admin" || user.role === "directrice") {
return true;
}
const [internalUsers, delegatedBySetting] = await Promise.all([
db.getAdminUsers(),
getDirectriceIdsDelegatingToUser(user.id),
]);
return internalUsers.some((entry) => {
if (entry.role !== "directrice" || !entry.isActive) return false;
return entry.delegatedSalleSignerUserId === user.id || delegatedBySetting.includes(entry.id);
});
}
export async function getSalleSignatureDelegatedByUserIds(userId: number) {
const [internalUsers, delegatedBySetting] = await Promise.all([
db.getAdminUsers(),
getDirectriceIdsDelegatingToUser(userId),
]);
return Array.from(
new Set(
internalUsers
.filter(
(entry) =>
entry.role === "directrice"
&& entry.isActive
&& entry.delegatedSalleSignerUserId === userId
)
.map((entry) => entry.id)
.concat(delegatedBySetting)
)
);
}
export async function withEffectiveInternalAccess<TUser extends User | null>(
user: TUser
): Promise<(AuthenticatedPortalUser & NonNullable<TUser>) | null> {
if (!user) return null;
const [effectiveLogisticsAccess, effectiveSalleSignatureAccess, delegatedByUserIds] =
await Promise.all([
userHasLogisticsAccess(user as MinimalUser),
userHasSalleSignatureAccess(user),
getSalleSignatureDelegatedByUserIds(user.id),
]);
return {
...user,
canManageLogistics: effectiveLogisticsAccess,
canSignSalle: effectiveSalleSignatureAccess,
salleSignatureDelegatedByUserIds: delegatedByUserIds,
} as AuthenticatedPortalUser & NonNullable<TUser>;
}
export async function getSalleSignatureDelegateCandidates(currentUserId: number) {
const internalUsers = await db.getAdminUsers();
return internalUsers
.filter(
(entry) =>
entry.isActive
&& entry.id !== currentUserId
&& (
entry.role === "accueil"
|| entry.role === "admin"
|| entry.role === "directrice"
|| entry.role === "super_admin"
)
)
.map((entry) => ({
id: entry.id,
name: entry.name || "Sans nom",
email: entry.email || "",
role: entry.role,
}));
}
export async function getSalleSignatureDelegates(directriceUserId: number) {
const delegateIds = await getDelegatedSignerIdsForDirectrice(directriceUserId);
const delegates = await Promise.all(delegateIds.map((id) => db.getUserById(id)));
return delegates
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry?.isActive))
.map((entry) => ({
id: entry.id,
name: entry.name || "Sans nom",
email: entry.email || "",
role: entry.role,
}));
}

82
server/logisticsGroup.ts Normal file
View file

@ -0,0 +1,82 @@
import type { User } from "../drizzle/schema";
import * as db from "./db";
export const LOGISTICS_GROUP_SETTING_KEY = "system.logistics.group";
export type LogisticsGroupSettings = {
label: string;
memberUserIds: number[];
updatedAt: string | null;
};
const defaultLogisticsGroup: LogisticsGroupSettings = {
label: "Groupe interne matériel CCDS - logistique et contrôle",
memberUserIds: [],
updatedAt: null,
};
function sanitizeMemberUserIds(value: unknown) {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((entry) => Number(entry))
.filter((entry) => Number.isInteger(entry) && entry > 0)
)
);
}
export async function getLogisticsGroupSettings(): Promise<LogisticsGroupSettings> {
const storedValue = await db.getPortalSetting(LOGISTICS_GROUP_SETTING_KEY);
if (!storedValue) return defaultLogisticsGroup;
try {
const parsed = JSON.parse(storedValue) as Partial<LogisticsGroupSettings>;
return {
label: typeof parsed.label === "string" && parsed.label.trim()
? parsed.label.trim()
: defaultLogisticsGroup.label,
memberUserIds: sanitizeMemberUserIds(parsed.memberUserIds),
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null,
};
} catch {
return defaultLogisticsGroup;
}
}
export async function saveLogisticsGroupSettings(input: {
label?: string;
memberUserIds?: number[];
}) {
const nextValue: LogisticsGroupSettings = {
label: input.label?.trim() || defaultLogisticsGroup.label,
memberUserIds: sanitizeMemberUserIds(input.memberUserIds),
updatedAt: new Date().toISOString(),
};
await db.setPortalSetting(
LOGISTICS_GROUP_SETTING_KEY,
JSON.stringify(nextValue),
"Groupe interne à accès limité autorisé à traiter les demandes de matériel CCDS, piloter les restitutions et contrôler les retours"
);
return nextValue;
}
export async function userHasLogisticsAccess(user: Pick<User, "id" | "role" | "canManageLogistics">) {
if (user.role === "super_admin" || user.canManageLogistics) {
return true;
}
const group = await getLogisticsGroupSettings();
return group.memberUserIds.includes(user.id);
}
export async function withEffectiveLogisticsAccess<TUser extends User | null>(user: TUser): Promise<TUser> {
if (!user) return user;
const effectiveAccess = await userHasLogisticsAccess(user);
return {
...user,
canManageLogistics: effectiveAccess,
} as TUser;
}

161
server/logisticsSettings.ts Normal file
View file

@ -0,0 +1,161 @@
import * as db from "./db";
import {
materialEventInventory,
materialEventItems,
materialEventReplacementValues,
type MaterialEventItemKey,
} from "@shared/materialEvent";
export const LOGISTICS_SETTINGS_KEY = "system.logistics.settings";
export type LogisticsSettings = {
materialReturnLeadDays: number;
materialReturnGraceDays: number;
inventory: Record<MaterialEventItemKey, number | null>;
replacementValues: Record<MaterialEventItemKey, number | null>;
updatedAt: string | null;
};
export const defaultLogisticsSettings: Omit<LogisticsSettings, "updatedAt"> = {
materialReturnLeadDays: 4,
materialReturnGraceDays: 3,
inventory: {
...materialEventInventory,
},
replacementValues: {
...materialEventReplacementValues,
},
};
function clampWholeNumber(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function sanitizeInventory(rawInventory: unknown) {
const nextInventory = { ...defaultLogisticsSettings.inventory } as Record<MaterialEventItemKey, number | null>;
if (!rawInventory || typeof rawInventory !== "object") {
return nextInventory;
}
for (const item of materialEventItems) {
const rawValue = (rawInventory as Record<string, unknown>)[item.key];
if (rawValue === null || rawValue === "") {
nextInventory[item.key] = null;
continue;
}
const parsed = Number.parseInt(String(rawValue ?? ""), 10);
if (Number.isFinite(parsed) && parsed >= 0) {
nextInventory[item.key] = parsed;
}
}
return nextInventory;
}
function sanitizeReplacementValues(rawReplacementValues: unknown) {
const nextReplacementValues = {
...defaultLogisticsSettings.replacementValues,
} as Record<MaterialEventItemKey, number | null>;
if (!rawReplacementValues || typeof rawReplacementValues !== "object") {
return nextReplacementValues;
}
for (const item of materialEventItems) {
const rawValue = (rawReplacementValues as Record<string, unknown>)[item.key];
if (rawValue === null || rawValue === "") {
nextReplacementValues[item.key] = null;
continue;
}
const parsed = Number.parseInt(String(rawValue ?? ""), 10);
if (Number.isFinite(parsed) && parsed >= 0) {
nextReplacementValues[item.key] = parsed;
}
}
return nextReplacementValues;
}
function sanitizeLogisticsSettings(rawValue: unknown, updatedAt: string | null): LogisticsSettings {
const source = rawValue && typeof rawValue === "object" ? rawValue as Record<string, unknown> : {};
return {
materialReturnLeadDays: clampWholeNumber(
source.materialReturnLeadDays,
defaultLogisticsSettings.materialReturnLeadDays,
1,
30
),
materialReturnGraceDays: clampWholeNumber(
source.materialReturnGraceDays,
defaultLogisticsSettings.materialReturnGraceDays,
0,
30
),
inventory: sanitizeInventory(source.inventory),
replacementValues: sanitizeReplacementValues(source.replacementValues),
updatedAt,
};
}
export async function getLogisticsSettings(): Promise<LogisticsSettings> {
const record = await db.getPortalSettingRecord(LOGISTICS_SETTINGS_KEY);
if (!record?.valeur) {
return {
...defaultLogisticsSettings,
updatedAt: null,
};
}
try {
const parsed = JSON.parse(record.valeur);
return sanitizeLogisticsSettings(parsed, record.updatedAt?.toISOString?.() || null);
} catch {
return {
...defaultLogisticsSettings,
updatedAt: record.updatedAt?.toISOString?.() || null,
};
}
}
export async function saveLogisticsSettings(input: {
materialReturnLeadDays?: number;
materialReturnGraceDays?: number;
inventory?: Partial<Record<MaterialEventItemKey, number | null>>;
replacementValues?: Partial<Record<MaterialEventItemKey, number | null>>;
}) {
const current = await getLogisticsSettings();
const next = sanitizeLogisticsSettings(
{
materialReturnLeadDays: input.materialReturnLeadDays ?? current.materialReturnLeadDays,
materialReturnGraceDays: input.materialReturnGraceDays ?? current.materialReturnGraceDays,
inventory: {
...current.inventory,
...(input.inventory || {}),
},
replacementValues: {
...current.replacementValues,
...(input.replacementValues || {}),
},
},
new Date().toISOString()
);
await db.setPortalSetting(
LOGISTICS_SETTINGS_KEY,
JSON.stringify({
materialReturnLeadDays: next.materialReturnLeadDays,
materialReturnGraceDays: next.materialReturnGraceDays,
inventory: next.inventory,
replacementValues: next.replacementValues,
}),
"Réglages logistiques globaux du portail"
);
return getLogisticsSettings();
}

256
server/mailSettings.ts Normal file
View file

@ -0,0 +1,256 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import * as db from "./db";
import { ENV } from "./_core/env";
import { providerDefaults, resolveMailConfig, type MailEnvInput, type SupportedMailProvider } from "@shared/mailProviders";
export const MAIL_SETTINGS_KEY = "system.mail.settings";
type StoredMailSettings = {
smtpProvider: SupportedMailProvider;
smtpHost?: string;
smtpPort?: string;
smtpUser?: string;
smtpFrom?: string;
smtpSecure?: boolean;
smtpRequireTls?: boolean;
smtpPassEncrypted?: string;
updatedByUserId?: number;
updatedByUserName?: string | null;
};
export type MailSettingsFormInput = {
smtpProvider: SupportedMailProvider;
smtpHost?: string;
smtpPort?: string;
smtpUser?: string;
smtpFrom?: string;
smtpSecure?: boolean;
smtpRequireTls?: boolean;
smtpPass?: string;
};
export type AdminMailSettings = {
smtpProvider: SupportedMailProvider;
smtpHost: string;
smtpPort: string;
smtpUser: string;
smtpFrom: string;
smtpSecure: boolean;
smtpRequireTls: boolean;
smtpPassConfigured: boolean;
ready: boolean;
missing: string[];
source: "environment" | "admin" | "admin+environment";
hasSavedConfig: boolean;
updatedAt: string | null;
updatedByUserId: number | null;
updatedByUserName: string | null;
};
function buildCipherKey() {
return createHash("sha256")
.update(ENV.cookieSecret || "local-dev-mail-settings")
.digest();
}
function encryptSecret(value: string) {
const iv = randomBytes(12);
const key = buildCipherKey();
const cipher = createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString("base64")}.${tag.toString("base64")}.${encrypted.toString("base64")}`;
}
function decryptSecret(value?: string) {
if (!value) return "";
const [ivBase64, tagBase64, encryptedBase64] = value.split(".");
if (!ivBase64 || !tagBase64 || !encryptedBase64) {
return "";
}
try {
const key = buildCipherKey();
const decipher = createDecipheriv(
"aes-256-gcm",
key,
Buffer.from(ivBase64, "base64")
);
decipher.setAuthTag(Buffer.from(tagBase64, "base64"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedBase64, "base64")),
decipher.final(),
]);
return decrypted.toString("utf8");
} catch {
return "";
}
}
function trimOrEmpty(value?: string | null) {
return String(value || "").trim();
}
function normalizeStoredSettings(input: Partial<StoredMailSettings> | null | undefined): StoredMailSettings | null {
if (!input?.smtpProvider) return null;
return {
smtpProvider: input.smtpProvider,
smtpHost: trimOrEmpty(input.smtpHost),
smtpPort: trimOrEmpty(input.smtpPort),
smtpUser: trimOrEmpty(input.smtpUser),
smtpFrom: trimOrEmpty(input.smtpFrom),
smtpSecure: Boolean(input.smtpSecure),
smtpRequireTls: Boolean(input.smtpRequireTls),
smtpPassEncrypted: trimOrEmpty(input.smtpPassEncrypted),
updatedByUserId: input.updatedByUserId ? Number(input.updatedByUserId) : undefined,
updatedByUserName: input.updatedByUserName?.trim() || null,
};
}
async function getStoredMailSettingsRecord() {
const setting = await db.getPortalSettingRecord(MAIL_SETTINGS_KEY);
if (!setting?.valeur) {
return { setting: null, payload: null as StoredMailSettings | null };
}
try {
const parsed = JSON.parse(setting.valeur);
return {
setting,
payload: normalizeStoredSettings(parsed),
};
} catch {
return {
setting,
payload: null,
};
}
}
function mergeMailInput(stored: StoredMailSettings | null) {
const decryptedPassword = decryptSecret(stored?.smtpPassEncrypted);
const sourceEntries: Array<keyof MailEnvInput> = [
"smtpProvider",
"smtpHost",
"smtpPort",
"smtpUser",
"smtpPass",
"smtpFrom",
"smtpSecure",
"smtpRequireTls",
];
const storedInput: MailEnvInput = {
smtpProvider: stored?.smtpProvider,
smtpHost: stored?.smtpHost,
smtpPort: stored?.smtpPort,
smtpUser: stored?.smtpUser,
smtpPass: decryptedPassword,
smtpFrom: stored?.smtpFrom,
smtpSecure: stored ? String(Boolean(stored.smtpSecure)) : "",
smtpRequireTls: stored ? String(Boolean(stored.smtpRequireTls)) : "",
};
const envInput: MailEnvInput = {
smtpProvider: ENV.smtpProvider,
smtpHost: ENV.smtpHost,
smtpPort: ENV.smtpPort,
smtpUser: ENV.smtpUser,
smtpPass: ENV.smtpPass,
smtpFrom: ENV.smtpFrom,
smtpSecure: ENV.smtpSecure,
smtpRequireTls: ENV.smtpRequireTls,
};
const merged: MailEnvInput = {};
let usesEnvironmentFallback = false;
for (const key of sourceEntries) {
const storedValue = trimOrEmpty(storedInput[key]);
const envValue = trimOrEmpty(envInput[key]);
if (storedValue) {
merged[key] = storedInput[key];
} else if (envValue) {
merged[key] = envInput[key];
if (stored) {
usesEnvironmentFallback = true;
}
}
}
const source: AdminMailSettings["source"] = stored
? usesEnvironmentFallback
? "admin+environment"
: "admin"
: "environment";
return { merged, source };
}
export async function getResolvedMailConfiguration() {
const { setting, payload } = await getStoredMailSettingsRecord();
const { merged, source } = mergeMailInput(payload);
return {
config: resolveMailConfig(merged),
source,
stored: payload,
updatedAt: setting?.updatedAt?.toISOString?.() ?? null,
};
}
export async function getAdminMailSettings(): Promise<AdminMailSettings> {
const { config, source, stored, updatedAt } = await getResolvedMailConfiguration();
const defaults = stored?.smtpProvider && stored.smtpProvider !== "custom"
? providerDefaults[stored.smtpProvider]
: null;
return {
smtpProvider: config.provider,
smtpHost: stored?.smtpHost || config.host || defaults?.host || "",
smtpPort: stored?.smtpPort || (config.port ? String(config.port) : defaults?.port ? String(defaults.port) : ""),
smtpUser: stored?.smtpUser || config.user || "",
smtpFrom: stored?.smtpFrom || config.from || "",
smtpSecure: stored ? Boolean(stored.smtpSecure) : config.secure,
smtpRequireTls: stored ? Boolean(stored.smtpRequireTls) : config.requireTLS,
smtpPassConfigured: Boolean(stored?.smtpPassEncrypted || ENV.smtpPass),
ready: config.ready,
missing: config.missing,
source,
hasSavedConfig: Boolean(stored),
updatedAt,
updatedByUserId: stored?.updatedByUserId ?? null,
updatedByUserName: stored?.updatedByUserName ?? null,
};
}
export async function saveAdminMailSettings(
input: MailSettingsFormInput,
actor?: { id: number; name?: string | null }
) {
const existing = await getStoredMailSettingsRecord();
const nextPassword = trimOrEmpty(input.smtpPass)
? encryptSecret(trimOrEmpty(input.smtpPass))
: existing.payload?.smtpPassEncrypted || "";
const payload: StoredMailSettings = {
smtpProvider: input.smtpProvider,
smtpHost: trimOrEmpty(input.smtpHost),
smtpPort: trimOrEmpty(input.smtpPort),
smtpUser: trimOrEmpty(input.smtpUser),
smtpFrom: trimOrEmpty(input.smtpFrom),
smtpSecure: Boolean(input.smtpSecure),
smtpRequireTls: Boolean(input.smtpRequireTls),
smtpPassEncrypted: nextPassword,
updatedByUserId: actor?.id,
updatedByUserName: actor?.name?.trim() || null,
};
await db.setPortalSetting(
MAIL_SETTINGS_KEY,
JSON.stringify(payload),
"Configuration SMTP et messagerie du portail"
);
return getAdminMailSettings();
}

58
server/mailer.ts Normal file
View file

@ -0,0 +1,58 @@
import nodemailer from "nodemailer";
import { getResolvedMailConfiguration } from "./mailSettings";
type SendMailInput = {
to: string[];
subject: string;
text: string;
html?: string;
replyTo?: string;
fromName?: string;
attachments?: Array<{
filename: string;
content: Buffer;
contentType?: string;
}>;
};
export async function canSendOperationalEmails() {
const { config } = await getResolvedMailConfiguration();
return config.ready;
}
export async function sendOperationalEmail(input: SendMailInput): Promise<{ sent: boolean; reason?: string }> {
const { config } = await getResolvedMailConfiguration();
if (!config.ready) {
return {
sent: false,
reason: config.missing.length > 0
? `Configuration SMTP incomplète: ${config.missing.join(", ")}`
: "SMTP non configuré",
};
}
const transporter = nodemailer.createTransport({
host: config.host,
port: Number(config.port),
secure: config.secure,
requireTLS: config.requireTLS,
auth: config.user && config.pass
? {
user: config.user,
pass: config.pass,
}
: undefined,
});
await transporter.sendMail({
from: input.fromName ? `${input.fromName} <${config.from}>` : config.from,
to: input.to.join(", "),
replyTo: input.replyTo,
subject: input.subject,
text: input.text,
html: input.html,
attachments: input.attachments,
});
return { sent: true };
}

View file

@ -0,0 +1,288 @@
import PDFDocument from "pdfkit";
import fs from "node:fs";
import path from "node:path";
const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
export type MaterialContractFinancialMode = "gratuite" | "gratuite_avec_caution" | "location_payante";
export type MaterialContractStatus = "a_generer" | "generee" | "signee" | "refusee" | "annulee";
type ContractRequestLike = {
id: number;
titre: string;
status: string;
formData?: string | null;
};
type ContractAssociationLike = {
nomAssociation?: string | null;
adresse?: string | null;
codePostal?: string | null;
ville?: string | null;
telephone?: string | null;
emailContact?: string | null;
nomRepresentant?: string | null;
} | null | undefined;
type ContractDecision = {
financialMode: MaterialContractFinancialMode;
depositRequired: boolean;
depositAmountCents: number;
rentalAmountCents: number;
pricingNotes?: string | null;
contractStatus: MaterialContractStatus;
contractGeneratedAt?: string | Date | null;
contractValidatedByUserId?: number | null;
};
function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) {
if (!fs.existsSync(CCDS_LOGO_PATH)) return;
try {
doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" });
} catch (error) {
console.warn("[MaterialConventionPdf] Impossible de charger le logo CCDS:", error);
}
}
function formatDateFr(date: string | Date | null | undefined) {
if (!date) return "-";
try {
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return String(date);
}
}
function formatCurrency(cents: number | null | undefined) {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format((cents || 0) / 100);
}
function getFinancialModeLabel(mode: MaterialContractFinancialMode) {
switch (mode) {
case "gratuite":
return "Mise à disposition gratuite";
case "gratuite_avec_caution":
return "Mise à disposition gratuite avec caution";
case "location_payante":
return "Location payante";
default:
return mode;
}
}
export async function generateMaterialConventionPdf(input: {
request: ContractRequestLike;
association?: ContractAssociationLike;
decision: ContractDecision;
}) {
const formData = (() => {
try {
return input.request.formData ? JSON.parse(input.request.formData) : {};
} catch {
return {};
}
})();
const requestedRows = Object.entries(formData.cadreDSU?.materielEvent?.itemsAccordes || {})
.filter(([, granted]) => Boolean(granted))
.map(([key]) => ({
key,
label:
key === "tente3x3"
? "Tente 3x3"
: key === "chapiteau5x5"
? "Chapiteau 5x5"
: key === "podium"
? "Podium"
: "Autres",
quantity: formData.cadreDSU?.materielEvent?.quantitesAccordees?.[key] || "-",
extra: key === "autres" ? formData.cadreDSU?.materielEvent?.autresPrecisions || "" : "",
}));
const manifestationStart = formData.dateDebutManifestation || formData.dateManifestation;
const manifestationEnd = formData.dateFinManifestation || formData.dateManifestation;
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({
size: "A4",
margins: { top: 38, bottom: 42, left: 42, right: 42 },
info: {
Title: `Convention matériel CCDS - dossier ${input.request.id}`,
Author: "Communauté de Communes Des Savanes",
Subject: "Convention de mise à disposition / location du matériel événementiel",
},
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
let y = doc.page.margins.top;
doc.lineWidth(2).strokeColor("#efb100").moveTo(doc.page.margins.left, y).lineTo(doc.page.margins.left + pageWidth, y).stroke();
y += 16;
drawLogo(doc, doc.page.margins.left + (pageWidth - 84) / 2, y, 84, 84);
y += 92;
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, {
width: pageWidth,
align: "center",
});
y += 18;
doc.font("Helvetica-Bold").fontSize(17).fillColor("#0f172a").text(
"CONVENTION DE MISE À DISPOSITION / LOCATION",
doc.page.margins.left,
y,
{ width: pageWidth, align: "center" }
);
y += 20;
doc.font("Helvetica-Bold").fontSize(14).text("DU MATÉRIEL ÉVÉNEMENTIEL DE LA CCDS", doc.page.margins.left, y, {
width: pageWidth,
align: "center",
});
y += 28;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 42, 8).fillAndStroke("#eef5ff", "#bfd2ef");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f2d63").text(
"Document généré à partir de la décision administrative et de l'annexe de référence CCDS",
doc.page.margins.left + 14,
y + 9,
{ width: pageWidth - 28, align: "center" }
);
doc.font("Helvetica").fontSize(9).fillColor("#334155").text(
`Dossier #${input.request.id} — statut contrat : ${input.decision.contractStatus}`,
doc.page.margins.left + 14,
y + 23,
{ width: pageWidth - 28, align: "center" }
);
y += 56;
const infoRows: Array<[string, string]> = [
["Association", input.association?.nomAssociation || formData.nomAssociation || "-"],
["Représentant", input.association?.nomRepresentant || formData.demandeurNomPrenom || "-"],
["Adresse", [input.association?.adresse, input.association?.codePostal, input.association?.ville].filter(Boolean).join(" ") || "-"],
["Téléphone", input.association?.telephone || formData.telephoneAssociation || "-"],
["Email", input.association?.emailContact || formData.emailAssociation || "-"],
["Commune / manifestation", formData.commune || "-"],
[
"Période d'utilisation",
manifestationStart || manifestationEnd
? `${formatDateFr(manifestationStart)}${manifestationEnd ? ` au ${formatDateFr(manifestationEnd)}` : ""}`
: "-",
],
["Prise en charge", formatDateFr(formData.datePriseEnCharge)],
["Restitution", formatDateFr(formData.dateRestitution)],
["Objet", formData.motifDemande || input.request.titre || "-"],
];
for (const [label, value] of infoRows) {
doc.font("Helvetica-Bold").fontSize(9).fillColor("#334155").text(`${label} :`, doc.page.margins.left, y, { width: 140 });
doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(value, doc.page.margins.left + 145, y - 1, {
width: pageWidth - 145,
});
y += 18;
}
y += 6;
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Matériel accordé", doc.page.margins.left, y);
y += 16;
const cols = [pageWidth * 0.56, pageWidth * 0.14, pageWidth * 0.30];
let x = doc.page.margins.left;
["Équipement", "Qté", "Observation"].forEach((header, index) => {
doc.rect(x, y, cols[index], 22).fillAndStroke("#dce8f8", "#bfd2ef");
doc.font("Helvetica-Bold").fontSize(9).fillColor("#0f2d63").text(header, x + 6, y + 7, {
width: cols[index] - 12,
align: index === 0 ? "left" : "center",
});
x += cols[index];
});
y += 22;
const rows = requestedRows.length > 0 ? requestedRows : [{ key: "none", label: "Aucun matériel accordé", quantity: "-", extra: "" }];
rows.forEach((row) => {
const rowHeight = row.extra ? 34 : 26;
let rowX = doc.page.margins.left;
[row.label, row.quantity, row.extra || "—"].forEach((value, index) => {
doc.rect(rowX, y, cols[index], rowHeight).stroke("#cbd5e1");
doc.font("Helvetica").fontSize(9).fillColor("#0f172a").text(String(value), rowX + 6, y + 7, {
width: cols[index] - 12,
align: index === 0 ? "left" : "center",
});
rowX += cols[index];
});
y += rowHeight;
});
y += 18;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 108, 8).fillAndStroke("#f8fafc", "#cbd5e1");
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Conditions financières", doc.page.margins.left + 14, y + 12);
doc.font("Helvetica").fontSize(10).fillColor("#334155")
.text(`Régime retenu : ${getFinancialModeLabel(input.decision.financialMode)}`, doc.page.margins.left + 14, y + 34, {
width: pageWidth - 28,
})
.text(`Montant de location : ${formatCurrency(input.decision.rentalAmountCents)}`, doc.page.margins.left + 14, y + 52, {
width: pageWidth - 28,
})
.text(
`Caution : ${input.decision.depositRequired ? formatCurrency(input.decision.depositAmountCents) : "Aucune caution exigée"}`,
doc.page.margins.left + 14,
y + 70,
{ width: pageWidth - 28 }
);
y += 122;
if (input.decision.pricingNotes?.trim()) {
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Clauses / réserves spécifiques", doc.page.margins.left, y);
y += 16;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 70, 8).stroke("#cbd5e1");
doc.font("Helvetica").fontSize(10).fillColor("#334155").text(input.decision.pricingNotes.trim(), doc.page.margins.left + 12, y + 12, {
width: pageWidth - 24,
});
y += 84;
}
y += 6;
doc.font("Helvetica").fontSize(9).fillColor("#475569").text(
"Cette convention formalise les conditions administratives retenues par la CCDS pour la mise à disposition ou la location du matériel événementiel.",
doc.page.margins.left,
y,
{ width: pageWidth }
);
y += 34;
const signatureWidth = (pageWidth - 20) / 2;
doc.roundedRect(doc.page.margins.left, y, signatureWidth, 90, 8).stroke("#cbd5e1");
doc.roundedRect(doc.page.margins.left + signatureWidth + 20, y, signatureWidth, 90, 8).stroke("#cbd5e1");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a")
.text("Pour l'association", doc.page.margins.left + 12, y + 12, { width: signatureWidth - 24 })
.text("Pour la CCDS / DSU", doc.page.margins.left + signatureWidth + 32, y + 12, { width: signatureWidth - 24 });
doc.font("Helvetica").fontSize(9).fillColor("#64748b")
.text("Nom, qualité, signature", doc.page.margins.left + 12, y + 32, { width: signatureWidth - 24 })
.text("Nom, qualité, signature", doc.page.margins.left + signatureWidth + 32, y + 32, { width: signatureWidth - 24 });
doc.font("Helvetica").fontSize(8).fillColor("#64748b").text(
`Convention générée le ${formatDateFr(input.decision.contractGeneratedAt || new Date())}.`,
doc.page.margins.left,
doc.page.height - doc.page.margins.bottom - 12,
{ width: pageWidth, align: "center" }
);
doc.end();
} catch (error) {
reject(error);
}
});
return {
buffer: pdfBuffer,
fileName: `Convention_Materiel_CCDS_${input.request.id}.pdf`,
};
}

View file

@ -0,0 +1,238 @@
function formatDateFr(date: Date | string | null | undefined) {
if (!date) return "Non renseignée";
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
});
}
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function generateMaterialReturnReminderEmail(input: {
associationName: string;
serviceLabel?: string | null;
restitutionDate: Date | string;
uploadLink: string;
requestTitle: string;
tone?: "assignment" | "scheduled" | "overdue" | "reactivation";
}) {
const restitutionLabel = formatDateFr(input.restitutionDate);
const serviceLabel = input.serviceLabel?.trim() || "service technique / logistique";
const tone = input.tone || "scheduled";
const toneContent = {
assignment: {
subject: `Mission de récupération assignée - ${input.associationName} - restitution prévue le ${restitutionLabel}`,
title: "Mission de récupération assignée",
intro: `Une mission de récupération du matériel a été attribuée à <strong>${escapeHtml(serviceLabel)}</strong> pour <strong>${escapeHtml(input.associationName)}</strong>.`,
actionTitle: "Action à préparer",
actionBody:
"La fiche d'état des lieux préremplie CCDS est jointe à cet email. Conservez ce lien terrain : il vous permettra de constater la restitution, signer sur smartphone et clôturer le dossier le moment venu.",
accent: "#0f2d63",
alertBg: "#f5f9ff",
alertBorder: "#cfe0ff",
alertTitleColor: "#0f2d63",
},
scheduled: {
subject: `Récupération matériel à organiser - ${input.associationName} - restitution prévue le ${restitutionLabel}`,
title: "Récupération du matériel à organiser",
intro: `La restitution du matériel pour <strong>${escapeHtml(input.associationName)}</strong> approche.`,
actionTitle: "Action attendue",
actionBody:
"La fiche d'état des lieux préremplie CCDS est jointe à cet email. Votre service dispose d'un délai maximal de <strong>3 jours</strong> pour effectuer la récupération, compléter la fiche contradictoire puis la déposer sur le portail.",
accent: "#0f2d63",
alertBg: "#fff8e6",
alertBorder: "#efb100",
alertTitleColor: "#7c5a00",
},
overdue: {
subject: `Retard de restitution - action requise - ${input.associationName} - ${restitutionLabel}`,
title: "Alerte retard de restitution",
intro: `La restitution du matériel pour <strong>${escapeHtml(input.associationName)}</strong> n'a pas encore été validée dans les délais.`,
actionTitle: "Relance automatique",
actionBody:
"Merci d'intervenir en priorité. Le délai de 3 jours après la date de restitution est dépassé. Utilisez le lien ci-dessous pour finaliser le constat terrain et débloquer la clôture du dossier.",
accent: "#b45309",
alertBg: "#fff7ed",
alertBorder: "#fdba74",
alertTitleColor: "#9a3412",
},
reactivation: {
subject: `Nouveau lien de restitution actif - ${input.associationName} - ${restitutionLabel}`,
title: "Lien de restitution réactivé",
intro: `Un nouveau lien terrain actif a été généré pour <strong>${escapeHtml(input.associationName)}</strong>.`,
actionTitle: "Action immédiate",
actionBody:
"Utilisez exclusivement le nouveau lien ci-dessous pour constater la restitution, signer sur smartphone et clôturer le dossier. Les anciens liens de restitution ne sont plus valides.",
accent: "#0f2d63",
alertBg: "#eef6ff",
alertBorder: "#93c5fd",
alertTitleColor: "#1d4ed8",
},
}[tone];
const subject = toneContent.subject;
const text = [
tone === "assignment"
? "Assignation logistique - récupération du matériel événementiel"
: tone === "overdue"
? "Alerte automatique - restitution du matériel en retard"
: "Rappel automatique - récupération du matériel événementiel",
"",
`Association : ${input.associationName}`,
`Demande : ${input.requestTitle}`,
`Service concerné : ${serviceLabel}`,
`Date de restitution prévue : ${restitutionLabel}`,
"",
"La fiche d'état des lieux préremplie CCDS est jointe à cet email.",
tone === "assignment"
? "Ce lien terrain devra être utilisé par l'agent mandaté pour effectuer le constat contradictoire, signer et clôturer la restitution."
: tone === "reactivation"
? "Un nouveau lien actif vient d'être généré. Les anciens liens de restitution ne sont plus valides."
: tone === "overdue"
? "Le délai de 3 jours après la restitution est dépassé. Merci de finaliser la récupération et de valider immédiatement la fiche sur le portail."
: "Vous disposez d'un délai maximal de 3 jours pour effectuer la récupération, compléter la fiche de manière contradictoire puis la téléverser sur le portail.",
"",
`Téléverser la fiche signée : ${input.uploadLink}`,
"",
"Ce message a été généré automatiquement par le Portail Associations.",
].join("\n");
const html = `
<div style="margin:0;padding:32px 0;background:#f4f7fb;font-family:Arial,Helvetica,sans-serif;color:#0f172a;">
<div style="max-width:720px;margin:0 auto;background:#ffffff;border:1px solid #dbe4f0;border-radius:20px;overflow:hidden;box-shadow:0 12px 30px rgba(15,23,42,0.08);">
<div style="padding:28px 32px;background:${tone === "overdue" ? "linear-gradient(135deg,#8a4b00 0%,#b45309 100%)" : "linear-gradient(135deg,#0f2d63 0%,#18438f 100%)"};color:#ffffff;">
<div style="font-size:12px;letter-spacing:0.12em;text-transform:uppercase;opacity:0.82;">Portail Associations</div>
<h1 style="margin:12px 0 6px 0;font-size:24px;line-height:1.2;">${toneContent.title}</h1>
<p style="margin:0;font-size:15px;line-height:1.6;opacity:0.9;">
${toneContent.intro}
</p>
</div>
<div style="padding:28px 32px;">
<div style="padding:18px 20px;border:1px solid #cfe0ff;border-radius:16px;background:#f5f9ff;">
<div style="display:grid;grid-template-columns:180px 1fr;gap:10px 18px;font-size:14px;line-height:1.5;">
<div style="color:#64748b;">Association</div>
<div style="font-weight:700;color:#0f172a;">${escapeHtml(input.associationName)}</div>
<div style="color:#64748b;">Demande</div>
<div style="font-weight:700;color:#0f172a;">${escapeHtml(input.requestTitle)}</div>
<div style="color:#64748b;">Service concerné</div>
<div style="font-weight:700;color:#0f172a;">${escapeHtml(serviceLabel)}</div>
<div style="color:#64748b;">Restitution prévue</div>
<div style="font-weight:700;color:#0f172a;">${escapeHtml(restitutionLabel)}</div>
</div>
</div>
<div style="margin-top:22px;padding:18px 20px;border-left:4px solid ${toneContent.alertBorder};background:${toneContent.alertBg};border-radius:12px;">
<p style="margin:0 0 8px 0;font-size:15px;font-weight:700;color:${toneContent.alertTitleColor};">${toneContent.actionTitle}</p>
<p style="margin:0;font-size:14px;line-height:1.7;color:#3f3f46;">
${toneContent.actionBody}
</p>
</div>
<div style="margin-top:26px;text-align:center;">
<a href="${escapeHtml(input.uploadLink)}" style="display:inline-block;padding:14px 22px;background:#0f2d63;color:#ffffff;text-decoration:none;border-radius:12px;font-weight:700;">
${tone === "reactivation" ? "Ouvrir le nouveau lien terrain" : "Téléverser la fiche signée"}
</a>
</div>
<div style="margin-top:16px;padding:14px 16px;border:1px dashed #cbd5e1;border-radius:12px;background:#f8fafc;">
<p style="margin:0 0 6px 0;font-size:12px;font-weight:700;color:#475569;text-transform:uppercase;letter-spacing:0.08em;">
Lien terrain à utiliser
</p>
<p style="margin:0;font-size:13px;line-height:1.6;word-break:break-all;color:#0f172a;">
<a href="${escapeHtml(input.uploadLink)}" style="color:#0f2d63;text-decoration:underline;">${escapeHtml(input.uploadLink)}</a>
</p>
</div>
<p style="margin:26px 0 0 0;font-size:13px;line-height:1.6;color:#64748b;">
Ce message a é généré automatiquement par le Portail Associations afin de sécuriser le retour du matériel événementiel.
</p>
</div>
</div>
</div>
`;
return { subject, text, html };
}
export function generateMaterialReturnCompletionEmail(input: {
associationName: string;
requestTitle: string;
restitutionDate: Date | string;
serviceLabel?: string | null;
issueFlag: boolean;
compliance: "conforme" | "non_conforme";
discrepancyDetails?: string | null;
}) {
const restitutionLabel = formatDateFr(input.restitutionDate);
const serviceLabel = input.serviceLabel?.trim() || "service technique / logistique";
const statusLabel = input.issueFlag ? "Alerte dégradation / litige" : "Matériel récupéré / dossier clôturé";
const subject = `${input.issueFlag ? "Alerte restitution" : "Restitution clôturée"} - ${input.associationName} - ${restitutionLabel}`;
const text = [
"Clôture de restitution du matériel événementiel",
"",
`Association : ${input.associationName}`,
`Demande : ${input.requestTitle}`,
`Service concerné : ${serviceLabel}`,
`Restitution : ${restitutionLabel}`,
`Résultat : ${statusLabel}`,
`Conformité : ${input.compliance === "conforme" ? "Conforme" : "Non conforme / partielle"}`,
input.discrepancyDetails ? `Réserves : ${input.discrepancyDetails}` : "",
"",
"Le PDF officiel finalisé est joint à cet email pour archivage et suivi.",
"",
"Ce message a été généré automatiquement par le Portail Associations.",
]
.filter(Boolean)
.join("\n");
const html = `
<div style="margin:0;padding:32px 0;background:#f4f7fb;font-family:Arial,Helvetica,sans-serif;color:#0f172a;">
<div style="max-width:720px;margin:0 auto;background:#ffffff;border:1px solid #dbe4f0;border-radius:20px;overflow:hidden;box-shadow:0 12px 30px rgba(15,23,42,0.08);">
<div style="padding:28px 32px;background:${input.issueFlag ? "#8a4b00" : "#0f2d63"};color:#ffffff;">
<div style="font-size:12px;letter-spacing:0.12em;text-transform:uppercase;opacity:0.82;">Portail Associations</div>
<h1 style="margin:12px 0 6px 0;font-size:24px;line-height:1.2;">${input.issueFlag ? "Alerte restitution" : "Restitution clôturée"}</h1>
<p style="margin:0;font-size:15px;line-height:1.6;opacity:0.92;">
${input.issueFlag ? "Des réserves ont été constatées lors de la restitution." : "Le matériel a été récupéré et la fiche contradictoire est finalisée."}
</p>
</div>
<div style="padding:28px 32px;">
<div style="display:grid;grid-template-columns:180px 1fr;gap:10px 18px;font-size:14px;line-height:1.5;">
<div style="color:#64748b;">Association</div>
<div style="font-weight:700;color:#0f172a;">${escapeHtml(input.associationName)}</div>
<div style="color:#64748b;">Demande</div>
<div style="font-weight:700;color:#0f172a;">${escapeHtml(input.requestTitle)}</div>
<div style="color:#64748b;">Service concerné</div>
<div style="font-weight:700;color:#0f172a;">${escapeHtml(serviceLabel)}</div>
<div style="color:#64748b;">Restitution</div>
<div style="font-weight:700;color:#0f172a;">${escapeHtml(restitutionLabel)}</div>
<div style="color:#64748b;">Résultat</div>
<div style="font-weight:700;color:${input.issueFlag ? "#9a3412" : "#166534"};">${escapeHtml(statusLabel)}</div>
</div>
<div style="margin-top:20px;padding:18px 20px;border-radius:14px;background:${input.issueFlag ? "#fff7ed" : "#ecfdf3"};border:1px solid ${input.issueFlag ? "#fdba74" : "#a7f3d0"};">
<p style="margin:0 0 8px 0;font-size:15px;font-weight:700;color:${input.issueFlag ? "#9a3412" : "#166534"};">
${input.compliance === "conforme" ? "Restitution conforme" : "Restitution non conforme / partielle"}
</p>
<p style="margin:0;font-size:14px;line-height:1.7;color:#3f3f46;">
${escapeHtml(input.discrepancyDetails || "Aucune réserve particulière n'a été déclarée.")}
</p>
</div>
<p style="margin:22px 0 0 0;font-size:13px;line-height:1.6;color:#64748b;">
Le PDF officiel finalisé est joint à cet email pour archivage et suivi.
</p>
</div>
</div>
</div>
`;
return { subject, text, html };
}

View file

@ -0,0 +1,202 @@
import PDFDocument from "pdfkit";
import fs from "node:fs";
import path from "node:path";
import { getMaterialEventLabel, materialEventItems, sanitizeMaterialEventQuantityMap, type MaterialEventItemKey } from "@shared/materialEvent";
const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) {
if (!fs.existsSync(CCDS_LOGO_PATH)) return;
try {
doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" });
} catch (error) {
console.warn("[MaterialReturnLitigationPdf] Impossible de charger le logo CCDS:", error);
}
}
function formatDateFr(date: string | Date | null | undefined) {
if (!date) return "-";
try {
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return String(date);
}
}
function formatCurrency(amountCents: number | null | undefined) {
if (!Number.isFinite(amountCents as number)) return "-";
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format((amountCents || 0) / 100);
}
export async function generateMaterialReturnLitigationLetterPdf(input: {
request: {
id: number;
titre: string;
formData?: string | null;
};
association?: {
nomAssociation?: string | null;
emailContact?: string | null;
telephone?: string | null;
ville?: string | null;
} | null;
followup: {
restitutionDate?: string | Date | null;
discrepancyCategories?: string[];
discrepancyDetails?: string | null;
};
arbitration: {
blockedItems: Record<MaterialEventItemKey, number>;
decision: "partial_retention" | "full_retention" | "dismissed";
amountCents: number;
notes?: string | null;
};
}) {
const formData = (() => {
try {
return input.request.formData ? JSON.parse(input.request.formData) : {};
} catch {
return {};
}
})();
const blockedItems = sanitizeMaterialEventQuantityMap(input.arbitration.blockedItems);
const blockedRows = materialEventItems
.map((item) => ({ ...item, quantity: blockedItems[item.key] || 0 }))
.filter((item) => item.quantity > 0);
const decisionLabel =
input.arbitration.decision === "full_retention"
? "Encaissement total de la caution"
: input.arbitration.decision === "partial_retention"
? "Retenue partielle sur caution"
: "Classement sans suite";
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({
size: "A4",
margins: { top: 38, bottom: 42, left: 46, right: 46 },
info: {
Title: `Courrier de litige - Demande ${input.request.id}`,
Author: "Communauté de Communes Des Savanes",
Subject: "Notification de litige sur restitution de matériel",
},
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
let y = doc.page.margins.top;
drawLogo(doc, doc.page.margins.left, y, 74, 74);
doc.font("Helvetica-Bold").fontSize(16).fillColor("#0f172a").text(
"Notification de litige et arbitrage matériel",
doc.page.margins.left + 92,
y + 14,
{ width: pageWidth - 92 }
);
doc.font("Helvetica").fontSize(10).fillColor("#475569").text(
"Communauté de Communes Des Savanes - Direction des Services aux Usagers",
doc.page.margins.left + 92,
y + 40,
{ width: pageWidth - 92 }
);
y += 96;
const lines = [
["Association", input.association?.nomAssociation || "-"],
["Demande", input.request.titre || `Dossier #${input.request.id}`],
["Commune", formData.commune || input.association?.ville || "-"],
["Date de restitution", formatDateFr(input.followup.restitutionDate)],
["Décision d'arbitrage", decisionLabel],
["Montant retenu", formatCurrency(input.arbitration.amountCents)],
] as const;
lines.forEach(([label, value]) => {
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text(`${label} :`, doc.page.margins.left, y, {
width: 170,
});
doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(String(value || "-"), doc.page.margins.left + 170, y, {
width: pageWidth - 170,
});
y += 18;
});
y += 8;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 88, 10).fillAndStroke("#fff7ed", "#fdba74");
doc.font("Helvetica-Bold").fontSize(11).fillColor("#9a3412").text("Constat de terrain", doc.page.margins.left + 14, y + 12);
doc.font("Helvetica").fontSize(10).fillColor("#7c2d12").text(
[
input.followup.discrepancyCategories?.length
? `Réserves : ${input.followup.discrepancyCategories.join(", ")}.`
: null,
input.followup.discrepancyDetails?.trim() || null,
].filter(Boolean).join(" "),
doc.page.margins.left + 14,
y + 32,
{ width: pageWidth - 28 }
);
y += 108;
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Matériel provisoirement bloqué", doc.page.margins.left, y);
y += 18;
if (blockedRows.length === 0) {
doc.font("Helvetica").fontSize(10).fillColor("#475569").text(
"Aucun équipement n'est maintenu en indisponibilité après arbitrage.",
doc.page.margins.left,
y,
{ width: pageWidth }
);
y += 22;
} else {
blockedRows.forEach((item) => {
doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(
`- ${getMaterialEventLabel(item.key)} : ${item.quantity}`,
doc.page.margins.left + 8,
y,
{ width: pageWidth - 8 }
);
y += 16;
});
}
if (input.arbitration.notes?.trim()) {
y += 12;
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Observations administratives", doc.page.margins.left, y);
y += 18;
doc.font("Helvetica").fontSize(10).fillColor("#334155").text(input.arbitration.notes.trim(), doc.page.margins.left, y, {
width: pageWidth,
});
y = doc.y + 10;
}
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text(
`Document généré automatiquement pour le dossier #${input.request.id}.`,
doc.page.margins.left,
Math.max(y + 28, doc.page.height - doc.page.margins.bottom - 18),
{ width: pageWidth, align: "center" }
);
doc.end();
} catch (error) {
reject(error);
}
});
return {
buffer: pdfBuffer,
fileName: `Courrier_Litige_${input.request.id}.pdf`,
};
}

462
server/materialReturnPdf.ts Normal file
View file

@ -0,0 +1,462 @@
import PDFDocument from "pdfkit";
import fs from "node:fs";
import path from "node:path";
type MaterialReturnRequest = {
id: number;
titre: string;
formData?: string | null;
};
type MaterialReturnAssociation = {
nomAssociation?: string | null;
telephone?: string | null;
emailContact?: string | null;
ville?: string | null;
} | null | undefined;
const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) {
if (!fs.existsSync(CCDS_LOGO_PATH)) return;
try {
doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" });
} catch (error) {
console.warn("[MaterialReturnPdf] Impossible de charger le logo CCDS:", error);
}
}
function formatDateFr(date: string | Date | null | undefined) {
if (!date) return "";
try {
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return String(date);
}
}
function addLabeledValue(doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) {
doc.font("Helvetica-Bold").fontSize(9).fillColor("#334155").text(label, x, y, { width });
doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(value || "........................................................", x, y + 12, { width });
}
function addSignatureBlock(doc: PDFKit.PDFDocument, title: string, x: number, y: number, width: number) {
doc.roundedRect(x, y, width, 90, 8).stroke("#cbd5e1");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text(title, x + 12, y + 12, { width: width - 24 });
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("Nom, qualité, signature et date", x + 12, y + 32, { width: width - 24 });
}
export async function generateMaterialReturnStatementPdf(input: {
request: MaterialReturnRequest;
association?: MaterialReturnAssociation;
}) {
const formData = (() => {
try {
return input.request.formData ? JSON.parse(input.request.formData) : {};
} catch {
return {};
}
})();
const manifestationStart = formData.dateDebutManifestation || formData.dateManifestation;
const manifestationEnd = formData.dateFinManifestation || formData.dateManifestation;
const requestedItems = Object.entries(formData.materielsDemandes || {})
.filter(([, checked]) => Boolean(checked))
.map(([key]) => ({
key,
label:
key === "tente3x3"
? "Tente 3x3"
: key === "chapiteau5x5"
? "Chapiteau 5x5"
: key === "podium"
? "Podium"
: "Autres",
quantity: formData.quantitesDemandees?.[key] || "-",
extra: key === "autres" ? formData.autreMaterielPrecisions || "" : "",
}));
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({
size: "A4",
margins: { top: 36, bottom: 40, left: 42, right: 42 },
info: {
Title: `Fiche d'état des lieux - Demande ${input.request.id}`,
Author: "Communauté de Communes Des Savanes",
Subject: "Fiche d'état des lieux du matériel événementiel",
},
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
let y = doc.page.margins.top;
doc.moveTo(doc.page.margins.left, y).lineTo(doc.page.margins.left + pageWidth, y).lineWidth(2).stroke("#efb100");
y += 14;
drawLogo(doc, doc.page.margins.left + (pageWidth - 76) / 2, y, 76, 76);
y += 82;
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, {
width: pageWidth,
align: "center",
});
y += 16;
doc.font("Helvetica-Bold").fontSize(18).fillColor("#0f172a").text("FICHE D'ÉTAT DES LIEUX", doc.page.margins.left, y, {
width: pageWidth,
align: "center",
});
y += 18;
doc.font("Helvetica-Bold").fontSize(14).text("DU MATÉRIEL ÉVÉNEMENTIEL DE LA CCDS", doc.page.margins.left, y, {
width: pageWidth,
align: "center",
});
y += 22;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 34, 8).fillAndStroke("#eef5ff", "#bfd2ef");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f2d63").text("Document de suivi prérempli à compléter contradictoirement", doc.page.margins.left + 14, y + 9, {
width: pageWidth - 28,
align: "center",
});
y += 48;
const leftColWidth = (pageWidth - 16) / 2;
addLabeledValue(doc, "Commune", formData.commune || input.association?.ville || "", doc.page.margins.left, y, leftColWidth);
addLabeledValue(doc, "Demandeur", formData.demandeurNomPrenom || input.association?.nomAssociation || "", doc.page.margins.left + leftColWidth + 16, y, leftColWidth);
y += 40;
addLabeledValue(doc, "Téléphone", formData.telephoneAssociation || input.association?.telephone || "", doc.page.margins.left, y, leftColWidth);
addLabeledValue(doc, "Intitulé de la manifestation", formData.motifDemande || input.request.titre || "", doc.page.margins.left + leftColWidth + 16, y, leftColWidth);
y += 40;
addLabeledValue(
doc,
"Date prévue du / au",
manifestationStart || manifestationEnd
? `${formatDateFr(manifestationStart)}${manifestationEnd ? ` au ${formatDateFr(manifestationEnd)}` : ""}`
: "",
doc.page.margins.left,
y,
leftColWidth
);
addLabeledValue(doc, "Date de restitution prévue", formatDateFr(formData.dateRestitution), doc.page.margins.left + leftColWidth + 16, y, leftColWidth);
y += 48;
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Matériel concerné", doc.page.margins.left, y);
y += 14;
const tableX = doc.page.margins.left;
const cols = [pageWidth * 0.46, pageWidth * 0.16, pageWidth * 0.19, pageWidth * 0.19];
const headers = ["Équipement", "Qté", "Mise à disposition", "Restitution"];
let cursorX = tableX;
headers.forEach((header, index) => {
doc.rect(cursorX, y, cols[index], 22).fillAndStroke("#dce8f8", "#bfd2ef");
doc.font("Helvetica-Bold").fontSize(9).fillColor("#0f2d63").text(header, cursorX + 6, y + 7, {
width: cols[index] - 12,
align: index === 0 ? "left" : "center",
});
cursorX += cols[index];
});
y += 22;
const items = requestedItems.length > 0 ? requestedItems : [{ key: "none", label: "Aucun matériel renseigné", quantity: "-", extra: "" }];
items.forEach((item) => {
const rowHeight = item.extra ? 36 : 28;
let rowX = tableX;
const values = [
item.extra ? `${item.label}${item.extra}` : item.label,
item.quantity,
"À compléter",
"À compléter",
];
values.forEach((value, index) => {
doc.rect(rowX, y, cols[index], rowHeight).stroke("#cbd5e1");
doc.font(index < 2 ? "Helvetica" : "Helvetica-Oblique")
.fontSize(9)
.fillColor(index < 2 ? "#0f172a" : "#64748b")
.text(value, rowX + 6, y + 8, {
width: cols[index] - 12,
align: index === 0 ? "left" : "center",
});
rowX += cols[index];
});
y += rowHeight;
});
y += 20;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 76, 8).stroke("#cbd5e1");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text("Observations contradictoires", doc.page.margins.left + 12, y + 12);
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("Préciser l'état général du matériel, les éventuelles anomalies, manques ou réserves constatées.", doc.page.margins.left + 12, y + 28, {
width: pageWidth - 24,
});
y += 92;
addSignatureBlock(doc, "L'agent CCDS / service récupérateur", doc.page.margins.left, y, (pageWidth - 20) / 2);
addSignatureBlock(doc, "L'emprunteur / représentant de l'association", doc.page.margins.left + (pageWidth - 20) / 2 + 20, y, (pageWidth - 20) / 2);
y += 104;
doc.font("Helvetica").fontSize(8).fillColor("#64748b").text(
`Document préparé automatiquement pour la demande #${input.request.id} — à vérifier et compléter sur site lors de la restitution.`,
doc.page.margins.left,
y,
{ width: pageWidth, align: "center" }
);
doc.end();
} catch (error) {
reject(error);
}
});
return {
buffer: pdfBuffer,
fileName: `Fiche_Etat_Des_Lieux_${input.request.id}.pdf`,
};
}
export async function generateCompletedMaterialReturnStatementPdf(input: {
request: MaterialReturnRequest;
association?: MaterialReturnAssociation;
completion: {
compliance: "conforme" | "non_conforme";
discrepancyLabels: string[];
discrepancyDetails?: string | null;
agentName: string;
agentRole: string;
borrowerName: string;
borrowerRole: string;
validatedAt: Date;
geoLatitude?: string | null;
geoLongitude?: string | null;
geoStatus?: string | null;
geoFailureReason?: string | null;
locationReference?: string | null;
agentSignatureBuffer?: Buffer;
borrowerSignatureBuffer?: Buffer;
};
}) {
const formData = (() => {
try {
return input.request.formData ? JSON.parse(input.request.formData) : {};
} catch {
return {};
}
})();
const manifestationStart = formData.dateDebutManifestation || formData.dateManifestation;
const manifestationEnd = formData.dateFinManifestation || formData.dateManifestation;
const requestedItems = Object.entries(formData.materielsDemandes || {})
.filter(([, checked]) => Boolean(checked))
.map(([key]) => ({
key,
label:
key === "tente3x3"
? "Tente 3x3"
: key === "chapiteau5x5"
? "Chapiteau 5x5"
: key === "podium"
? "Podium"
: "Autres",
quantity: formData.quantitesDemandees?.[key] || "-",
extra: key === "autres" ? formData.autreMaterielPrecisions || "" : "",
}));
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({
size: "A4",
margins: { top: 36, bottom: 40, left: 42, right: 42 },
info: {
Title: `Fiche d'état des lieux finalisée - Demande ${input.request.id}`,
Author: "Communauté de Communes Des Savanes",
Subject: "Fiche d'état des lieux contradictoire du matériel événementiel",
},
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
let y = doc.page.margins.top;
doc.moveTo(doc.page.margins.left, y).lineTo(doc.page.margins.left + pageWidth, y).lineWidth(2).stroke("#efb100");
y += 14;
drawLogo(doc, doc.page.margins.left + (pageWidth - 76) / 2, y, 76, 76);
y += 82;
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, {
width: pageWidth,
align: "center",
});
y += 16;
doc.font("Helvetica-Bold").fontSize(18).fillColor("#0f172a").text("FICHE D'ÉTAT DES LIEUX FINALISÉE", doc.page.margins.left, y, {
width: pageWidth,
align: "center",
});
y += 18;
doc.font("Helvetica-Bold").fontSize(14).text("DU MATÉRIEL ÉVÉNEMENTIEL DE LA CCDS", doc.page.margins.left, y, {
width: pageWidth,
align: "center",
});
y += 22;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 44, 8).fillAndStroke(
input.completion.compliance === "conforme" ? "#ecfdf3" : "#fff7ed",
input.completion.compliance === "conforme" ? "#a7f3d0" : "#fdba74"
);
doc.font("Helvetica-Bold").fontSize(11).fillColor(input.completion.compliance === "conforme" ? "#166534" : "#9a3412").text(
input.completion.compliance === "conforme" ? "Restitution conforme" : "Restitution non conforme / partielle",
doc.page.margins.left + 14,
y + 10,
{ width: pageWidth - 28, align: "center" }
);
doc.font("Helvetica").fontSize(9).fillColor("#475569").text(
`Validée le ${formatDateFr(input.completion.validatedAt)}`,
doc.page.margins.left + 14,
y + 25,
{ width: pageWidth - 28, align: "center" }
);
y += 58;
const leftColWidth = (pageWidth - 16) / 2;
addLabeledValue(doc, "Commune", formData.commune || input.association?.ville || "", doc.page.margins.left, y, leftColWidth);
addLabeledValue(doc, "Association / demandeur", input.association?.nomAssociation || formData.demandeurNomPrenom || "", doc.page.margins.left + leftColWidth + 16, y, leftColWidth);
y += 40;
addLabeledValue(doc, "Téléphone", formData.telephoneAssociation || input.association?.telephone || "", doc.page.margins.left, y, leftColWidth);
addLabeledValue(doc, "Manifestation", formData.motifDemande || input.request.titre || "", doc.page.margins.left + leftColWidth + 16, y, leftColWidth);
y += 40;
addLabeledValue(
doc,
"Période",
manifestationStart || manifestationEnd
? `${formatDateFr(manifestationStart)}${manifestationEnd ? ` au ${formatDateFr(manifestationEnd)}` : ""}`
: "",
doc.page.margins.left,
y,
leftColWidth
);
addLabeledValue(doc, "Restitution prévue", formatDateFr(formData.dateRestitution), doc.page.margins.left + leftColWidth + 16, y, leftColWidth);
y += 48;
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Matériel restitué", doc.page.margins.left, y);
y += 14;
requestedItems.forEach((item) => {
doc.rect(doc.page.margins.left, y, pageWidth, 24).stroke("#cbd5e1");
doc.font("Helvetica").fontSize(9).fillColor("#0f172a").text(
`${item.label}${item.extra ? `${item.extra}` : ""}`,
doc.page.margins.left + 8,
y + 7,
{ width: pageWidth * 0.65 }
);
doc.font("Helvetica-Bold").fontSize(9).text(
`Qté : ${item.quantity}`,
doc.page.margins.left + pageWidth - 120,
y + 7,
{ width: 110, align: "right" }
);
y += 24;
});
y += 16;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 100, 8).stroke("#cbd5e1");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text("Observations contradictoires", doc.page.margins.left + 12, y + 12);
doc.font("Helvetica").fontSize(9).fillColor("#475569").text(
input.completion.discrepancyLabels.length > 0
? input.completion.discrepancyLabels.join(" • ")
: "Aucune réserve relevée.",
doc.page.margins.left + 12,
y + 30,
{ width: pageWidth - 24 }
);
doc.text(
input.completion.discrepancyDetails || "Aucune précision complémentaire.",
doc.page.margins.left + 12,
y + 50,
{ width: pageWidth - 24 }
);
y += 118;
const geoLine = input.completion.geoLatitude && input.completion.geoLongitude
? `Géolocalisation : ${input.completion.geoLatitude}, ${input.completion.geoLongitude}`
: input.completion.locationReference
? `Référence de lieu : ${input.completion.locationReference}${
input.completion.geoFailureReason ? ` • GPS indisponible (${input.completion.geoFailureReason})` : ""
}`
: input.completion.geoFailureReason
? `GPS indisponible (${input.completion.geoFailureReason})`
: "";
doc.font("Helvetica").fontSize(8).fillColor("#64748b").text(
`Horodatage serveur : ${new Date(input.completion.validatedAt).toLocaleString("fr-FR")}${geoLine ? `${geoLine}` : ""}`,
doc.page.margins.left,
y,
{ width: pageWidth }
);
y += 18;
const signatureWidth = (pageWidth - 20) / 2;
doc.roundedRect(doc.page.margins.left, y, signatureWidth, 126, 8).stroke("#cbd5e1");
doc.roundedRect(doc.page.margins.left + signatureWidth + 20, y, signatureWidth, 126, 8).stroke("#cbd5e1");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text("Agent CCDS / service récupérateur", doc.page.margins.left + 12, y + 12, {
width: signatureWidth - 24,
});
doc.font("Helvetica").fontSize(9).fillColor("#475569").text(
`${input.completion.agentName}\n${input.completion.agentRole}`,
doc.page.margins.left + 12,
y + 30,
{ width: signatureWidth - 24 }
);
if (input.completion.agentSignatureBuffer) {
doc.image(input.completion.agentSignatureBuffer, doc.page.margins.left + 12, y + 64, {
fit: [signatureWidth - 24, 42],
align: "center",
valign: "center",
});
}
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text("Emprunteur / représentant", doc.page.margins.left + signatureWidth + 32, y + 12, {
width: signatureWidth - 24,
});
doc.font("Helvetica").fontSize(9).fillColor("#475569").text(
`${input.completion.borrowerName}\n${input.completion.borrowerRole}`,
doc.page.margins.left + signatureWidth + 32,
y + 30,
{ width: signatureWidth - 24 }
);
if (input.completion.borrowerSignatureBuffer) {
doc.image(input.completion.borrowerSignatureBuffer, doc.page.margins.left + signatureWidth + 32, y + 64, {
fit: [signatureWidth - 24, 42],
align: "center",
valign: "center",
});
}
y += 138;
doc.font("Helvetica").fontSize(8).fillColor("#64748b").text(
`Document finalisé automatiquement pour la demande #${input.request.id}.`,
doc.page.margins.left,
y,
{ width: pageWidth, align: "center" }
);
doc.end();
} catch (error) {
reject(error);
}
});
return {
buffer: pdfBuffer,
fileName: `Fiche_Etat_Des_Lieux_Finalisee_${input.request.id}.pdf`,
};
}

View file

@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./db", () => ({
getMaterialReturnFollowupByRequestId: vi.fn(),
upsertMaterialReturnFollowup: vi.fn(),
}));
vi.mock("./mailer", () => ({
sendOperationalEmail: vi.fn(),
}));
vi.mock("./materialReturnEmail", () => ({
generateMaterialReturnReminderEmail: vi.fn(),
}));
vi.mock("./materialReturnPdf", () => ({
generateMaterialReturnStatementPdf: vi.fn(),
}));
vi.mock("./logisticsSettings", () => ({
getLogisticsSettings: vi.fn(),
}));
import * as db from "./db";
import { getLogisticsSettings } from "./logisticsSettings";
import { scheduleMaterialReturnFollowup } from "./materialReturnWorkflow";
describe("scheduleMaterialReturnFollowup", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getLogisticsSettings).mockResolvedValue({
materialReturnLeadDays: 4,
materialReturnGraceDays: 3,
inventory: {
tente3x3: 12,
chapiteau5x5: 10,
podium: 1,
autres: null,
},
replacementValues: {
tente3x3: 0,
chapiteau5x5: 0,
podium: 0,
autres: null,
},
});
});
it("keeps the standard expiry window for a fresh follow-up", async () => {
vi.mocked(db.getMaterialReturnFollowupByRequestId).mockResolvedValue(undefined);
vi.mocked(db.upsertMaterialReturnFollowup).mockResolvedValue(1);
const request = {
id: 12,
associationId: 3,
formData: JSON.stringify({ dateRestitution: "2026-06-15" }),
} as any;
await scheduleMaterialReturnFollowup({
request,
serviceLabel: "Service technique",
recipientEmails: ["agent@example.com"],
});
expect(db.upsertMaterialReturnFollowup).toHaveBeenCalledTimes(1);
const payload = vi.mocked(db.upsertMaterialReturnFollowup).mock.calls[0]?.[1];
expect(payload.status).toBe("planifie");
expect(new Date(payload.uploadTokenExpiresAt).toISOString().startsWith("2026-06-18")).toBe(true);
});
it("reactivates an expired upload window when the follow-up is reassigned", async () => {
const expiredDate = new Date();
expiredDate.setDate(expiredDate.getDate() - 2);
expiredDate.setHours(23, 59, 59, 999);
vi.mocked(db.getMaterialReturnFollowupByRequestId).mockResolvedValue({
id: 4,
requestId: 12,
associationId: 3,
status: "en_cours",
uploadToken: "existing-token",
uploadTokenExpiresAt: expiredDate,
} as any);
vi.mocked(db.upsertMaterialReturnFollowup).mockResolvedValue(4);
const request = {
id: 12,
associationId: 3,
formData: JSON.stringify({ dateRestitution: "2026-05-01" }),
} as any;
await scheduleMaterialReturnFollowup({
request,
serviceLabel: "Service technique",
recipientEmails: ["agent@example.com"],
});
const payload = vi.mocked(db.upsertMaterialReturnFollowup).mock.calls[0]?.[1];
expect(payload.uploadToken).toBe("existing-token");
expect(new Date(payload.uploadTokenExpiresAt).getTime()).toBeGreaterThan(Date.now());
expect(payload.status).toBe("planifie");
});
it("generates a new token and a fresh upload window when reactivation is explicit", async () => {
const futureExpiry = new Date("2026-06-18T23:59:59.999Z");
vi.mocked(db.getMaterialReturnFollowupByRequestId).mockResolvedValue({
id: 4,
requestId: 12,
associationId: 3,
status: "en_attente",
uploadToken: "existing-token",
uploadTokenExpiresAt: futureExpiry,
} as any);
vi.mocked(db.upsertMaterialReturnFollowup).mockResolvedValue(4);
const request = {
id: 12,
associationId: 3,
formData: JSON.stringify({ dateRestitution: "2026-06-15" }),
} as any;
const reactivatedAt = new Date("2026-06-20T10:30:00.000Z");
await scheduleMaterialReturnFollowup({
request,
serviceLabel: "Service technique",
recipientEmails: ["agent@example.com"],
}, {
forceNewToken: true,
forceImmediateSendWindow: true,
reactivatedByUserId: 9,
reactivatedAt,
reactivationReason: "Suivi rouvert",
});
const payload = vi.mocked(db.upsertMaterialReturnFollowup).mock.calls[0]?.[1];
expect(payload.uploadToken).not.toBe("existing-token");
expect(new Date(payload.uploadTokenExpiresAt).getTime()).toBeGreaterThan(futureExpiry.getTime());
expect(new Date(payload.uploadTokenExpiresAt).getTime()).toBeGreaterThan(reactivatedAt.getTime());
expect(payload.reactivatedByUserId).toBe(9);
expect(payload.reactivationReason).toBe("Suivi rouvert");
});
});

View file

@ -0,0 +1,436 @@
import { randomBytes } from "node:crypto";
import * as db from "./db";
import { sendOperationalEmail } from "./mailer";
import { generateMaterialReturnReminderEmail } from "./materialReturnEmail";
import { generateMaterialReturnStatementPdf } from "./materialReturnPdf";
import { getLogisticsSettings } from "./logisticsSettings";
import { sanitizeMaterialEventQuantityMap } from "@shared/materialEvent";
type RequestLike = Awaited<ReturnType<typeof db.getRequestById>>;
export const materialReturnStatusLabels = {
planifie: "Planifié",
en_attente: "En attente",
en_cours: "En cours",
cloture: "Clôturé / Récupéré",
} as const;
export const materialReturnStatusColors = {
planifie: "bg-slate-100 text-slate-700",
en_attente: "bg-red-100 text-red-700",
en_cours: "bg-amber-100 text-amber-700",
cloture: "bg-green-100 text-green-700",
} as const;
export const materialReturnBoardStateLabels = {
a_attribuer: "À attribuer",
planifie: "Planifié",
terrain: "Sur le terrain",
retard: "Alerte / retard",
conforme: "Retour conforme",
litige: "Litige / dégradation",
} as const;
export const materialReturnBoardStateColors = {
a_attribuer: "bg-slate-100 text-slate-700 border-slate-200",
planifie: "bg-blue-100 text-blue-700 border-blue-200",
terrain: "bg-amber-100 text-amber-700 border-amber-200",
retard: "bg-red-100 text-red-700 border-red-200",
conforme: "bg-green-100 text-green-700 border-green-200",
litige: "bg-orange-100 text-orange-800 border-orange-200",
} as const;
export const materialReturnDiscrepancyOptions = [
{ value: "materiel_manquant", label: "Matériel manquant" },
{ value: "accessoires_manquants", label: "Visserie / accessoires manquants" },
{ value: "structure_deformee", label: "Structure déformée / tordue" },
{ value: "toile_dechiree", label: "Toile / bâche déchirée" },
{ value: "choc_important", label: "Choc important" },
{ value: "materiel_sale", label: "Matériel restitué sale" },
{ value: "materiel_humide", label: "Matériel humide (risque moisissure)" },
] as const;
export const agentRoleOptions = [
"Agent technique",
"Responsable logistique",
"Agent MJS",
"Agent DSU",
] as const;
export const borrowerRoleOptions = [
"Président d'association",
"Trésorier",
"Régisseur commune",
"Bénévole mandaté",
] as const;
export function parseRecipientEmails(value: string | null | undefined) {
if (!value) return [] as string[];
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) return [];
return parsed.map((entry) => String(entry || "").trim()).filter(Boolean);
} catch {
return [];
}
}
export function formatMaterialReturnDate(date: Date | string | null | undefined) {
if (!date) return "";
return new Date(date).toISOString().split("T")[0];
}
export function buildMaterialReturnUploadLink(baseUrl: string, token: string) {
return `${baseUrl.replace(/\/$/, "")}/materiel/restitution/${token}`;
}
export function getMaterialReturnUploadLinkState(input: {
status?: string | null;
uploadTokenExpiresAt?: Date | string | null;
now?: Date;
}) {
if (input.status === "cloture") {
return "closed" as const;
}
if (!input.uploadTokenExpiresAt) {
return "expired" as const;
}
const now = input.now || new Date();
return now <= new Date(input.uploadTokenExpiresAt) ? "active" as const : "expired" as const;
}
function getRestitutionDateFromRequest(request: NonNullable<RequestLike>) {
try {
const formData = request.formData ? JSON.parse(request.formData) : {};
const rawDate = formData?.dateRestitution;
if (!rawDate) return null;
const date = new Date(rawDate);
return Number.isNaN(date.getTime()) ? null : date;
} catch {
return null;
}
}
function getPlannedSendAt(restitutionDate: Date, leadDays: number) {
const planned = new Date(restitutionDate);
planned.setDate(planned.getDate() - leadDays);
planned.setHours(8, 0, 0, 0);
return planned;
}
function getUploadTokenExpiry(restitutionDate: Date, graceDays: number) {
const expiry = new Date(restitutionDate);
expiry.setDate(expiry.getDate() + graceDays);
expiry.setHours(23, 59, 59, 999);
return expiry;
}
function getReactivatedUploadTokenExpiry(now: Date, graceDays: number) {
const expiry = new Date(now);
expiry.setDate(expiry.getDate() + Math.max(0, graceDays));
expiry.setHours(23, 59, 59, 999);
return expiry;
}
function getDelayAlertDate(restitutionDate: Date | string, graceDays: number) {
const alertDate = new Date(restitutionDate);
alertDate.setDate(alertDate.getDate() + graceDays);
alertDate.setHours(0, 0, 0, 0);
return alertDate;
}
function hasOperationalAssignment(followup: {
serviceLabel?: string | null;
recipientEmails?: string | null;
} | null | undefined) {
if (!followup) return false;
return Boolean(followup.serviceLabel?.trim()) || parseRecipientEmails(followup.recipientEmails).length > 0;
}
function mergeRecipientLists(...recipientSets: Array<string[] | undefined>) {
return Array.from(
new Set(
recipientSets
.flatMap((recipientSet) => recipientSet || [])
.map((entry) => String(entry || "").trim())
.filter(Boolean)
)
);
}
function getNotificationRecipients(followup: {
recipientEmails?: string | null;
supervisionRecipientEmails?: string | null;
}, tone: "assignment" | "scheduled" | "overdue" | "reactivation") {
const terrainRecipients = parseRecipientEmails(followup.recipientEmails);
const supervisionRecipients = parseRecipientEmails(followup.supervisionRecipientEmails);
if (tone === "overdue") {
return mergeRecipientLists(supervisionRecipients, terrainRecipients);
}
return terrainRecipients;
}
export function computeMaterialReturnBoardState(input: {
requestStatus?: string | null;
restitutionDate?: Date | string | null;
followup?: {
status?: string | null;
issueFlag?: boolean | null;
compliance?: string | null;
litigationStatus?: string | null;
serviceLabel?: string | null;
recipientEmails?: string | null;
} | null;
now?: Date;
graceDays?: number;
}) {
const now = input.now || new Date();
const followup = input.followup;
const graceDays = Number.isFinite(input.graceDays) ? Math.max(0, input.graceDays || 0) : 3;
if (followup?.litigationStatus === "pending") {
return "litige" as const;
}
if (followup?.status === "cloture") {
return "conforme" as const;
}
const restitutionDate = input.restitutionDate ? new Date(input.restitutionDate) : null;
const assigned = hasOperationalAssignment(followup);
if (!assigned) {
return "a_attribuer" as const;
}
if (restitutionDate) {
const delayAlertDate = getDelayAlertDate(restitutionDate, graceDays);
if (now >= delayAlertDate) {
return "retard" as const;
}
const restitutionDay = new Date(restitutionDate);
restitutionDay.setHours(0, 0, 0, 0);
const currentDay = new Date(now);
currentDay.setHours(0, 0, 0, 0);
if (currentDay >= restitutionDay) {
return "terrain" as const;
}
}
return "planifie" as const;
}
export async function sendMaterialReturnFollowupEmail(input: {
followup: NonNullable<Awaited<ReturnType<typeof db.getMaterialReturnFollowupByRequestId>>>;
request: NonNullable<RequestLike>;
association: Awaited<ReturnType<typeof db.getAssociationById>>;
baseUrl: string;
tone?: "assignment" | "scheduled" | "overdue" | "reactivation";
}) {
const tone = input.tone || "scheduled";
const recipients = getNotificationRecipients(input.followup, tone);
if (recipients.length === 0) {
return { sent: false as const, reason: "no_recipients" as const };
}
const uploadLink = buildMaterialReturnUploadLink(input.baseUrl, input.followup.uploadToken);
const pdf = await generateMaterialReturnStatementPdf({
request: input.request,
association: input.association,
});
const email = generateMaterialReturnReminderEmail({
associationName: input.association?.nomAssociation || "Association",
serviceLabel: input.followup.serviceLabel,
restitutionDate: input.followup.restitutionDate,
uploadLink,
requestTitle: input.request.titre,
tone,
});
return sendOperationalEmail({
to: recipients,
subject: email.subject,
text: email.text,
html: email.html,
attachments: [
{
filename: pdf.fileName,
content: pdf.buffer,
contentType: "application/pdf",
},
],
});
}
export async function scheduleMaterialReturnFollowup(input: {
request: NonNullable<RequestLike>;
serviceLabel?: string;
recipientEmails: string[];
supervisionServiceLabel?: string;
supervisionRecipientEmails?: string[];
}, options?: {
forceNewToken?: boolean;
forceImmediateSendWindow?: boolean;
reactivatedByUserId?: number | null;
reactivatedAt?: Date | null;
reactivationReason?: string | null;
}) {
const logisticsSettings = await getLogisticsSettings();
const restitutionDate = getRestitutionDateFromRequest(input.request);
if (!restitutionDate) return null;
const existing = await db.getMaterialReturnFollowupByRequestId(input.request.id);
const uploadToken = options?.forceNewToken || !existing?.uploadToken
? randomBytes(32).toString("hex")
: existing.uploadToken;
const now = new Date();
const plannedSendAt = options?.forceImmediateSendWindow ? now : getPlannedSendAt(restitutionDate, logisticsSettings.materialReturnLeadDays);
const defaultUploadTokenExpiry = getUploadTokenExpiry(restitutionDate, logisticsSettings.materialReturnGraceDays);
const shouldReactivateWindow = Boolean(options?.forceNewToken || options?.forceImmediateSendWindow);
const uploadTokenExpiresAt = shouldReactivateWindow
? getReactivatedUploadTokenExpiry(options?.reactivatedAt || now, logisticsSettings.materialReturnGraceDays)
: existing
&& existing.status !== "cloture"
&& existing.uploadTokenExpiresAt
&& new Date(existing.uploadTokenExpiresAt) < now
? getReactivatedUploadTokenExpiry(now, logisticsSettings.materialReturnGraceDays)
: defaultUploadTokenExpiry;
await db.upsertMaterialReturnFollowup(input.request.id, {
associationId: input.request.associationId,
serviceLabel: input.serviceLabel || null,
recipientEmails: JSON.stringify(input.recipientEmails),
supervisionServiceLabel: input.supervisionServiceLabel || null,
supervisionRecipientEmails: JSON.stringify(input.supervisionRecipientEmails || []),
restitutionDate,
plannedSendAt,
status: existing?.status === "cloture" ? "cloture" : "planifie",
uploadToken,
uploadTokenExpiresAt,
signedFileKey: existing?.signedFileKey || null,
signedFileUrl: existing?.signedFileUrl || null,
signedFileName: existing?.signedFileName || null,
signedMimeType: existing?.signedMimeType || null,
uploadedByName: existing?.uploadedByName || null,
uploadedByEmail: existing?.uploadedByEmail || null,
uploadedAt: existing?.uploadedAt || null,
closedAt: existing?.closedAt || null,
lastReminderSentAt: existing?.lastReminderSentAt || null,
reactivatedByUserId: options?.reactivatedByUserId ?? existing?.reactivatedByUserId ?? null,
reactivatedAt: options?.reactivatedAt ?? existing?.reactivatedAt ?? null,
reactivationReason: options?.reactivationReason ?? existing?.reactivationReason ?? null,
sentAt: existing?.sentAt || null,
});
return db.getMaterialReturnFollowupByRequestId(input.request.id);
}
export async function runMaterialReturnScheduler(baseUrl: string) {
const now = new Date();
const logisticsSettings = await getLogisticsSettings();
const toEscalate = await db.listMaterialReturnFollowupsToEscalate();
for (const followup of toEscalate) {
const alertDate = getDelayAlertDate(followup.restitutionDate, logisticsSettings.materialReturnGraceDays);
if (now >= alertDate) {
const shouldSendOverdueAlert = !followup.lastReminderSentAt || new Date(followup.lastReminderSentAt) < alertDate;
if (shouldSendOverdueAlert) {
const request = await db.getRequestById(followup.requestId);
if (request?.status === "validee" && request.type === "demande_materiel_evenementiel") {
const association = await db.getAssociationById(request.associationId);
try {
const result = await sendMaterialReturnFollowupEmail({
followup,
request,
association,
baseUrl,
tone: "overdue",
});
if (result.sent) {
await db.updateMaterialReturnFollowup(followup.id, {
lastReminderSentAt: new Date(),
});
}
} catch (error) {
console.error("[MaterialReturnScheduler] Overdue reminder failed:", error);
}
}
}
await db.updateMaterialReturnFollowup(followup.id, {
status: "en_cours",
});
}
}
const dueFollowups = await db.listPendingMaterialReturnFollowups(now);
for (const followup of dueFollowups) {
const request = await db.getRequestById(followup.requestId);
if (!request || request.status !== "validee" || request.type !== "demande_materiel_evenementiel") {
continue;
}
const association = await db.getAssociationById(request.associationId);
try {
const result = await sendMaterialReturnFollowupEmail({
followup,
request,
association,
baseUrl,
tone: "scheduled",
});
if (result.sent) {
await db.updateMaterialReturnFollowup(followup.id, {
status: "en_attente",
sentAt: new Date(),
lastReminderSentAt: new Date(),
});
}
} catch (error) {
console.error("[MaterialReturnScheduler] Email send failed:", error);
}
}
}
export function serializeMaterialReturnFollowup(followup: Awaited<ReturnType<typeof db.getMaterialReturnFollowupByRequestId>>) {
if (!followup) return null;
let discrepancyCategories: string[] = [];
try {
const parsed = JSON.parse(followup.discrepancyCategories || "[]");
if (Array.isArray(parsed)) {
discrepancyCategories = parsed.map((entry) => String(entry || "")).filter(Boolean);
}
} catch {
discrepancyCategories = [];
}
return {
...followup,
recipientEmails: parseRecipientEmails(followup.recipientEmails),
supervisionRecipientEmails: parseRecipientEmails(followup.supervisionRecipientEmails),
serviceLabel: typeof followup.serviceLabel === "string" ? followup.serviceLabel : "",
supervisionServiceLabel: typeof followup.supervisionServiceLabel === "string" ? followup.supervisionServiceLabel : "",
status: typeof followup.status === "string" ? followup.status : "planifie",
uploadLinkState: getMaterialReturnUploadLinkState({
status: followup.status,
uploadTokenExpiresAt: followup.uploadTokenExpiresAt,
}),
compliance: followup.compliance === "conforme" || followup.compliance === "non_conforme"
? followup.compliance
: null,
issueFlag: Boolean(followup.issueFlag),
litigationStatus: followup.litigationStatus === "pending" ? "pending" : "none",
geoFailureReason: typeof followup.geoFailureReason === "string" ? followup.geoFailureReason : null,
discrepancyCategories,
blockedItems: sanitizeMaterialEventQuantityMap(
(() => {
try {
return followup.blockedItems ? JSON.parse(followup.blockedItems) : null;
} catch {
return null;
}
})()
),
};
}

952
server/operationalTour.ts Normal file
View file

@ -0,0 +1,952 @@
import { z } from "zod";
import type { Request } from "express";
import { COOKIE_NAME } from "@shared/const";
import type { User } from "../drizzle/schema";
import { createSessionToken, loginLocalUser } from "./_core/auth";
import * as db from "./db";
export const OPERATIONAL_TOUR_SETTINGS_KEY = "system.operationalTour.settings";
export const OPERATIONAL_TOUR_HISTORY_KEY = "system.operationalTour.history";
export const operationalTourEnvironmentSchema = z.enum(["production", "secondary"]);
export type OperationalTourEnvironment = z.infer<typeof operationalTourEnvironmentSchema>;
export const operationalTourAuthModeSchema = z.enum(["local", "oauth"]);
export type OperationalTourAuthMode = z.infer<typeof operationalTourAuthModeSchema>;
export const operationalTourStepKindSchema = z.enum(["technical", "user"]);
export type OperationalTourStepKind = z.infer<typeof operationalTourStepKindSchema>;
const operationalTourTargetSchema = z.object({
baseUrl: z.string().trim().optional().default(""),
associationEmail: z.string().trim().optional().default(""),
associationPassword: z.string().trim().optional().default(""),
associationAuthMode: operationalTourAuthModeSchema.optional().default("local"),
accueilEmail: z.string().trim().optional().default(""),
accueilPassword: z.string().trim().optional().default(""),
accueilAuthMode: operationalTourAuthModeSchema.optional().default("local"),
adminEmail: z.string().trim().optional().default(""),
adminPassword: z.string().trim().optional().default(""),
adminAuthMode: operationalTourAuthModeSchema.optional().default("local"),
});
const EMPTY_TARGET = {
baseUrl: "",
associationEmail: "",
associationPassword: "",
associationAuthMode: "local",
accueilEmail: "",
accueilPassword: "",
accueilAuthMode: "local",
adminEmail: "",
adminPassword: "",
adminAuthMode: "local",
} as const;
const operationalTourSettingsSchema = z.object({
defaultEnvironment: operationalTourEnvironmentSchema.default("production"),
targets: z.object({
production: operationalTourTargetSchema.default(EMPTY_TARGET),
secondary: operationalTourTargetSchema.default(EMPTY_TARGET),
}),
});
export type OperationalTourSettings = z.infer<typeof operationalTourSettingsSchema>;
const operationalTourStepSchema = z.object({
id: z.string(),
label: z.string(),
kind: operationalTourStepKindSchema.default("technical"),
status: z.enum(["ok", "warning", "error"]),
durationMs: z.number().int().nonnegative(),
message: z.string(),
details: z.string().optional(),
url: z.string().optional(),
critical: z.boolean().default(true),
});
export type OperationalTourStep = z.infer<typeof operationalTourStepSchema>;
const operationalTourReportSchema = z.object({
id: z.string(),
environment: operationalTourEnvironmentSchema,
environmentLabel: z.string(),
baseUrl: z.string(),
status: z.enum(["ok", "warning", "error"]),
startedAt: z.string(),
finishedAt: z.string(),
durationMs: z.number().int().nonnegative(),
initiatedBy: z.object({
userId: z.number().int().positive(),
name: z.string(),
email: z.string().optional(),
}),
summary: z.object({
total: z.number().int().nonnegative(),
ok: z.number().int().nonnegative(),
warning: z.number().int().nonnegative(),
error: z.number().int().nonnegative(),
}),
steps: z.array(operationalTourStepSchema),
});
export type OperationalTourReport = z.infer<typeof operationalTourReportSchema>;
const OPERATIONAL_TOUR_HISTORY_MAX_BYTES = 60_000;
const OPERATIONAL_TOUR_HISTORY_MAX_REPORTS = 12;
const OPERATIONAL_TOUR_DUPLICATE_WINDOW_MS = 15_000;
const EMPTY_SETTINGS: OperationalTourSettings = {
defaultEnvironment: "production",
targets: {
production: {
baseUrl: "https://www.portail-association973.com",
associationEmail: "",
associationPassword: "",
associationAuthMode: "local",
accueilEmail: "",
accueilPassword: "",
accueilAuthMode: "local",
adminEmail: "",
adminPassword: "",
adminAuthMode: "local",
},
secondary: {
baseUrl: "",
associationEmail: "",
associationPassword: "",
associationAuthMode: "local",
accueilEmail: "",
accueilPassword: "",
accueilAuthMode: "local",
adminEmail: "",
adminPassword: "",
adminAuthMode: "local",
},
},
};
type PageCheckOptions = {
id: string;
label: string;
kind: OperationalTourStepKind;
url: string;
cookieHeader?: string | null;
markers?: string[];
expectedPath?: string;
critical?: boolean;
};
type PageCheckResult = OperationalTourStep;
function normalizeBaseUrl(value: string | undefined) {
return String(value || "").trim().replace(/\/+$/, "");
}
function normalizeEmail(value: string | undefined) {
return String(value || "").trim().toLowerCase();
}
function buildRequestOrigin(req: Request) {
const forwardedProto = typeof req.headers["x-forwarded-proto"] === "string" ? req.headers["x-forwarded-proto"] : undefined;
const forwardedHost = typeof req.headers["x-forwarded-host"] === "string" ? req.headers["x-forwarded-host"] : undefined;
const protocol = forwardedProto || req.protocol || "http";
const host = forwardedHost || req.get?.("host") || "";
return host ? `${protocol}://${host}` : "";
}
function isLoginPage(body: string, finalUrl: string) {
return finalUrl.includes("/login") || body.includes("Connexion requise") || body.includes("Se connecter");
}
function normalizePathname(url: string) {
try {
const pathname = new URL(url).pathname || "/";
return pathname === "/" ? "/" : pathname.replace(/\/+$/, "");
} catch {
return url;
}
}
function hasReactShell(body: string, contentType: string | null) {
const lowerBody = body.toLowerCase();
const lowerType = String(contentType || "").toLowerCase();
return (
lowerType.includes("text/html") &&
(lowerBody.includes("<!doctype html") || lowerBody.includes("<html")) &&
(lowerBody.includes("<title>") || lowerBody.includes("script type=\"module\"") || lowerBody.includes("id=\"manus-runtime\""))
);
}
function buildStaticStep(input: Omit<OperationalTourStep, "durationMs"> & { durationMs?: number }): OperationalTourStep {
return {
...input,
durationMs: input.durationMs ?? 0,
};
}
async function fetchPageCheck(options: PageCheckOptions): Promise<PageCheckResult> {
const started = Date.now();
try {
const response = await fetch(options.url, {
headers: options.cookieHeader ? { cookie: options.cookieHeader } : undefined,
redirect: "follow",
});
const body = await response.text();
const finalUrl = response.url || options.url;
const contentType = response.headers.get("content-type");
const expectedPath = normalizePathname(options.expectedPath || options.url);
const finalPath = normalizePathname(finalUrl);
const markers = options.markers || [];
const matchedMarker = markers.find((marker) => body.includes(marker));
const routeMatches = expectedPath === "/" ? finalPath === "/" : finalPath === expectedPath || finalPath.startsWith(`${expectedPath}/`);
const reactShell = hasReactShell(body, contentType);
if (!response.ok) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "error",
durationMs: Date.now() - started,
message: `HTTP ${response.status}`,
details: finalUrl,
url: options.url,
critical: options.critical ?? true,
};
}
if (options.cookieHeader && isLoginPage(body, finalUrl)) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "error",
durationMs: Date.now() - started,
message: "Authentification non reconnue",
details: finalUrl,
url: options.url,
critical: options.critical ?? true,
};
}
if (!routeMatches) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "warning",
durationMs: Date.now() - started,
message: "Page servie sur un chemin inattendu",
details: `${finalUrl} (attendu: ${expectedPath})`,
url: options.url,
critical: options.critical ?? true,
};
}
if (matchedMarker) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "ok",
durationMs: Date.now() - started,
message: `Contrôle réussi (${matchedMarker})`,
details: finalUrl,
url: options.url,
critical: options.critical ?? true,
};
}
if (reactShell) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "ok",
durationMs: Date.now() - started,
message: "Page accessible, rendu client servi",
details: finalUrl,
url: options.url,
critical: options.critical ?? true,
};
}
return {
id: options.id,
label: options.label,
kind: options.kind,
status: markers.length > 0 ? "warning" : "ok",
durationMs: Date.now() - started,
message: markers.length > 0 ? "Page accessible, marqueur visuel non confirmé" : "Contrôle réussi",
details: finalUrl,
url: options.url,
critical: options.critical ?? true,
};
} catch (error) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "error",
durationMs: Date.now() - started,
message: error instanceof Error ? error.message : "Erreur réseau",
url: options.url,
critical: options.critical ?? true,
};
}
}
async function fetchJsonCheck(options: {
id: string;
label: string;
kind: OperationalTourStepKind;
url: string;
cookieHeader?: string | null;
markers?: string[];
critical?: boolean;
}) {
const started = Date.now();
try {
const response = await fetch(options.url, {
headers: {
accept: "application/json",
...(options.cookieHeader ? { cookie: options.cookieHeader } : {}),
},
redirect: "follow",
});
const body = await response.text();
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
const matchesMarker = (options.markers || []).some((marker) => body.includes(marker));
if (!response.ok) {
return buildStaticStep({
id: options.id,
label: options.label,
kind: options.kind,
status: "error",
message: `HTTP ${response.status}`,
details: response.url || options.url,
url: options.url,
critical: options.critical ?? true,
durationMs: Date.now() - started,
});
}
if (!contentType.includes("application/json")) {
return buildStaticStep({
id: options.id,
label: options.label,
kind: options.kind,
status: "warning",
message: "Réponse non JSON",
details: response.url || options.url,
url: options.url,
critical: options.critical ?? true,
durationMs: Date.now() - started,
});
}
return buildStaticStep({
id: options.id,
label: options.label,
kind: options.kind,
status: matchesMarker || !(options.markers || []).length ? "ok" : "warning",
message: matchesMarker || !(options.markers || []).length ? "API accessible et réponse JSON valide" : "API accessible, signature JSON à confirmer",
details: response.url || options.url,
url: options.url,
critical: options.critical ?? true,
durationMs: Date.now() - started,
});
} catch (error) {
return buildStaticStep({
id: options.id,
label: options.label,
kind: options.kind,
status: "error",
message: error instanceof Error ? error.message : "Erreur réseau",
url: options.url,
critical: options.critical ?? true,
durationMs: Date.now() - started,
});
}
}
async function fetchHomeDiagnostics(baseUrl: string) {
const started = Date.now();
try {
const response = await fetch(`${baseUrl}/`, { redirect: "follow" });
const body = await response.text();
return {
durationMs: Date.now() - started,
body,
headers: response.headers,
ok: response.ok,
finalUrl: response.url || `${baseUrl}/`,
};
} catch (error) {
return {
durationMs: Date.now() - started,
body: "",
headers: new Headers(),
ok: false,
finalUrl: `${baseUrl}/`,
error: error instanceof Error ? error.message : "Erreur réseau",
};
}
}
async function createConfiguredSessionCookie(email: string, password?: string) {
const normalizedEmail = normalizeEmail(email);
const normalizedPassword = String(password || "");
let user;
if (normalizedPassword) {
user = await loginLocalUser({ email: normalizedEmail, password: normalizedPassword });
} else {
const existingUser = await db.getUserByEmail(normalizedEmail);
if (!existingUser) {
throw new Error("Compte introuvable");
}
if (!existingUser.isActive) {
throw new Error("Compte inactif");
}
if (!existingUser.loginMethod || !existingUser.loginMethod.endsWith("_oauth")) {
throw new Error("Mot de passe requis pour ce compte");
}
user = existingUser;
}
const token = await createSessionToken(user);
return `${COOKIE_NAME}=${token}`;
}
async function tryFindPublicProfileUrl(baseUrl: string) {
try {
const response = await fetch(`${baseUrl}/associations`, { redirect: "follow" });
if (!response.ok) return null;
const body = await response.text();
const match = body.match(/href="(\/associations\/[^"]+)"/);
if (match) return `${baseUrl}${match[1]}`;
} catch {
// fallback below
}
try {
const directory = await db.listAssociationDirectoryMapEntries({ limit: 1 });
const firstEntry = directory.data?.[0];
return firstEntry?.id ? `${baseUrl}/associations/${firstEntry.id}` : null;
} catch {
return null;
}
}
function computeReportStatus(steps: OperationalTourStep[]): OperationalTourReport["status"] {
const hasCriticalError = steps.some((step) => step.critical !== false && step.status === "error");
if (hasCriticalError) return "error";
const hasWarning = steps.some((step) => step.status !== "ok");
return hasWarning ? "warning" : "ok";
}
function summarizeSteps(steps: OperationalTourStep[]) {
return {
total: steps.length,
ok: steps.filter((step) => step.status === "ok").length,
warning: steps.filter((step) => step.status === "warning").length,
error: steps.filter((step) => step.status === "error").length,
};
}
function summarizeStepsByKind(steps: OperationalTourStep[], kind: OperationalTourStepKind) {
return summarizeSteps(steps.filter((step) => step.kind === kind));
}
export function sanitizeOperationalTourSettings(input?: unknown): OperationalTourSettings {
const parsed = operationalTourSettingsSchema.safeParse(input);
if (!parsed.success) return EMPTY_SETTINGS;
return {
defaultEnvironment: parsed.data.defaultEnvironment,
targets: {
production: {
...EMPTY_SETTINGS.targets.production,
...parsed.data.targets.production,
baseUrl: normalizeBaseUrl(parsed.data.targets.production.baseUrl) || EMPTY_SETTINGS.targets.production.baseUrl,
associationAuthMode: parsed.data.targets.production.associationAuthMode || "local",
accueilAuthMode: parsed.data.targets.production.accueilAuthMode || "local",
adminAuthMode: parsed.data.targets.production.adminAuthMode || "local",
},
secondary: {
...EMPTY_SETTINGS.targets.secondary,
...parsed.data.targets.secondary,
baseUrl: normalizeBaseUrl(parsed.data.targets.secondary.baseUrl),
associationAuthMode: parsed.data.targets.secondary.associationAuthMode || "local",
accueilAuthMode: parsed.data.targets.secondary.accueilAuthMode || "local",
adminAuthMode: parsed.data.targets.secondary.adminAuthMode || "local",
},
},
};
}
export async function getOperationalTourSettings() {
const raw = await db.getPortalSetting(OPERATIONAL_TOUR_SETTINGS_KEY);
if (!raw) return EMPTY_SETTINGS;
try {
return sanitizeOperationalTourSettings(JSON.parse(raw));
} catch {
return EMPTY_SETTINGS;
}
}
export async function saveOperationalTourSettings(settings: OperationalTourSettings) {
const sanitized = sanitizeOperationalTourSettings(settings);
await db.setPortalSetting(
OPERATIONAL_TOUR_SETTINGS_KEY,
JSON.stringify(sanitized),
"Configuration du tour opérationnel du site"
);
return sanitized;
}
export async function getOperationalTourHistory() {
const raw = await db.getPortalSetting(OPERATIONAL_TOUR_HISTORY_KEY);
if (!raw) return [] as OperationalTourReport[];
try {
const parsed = z.array(operationalTourReportSchema).parse(JSON.parse(raw));
return parsed;
} catch {
return [] as OperationalTourReport[];
}
}
function compactOperationalTourHistory(history: OperationalTourReport[]) {
const compactStep = (step: OperationalTourStep): OperationalTourStep => ({
id: step.id,
label: step.label,
kind: step.kind,
status: step.status,
durationMs: step.durationMs,
message: step.message,
critical: step.critical ?? true,
...(step.status !== "ok" && step.details ? { details: step.details } : {}),
...(step.status !== "ok" && step.url ? { url: step.url } : {}),
});
const buildSignature = (report: OperationalTourReport) => JSON.stringify({
environment: report.environment,
baseUrl: report.baseUrl,
status: report.status,
summary: report.summary,
steps: report.steps.map((step) => ({
id: step.id,
kind: step.kind,
status: step.status,
message: step.message,
details: step.details || "",
})),
});
const dedupedHistory: OperationalTourReport[] = [];
for (const report of history) {
const previous = dedupedHistory[dedupedHistory.length - 1];
if (previous && previous.environment === report.environment) {
const previousTime = new Date(previous.finishedAt).getTime();
const currentTime = new Date(report.finishedAt).getTime();
if (
Number.isFinite(previousTime) &&
Number.isFinite(currentTime) &&
Math.abs(currentTime - previousTime) <= OPERATIONAL_TOUR_DUPLICATE_WINDOW_MS &&
buildSignature(previous) === buildSignature(report)
) {
dedupedHistory[dedupedHistory.length - 1] = report;
continue;
}
}
dedupedHistory.push(report);
}
let reports = dedupedHistory.slice(0, OPERATIONAL_TOUR_HISTORY_MAX_REPORTS).map((report) => ({
...report,
steps: report.steps.map(compactStep),
}));
while (reports.length > 0) {
const serialized = JSON.stringify(reports);
if (Buffer.byteLength(serialized, "utf8") <= OPERATIONAL_TOUR_HISTORY_MAX_BYTES) {
return reports;
}
reports = reports.slice(0, -1);
}
return [];
}
export async function saveOperationalTourHistory(history: OperationalTourReport[]) {
await db.setPortalSetting(
OPERATIONAL_TOUR_HISTORY_KEY,
JSON.stringify(compactOperationalTourHistory(history)),
"Historique du tour opérationnel du site"
);
}
export async function clearOperationalTourHistory() {
const history = await getOperationalTourHistory();
const seen = new Set<OperationalTourEnvironment>();
const retained = history.filter((report) => {
if (seen.has(report.environment)) {
return false;
}
seen.add(report.environment);
return true;
});
await saveOperationalTourHistory(retained);
return retained;
}
export async function runOperationalTour(params: {
environment: OperationalTourEnvironment;
req: Request;
currentUser: User;
}) {
const startedAt = new Date();
const settings = await getOperationalTourSettings();
const target = settings.targets[params.environment];
const baseUrl = normalizeBaseUrl(target.baseUrl);
const environmentLabel = params.environment === "production" ? "Production" : "Préproduction / Local";
const steps: OperationalTourStep[] = [];
if (!baseUrl) {
const report: OperationalTourReport = {
id: `tour-${Date.now()}`,
environment: params.environment,
environmentLabel,
baseUrl: "",
status: "error",
startedAt: startedAt.toISOString(),
finishedAt: new Date().toISOString(),
durationMs: 0,
initiatedBy: {
userId: params.currentUser.id,
name: params.currentUser.name || params.currentUser.email || `Utilisateur #${params.currentUser.id}`,
email: params.currentUser.email || undefined,
},
summary: { total: 1, ok: 0, warning: 0, error: 1 },
steps: [
{
id: "configuration",
label: "Configuration de lenvironnement",
kind: "technical",
status: "error",
durationMs: 0,
message: "URL cible manquante",
details: `Aucune URL nest configurée pour ${environmentLabel.toLowerCase()}.`,
critical: true,
},
],
};
const nextHistory = [report, ...(await getOperationalTourHistory())];
await saveOperationalTourHistory(nextHistory);
return report;
}
const currentOrigin = buildRequestOrigin(params.req);
const isSameOrigin = currentOrigin && normalizeBaseUrl(currentOrigin) === baseUrl;
const incomingCookieHeader = typeof params.req.headers.cookie === "string" ? params.req.headers.cookie : "";
const homeDiagnostics = await fetchHomeDiagnostics(baseUrl);
if (homeDiagnostics.ok) {
steps.push(
buildStaticStep({
id: "technical-performance-home",
label: "Performance de laccueil",
kind: "technical",
status: homeDiagnostics.durationMs <= 1500 ? "ok" : homeDiagnostics.durationMs <= 3000 ? "warning" : "error",
message:
homeDiagnostics.durationMs <= 1500
? "Temps de réponse confortable"
: homeDiagnostics.durationMs <= 3000
? "Temps de réponse correct mais à surveiller"
: "Temps de réponse trop élevé",
details: `${homeDiagnostics.durationMs} ms`,
critical: false,
durationMs: homeDiagnostics.durationMs,
}),
buildStaticStep({
id: "technical-security-baseline",
label: "Sécurité de base",
kind: "technical",
status: baseUrl.startsWith("https://") ? "ok" : "warning",
message: baseUrl.startsWith("https://") ? "Le site est servi en HTTPS" : "Le site nutilise pas HTTPS",
details: homeDiagnostics.finalUrl,
critical: false,
}),
buildStaticStep({
id: "technical-accessibility-baseline",
label: "Accessibilité de base",
kind: "technical",
status:
homeDiagnostics.body.includes("<title>")
&& /<html[^>]+lang=/i.test(homeDiagnostics.body)
? "ok"
: "warning",
message:
homeDiagnostics.body.includes("<title>")
&& /<html[^>]+lang=/i.test(homeDiagnostics.body)
? "Titre de page et langue du document détectés"
: "Titre ou langue du document à confirmer",
details: homeDiagnostics.finalUrl,
critical: false,
}),
buildStaticStep({
id: "technical-mobile-baseline",
label: "Compatibilité mobile",
kind: "technical",
status: /<meta[^>]+name=["']viewport["']/i.test(homeDiagnostics.body) ? "ok" : "warning",
message: /<meta[^>]+name=["']viewport["']/i.test(homeDiagnostics.body)
? "Viewport mobile détecté"
: "Meta viewport absente ou non détectée",
details: homeDiagnostics.finalUrl,
critical: false,
})
);
} else {
steps.push(
buildStaticStep({
id: "technical-performance-home",
label: "Performance de laccueil",
kind: "technical",
status: "error",
message: homeDiagnostics.error || "Impossible de mesurer laccueil",
details: homeDiagnostics.finalUrl,
critical: false,
durationMs: homeDiagnostics.durationMs,
})
);
}
steps.push(
await fetchJsonCheck({
id: "technical-api-public",
label: "API publique",
kind: "technical",
url: `${baseUrl}/api/trpc/auth.providers?input=%7B%7D`,
markers: ['"google"', '"facebook"'],
})
);
let adminCookieHeader: string | null = isSameOrigin && incomingCookieHeader ? incomingCookieHeader : null;
const hasConfiguredAdminAccount = Boolean(
target.adminEmail &&
(target.adminAuthMode === "oauth" || target.adminPassword)
);
if (!adminCookieHeader && hasConfiguredAdminAccount) {
try {
adminCookieHeader = await createConfiguredSessionCookie(target.adminEmail, target.adminAuthMode === "oauth" ? "" : target.adminPassword);
} catch (error) {
const usesOAuthOnly = target.adminAuthMode === "oauth";
steps.push({
id: "admin-config",
label: "Configuration compte admin de contrôle",
kind: "user",
status: "warning",
durationMs: 0,
message: usesOAuthOnly ? "Impossible douvrir une session admin OAuth" : "Impossible douvrir une session admin dédiée",
details: error instanceof Error ? error.message : usesOAuthOnly ? "Compte OAuth admin introuvable" : "Identifiants admin invalides",
critical: false,
});
}
}
let associationCookieHeader: string | null = null;
const hasConfiguredAssociationAccount = Boolean(
target.associationEmail &&
(target.associationAuthMode === "oauth" || target.associationPassword)
);
if (hasConfiguredAssociationAccount) {
try {
associationCookieHeader = await createConfiguredSessionCookie(
target.associationEmail,
target.associationAuthMode === "oauth" ? "" : target.associationPassword
);
} catch (error) {
const usesOAuthOnly = target.associationAuthMode === "oauth";
steps.push({
id: "association-config",
label: "Configuration compte association de test",
kind: "user",
status: "warning",
durationMs: 0,
message: usesOAuthOnly ? "Impossible douvrir une session association OAuth" : "Impossible douvrir une session association",
details: error instanceof Error ? error.message : usesOAuthOnly ? "Compte OAuth association introuvable" : "Identifiants association invalides",
critical: false,
});
}
} else {
steps.push({
id: "association-config",
label: "Configuration compte association de test",
kind: "user",
status: "warning",
durationMs: 0,
message: "Compte association non configuré",
details: "Les contrôles authentifiés association sont ignorés tant que lemail et le mot de passe ne sont pas renseignés.",
critical: false,
});
}
let accueilCookieHeader: string | null = null;
const hasConfiguredAccueilAccount = Boolean(
target.accueilEmail &&
(target.accueilAuthMode === "oauth" || target.accueilPassword)
);
if (hasConfiguredAccueilAccount) {
try {
accueilCookieHeader = await createConfiguredSessionCookie(
target.accueilEmail,
target.accueilAuthMode === "oauth" ? "" : target.accueilPassword
);
} catch (error) {
const usesOAuthOnly = target.accueilAuthMode === "oauth";
steps.push(buildStaticStep({
id: "accueil-config",
label: "Configuration compte accueil / hôtesse",
kind: "user",
status: "warning",
message: usesOAuthOnly ? "Impossible douvrir une session accueil OAuth" : "Impossible douvrir une session accueil",
details: error instanceof Error ? error.message : usesOAuthOnly ? "Compte OAuth accueil introuvable" : "Identifiants accueil invalides",
critical: false,
}));
}
} else {
steps.push(buildStaticStep({
id: "accueil-config",
label: "Configuration compte accueil / hôtesse",
kind: "user",
status: "warning",
message: "Compte accueil non configuré",
details: "Le parcours hôtesse reste ignoré tant quun compte accueil nest pas renseigné.",
critical: false,
}));
}
steps.push(
await fetchPageCheck({ id: "technical-public-home", label: "Navigation publique • Accueil", kind: "technical", url: `${baseUrl}/`, expectedPath: "/", markers: ["Portail Associations", "<title>Portail Associations</title>"] }),
await fetchPageCheck({ id: "technical-public-login", label: "Navigation publique • Connexion", kind: "technical", url: `${baseUrl}/login`, expectedPath: "/login", markers: ["Connexion", "Se connecter"] }),
await fetchPageCheck({ id: "technical-directory", label: "Annuaire public des associations", kind: "technical", url: `${baseUrl}/associations`, expectedPath: "/associations", markers: ["Annuaire des associations", "associationDirectory.listPortal"] }),
await fetchPageCheck({ id: "technical-map", label: "Cartographie des associations", kind: "technical", url: `${baseUrl}/associations/carte`, expectedPath: "/associations/carte", markers: ["Cartographie interactive", "associationDirectory.listMap"] }),
);
const publicProfileUrl = await tryFindPublicProfileUrl(baseUrl);
if (publicProfileUrl) {
steps.push(
await fetchPageCheck({
id: "technical-public-profile",
label: "Fiche publique association",
kind: "technical",
url: publicProfileUrl,
expectedPath: new URL(publicProfileUrl).pathname,
markers: ["Coordonnées", "actualités et publications"],
critical: false,
})
);
} else {
steps.push(buildStaticStep({
id: "technical-public-profile",
label: "Fiche publique association",
kind: "technical",
status: "warning",
durationMs: 0,
message: "Aucune fiche publique détectée",
details: "Le tour na pas trouvé de lien de fiche association dans lannuaire public.",
critical: false,
}));
}
if (associationCookieHeader) {
steps.push(
await fetchJsonCheck({
id: "technical-api-association",
label: "API association",
kind: "technical",
url: `${baseUrl}/api/trpc/auth.me?input=%7B%7D`,
cookieHeader: associationCookieHeader,
markers: ['"email"', '"role"'],
}),
await fetchPageCheck({ id: "user-association-dashboard", label: "Parcours association • Tableau de bord", kind: "user", url: `${baseUrl}/dashboard`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard", markers: ["Tableau de bord", "<title>Portail Associations</title>"] }),
await fetchPageCheck({ id: "user-association-requests", label: "Parcours association • Mes demandes", kind: "user", url: `${baseUrl}/dashboard/requests`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/requests", markers: ["Mes demandes"] }),
await fetchPageCheck({ id: "user-association-documents", label: "Parcours association • Mes documents", kind: "user", url: `${baseUrl}/dashboard/documents`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/documents", markers: ["Mes documents"] }),
await fetchPageCheck({ id: "user-association-directory", label: "Parcours association • Annuaire", kind: "user", url: `${baseUrl}/dashboard/associations`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/associations", markers: ["Annuaire des associations"], critical: false }),
await fetchPageCheck({ id: "user-association-form-salle", label: "Formulaire • Réservation de salle", kind: "user", url: `${baseUrl}/dashboard/reservation-salle`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/reservation-salle", markers: ["Réservation de salle", "Soumettre"] }),
await fetchPageCheck({ id: "user-association-form-request", label: "Formulaire • Nouvelle demande", kind: "user", url: `${baseUrl}/dashboard/requests/new`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/requests/new", markers: ["Nouvelle demande", "Soumettre"] }),
await fetchPageCheck({ id: "user-association-form-material", label: "Formulaire • Matériel événementiel", kind: "user", url: `${baseUrl}/dashboard/materiel-evenementiel`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/materiel-evenementiel", markers: ["matériel événementiel", "Soumettre"], critical: false }),
);
}
if (accueilCookieHeader) {
steps.push(
await fetchJsonCheck({
id: "technical-api-accueil",
label: "API accueil / hôtesse",
kind: "technical",
url: `${baseUrl}/api/trpc/auth.me?input=%7B%7D`,
cookieHeader: accueilCookieHeader,
markers: ['"accueil"'],
critical: false,
}),
await fetchPageCheck({ id: "user-accueil-dashboard", label: "Parcours hôtesse • Administration", kind: "user", url: `${baseUrl}/admin`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Administration accueil", "<title>Portail Associations</title>"] }),
await fetchPageCheck({ id: "user-accueil-requests", label: "Parcours hôtesse • Gestion des demandes", kind: "user", url: `${baseUrl}/admin?tab=requests`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Gestion des demandes"] }),
await fetchPageCheck({ id: "user-accueil-calendar", label: "Parcours hôtesse • Calendrier", kind: "user", url: `${baseUrl}/admin?tab=calendrier`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Calendrier"] }),
await fetchPageCheck({ id: "user-accueil-notifications", label: "Parcours hôtesse • Notifications", kind: "user", url: `${baseUrl}/admin?tab=notifications`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Notifications"], critical: false }),
);
}
if (adminCookieHeader) {
steps.push(
await fetchJsonCheck({
id: "technical-api-admin",
label: "API administration",
kind: "technical",
url: `${baseUrl}/api/internal/stats/reservations?period=month`,
cookieHeader: adminCookieHeader,
markers: ['"summary"', '"history"'],
}),
await fetchPageCheck({ id: "user-admin-dashboard", label: "Parcours administrateur • Administration", kind: "user", url: `${baseUrl}/admin`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Tableau de bord", "<title>Portail Associations</title>"] }),
await fetchPageCheck({ id: "user-admin-requests", label: "Parcours administrateur • Gestion des demandes", kind: "user", url: `${baseUrl}/admin?tab=requests`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Gestion des demandes"] }),
await fetchPageCheck({ id: "user-admin-calendar", label: "Parcours administrateur • Calendrier", kind: "user", url: `${baseUrl}/admin?tab=calendrier`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Calendrier"] }),
await fetchPageCheck({ id: "user-admin-analytics", label: "Parcours administrateur • Statistiques", kind: "user", url: `${baseUrl}/admin?tab=analytics`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Statistiques dactivité", "Tour opérationnel du site"] }),
await fetchPageCheck({ id: "user-admin-ccds-directory", label: "Parcours administrateur • Annuaire interne CCDS", kind: "user", url: `${baseUrl}/admin?tab=ccds-directory`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Annuaire interne CCDS"] }),
);
} else {
steps.push(buildStaticStep({
id: "admin-config",
label: "Configuration session admin",
kind: "user",
status: "warning",
durationMs: 0,
message: "Aucune session admin disponible pour cet environnement",
details: isSameOrigin
? "Le navigateur na pas transmis de cookie de session exploitable."
: "Renseignez un compte admin dédié si vous voulez tester un autre environnement que celui où vous êtes connecté.",
critical: false,
}));
}
const finishedAt = new Date();
const report: OperationalTourReport = {
id: `tour-${finishedAt.getTime()}`,
environment: params.environment,
environmentLabel,
baseUrl,
status: computeReportStatus(steps),
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
initiatedBy: {
userId: params.currentUser.id,
name: params.currentUser.name || params.currentUser.email || `Utilisateur #${params.currentUser.id}`,
email: params.currentUser.email || undefined,
},
summary: summarizeSteps(steps),
steps,
};
const nextHistory = [report, ...(await getOperationalTourHistory())];
await saveOperationalTourHistory(nextHistory);
return report;
}

166
server/pdfGenerator.test.ts Normal file
View file

@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest';
import { generateMaterialEventPDF, generateReservationPDF } from './pdfGenerator';
describe('generateReservationPDF', () => {
it('should generate a PDF buffer for a complete reservation', async () => {
const data = {
titre: 'Réservation Salle TOUCAN - 15/04/2026',
status: 'soumise',
createdAt: new Date('2026-03-01'),
dateSubmission: new Date('2026-03-01'),
dateTraitement: null,
commentaireAdmin: null,
formData: {
nomAssociation: 'Association Test',
adresseAssociation: '12 rue de la Paix, 97300 Cayenne',
communeSiege: 'Cayenne',
representantLegal: 'Jean Dupont',
telephoneAssociation: '0694123456',
emailAssociation: 'test@asso.fr',
sallesSelectionnees: ['Salle TOUCAN', 'Salle IBIS'],
motifReservation: 'Assemblée générale annuelle',
dateReservation: '2026-04-15',
dateFinReservation: '2026-04-15',
heureDebut: '09:00',
heureFin: '17:00',
nombreParticipants: '50',
materielNecessaire: 'Tables, chaises, vidéoprojecteur',
observations: 'Prévoir un accès PMR',
},
};
const buffer = await generateReservationPDF(data);
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(0);
// Verify PDF header
expect(buffer.slice(0, 5).toString()).toBe('%PDF-');
});
it('should generate a PDF with DSU cadre filled', async () => {
const data = {
titre: 'Réservation Salle PELICAN',
status: 'validee',
createdAt: new Date('2026-03-01'),
dateSubmission: new Date('2026-03-01'),
dateTraitement: new Date('2026-03-10'),
commentaireAdmin: 'Demande acceptée sous conditions',
formData: {
nomAssociation: 'Association Sportive',
adresseAssociation: '5 avenue des Sports, 97360 Mana',
communeSiege: 'Mana',
representantLegal: 'Marie Martin',
telephoneAssociation: '0694567890',
emailAssociation: 'sport@asso.fr',
sallesSelectionnees: ['Salle PELICAN'],
motifReservation: 'Tournoi de basketball',
dateReservation: '2026-05-20',
dateFinReservation: '2026-05-21',
heureDebut: '08:00',
heureFin: '20:00',
nombreParticipants: '100',
materielNecessaire: 'Sono, tables de score',
observations: '',
cadreDSU: {
dateReception: '2026-03-02',
avisDSU: 'favorable_avec_reserves',
conditionsParticulieres: 'Nettoyage obligatoire après utilisation',
cautionRequise: true,
montantCaution: '500',
assuranceRequise: true,
horairesImposes: '08:00 - 20:00 strictement',
responsableDSU: 'Pierre Durand',
dateDecision: '2026-03-10',
observationsDSU: 'Veiller au respect des horaires de fermeture',
},
},
};
const buffer = await generateReservationPDF(data);
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(0);
expect(buffer.slice(0, 5).toString()).toBe('%PDF-');
// PDF with DSU should be larger than without
expect(buffer.length).toBeGreaterThan(1000);
});
it('should generate a PDF with empty DSU cadre (pending)', async () => {
const data = {
titre: 'Réservation DOJO',
status: 'soumise',
createdAt: new Date('2026-03-05'),
formData: {
nomAssociation: 'Club Judo',
sallesSelectionnees: ['DOJO'],
motifReservation: 'Entraînement hebdomadaire',
dateReservation: '2026-04-01',
heureDebut: '18:00',
heureFin: '20:00',
},
};
const buffer = await generateReservationPDF(data);
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(0);
expect(buffer.slice(0, 5).toString()).toBe('%PDF-');
});
it('should handle missing optional fields gracefully', async () => {
const data = {
titre: 'Réservation minimale',
status: 'brouillon',
createdAt: new Date(),
formData: {},
};
const buffer = await generateReservationPDF(data);
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(0);
expect(buffer.slice(0, 5).toString()).toBe('%PDF-');
});
});
describe('generateMaterialEventPDF', () => {
it('should generate a PDF buffer for an event material request', async () => {
const data = {
titre: 'Demande matériel événementiel',
status: 'validee',
createdAt: new Date('2026-03-01'),
dateSubmission: new Date('2026-03-01'),
dateTraitement: new Date('2026-03-10'),
commentaireAdmin: 'Accord partiel',
formData: {
nomAssociation: 'Association Test',
commune: 'Kourou',
direction: 'Sports',
service: 'Animation',
demandeurNomPrenom: 'Jean Dupont',
dateDemande: '2026-03-01',
dateManifestation: '2026-04-15',
dateDebutManifestation: '2026-04-15',
dateFinManifestation: '2026-04-16',
motifDemande: 'Fête communale',
datePriseEnCharge: '2026-04-15',
dateRestitution: '2026-04-19',
materielsDemandes: { tente3x3: true, podium: true, chapiteau5x5: false, autres: true },
quantitesDemandees: { tente3x3: '5', podium: '1', chapiteau5x5: '', autres: '2' },
autreMaterielPrecisions: 'Barrières',
cadreDSU: {
dateReception: '2026-03-02',
responsableDSU: 'Pierre Durand',
dateDecision: '2026-03-10',
observationsDSU: 'Prévoir le retrait la veille',
materielEvent: {
itemsAccordes: { tente3x3: true, podium: true, chapiteau5x5: false, autres: false },
quantitesAccordees: { tente3x3: '3', podium: '1', chapiteau5x5: '', autres: '' },
autresPrecisions: '',
},
},
},
};
const buffer = await generateMaterialEventPDF(data);
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(0);
expect(buffer.slice(0, 5).toString()).toBe('%PDF-');
});
});

958
server/pdfGenerator.ts Normal file
View file

@ -0,0 +1,958 @@
import PDFDocument from 'pdfkit';
import fs from 'node:fs';
import path from 'node:path';
import { SALLE_FREQUENCY_LABELS, SALLE_USAGE_TYPE_LABELS } from '@shared/sallePricing';
interface ReservationPDFData {
titre: string;
status: string;
createdAt: string | Date;
dateSubmission?: string | Date | null;
dateTraitement?: string | Date | null;
commentaireAdmin?: string | null;
formData: {
nomAssociation?: string;
adresseAssociation?: string;
communeSiege?: string;
representantLegal?: string;
telephoneAssociation?: string;
emailAssociation?: string;
sallesSelectionnees?: string[];
motifReservation?: string;
dateReservation?: string;
dateFinReservation?: string;
heureDebut?: string;
heureFin?: string;
useDetailedSchedule?: boolean;
horairesParJour?: Array<{
date?: string;
heureDebut?: string;
heureFin?: string;
}>;
typeUsage?: 'conventionne' | 'occasionnel';
frequence?: 'demi_journee' | 'journee' | 'mensuel';
nombreParticipants?: string;
besoinsComplementaires?: string;
materielNecessaire?: string;
observations?: string;
salleWorkflow?: {
pricing?: {
totalAmountCents?: number;
};
directorSignedAt?: string;
directorSignedByName?: string;
};
cadreDSU?: {
dateReception?: string;
avisDSU?: string;
conditionsParticulieres?: string;
cautionRequise?: boolean;
montantCaution?: string;
assuranceRequise?: boolean;
horairesImposes?: string;
responsableDSU?: string;
dateDecision?: string;
observationsDSU?: string;
materielEvent?: {
financialDecision?: {
financialMode?: 'gratuite' | 'gratuite_avec_caution' | 'location_payante';
depositRequired?: boolean;
depositAmountCents?: number;
rentalAmountCents?: number;
pricingNotes?: string;
contractStatus?: 'a_generer' | 'generee' | 'signee' | 'refusee' | 'annulee';
};
};
};
};
}
interface MaterialEventPDFData {
titre: string;
status: string;
createdAt: string | Date;
dateSubmission?: string | Date | null;
dateTraitement?: string | Date | null;
commentaireAdmin?: string | null;
formData: {
nomAssociation?: string;
adresseAssociation?: string;
communeSiege?: string;
representantLegal?: string;
telephoneAssociation?: string;
emailAssociation?: string;
commune?: string;
direction?: string;
service?: string;
demandeurNomPrenom?: string;
dateDemande?: string;
dateManifestation?: string;
dateDebutManifestation?: string;
dateFinManifestation?: string;
motifDemande?: string;
datePriseEnCharge?: string;
dateRestitution?: string;
autreMaterielPrecisions?: string;
materielsDemandes?: Record<string, boolean>;
quantitesDemandees?: Record<string, string>;
cadreDSU?: {
dateReception?: string;
cautionRequise?: boolean;
montantCaution?: string;
responsableDSU?: string;
dateDecision?: string;
observationsDSU?: string;
materielEvent?: {
itemsAccordes?: Record<string, boolean>;
quantitesAccordees?: Record<string, string>;
autresPrecisions?: string;
financialDecision?: {
financialMode?: 'gratuite' | 'gratuite_avec_caution' | 'location_payante';
depositRequired?: boolean;
depositAmountCents?: number;
rentalAmountCents?: number;
pricingNotes?: string;
contractStatus?: 'a_generer' | 'generee' | 'signee' | 'refusee' | 'annulee';
contractGeneratedAt?: string | Date | null;
};
};
};
};
}
const statusLabels: Record<string, string> = {
brouillon: 'Brouillon',
soumise: 'Soumise',
en_cours_traitement: 'En cours de traitement',
information_complementaire: 'Information complémentaire requise',
validee: 'Validée',
refusee: 'Refusée',
};
const avisDSULabels: Record<string, string> = {
favorable: 'Favorable',
defavorable: 'Défavorable',
favorable_avec_reserves: 'Favorable avec réserves',
};
const materialEventLabels: Record<string, string> = {
tente3x3: 'Tente 3x3',
chapiteau5x5: 'Chapiteau 5x5',
podium: 'Podium',
autres: 'Autres',
};
const CCDS_LOGO_PATH = path.resolve(process.cwd(), 'client/src/assets/ccds.png');
function formatDateFr(dateStr: string | Date | null | undefined): string {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
} catch {
return String(dateStr);
}
}
function formatCurrency(cents: number | null | undefined): string {
if (cents === null || cents === undefined || !Number.isFinite(cents)) return '-';
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(cents / 100);
}
function getFinancialModeLabel(mode: string | null | undefined): string {
switch (mode) {
case 'gratuite':
return 'Mise à disposition gratuite';
case 'gratuite_avec_caution':
return 'Mise à disposition gratuite avec caution';
case 'location_payante':
return 'Location payante';
default:
return '-';
}
}
function getContractStatusLabel(status: string | null | undefined): string {
switch (status) {
case 'a_generer':
return 'À générer';
case 'generee':
return 'Générée';
case 'signee':
return 'Signée';
case 'refusee':
return 'Refusée';
case 'annulee':
return 'Annulée';
default:
return '-';
}
}
function formatMaterialEventPeriod(formData: {
dateManifestation?: string;
dateDebutManifestation?: string;
dateFinManifestation?: string;
}): string {
const start = formData.dateDebutManifestation || formData.dateManifestation;
const end = formData.dateFinManifestation || formData.dateManifestation;
if (!start && !end) return '-';
if (start && end && start !== end) {
return `${formatDateFr(start)} au ${formatDateFr(end)}`;
}
return formatDateFr(start || end);
}
function drawCcdsLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) {
if (!fs.existsSync(CCDS_LOGO_PATH)) return;
try {
doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: 'center', valign: 'center' });
} catch (error) {
console.warn('[PDF] Impossible de charger le logo CCDS:', error);
}
}
// Color definitions (RGB)
const COLORS = {
primary: [0, 0, 102] as [number, number, number], // oklch(0.25 0.05 265) approx
primaryLight: [230, 230, 245] as [number, number, number],
text: [30, 30, 30] as [number, number, number],
muted: [120, 120, 120] as [number, number, number],
border: [200, 200, 200] as [number, number, number],
white: [255, 255, 255] as [number, number, number],
green: [34, 139, 34] as [number, number, number],
red: [220, 20, 60] as [number, number, number],
amber: [200, 150, 0] as [number, number, number],
blueBg: [235, 245, 255] as [number, number, number],
blueBorder: [180, 210, 240] as [number, number, number],
blueText: [50, 80, 140] as [number, number, number],
};
export function generateReservationPDF(data: ReservationPDFData): Promise<Buffer> {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margins: { top: 40, bottom: 40, left: 50, right: 50 },
info: {
Title: `Formulaire de Réservation - ${data.formData.nomAssociation || 'Association'}`,
Author: 'Communauté de Communes Des Savanes',
Subject: 'Réservation de local - Maison de la Jeunesse des Savanes',
},
});
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
let y = doc.page.margins.top;
const dateDebut = formatDateFr(data.formData.dateReservation);
const dateFin = data.formData.dateFinReservation && data.formData.dateFinReservation !== data.formData.dateReservation
? formatDateFr(data.formData.dateFinReservation)
: dateDebut;
const dailySlots = Array.isArray(data.formData.horairesParJour)
? data.formData.horairesParJour.filter((slot) => slot?.date)
: [];
const creneau = data.formData.heureDebut || data.formData.heureFin
? `${data.formData.heureDebut || '?'}${data.formData.heureFin || '?'}`
: '-';
doc.save();
doc.lineWidth(2)
.strokeColor('#f0c94b')
.moveTo(doc.page.margins.left, y)
.lineTo(doc.page.margins.left + pageWidth, y)
.stroke();
doc.restore();
y += 16;
drawCcdsLogo(doc, doc.page.margins.left + (pageWidth - 90) / 2, y, 90, 90);
y += 96;
doc.fillColor(COLORS.muted)
.fontSize(9)
.font('Helvetica')
.text('COMMUNAUTÉ DE COMMUNES DES SAVANES', doc.page.margins.left, y, {
width: pageWidth,
align: 'center',
});
y += 18;
doc.fillColor(COLORS.text)
.fontSize(18)
.font('Helvetica-Bold')
.text('FORMULAIRE ADMINISTRATIF DE RÉSERVATION', doc.page.margins.left, y, {
width: pageWidth,
align: 'center',
});
y += 20;
doc.text('D\'UN LOCAL - MAISON DE LA JEUNESSE DES SAVANES', doc.page.margins.left, y, {
width: pageWidth,
align: 'center',
});
y += 26;
doc.save();
doc.roundedRect(doc.page.margins.left + 18, y, pageWidth - 36, 34, 4)
.fill('#d9eefc')
.stroke('#f0c94b');
doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold')
.text('Réservation à transmettre à la Direction des Services aux Usagers', doc.page.margins.left + 28, y + 7, {
width: pageWidth - 56,
align: 'center',
});
doc.fontSize(9).font('Helvetica')
.text('Merci de compléter les informations demandées avant instruction', doc.page.margins.left + 28, y + 19, {
width: pageWidth - 56,
align: 'center',
});
doc.restore();
y += 50;
// Statut de la demande
const statusLabel = statusLabels[data.status] || data.status;
doc.fillColor(COLORS.muted)
.fontSize(8)
.font('Helvetica')
.text(`Statut : ${statusLabel} | Créée le : ${formatDateFr(data.createdAt)}`, doc.page.margins.left, y, {
width: pageWidth,
align: 'right',
});
y += 18;
// ==========================================
// SECTION 1 : INFORMATIONS DE L'ASSOCIATION
// ==========================================
y = drawSectionTitle(doc, 'INFORMATIONS DE L\'ASSOCIATION', y, pageWidth);
const assoRows = [
['Nom de l\'association', data.formData.nomAssociation || '-'],
['Adresse', data.formData.adresseAssociation || '-'],
['Commune du siège', data.formData.communeSiege || '-'],
['Représentant légal', data.formData.representantLegal || '-'],
['Téléphone', data.formData.telephoneAssociation || '-'],
['Email', data.formData.emailAssociation || '-'],
];
y = drawTable(doc, assoRows, y, pageWidth);
y += 15;
// ==========================================
// SECTION 2 : LOCAL(AUX) SOLLICITÉ(S)
// ==========================================
y = drawSectionTitle(doc, 'LOCAL(AUX) SOLLICITÉ(S)', y, pageWidth);
const salles = data.formData.sallesSelectionnees?.join(', ') || '-';
doc.fillColor(COLORS.text)
.fontSize(10)
.font('Helvetica')
.text(salles, doc.page.margins.left + 5, y, { width: pageWidth - 10 });
y += doc.heightOfString(salles, { width: pageWidth - 10 }) + 15;
y = checkPageBreak(doc, y, 200);
y = drawSectionTitle(doc, 'DÉTAILS DE LA RÉSERVATION', y, pageWidth);
y = drawTable(doc, [['Motif de la réservation', data.formData.motifReservation || '-']], y, pageWidth);
y += 8;
const hasDetailedSchedule = Boolean(data.formData.useDetailedSchedule && dailySlots.length > 0);
const scheduleBoxHeight = hasDetailedSchedule ? 70 + (dailySlots.length * 18) : 70;
y = checkPageBreak(doc, y, scheduleBoxHeight + 18);
doc.save();
doc.roundedRect(doc.page.margins.left, y, pageWidth, scheduleBoxHeight, 4)
.fill('#f8fafc')
.stroke(COLORS.border);
doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold')
.text('PÉRIODE ET CRÉNEAU SOLLICITÉS', doc.page.margins.left + 14, y + 10, { width: pageWidth - 28 });
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Du', doc.page.margins.left + 14, y + 34);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(dateDebut, doc.page.margins.left + 34, y + 33);
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Au', doc.page.margins.left + pageWidth / 2, y + 34);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(dateFin, doc.page.margins.left + pageWidth / 2 + 20, y + 33, {
width: pageWidth / 2 - 34,
});
if (hasDetailedSchedule) {
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Organisation', doc.page.margins.left + 14, y + 52);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(`Horaires détaillés sur ${dailySlots.length} jour(s)`, doc.page.margins.left + 72, y + 51, {
width: pageWidth - 86,
});
let slotY = y + 68;
dailySlots.forEach((slot) => {
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text(formatDateFr(slot.date || ''), doc.page.margins.left + 28, slotY);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(`${slot.heureDebut || '?'}${slot.heureFin || '?'}`, doc.page.margins.left + 160, slotY, {
width: pageWidth - 190,
});
slotY += 16;
});
} else {
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Créneau', doc.page.margins.left + 14, y + 52);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(creneau, doc.page.margins.left + 72, y + 51, {
width: pageWidth - 86,
});
}
doc.restore();
y += scheduleBoxHeight + 12;
const detailRows = [
['Nombre prévisionnel de participants', data.formData.nombreParticipants ? `${data.formData.nombreParticipants} personnes` : '-'],
['Type dusage', data.formData.typeUsage ? SALLE_USAGE_TYPE_LABELS[data.formData.typeUsage] : '-'],
['Fréquence', data.formData.frequence ? SALLE_FREQUENCY_LABELS[data.formData.frequence] : '-'],
['Besoins complémentaires', data.formData.besoinsComplementaires || '-'],
['Matériel nécessaire', data.formData.materielNecessaire || '-'],
['Observations complémentaires', data.formData.observations || '-'],
['Montant estimatif / validé', typeof data.formData.salleWorkflow?.pricing?.totalAmountCents === 'number' ? formatCurrency(data.formData.salleWorkflow.pricing.totalAmountCents) : '-'],
];
y = drawTable(doc, detailRows, y, pageWidth);
y += 18;
// ==========================================
// SECTION 4 : CADRE RÉSERVÉ À LA DSU
// ==========================================
y = checkPageBreak(doc, y, 250);
y = drawSectionTitle(doc, 'CADRE RÉSERVÉ À LA DIRECTION DES SERVICES AUX USAGERS (DSU)', y, pageWidth);
const dsu = data.formData.cadreDSU;
if (dsu) {
const financialDecision = dsu.materielEvent?.financialDecision;
// Avis DSU avec couleur
if (dsu.avisDSU) {
const avisLabel = avisDSULabels[dsu.avisDSU] || dsu.avisDSU;
const avisColor = dsu.avisDSU === 'favorable' ? COLORS.green
: dsu.avisDSU === 'defavorable' ? COLORS.red
: COLORS.amber;
doc.fillColor(COLORS.muted)
.fontSize(9)
.font('Helvetica')
.text('Avis de la DSU : ', doc.page.margins.left + 5, y, { continued: true });
doc.fillColor(avisColor)
.font('Helvetica-Bold')
.text(avisLabel);
y += 18;
}
const dsuRows = [
['Date de réception', dsu.dateReception ? formatDateFr(dsu.dateReception) : '-'],
['Date de la décision', dsu.dateDecision ? formatDateFr(dsu.dateDecision) : '-'],
['Responsable DSU', dsu.responsableDSU || '-'],
];
y = drawTable(doc, dsuRows, y, pageWidth);
y += 10;
// Conditions particulières
const conditions: string[] = [];
if (dsu.cautionRequise) {
conditions.push(`Caution requise${dsu.montantCaution ? ` : ${dsu.montantCaution}` : ''}`);
}
if (dsu.assuranceRequise) {
conditions.push('Attestation d\'assurance requise');
}
if (dsu.horairesImposes) {
conditions.push(`Horaires imposés : ${dsu.horairesImposes}`);
}
if (dsu.conditionsParticulieres) {
conditions.push(dsu.conditionsParticulieres);
}
if (conditions.length > 0) {
y = checkPageBreak(doc, y, 80);
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text('Conditions particulières :', doc.page.margins.left + 5, y);
y += 14;
for (const condition of conditions) {
y = checkPageBreak(doc, y, 20);
doc.fillColor(COLORS.text)
.fontSize(9)
.font('Helvetica')
.text(`${condition}`, doc.page.margins.left + 15, y, { width: pageWidth - 25 });
y += doc.heightOfString(`${condition}`, { width: pageWidth - 25 }) + 4;
}
y += 5;
}
if (financialDecision?.financialMode) {
y = checkPageBreak(doc, y, 96);
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text('Conditions financières / location :', doc.page.margins.left + 5, y);
y += 14;
const financialRows = [
['Mode financier', getFinancialModeLabel(financialDecision.financialMode)],
['Caution / dépôt', financialDecision.depositRequired ? formatCurrency(financialDecision.depositAmountCents ?? 0) : 'Aucune'],
['Montant de location', financialDecision.financialMode === 'location_payante' ? formatCurrency(financialDecision.rentalAmountCents ?? 0) : 'Non applicable'],
['Statut de la convention', getContractStatusLabel(financialDecision.contractStatus)],
];
y = drawTable(doc, financialRows, y, pageWidth);
y += 8;
if (financialDecision.pricingNotes) {
y = checkPageBreak(doc, y, 50);
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text('Clauses spécifiques :', doc.page.margins.left + 5, y);
y += 14;
doc.fillColor(COLORS.text)
.fontSize(9)
.font('Helvetica')
.text(financialDecision.pricingNotes, doc.page.margins.left + 15, y, { width: pageWidth - 25 });
y += doc.heightOfString(financialDecision.pricingNotes, { width: pageWidth - 25 }) + 10;
}
}
// Observations DSU
if (dsu.observationsDSU) {
y = checkPageBreak(doc, y, 50);
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text('Observations de la DSU :', doc.page.margins.left + 5, y);
y += 14;
doc.fillColor(COLORS.text)
.fontSize(9)
.font('Helvetica')
.text(dsu.observationsDSU, doc.page.margins.left + 15, y, { width: pageWidth - 25 });
y += doc.heightOfString(dsu.observationsDSU, { width: pageWidth - 25 }) + 10;
}
} else {
// Cadre vide avec lignes pointillées
const emptyRows = [
['Date de réception', ''],
['Avis', ''],
['Conditions particulières', ''],
['Responsable DSU', ''],
['Date de la décision', ''],
['Observations', ''],
];
y = drawTable(doc, emptyRows, y, pageWidth, true);
}
y += 18;
y = checkPageBreak(doc, y, 80);
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left + 10, y + 28)
.lineTo(doc.page.margins.left + pageWidth / 2 - 20, y + 28)
.stroke();
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left + pageWidth / 2 + 20, y + 28)
.lineTo(doc.page.margins.left + pageWidth - 10, y + 28)
.stroke();
doc.fillColor(COLORS.text).fontSize(9).font('Helvetica')
.text('Signature du représentant de l\'association', doc.page.margins.left + 10, y + 34, {
width: pageWidth / 2 - 30,
align: 'center',
})
.text('Visa de la DSU', doc.page.margins.left + pageWidth / 2 + 20, y + 34, {
width: pageWidth / 2 - 30,
align: 'center',
});
const directorSignedAt = data.formData.salleWorkflow?.directorSignedAt;
const directorSignedByName = data.formData.salleWorkflow?.directorSignedByName;
if (directorSignedAt) {
doc.fillColor(COLORS.blueText).fontSize(8).font('Helvetica-Bold')
.text('Validé électroniquement', doc.page.margins.left + pageWidth / 2 + 20, y + 49, {
width: pageWidth / 2 - 30,
align: 'center',
});
doc.fillColor(COLORS.text).fontSize(8).font('Helvetica')
.text(
`${formatDateFr(directorSignedAt)}${directorSignedByName ? ` · ${directorSignedByName}` : ''}`,
doc.page.margins.left + pageWidth / 2 + 20,
y + 61,
{
width: pageWidth / 2 - 30,
align: 'center',
}
);
}
// ==========================================
// COMMENTAIRE ADMIN (si présent)
// ==========================================
if (data.commentaireAdmin) {
y = checkPageBreak(doc, y, 60);
y += 10;
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text('Commentaire de l\'administration :', doc.page.margins.left + 5, y);
y += 14;
doc.fillColor(COLORS.text)
.fontSize(9)
.font('Helvetica')
.text(data.commentaireAdmin, doc.page.margins.left + 15, y, { width: pageWidth - 25 });
y += doc.heightOfString(data.commentaireAdmin, { width: pageWidth - 25 }) + 10;
}
// ==========================================
// PIED DE PAGE
// ==========================================
const footerY = doc.page.height - doc.page.margins.bottom - 30;
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left, footerY)
.lineTo(doc.page.margins.left + pageWidth, footerY)
.stroke();
doc.fillColor(COLORS.muted)
.fontSize(7)
.font('Helvetica')
.text(
`Document généré le ${formatDateFr(new Date())} — Communauté de Communes Des Savanes — Direction des Services aux Usagers`,
doc.page.margins.left,
footerY + 8,
{ width: pageWidth, align: 'center' }
);
if (directorSignedAt) {
doc.text(
`Visa Direction enregistré le ${formatDateFr(directorSignedAt)}${directorSignedByName ? ` par ${directorSignedByName}` : ''}`,
doc.page.margins.left,
footerY + 18,
{ width: pageWidth, align: 'center' }
);
}
doc.end();
} catch (err) {
reject(err);
}
});
}
export function generateMaterialEventPDF(data: MaterialEventPDFData): Promise<Buffer> {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margins: { top: 40, bottom: 40, left: 50, right: 50 },
info: {
Title: `Demande matériel - ${data.formData.nomAssociation || data.formData.commune || 'Association'}`,
Author: 'Communauté de Communes Des Savanes',
Subject: 'Demande de mise à disposition du matériel événementiel',
},
});
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
let y = doc.page.margins.top;
const manifestationStart = data.formData.dateDebutManifestation || data.formData.dateManifestation;
const manifestationEnd = data.formData.dateFinManifestation || data.formData.dateManifestation;
doc.save();
doc.lineWidth(2)
.strokeColor('#f0c94b')
.moveTo(doc.page.margins.left, y)
.lineTo(doc.page.margins.left + pageWidth, y)
.stroke();
doc.restore();
y += 16;
drawCcdsLogo(doc, doc.page.margins.left + (pageWidth - 90) / 2, y, 90, 90);
y += 96;
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('COMMUNAUTÉ DE COMMUNES DES SAVANES', doc.page.margins.left, y, { width: pageWidth, align: 'center' });
y += 18;
doc.fillColor(COLORS.text).fontSize(18).font('Helvetica-Bold')
.text('FICHE DE DEMANDE DE MISE À DISPOSITION', doc.page.margins.left, y, { width: pageWidth, align: 'center' });
y += 20;
doc.text('DU MATÉRIEL ÉVÉNEMENTIEL', doc.page.margins.left, y, { width: pageWidth, align: 'center' });
y += 26;
doc.save();
doc.roundedRect(doc.page.margins.left + 18, y, pageWidth - 36, 34, 4)
.fill('#d9eefc')
.stroke('#f0c94b');
doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold')
.text('Fiche à remplir obligatoirement (1 mois avant la manifestation)', doc.page.margins.left + 28, y + 7, {
width: pageWidth - 56,
align: 'center',
});
doc.fontSize(9).font('Helvetica')
.text('À transmettre à la Direction des Services aux Usagers', doc.page.margins.left + 28, y + 19, {
width: pageWidth - 56,
align: 'center',
});
doc.restore();
y += 50;
const statusLabel = statusLabels[data.status] || data.status;
doc.fillColor(COLORS.muted).fontSize(8).font('Helvetica')
.text(`Statut : ${statusLabel} | Créée le : ${formatDateFr(data.createdAt)}`, doc.page.margins.left, y, {
width: pageWidth,
align: 'right',
});
y += 18;
y = drawSectionTitle(doc, 'INFORMATIONS DU DEMANDEUR', y, pageWidth);
y = drawTable(doc, [
['Association', data.formData.nomAssociation || '-'],
['Commune', data.formData.commune || data.formData.communeSiege || '-'],
['Direction', data.formData.direction || '-'],
['Service', data.formData.service || '-'],
['Nom et prénom du demandeur', data.formData.demandeurNomPrenom || '-'],
['Téléphone', data.formData.telephoneAssociation || '-'],
['Email', data.formData.emailAssociation || '-'],
], y, pageWidth);
y += 15;
y = drawSectionTitle(doc, 'MATÉRIEL DEMANDÉ', y, pageWidth);
const requestedRows = Object.entries(materialEventLabels)
.filter(([key]) => data.formData.materielsDemandes?.[key])
.map(([key, label]) => [
label,
`${data.formData.quantitesDemandees?.[key] || '-'}${key === 'autres' && data.formData.autreMaterielPrecisions ? `${data.formData.autreMaterielPrecisions}` : ''}`,
]);
y = drawTable(doc, requestedRows.length > 0 ? requestedRows : [['Aucun matériel demandé', '-']], y, pageWidth);
y += 15;
y = drawSectionTitle(doc, 'DÉTAILS DE LA DEMANDE', y, pageWidth);
y = drawTable(doc, [['Date de la demande', formatDateFr(data.formData.dateDemande)]], y, pageWidth);
y += 8;
y = checkPageBreak(doc, y, 88);
doc.save();
doc.roundedRect(doc.page.margins.left, y, pageWidth, 70, 4)
.fill('#f8fafc')
.stroke(COLORS.border);
doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold')
.text('PÉRIODE DE LA MANIFESTATION', doc.page.margins.left + 14, y + 10, { width: pageWidth - 28 });
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Du', doc.page.margins.left + 14, y + 34);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(formatDateFr(manifestationStart), doc.page.margins.left + 34, y + 33);
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Au', doc.page.margins.left + pageWidth / 2, y + 34);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(formatDateFr(manifestationEnd), doc.page.margins.left + pageWidth / 2 + 20, y + 33, {
width: pageWidth / 2 - 34,
});
doc.restore();
y += 82;
y = drawTable(doc, [
['Date prévisionnelle de prise en charge du matériel', formatDateFr(data.formData.datePriseEnCharge)],
['Date prévisionnelle de restitution', formatDateFr(data.formData.dateRestitution)],
['Motif de la demande', data.formData.motifDemande || '-'],
], y, pageWidth);
y += 18;
y = checkPageBreak(doc, y, 220);
y = drawSectionTitle(doc, 'CADRE RÉSERVÉ À LA DSU', y, pageWidth);
const dsu = data.formData.cadreDSU;
if (dsu) {
y = drawTable(doc, [
['Date de réception', formatDateFr(dsu.dateReception)],
['Date de la décision', formatDateFr(dsu.dateDecision)],
['Responsable DSU', dsu.responsableDSU || '-'],
], y, pageWidth);
y += 10;
const grantedRows = Object.entries(materialEventLabels).map(([key, label]) => {
const granted = dsu.materielEvent?.itemsAccordes?.[key];
const qty = dsu.materielEvent?.quantitesAccordees?.[key];
const extra = key === 'autres' ? dsu.materielEvent?.autresPrecisions : '';
return [label, granted ? `${qty || '-'} accordé(s)${extra ? `${extra}` : ''}` : (data.status === 'refusee' ? 'Refusé' : 'Non renseigné')];
});
y = drawTable(doc, grantedRows, y, pageWidth);
const financialDecision = dsu.materielEvent?.financialDecision;
if (financialDecision?.financialMode) {
y += 12;
y = checkPageBreak(doc, y, 120);
doc.fillColor(COLORS.primary).fontSize(9).font('Helvetica-Bold')
.text('Conditions financières / convention :', doc.page.margins.left + 5, y);
y += 14;
const financialRows = [
['Mode financier', getFinancialModeLabel(financialDecision.financialMode)],
['Caution / dépôt de garantie', financialDecision.depositRequired ? formatCurrency(financialDecision.depositAmountCents ?? 0) : 'Aucune'],
['Montant de location', financialDecision.financialMode === 'location_payante' ? formatCurrency(financialDecision.rentalAmountCents ?? 0) : 'Non applicable'],
['Statut de la convention', getContractStatusLabel(financialDecision.contractStatus)],
['Convention générée le', formatDateFr(financialDecision.contractGeneratedAt)],
];
y = drawTable(doc, financialRows, y, pageWidth);
if (financialDecision.pricingNotes) {
y += 10;
doc.fillColor(COLORS.primary).fontSize(9).font('Helvetica-Bold')
.text('Clauses spécifiques :', doc.page.margins.left + 5, y);
y += 14;
doc.fillColor(COLORS.text).fontSize(9).font('Helvetica')
.text(financialDecision.pricingNotes, doc.page.margins.left + 5, y, { width: pageWidth - 10 });
y += doc.heightOfString(financialDecision.pricingNotes, { width: pageWidth - 10 }) + 6;
}
}
if (dsu.observationsDSU) {
y += 12;
doc.fillColor(COLORS.primary).fontSize(9).font('Helvetica-Bold')
.text('Observations de la DSU :', doc.page.margins.left + 5, y);
y += 14;
doc.fillColor(COLORS.text).fontSize(9).font('Helvetica')
.text(dsu.observationsDSU, doc.page.margins.left + 5, y, { width: pageWidth - 10 });
}
} else {
doc.fillColor(COLORS.muted).fontSize(10).font('Helvetica-Oblique')
.text('Cadre DSU non encore renseigné.', doc.page.margins.left + 5, y);
}
y += 18;
y = checkPageBreak(doc, y, 80);
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left + 10, y + 28)
.lineTo(doc.page.margins.left + pageWidth / 2 - 20, y + 28)
.stroke();
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left + pageWidth / 2 + 20, y + 28)
.lineTo(doc.page.margins.left + pageWidth - 10, y + 28)
.stroke();
doc.fillColor(COLORS.text).fontSize(9).font('Helvetica')
.text('Signature du DGS de la commune', doc.page.margins.left + 10, y + 34, {
width: pageWidth / 2 - 30,
align: 'center',
})
.text('Signature de la DSU', doc.page.margins.left + pageWidth / 2 + 20, y + 34, {
width: pageWidth / 2 - 30,
align: 'center',
});
const footerY = doc.page.height - doc.page.margins.bottom - 30;
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left, footerY)
.lineTo(doc.page.margins.left + pageWidth, footerY)
.stroke();
doc.fillColor(COLORS.muted)
.fontSize(7)
.font('Helvetica')
.text(
`Document généré le ${formatDateFr(new Date())} — Communauté de Communes Des Savanes — Direction des Services aux Usagers`,
doc.page.margins.left,
footerY + 8,
{ width: pageWidth, align: 'center' }
);
doc.end();
} catch (error) {
reject(error);
}
});
}
// ==========================================
// HELPER FUNCTIONS
// ==========================================
function drawSectionTitle(doc: PDFKit.PDFDocument, title: string, y: number, pageWidth: number): number {
const leftMargin = (doc as any).page.margins.left;
// Background bar
doc.save();
doc.rect(leftMargin - 5, y, pageWidth + 10, 20)
.fill(COLORS.primaryLight);
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text(title, leftMargin + 5, y + 5, { width: pageWidth });
doc.restore();
return y + 28;
}
function drawTable(doc: PDFKit.PDFDocument, rows: string[][], y: number, pageWidth: number, emptyStyle: boolean = false): number {
const leftMargin = (doc as any).page.margins.left;
const labelWidth = pageWidth * 0.4;
const valueWidth = pageWidth * 0.6;
const rowPadding = 5;
for (const [label, value] of rows) {
y = checkPageBreak(doc, y, 20);
// Label
doc.fillColor(COLORS.muted)
.fontSize(9)
.font('Helvetica')
.text(label + ' :', leftMargin + rowPadding, y, {
width: labelWidth - rowPadding * 2,
});
// Value
if (emptyStyle && !value) {
// Draw dotted line for empty fields
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.dash(3, { space: 2 })
.moveTo(leftMargin + labelWidth + 5, y + 10)
.lineTo(leftMargin + pageWidth - 5, y + 10)
.stroke()
.undash();
} else {
doc.fillColor(COLORS.text)
.fontSize(9)
.font('Helvetica-Bold')
.text(value || '-', leftMargin + labelWidth, y, {
width: valueWidth - rowPadding,
align: 'right',
});
}
// Calculate the height used
const labelH = doc.heightOfString(label + ' :', { width: labelWidth - rowPadding * 2 });
const valueH = value ? doc.heightOfString(value, { width: valueWidth - rowPadding }) : 12;
const rowHeight = Math.max(labelH, valueH) + 4;
y += rowHeight;
// Separator line
doc.strokeColor(COLORS.border)
.lineWidth(0.3)
.dash(1, { space: 2 })
.moveTo(leftMargin + 5, y)
.lineTo(leftMargin + pageWidth - 5, y)
.stroke()
.undash();
y += 4;
}
return y;
}
function checkPageBreak(doc: PDFKit.PDFDocument, y: number, requiredSpace: number): number {
const pageBottom = doc.page.height - doc.page.margins.bottom - 40;
if (y + requiredSpace > pageBottom) {
doc.addPage();
return doc.page.margins.top;
}
return y;
}

View file

@ -0,0 +1,224 @@
import { describe, it, expect, vi } from 'vitest';
// Mock db module
vi.mock('./db', () => ({
getRequestById: vi.fn(),
getAssociationByUserId: vi.fn(),
getAssociationById: vi.fn(),
getDocumentById: vi.fn(),
getRequestHistoryByRequestId: vi.fn(),
deleteRequest: vi.fn(),
createAuditLog: vi.fn(),
}));
import * as db from './db';
describe('Request Detail & Delete Logic', () => {
describe('getById enrichment', () => {
it('should return associationInfo when association exists', async () => {
const mockAssociation = {
id: 1,
nomAssociation: 'Association Test',
siret: '12345678901234',
adresse: '10 rue de la Paix',
codePostal: '97300',
ville: 'Cayenne',
telephone: '0594123456',
emailContact: 'test@asso.fr',
nomRepresentant: 'Jean Dupont',
};
(db.getAssociationById as any).mockResolvedValue(mockAssociation);
const result = await db.getAssociationById(1);
expect(result).toBeDefined();
expect(result?.nomAssociation).toBe('Association Test');
expect(result?.siret).toBe('12345678901234');
expect(result?.telephone).toBe('0594123456');
});
it('should return null when association does not exist', async () => {
(db.getAssociationById as any).mockResolvedValue(null);
const result = await db.getAssociationById(999);
expect(result).toBeNull();
});
it('should resolve attached documents from documentsJoints JSON', async () => {
const mockDoc1 = { id: 1, nom: 'Statuts', type: 'statuts', fileSize: 1024 };
const mockDoc2 = { id: 2, nom: 'RIB', type: 'rib', fileSize: 2048 };
(db.getDocumentById as any)
.mockResolvedValueOnce(mockDoc1)
.mockResolvedValueOnce(mockDoc2);
const documentsJoints = JSON.stringify([1, 2]);
const docIds = JSON.parse(documentsJoints) as number[];
const docs = await Promise.all(
docIds.map(async (docId: number) => {
const doc = await db.getDocumentById(docId);
return doc || null;
})
);
const attachedDocuments = docs.filter(Boolean);
expect(attachedDocuments).toHaveLength(2);
expect(attachedDocuments[0]?.nom).toBe('Statuts');
expect(attachedDocuments[1]?.nom).toBe('RIB');
});
it('should handle empty documentsJoints gracefully', () => {
const documentsJoints = null;
let attachedDocuments: any[] = [];
if (documentsJoints) {
try {
const docIds = JSON.parse(documentsJoints) as number[];
// would process here
} catch {
// ignore
}
}
expect(attachedDocuments).toHaveLength(0);
});
it('should handle invalid JSON in documentsJoints', () => {
const documentsJoints = 'not-json';
let attachedDocuments: any[] = [];
if (documentsJoints) {
try {
const docIds = JSON.parse(documentsJoints) as number[];
attachedDocuments = docIds as any[];
} catch {
// ignore parse errors
}
}
expect(attachedDocuments).toHaveLength(0);
});
});
describe('delete request', () => {
it('should allow deletion of a brouillon request by the owning association', async () => {
const mockRequest = {
id: 1,
associationId: 10,
status: 'brouillon',
titre: 'Ma demande',
type: 'subvention_fonctionnement',
};
const mockAssociation = { id: 10, userId: 5 };
(db.getRequestById as any).mockResolvedValue(mockRequest);
(db.getAssociationByUserId as any).mockResolvedValue(mockAssociation);
(db.deleteRequest as any).mockResolvedValue(undefined);
// Simulate the logic
const request = await db.getRequestById(1);
const association = await db.getAssociationByUserId(5);
expect(request).toBeDefined();
expect(association).toBeDefined();
expect(request!.associationId).toBe(association!.id);
expect(request!.status).not.toBe('validee');
expect(request!.status).not.toBe('refusee');
await db.deleteRequest(1);
expect(db.deleteRequest).toHaveBeenCalledWith(1);
});
it('should reject deletion of a validated request', async () => {
const mockRequest = {
id: 2,
associationId: 10,
status: 'validee',
titre: 'Demande validée',
type: 'subvention_projet',
};
(db.getRequestById as any).mockResolvedValue(mockRequest);
const request = await db.getRequestById(2);
expect(request!.status).toBe('validee');
// The router would throw a TRPCError here
const canDelete = request!.status !== 'validee' && request!.status !== 'refusee';
expect(canDelete).toBe(false);
});
it('should reject deletion of a refused request', async () => {
const mockRequest = {
id: 3,
associationId: 10,
status: 'refusee',
titre: 'Demande refusée',
type: 'demande_salle',
};
(db.getRequestById as any).mockResolvedValue(mockRequest);
const request = await db.getRequestById(3);
const canDelete = request!.status !== 'validee' && request!.status !== 'refusee';
expect(canDelete).toBe(false);
});
it('should reject deletion by a non-owning association', async () => {
const mockRequest = {
id: 4,
associationId: 10,
status: 'soumise',
titre: 'Demande autre asso',
type: 'autre',
};
const mockAssociation = { id: 20, userId: 8 }; // Different association
(db.getRequestById as any).mockResolvedValue(mockRequest);
(db.getAssociationByUserId as any).mockResolvedValue(mockAssociation);
const request = await db.getRequestById(4);
const association = await db.getAssociationByUserId(8);
// Association ID mismatch
expect(request!.associationId).not.toBe(association!.id);
});
it('should allow deletion of a submitted request', async () => {
const mockRequest = {
id: 5,
associationId: 10,
status: 'soumise',
titre: 'Demande soumise',
type: 'subvention_fonctionnement',
};
(db.getRequestById as any).mockResolvedValue(mockRequest);
(db.deleteRequest as any).mockResolvedValue(undefined);
const request = await db.getRequestById(5);
const canDelete = request!.status !== 'validee' && request!.status !== 'refusee';
expect(canDelete).toBe(true);
await db.deleteRequest(5);
expect(db.deleteRequest).toHaveBeenCalledWith(5);
});
it('should allow deletion of a request in information_complementaire status', async () => {
const mockRequest = {
id: 6,
associationId: 10,
status: 'information_complementaire',
titre: 'Demande info comp',
type: 'agrement_sport',
};
(db.getRequestById as any).mockResolvedValue(mockRequest);
const request = await db.getRequestById(6);
const canDelete = request!.status !== 'validee' && request!.status !== 'refusee';
expect(canDelete).toBe(true);
});
});
});

66
server/requestPdf.ts Normal file
View file

@ -0,0 +1,66 @@
import { TRPCError } from "@trpc/server";
import { generateMaterialEventPDF, generateReservationPDF } from "./pdfGenerator";
type RequestLike = {
id: number;
type: string;
titre: string;
status: string;
createdAt: Date | string;
dateSubmission?: Date | string | null;
dateTraitement?: Date | string | null;
commentaireAdmin?: string | null;
formData?: string | null;
};
export async function generateRequestPdfDocument(request: RequestLike) {
let formData: any = {};
try {
formData = request.formData ? JSON.parse(request.formData) : {};
} catch {
formData = {};
}
if (request.type === "demande_salle") {
const pdfBuffer = await generateReservationPDF({
titre: request.titre,
status: request.status,
createdAt: request.createdAt,
dateSubmission: request.dateSubmission,
dateTraitement: request.dateTraitement,
commentaireAdmin: request.commentaireAdmin,
formData,
});
return {
buffer: pdfBuffer,
storageName: `reservations/pdf-${request.id}-${Date.now()}.pdf`,
fileName: `Reservation_${request.id}_${formData.nomAssociation || "association"}.pdf`,
contentType: "application/pdf",
};
}
if (request.type === "demande_materiel_evenementiel") {
const pdfBuffer = await generateMaterialEventPDF({
titre: request.titre,
status: request.status,
createdAt: request.createdAt,
dateSubmission: request.dateSubmission,
dateTraitement: request.dateTraitement,
commentaireAdmin: request.commentaireAdmin,
formData,
});
return {
buffer: pdfBuffer,
storageName: `materiel-evenementiel/pdf-${request.id}-${Date.now()}.pdf`,
fileName: `Materiel_Evenementiel_${request.id}_${formData.nomAssociation || formData.commune || "association"}.pdf`,
contentType: "application/pdf",
};
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le PDF n'est disponible que pour les demandes de salle et de matériel événementiel",
});
}

10846
server/routers.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
import PDFDocument from "pdfkit";
import fs from "node:fs";
import path from "node:path";
const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) {
if (!fs.existsSync(CCDS_LOGO_PATH)) return;
try {
doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" });
} catch (error) {
console.warn("[SalleAdministrativeDecisionPdf] Impossible de charger le logo CCDS:", error);
}
}
export async function generateSalleAdministrativeDecisionPdf(input: {
requestId: number;
decisionText: string;
signedAt?: string | Date | null;
signedByLabel?: string | null;
}) {
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({
size: "A4",
margins: { top: 42, bottom: 42, left: 42, right: 42 },
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
let y = doc.page.margins.top;
drawLogo(doc, doc.page.margins.left + (width - 90) / 2, y, 90, 90);
y += 102;
doc.font("Helvetica-Bold").fontSize(18).fillColor("#0f2d63").text(
"DÉCISION ADMINISTRATIVE PRIORITAIRE",
doc.page.margins.left,
y,
{ width, align: "center" }
);
y += 22;
doc.font("Helvetica").fontSize(10).fillColor("#475569").text(
`Dossier salle #${input.requestId}`,
doc.page.margins.left,
y,
{ width, align: "center" }
);
y += 34;
const bodyHeight = input.signedAt ? 360 : 420;
doc.roundedRect(doc.page.margins.left, y, width, bodyHeight, 10).stroke("#cbd5e1");
doc.font("Helvetica").fontSize(11).fillColor("#1f2937").text(
input.decisionText,
doc.page.margins.left + 18,
y + 18,
{ width: width - 36, lineGap: 4 }
);
if (input.signedAt) {
const signatureY = y + bodyHeight + 18;
doc.roundedRect(doc.page.margins.left, signatureY, width, 62, 8).fillAndStroke("#eef6ff", "#93c5fd");
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1d4ed8").text(
"Visa de la Direction",
doc.page.margins.left + 16,
signatureY + 12,
{ width: width - 32 }
);
doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text(
`Validation électronique le ${new Date(input.signedAt).toLocaleString("fr-FR")}${input.signedByLabel ? ` par ${input.signedByLabel}` : ""}.`,
doc.page.margins.left + 16,
signatureY + 32,
{ width: width - 32 }
);
}
doc.end();
} catch (error) {
reject(error);
}
});
return {
buffer: pdfBuffer,
fileName: `decision-administrative-salle-${input.requestId}.pdf`,
};
}

View file

@ -0,0 +1,254 @@
import PDFDocument from "pdfkit";
import fs from "node:fs";
import path from "node:path";
import type { MaterialContractFinancialMode, MaterialContractStatus } from "./materialConventionPdf";
const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
type ContractRequestLike = {
id: number;
titre: string;
status: string;
formData?: string | null;
};
type ContractAssociationLike = {
nomAssociation?: string | null;
adresse?: string | null;
codePostal?: string | null;
ville?: string | null;
telephone?: string | null;
emailContact?: string | null;
nomRepresentant?: string | null;
} | null | undefined;
type ContractDecision = {
financialMode: MaterialContractFinancialMode;
depositRequired: boolean;
depositAmountCents: number;
rentalAmountCents: number;
pricingNotes?: string | null;
contractStatus: MaterialContractStatus;
contractGeneratedAt?: string | Date | null;
contractValidatedByUserId?: number | null;
};
function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) {
if (!fs.existsSync(CCDS_LOGO_PATH)) return;
try {
doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" });
} catch (error) {
console.warn("[SalleConventionPdf] Impossible de charger le logo CCDS:", error);
}
}
function formatDateFr(date: string | Date | null | undefined) {
if (!date) return "-";
try {
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return String(date);
}
}
function formatCurrency(cents: number | null | undefined) {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format((cents || 0) / 100);
}
function getFinancialModeLabel(mode: MaterialContractFinancialMode) {
switch (mode) {
case "gratuite":
return "Mise à disposition gratuite";
case "gratuite_avec_caution":
return "Mise à disposition gratuite avec caution";
case "location_payante":
return "Location payante";
default:
return mode;
}
}
function getScheduleSummary(formData: any) {
const dailySlots = Array.isArray(formData?.horairesParJour)
? formData.horairesParJour.filter((slot: any) => slot?.date)
: [];
if (Boolean(formData?.useDetailedSchedule) && dailySlots.length > 0) {
return dailySlots
.map((slot: any) => `${formatDateFr(slot.date)} : ${slot.heureDebut || "?"} - ${slot.heureFin || "?"}`)
.join(" | ");
}
if (formData?.heureDebut || formData?.heureFin) {
return `${formData.heureDebut || "?"} - ${formData.heureFin || "?"}`;
}
return "-";
}
export async function generateSalleConventionPdf(input: {
request: ContractRequestLike;
association?: ContractAssociationLike;
decision: ContractDecision;
}) {
const formData = (() => {
try {
return input.request.formData ? JSON.parse(input.request.formData) : {};
} catch {
return {};
}
})();
const sallesSelectionnees = Array.isArray(formData.sallesSelectionnees) && formData.sallesSelectionnees.length > 0
? formData.sallesSelectionnees.join(", ")
: "-";
const reservationStart = formData.dateReservation;
const reservationEnd = formData.dateFinReservation || formData.dateReservation;
const scheduleSummary = getScheduleSummary(formData);
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({
size: "A4",
margins: { top: 38, bottom: 42, left: 42, right: 42 },
info: {
Title: `Convention salle CCDS - dossier ${input.request.id}`,
Author: "Communauté de Communes Des Savanes",
Subject: "Convention de mise à disposition / location d'un local CCDS",
},
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
let y = doc.page.margins.top;
doc.lineWidth(2).strokeColor("#efb100").moveTo(doc.page.margins.left, y).lineTo(doc.page.margins.left + pageWidth, y).stroke();
y += 16;
drawLogo(doc, doc.page.margins.left + (pageWidth - 84) / 2, y, 84, 84);
y += 92;
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, {
width: pageWidth,
align: "center",
});
y += 18;
doc.font("Helvetica-Bold").fontSize(17).fillColor("#0f172a").text(
"CONVENTION DE MISE À DISPOSITION / LOCATION",
doc.page.margins.left,
y,
{ width: pageWidth, align: "center" }
);
y += 20;
doc.font("Helvetica-Bold").fontSize(14).text("D'UN LOCAL - MAISON DE LA JEUNESSE DES SAVANES", doc.page.margins.left, y, {
width: pageWidth,
align: "center",
});
y += 28;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 42, 8).fillAndStroke("#eef5ff", "#bfd2ef");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f2d63").text(
"Document généré à partir de la décision administrative CCDS",
doc.page.margins.left + 14,
y + 9,
{ width: pageWidth - 28, align: "center" }
);
doc.font("Helvetica").fontSize(9).fillColor("#334155").text(
`Dossier #${input.request.id} — statut contrat : ${input.decision.contractStatus}`,
doc.page.margins.left + 14,
y + 23,
{ width: pageWidth - 28, align: "center" }
);
y += 56;
const infoRows: Array<[string, string]> = [
["Association", input.association?.nomAssociation || formData.nomAssociation || "-"],
["Représentant", input.association?.nomRepresentant || formData.representantLegal || "-"],
["Adresse", [input.association?.adresse, input.association?.codePostal, input.association?.ville].filter(Boolean).join(" ") || "-"],
["Téléphone", input.association?.telephone || formData.telephoneAssociation || "-"],
["Email", input.association?.emailContact || formData.emailAssociation || "-"],
["Local réservé", sallesSelectionnees],
[
"Période d'utilisation",
reservationStart || reservationEnd
? `${formatDateFr(reservationStart)}${reservationEnd ? ` au ${formatDateFr(reservationEnd)}` : ""}`
: "-",
],
["Créneau / horaires", scheduleSummary],
["Objet", formData.motifReservation || input.request.titre || "-"],
];
for (const [label, value] of infoRows) {
doc.font("Helvetica-Bold").fontSize(9).fillColor("#334155").text(`${label} :`, doc.page.margins.left, y, { width: 140 });
doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(value, doc.page.margins.left + 145, y - 1, {
width: pageWidth - 145,
});
y += 18;
}
y += 10;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 108, 8).fillAndStroke("#f8fafc", "#cbd5e1");
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Conditions financières", doc.page.margins.left + 14, y + 12);
doc.font("Helvetica").fontSize(10).fillColor("#334155")
.text(`Régime retenu : ${getFinancialModeLabel(input.decision.financialMode)}`, doc.page.margins.left + 14, y + 34, {
width: pageWidth - 28,
})
.text(`Montant de location : ${formatCurrency(input.decision.rentalAmountCents)}`, doc.page.margins.left + 14, y + 52, {
width: pageWidth - 28,
})
.text(
`Caution : ${input.decision.depositRequired ? formatCurrency(input.decision.depositAmountCents) : "Aucune caution exigée"}`,
doc.page.margins.left + 14,
y + 70,
{ width: pageWidth - 28 }
);
y += 122;
if (input.decision.pricingNotes?.trim()) {
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Clauses / réserves spécifiques", doc.page.margins.left, y);
y += 16;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 70, 8).stroke("#cbd5e1");
doc.font("Helvetica").fontSize(10).fillColor("#334155").text(input.decision.pricingNotes.trim(), doc.page.margins.left + 12, y + 12, {
width: pageWidth - 24,
});
y += 84;
}
y += 8;
doc.font("Helvetica").fontSize(9).fillColor("#475569").text(
"Cette convention formalise les conditions administratives retenues par la CCDS pour la mise à disposition ou la location du local sollicité.",
doc.page.margins.left,
y,
{ width: pageWidth }
);
y += 36;
const signatureWidth = (pageWidth - 16) / 2;
doc.roundedRect(doc.page.margins.left, y, signatureWidth, 84, 8).stroke("#cbd5e1");
doc.roundedRect(doc.page.margins.left + signatureWidth + 16, y, signatureWidth, 84, 8).stroke("#cbd5e1");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a")
.text("Association emprunteuse", doc.page.margins.left + 12, y + 12, { width: signatureWidth - 24, align: "center" })
.text("CCDS / DSU", doc.page.margins.left + signatureWidth + 28, y + 12, { width: signatureWidth - 24, align: "center" });
doc.font("Helvetica").fontSize(9).fillColor("#64748b")
.text("Nom, qualité et signature", doc.page.margins.left + 12, y + 56, { width: signatureWidth - 24, align: "center" })
.text("Visa administratif", doc.page.margins.left + signatureWidth + 28, y + 56, { width: signatureWidth - 24, align: "center" });
doc.end();
} catch (error) {
reject(error);
}
});
return {
buffer: pdfBuffer,
fileName: `convention-salle-ccds-${input.request.id}.pdf`,
};
}

150
server/salleInvoicePdf.ts Normal file
View file

@ -0,0 +1,150 @@
import PDFDocument from "pdfkit";
import fs from "node:fs";
import path from "node:path";
import type { SallePricingSummary } from "@shared/sallePricing";
const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
function formatCurrency(cents: number) {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(cents / 100);
}
function formatDateFr(date: string | Date | null | undefined) {
if (!date) return "-";
try {
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return String(date);
}
}
function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) {
if (!fs.existsSync(CCDS_LOGO_PATH)) return;
try {
doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" });
} catch (error) {
console.warn("[SalleInvoicePdf] Impossible de charger le logo CCDS:", error);
}
}
export async function generateSalleInvoicePdf(input: {
requestId: number;
requestTitle: string;
associationName: string;
datesLabel: string;
pricing: SallePricingSummary;
}) {
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({
size: "A4",
margins: { top: 36, bottom: 42, left: 34, right: 34 },
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
let y = doc.page.margins.top;
drawLogo(doc, doc.page.margins.left, y, 180, 70);
doc.font("Helvetica-Bold").fontSize(18).fillColor("#244f7a").text(
`Facture N° ${String(input.requestId).padStart(3, "0")}/${new Date().getFullYear()}`,
doc.page.margins.left,
y + 34,
{ width, align: "right" }
);
y += 82;
const infoRows: Array<[string, string]> = [
["Date", formatDateFr(new Date())],
["Référence dossier", `Réservation salle #${input.requestId}`],
["Émis par", "Maison de la Jeunesse des Savanes - CCDS"],
["Association", input.associationName],
["Période", input.datesLabel],
];
const labelWidth = 160;
infoRows.forEach(([label, value]) => {
doc.rect(doc.page.margins.left, y, labelWidth, 36).stroke("#d9d9d9");
doc.rect(doc.page.margins.left + labelWidth, y, width - labelWidth, 36).stroke("#d9d9d9");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#404040").text(label, doc.page.margins.left + 10, y + 12, { width: labelWidth - 20 });
doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text(value, doc.page.margins.left + labelWidth + 10, y + 12, {
width: width - labelWidth - 20,
});
y += 36;
});
y += 26;
const col1 = width * 0.52;
const col2 = width * 0.13;
const col3 = width * 0.17;
const col4 = width - col1 - col2 - col3;
doc.rect(doc.page.margins.left, y, width, 40).fillAndStroke("#244f7a", "#244f7a");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#ffffff")
.text("DESCRIPTION", doc.page.margins.left + 14, y + 14, { width: col1 - 20 })
.text("QTÉ", doc.page.margins.left + col1 + 10, y + 14, { width: col2 - 20, align: "center" })
.text("PRIX UNITAIRE", doc.page.margins.left + col1 + col2 + 10, y + 14, { width: col3 - 20, align: "center" })
.text("TOTAL", doc.page.margins.left + col1 + col2 + col3 + 10, y + 14, { width: col4 - 20, align: "center" });
y += 40;
input.pricing.lineItems.forEach((line) => {
const rowHeight = 88;
doc.rect(doc.page.margins.left, y, col1, rowHeight).stroke("#d9d9d9");
doc.rect(doc.page.margins.left + col1, y, col2, rowHeight).stroke("#d9d9d9");
doc.rect(doc.page.margins.left + col1 + col2, y, col3, rowHeight).stroke("#d9d9d9");
doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, rowHeight).stroke("#d9d9d9");
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1f2937").text(
`Location ${line.salleNom}`,
doc.page.margins.left + 14,
y + 12,
{ width: col1 - 24 }
);
doc.font("Helvetica").fontSize(10).fillColor("#334155").text(
`${line.categoryLabel}\n${input.datesLabel}\n${input.requestTitle}`,
doc.page.margins.left + 14,
y + 32,
{ width: col1 - 24 }
);
doc.font("Helvetica-Bold").fontSize(12).fillColor("#1f2937")
.text(String(line.quantity), doc.page.margins.left + col1, y + 34, { width: col2, align: "center" })
.text(formatCurrency(line.unitAmountCents), doc.page.margins.left + col1 + col2, y + 34, { width: col3, align: "center" })
.text(formatCurrency(line.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 34, { width: col4, align: "center" });
y += rowHeight;
});
doc.rect(doc.page.margins.left, y, col1 + col2 + col3, 44).stroke("#244f7a");
doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, 44).stroke("#244f7a");
doc.font("Helvetica-Bold").fontSize(12).fillColor("#244f7a")
.text("TOTAL À RÉGLER", doc.page.margins.left, y + 14, { width: col1 + col2 + col3 - 16, align: "right" })
.fillColor("#c2410c")
.text(formatCurrency(input.pricing.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 14, { width: col4, align: "center" });
y += 60;
doc.roundedRect(doc.page.margins.left, y, width, 110, 8).fillAndStroke("#f8fafc", "#cbd5e1");
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1f2937").text("Récapitulatif pour lenvoi final", doc.page.margins.left + 16, y + 14);
doc.font("Helvetica").fontSize(10).fillColor("#475569")
.text("Cette facture accompagne la validation finale de la réservation. Le règlement est attendu selon les modalités précisées par la Maison de la Jeunesse des Savanes.", doc.page.margins.left + 16, y + 36, {
width: width - 32,
})
.text("RIB MJS : à joindre ou communiquer depuis laccueil si nécessaire.", doc.page.margins.left + 16, y + 72, {
width: width - 32,
});
doc.end();
} catch (error) {
reject(error);
}
});
return {
buffer: pdfBuffer,
fileName: `facture-salle-ccds-${input.requestId}.pdf`,
};
}

182
server/salleQuotePdf.ts Normal file
View file

@ -0,0 +1,182 @@
import PDFDocument from "pdfkit";
import fs from "node:fs";
import path from "node:path";
import type { SallePricingSummary } from "@shared/sallePricing";
const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
function formatCurrency(cents: number) {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(cents / 100);
}
function formatDateFr(date: string | Date | null | undefined) {
if (!date) return "-";
try {
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return String(date);
}
}
function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) {
if (!fs.existsSync(CCDS_LOGO_PATH)) return;
try {
doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" });
} catch (error) {
console.warn("[SalleQuotePdf] Impossible de charger le logo CCDS:", error);
}
}
function getScheduleSummary(formData: any) {
const dailySlots = Array.isArray(formData?.horairesParJour)
? formData.horairesParJour.filter((slot: any) => slot?.date)
: [];
if (Boolean(formData?.useDetailedSchedule) && dailySlots.length > 0) {
const start = dailySlots[0];
const end = dailySlots[dailySlots.length - 1];
return `${formatDateFr(start.date)} au ${formatDateFr(end.date)}`;
}
return `${formData?.heureDebut || "?"} à ${formData?.heureFin || "?"}`;
}
export async function generateSalleQuotePdf(input: {
requestId: number;
requestTitle: string;
formData: any;
association: {
nomAssociation?: string | null;
adresse?: string | null;
codePostal?: string | null;
ville?: string | null;
} | null | undefined;
pricing: SallePricingSummary;
signedAt?: string | Date | null;
signedByLabel?: string | null;
}) {
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({
size: "A4",
margins: { top: 36, bottom: 42, left: 34, right: 34 },
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
let y = doc.page.margins.top;
drawLogo(doc, doc.page.margins.left, y, 180, 70);
doc.font("Helvetica-Bold").fontSize(18).fillColor("#244f7a").text(
`Devis N° ${String(input.requestId).padStart(3, "0")}/${new Date().getFullYear()}`,
doc.page.margins.left,
y + 34,
{ width, align: "right" }
);
y += 82;
const clientAddress = [input.association?.adresse, input.association?.codePostal, input.association?.ville].filter(Boolean).join(" ");
const infoRows: Array<[string, string]> = [
["Date", formatDateFr(new Date())],
["Référence", `${String(input.requestId).padStart(3, "0")}/${new Date().getFullYear()}`],
["Émis par", "Communauté de Communes Des Savanes"],
["Client", input.association?.nomAssociation || input.formData.nomAssociation || "-"],
["Adresse", clientAddress || "-"],
];
const labelWidth = 160;
infoRows.forEach(([label, value]) => {
doc.rect(doc.page.margins.left, y, labelWidth, 36).stroke("#d9d9d9");
doc.rect(doc.page.margins.left + labelWidth, y, width - labelWidth, 36).stroke("#d9d9d9");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#404040").text(label, doc.page.margins.left + 10, y + 12, { width: labelWidth - 20 });
doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text(value, doc.page.margins.left + labelWidth + 10, y + 12, {
width: width - labelWidth - 20,
});
y += 36;
});
y += 26;
const col1 = width * 0.54;
const col2 = width * 0.13;
const col3 = width * 0.15;
const col4 = width - col1 - col2 - col3;
doc.rect(doc.page.margins.left, y, width, 40).fillAndStroke("#244f7a", "#244f7a");
doc.font("Helvetica-Bold").fontSize(10).fillColor("#ffffff")
.text("DESCRIPTION", doc.page.margins.left + 14, y + 14, { width: col1 - 20 })
.text("QUANTITÉ", doc.page.margins.left + col1 + 10, y + 14, { width: col2 - 20, align: "center" })
.text("PRIX UNITAIRE HT", doc.page.margins.left + col1 + col2 + 10, y + 8, { width: col3 - 20, align: "center" })
.text("TOTAL TTC", doc.page.margins.left + col1 + col2 + col3 + 10, y + 14, { width: col4 - 20, align: "center" });
y += 40;
input.pricing.lineItems.forEach((line) => {
const rowHeight = 92;
doc.rect(doc.page.margins.left, y, col1, rowHeight).stroke("#d9d9d9");
doc.rect(doc.page.margins.left + col1, y, col2, rowHeight).stroke("#d9d9d9");
doc.rect(doc.page.margins.left + col1 + col2, y, col3, rowHeight).stroke("#d9d9d9");
doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, rowHeight).stroke("#d9d9d9");
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1f2937").text(
`location de la salle "${line.salleNom.replace("Salle ", "")}"`,
doc.page.margins.left + 14,
y + 12,
{ width: col1 - 24 }
);
doc.font("Helvetica").fontSize(10).fillColor("#334155").text(
`${line.categoryLabel}\n${input.formData.dateReservation ? `Période du ${formatDateFr(input.formData.dateReservation)}${input.formData.dateFinReservation && input.formData.dateFinReservation !== input.formData.dateReservation ? ` au ${formatDateFr(input.formData.dateFinReservation)}` : ""}` : ""}\n${getScheduleSummary(input.formData)}`,
doc.page.margins.left + 14,
y + 34,
{ width: col1 - 24 }
);
doc.font("Helvetica-Bold").fontSize(12).fillColor("#1f2937")
.text(String(line.quantity), doc.page.margins.left + col1, y + 38, { width: col2, align: "center" })
.text(formatCurrency(line.unitAmountCents), doc.page.margins.left + col1 + col2, y + 38, { width: col3, align: "center" })
.text(formatCurrency(line.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 38, { width: col4, align: "center" });
y += rowHeight;
});
doc.rect(doc.page.margins.left, y, col1 + col2 + col3, 44).stroke("#244f7a");
doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, 44).stroke("#244f7a");
doc.font("Helvetica-Bold").fontSize(12).fillColor("#244f7a")
.text("TOTAL TTC", doc.page.margins.left, y + 14, { width: col1 + col2 + col3 - 16, align: "right" })
.fillColor("#c2410c")
.text(formatCurrency(input.pricing.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 14, { width: col4, align: "center" });
y += 64;
doc.font("Helvetica-Bold").fontSize(12).fillColor("#111827").text("Arrêter le présent devis à la somme de :", doc.page.margins.left, y);
y += 22;
doc.font("Helvetica").fontSize(11).fillColor("#374151").text(formatCurrency(input.pricing.totalAmountCents), doc.page.margins.left, y);
y += 48;
doc.font("Helvetica-Bold").fontSize(10).fillColor("#244f7a").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, { width, align: "center" });
y += 16;
doc.font("Helvetica").fontSize(10).fillColor("#475569").text("GESTION ADMINISTRATIVE ET FACTURATION", doc.page.margins.left, y, { width, align: "center" });
if (input.signedAt) {
y += 26;
doc.roundedRect(doc.page.margins.left, y, width, 56, 8).fillAndStroke("#eef6ff", "#93c5fd");
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1d4ed8").text("Devis validé pour signature Direction", doc.page.margins.left + 16, y + 12, {
width: width - 32,
});
doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text(
`Validation électronique le ${formatDateFr(input.signedAt)}${input.signedByLabel ? ` par ${input.signedByLabel}` : ""}.`,
doc.page.margins.left + 16,
y + 30,
{ width: width - 32 }
);
}
doc.end();
} catch (error) {
reject(error);
}
});
return {
buffer: pdfBuffer,
fileName: `devis-salle-ccds-${input.requestId}.pdf`,
};
}

View file

@ -0,0 +1,137 @@
import { CONDITIONS_FINANCIERES_SALLE_TEXT, SALLE_FREQUENCY_LABELS, SALLE_USAGE_TYPE_LABELS, type SallePricingSummary } from "@shared/sallePricing";
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatCurrency(cents: number) {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(cents / 100);
}
export function generateSalleQuoteEmail(input: {
associationName: string;
requestTitle: string;
pricing: SallePricingSummary;
acceptUrl: string;
refuseUrl: string;
decisionText: string;
}) {
const total = formatCurrency(input.pricing.totalAmountCents);
const conditionLines = CONDITIONS_FINANCIERES_SALLE_TEXT.split("\n").filter(Boolean);
const decisionLines = input.decisionText.split("\n").filter(Boolean);
const isBilled = input.pricing.totalAmountCents > 0;
const subject = isBilled
? `Validation du devis - ${input.requestTitle}`
: `Validation de la mise à disposition - ${input.requestTitle}`;
const intro = isBilled
? `Bonjour ${input.associationName},\n\nVous trouverez ci-joint le devis, les conditions financières et la décision administrative prioritaire relatives à votre demande de réservation.`
: `Bonjour ${input.associationName},\n\nVous trouverez ci-joint les conditions financières et la décision administrative prioritaire relatives à votre demande de réservation.`;
const amountBlock = isBilled
? `Type d'usage : ${SALLE_USAGE_TYPE_LABELS[input.pricing.usageType]}
Fréquence : ${SALLE_FREQUENCY_LABELS[input.pricing.frequency]}
Montant calculé automatiquement : ${total}`
: `Type d'usage : ${SALLE_USAGE_TYPE_LABELS[input.pricing.usageType]}
Fréquence : ${SALLE_FREQUENCY_LABELS[input.pricing.frequency]}
Mise à disposition sans facturation.`;
const text = `${intro}
${amountBlock}
Pour poursuivre le traitement de votre dossier, merci d'utiliser l'un des liens suivants :
- J'accepte : ${input.acceptUrl}
- Je refuse : ${input.refuseUrl}
Conditions financières :
${conditionLines.join("\n")}
Décision administrative :
${decisionLines.join("\n")}
Maison de la Jeunesse des Savanes - Mélissa ALVES`;
const html = `
<div style="font-family:Arial,sans-serif;color:#1f2937;line-height:1.5">
<p>Bonjour ${escapeHtml(input.associationName)},</p>
<p>${escapeHtml(isBilled
? "Vous trouverez ci-joint le devis, les conditions financières et la décision administrative prioritaire relatives à votre demande de réservation."
: "Vous trouverez ci-joint les conditions financières et la décision administrative prioritaire relatives à votre demande de réservation.")}</p>
<div style="border:1px solid #dbe4f0;border-radius:10px;padding:16px;background:#f8fbff;margin:16px 0">
<p style="margin:0 0 8px 0;"><strong>Type d'usage :</strong> ${escapeHtml(SALLE_USAGE_TYPE_LABELS[input.pricing.usageType])}</p>
<p style="margin:0 0 8px 0;"><strong>Fréquence :</strong> ${escapeHtml(SALLE_FREQUENCY_LABELS[input.pricing.frequency])}</p>
<p style="margin:0;"><strong>${escapeHtml(isBilled ? "Montant calculé automatiquement :" : "Conditions financières :")}</strong> ${escapeHtml(isBilled ? total : "Mise à disposition sans facturation")}</p>
</div>
<p>Merci de nous confirmer votre position :</p>
<div style="margin:18px 0;">
<a href="${input.acceptUrl}" style="display:inline-block;background:#18753c;color:#fff;text-decoration:none;padding:12px 18px;border-radius:8px;margin-right:10px;">J'accepte le devis</a>
<a href="${input.refuseUrl}" style="display:inline-block;background:#ce0500;color:#fff;text-decoration:none;padding:12px 18px;border-radius:8px;">Je refuse</a>
</div>
<p style="font-size:13px;color:#475569">Si les boutons ne fonctionnent pas, utilisez directement ces liens :<br />${escapeHtml(input.acceptUrl)}<br />${escapeHtml(input.refuseUrl)}</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0" />
<p style="font-size:13px;color:#475569;margin:0">Maison de la Jeunesse des Savanes - Mélissa ALVES</p>
</div>
`;
return { subject, text, html };
}
export function generateSalleFinalEmail(input: {
associationName: string;
datesLabel: string;
pricingTotalCents: number;
billed: boolean;
}) {
const subject = input.billed
? `Réservation validée - location de salle`
: `Réservation validée - mise à disposition de salle`;
const text = input.billed
? `Bonjour ${input.associationName},
Votre demande de réservation dun espace pour le ${input.datesLabel} a reçu un avis favorable.
Nous vous confirmons que votre événement se tiendra au sein de la Maison de la Jeunesse des Savanes « Mélissa ALVES ».
Le montant de la location sélève à ${formatCurrency(input.pricingTotalCents)} conformément à la tarification en vigueur et au devis validé.
Nous vous rappelons que vous êtes seule responsable de tout ce qui se passera dans les locaux, à lintérieur comme à lextérieur, concernant les participants et le public durant la période de lévénement.
Merci de bien vouloir nous transmettre une attestation de responsabilité civile couvrant cette journée, ainsi que le règlement du montant indiqué selon les modalités précisées dans le devis.
Il est formellement interdit de manger à lintérieur des espaces utilisés et nous vous demandons de veiller à ce que les lieux soient laissés aussi propres à la fin de lévénement quà votre arrivée.
Vous trouverez en pièce jointe :
- le devis validé
- la décision administrative
- le formulaire signé
- la facture à régler
Bien cordialement,
Maison de la Jeunesse des Savanes - Mélissa ALVES`
: `Bonjour ${input.associationName},
Votre demande de réservation dun espace pour le ${input.datesLabel} a reçu un avis favorable.
Nous vous confirmons que votre événement se tiendra au sein de la Maison de la Jeunesse des Savanes « Mélissa ALVES ».
Nous vous rappelons que vous êtes seule responsable de tout ce qui se passera dans les locaux, à lintérieur comme à lextérieur, concernant les participants et le public durant la période de lévénement.
Merci de bien vouloir nous transmettre une attestation de responsabilité civile couvrant cette journée.
Il est formellement interdit de manger à lintérieur des espaces utilisés.
Nous vous demandons également de veiller à laisser les lieux propres et en bon état à la fin de votre événement.
Vous trouverez en pièce jointe le formulaire validé et signé.
Bien cordialement,
Maison de la Jeunesse des Savanes - Mélissa ALVES`;
return {
subject,
text,
html: `<div style="font-family:Arial,sans-serif;white-space:pre-line;color:#1f2937">${escapeHtml(text)}</div>`,
};
}

View file

@ -0,0 +1,123 @@
import {
buildDecisionAdministrativeText,
computeSallePricing,
CONDITIONS_FINANCIERES_SALLE_TEXT,
type SallePricingSummary,
type SalleReservationFrequency,
type SalleUsageType,
type SalleWorkflowData,
type SalleWorkflowDirectorStatus,
type SalleWorkflowQuoteStatus,
} from "@shared/sallePricing";
function formatDateFr(date: string | Date | null | undefined) {
if (!date) return "-";
try {
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return String(date);
}
}
function getScheduleSummary(formData: any) {
const dailySlots = Array.isArray(formData?.horairesParJour)
? formData.horairesParJour.filter((slot: any) => slot?.date)
: [];
if (Boolean(formData?.useDetailedSchedule) && dailySlots.length > 0) {
return dailySlots
.map((slot: any) => `${formatDateFr(slot.date)} : ${slot.heureDebut || "?"} - ${slot.heureFin || "?"}`)
.join(" | ");
}
if (formData?.heureDebut || formData?.heureFin) {
return `${formData.heureDebut || "?"} - ${formData.heureFin || "?"}`;
}
return "-";
}
export function parseRequestFormData(rawFormData?: string | null) {
if (!rawFormData) return {};
try {
return JSON.parse(rawFormData);
} catch {
return {};
}
}
export function getSalleWorkflowData(rawFormData?: string | null): SalleWorkflowData {
const formData = parseRequestFormData(rawFormData);
return formData?.salleWorkflow && typeof formData.salleWorkflow === "object" ? formData.salleWorkflow : {};
}
export function computeSalleWorkflowPricing(rawFormData?: string | null): SallePricingSummary | null {
const formData = parseRequestFormData(rawFormData);
const usageType = formData?.typeUsage as SalleUsageType | undefined;
const frequency = formData?.frequence as SalleReservationFrequency | undefined;
const sallesIds = Array.isArray(formData?.sallesIds) ? formData.sallesIds : [];
if (!usageType || !frequency || sallesIds.length === 0) {
return null;
}
return computeSallePricing({
sallesIds,
usageType,
frequency,
dateReservation: formData.dateReservation,
dateFinReservation: formData.dateFinReservation,
});
}
export function mergeSalleWorkflowData(rawFormData: string | null | undefined, workflowPatch: Partial<SalleWorkflowData>) {
const formData = parseRequestFormData(rawFormData);
const currentWorkflow = getSalleWorkflowData(rawFormData);
formData.salleWorkflow = {
...currentWorkflow,
...workflowPatch,
};
return JSON.stringify(formData);
}
export function buildDefaultSalleWorkflowTexts(rawFormData?: string | null) {
const formData = parseRequestFormData(rawFormData);
const salles = Array.isArray(formData?.sallesSelectionnees) && formData.sallesSelectionnees.length > 0
? formData.sallesSelectionnees.join(", ")
: "-";
const dates = formData?.dateReservation
? `${formatDateFr(formData.dateReservation)}${formData?.dateFinReservation && formData.dateFinReservation !== formData.dateReservation ? ` au ${formatDateFr(formData.dateFinReservation)}` : ""}`
: "-";
const horaires = getScheduleSummary(formData);
const association = formData?.nomAssociation || "-";
return {
conditionsFinancieresText: CONDITIONS_FINANCIERES_SALLE_TEXT,
decisionAdministrativeText: buildDecisionAdministrativeText({
espace: salles,
dates,
horaires,
association,
}),
};
}
export function updateSalleWorkflowQuoteStatus(rawFormData: string | null | undefined, status: SalleWorkflowQuoteStatus) {
return mergeSalleWorkflowData(rawFormData, {
quoteStatus: status,
quoteRespondedAt: status === "accepte" || status === "refuse" ? new Date().toISOString() : undefined,
});
}
export function updateSalleWorkflowDirectorStatus(rawFormData: string | null | undefined, status: SalleWorkflowDirectorStatus) {
return mergeSalleWorkflowData(rawFormData, {
directorStatus: status,
directorTransmissionAt: status === "en_attente_signature" ? new Date().toISOString() : undefined,
directorSignedAt: status === "signee" ? new Date().toISOString() : undefined,
directorReturnComment: status === "en_attente_signature" ? "" : undefined,
directorReturnedAt: status === "en_attente_signature" ? "" : undefined,
});
}

View file

@ -0,0 +1,102 @@
import * as db from "./db";
const SALLE_SIGNATURE_DELEGATION_SETTING_KEY = "system.salle_signature.delegations";
type DirectriceDelegationEntry = {
delegateUserIds: number[];
};
type SalleSignatureDelegationSettings = {
directriceDelegations: Record<string, DirectriceDelegationEntry>;
};
function sanitizeUserIds(values: unknown): number[] {
if (!Array.isArray(values)) return [];
return Array.from(
new Set(
values
.map((value) => Number(value))
.filter((value) => Number.isInteger(value) && value > 0)
)
);
}
function sanitizeSettings(value: unknown): SalleSignatureDelegationSettings {
if (!value || typeof value !== "object") {
return { directriceDelegations: {} };
}
const record = (value as { directriceDelegations?: unknown }).directriceDelegations;
if (!record || typeof record !== "object") {
return { directriceDelegations: {} };
}
const entries = Object.entries(record as Record<string, unknown>).reduce<Record<string, DirectriceDelegationEntry>>(
(acc, [directriceId, entry]) => {
const userId = Number(directriceId);
if (!Number.isInteger(userId) || userId <= 0) return acc;
const delegateUserIds = sanitizeUserIds((entry as { delegateUserIds?: unknown })?.delegateUserIds);
acc[String(userId)] = { delegateUserIds };
return acc;
},
{}
);
return { directriceDelegations: entries };
}
export async function getSalleSignatureDelegationSettings(): Promise<SalleSignatureDelegationSettings> {
const raw = await db.getPortalSetting(SALLE_SIGNATURE_DELEGATION_SETTING_KEY);
if (!raw) {
return { directriceDelegations: {} };
}
try {
return sanitizeSettings(JSON.parse(raw));
} catch {
return { directriceDelegations: {} };
}
}
export async function saveSalleSignatureDelegationSettings(settings: SalleSignatureDelegationSettings) {
await db.setPortalSetting(
SALLE_SIGNATURE_DELEGATION_SETTING_KEY,
JSON.stringify(sanitizeSettings(settings)),
"Délégations de signature des demandes de salle"
);
}
export async function getDelegatedSignerIdsForDirectrice(directriceUserId: number) {
const settings = await getSalleSignatureDelegationSettings();
return settings.directriceDelegations[String(directriceUserId)]?.delegateUserIds || [];
}
export async function setDelegatedSignerIdsForDirectrice(directriceUserId: number, delegateUserIds: number[]) {
const settings = await getSalleSignatureDelegationSettings();
settings.directriceDelegations[String(directriceUserId)] = {
delegateUserIds: sanitizeUserIds(delegateUserIds),
};
await saveSalleSignatureDelegationSettings(settings);
}
export async function addDelegatedSignerForDirectrice(directriceUserId: number, delegateUserId: number) {
const current = await getDelegatedSignerIdsForDirectrice(directriceUserId);
const next = Array.from(new Set([...current, delegateUserId]));
await setDelegatedSignerIdsForDirectrice(directriceUserId, next);
return next;
}
export async function removeDelegatedSignerForDirectrice(directriceUserId: number, delegateUserId: number) {
const current = await getDelegatedSignerIdsForDirectrice(directriceUserId);
const next = current.filter((entry) => entry !== delegateUserId);
await setDelegatedSignerIdsForDirectrice(directriceUserId, next);
return next;
}
export async function getDirectriceIdsDelegatingToUser(userId: number) {
const settings = await getSalleSignatureDelegationSettings();
return Object.entries(settings.directriceDelegations)
.filter(([, entry]) => sanitizeUserIds(entry.delegateUserIds).includes(userId))
.map(([directriceId]) => Number(directriceId))
.filter((value) => Number.isInteger(value) && value > 0);
}

281
server/socialAuth.ts Normal file
View file

@ -0,0 +1,281 @@
import { randomBytes } from "node:crypto";
import type { Express, Request, Response } from "express";
import { parse as parseCookieHeader } from "cookie";
import { createSessionToken, loginOAuthUser, setSessionCookie } from "./_core/auth";
import { getSessionCookieOptions } from "./_core/cookies";
import { ENV } from "./_core/env";
const GOOGLE_STATE_COOKIE = "oauth_state_google";
const FACEBOOK_STATE_COOKIE = "oauth_state_facebook";
const OAUTH_STATE_MAX_AGE_MS = 10 * 60 * 1000;
function parseCookies(cookieHeader: string | undefined) {
if (!cookieHeader) return new Map<string, string>();
return new Map(Object.entries(parseCookieHeader(cookieHeader)));
}
function buildBaseUrl(req: Request) {
if (ENV.appBaseUrl) {
return ENV.appBaseUrl.replace(/\/$/, "");
}
const forwardedProto = req.headers["x-forwarded-proto"];
const forwardedHost = req.headers["x-forwarded-host"];
const proto = typeof forwardedProto === "string"
? forwardedProto.split(",")[0]?.trim()
: req.protocol;
const host = typeof forwardedHost === "string"
? forwardedHost.split(",")[0]?.trim()
: req.get("host");
return `${proto || "http"}://${host}`;
}
function getCallbackUrl(req: Request, provider: "google" | "facebook") {
return `${buildBaseUrl(req)}/auth/${provider}/callback`;
}
function redirectToLogin(res: Response, message: string) {
res.redirect(`/login?authError=${encodeURIComponent(message)}`);
}
function setOAuthStateCookie(req: Request, res: Response, name: string, value: string) {
res.cookie(name, value, {
...getSessionCookieOptions(req),
maxAge: OAUTH_STATE_MAX_AGE_MS,
});
}
function clearOAuthStateCookie(req: Request, res: Response, name: string) {
res.clearCookie(name, {
...getSessionCookieOptions(req),
maxAge: -1,
});
}
function readAndValidateState(req: Request, res: Response, cookieName: string, receivedState: string | null) {
const cookies = parseCookies(req.headers.cookie);
const expectedState = cookies.get(cookieName) ?? "";
clearOAuthStateCookie(req, res, cookieName);
return Boolean(receivedState && expectedState && receivedState === expectedState);
}
function createOAuthState() {
return randomBytes(24).toString("hex");
}
function isGoogleConfigured() {
return Boolean(ENV.googleClientId && ENV.googleClientSecret);
}
function isFacebookConfigured() {
return Boolean(ENV.facebookAppId && ENV.facebookAppSecret);
}
async function finishLogin(req: Request, res: Response, userData: {
provider: "google" | "facebook";
providerUserId: string;
email: string;
name?: string | null;
}) {
const user = await loginOAuthUser(userData);
const token = await createSessionToken(user);
setSessionCookie(req, res, token);
res.redirect(user.role === "service_terrain" ? "/terrain" : "/dashboard");
}
export function getOAuthProviderStatus() {
return {
google: isGoogleConfigured(),
facebook: isFacebookConfigured(),
};
}
export function registerSocialAuthRoutes(app: Express) {
app.get("/auth/google/start", (req, res) => {
if (!isGoogleConfigured()) {
redirectToLogin(res, "Connexion Google non configurée");
return;
}
const state = createOAuthState();
setOAuthStateCookie(req, res, GOOGLE_STATE_COOKIE, state);
const params = new URLSearchParams({
client_id: ENV.googleClientId,
redirect_uri: getCallbackUrl(req, "google"),
response_type: "code",
scope: "openid email profile",
state,
access_type: "online",
include_granted_scopes: "true",
prompt: "select_account",
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
});
app.get("/auth/google/callback", async (req, res) => {
try {
if (!isGoogleConfigured()) {
redirectToLogin(res, "Connexion Google non configurée");
return;
}
const code = typeof req.query.code === "string" ? req.query.code : "";
const state = typeof req.query.state === "string" ? req.query.state : null;
if (!code || !readAndValidateState(req, res, GOOGLE_STATE_COOKIE, state)) {
redirectToLogin(res, "Connexion Google invalide ou expirée");
return;
}
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
code,
client_id: ENV.googleClientId,
client_secret: ENV.googleClientSecret,
redirect_uri: getCallbackUrl(req, "google"),
grant_type: "authorization_code",
}),
});
if (!tokenResponse.ok) {
redirectToLogin(res, "Google a refusé la connexion");
return;
}
const tokenData = await tokenResponse.json() as { access_token?: string };
if (!tokenData.access_token) {
redirectToLogin(res, "Jeton Google manquant");
return;
}
const userInfoResponse = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
headers: {
authorization: `Bearer ${tokenData.access_token}`,
},
});
if (!userInfoResponse.ok) {
redirectToLogin(res, "Impossible de récupérer le profil Google");
return;
}
const userInfo = await userInfoResponse.json() as {
sub?: string;
email?: string;
email_verified?: boolean;
name?: string;
};
if (!userInfo.sub || !userInfo.email || userInfo.email_verified === false) {
redirectToLogin(res, "Le compte Google doit fournir une adresse email vérifiée");
return;
}
await finishLogin(req, res, {
provider: "google",
providerUserId: userInfo.sub,
email: userInfo.email,
name: userInfo.name,
});
} catch (error) {
console.error("[OAuth] Google callback failed:", error);
redirectToLogin(res, "Connexion Google impossible pour le moment");
}
});
app.get("/auth/facebook/start", (req, res) => {
if (!isFacebookConfigured()) {
redirectToLogin(res, "Connexion Facebook non configurée");
return;
}
const state = createOAuthState();
setOAuthStateCookie(req, res, FACEBOOK_STATE_COOKIE, state);
const params = new URLSearchParams({
client_id: ENV.facebookAppId,
redirect_uri: getCallbackUrl(req, "facebook"),
state,
scope: "email,public_profile",
});
res.redirect(`https://www.facebook.com/dialog/oauth?${params.toString()}`);
});
app.get("/auth/facebook/callback", async (req, res) => {
try {
if (!isFacebookConfigured()) {
redirectToLogin(res, "Connexion Facebook non configurée");
return;
}
const code = typeof req.query.code === "string" ? req.query.code : "";
const state = typeof req.query.state === "string" ? req.query.state : null;
if (!code || !readAndValidateState(req, res, FACEBOOK_STATE_COOKIE, state)) {
redirectToLogin(res, "Connexion Facebook invalide ou expirée");
return;
}
const tokenUrl = new URL("https://graph.facebook.com/oauth/access_token");
tokenUrl.search = new URLSearchParams({
client_id: ENV.facebookAppId,
client_secret: ENV.facebookAppSecret,
redirect_uri: getCallbackUrl(req, "facebook"),
code,
}).toString();
const tokenResponse = await fetch(tokenUrl);
if (!tokenResponse.ok) {
redirectToLogin(res, "Facebook a refusé la connexion");
return;
}
const tokenData = await tokenResponse.json() as { access_token?: string };
if (!tokenData.access_token) {
redirectToLogin(res, "Jeton Facebook manquant");
return;
}
const profileUrl = new URL("https://graph.facebook.com/me");
profileUrl.search = new URLSearchParams({
fields: "id,name,email",
access_token: tokenData.access_token,
}).toString();
const profileResponse = await fetch(profileUrl);
if (!profileResponse.ok) {
redirectToLogin(res, "Impossible de récupérer le profil Facebook");
return;
}
const profile = await profileResponse.json() as {
id?: string;
name?: string;
email?: string;
};
if (!profile.id || !profile.email) {
redirectToLogin(res, "Le compte Facebook doit partager une adresse email");
return;
}
await finishLogin(req, res, {
provider: "facebook",
providerUserId: profile.id,
email: profile.email,
name: profile.name,
});
} catch (error) {
console.error("[OAuth] Facebook callback failed:", error);
redirectToLogin(res, "Connexion Facebook impossible pour le moment");
}
});
}

View file

@ -0,0 +1,231 @@
import PDFDocument from "pdfkit";
import * as XLSX from "xlsx";
type AnalyticsPayload = {
period: string;
periodLabel: string;
comparisonLabel: string;
startDate: string;
endDate: string;
summary: {
totalReservations: number;
salleReservations: number;
materialReservations: number;
occupancyRate: number;
activeAssociations: number;
totalMaterialUnits: number;
averageReservationsPerAssociation: number;
};
associationUsage: Array<{
associationName: string;
thematics: string[];
totalReservations: number;
salleReservations: number;
materialReservations: number;
lastReservationAt: string;
}>;
roomOccupancy: Array<{
roomName: string;
bookedDays: number;
occupancyRate: number;
}>;
materialUsage: Array<{
label: string;
quantity: number;
requests: number;
}>;
history: Array<{
type: string;
associationName: string;
title: string;
startDate: string;
endDate: string;
resources: string;
status: string;
createdAt: Date;
}>;
};
const requestTypeLabels: Record<string, string> = {
demande_salle: "Demande salle",
demande_materiel_evenementiel: "Matériel événementiel",
};
const statusLabels: Record<string, string> = {
brouillon: "Brouillon",
soumise: "Soumise",
en_cours_traitement: "En cours",
information_complementaire: "Info requise",
validee: "Validée",
refusee: "Refusée",
annulee: "Annulée",
};
function formatDateFr(value: string | Date | null | undefined) {
if (!value) return "-";
try {
return new Date(value).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return String(value);
}
}
function buildFileBaseName(data: AnalyticsPayload) {
return `rapport-statistiques-ccds-${data.period}-${data.endDate}`;
}
export async function generateReservationAnalyticsPdf(data: AnalyticsPayload) {
const doc = new PDFDocument({
size: "A4",
margin: 40,
info: {
Title: `Rapport statistiques CCDS - ${data.periodLabel}`,
Author: "Portail Associations CCDS",
},
});
const chunks: Buffer[] = [];
doc.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
const done = new Promise<Buffer>((resolve) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
});
doc.fontSize(20).fillColor("#000091").text("Rapport statistiques CCDS");
doc.moveDown(0.3);
doc.fontSize(11).fillColor("#475569").text(`${data.periodLabel} - ${formatDateFr(data.startDate)} au ${formatDateFr(data.endDate)}`);
doc.moveDown(1);
doc.fontSize(14).fillColor("#0f172a").text("Synthèse");
doc.moveDown(0.5);
[
["Réservations totales", String(data.summary.totalReservations)],
["Réservations de salles", String(data.summary.salleReservations)],
["Réservations de matériel", String(data.summary.materialReservations)],
["Taux d'occupation des salles", `${data.summary.occupancyRate}%`],
["Associations utilisatrices", String(data.summary.activeAssociations)],
["Unités matérielles demandées", String(data.summary.totalMaterialUnits)],
["Fréquence moyenne par association", String(data.summary.averageReservationsPerAssociation)],
].forEach(([label, value]) => {
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text(`${label} : `, { continued: true });
doc.font("Helvetica").text(value);
});
doc.moveDown(1);
doc.font("Helvetica-Bold").fontSize(14).text("Top associations");
doc.moveDown(0.5);
data.associationUsage.slice(0, 10).forEach((entry) => {
doc.fontSize(10).font("Helvetica-Bold").text(entry.associationName);
doc.font("Helvetica").text(
`Catégories: ${entry.thematics.join(" / ") || "Non renseignée"} • Total: ${entry.totalReservations} • Salles: ${entry.salleReservations} • Matériel: ${entry.materialReservations} • Dernière réservation: ${formatDateFr(entry.lastReservationAt)}`
);
doc.moveDown(0.4);
});
doc.addPage();
doc.font("Helvetica-Bold").fontSize(14).text("Occupation des salles");
doc.moveDown(0.5);
data.roomOccupancy.forEach((entry) => {
doc.fontSize(10).font("Helvetica-Bold").text(entry.roomName, { continued: true });
doc.font("Helvetica").text(` - ${entry.bookedDays} jour(s) réservé(s), ${entry.occupancyRate}% d'occupation`);
});
doc.moveDown(1);
doc.font("Helvetica-Bold").fontSize(14).text("Matériels les plus demandés");
doc.moveDown(0.5);
data.materialUsage.forEach((entry) => {
doc.fontSize(10).font("Helvetica-Bold").text(entry.label, { continued: true });
doc.font("Helvetica").text(` - Quantité: ${entry.quantity} • Dossiers: ${entry.requests}`);
});
doc.moveDown(1);
doc.font("Helvetica-Bold").fontSize(14).text("Historique récent");
doc.moveDown(0.5);
data.history.slice(0, 20).forEach((entry) => {
const period = entry.startDate === entry.endDate
? formatDateFr(entry.startDate)
: `${formatDateFr(entry.startDate)} au ${formatDateFr(entry.endDate)}`;
doc.fontSize(10).font("Helvetica-Bold").text(`${requestTypeLabels[entry.type] || entry.type} - ${entry.associationName}`);
doc.font("Helvetica").text(`${entry.title}${period}${entry.resources || "-"}${statusLabels[entry.status] || entry.status}`);
doc.moveDown(0.35);
});
doc.end();
const buffer = await done;
return {
buffer,
fileName: `${buildFileBaseName(data)}.pdf`,
contentType: "application/pdf",
};
}
export function generateReservationAnalyticsExcel(data: AnalyticsPayload) {
const workbook = XLSX.utils.book_new();
const summaryRows = [
["Rapport statistiques CCDS", ""],
["Période", `${data.periodLabel} (${data.startDate} au ${data.endDate})`],
["Comparaison", data.comparisonLabel],
[],
["Indicateur", "Valeur"],
["Réservations totales", data.summary.totalReservations],
["Réservations de salles", data.summary.salleReservations],
["Réservations de matériel", data.summary.materialReservations],
["Taux d'occupation des salles", `${data.summary.occupancyRate}%`],
["Associations utilisatrices", data.summary.activeAssociations],
["Unités matérielles demandées", data.summary.totalMaterialUnits],
["Fréquence moyenne par association", data.summary.averageReservationsPerAssociation],
];
const associationRows = [
["Association", "Catégories", "Total", "Salles", "Matériel", "Dernière réservation"],
...data.associationUsage.map((item) => [
item.associationName,
item.thematics.join(" / "),
item.totalReservations,
item.salleReservations,
item.materialReservations,
item.lastReservationAt,
]),
];
const roomRows = [
["Salle", "Jours réservés", "Taux d'occupation"],
...data.roomOccupancy.map((item) => [item.roomName, item.bookedDays, `${item.occupancyRate}%`]),
];
const materialRows = [
["Matériel", "Quantité", "Dossiers"],
...data.materialUsage.map((item) => [item.label, item.quantity, item.requests]),
];
const historyRows = [
["Type", "Association", "Dossier", "Début", "Fin", "Ressources", "Statut"],
...data.history.map((item) => [
requestTypeLabels[item.type] || item.type,
item.associationName,
item.title,
item.startDate,
item.endDate,
item.resources,
statusLabels[item.status] || item.status,
]),
];
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(summaryRows), "Synthèse");
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(associationRows), "Associations");
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(roomRows), "Salles");
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(materialRows), "Matériel");
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(historyRows), "Historique");
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
return {
buffer,
fileName: `${buildFileBaseName(data)}.xlsx`,
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};
}

39
server/storage.ts Normal file
View file

@ -0,0 +1,39 @@
import fs from "node:fs/promises";
import path from "node:path";
function normalizeKey(relKey: string): string {
return relKey
.replace(/^\/+/, "")
.split("/")
.filter(Boolean)
.map(segment => segment.replace(/[^a-zA-Z0-9._-]/g, "_"))
.join("/");
}
function getUploadRoot() {
return path.resolve(process.cwd(), "uploads");
}
function getPublicUrl(key: string) {
return `/uploads/${key}`;
}
export async function storagePut(
relKey: string,
data: Buffer | Uint8Array | string,
contentType = "application/octet-stream"
): Promise<{ key: string; url: string }> {
const key = normalizeKey(relKey);
const filePath = path.join(getUploadRoot(), key);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, data);
return { key, url: getPublicUrl(key) };
}
export async function storageGet(relKey: string): Promise<{ key: string; url: string; }> {
const key = normalizeKey(relKey);
return {
key,
url: getPublicUrl(key),
};
}

Binary file not shown.