Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue