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; export const operationalTourAuthModeSchema = z.enum(["local", "oauth"]); export type OperationalTourAuthMode = z.infer; export const operationalTourStepKindSchema = z.enum(["technical", "user"]); export type OperationalTourStepKind = z.infer; 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; 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; 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; 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("") || lowerBody.includes("script type=\"module\"") || lowerBody.includes("id=\"manus-runtime\"")) ); } function buildStaticStep(input: Omit & { durationMs?: number }): OperationalTourStep { return { ...input, durationMs: input.durationMs ?? 0, }; } async function fetchPageCheck(options: PageCheckOptions): Promise { 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(); 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("") && /<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"] }), 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", "Portail Associations"] }), 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", "Portail Associations"] }), 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", "Portail Associations"] }), 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; }