Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
962
server/_core/auth.ts
Normal file
962
server/_core/auth.ts
Normal 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
28
server/_core/context.ts
Normal 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
50
server/_core/cookies.ts
Normal 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
64
server/_core/dataApi.ts
Normal 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
30
server/_core/env.ts
Normal 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,
|
||||
};
|
||||
92
server/_core/imageGeneration.ts
Normal file
92
server/_core/imageGeneration.ts
Normal 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
217
server/_core/index.ts
Normal 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é à l’accueil 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
332
server/_core/llm.ts
Normal 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
319
server/_core/map.ts
Normal 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
|
||||
*/
|
||||
|
||||
|
||||
|
||||
|
||||
110
server/_core/notification.ts
Normal file
110
server/_core/notification.ts
Normal 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
78
server/_core/runtime.ts
Normal 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;
|
||||
}
|
||||
29
server/_core/systemRouter.ts
Normal file
29
server/_core/systemRouter.ts
Normal 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
45
server/_core/trpc.ts
Normal 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
6
server/_core/types/cookie.d.ts
vendored
Normal 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
88
server/_core/vite.ts
Normal 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"));
|
||||
});
|
||||
}
|
||||
284
server/_core/voiceTranscription.ts
Normal file
284
server/_core/voiceTranscription.ts
Normal 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
452
server/admin.test.ts
Normal 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é à l’accueil, 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
510
server/association.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
server/associationCommunes.test.ts
Normal file
27
server/associationCommunes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
73
server/associationDirectory.test.ts
Normal file
73
server/associationDirectory.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
419
server/associationDirectory.ts
Normal file
419
server/associationDirectory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
254
server/associationDirectoryMatcher.ts
Normal file
254
server/associationDirectoryMatcher.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
85
server/associationGeo.test.ts
Normal file
85
server/associationGeo.test.ts
Normal 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
180
server/associationGeo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
442
server/associationHelloAssoSync.ts
Normal file
442
server/associationHelloAssoSync.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
68
server/associationInvitationEmail.ts
Normal file
68
server/associationInvitationEmail.ts
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
402
server/associationReferenceSync.ts
Normal file
402
server/associationReferenceSync.ts
Normal 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 d’entreprises" : 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,
|
||||
};
|
||||
}
|
||||
67
server/auth.logout.test.ts
Normal file
67
server/auth.logout.test.ts
Normal 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
231
server/calendar.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
server/cancelRequest.test.ts
Normal file
70
server/cancelRequest.test.ts
Normal 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
1649
server/db.ts
Normal file
File diff suppressed because it is too large
Load diff
280
server/dsu.test.ts
Normal file
280
server/dsu.test.ts
Normal 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
159
server/emailActions.test.ts
Normal 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
369
server/emailActions.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
343
server/emailRecap.ts
Normal file
343
server/emailRecap.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 lorsqu’il est disponible pour ce type de demande. Tu peux l’utiliser pour la transmission interne, l’archivage ou l’impression.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return {
|
||||
title: subject,
|
||||
content: textSections.filter(Boolean).join("\n"),
|
||||
html,
|
||||
};
|
||||
}
|
||||
108
server/internalAccess.ts
Normal file
108
server/internalAccess.ts
Normal 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
82
server/logisticsGroup.ts
Normal 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
161
server/logisticsSettings.ts
Normal 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
256
server/mailSettings.ts
Normal 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
58
server/mailer.ts
Normal 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 };
|
||||
}
|
||||
288
server/materialConventionPdf.ts
Normal file
288
server/materialConventionPdf.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
238
server/materialReturnEmail.ts
Normal file
238
server/materialReturnEmail.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 été 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 };
|
||||
}
|
||||
202
server/materialReturnLitigationPdf.ts
Normal file
202
server/materialReturnLitigationPdf.ts
Normal 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
462
server/materialReturnPdf.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
143
server/materialReturnWorkflow.test.ts
Normal file
143
server/materialReturnWorkflow.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
436
server/materialReturnWorkflow.ts
Normal file
436
server/materialReturnWorkflow.ts
Normal 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
952
server/operationalTour.ts
Normal 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 l’environnement",
|
||||
kind: "technical",
|
||||
status: "error",
|
||||
durationMs: 0,
|
||||
message: "URL cible manquante",
|
||||
details: `Aucune URL n’est 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 l’accueil",
|
||||
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 n’utilise 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 l’accueil",
|
||||
kind: "technical",
|
||||
status: "error",
|
||||
message: homeDiagnostics.error || "Impossible de mesurer l’accueil",
|
||||
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 d’ouvrir une session admin OAuth" : "Impossible d’ouvrir 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 d’ouvrir une session association OAuth" : "Impossible d’ouvrir 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 l’email 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 d’ouvrir une session accueil OAuth" : "Impossible d’ouvrir 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 qu’un compte accueil n’est 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 n’a pas trouvé de lien de fiche association dans l’annuaire 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 d’activité", "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 n’a 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
166
server/pdfGenerator.test.ts
Normal 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
958
server/pdfGenerator.ts
Normal 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 d’usage', 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;
|
||||
}
|
||||
224
server/requestDetail.test.ts
Normal file
224
server/requestDetail.test.ts
Normal 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
66
server/requestPdf.ts
Normal 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
10846
server/routers.ts
Normal file
File diff suppressed because it is too large
Load diff
89
server/salleAdministrativeDecisionPdf.ts
Normal file
89
server/salleAdministrativeDecisionPdf.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
254
server/salleConventionPdf.ts
Normal file
254
server/salleConventionPdf.ts
Normal 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
150
server/salleInvoicePdf.ts
Normal 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 l’envoi 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 l’accueil 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
182
server/salleQuotePdf.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
137
server/salleReservationEmails.ts
Normal file
137
server/salleReservationEmails.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 d’un 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, à l’intérieur comme à l’exté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 à l’inté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 d’un 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, à l’intérieur comme à l’exté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 à l’inté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>`,
|
||||
};
|
||||
}
|
||||
123
server/salleReservationWorkflow.ts
Normal file
123
server/salleReservationWorkflow.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
102
server/salleSignatureDelegation.ts
Normal file
102
server/salleSignatureDelegation.ts
Normal 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
281
server/socialAuth.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
231
server/statsReportArtifacts.ts
Normal file
231
server/statsReportArtifacts.ts
Normal 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
39
server/storage.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
BIN
server/templates/fiche_etat_des_lieux_officielle.pdf
Normal file
BIN
server/templates/fiche_etat_des_lieux_officielle.pdf
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue